Why the same calculation gives different numbers, and how to make it deterministic
The same calculation can give different numbers because floating-point arithmetic rounds after every operation, so the order of a sum, the compiler, the processor and the math library can all change the last digits. To make it deterministic, compute in integers at a fixed scale with a written rounding rule, fix the order of every reduction, and avoid platform math functions. Then serialize results canonically, hash them, and check the hashes against golden test vectors on every target you ship.
- Updated
- Reading
- 5 min
- The need
- Get exactly the same numeric result from the same inputs on every machine, language and build.
- Plain text
- /guides/deterministic-numbers.md
| Expression | Floating point gives | Integer fixed point gives |
|---|---|---|
0.1 + 0.2Neither 0.1 nor 0.2 has an exact binary form. | … | 100000 + 200000 = 300000 → 0.300000 |
(1e16 + 1) - 1e16The 1 is lost when it is added to a number this large. | … | integers: (10¹⁶ + 1) − 10¹⁶ = 1 |
1e16 - 1e16 + 1Same numbers, different order, different answer. | … | integers: 10¹⁶ − 10¹⁶ + 1 = 1 |
[0.1, 0.2, 0.3].reduce(+)Summing left to right. | … | 100000 + 200000 + 300000 = 600000 |
[0.3, 0.2, 0.1].reduce(+)Summing right to left gives a different float. | … | 300000 + 200000 + 100000 = 600000 |
#What the demo above shows
Most software stores numbers in IEEE 754 binary floating point. Neither 0.1 nor 0.2 has an exact binary form, so each is stored as the nearest value the format can hold, and their sum is rounded again. That is why 0.1 + 0.2 prints 0.30000000000000004.
Near 10^16, neighbouring double-precision numbers are 2 apart. In (1e16 + 1) - 1e16, the 1 is rounded away when it meets the large number, and the result is 0. In 1e16 - 1e16 + 1, the large numbers cancel first and the result is 1. Same numbers, different order, different answer: floating-point addition is not associative. The last two rows show the same on a list: 0.6000000000000001 from the left, 0.6 from the right.
A single operation is not the problem. IEEE 754 requires basic arithmetic to be exactly rounded, so one operation on the same inputs and precision repeats bit for bit. The drift comes from everything around it.
#Where the differences come from
- Order of operations. A sum over a hash map, a database query without
ORDER BYor a parallel reduction can add the same values in a different order each run. - Compiler choices. A compiler may turn
a * b + cinto one fused multiply-add that rounds once, not twice. GCC does so by default outside strict C standards modes (-ffp-contract), and-ffast-mathalso lets it reorder operations. - Extra precision. The x87 unit, GCC's default on most 32-bit x86 targets, keeps intermediates in 80 bits. A value is rounded to 64 bits when the compiler stores it to memory, so results can depend on register use.
- Math library functions. ECMAScript lets
Math.sin,Math.expand similar functions give an implementation-approximated result, so engines may disagree in the last bit. Rust documents the precision off64::sinas varying by platform and version. - WebAssembly corners. WebAssembly follows IEEE 754, but NaN signs and payloads are non-deterministic, and relaxed SIMD instructions can depend on the hardware.
- Rounding and division rules. Python's
round(2.5)gives2; JavaScript'sMath.round(2.5)gives3. Rust truncates-7 / 2to-3, while Python's-7 // 2floors to-4.
#How to make a calculation deterministic
1. Use integers at a fixed scale
Pick a unit and store every quantity as a whole number of it. In millionths, 0.1 is 100000 and addition is exact. Multiplication and division need a wider intermediate and one written rounding rule:
const SCALE: i128 = 1_000_000; // whole millionths
/// a × b at the same scale, rounding half away from zero.
fn mul_e6(a: i64, b: i64) -> i64 {
let p = a as i128 * b as i128;
let q = if p >= 0 { (p + SCALE / 2) / SCALE } else { (p - SCALE / 2) / SCALE };
i64::try_from(q).expect("overflow")
}
The rule matters less than writing it down and using it everywhere. Integer addition is associative, so the order of a sum stops mattering as long as no partial sum can overflow. Use checked operations: Rust panics on overflow in debug builds but may wrap silently in release builds.
2. Fix the order
Sort inputs by a stable key before any reduction that is not exact, and add ORDER BY to every query that feeds a calculation. For floats you cannot avoid, the latest ECMAScript adds Math.sumPrecise, which sums exactly and rounds once; not every engine ships it yet.
3. Own your math functions
Do not call the platform's sin, exp, log or pow where results must repeat. Ship your own integer approximations or tables, so every target runs the same steps.
4. Serialize canonically and hash
Two results are equal only if their bytes are. Serialize with a canonical form such as the JSON Canonicalization Scheme (RFC 8785): sorted keys, fixed number formatting, no extra whitespace. It limits numbers to what a double can hold, so larger integers go in strings. Hash the bytes and compare digests.
5. Test golden vectors on every target
Keep inputs and their expected output digests in the repository, and run them on every build for every target you ship: native, WebAssembly, each operating system and processor. A mismatch fails the build.
6. Make the build reproducible
Pin the compiler and every dependency, so anyone with the same source, environment and instructions can recreate bit-for-bit identical artifacts. A hash of the build then names the exact code behind a number.
#Common mistakes
- Rounding for display, then reusing the result. A value shown with two decimals is no longer the value you computed.
- Parsing amounts with
JSON.parse. Every JSON number becomes a double. Integers aboveNumber.MAX_SAFE_INTEGER(9007199254740991) lose precision; send them as strings and useBigInt. - Rounding in different places. Rounding each row on one screen and only the total on another gives two answers.
- Reading the clock or a random source inside the calculation. Pass time in as an input instead.
#Questions
Should I use a decimal type instead?
Decimal types such as Python's decimal hold 0.1 exactly but still round on division, so every target needs the same precision settings. Fixed-scale integers travel more easily between languages.
Do WebAssembly and native code give the same results?
Integer arithmetic matches when both sides use the same widths and overflow rules. Floats usually match too, except for NaN bits, relaxed SIMD and library functions. Golden vectors tell you for certain.
How many golden vectors do I need?
Enough to cover each rounding branch, both signs, zero, the largest allowed values and every overflow path. Choose them for edges, not volume.
#How Upvera does it
Upvera Value Engine 3.0 computes Upvera Value in integer fixed point, and a lint that runs on every build rejects floating-point arithmetic in the engine. The engine is written in Rust. Its WebAssembly build, which Upvera's servers run, and a native build of the same code must produce byte-identical outputs on a shared set of golden test vectors. Outputs are serialized as canonical JSON and hashed, so equality is a comparison of digests. The engine reads no clock and uses no randomness. Upvera Value is computed by Upvera and has no unit; it is not a price and not what a company is worth.
Receipts list each reason as an integer in whole millionths, so the rows add up exactly to the change they explain. The design is written up in the Value Engine report.