Research / Numerics
One sum, five engines, one answer
Five JavaScript engines disagree about the last bit of exp, log, sin and tan. We built the kernel from operations they cannot disagree about, and run all five.
Ask five JavaScript engines for the exponential of two thousand evenly spaced numbers between −30 and 30. Node, Chromium, Firefox, WebKit and workerd, the runtime behind Cloudflare Workers, do not all give the same answer. We count the points at which an engine returns different bits from Node.
| Engine | exp |
log |
sin |
tan |
|---|---|---|---|---|
| Chromium 156.0.8078.4 | 189 | 32 | 60 | 56 |
| Firefox 157.0 | 2 | 2 | 15 | 30 |
| WebKit 27.2 | 185 | 32 | 90 | 781 |
| workerd 1.20261023.1 | 189 | 32 | 60 | 56 |
Each count is out of 2,000 points, and each function has its own grid. The widest gap between any engine and Node, on these grids, is two units in the last place, which means that the two results are two doubles apart. Most gaps are one. Every engine ran on one Apple silicon Mac, in one run. Another processor or another build would give other counts, and that is the point. This is the file that GET / returns, and the platform page shows the same counts beside the receipt that each engine produced.
Nobody is wrong
IEEE 754 requires correctly rounded results for addition, subtraction, multiplication, division and square root. A correctly rounded result is the nearest representable number to the true one, so there is only one right answer and every conforming engine must give it. For exp, log and the trigonometric functions the standard recommends but does not require correct rounding. The JavaScript specification says that Math.exp returns an implementation-approximated value. Each engine ships its own library, and the libraries are not the same.
That is harmless for most programs and fatal for ours. A receipt is a hash of the bits of every number in a calculation. A result that differs in its last bit has a different receipt. A receipt that depends on which browser opened the page proves nothing.
The rule
The kernel uses only operations that every engine must perform identically: addition, subtraction, multiplication, division and square root, and arithmetic on the bits of a number. Anything else it builds from those. Here is exp, as it stands in the kernel.
export function exp(x: number): number {
if (Number.isNaN(x)) return Number.NaN;
if (x > 709.782712893384) return Number.POSITIVE_INFINITY;
if (x < -745.1332191019411) return 0;
const k = Math.round(x * INV_LN2);
const r = x - k * LN2_HI - k * LN2_LO;
let p = 1;
for (let n = 14; n >= 1; n--) p = 1 + (r / n) * p;
return ldexp(p, k);
}
It writes x as k × ln 2 + r, where k is a whole number and r is small. The high part of ln 2 has trailing zero bits, so multiplying it by k loses nothing, and the low part carries the rest. The series for the exponential of r, summed from the inside out in a fixed order, converges within fourteen terms because r never exceeds half of ln 2. The result is then scaled by 2 to the power k, which ldexp does by building that power of two exactly. ln follows the same pattern.
This costs accuracy. The kernel’s exp and ln stay within three units in the last place of the platform’s own functions over generated inputs, which is statement KE-01 on the verification page. The best libraries are better than that. We trade a bit or two of accuracy for the same bits everywhere. For a capital figure that is the right trade, because the inputs are not known to sixteen digits and the answer has to be the same each time it is asked for.
The rest of the care
Replacing the library functions is the visible part. The rest is discipline.
- Every sum has a fixed order. Adding the same numbers in another order gives other bits, so a sum over a portfolio is always taken in the order of the input.
- JavaScript evaluates each operation on its own and rounds it. A compiler for another language is free to fuse a multiplication and an addition into one operation that rounds once, which gives other bits. A port of the kernel to C or C++ has to switch that off.
- Where an answer must be a whole number, the kernel does not trust a division that rounds. The repetition count cutoff in the entropy tests is one plus the false positive exponent divided by the claimed entropy, rounded up. The kernel does that division on integers of any size, using the exact value of the double that holds the claim, so a rounded quotient cannot land on the wrong side of a whole number.
What we check
The parity run executes twenty-four fixtures on all five engines and compares the receipts. The fixtures run the following.
- A grid of
expandlnover almost the whole range. - The cube root and the sine and cosine of whole degrees.
- A stream of random numbers.
- The interest rate capital charge of a book and the definiteness of its correlation matrices.
- The capital of an insurer under Solvency II and an exact certificate for its correlation matrix.
- The risk class of a fund under PRIIPs.
- The cutoffs and the health runs of the entropy tests.
- Two vehicle calculations.
- The key and signature sizes of the post-quantum standards with a migration plan.
- The plume of a stack in every class of the air and the design values of five years of illustrative weather.
- The rounding of the air quality rules.
- The chain ladder and the standard error of Mack on six triangles.
- The Smith-Wilson curve of the supervisor’s own worked example, and the closed form that replaces it from 30 January 2027 with the rule that moves the ultimate forward rate from year to year.
- The capital for credit valuation adjustment risk of seven books and of the first one at eight sizes of its hedges.
- The capital of ten insurance groups under the standard method of the ICS, with its two spread rows, its tax rules and its limits on capital.
- The delta margin of the initial margin model on the three books of the plate at three sizes each and at the thresholds of its concentration rule.
- The reconciliation of three pairs of portfolios.
- The rounding that makes shares add up.
For every fixture all five engines give the same receipt, and the receipt over all twenty-four begins 127dcc3b. The run fails if one bit differs. That is statement KE-03.
The same file counts how often each engine’s own library disagrees with Node. Those counts are the table at the top. We keep them in the file as a reminder of what the kernel is avoiding. Chromium and workerd both build on V8, as Node does, and both differ from Node at 189 points. We did not look for the cause, because finding it would not change what we do.
What we have not checked
- Five engines, one machine, one run. A different processor, such as x86-64, or a later version of an engine, has not been tried. The run is evidence, not a proof, and KE-03 is labelled tested for that reason.
- Twenty-four fixtures do not cover every path through the kernel. A test checks that the run loads every module of the kernel except one file of types, which is the least it can do. It does not say how much of each module the fixtures use.
- We have not tried a port to another language. The documentation shows how to check a receipt with Python and the standard library, which does not need any of the arithmetic above. Reproducing a result means redoing it, and that needs the care in the list.