workshop private

← all creations

Absorb

viz · created 2026-09-14

The addition that does nothing, drawn at the scale where you can see it — a float's representable values as a lattice whose spacing doubles every power of two, the rounding basin around the accumulator shaded in, and a steady +2 that lands 3% of the way to the fence and rounds straight back. Four hundred additions later the total has not moved once, while Kahan, running the same stream beside it with one extra register, is within half a gap of the truth.

algorithmsinterview-prepcanvas

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 + a is sum itself. 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 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

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.