Walkthrough — Module 19 (Floating-Point Arithmetic)
Read this after a stage is green. It is a design commentary on
reference/src/m19_float.rs: why the code is shaped the way it is, the
invariant that makes it correct, and the idioms worth stealing. You do not need
any of it to pass — only to see why the reference beats a naive version.
Stage 1: Representation
The whole module rests on one representation invariant: a nonzero Float is
normalized, 2^52 ≤ frac < 2^53, and value = (-1)^sign · frac · 2^exp.
Keeping exp as the weight of frac's last bit (rather than IEEE's "weight
of the leading bit") is the single decision that makes everything downstream
clean: ulp is literally 2^exp, and the wide-integer arithmetic of later
stages never has to track a fractional point.
from_f64/to_f64 are deliberately built as exact bit inverses on the normal
range, not as frac as f64 * 2f64.powi(exp). The naive scaling double-rounds
(once forming 2^exp, once multiplying) and loses the bottom of the subnormal
range; the bit-assembly version ((sign<<63)|(biased<<52)|mantissa) is exact
and, crucially, is what lets every later stage assert == hardware bit for bit.
That single design choice turns the hardware f64 into a free oracle for
40000-case property tests.
normalize only ever shifts left. That is not laziness — it is the invariant
talking: a legal 53-bit significand is already < 2^53, so it can only be
under-normalized (leading 1 too low), never over. Rounding (which is the only
thing that can produce more than 53 bits) is a different operation, factored
out into round_wide, so normalize stays lossless and idempotent. The 0.1
test is worth stealing: it proves "within one ulp of 1/10" with pure integer
arithmetic (|10·frac - 2^{-exp}| < 10), sidestepping the need for a bignum
rational just to talk about accuracy.
Stage 2: Addition and subtraction
The naive approach — shift the smaller operand right and hope — either loses
rounding information or needs a 2000-bit register for 1e300 + 1e-300. The
reference's idiom is a bounded exact window plus a sticky bit: put the large
operand's frac at bit 64 of a u128 (a_wide = frac << 64), giving 64 exact
guard bits below it; shift the small operand into that window; and if it would
shift past the window (shift > 64), collapse all of it into a single sticky
bit. The correctness argument is the payoff of §4.2.1's guard/round/sticky
theory: an operand shifted more than 64 places cannot reach even the round bit,
so its only possible influence on the result is "is there anything nonzero down
there?" — exactly one bit of information.
Two subtleties the code gets right and a naive version botches:
- Sign handling via magnitude. For opposite signs it subtracts the smaller
u128from the larger and takes the larger's sign, so cancellation is exact and the result sign is always correct — no special cases for "which is bigger" beyond the one comparison. - The
mag |= 1sticky merge is provably safe because wheneverstickycan be set (shift > 64), the low 64 bits ofa_wideare zero, so OR-ing a 1 there cannot corrupt a real bit. This is why a single rounding routine can serve add, mul, and div.
All roundings funnel through round_wide, which implements step N once and
correctly (leading-bit find via leading_zeros, then the RNE decision
round_bit == 1 && (sticky || kept&1), with carry-out to 1<<53 renormalized).
Centralizing rounding is the reason the three operations are each only a dozen
lines.
Stage 3: Multiplication and division
The governing idea is exact-then-round. mul forms the full 106-bit product
in a u128 — exact, no information lost — and hands it to the same round_wide.
Because there is exactly one rounding, of an exact product, the result is
correctly rounded by construction; the tests confirm it matches hardware *
to the bit. A naive (self.to_f64() * other.to_f64()) would work here only
because f64 already does this — the point of the exercise is to be the
hardware, and the u128 product is how.
Division cannot be exact, so the reference manufactures the guard/round/sticky
it needs: q = (frac_u << 64) / frac_v gives 64 fractional bits of quotient
(vastly more than the 2 rounding needs), and the remainder r = (frac_u<<64) % frac_v is the sticky bit — r != 0 means "the true quotient has more below
what we computed." Setting q |= 1 folds that into the low bit, and round_wide
finishes. The elegance is that division reuses the exact same rounding path as
add and mul; the only division-specific line is turning the remainder into
sticky. Note the assert!(other.frac != 0, "...division by zero") with a
message the tests match — infinities are out of scope, and the model says so
loudly rather than returning garbage.
Stage 4: Error analysis
The functions are short; the insight density is high. ulp_f64 and
machine_epsilon are built from the same bias arithmetic as stage 1 — reuse,
not reinvention. The two summation functions are nearly identical in shape,
which is the whole pedagogical point: four extra flops per element (kahan's
y, t, c) buy an error bound that no longer grows with n.
The idiom to steal from kahan_sum is the error-free transformation hiding
in c = (t - s) - y. In exact arithmetic that is zero; in floating point it is
exactly the rounding error of t = fl(s + y). This is TwoSum, and the same
three lines that verify the fundamental bound in the tests are what make Kahan
work — recovering the rounding error in the same precision is one of the most
useful tricks in all of numerical computing (double-double arithmetic, exact dot
products, and reproducible summation all start here). A naive summation loop
throws that error on the floor every iteration; Kahan picks it back up. The
reference deliberately keeps both so you can diff them and see that accuracy is
a handful of instructions away, not a change of number type.