NilPy float repr is fixed-precision, not CPython's shortest round-trip
- Type: bug (NilPy output fidelity — silently WRONG-LOOKING and, in one direction, actively misleading) — Track N
- Found: 2026-08-01 while writing the regression test for [[bug-nilpy-typed-const-import-reads-zero]]; the test had to route around it.
Measured (self-hosted binary at a9edb6fba)
| expression | pxx | CPython |
|---|---|---|
print(25.4) |
25.399999999999999 |
25.4 |
print(1/3) |
0.333333333333333 |
0.3333333333333333 |
print(0.1 + 0.2) |
0.3 |
0.30000000000000004 |
print(0.1) |
0.1 |
0.1 |
print(2.5 * 2) |
5.0 |
5.0 |
print(round(3.14159265, 3)) |
3.142 |
3.142 |
So it is not uniformly broken — it looks like fixed ~15-significant-digit
formatting where CPython uses repr's shortest string that round-trips.
Why it matters, and which direction is worse
Two distinct failures, and they point opposite ways:
- Too few digits —
0.1 + 0.2printing0.3. This is the dangerous one. CPython deliberately SHOWS the floating-point artifact; pxx hides it. Anyone using NilPy to reason about float behaviour is told the wrong thing, and the output looks more correct than reality. It also silently disagrees with the oracle in any diff-against-CPython test. - Too many digits —
25.4printing25.399999999999999. Cosmetic by comparison, but it makes every float-bearing expected-output ugly, invites tests to bake in the wrong literal, and1/3losing its 16th digit means a printed value does not round-trip.
Any .npy test whose expectation contains a computed float is exposed, which is
also why this is worth fixing before more such tests are written.
Fix shape (to determine — measure first)
CPython's repr(float) is the shortest decimal string that round-trips to the
same double (David Gay / Grisu / Ryu style). The likely home is NilPy's float →
string path (pystr_of / the float formatting in compiler/builtin/pylib.pas),
not the Pascal Str/writeln formatting, which has its own separate
contract and must not be changed to match Python.
Check first whether Pascal's own float output is a shared code path — if it
is, this needs a NilPy-only entry point rather than a change in place, or every
Pascal program's writeln(x) shifts too. That distinction is the whole risk of
this ticket.
A correct shortest-round-trip implementation is the real fix; a "print 17
digits then trim trailing zeros" approximation gets 0.1 wrong and must not be
substituted for it.
Gate
A .npy diffed against CPython covering: 0.1 + 0.2, 1/3, 25.4, 0.1,
integral floats (5.0), very large and very small magnitudes, negative zero,
and inf/nan spellings. Plus confirmation that Pascal's own writeln of a
Double is unchanged.
2026-08-01 — investigated; BLOCKED on the RTL, and the shared-path risk is real
Both questions the ticket raised are now answered by measurement.
1. Is Pascal's float output a shared path? YES. pystr_of(d: Double) in
compiler/builtin/pylib.pas is literally Result := FloatToStr(d). So this
must gain a NilPy-only entry point; changing FloatToStr in place would move
every Pascal program's writeln and a lot of expected output with it.
2. Can shortest-round-trip be built on today's RTL? NO.
FloatToStrSig caps at 15 significant digits by design
(if sig > 15 then sig := 15; { past 15 a double scaled in doubles lies }) —
it normalises by scaling in doubles, so further digits would be fiction. A
double needs up to 17 to round-trip. Probed with
StrToFloat(FloatToStrSig(d, p)) = d for p = 1..17:
| value | shortest p | at p=17 |
|---|---|---|
25.4 |
3 (25.4) |
— |
0.1 |
1 (0.1) |
— |
0.1 + 0.2 |
never | 0.3 |
1/3 |
never | 0.333333333333333 |
So the exact values this ticket is about are precisely the ones the RTL cannot express. blocked-by: [[bug-b-floattostrsig-caps-at-15-significant-digits]].
A partial fix is possible and was deliberately NOT taken
A 1..15 round-trip loop would fix 25.4 (→ 25.4) and leave the rest no worse.
Rejected for now on blast radius: float output changes ripple into every
expected-output that contains one, and it would still get the dangerous case
wrong — 0.1 + 0.2 would keep printing 0.3, hiding the artifact CPython
shows, which is the half of this bug that actually misleads. Better to land it
once, correctly, after the RTL gains exact digits. Whoever picks this up can
revisit that call.
Also needs handling on the NilPy side once unblocked
FloatToStrSig spells several things Pascal's way: 5.0→5, 1.0e20→1E20,
-0.0→0, NaN/Inf. Python needs 5.0, 1e+20, -0.0, nan/inf.
2026-08-02 — a SECOND consumer wants the same primitive
round(x, n) needs it too. CPython's round operates on the double's exact
decimal value, not on x * 10**n:
Decimal(2.675) = 2.674999999999999822... -> round(2.675, 2) = 2.67
Decimal(2.665) = 2.665000000000000035... -> round(2.665, 2) = 2.67
Both scale to exactly 267.5 / 266.5, so the deciding information is gone
before any tie-break rule runs — see
[[bug-nilpy-round-ndigits-half-up-and-ignores-negative-ndigits]], where the
other two defects were fixed and these two cases documented as blocked.
Note a 17-significant-digit approximation does NOT suffice for round either:
2.665 renders as exactly 2.6650000000000000, which is ambiguous at the tie.
So both consumers need genuinely EXACT digit generation, not more digits.
Raising the value of [[bug-b-floattostrsig-caps-at-15-significant-digits]] accordingly: it now unblocks two user-visible parity gaps, not one.
2026-08-02 — a THIRD symptom: no exponent form for small magnitudes
print(1e-10) # CPython 1e-10 pxx 0.0000000001
print(1e10) # CPython 10000000000.0 pxx 10000000000.0 (agrees)
CPython's repr switches to exponent notation below 1e-4 and at/above
1e16; pxx renders fixed-point throughout. Same routine family as the
shortest-round-trip gap, so it should be settled in the same pass rather than
patched separately — the exponent threshold is part of the same "how does a
double print" contract.
Resolved 2026-08-04 — the repr half had landed; this closes the round half
Re-measured first rather than assuming the ticket still described reality. The
repr half was already fixed by the exact-decimal work that unblocked this
(PyFloatRepr / PyFloatLayout in compiler/builtin/pylib.pas): every row of
this ticket's table, plus the exponent-threshold and extremes rows the
2026-08-02 notes added, now matches CPython byte for byte. The 23-line probe
that reproduced all three symptoms diffed clean against python3 except for
two lines — both of them round.
So the only thing left was the second consumer this ticket recorded on
2026-08-02: round(x, n).
pyround_n now rounds the exact value, not x * 10**n
The old body scaled in doubles and then applied half-to-even. That rule was
right and the input was not: 2.675 * 100 is exactly 267.5 and
2.665 * 100 is exactly 266.5, so both look like ties and the digits that
decide them (2.674999... below, 2.665000...035 above) are already gone. As
this ticket and
[[bug-nilpy-round-ndigits-half-up-and-ignores-negative-ndigits]] both noted, no
tie-break rule on the scaled value can be correct, and 17 significant digits do
not suffice either.
Rewritten on the exact-decimal core that is now sitting in the same file:
expand the double exactly (PyExDecDigits), round the digit string
half-to-even on the exact remainder (PyExDecRound, at
decExp + n + 1 significant digits), lay it out as plain decimal text, and
read it back with the correctly-rounded parser (PyStrToFloatDef) to land on
the nearest double. Only the final read rounds, and it rounds correctly — the
same shape CPython uses.
The body moved in the file, from among the numeric builtins down to just
past PyStrToFloatDef, because that is what it now depends on; the interface
declaration was already there, so nothing else had to change.
Cases the exact path handles that the old one could not express: sig <= 0
(the whole value below the rounding position, where sig = 0 can still carry
one unit up — round(0.6, 0) is 1.0, round(0.5, 0) is 0.0 because the
implicit preceding digit is an even zero), and -0.0 preservation, which is
read off the sign bit rather than from a comparison.
Verified against the oracle, twice
- The 24 hand-picked cases from this ticket and the round ticket:
identical to CPython, including
round(2.675, 2)=2.67andround(2.665, 2)=2.67, the two that were open. - A randomized differential sweep of 2999
round(d, n)calls — half from random 64-bit patterns, half from exact-binary decimals chosen to land on ties,nin -6..10 — against CPython: 0 mismatches.
test/test_nilpy_round.npy gained two lines of exact-value cases and its
header now records why the divergence existed; every expectation in it is
CPython's, and the Makefile comment that told the next reader NOT to fix those
two values is replaced with the reason they are now fixable.
Not changed, deliberately
lib/rtl/sysutils.pas is untouched, so Pascal's own writeln of a Double is
unchanged — the half of this ticket's gate that asked for that is met by
construction, not by measurement. NilPy's float output has its own entry point,
which is exactly the shape this ticket asked for in 2026-08-01.
Log
- 2026-08-04 — resolved.
- 2026-08-04 — resolved, commit a529cec84.