Repository navigation
feat(num): num.fixed writes a number with N decimals, half to even (JSON-FIX) - #80
Merged
Merged
Conversation
…SON-FIX) `num.fixed(raw, places)` writes a JSON number's text with exactly `places` decimals, rounding half to even on its decimal digits, as Python's `Decimal(raw).quantize(..., ROUND_HALF_EVEN)` does and as Python's `'%.Nf' % x` does whenever the text is exactly a double. It reads exponents, keeps a minus on a value that rounds to zero (`-0.0`), and is None when the text is not a JSON number. Services were formatting `'%.1f'` by hand on number text. Spec-first: JSON-FIX-1 to JSON-FIX-5 are new Proved rows (result and None, sign, cut and exponent, rounding, writing), proved by 53 tagged quantified laws in LAWS.bend. SPEC.md documents where Python's binary rounding differs (2.675, 0.15, 1e23, 1e400). The trust boundary is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a fixed-decimal number writer, spec-first. The services ported from Python format numbers with
'%.1f'/format(x, '.2f')and were hand-rolling the rounding on number text (qbt queue-cleanup has its own half-to-evenround.go).It takes a number's text, which is what
as_numreads, and not aJson. That way a caller can format a number it holds as text too, andas_numcomposes with it in aMaybebind. It answersNonewhen the text is not a JSON number (num.ok). The export is inmain.bend, the logic is in the new pure modulesrc/fixed.bend, and the README usage section documents it.placesis aNat, as Python's precision is.Reference behaviour and the float caveat
The result is IEEE 754 roundTiesToEven applied to the exact decimal value of the text. That is Python's
format(Decimal(s).quantize(Decimal(10) ** -n, ROUND_HALF_EVEN), 'f'). On every input whose decimal value is exactly a binary double, it is also Python's'%.Nf' % float(s). Python's'%.Nf'rounds the nearest double, not the text, so the two differ in three ways. SPEC.md lists all three:2.675to 2 places is2.68here and2.67in Python.0.15to 1 place is0.2here and0.1in Python.0.45is0.4here and0.5in Python.0.0005to 3 places is0.000here and0.001in Python.9.995to 2 places is10.00here and9.99in Python.0.049999999999999999999to 1 place is0.0here and0.1in Python.1e23to 0 places is1followed by 23 zeros here and99999999999999991611392in Python.123456789012345678.5to 0 places is123456789012345678here and123456789012345680in Python.1e400is written out in full here and isinfin Python.1e-400agrees: it is0.00.Exact values agree with Python:
0.125,2.5to 0 places is2, and0.5is0. Negative zero is kept as Python and C'sprintfkeep it:-0.04to 1 place is-0.0, and-0to 2 places is-0.00. With 0 places there is no point, so'%.0f' % 2.5is2.Cost: the exponent is read as a
Natand the point moves one place per step, so time and memory grow with the exponent's value.1e999999999asks for a billion digits. A caller formatting untrusted numbers should bound the exponent first. SPEC.md's prose says so.Rows (new group JSON-FIX, interface family)
num.fixed(s, n)isNonewhennum.ok(s)is false, andSome{F.text(s, n)}when it is true-followed bysis written as-followed by the unsigned text ofs. A text that does not start with-(the empty text too) is written unsigned. The minus is kept even when every digit rounds to 0e/E, then the mantissa at the first.. The exponent's sign is applied (-makes it negative,+is dropped) and its digits are read most significant first. The point moveskplaces right or left, one carry per step, with a0when a side runs out. A move right then left gives the same digits back, and so does left then rightnfraction digits are kept, padded with0s. The kept digits go up by one when the first dropped digit is above 5, or when it is 5 and a later dropped digit is nonzero, or when it is a tie and the last kept digit is odd. No kept digit at all counts as an even 0. A carry turns9s into0s and may add a new leading1n, with leading0s dropped (0if none is left), then.and the lastndigits whenn ≥ 1, and no point whenn = 0Laws (53, all quantified and tagged; LAWS.bend
--- fixed decimals ---)fixed_noneandfixed_some(direct).text_minus,text_plainandtext_empty(direct).unsigned_stepsstates the decomposition: split, move, round, write.parts_expandparts_plain, under the hypothesisno_e(mant);mant_dotandmant_int, underno_dot(int). The proofs go by induction through the new lemmasfx_cut_e_hit,fx_cut_e_none,fx_cut_dot_hitandfx_cut_dot_none.esign_minus,esign_plusandesign_digits.place_parts,nat_startandnat_end.nat_snocsays that one more digit gives 10 × the value + d, by induction, which is what makes the reading positional.move_right,move_left,ups_zero,ups_succ,downs_zero,downs_succ,up_digit,up_zero,down_digitanddown_zero.up_downanddown_upare frame laws: a move and its inverse give the same digits back.round_cutstates the decomposition.pad_widthsays exactlyndigits are kept, a frame law proved by induction. Alsopad_none,pad_digitandpad_past.up_exact,up_below,up_above,up_past_half,up_tie(the half-to-even rule on the last kept digit) andup_tie_none.nonzero_zerossays all-zero dropped digits are not past half.nonzero_digitsays one nonzero digit anywhere is past half. Both are by induction.bump_up,bump_down,inc_digitandinc_nine.inc_ninessays all 9s become 0s plus a1, by induction.write_parts,point_none,point_some,trim_zeroandtrim_lead.trim_zerossays only zeros, or no digits, are written0, by induction.Many of these laws are step equations whose proof is a single rewrite on the hypothesis, or
{==}. Together they pin every step of the algorithm in the order the text is read, as SYS-TIME does in ezsys. The non-definitional ones arenat_snoc,pad_width,nonzero_zeros,nonzero_digit,inc_nines,trim_zeros,up_down,down_upand the cut laws. They are proved by induction.Trust boundary
Unchanged. No Trusted row was added. This is new behavior, so no released guarantee changes.
Gate (bend 2.0.38, bolt and ez 1.5.0 from the flake)
With the laws written and no proofs yet,
bend PROOF.bendreported 53 TODOs. With the proofs in place it printsALL PROOFS CHECK(about 5 s).bolton the whole tree reports 0 errors and 2 warnings. Both warnings are the existing L001s onlex.runandprint.maybe, the same as on main.traceis at error, and the 5 rows agree with the tags.nix flake checkpasses: proofs, lint, scale and bench-compare.Planted bugs: I planted one bug per row group, confirmed the gate failed, and restored the code:
Some{""}fixed_nonetext_minus1up_zeroe+makes the exponent negativeesign_plusup_tieincadds 2inc_digitnonzerois invertednonzero_zerostrimwrites the empty text for zerostrim_zeros,instead of.point_someDifferential test against Python (driver not committed)
The driver is a compiled C-lane binary that calls
num.fixedon each case. I ran 4 seeds of 751 cases each, 3004 checks in all. Each seed had 500 random JSON numbers, biased toward5ties, with signs, fractions,e/E/+/-exponents up to 2 digits and 0 to 8 places. Each seed also had 47 edge texts × 5 place counts, and 16 invalid texts ("",01,1.,.5,+1,1e,NaN,' 1', …). The JS lane was spot-checked on the README examples.Decimal(s).quantize(10**-n, ROUND_HALF_EVEN), andNonefor the invalid texts: 0 mismatches.'%.Nf' % float(s)there were 291 mismatches across the 4 runs, and every one falls in one of the three classes above:1e400gaveinfin Python.1e23,45432.875e25or5E+49.2.675,0.15,-71.350,1548.7455and0.650. Others were near-ties on the other side, such as0.0005,9.995and0.049999999999999999999.🤖 Generated with Claude Code