Skip to content

Exploration: back FloatValue with Decimal for exact decimal arithmetic #151

@xujustinj

Description

@xujustinj

Motivation

FloatValue is currently Value[float], i.e. IEEE-754 binary floating point. This produces the classic surprises:

0.1 + 0.2 == 0.3   # False -> 0.30000000000000004

We worked around this for the new Equal/NotEqual comparison nodes by adding rel_tol/abs_tol parameters (via math.isclose). That's a correct local band-aid, but the rounding error is already baked into the values before they reach the comparison node — so tolerance masks the symptom rather than fixing the cause.

The real fix for the decimal-rounding class of bugs is to carry Decimal end-to-end through the numeric value type and arithmetic nodes (Add, Sum, etc.), so 0.1 + 0.2 is exactly 0.3.

Proposed exploration

Change FloatValue to be driven by Decimal under the hood (or introduce a parallel DecimalValue and migrate). Investigate the blast radius and whether Pydantic cooperates.

Key open questions / risks

  1. Pydantic support. Value[T] is a RootModel-style generic. Confirm Pydantic v2 cleanly supports Decimal as a root type for validation and JSON (de)serialization. Pydantic supports Decimal natively, but verify behavior with our model_validate_json casts (cast_string_to_float, etc.) and JSON output. If it resists, determine whether a custom serializer/validator ("strongarming") is needed.

  2. End-to-end Decimal, or it's pointless. The win only materializes if a value never round-trips through a binary float:

    • JSON/YAML parsing must parse numbers to Decimal (stdlib json needs parse_float=Decimal; check ruamel.yaml behavior).
    • Every numeric node must do Decimal arithmetic, not float.
    • Casts (Integer<->Float<->String) must preserve Decimal.
  3. Decimal is not a silver bullet. It's exact only for terminating decimal fractions. 1/3, sqrt(2), etc. still don't terminate (truncated at getcontext().prec, default 28 digits). Division/roots/irrationals will still need tolerance. Scope: this eliminates the 0.1 + 0.2 class, not all float error.

  4. Context precision. Decide on and manage decimal.getcontext().prec (global vs. per-operation context). Determines rounding of non-terminating results.

  5. Performance. Decimal is meaningfully slower than float. Probably fine for this engine's workloads, but note it.

  6. Migration / compatibility. Existing serialized workflows and stored values use float JSON. Decide on a node-version bump + migration story, and whether FloatValue is replaced in place or DecimalValue is added alongside (with casts).

Out of scope

The Equal/NotEqual tolerance params stay regardless — they're still the right tool for division/irrational results even after a Decimal value type lands.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions