fix: handle subnormals in both float codecs and round ties to even - #30
Conversation
Subnormals were wrong in all four codecs and encode_float broke exact midpoints away from zero instead of toward the even mantissa. Decode built every result as 1 + m / 2^k, which is only correct for a normal: a subnormal has no implicit leading one and its exponent is pinned at the minimum rather than sitting at the bias. The float 01000000 read as 5.88e-39 instead of 1.40e-45. Encode clamped the biased exponent to 0 with a zero mantissa, so any subnormal flushed to zero, and the clamp only caught e < 0, letting e == 0 fall through and emit a plausible looking wrong subnormal. Rounding was floor(mantissa + 0.5), which is round-half-away-from-zero. IEEE 754 rounds half to even, and the difference is not confined to subnormals: 291 of the corpus assertions that fail on the previous code are exact midpoints in the ordinary normal range. One round_half_even helper covers both bands, and its carry out of the mantissa lands on the exponent's low bit, which is the wanted answer for a subnormal carrying into the smallest normal, a normal carrying into the next binade, and the largest finite carrying to infinity. Both bands are selected by magnitude against the smallest normal rather than from a frexp exponent, so the boundary does not depend on which frexp is bound. test/float_vectors_test.lua adds 6525 assertions over 1183 generated vectors, enumerated rather than sampled. The oracle is the C float cast via ctypes and struct.pack, never a hand-written literal. Each vector carries the exact rational its decimal literal must parse to, because a midpoint that reads back as a neighbour still encodes to the golden and would pass while testing nothing. make check-float-vectors recomputes every expectation from the oracle and checks the checked-in corpus for drift; it runs in CI as its own step, and is not part of make check, since Python is genuinely required. A smaller group of the same cases goes in selftest(), which is the only suite that runs on a real controller. Refs FL-16
derek-miller
left a comment
There was a problem hiding this comment.
The codec change is correct and the coverage is the right shape. I checked it with a corpus of my own rather than yours: 29,041 values from a fresh seed (random f32 subnormals with their half-ulp midpoints, ties built as exact midpoints between adjacent float32s, the overflow band around FLT_MAX + 2^103, random f64 subnormals, random normals across the exponent range), oracle ctypes.c_float + struct.pack, values carried as exact m * 2^e so strtod is out of the loop. 116,164 assertions, 0 failures on Lua 5.1, 5.4 and LuaJIT 2.1, each with native and cleared math.frexp/math.ldexp. The same corpus against main fails 20,693, so it sees what it is meant to see. make check, run_tests.sh on the three interpreters, check-float-vectors, and gen-float-vectors drift are all clean here too.
The selftest() additions stay: that is the suite that runs on a controller, and 13 boundary cases is the existing float section's shape. The corpus size is fine.
Three edits, all small:
-
Two comments narrate the old bug rather than the invariant.
decode_float: keep "A subnormal carries no implicit leading one and sits at the minimum exponent rather than at the bias." and drop the "Reading one as ... made 01000000 come back as 5.88e-39 instead of 1.40e-45" sentence.decode_double: "Subnormal, as in decode_float." and drop "0100000000000000 read as 1.11e-308, not 5e-324." The wrong values belong in the PR and the ticket, not in the source. -
CLAUDE.md "What is not implemented" now carries a bullet that says something is implemented. Delete the
float/doublebullet from that list rather than inverting it; README's table and the paragraph under it already state the support, and the section should stay a list of gaps. -
One line on
ascending's table branch, carried over from #29. The Int64 comparator orders{high, low}by the unsigned high word, so negative 64-bit keys sort after positive ones; that is deliberate (stable, and no consumer depends on numeric order) but not obvious. Since this is the next commit oninit.lua, add a one-line comment there:-- Unsigned high word first: negative 64-bit keys sort last, which is stable and all that matters.or similar.
Nothing else. Push those and I will approve.
… order Per review on #30: The two subnormal decode comments narrated the values the old code got wrong. That belongs in the PR and the ticket, where it is dated and attached to the decision, not in the source. They now state the invariant only. CLAUDE.md's "What is not implemented" is a list of gaps, so the float and double entry is removed rather than inverted into a statement of support. README's type table and the paragraph under it already carry that. ascending's table branch orders Int64 keys by the unsigned high word, so negative 64-bit keys sort after positive ones. Verified against the comparator before writing it down: from_number(-1) is {0xFFFFFFFF, 0xFFFFFFFF} and sorts last, behind 0x7FFFFFFF. Deterministic rather than numeric, which is what a map's emission order needs. Carried over from #29. Refs FL-16
derek-miller
left a comment
There was a problem hiding this comment.
Approving at 2958710. The three edits are as asked and nothing else moved; the codec verification from the previous review stands (116,164 oracle assertions, 0 failures on 5.1/5.4/LuaJIT in both math modes; 20,693 against main). Merging when the re-run is green.
|
Thanks, and noting the approval landed while I was writing this. CI is green 8/8 at For the record, since two of the three edits were judgment calls:
Re-ran everything after the edits rather than assuming comment changes are inert: full One thing worth recording: your corpus is a better instrument than mine on one axis. |
Part 1 of FL-16: subnormals in all four codecs, and round-half-to-even for the tie
band.
src/protobuf/init.luais the only source file touched. Part 2 landed in #27.The defects
Every figure below is measured against the oracle, not read off the source.
Decode, both widths. The result was built as
1 + m / 2^kunconditionally, whichis only correct for a normal. A subnormal has no implicit leading one and its exponent
is pinned at the minimum rather than sitting at the bias.
01000000FFFF7F000100000000000000Encode, both widths. The clamp caught only
e < 0, so everything below thesmallest normal flushed to zero while
e == 0fell through into the normal path andemitted a plausible looking wrong subnormal.
Rounding, not confined to subnormals.
math.floor(x + 0.5)isround-half-away-from-zero; IEEE 754 rounds half to even. 291 of the assertions that
fail on the previous code are exact midpoints in the ordinary normal range, one per
binade in each direction. One
round_half_evenhelper serves both bands, which is theargument for taking them together.
Two corrections to the ticket description
Worth flagging because both are load-bearing for how the fix is written.
math_ldexp's fallback nowsteps its scale in normal-range chunks, so the
math_ldexp(math_ldexp(v, 537), 537)workaround is unnecessary.
math_ldexp(value, 1074)is exact in one call.says the frexp fallback returns -1021 for the largest subnormal double where native
returns -1022, so classifying by frexp exponent would break 5.3 and 5.4 only. That
was true when the description was written. fix: correct the frexp fallback and drive it from generated vectors #26 rewrote the fallback, and it now
agrees with native on both boundaries at both widths. The fix still classifies by
magnitude, but for the plainer reason that the boundary then does not depend on
which frexp is bound at all.
Coverage
test/float_vectors_test.lua, 6525 assertions over 1183 vectors, enumerated ratherthan sampled so the corpus is byte-identical on every host. Two oracles, neither a
hand-written literal:
ctypes.c_floatfor the double-to-float narrowing, which is thecast the hardware performs, and
struct.packfor the bytes.Per-bucket counts are printed every run, so an arm that stops running is visible:
Against the previous code: f32 subnormal 502 failures, f32 normal 291, f64 subnormal
352, identical on all four interpreters I have locally. Everything else was already
green, including the overflow band, which #25 fixed.
make gen-float-vectorsandmake check-float-vectors, mirroring the wire vectors:goldens checked in so
make testruns on a bare clone with no Python, drift checkplus an independent recomputation of every expectation, wired into CI as its own step
in the
checkjob, and deliberately not part ofmake check.Three things the harness has that are not obvious
the suite asserts
value * 2 ^ scale[1] * 2 ^ scale[2] == unitsbefore using it.No assertion on the codec can pin this: a midpoint that reads back as one of its
neighbours still encodes to the golden, so it would pass while testing nothing. Two
factors because a subnormal double needs a scale of 1074 and
2 ^ 1074is infinity.\10followed by a"0" byte reads back as
\100, which corrupted three vectors in fix: sign the 32-bit codecs and cover the wire format against the reference #27.lands in. This caught four of my own mislabellings, including
1e-308, which is asubnormal double and was filed under "normal". Left alone, the bucket table would
have reported the wrong arm as covered.
A finding from running real 5.1
The first corpus emitted negative zero as the literal
-0.0. Lua 5.1 constant-foldsthat to
+0.0, so the suite had 11 failures there and nowhere else, all of them thecorpus being wrong rather than the codec. Now emitted as
(-1 / math.huge), which iswhat
src/protobuf/init.luaalready does for its ownNEG_ZEROand for the samereason. The literal guard could not catch this one: for zero it is vacuous.
Verification
Every new assertion was demonstrated red before the fix, which is the standard the
ticket asks for.
2.1, in both math modes.
selftest()group tomain'scodecs: 11 of 13 fail. The two that pass are cases where round-half-up and
round-half-even agree, and both are killed by mutation instead.
checker red, with an anchor count asserted as exactly 1 before every substitution and
a content hash asserted after every revert. Two of the 17 mutate the corpus rather
than the source, to prove the literal guard and the golden readback are live.
value <= FLOAT_MIN_NORMALinstead of<differs on exactly one input, 2^-126, andboth spellings emit
00008000: the subnormal branch computes a mantissa of 2^23,which carries into an exponent of 1 and gives the same answer. The input is in the
corpus, so this is not a coverage gap.
Also green: the full suite 7/7 on those four interpreters,
make check-wire-vectorsagainst the reference implementation,
format-check,check-types,check-schema,typecheckandluacheck.Not run locally, stated rather than implied: real 5.2, 5.3 and LuaJIT 2.0, which
are 3 of the 6 matrix entries. 5.5 is not in the matrix and stands in for the 5.3/5.4
arm as a second number model.
make buildalso did not run: amalg is not installed foran interpreter I have here. The amalgamation consumes
src/only, so nothing undertest/can reach the artifact, but the Build Combined Module job is the proof.Two judgment calls
test/generated/float_vectors.luais 214 KB, most of it the 508 normal-bandmidpoints, one per binade in each rounding direction. The defect is
exponent-independent, so a sample would find it, but the frexp path is not, and fix: correct the frexp fallback and drive it from generated vectors #26's
defects were exponent-specific. Happy to thin it if you would rather.
selftest()as well, 13 assertions. Yourcomment scoped generated corpora to
test/and saidselftest()stays as it is, sothis is the one place I went slightly past it:
selftest()is the only suite thatruns on a real controller against the real LuaJIT, and the existing float section
already carries boundary and carry cases in exactly this shape. Say the word and it
comes out.
Refs FL-16