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
stdx.sin(), x.cos(), x.ln(), x.tan()0xb1e7612ea4083f410xe263278e1905d527
libmlibm::sinf(), …0xe263278e1905d5270xe263278e1905d527

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:

projectfeaturewhat it switches to
Bevybevy_math/libmrust-lang/libm via glam
Rapierenhanced-determinismsimba/libm_force → rust-lang/libm
Avianenhanced-determinismdep: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
tanf615/400015.4%
expf405/400010.1%
exp (f64)391/40009.8%
powf374/39999.4%
asinf181/20009.1%
sin (f64)112/40002.8%
logf84/39992.1%
sqrtf0/39990.0%
atanf0/40000.0%
total2320/459965.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:

That second bullet does not generalise, and an earlier draft of this page wrongly implied it did. Widening the battery finds a real change:

cbrt changed 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”:

powi powf log log2 log10 ln ln_1p exp exp2 exp_m1 cbrt hypot sin cos tan sin_cos asin acos atan atan2 sinh cosh tanh asinh acosh atanh

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 calls871 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 pins0 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:

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:

  1. 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.
  2. 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_add and 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:

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:

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

jobwhat it gates
determinismthe golden forward-pass logit CID and the conformance vectors, on x86-64 and arm64
determinism invariant guardfails the build on mul_add, rayon, fast-math, or any libm transcendental on the forward path
determinism-wasmbuilds 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-enginesruns the same probe under V8 at each JIT tier and JavaScriptCore, and requires at least two independent engines to agree
embeddedverse-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:

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.