Float printing is not Python's repr — six distinct divergences
- Type: bug (NilPy — SILENT wrong output) — Track N
- Found: 2026-08-02 by a differential sweep against the CPython oracle
(
tools/pydiff.py).
Measured
for v in [0.1, 1/3, 1e20, 1e-20, 2.0, -0.0, 100.0, 1.5e300, 3.0e-5, 123456789.123]:
print(v)
print(0.1 + 0.2)
print(1/3 + 1/3)
| expression | CPython | pxx |
|---|---|---|
1/3 |
0.3333333333333333 |
0.333333333333333 |
1/3 + 1/3 |
0.6666666666666666 |
0.666666666666667 |
1e-20 |
1e-20 |
1.000000000000001e-20 |
3.0e-5 |
3e-05 |
0.00003 |
123456789.123 |
123456789.123 |
123456789.122999995946884 |
0.1 + 0.2 |
0.30000000000000004 |
0.3 |
0.1, 1e20, 2.0, -0.0, 100.0, 1.5e300, 7/2, 7//2, 7.0//2 and
float("inf") / float("-inf") all agree.
The four separate faults, because they need different fixes
-
Digit count.
0.333333333333333is 15 significant digits; a double needs 17 to round-trip, and Python emits the shortest string that round-trips (16 here). One digit is being dropped, so the value does not round-trip. -
Wrong digits, not just fewer.
1e-20printing as1.000000000000001e-20is not a truncation — it is a different number. The conversion is producing noise digits in the small-exponent path. -
Scientific-notation threshold. Python switches to exponent form for
abs(v) < 1e-4(3e-05) and for>= 1e16. pxx printed0.00003, so the low-end threshold is absent or set elsewhere. Note also the two-digit exponent (3e-05, not3e-5), which is part of the format. -
Over-expansion.
123456789.122999995946884is the exact binary value printed to excess precision instead of the shortest round-tripping form.
0.1 + 0.2 → 0.3 is the same root cause as (1) seen from the friendly side:
rounding to too few digits happens to hide the representation error. It is still
wrong against the oracle, and it is the one most likely to be mistaken for
correct behaviour.
Why this matters more than it looks
Python's repr contract is round-trip: float(repr(x)) == x for every finite
double. Every divergence above breaks it, so any NilPy program that serialises
floats through str/print — JSON, CSV, a config dump, a test's expected
output — loses precision silently and asymmetrically. It also means a .npy
regression test whose expected output contains a float is currently recording
pxx's rounding, not CPython's.
Where to look
The Python-facing float formatting path, not Pascal's Str/WriteLn — Pascal
has its own (correct for Pascal) conventions and must not change. Check whether
the NilPy print of a float routes through a shared Pascal float-to-string
helper; if it does, this needs a NilPy-specific formatter rather than a change
to the shared one, or Track P/A regressions follow.
The algorithm is the shortest-round-trip one (Steele & White / Grisu / Ryu). Getting it exactly right for all doubles is real work; getting (2) and (3) right — wrong digits and the threshold — is separable and worth doing first, since a wrong digit is a worse failure than a suboptimal digit count.
Related, found in the same sweep
repr is not defined as a builtin at all (error: undefined variable (repr)),
so the round-trip property cannot even be spelled from NilPy source today. See
[[bug-nilpy-unsupported-protocols-repr-iter-getattr-delitem-hash]] for the
dunder side of that.
Gate
A .npy diffed against CPython over a table of doubles covering: the six rows
above, both notation thresholds from either side (1e-4, 9.9e-5, 1e16,
9.9e15), negatives, subnormals, float('inf') / -inf / nan, and a
round-trip assertion float(str(x)) == x over the whole table.
2026-08-03 — the formatter is WRITTEN and measured; it is blocked on layering
The Python side is done. What stopped it is where two numeric routines live, not anything about Python — filed as [[decide-nilpy-where-the-exact-decimal-float-core-lives]].
What was established
Python's repr is the SHORTEST decimal that reads back as the same double, and the way to get that right is not a digit-count heuristic: try precisions 1..17 and CHECK the round trip, so the answer is correct by construction. That needs exact decimal digits and a correctly-rounded parser.
Both already exist, in lib/rtl/sysutils.pas: ExDecDigits / ExDecRound
/ FloatToStrExact / StrToFloat, and FloatToStrShortest is already this
exact loop with PASCAL's layout. Nothing needs inventing.
But PyFloatStr lives in compiler/builtin/pylib.pas, and a builtin unit may
not uses sysutils — the rule compiler/builtin/promoint.pas states and
follows (it reimplemented bignum rather than use lib/rtl/bignum.pas).
Measured, not assumed: PXXDBG=a.ir:FloatToStrExact on a print(1) program
dumps nothing, so a trivial .npy does not link sysutils today and pylib using
it really would add it to every NilPy binary.
builtin.pas's own FloatToStr uses Trunc/Frac scaled to 15 decimal
places, which is the direct cause of all six divergences above.
The three layout rules, since they are the part that is easy to get wrong
- exponential iff
decpt <= -4ordecpt > 16, wheredecpt = decExp + 1— NOT Pascal's[-3, sig]window, which also moves with the requested precision, soExDecLayoutcannot be reused; - fixed form ALWAYS shows a point (
2.0,100.0— CPython'sPy_DTSF_ADD_DOT_0); - the exponent is signed with at LEAST two digits:
3e-05,1e+16, and1.5e+300(no padding past two).
Checked by hand against every row of the table above plus both thresholds from
either side (1e-4 -> 0.0001, 9.9e-5 -> 9.9e-05, 9.9e15 ->
9900000000000000.0, 1e16 -> 1e+16).
Ready to apply
The code below drops into pylib.pas in place of PyFloatStr's body the
moment FloatToStrExact / StrToFloat are reachable from a builtin unit. It
compiled; the only error left was undefined variable (FloatToStrExact), i.e.
exactly the blocker. (IntToStr is likewise not available there — pylib's
integer-to-string is StrInt(n, 0), already used below.)
{ Split FloatToStrExact's output for a POSITIVE finite value into Python's two
ingredients: `digits`, the significant digits with no point and no leading or
trailing zeros, and `decExp`, the power of ten the FIRST digit carries
(value = d.ddd * 10^decExp).
Parsing a string this unit just produced, rather than reaching for sysutils'
internal ExDecDigits: those are implementation-only, and exporting them to
give NilPy a Python-shaped formatter would put a Python rule in the Pascal
RTL's interface. The output format is fixed and small — an optional
`E<exp>` tail, an optional point, leading zeros — so this stays a few lines.
Every result is round-trip CHECKED by the caller regardless. }
procedure PyFloatSplit(const s: AnsiString; var digits: AnsiString; var decExp: Integer);
var i, dot, intLen, expPart, lead, tail, esign: Integer; mant, all: AnsiString;
begin
digits := '0'; decExp := 0;
mant := s; expPart := 0;
for i := 1 to Length(s) do
if (s[i] = 'E') or (s[i] = 'e') then
begin
mant := Copy(s, 1, i - 1);
esign := 1;
dot := i + 1;
if (dot <= Length(s)) and ((s[dot] = '-') or (s[dot] = '+')) then
begin
if s[dot] = '-' then esign := -1;
dot := dot + 1;
end;
expPart := 0;
while dot <= Length(s) do
begin
expPart := expPart * 10 + (Ord(s[dot]) - Ord('0'));
dot := dot + 1;
end;
expPart := expPart * esign;
Break;
end;
dot := 0;
for i := 1 to Length(mant) do
if mant[i] = '.' then begin dot := i; Break; end;
if dot = 0 then intLen := Length(mant) else intLen := dot - 1;
all := '';
for i := 1 to Length(mant) do
if mant[i] <> '.' then all := all + mant[i];
lead := 0;
while (lead < Length(all)) and (all[lead + 1] = '0') do lead := lead + 1;
all := Copy(all, lead + 1, Length(all) - lead);
if all = '' then begin digits := '0'; decExp := 0; Exit; end;
decExp := intLen - 1 - lead + expPart;
tail := Length(all);
while (tail > 1) and (all[tail] = '0') do tail := tail - 1;
digits := Copy(all, 1, tail);
end;
{ Lay `digits` / `decExp` out the way CPython's repr does. The rule is one
comparison on `decpt`, the position of the decimal point: exponential when
`decpt <= -4` or `decpt > 16`, fixed otherwise — which is NOT Pascal's
window ([-3, sig], and dependent on the requested precision), so it cannot be
borrowed from ExDecLayout.
Two details that are part of the format and easy to miss: a fixed-form float
always shows a point (`2.0`, `100.0` — Python's Py_DTSF_ADD_DOT_0), and the
exponent is signed with at LEAST two digits (`3e-05`, `1e+16`, but
`1.5e+300`). }
function PyFloatLayout(const digits: AnsiString; decExp: Integer): AnsiString;
var decpt, i, ae: Integer; s, es: AnsiString;
begin
decpt := decExp + 1;
if (decpt <= -4) or (decpt > 16) then
begin
s := Copy(digits, 1, 1);
if Length(digits) > 1 then s := s + '.' + Copy(digits, 2, Length(digits) - 1);
if decExp < 0 then begin s := s + 'e-'; ae := -decExp; end
else begin s := s + 'e+'; ae := decExp; end;
es := StrInt(ae, 0);
if Length(es) < 2 then es := '0' + es;
Result := s + es;
end
else if decpt <= 0 then
begin
s := '0.';
for i := 1 to -decpt do s := s + '0';
Result := s + digits;
end
else if decpt >= Length(digits) then
begin
s := digits;
for i := Length(digits) + 1 to decpt do s := s + '0';
Result := s + '.0';
end
else
Result := Copy(digits, 1, decpt) + '.' + Copy(digits, decpt + 1, Length(digits) - decpt);
end;
{ Python's `repr` of a float: the SHORTEST decimal string that reads back as
the same double, laid out by Python's rules.
Shortest is found by trying precisions and CHECKING the round trip, so the
result is correct by construction rather than by trusting a digit-count
heuristic — the same method sysutils' FloatToStrShortest uses, with Python's
layout instead of Pascal's. 17 significant digits always round-trip a double,
so the loop terminates.
Returns '' when the value is not finite-nonzero or when nothing round-tripped;
the caller handles those. }
function PyFloatRepr(av: Double): AnsiString;
var sig: Integer; digits, cand: AnsiString; decExp: Integer;
begin
Result := '';
for sig := 1 to 17 do
begin
PyFloatSplit(FloatToStrExact(av, sig), digits, decExp);
cand := PyFloatLayout(digits, decExp);
if StrToFloat(cand) = av then begin Result := cand; Exit; end;
end;
end;
function PyFloatStr(d: Double): AnsiString;
var bits: Int64; av: Double; rep: AnsiString;
begin
{ Python's repr, not Pascal's FloatToStr. FloatToStr is FPC's fifteen
SIGNIFICANT digits with FPC's own fixed/exponential window, and it is
correct for Pascal and observable by every Pascal program in the tree — so
it is not changed; NilPy gets its own formatter instead. Six measured
divergences it removes, all silent: 1/3 lost a digit and stopped
round-tripping, 0.1+0.2 printed 0.3 (the representation error HIDDEN by
rounding, the one most likely to be read as correct), 1e-20 printed
1.000000000000001e-20 (different digits, not fewer), 3.0e-5 printed
0.00003 instead of 3e-05, and 123456789.123 expanded to
123456789.122999995946884
(bug-nilpy-float-repr-is-not-pythons-shortest-roundtrip). }
av := d;
if av < 0 then av := -av;
if (d = d) and (av <= 1.7976931348623157e308) and (d <> 0) then
begin
rep := PyFloatRepr(av);
if rep <> '' then
begin
if d < 0 then Result := '-' + rep else Result := rep;
Exit;
end;
end;
Result := FloatToStr(d);
Also worth keeping
str(float) and repr(float) are the same function in Python 3, so one
formatter serves both — but repr is not even defined as a builtin in NilPy
yet ([[bug-nilpy-unsupported-protocols-repr-iter-getattr-delitem-hash]]), so the
round-trip property cannot be spelled from NilPy source. The gate's
float(str(x)) == x assertion needs that ticket first, or has to be written as
a comparison against a literal table.
2026-08-03 — UNBLOCKED. Decision: copy the core into the builtin layer.
[[decide-nilpy-where-the-exact-decimal-float-core-lives]] is resolved — option
B, copy rather than reimplement, sysutils.pas untouched. Full rationale is on
that ticket; the parts that change what gets built here:
- Copy ~420 lines verbatim into the builtin layer: the digits closure
(
ExDecMul,ExDecSplit,ExDecOfMant,ExDecDigits,ExDecRound+TExDecBuf/PXX_EXDEC_LIMBS, ~113) and the correctly-rounded parser closure (ExDecCmp,ExDecBitsToDouble,ExDecDoubleToBits,ExDecEstimate,ExDecNearest,StrToFloatDef, ~304). Do NOT reimplement, and do NOT substitute Steele-White midpoints to avoid the parser. - Point
pyfloat_parseat the copied parser — filed on its own as [[bug-nilpy-float-of-a-string-is-not-correctly-rounded]], because it is a user-visible defect independent ofreprand must not be dropped as "just for the round-trip check". It reconstructs with float arithmetic today and is measurably wrong —float("1e308"),float("0.3333333333333333")andfloat("2.2250738585072011e-308")all disagree with pxx's own (correct) literals. This ticket fixesfloat(str)as well asrepr, and that is not a bonus to be skipped: it is half the reason the parser is worth copying. - Land
PyFloatRepr— written, measured, and pasted in the section above. - The round-trip check compares the 64 BITS, not the doubles. Not
cosmetic:
StrToFloat('0') = -0.0is True, so the existingFloatToStrShortestin sysutils would accept'0'as the shortest form of-0.0. Reading bits also handles NaN and is immune to extended-precision registers. - A permanent DIFFERENTIAL TEST over a large sample, asserting the copy and
sysutilsagree — the anti-drift device, and the thing that makes the duplication defensible. A note in each file naming the other and why.
str() and repr() share the one formatter, matching CPython 3.1+. Worth
carrying the reasoning into the code comment, because it is what stops someone
"improving" the output later: before 3.1, repr was %.17g (round-trips,
ugly) and str was %.12g (friendly, lossy — 0.3 for 0.1 + 0.2); 3.1 made
them identical because for every value whose friendly answer is TRUE,
shortest-round-trip already IS it. pxx today is the pre-3.1 str, which is
the behaviour Python dropped in 2009 for silently lying.
The duplication itself is filed as a separate, non-blocking concern: [[decide-builtin-and-library-code-sharing]].
Resolved 2026-08-03 — all six divergences gone, and float(str) with them
Built as the decision specified: ~420 lines of lib/rtl/sysutils.pas's exact
decimal core copied into compiler/builtin/pylib.pas under a Py prefix (the
only edit inside the copy is IntToStr becoming this layer's StrInt(x, 0)),
pyfloat_parse reduced to Python's VALIDATION plus a call into the copied
correctly-rounded parser, and PyFloatRepr / PyFloatLayout on top.
One simplification against the code drafted on this ticket: PyFloatSplit is
not needed. It existed to re-parse FloatToStrExact's output back into
(digits, decExp) — but PyExDecDigits + PyExDecRound produce exactly that
pair directly, so the round-trip through a formatted string, and the copy of
FloatToStrExact it would have required, both disappear. The round-trip check
compares BITS via PyExDecDoubleToBits, as decided.
Measured
Every row of this ticket's table, both notation thresholds from either side,
subnormals, DBL_MAX, -0.0, inf/nan, the float(str) cases, Python's
spelling rules (1_000.5, 3., .5, +2.5, 1E+2, -0.0) and a round-trip
assertion over the whole table: byte-identical to CPython
(test/test_nilpy_float_repr.npy, wired into make test-nilpy).
gate.sh quick GREEN, self-host fixedpoint byte-identical.
Anti-drift, as decided
lib/rtl/sysutils.pas and compiler/builtin/pylib.pas each carry a header
naming the other and saying CHANGE ONE, CHANGE BOTH.
test/lib_floattostr.pas gained the shared value table (digits and round trip
— the layouts differ by design), so the two cores are pinned from both sides
with CPython as the oracle.
A 500-value random sweep found 23 divergences — and NONE of them are here
A sweep of 400 random bit patterns plus a 1e-320..1e308 ladder disagreed with
CPython on 23 values (1e-292 printed 9.999999999999999e-293). Measured
rather than assumed: for exactly those 23, and only those,
float("<literal text>") != <the same literal> inside one pxx program —
so the correctly-rounded parser and the FLOAT LITERAL LEXER disagree, and it is
the lexer that is 1 ULP off. That is compiler/lexer.inc's StrToDoubleBits,
the third float parser this ticket's decision explicitly declined to sweep
along "until it has its own measurement". It now has one, filed as
[[bug-a-float-literal-lexer-is-not-correctly-rounded]].
The 23 are proof the new formatter is right, not wrong: a literal that is one ULP away from the intended value HAS a different shortest repr, and printing 17 digits for it is the correct answer for the double actually in the program.
Log
- 2026-08-03 — resolved.
- 2026-08-03 — resolved, commit HEAD.