Engineering 9 min

Three engines, one answer

A TypeScript reference, a C++ core, and a WebAssembly build all compute the same 401 numbers. Here is the machinery that makes that true and keeps it true.

This lab computes its colorimetry three times. There is a TypeScript reference engine that the MCP server calls. There is a C++20 core behind a C ABI, for speed and for native embedding. And there is a WebAssembly build of that same core, so a browser can run the real engine instead of a JavaScript retelling of it.

Three implementations of the same math is three chances to disagree. This post is about the machinery that stops that, and about why the redundancy is worth its cost.

Why not just have one?

The honest answer is that each exists for a reason that the others cannot serve.

The TypeScript engine is the reference. It is the one that is easy to read, easy to change, and easy to test against a paper. When a formula is in question, that is the copy you check. It also means the MCP server runs anywhere Node runs, with no toolchain.

The C++ core exists because some of these kernels do real work — sampling a metamer polytope’s boundary, covering a gamut to certify a palette ceiling, running an evolution strategy over spectra. Those are loops in the hundreds of thousands, and the difference between a language that compiles to native code and one that does not is the difference between a tool you use and a tool you wait for.

The WebAssembly build exists so the website can demonstrate the engine rather than describe it. A demo that reimplements the math in JavaScript is a demo of the JavaScript, and any drift between it and the real engine is invisible to the reader and embarrassing to the author.

So: three, each justified. The problem is that three implementations of one specification is a specification with three votes and no tiebreaker.

Golden vectors

The mechanism is a file: core/tests/golden/vectors/conformance.csv, 401 rows, generated from the TypeScript engine. Each row is a test case — a kind, a tolerance, an input vector, and the expected output.

The kinds cover the primitives everything else is built on: xyz_to_oklab, oklab_to_xyz, srgb_to_xyz, xyz_to_srgb, xyz_to_lab_d65, ciede2000, spd_to_xyz_d65, and full-severity deutan simulation. Sixty-five cases each for the color-space maps, sweeping the space rather than sampling a few pretty colors.

The native test suite reads that file and checks the C++ core against it. The WebAssembly parity harness reads the same file and checks the browser build against it. One source of truth, two consumers, and any drift shows up as a failing test naming the exact vector and the size of the discrepancy.

That the file is generated matters. Hand-written expected values encode whatever the author believed at the time; generated ones encode what the reference engine actually does, which is the thing the other engines are supposed to match.

It also creates an obvious hazard: if the goldens can be regenerated casually, they stop being a check and become a rubber stamp. So regenerating them requires a pull request labeled golden-update with a justification for why the math changed. The friction is deliberate. A test you can silence by re-recording is not a test.

The tolerance question

Three engines cannot agree exactly, and pretending otherwise produces a suite that fails for reasons unrelated to correctness.

The C ABI stores float32. The reference engine computes in JavaScript doubles. That difference alone puts a floor under any comparison: roughly seven significant decimal digits, and error accumulates through a chain of operations. The native conformance test therefore uses max(vector_tolerance, 2e-6) — the per-vector tolerance when it is looser, a float epsilon floor when it is not.

The WebAssembly harness uses the identical rule, and this is the part worth stating plainly: it uses the same floor because it is the same code compiled differently, not because 2e-6 was the number that made it pass. A tolerance chosen to make a test green is a tolerance that measures nothing. A tolerance derived from the storage format is a statement about what precision is available.

When the WASM parity harness was first written it reported 395 of 401, skipping the six spectral-integration vectors because that path takes an illuminant struct rather than a plain array. Six skipped vectors is a small gap, and small gaps are exactly the ones that grow. The struct is two words; passing it correctly took ten minutes; the count is now 401 of 401 with zero skipped. “Mostly covered” is a number that should always be followed by “why not all of it.”

Relations, not just values

Golden vectors check specific inputs. They cannot tell you the engine is right on inputs nobody recorded.

For that this lab runs metamorphic relations: properties that must hold for arbitrary inputs, without knowing the correct answer for any of them. Round-tripping through a color space and back must return where you started. A color difference must be symmetric, and zero exactly when the colors are identical. Scaling an illuminant must not change relative colorimetry. Simulating color vision deficiency at zero severity must be the identity.

Nine such relations run against both engines. They catch a different class of bug than vectors do — the ones that only appear in regions of input space nobody thought to record.

There is a subtlety worth flagging, because it bit this project. The relation runner originally chose which relation to test and then drew random inputs, meaning the choice of relation shifted the random stream. Comparing two engines then compared them on different inputs, and a correct implementation could fail. Now every input is drawn up front, before any relation is selected. The same stream-coupling mistake later appeared in the fuzzer, where a tool’s inputs depended on how many random draws earlier tools had consumed. Shared mutable random state is a recurring trap and it never announces itself.

What the redundancy actually buys

The C++ core once contained a macro that had never compiled — a single-parameter guard applied to bodies containing top-level commas. Every claim about the native engine at that point rested on a build that had never happened. The golden vectors are what turned “we believe these agree” into a statement with a number attached, and the WASM harness extended that from two engines to three.

Three implementations is more work than one. What it produces is a codebase where “the browser demo agrees with the paper” is a thing a test asserts, on every push, rather than a thing the author hopes.