← board

float(str) is not correctly rounded

Measured

Each line compares float("...") against pxx's own literal for the same text. The literals are correct (verified separately against CPython), so a False is pyfloat_parse being wrong:

float("0.1")                    == 0.1                       -> True
float("1e308")                  == 1e308                     -> False
float("2.2250738585072011e-308")== 2.2250738585072011e-308   -> False
float("123456789.123456789")    == 123456789.123456789       -> True
float("0.3333333333333333")     == 0.3333333333333333        -> False
float("9007199254740993")       == 9007199254740993.0        -> True

CPython answers True to all six.

Cause

pyfloat_parse (compiler/builtin/pylib.pas) reconstructs the value with FLOAT arithmetic — intPart, frac, scale are all Double, accumulated digit by digit and scaled by powers of ten. Every one of those steps rounds, so the result is near the correct double rather than being it. The failures cluster where they should: large exponents, subnormal-adjacent values, and 17-significant-digit inputs.

lib/rtl/sysutils.pas has a correctly rounded one (StrToFloatDef — Clinger fast path, exact ExDecNearest slow path) but a builtin unit may not use sysutils (flat NilPy unit scope; see [[decide-builtin-and-library-code-sharing]]).

Why it matters beyond exactness

It breaks round-tripping in the direction users notice first: read a float from JSON/CSV/config, write it back, and the bytes differ. It also makes any future repr round-trip gate untestable from NilPy source, since the check itself would run through the broken parser.

Fix

Already decided and scoped — this is fixed by the same copy [[bug-nilpy-float-repr-is-not-pythons-shortest-roundtrip]] performs: bring the correctly-rounded parser closure (ExDecCmp, ExDecBitsToDouble, ExDecDoubleToBits, ExDecEstimate, ExDecNearest, StrToFloatDef, ~304 lines) into the builtin layer and make pyfloat_parse a thin wrapper over it, keeping its Python-specific front end (the inf/nan/infinity spellings and the ValueError on a bad parse).

Doing the two together is the point: the repr work NEEDS a correct parser for its round-trip check, and this ticket is what that parser also buys. Neither should be landed with the other's half skipped.

Gate

A .npy diffed against CPython over the six rows above plus: float of a 17-digit string, of a subnormal, of 1e-308/1e308 at both ends, and the inf/-inf/nan spellings and the bad-parse ValueError as controls that the Python front end still works. Ideally float(str(x)) == x over a table once repr exists as a builtin ([[bug-nilpy-unsupported-protocols-repr-iter-getattr-delitem-hash]]).

Resolved 2026-08-03 — together with the repr ticket, as the decision intended

pyfloat_parse no longer reconstructs the value at all. It keeps Python's validation — where a sign, a point, an exponent and an underscore may appear, which spellings raise ValueError, and the inf/infinity/nan forms — and hands the accepted text to PyStrToFloatDef, the correctly-rounded parser copied from lib/rtl/sysutils.pas for [[bug-nilpy-float-repr-is-not-pythons-shortest-roundtrip]].

The three cases on this ticket now agree with pxx's own literals for the same numbers, and are covered in test/test_nilpy_float_repr.npy both as printed values and as == comparisons:

float("1e308") == 1e308
float("0.3333333333333333") == 0.3333333333333333
float("2.2250738585072011e-308") == 2.2250738585072011e-308
float("5e-324") == 5e-324

Net effect on the count of disagreeing float parsers in the tree: three become two. The remaining pair is sysutils.StrToFloatDef and its pylib copy, which are the same algorithm and are pinned against each other by tests — plus compiler/lexer.inc's StrToDoubleBits, which the sweep on the sibling ticket proved is the one that is actually wrong ([[bug-a-float-literal-lexer-is-not-correctly-rounded]]).

Log