Browse documentation

FormatDouble first-hit precision scan

Date: 2026-06-28 Area: engine Issue: #812 Pull Request: #899

FormatDouble (Goccia.Values.Primitives) implements ES2026 §6.1.6.1.20 Number::toString for the non-integer case by finding the shortest decimal that round-trips: it scans the Str(V:W) precision width W from 9 (2 significant digits) to 24 (17 significant digits) and takes the first width whose output parses back to the original double. The normative step requires k (the digit count) to be "as small as possible", so the shortest representation is a conformance requirement, not a quality-of-implementation nicety. This path backs Number.prototype.toString, String(x), template interpolation, property-key stringification, and JSON.stringify of floats; toFixed/toExponential/toPrecision use a separate FormatDoubleToPrecision path and are unaffected.

Issue #812 proposed replacing the linear scan with a binary search over W ("same candidates, fewer probes", assumed low risk). It is not low risk: it is incorrect. A sweep of ~70M doubles (FPC 3.2.2, prod -O4 with NOFASTMATH) found the round-trip predicate Val(Str(V:W)) = V is not monotonic in W — 14,241 general-case values have a width that round-trips, a wider width that does not, then a wider one that does again, because FPC Str is not correctly rounded at every width. The upward first-hit scan is robust to these holes (the first hit is still the smallest, hence shortest), but a binary search can converge onto a hole above the true minimum: for 115 of ~60M sampled doubles it selected a wider width and emitted a non-shortest string (for example 9.18742501042000e+222 instead of 9.18742501042e+222, or 6.110371725116101e+201 instead of 6.1103717251161e+201), violating "k as small as possible". Every probe-skipping variant (stride, galloping, scan-down-until-false) fails for the same reason. Decision: the scan stays first-hit-from-the-bottom; binary search and probe-skipping are rejected for this function. A correct single-pass alternative would be a Ryū/Grisu shortest-representation algorithm, which removes the dependence on Str's per-width rounding entirely; that is a larger spec-exact rewrite left for a future decision.

The performance concern behind #812 is addressed without changing the algorithm or its output. Each probe now reads Str(V:W) into a fixed ShortString, strips the right-justification padding in place, and parses with the locale-free Val instead of Trim + TryStrToFloat. Val selects the identical width — verified byte-for-byte against TryStrToFloat over 74.9M doubles with zero divergence — while avoiding the per-iteration heap allocation and the TFormatSettings scan. The probe loop itself is roughly halved; end-to-end the change is about 1.4× faster (−28% execution time) on a toString-dominated float workload (2M Number.prototype.toString calls over 15–17-significant-digit doubles, bytecode, --prod), with the engine's per-call string allocation and dispatch a roughly constant overhead around FormatDouble. benchmarks/numbers.js covers this path (the toString non-integer bench). Regression tests in tests/built-ins/Number/prototype/toString.js and tests/built-ins/JSON/stringify.js lock the exact shortest output for computed fractional values and for several of the non-monotonic "hole" doubles, so any future move to binary search (or any change that lengthens these strings) fails the suite immediately.