open-standards/wai/wai-rs/src/float_repro.rs
Transaction Science f96ce04da3
Some checks failed
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 1m48s
standards workspaces build + test / aac-rs (pull_request) Successful in 36s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 47s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 39s
standards workspaces build + test / map-rs (pull_request) Successful in 40s
standards workspaces build + test / mesh-rs (pull_request) Successful in 32s
standards workspaces build + test / openpay (pull_request) Successful in 1m50s
wai cross-architecture byte-equality / linux-aarch64 (pull_request) Successful in 21s
WAI conformance / cargo-class corpora · linux-arm64 (pull_request) Successful in 1m0s
WAI conformance / cross-arch byte-equality · linux-arm64 (pull_request) Successful in 14s
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 3s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m52s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m21s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m16s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m10s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m30s
wai cross-architecture byte-equality / linux-x86_64 (pull_request) Successful in 16s
WAI conformance / cargo-class corpora · linux (pull_request) Successful in 1m13s
WAI conformance / cross-arch byte-equality · linux (pull_request) Successful in 14s
WAI conformance / C2PA manifest emission · signed, read back, validated (pull_request) Successful in 40s
wai formal verification (integer core) / kani (pull_request) Successful in 3m59s
WAI neural decoders end-to-end / synthetic fixtures → Rust decode → reference (±4) (pull_request) Failing after 1m8s
WAI neural sink gates / model-free refusals of the neural sink (pull_request) Successful in 27s
standards workspaces build + test / arl-rs (pull_request) Successful in 2m54s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 2m19s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 2m45s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 2m26s
standards workspaces build + test / sense-rs (pull_request) Successful in 2m15s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Failing after 2m23s
wai cross-architecture byte-equality / macos-arm64 (reference) (pull_request) Successful in 13s
WAI conformance / cargo-class corpora · macos (pull_request) Successful in 2m30s
WAI conformance / cross-arch byte-equality · macos (pull_request) Successful in 35s
wai cross-architecture byte-equality / windows-x86_64 (pull_request) Has been cancelled
WAI conformance / cargo-class corpora · windows (pull_request) Has been cancelled
WAI conformance / cross-arch byte-equality · windows (pull_request) Has been cancelled
wai-rs: the JPEG AI exact decoder calls no platform math library, and its output is pinned
The exact decoder's only math-library calls were `exp` in the gain scaler
and, on some targets, the float-to-integer roundings. Those results could
differ between platform math libraries, so the decoded bytes could differ
between machines.

- `float_repro` provides `exp` and the roundings built only from IEEE-754
  basic operations, so their bits are the same on every conforming target.
  Its unit tests pin their bits over a fixed grid of inputs and compare them
  with the host's own functions.
- Two guards keep the decode path free of math-library calls. Clippy's
  `disallowed_methods` (wai-rs/clippy.toml) is denied in each module on the
  path, however the call is spelled. tests/jpegai_decode_symbols.rs fails if
  the linked decoder imports a C math-library function.
- tests/jpegai_cross_platform.rs pins the decoded bytes of the `jpegai/v1`
  inputs and the bits of the float values reported after each stage. They
  were measured identical on macOS arm64, wasm32-wasip2, Linux arm64 (glibc)
  and Linux x86_64 (glibc, under user-mode emulation). Not measured: macOS
  x86_64, Windows, x86_64 hardware, 32-bit targets. The test skips unless its
  inputs are supplied.
- The gain-scaler margin tool now models the reference software's float32
  arithmetic and corrects `log_k`.
- determinism-tiers' evidence table records the measurement and its limits.
  The registry class of `wai.image.jpegai` is unchanged (NeuralEntropyExact).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 18:30:35 -04:00

