← board

Float printing is not Python's repr — six distinct divergences

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

  1. Digit count. 0.333333333333333 is 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.

  2. Wrong digits, not just fewer. 1e-20 printing as 1.000000000000001e-20 is not a truncation — it is a different number. The conversion is producing noise digits in the small-exponent path.

  3. Scientific-notation threshold. Python switches to exponent form for abs(v) < 1e-4 (3e-05) and for >= 1e16. pxx printed 0.00003, so the low-end threshold is absent or set elsewhere. Note also the two-digit exponent (3e-05, not 3e-5), which is part of the format.

  4. Over-expansion. 123456789.122999995946884 is the exact binary value printed to excess precision instead of the shortest round-tripping form.

0.1 + 0.20.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.

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

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:

  1. 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.
  2. Point pyfloat_parse at 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 of repr and 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") and float("2.2250738585072011e-308") all disagree with pxx's own (correct) literals. This ticket fixes float(str) as well as repr, and that is not a bonus to be skipped: it is half the reason the parser is worth copying.
  3. Land PyFloatRepr — written, measured, and pasted in the section above.
  4. The round-trip check compares the 64 BITS, not the doubles. Not cosmetic: StrToFloat('0') = -0.0 is True, so the existing FloatToStrShortest in sysutils would accept '0' as the shortest form of -0.0. Reading bits also handles NaN and is immune to extended-precision registers.
  5. A permanent DIFFERENTIAL TEST over a large sample, asserting the copy and sysutils agree — 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