A binary float keeps a fixed number of significant bits, so the values it can
actually hold are not spread evenly along the number line. They sit on a
lattice whose spacing doubles at every power of two: inside the binade
[2^e, 2^(e+1)) there are exactly 2^p of them with p mantissa bits, so the
gap between neighbours — one ulp — is 2^(e-p). Near 1 that gap is tiny. Near
4096 it is 4096 times bigger. Same count of values, wider and wider spacing.
Every operation computes the exact real result and then rounds it to the nearest point on that lattice. Which gives the fact this piece is about, and it is a much harder fact than “floats are a bit inaccurate”:
If the addend is smaller than half the gap at the accumulator, the nearest lattice point to
sum + aissumitself. The addition is a no-op. Not approximately — exactly.sum + a === sum, and it will be true the next thousand times too.
The default run is 400 additions of +2 into an accumulator starting at 4096,
at 5 mantissa bits. The gap up there is 128, so the fence is at 64, and +2
covers 3.1% of it. Four hundred times. The accumulator finishes on 4096 — the
number it started on — having been handed 800 and kept none of it.
What’s on screen
- The lattice (top). Six binades as rows. Every row holds the same number of representable values; the bar says how far apart they are, doubling as you climb. This is the whole mechanism in one glance, and the reason the trouble arrives when the accumulator gets large, not when the addends get small.
- The journey. One number line covering the entire run, with both accumulators on it. The plain one is pinned where it stalled; the compensated one walks away from it toward the amber truth line, and the shaded band between them is the ground that was given up. The hairlines are the lattice itself — watch them crowd toward the left in the harmonic run, which is the gap doubling drawn on a linear axis without anyone arranging it.
- Two microscopes, zoomed hard around each accumulator. The shaded band is the rounding basin: every real number in there rounds back to the tick in the middle. The step this frame asked for is drawn as a bar from the tick to the exact real result, with an amber caret at the tip. In the plain lane that caret is the same pixel as the accumulator, which is the honest picture of what the hardware is looking at.
- The gauges, because a quantity that is 3% of a pixel cannot be read off the line above. Zero in the middle, a fence at each end, filling toward whichever fence this step is heading for.
The lopsided basin
Worth stopping on, because it is the detail a symmetric drawing would lie
about. Sitting exactly on a power of two, the lattice below you is the binade
below, spaced half as far. At 5 bits the neighbours of 4096 are 4064 and 4224 —
so its rounding basin is [4064, 4160], and the fence going down is half the
height of the fence going up. gapBelow() exists for this, the microscope
labels each fence separately, and you can see the tick labels change density
mid-lane as they cross 4096.
Kahan, and why the residue is recoverable
The compensated lane runs the same stream with one extra register:
y = a − c the addend, minus the debt left over from last time
t = sum + y the accumulator takes whatever step it can
c = (t − sum) − y what it actually moved, minus what it was asked to
The sliver that fell short of the fence is not lost information — it is exactly
(t − sum) − y, and that middle subtraction is exact because t and sum are
within a factor of two of each other, so their difference is representable. The
shortfalls pool. When the pool finally clears half a gap, the accumulator jumps
a whole tick and the overshoot carries back out as a negative c — which is
why the Kahan bar sometimes points left, paying back.
So the two lanes are not “wrong vs right”. They are: throw the residue away, or keep it. Same arithmetic, same addends, one more register.
Things it turned up
- Watch for the tie. At step 288 of the default run the pooled debt reaches exactly 64 — precisely on the fence — and the accumulator doesn’t move, because round-half-to-even sends an exact tie to the even mantissa. One step later, at 66, it goes. Ties-to-even is not a rounding detail here; it is visible in the animation as a beat that gets skipped.
- Kahan stalls too, at 3 mantissa bits — and the reason is the same
phenomenon, one level down. Slide the bits to 3 and the compensated lane
flatlines alongside the plain one. The carry is itself a float in the same
format, so it is not immune: the debt climbs to 32 and stops there, because
the gap at 32 is 4, the fence is 2, and an addend of exactly 2 lands on the
fence and ties to even.
quantize(2 + 32) === 32, forever. The carry never gets near the 256 it would need to move the accumulator — the addends are being absorbed into the compensation register. Kahan is relative to the precision you already have; it does not manufacture any. - The harmonic series, in this format, is 4. Run the
1 + ½ + ⅓ + …stream: the accumulator climbs to 4, the terms fall under the fence at term1/26, and it stops. Not slowly — completely. Add ten million more terms and it is still exactly 4. The compensated lane gets 6.625 against a true 6.583.
Reuse
src/absorb.js is framework-free and has no rendering in it. quantize(x, bits) is round-to-nearest-even into a bits-mantissa format with an unbounded
exponent — overflow, subnormals and infinities are a different subject, and a
small bits is what makes the lattice big enough to see. At bits = 52 every
routine agrees with IEEE binary64, so it doubles as a way to check a claim about
real doubles. latticeIn(lo, hi, bits) enumerates the representable values in a
range, stepping by mantissa so the density change at a binade boundary comes out
right instead of smeared. simulate() records one frame per addend carrying
both accumulators and every Kahan intermediate, so a different renderer can pick
it up unchanged.
node scripts/screenshot-demo.mjs regenerates thumb.png from a fixed frame.