275 lines
12 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Float functions whose results are fixed by this source, not by the platform.
//!
//! IEEE-754 requires addition, subtraction, multiplication, division, square root and
//! the conversions to be correctly rounded, so they give the same bits for the same
//! operands on every conforming target. A platform math library's transcendental
//! functions (`exp`, `ln`, `pow`, `tanh`, ...) are not held to that, and their last
//! bits differ between libraries. Rounding to an integer (`round`,
//! `round_ties_even`) is exactly specified, but whether it compiles to an instruction
//! or to a call depends on the target. With rustc 1.98, baseline x86_64 compiles
//! `f64::round`, `f32::round` and `f64::round_ties_even` to calls to the symbols
//! `round`, `roundf` and `rint`, and wasm32 compiles the first two to calls to `round`
//! and `roundf`; AArch64 uses instructions. What answers such a call is settled when
//! the executable is linked, not by the platform's math library as such: the x86_64
//! Linux test executable inspected defined `round`, `roundf` and `rint` itself, as weak
//! symbols from Rust's compiler builtins, and imported only `exp` and `expf` from the C
//! library. The compiler builtins for wasm32-wasip2 define the same three; which
//! definition a wasm32 link takes was not checked.
//!
//! The functions here use only those basic operations, integer conversions and bit
//! manipulation, evaluated in the order written. Rust neither fuses a multiply and an
//! add into one rounding nor reassociates unless the source asks for it, so each
//! function returns the same bits on every target that evaluates at the operand's own
//! precision (x86_64, AArch64, wasm32; not x87) in the IEEE-754 default
//! floating-point environment (round to nearest even, subnormals kept). The JPEG AI
//! exact decoder ([`crate::jpegai_decode`]) uses them so that no platform math library
//! is on its path.
// On the JPEG AI exact decode path: no platform math-library call (see clippy.toml).
#![deny(clippy::disallowed_methods)]
/// 2⁵²: every `f64` of at least this magnitude is an integer.
const TWO_52: f64 = 4_503_599_627_370_496.0;
/// 2²³: every `f32` of at least this magnitude is an integer.
const TWO_23: f32 = 8_388_608.0;
/// `x` rounded to the nearest integer, ties away from zero: the value `f64::round`
/// returns, including the sign of a zero result.
pub fn round_half_away(x: f64) -> f64 {
if x.is_nan() || x.abs() >= TWO_52 {
return x; // NaN, an infinity, or already an integer
}
let t = x as i64; // toward zero; exact, since |x| < 2^52
let d = x - t as f64; // exact (Sterbenz): the fraction, in (−1, 1)
let r = if d >= 0.5 { t + 1 } else if d <= -0.5 { t - 1 } else { t };
(r as f64).copysign(x)
}
/// `x` rounded to the nearest integer, ties to even: the value `f64::round_ties_even`
/// returns, including the sign of a zero result.
pub fn round_half_even(x: f64) -> f64 {
if x.is_nan() || x.abs() >= TWO_52 {
return x;
}
let t = x as i64;
let d = x - t as f64;
let odd = t % 2 != 0;
let r = if d > 0.5 || (d == 0.5 && odd) {
t + 1
} else if d < -0.5 || (d == -0.5 && odd) {
t - 1
} else {
t
};
(r as f64).copysign(x)
}
/// [`round_half_away`] for `f32`: the value `f32::round` returns.
pub fn round_half_away_f32(x: f32) -> f32 {
if x.is_nan() || x.abs() >= TWO_23 {
return x;
}
let t = x as i32; // exact, since |x| < 2^23
let d = x - t as f32; // exact: the fraction, in (−1, 1)
let r = if d >= 0.5 { t + 1 } else if d <= -0.5 { t - 1 } else { t };
(r as f32).copysign(x)
}
/// `2^e` for `−1022 ≤ e ≤ 1023`, exact, from its exponent bits.
fn pow2(e: i32) -> f64 {
debug_assert!((-1022..=1023).contains(&e));
f64::from_bits(((e + 1023) as u64) << 52)
}
/// ln 2 split so that `k · LN2_HI` is exact for |k| < 2²¹ (its low 21 significand bits
/// are zero); `LN2_HI + LN2_LO` is ln 2 to about 10⁻²⁶.
const LN2_HI: f64 = f64::from_bits(0x3FE6_2E42_FEE0_0000);
const LN2_LO: f64 = f64::from_bits(0x3DEA_39EF_3579_3C76);
/// `1/n!` for `n = 1..=13`, each correctly rounded by the compiler.
const INV_FACT: [f64; 13] = [
1.0,
0.5,
0.16666666666666666,
0.041666666666666664,
0.008333333333333333,
0.001388888888888889,
0.0001984126984126984,
2.48015873015873e-05,
2.7557319223985893e-06,
2.755731922398589e-07,
2.505210838544172e-08,
2.08767569878681e-09,
1.6059043836821613e-10,
];
/// `e^x`, from basic operations only.
///
/// Range reduction `x = k·ln2 + r` with `|r| ≤ ln2/2` (the first subtraction is exact,
/// by the split of ln 2), then `e^r = 1 + r·p(r)` with `p` the degree-12 Taylor
/// polynomial of `(e^r − 1)/r` in Horner form, then an exact scaling by `2^k`. On the
/// reduced range, truncating the series leaves the polynomial within 1.4·10⁻¹⁷
/// relative of `(e^r − 1)/r`, and `1 + r·p(r)` within 5.8·10⁻¹⁸ relative of `e^r` (both
/// at `r = −ln2/2`), far below one rounding (1.1·10⁻¹⁶). So the result's error is a
/// few rounding errors; a test bounds it against the host math library. It is not
/// correctly rounded, and it need not be: what it guarantees is the same bits on every
/// target.
pub fn exp(x: f64) -> f64 {
if x.is_nan() {
return x;
}
if x > 709.782_712_893_384 {
return f64::INFINITY; // above ln(f64::MAX)
}
if x < -745.133_219_101_941_2 {
return 0.0; // below ln of half the least subnormal
}
let k = round_half_away(x * std::f64::consts::LOG2_E); // x/ln2; |k| ≤ 1075
let r = (x - k * LN2_HI) - k * LN2_LO;
let mut p = INV_FACT[12];
for &c in INV_FACT[..12].iter().rev() {
p = p * r + c;
}
let er = 1.0 + r * p; // in [0.70, 1.42]
let k = k as i32;
if k > 1023 {
er * 2.0 * pow2(k - 1) // k ≤ 1024: one rounding, which may overflow to ∞
} else if k < -1022 {
er * pow2(k + 1000) * pow2(-1000) // exact, then one rounding into the subnormals
} else {
er * pow2(k)
}
}
#[cfg(test)]
#[allow(clippy::disallowed_methods, reason = "the tests compare with the standard library's functions")]
mod tests {
use super::*;
/// Deterministic sample of f64 bit patterns (an LCG), for sweeps.
fn lcg(seed: u64) -> impl Iterator<Item = u64> {
let mut s = seed;
std::iter::from_fn(move || {
s = s.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
Some(s)
})
}
/// The distance in units in the last place between two finite f64 of one sign.
fn ulps(a: f64, b: f64) -> u64 {
(a.to_bits() as i64 - b.to_bits() as i64).unsigned_abs()
}
fn same(a: f64, b: f64) -> bool {
a.to_bits() == b.to_bits() || (a.is_nan() && b.is_nan())
}
fn edge_values() -> Vec<f64> {
let mut v = vec![0.0, -0.0, 0.5, -0.5, 1.5, -1.5, 2.5, -2.5, 0.49999999999999994,
-0.49999999999999994, 4503599627370495.5, -4503599627370495.5, 2251799813685247.5,
TWO_52, -TWO_52, f64::MAX, f64::MIN, f64::MIN_POSITIVE, f64::from_bits(1),
-f64::from_bits(1), f64::INFINITY, f64::NEG_INFINITY, f64::NAN];
for i in -4000..=4000 {
v.push(i as f64 / 8.0);
}
v
}
#[test]
fn rounding_matches_the_exactly_specified_std_functions() {
let mut xs = edge_values();
xs.extend(lcg(7).take(200_000).map(f64::from_bits));
// and values with a small fraction part, where the ties are
xs.extend(lcg(11).take(200_000).map(|b| (b >> 11) as f64 / 64.0 - 70_368_744_177_664.0));
for &x in &xs {
assert!(same(round_half_away(x), x.round()), "round_half_away({x:e})");
assert!(same(round_half_even(x), x.round_ties_even()), "round_half_even({x:e})");
let xf = x as f32;
let (a, b) = (round_half_away_f32(xf), xf.round());
assert!(a.to_bits() == b.to_bits() || (a.is_nan() && b.is_nan()), "round_half_away_f32({xf:e})");
}
}
/// Accuracy against the host math library, over every input the JPEG AI gain
/// scaler can see (`n · GAIN_LOG_K / 128` for |n| ≤ 8192, which covers every
/// gain-table entry with every 12-bit β) and a sweep of the whole finite range. This
/// bounds the error; it is not what makes `exp` portable (that is its construction,
/// pinned below).
///
/// The assertion is about the host's `exp` as much as about this one. The gate below
/// selects by target: the test runs on macOS arm64, on Linux arm64 and x86_64 with
/// glibc, and on WASI, and is ignored, with the reason, everywhere else, Windows
/// included; the pinned test below still runs there. A target is not a host. The
/// gate cannot see the C library's version or the processor, so the test also runs
/// against glibc versions and x86_64 processors that were not measured. Which hosts
/// were measured, and the worst case on each, is in `extensions/determinism-tiers.md`
/// §6. A failure on a host not listed there says that its `exp` and this one are more
/// than 2 ULPs apart, not that this module's bits changed; the pinned test checks
/// those.
#[test]
#[cfg_attr(not(any(
all(target_os = "macos", target_arch = "aarch64"),
all(target_os = "linux", target_env = "gnu", any(target_arch = "x86_64", target_arch = "aarch64")),
all(target_arch = "wasm32", target_os = "wasi"),
)), ignore = "the host math library's exp has not been measured on this platform; \
float_repro's pinned-output test covers portability here")]
fn exp_is_within_two_ulps_of_the_host_math_library() {
let mut worst = (0u64, 0.0f64);
let mut check = |x: f64| {
let (a, b) = (exp(x), x.exp());
if b == 0.0 || b.is_infinite() || b < f64::MIN_POSITIVE {
assert!(a == b || (b < f64::MIN_POSITIVE && ulps(a, b) <= 2), "exp({x:e}) = {a:e}, host {b:e}");
return;
}
let u = ulps(a, b);
if u > worst.0 {
worst = (u, x);
}
};
for n in -8192i64..=8192 {
check(n as f64 * crate::jpegai_pipeline::GAIN_LOG_K / 128.0);
}
for i in -48_000i64..=45_440 {
check(i as f64 / 64.0);
}
for b in lcg(3).take(300_000) {
check((b >> 11) as f64 / (1u64 << 53) as f64 * 1454.9 - 745.1);
}
eprintln!("float_repro::exp vs host exp: worst {} ULP(s), at x = {:e}", worst.0, worst.1);
assert!(worst.0 <= 2, "exp differs from the host by {} ULPs at x = {:e}", worst.0, worst.1);
assert!(same(exp(0.0), 1.0) && same(exp(f64::NAN), f64::NAN));
assert_eq!(exp(710.0), f64::INFINITY);
assert_eq!(exp(-746.0), 0.0);
}
/// The portability claim itself: these functions' bits over a fixed grid, hashed
/// and pinned. It needs no model or input file, so it runs wherever the library
/// tests run: the conformance workflow's library-test step runs it on each leg
/// (Linux x86_64, Linux arm64, macOS arm64, Windows x86_64), and a target that
/// computes any of these functions differently fails here.
#[test]
fn outputs_are_pinned_bit_for_bit() {
let mut h = blake3::Hasher::new();
// A fixed grid, not the gain scaler's inputs: the factor is the gain unit's
// `log_k` literal from before its correction, kept so the pinned hash still
// names the same computation.
for n in -8192i64..=8192 {
h.update(&exp(n as f64 * 0.200_365_493_161_539_4 / 128.0).to_bits().to_le_bytes());
}
for i in -48_000i64..=45_440 {
h.update(&exp(i as f64 / 64.0).to_bits().to_le_bytes());
}
for b in lcg(3).take(100_000) {
h.update(&exp((b >> 11) as f64 / (1u64 << 53) as f64 * 1454.9 - 745.1).to_bits().to_le_bytes());
}
// NaN is left out: its payload through a conversion is not fixed on every target.
for &x in edge_values().iter().filter(|x| !x.is_nan()) {
h.update(&round_half_away(x).to_bits().to_le_bytes());
h.update(&round_half_even(x).to_bits().to_le_bytes());
h.update(&round_half_away_f32(x as f32).to_bits().to_le_bytes());
}
assert_eq!(h.finalize().to_hex().as_str(),
"80174a4b00930982e9e2ea4ffdc47805455d4cc59797b41eb562a1cca55b0cfa",
"float_repro output changed");
}
}