Build1 publisher3 min readPublished
A one-ULP divergence, caught because a fifteen-year-old test suite refused an epsilon
TinyColor's suite asserts contrast ratios to sixteen decimal places. Running it unmodified against a WebAssembly build of a Rust port showed that Math.pow disagrees with itself across platforms.
The Engineer · Build desk
Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction
What happened
- The author was porting TinyColor, a fifteen-year-old JavaScript colour library, to Rust.
- The rule the author set was that the original test suite had to pass unmodified: the actual file, byte for byte, sha256-pinned, loading the Rust instead of the JavaScript.
- Upstream's test file loads the library on exactly one line (const tinycolor = require("./tinycolor.js")), so the author compiled the Rust to WebAssembly and put a shim at that path.
- Forty-four of forty-five tests passed on the first try.
- The failing test was readability("#000", "#111"): expected 1.1121078324840545, got 1.1121078324840543.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
A developer porting TinyColor, a fifteen-year-old JavaScript colour library, to Rust set one rule for the port: the original test suite had to pass unmodified, as the actual file, byte for byte and sha256-pinned, loading the Rust instead of the JavaScript [1][2]. That was possible because upstream's test file pulls in the library on exactly one line, so the Rust was compiled to WebAssembly and a shim dropped at that path [3]. Forty-four of forty-five tests passed on the first run [4], which means exactly one failed [18].
The failure, according to the author's account on dev.to, was `readability("#000", "#111")`: expected 1.1121078324840545, got 1.1121078324840543 [5]. That is one unit in the last place, the smallest disagreement two f64 values can have [6]. Most suites would never have seen it, because most suites compare floats with an epsilon; TinyColor's asserts the literal value to sixteen decimal places, which the author read as fragile on first contact and which is the only reason any of this surfaced [7].
The surface area is small. `readability` is a WCAG contrast ratio built on relative luminance, which is 0.2126*R + 0.7152*G + 0.0722*B with one `Math.pow` per channel [8]. The first theory was a rounding difference, reasonable given that the port already carries a module reproducing JavaScript's numeric behaviour: `Math.round(-1.5)` is -1 in JavaScript and -2 in Rust, and `parseFloat("50%")` is 50 rather than an error [9]. It was not rounding. Intermediates printed at full precision matched all the way to the `pow` call [10].
Then the useful test: the same Rust source, run natively, produced 1.1121078324840545 and matched JavaScript; compiled to WebAssembly it produced 1.1121078324840543 [11]. The defect was not in the port. IEEE 754 mandates correct rounding for add, subtract, multiply, divide and square root, and explicitly leaves transcendentals like `pow` alone, because a correctly-rounded `pow` is expensive [12]. So there were three implementations in play: V8's own port of fdlibm, Apple's system libm on native macOS, and a MUSL-derived implementation that Rust links on wasm32 because there is no system libm there [13].
Because `toRgb()` rounds before luminance is computed, `pow` only ever sees integers from 0 to 255, so the input space is 256 values and can be checked exhaustively rather than sampled [14]. Checked against V8, both non-V8 implementations disagree on a few dozen of the 256 inputs, and on different ones [15]. Switching to the MUSL version would have fixed 18 more cases and broken others [16]. There was no libm to pick.
The fix follows from the domain being finite: run V8 once over all 256 channel values, capture the exact f64 bit patterns, and emit them as a Rust static `SRGB_LINEAR: [u64; 256]`, with the accessor returning `None` for non-integer or out-of-range input so the general path still computes [17]. That table is 2,048 bytes of data standing in for a transcendental call [19].
The transferable part is the assertion style. Sixteen decimal places of literal expected value is what most reviewers would soften to an epsilon comparison during a port, and softening it here would have shipped a build whose contrast ratios differ from the JavaScript on a few dozen inputs, silently. Worth watching: whether the same divergence is already sitting in other wasm ports that do use epsilons, and what happens to this table if a future version stops rounding channels before luminance [14][17].