Skip to content

Serialize numbers at full precision instead of truncating to 6 digits - #14

Merged
hellerve merged 1 commit into
mainfrom
claude/serialize-number-precision
Jul 14, 2026
Merged

Serialize numbers at full precision instead of truncating to 6 digits#14
hellerve merged 1 commit into
mainfrom
claude/serialize-number-precision

Conversation

@carpentry-agent

Copy link
Copy Markdown

The bug

JSON.str / JSON.pretty-str serialize every JSON.Num through Double.str, which is snprintf("%g", …)six significant digits. So serialization silently loses precision:

value JSON.str before should be
3.141592653589793 3.14159 3.141592653589793
1234567890.0 1.23457e+09 1234567890
0.1234567 0.123457 0.1234567

JSON.parse reads full precision back (via Double.from-string), but str then throws most of it away, so a parse → serialize round-trip corrupts numbers. The existing tests missed it because every serialized number was ≤ 6 significant digits.

The fix

A private num-str renders the fewest significant digits that parse back to exactly the input — it walks %g precision from 1 to 17 and keeps the first rendering whose Double.from-string equals the original. It prefers a plain-decimal form and only falls back to exponent notation for magnitudes no decimal precision can round-trip. This is pure Carp on top of the existing Double.format primitive — no new C.

Output stays clean and, for numbers already representable in ≤ 6 digits, byte-identical to before:

3.14              -> 3.14
42                -> 42
10                -> 10                    (not 1e+01)
1234567890        -> 1234567890            (decimal, not exponent)
100000            -> 100000
3.141592653589793 -> 3.141592653589793     (was 3.14159)
0.1 + 0.2         -> 0.30000000000000004
6.022e23          -> 6.02214076e+23        (exponent only when needed)
1e-7              -> 1e-07

NaN / infinity are unaffected — they're still caught by the existing guard above the number path and reported as NonFiniteNumber.

Tests

Added 7 assertions to the serialization block: full-precision preservation, high-precision parsestr round-trip, the 0.1 + 0.2 case, large-whole-number decimal form, and exact round-trips for magnitudes that require exponent notation. Full suite: 284 / 0 (was 277). carp-fmt --check, angler, and gendocs all clean.

JSON.str routed every JSON.Num through Double.str, which is snprintf
"%g" — six significant digits — so serialization silently corrupted
numbers: 3.141592653589793 became "3.14159" and 1234567890.0 became
"1.23457e+09". parse read full precision back, but str threw it away.

num-str instead renders the fewest significant digits that parse back to
exactly the input (searching %g precision 1..17), preferring a plain
decimal and falling back to exponent form only for magnitudes no decimal
precision can round-trip. Simple values are unchanged (3.14, 42, 10).

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build & Tests

Checked out claude/serialize-number-precision at e7f575d (merge-base = origin/main HEAD b271233, so the branch is current):

  • carp -x test/json.carp284 / 0 (was 277; +7 new).
  • carp-fmt --check, angler, carp -x gendocs.carp — all clean. Matches green CI (ubuntu + macos). No CHANGELOG in this repo.

Findings

The bug is real and the fix is correct — I didn't just trust the suite.

Bug confirmed: JSON.str routed every Num through Double.str = snprintf("%g") = 6 significant digits, so 3.141592653589793 serialized as 3.14159 and 1234567890.0 as 1.23457e+09 — a silent parse→serialize corruption on the core path.

Fix reviewed (json.carp:522-544): num-str walks %g precision 1→17, keeps the shortest rendering that Double.from-string round-trips back to the exact input, prefers a decimal over an exponent form, and falls back to %.17g. Pure Carp over the existing Double.format primitive — no new C. The %.17g fallback is effectively unreachable (17 sig digits always round-trips a double) but is a safe default.

Non-finite handling: verified num-str never sees NaN/inf — the (if (or (Double.nan? n) (Double.inf? n)) …) guard at json.carp:567 catches them upstream and routes to the existing NonFiniteNumber / "null" path, untouched by this change.

Stress test (beyond the suite): I round-tripped 3116 doubles through JSON.strJSON.parse — pi, 0.1+0.2, max-double, min-normal, min-subnormal (5e-324), 2^53±1, powers of 1.7/3/10, and ~2500 fractions — asserting exact equality. 0 failures. Simple values stay byte-identical to before (3.143.14, 1010 not 1e+01, 1234567890 in decimal form), which the new tests also pin.

Verdict: merge

Genuine silent-data-corruption fix on a flagship lib's serialize path, correct across every double I threw at it, no new C, all gates green.

@hellerve
hellerve merged commit 421ad14 into main Jul 14, 2026
2 checks passed
@hellerve
hellerve deleted the claude/serialize-number-precision branch July 14, 2026 14:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant