This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.
The setup
I was porting TinyColor, a fifteen-year-old JavaScript colour library, to Rust. The rule I set myself was that the original test suite had to pass unmodified. Not a translation of it. The actual file, byte for byte, sha256-pinned, loading my Rust instead of the JavaScript.
That works because upstream's test file loads the library on exactly one line:
const tinycolor = require("./tinycolor.js");
So I compiled the Rust to WebAssembly, put a shim at that path, and let the suite run. Forty-four of forty-five tests passed on the first try.
The forty-fifth is what this post is about.
One bit
readability("#000", "#111")
expected 1.1121078324840545
got 1.1121078324840543
Two doubles that differ in the last place. That's the smallest possible disagreement between two f64 values, and there's no smaller one to find.
It's also an unusual thing for a test to catch, because most test suites don't compare floats exactly. They use an epsilon. TinyColor's suite asserts the literal value to sixteen decimal places, which struck me as fragile when I first read it and turned out to be the reason I found any of this.
readability is a WCAG contrast ratio, and it's built on relative luminance:
function getLuminance() {
var rgb = this.toRgb();
var R = rgb.r / 255 <= 0.03928
? rgb.r / 255 / 12.92
: Math.pow((rgb.r / 255 + 0.055) / 1.055, 2.4);
// same for G and B
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
}
One Math.pow per channel. That's the whole surface area.
What I assumed, and why I was wrong
My first theory was rounding. My port has a whole module reproducing JavaScript's numeric quirks, because they bite constantly: Math.round(-1.5) is -1 in JS and -2 in Rust, parseFloat("50%") is 50 rather than an error. I'd already been caught by both. So a rounding difference in luminance felt like more of the same, and I went looking for one.
There wasn't one. The arithmetic was identical. I printed intermediate values at full precision and they matched all the way to the pow call.
Then I ran the same code natively instead of through WebAssembly, mostly out of frustration.
native Rust 1.1121078324840545 ← matches JavaScript
the same Rust,
compiled to WASM 1.1121078324840543 ← doesn't
Same source file. Same input. Different answer.
That reframed the whole thing. I'd been looking for a bug in my code, and my code was fine. The disagreement was underneath it.
Math.pow is not one function
Math.pow isn't defined to be bit-exact. IEEE 754 mandates correct rounding for add, subtract, multiply, divide and square root. Transcendental functions like pow are explicitly left alone, because a correctly-rounded pow is expensive and nobody wanted to require it.
So every implementation makes its own accuracy-versus-speed trade, and they disagree in the last bit or two:
- V8 uses its own port of fdlibm
- Rust on macOS calls the system libm, which is Apple's
- Rust on wasm32 has no system libm, so it links a MUSL-derived implementation
Three different pows. I was comparing two of them against a third.
Since getLuminance only ever sees integer channel values, I could check the whole input space rather than guess. All 256 of them, against V8:
| implementation | matches V8 exactly |
|---|---|
Rust std powf (macOS libm) |
207 / 256 |
Rust std powf (wasm32, MUSL) |
225 / 256 |
| V8 (fdlibm) | the reference |
Neither one matches. Not "close enough" — they each disagree with V8 on a few dozen of the 256 possible inputs, and they disagree on different ones.
That was the moment the bug stopped being annoying and got interesting. There was no libm I could pick that would be right. Switching to the MUSL version would have fixed 18 more cases and broken others.
The fix was in the question, not the answer
I'd been asking "which pow do I use." Wrong question.
Look again at what feeds it:
var rgb = this.toRgb(); // r, g, b are ROUNDED here
Math.pow((rgb.r / 255 + 0.055) / 1.055, 2.4);
toRgb() rounds. So rgb.r is always an integer from 0 to 255. pow never sees anything else. Ever.
That isn't a function call with a continuous domain. It's a lookup with 256 possible inputs, and I'd been treating it as maths because it was written as maths.
So I generated the table. Ran V8 once over all 256 channel values, captured the exact f64 bit patterns, and emitted them as Rust:
pub static SRGB_LINEAR: [u64; 256] = [
0x0000000000000000, // 0 -> 0
0x3f33e45677c176f7, // 1 -> 0.0003035269835488375
0x3f43e45677c176f7, // 2 -> 0.000607053967097675
// ... 253 more
];
pub fn srgb_linear(channel: f64) -> Option<f64> {
if channel.fract() != 0.0 || !(0.0..=255.0).contains(&channel) {
return None; // outside the domain: fall back to computing it
}
Some(f64::from_bits(SRGB_LINEAR[channel as usize]))
}
Bit patterns rather than decimal literals, so nothing depends on how a float parser rounds the text.
The test passed. And so did the other 44, on both build targets, which is the part that matters: the port stopped being sensitive to what it was compiled for.
It's also faster than calling pow, though that was luck rather than design.
The thing I keep thinking about
The test that caught this looked like bad practice. Asserting a float to sixteen decimal places is exactly what you're told not to do, and if TinyColor had used an epsilon like a sensible person, my port would have shipped with a silent cross-platform inconsistency that no test anywhere would have found.
I'd have had a colour library that computed contrast ratios differently depending on whether you ran it natively or in a browser. It would never have shown up as a bug. Just two systems quietly disagreeing about whether some text passes WCAG AA.
I'm not going to argue everyone should assert exact floats. But the strict version of that test found something a tolerant version couldn't, and I've stopped assuming an epsilon is always the grown-up choice.
The other lesson is cheaper: check the domain before optimising the function. I spent a couple of hours on which pow implementation to trust, and the answer was that the question didn't apply, because the input space was 256 values and had been the whole time.
The port, and a decision log with 22 of these written up: github.com/BigAchiever/tinycolor-rs