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
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]>
275 lines
12 KiB
Rust
275 lines
12 KiB
Rust
//! 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");
|
||
}
|
||
}
|