Determinism
Replay-verification is sound only if the answer cannot drift. If an independent verifier re-runs a capsule and gets different bits for an innocent reason, an honest provider is falsely convicted and a liar can hide behind “nondeterminism”. So determinism is not a nice property of Verse — it is the precondition the entire trust model rests on.
The problem, in one command
cd verse-node && bash scripts/prove-std-diverges.sh
It builds two throwaway crates in a temp directory — nothing from Verse, so you needn’t trust the project making the claim — and compiles the same Rust source twice, native and to wasm32:
| native (platform libm) | wasm32 | |
|---|---|---|
std — x.sin(), x.cos(), x.ln(), x.tan() | 0xb1e7612ea4083f41 | 0xe263278e1905d527 |
libm — libm::sinf(), … | 0xe263278e1905d527 | 0xe263278e1905d527 |
Plain std diverges. Nothing is broken: on a native target x.sin() is your platform’s libm
(glibc, Apple’s, MSVC’s); on wasm32 there is no platform libm, so the same call is served by Rust’s own
software implementation. Two implementations of sin agree to within an ULP — and an ULP is a desync.
This is not hypothetical for the Rust game ecosystem. Bevy, Rapier and Avian all expose determinism as
an opt-in feature that reroutes transcendentals to rust-lang/libm:
| project | feature | what it switches to |
|---|---|---|
| Bevy | bevy_math/libm | rust-lang/libm via glam |
| Rapier | enhanced-determinism | simba/libm_force → rust-lang/libm |
| Avian | enhanced-determinism | dep:libm + bevy_math/libm |
Opt-in means the divergent row is the default. A project shipping a native client and a web client with lockstep or rollback netcode is running two different maths until someone turns the feature on — and nothing tells them.
How far apart are they?
std versus rust-lang/libm, same inputs, measured over 45,996 values:
| differing | ||
|---|---|---|
tanf | 615/4000 | 15.4% |
expf | 405/4000 | 10.1% |
exp (f64) | 391/4000 | 9.8% |
powf | 374/3999 | 9.4% |
asinf | 181/2000 | 9.1% |
sin (f64) | 112/4000 | 2.8% |
logf | 84/3999 | 2.1% |
sqrtf | 0/3999 | 0.0% |
atanf | 0/4000 | 0.0% |
| total | 2320/45996 | 5.0% |
sqrtf is exactly zero because IEEE-754 mandates square root be correctly rounded — there is one
legal answer and every implementation must give it. The functions that disagree are precisely the ones
the standard does not pin. That split is the whole argument, measured rather than asserted.
Is libm itself safe to rely on?
Tested, because the claim rests on it and nobody had published an answer:
- Across engines: identical. Native x86_64, V8 and JavaScriptCore all produce
0x1cd045fd19cdd79dover a 4,800-vector battery of the twelve functions a rigid-body solver calls. - Across versions: identical across nine releases (0.2.6 → 0.2.16) — for those twelve functions.
That second bullet does not generalise, and an earlier draft of this page wrongly implied it did. Widening the battery finds a real change:
cbrtchanged output bits between libm 0.2.11 and 0.2.12 — 1,634 of 20,000 inputs (8.17%) — and the new values persist through 0.2.16.
Rapier depends on libm = "0.2", a caret requirement. A cargo update therefore moves you across that
boundary silently. The mitigation costs nothing and is already standard practice: commit your
Cargo.lock. A conformance battery is not needed to fix this; it is needed only to notice it, and
only if you are not pinning.
So the honest summary is: libm is stable across engines by construction, and mostly stable across
versions, with at least one shipped counter-example. A battery over libm would have caught cbrt and
nothing else in what was tested. Its more useful job is the other row above — telling you whether the
code you shipped is actually routed through libm at all, on every target, on every CI run.
Which functions are actually at risk — Bevy has already enumerated them
You do not have to guess at the determinism-critical surface. Bevy’s
clippy.toml bans 27 f32 methods in
its own codebase, 26 of them with the reason string “for libm determinism”:
powipowfloglog2log10lnln_1pexpexp2exp_m1cbrthypotsincostansin_cosasinacosatanatan2sinhcoshtanhasinhacoshatanh
f32::sqrt is deliberately absent. Bevy’s own ops docs explain why: sqrt “is specified by
IEEE 754 as squareRoot and guaranteed not to change”. That is the same line the measurements above
land on from the other side — sqrtf at 0.0% divergence, tanf at 15.4%.
Note the convergence with the version finding: cbrt and hypot are both on Bevy’s list, and
cbrt is precisely the function whose bits moved between libm 0.2.11 and 0.2.12. The risk is small,
but it is not theoretical, and it lands on a function a major engine has explicitly routed through libm
for determinism.
The JS side, measured in 10 seconds
cd verse-node
node scripts/js-math-probe.mjs --save /tmp/v8.txt # V8
bun scripts/js-math-probe.mjs --compare /tmp/v8.txt # JavaScriptCore
No install, no build; it also pastes into a browser devtools console. Same inputs, same machine:
| V8 vs JavaScriptCore | |
|---|---|
JS Math.* — what everyone actually calls | 871 of 16,806 values differ (5.2%): Math.pow 9.3%, Math.exp 9.1%, Math.log 4.4%, Math.tan 3.0%, Math.cos 2.7%, Math.sin 2.6% |
det_math — built only from operations IEEE-754 pins | 0 of 255, identical under wasmtime/Cranelift, V8 at each JIT tier, and JavaScriptCore |
Neither engine is buggy. ECMAScript explicitly declares Math.sin, cos, tan, exp, log and pow
implementation-approximated — engines may pick any approximation and remain conformant, so this
divergence is permanent by design. Rapier’s JavaScript documentation says the same thing about its own
users: “transcendental functions like Math.sin, Math.cos are not cross-platform determinism and may
give different results on different platforms.”
The fix is not to test harder. It is to stop calling Math.* on the path that has to reproduce.
The part IEEE-754 does not cover
IEEE-754 requires + − × ÷ √ and round-to-nearest-even to be correctly rounded: on every conforming
machine there is exactly one legal answer, so any function built only from those operations is
reproducible by construction.
It says nothing about exp, ln, sin, cos, pow. Those are whatever the platform’s libm chose,
and they differ in the last ULPs between x86 and ARM, between libm versions, and between compilers. That
is why cross-platform replays and lockstep simulations desync on trigonometry and essentially never on
arithmetic.
det_math (verse-node/src/det_math.rs) implements the elementary functions Verse’s floating-point
capsules need — det_exp, det_sin, det_cos, det_exp_f64, det_ln_f64, det_softmax — from only
the correctly-rounded core operations, plus exact exponent-bit manipulation. No f32::exp, no .sin(),
no .powf() anywhere on the forward path, and CI fails the build if anyone introduces one.
This is not a new idea, and Verse is not a better libm
It should be said plainly, because the opposite is often implied:
- rust-lang/libm (MIT,
no_std, ~200 functions) is what the Rust ecosystem actually uses for this — Rapier, Avian and Bevy all route their transcendentals through it. It is far larger and better maintained than anything here. (It publishes no golden vectors and no cross-target reproducibility guarantee, which is a gap in the evidence, not a reason to preferdet_mathas a library.) - SLEEF ships deterministic
_cinz_/_finz_variants under BSL-1.0 and is used inside Unity’s Burst. - CORE-MATH (MIT, INRIA) is landing correctly-rounded functions directly into glibc, function by function. Correct rounding is determinism.
- Rapier already claims x86/ARM/WASM bit-identity under Apache-2.0 via
enhanced-determinism.
If you need a general deterministic math library, use one of those.
What the standard already guarantees — and what it does not
Before claiming anything, subtract what you get for free. WebAssembly’s core floating point is
deterministic by design: f32/f64 add, sub, mul, div, sqrt, the comparisons, nearest, copysign,
abs, neg, and the conversions are pinned by the spec to a single value on every conforming engine.
Subnormals are mandatory; flush-to-zero is illegal. The documented carve-outs are narrow — NaN payload
bits, resource exhaustion, host functions, and relaxed-SIMD — and Verse’s runtime closes all of them
(NaN canonicalisation on, relaxed-SIMD off, threads off, fuel metering, four curated host functions, no
WASI linked at all).
So “we proved wasm arithmetic is deterministic” would be claiming the spec’s own work. It isn’t the contribution, and saying it is invites a knowledgeable reader to dismiss everything else.
You will also meet this sentence from the WebAssembly 3.0 announcement:
“Between platforms choosing to implement this deterministic execution profile, Wasm thereby is fully deterministic, reproducible, and portable.”
That is true and it is narrower than it sounds. The deterministic profile is opt-in; no engine ships
it as one named switch (wasmtime approximates it with cranelift_nan_canonicalization and
relaxed_simd_deterministic, both defaulting to false); it does not cover memory.grow or threads;
and — the part that matters here — it says nothing about exp or sin, because those are not
WebAssembly instructions. They are library code compiled into your module.
That leaves exactly two things the standard does not cover, and they are what this page is about:
- The composed transcendentals. Not instructions, so no wasm test suite covers them, no engine conformance run reports on them, and no differential fuzzer generates them. Outside the standard permanently, by construction.
- Native ↔ wasm agreement, where the native compiler is free to contract a multiply-add into an
FMA or carry x87 excess precision. That is exactly what Verse’s CI invariant guard on
mul_addand fast-math exists to prevent.
What is actually missing: the evidence
Be precise about what is not missing, because each of these is one link away:
- The official WebAssembly test suite ships thousands of exact float assertions with golden values.
- wasmtime’s
differentialfuzz target continuously compares Wasmtime against the spec interpreter, wasmi, V8, Winch and Pulley — byte-for-byte on results, globals and memory. - WADIFF (ASE 2023) and WASMaker (ISSTA 2024) already published cross-runtime inconsistency results academically.
All of that covers instructions. What almost nobody publishes, for the composed functions, is a way to check the claim on your own target matrix:
- Rapier’s determinism docs require that “the target platforms must strictly comply to the IEEE
754-2008 floating-points standard… This include most modern mainstream processors as well as WASM
targets” — and ship no vectors, no digests and no CI matrix with which to check that. Their
JavaScript docs concede the precise gap: “transcendental functions like
Math.sin, Math.cosare not cross-platform determinism and may give different results on different platforms.” - Bevy’s determinism audit has listed “checksum-based desync detection tools” as an unbuilt action item since 2021.
- SLEEF’s own tracker contains a case where the same input returns a 1-ULP-different result depending on
what is in the other SIMD lanes; the resolution was “use
_finz_”, and the reporter asked for it to at least be documented. - WebAssembly 3.0’s deterministic profile standardises NaN canonicalisation and relaxed-SIMD. Transcendentals are not wasm instructions at all, so the standard is silent on exactly these functions — and wasmtime’s own documentation disclaims cross-engine and cross-version determinism.
So Verse publishes the evidence.
verse det — the conformance harness
verse det digest # the golden + conformance digests for THIS build, on THIS engine
verse det vectors # the published battery: (function, input bits, output bits)
verse det vectors --json # same, machine-readable
verse det check --vectors det-conformance-vectors.txt
det check recomputes the battery and diffs it. Exit 0 means identical; exit 1 means drift — and it
names the function and the exact input bits that moved, rather than only telling you a digest changed:
✗ vector 79 (det_sin): expected `f1 000000003fe66667 000000003f794e14`,
this engine computed `f1 000000003fe66667 000000003f794e15`
✗ 1 of 255 conformance vectors DRIFTED on this engine.
The battery is 255 vectors across five functions — 51 arithmetically-spaced points each.
det-conformance-vectors.txt is committed as plain text so it diffs cleanly in a pull request and parses
in five lines of any language.
Be precise about its depth, because it is shallower than a conformance suite ought to be. The
det_exp points run to ±87.5, which crosses the flush-to-zero cutoff (−87.34) but stays below the
overflow cutoff (88.72284). There are no NaN, no infinity and no subnormal values anywhere in the
battery, and every vector except one takes the ordinary non-special-case path. For comparison, CosmWasm’s
float battery deliberately generates 25% NaN / 25% subnormal / 25% infinity inputs.
That constraint is deliberate and enforced by a test. The vector format compares output bits exactly,
and the WebAssembly spec leaves the sign of a generated NaN non-deterministic — two bit patterns are
legal for the same input on two conforming engines. The official spec testsuite handles this with
nan:canonical / nan:arithmetic / either result patterns that mask the sign before comparing; this
format has no such escape hatch yet. So widening the range into a domain-error branch would turn the
harness into a false-drift generator. Result patterns first, then more coverage.
det_math::conformance_vector(i) is index-addressed and allocation-free, so the identical call runs
natively, inside a no_std wasm capsule, and on a Cortex-M. That is the whole point: a conformance vector
only a full host can recompute proves nothing.
Put it in your CI
- run: verse det check --vectors det-conformance-vectors.txt
Run it on every target you claim to support — x86-64, arm64, and under a wasm engine — and a silent numerics regression becomes a red build instead of a desync report six months later.
What Verse’s own CI proves
| job | what it gates |
|---|---|
determinism | the golden forward-pass logit CID and the conformance vectors, on x86-64 and arm64 |
determinism invariant guard | fails the build on mul_add, rayon, fast-math, or any libm transcendental on the forward path |
determinism-wasm | builds the same det_math source into a no_std wasm capsule, runs it under wasmtime, and asserts the golden digest and all 255 vectors match native |
determinism-engines | runs the same probe under V8 at each JIT tier and JavaScriptCore, and requires at least two independent engines to agree |
embedded | verse-core compiles for thumbv7em-none-eabi, riscv32imc, and wasm32 |
Reproduce it yourself:
cd verse-node
bash scripts/prove-det-wasm.sh # native ↔ wasm, under wasmtime
bash scripts/prove-det-engines.sh # + V8 (each tier) and JavaScriptCore
Measured result: 0xb87421e672bdf23a and all 255 vectors identical under wasmtime/Cranelift, V8 (Liftoff
only, TurboFan only, and default tier-up) and JavaScriptCore — three independently-written engines.
The probe is a zero-import wasm module, so nothing about this is Verse-specific: any engine with the
WebAssembly JS API can run scripts/det-engine-probe.mjs with no install step. It refuses to run against
a probe that declares imports (a probe with imports could be fed its answers by the host) or one missing
exports (a stale artifact would otherwise print a narrower pass as if it were the full one).
One honest caveat, since a skeptic will ask what the test has ever caught: det_math is built only from
operations the spec mandates as exactly rounded, so cross-engine agreement is close to a priori and the
first run was clean. The harness earns its keep by catching regressions in det_math and engine
bugs in spec-mandated territory — not by discovering that arithmetic drifts.
The boundary — portable vs pinned
Be precise about what this does and does not make browser-verifiable. They are two different things:
- Portable. The receipt envelope check (canonical DAG-CBOR + ed25519 + BLAKE3 output hash + name
binding) runs anywhere, including a browser — that is the
verse-verifySDK insdk/. Anddet_mathis bit-identical across x86, ARM and a wasm engine, proven in CI. - Pinned. Full capsule replay — re-executing a WASM capsule to convict a liar — is bound to the exact
wasmtime build via
RUNTIME_TAG. A different engine abstains: it declines to replay rather than risk convicting an honest provider.
So the floating-point math a browser or microcontroller needs is proven portable. A full re-executing referee is still engine-pinned. Anyone telling you otherwise is selling something.