wai-rs: the JPEG AI exact decoder calls no platform math library, and its output is pinned #104

Merged
dcharlot merged 2 commits from wai/land-03-jpegai-no-libm into main 2026-09-30 19:50:50 -04:00
17 changed files with 1345 additions and 89 deletions

View file

@ -60,6 +60,7 @@ jobs:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0
rustup toolchain install 1.98.0 --profile minimal
rustup default 1.98.0
rustup component add --toolchain 1.98.0 clippy
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# Windows: a host executor with rustup already installed. Only ensure the
@ -80,6 +81,23 @@ jobs:
- name: Library unit tests (the fixed-point determinism floors)
working-directory: wai/wai-rs
run: cargo test --lib --no-default-features --features world,audio_scene,haptic,splat4d,score,provenance,neural_int,film,oer
# The JPEG AI exact decoder reproduces across platforms only if no platform math
# library is on its path. Two guards, each failing the step: clippy's
# disallowed-methods (wai-rs/clippy.toml), which each module on the decode path
# denies, rejects a listed float method however it is spelled; and
# tests/jpegai_decode_symbols.rs lists the linked decoder's symbols with `nm` and
# rejects any C math-library function it imports, whatever made the call, and any
# it defines other than the round-to-integral ones (see its doc). `-A clippy::all`
# keeps the output to that guard. Unix legs only: `nm` is not on the Windows
# runner, and for clippy the unix legs already cover both architectures.
- name: JPEG AI exact decode calls no platform math library (unix)
if: matrix.os != 'windows'
shell: bash
working-directory: wai/wai-rs
run: |
set -euo pipefail
cargo clippy --lib --no-default-features --features neural_int -- -A clippy::all
cargo test --no-default-features --features world,audio_scene,haptic,splat4d,score,provenance,neural_int,film --test jpegai_decode_symbols
- name: Carriage conformance — every registered capability round-trips byte-exact
working-directory: wai/wai-rs
run: cargo test --no-default-features --test carriage

View file

@ -6,6 +6,61 @@ wire format may change and such changes are recorded here.
## v0.4.0 — current
### JPEG AI exact decoder: no platform math library on the path, and a cross-platform check
- The exact decoder (`jpegai_decode::decode`) called the platform math library's
`exp` in the per-channel gain scaler, on every target. Its roundings to an integer
compiled, on x86_64 and wasm32 (as rustc 1.98 compiles them), to calls to the
symbols `round`, `roundf` and `rint`, which the link resolves; an x86_64 Linux
test executable inspected took them from Rust's compiler builtins, not from the C
library. The decoder now takes `exp` and rounding from the new `float_repro`
module, which builds them from IEEE-754 basic operations, integer conversions and
bit manipulation, so their bits are fixed by the source. `float_repro::exp` was
within 1 ULP of the host math library on every host it was compared with
(determinism-tiers §6 lists them). Its test asserts ≤ 2 ULPs. The test is ignored
on targets whose C library was not measured, Windows among them. It selects by
target, not by host, so it also runs on glibc versions and on x86_64 hardware that
were not measured. The roundings equal the standard library's. The decoded output
is unchanged (RGB8 SHA-256 `dbc494a5…` for the `jpegai/v1` inputs).
- The exact decoder now also refuses a target that evaluates float arithmetic in x87
extended precision (32-bit x86 without SSE2), where it would not reproduce.
- New `jpegai_decode::decode_with_stages`: the same decode, also reporting the float
values after each stage (latents, synthesis, each post-filter), so two platforms
can be compared stage by stage rather than only by their final bytes.
- New `tests/jpegai_cross_platform.rs` pins SHA-256 hashes of the parsed float
inputs, of every reported stage and of the RGB8 output for the `jpegai/v1` inputs.
All nine matched on macOS arm64 (native), wasm32-wasip2 (a WASI runtime), Linux
arm64 (glibc) and Linux x86_64 (glibc, under user-mode emulation), each running
this change's final source, and on all four also before the math-library calls
were replaced. determinism-tiers §6 records the toolchain and the method for each.
Not measured: macOS x86_64, Windows, x86_64 hardware. The test skips without its
inputs, and no CI job supplies them yet; `float_repro`'s own pinned-output test
needs no inputs and runs in the library tests.
- Two checks keep math-library calls off the decode path. Clippy's
`disallowed_methods` (`wai-rs/clippy.toml`), denied in each module on the path,
rejects a listed float method however it is spelled.
`tests/jpegai_decode_symbols.rs` lists the symbols of an executable that links
only the decoder and its weight loaders, and fails on any C math-library function
the executable imports, whatever made the call, and on any it defines other than
the round-to-integral functions, whose results IEEE 754 fixes (unix targets). A
definition alone shows what the linker kept, not what the decode calls: GNU ld
kept an unused `floor` in an x86_64 Linux debug build. The conformance workflow
runs both on its unix legs. They replace a scan of the source text, which missed a
call spelled `f64::exp(x)`. Details and evidence: determinism-tiers §5–§6. The
registry class of `wai.image.jpegai` is unchanged here.
- The gain unit's `log_k` constant was `0.2003654931615394`, 9.2 × 10⁻⁹ from
`(ln 54.82 − ln 0.11) / 31`, which the reference computes as
`0.20036548400207613`. It is now that value. At β = 0, the only β the exact decoder
accepts, both constants give the same scaler for every gain-table entry, so the
decoded output is unchanged.
- `tools/wai_jpegai_gain_exp_margin.py` now models the reference's gain scaler as the
reference computes it, in float32, rounding half to even. It fails if the decoder's
`log_k` differs from the reference's. At β = 0 every table entry is at least 77
float32 ULPs from a rounding boundary, so the two scalers agree for any float32
`exp` within that. The margin does not hold over every 12-bit β: at four inputs
the reference's own scaler depends on the last bit of its `exp`
(determinism-tiers §6).
### OER receipts — graded, chained energy receipts with three implementations
- New [`extensions/oer-receipts.md`]: ingests OER/2 draft 0.1. A receipt records one

View file

@ -373,12 +373,18 @@ decode-equivalence never held as a property of the capability (SPEC §7).
**The exact decoder's pinned reconstruction.** WAI's exact decoder
(`jpegai_decode::decode`) pins one reconstruction to canonical `RGB8`: the
high-operation-point synthesis, then the post-filters, in the arithmetic above. It
reproduces across machines when the conditions in §6 hold: the build, the default
floating-point environment (which the decoder probes before decoding,
`jpegai_decode::check_scope`), and the margin of its one library call. Those
conditions belong to this implementation. That is why the pinned reconstruction is
not a tier, does not back `replicate`, and is bindable only as
[reconstruction-binding](reconstruction-binding.md) defines.
reproduces across machines when the conditions in §6 hold: its float stages use only
IEEE-754 basic operations in the fixed order of the source, with the gain scaler's
`exp` and every rounding built from those operations (`float_repro`), so no platform
math library is on the path; the target evaluates at the operands' own precision;
and the floating-point environment is the default. The decoder checks the last two
before decoding (`jpegai_decode::check_scope`). Those conditions belong to this
implementation. That is why the pinned reconstruction is not a tier, does not back
`replicate`, and is bindable only as
[reconstruction-binding](reconstruction-binding.md) defines. On one codestream, the
decoded bytes, and the float values the decoder reports after each stage, have been
measured identical on four platforms (§6); values inside a stage are not compared
directly.
The exact decoder implements one configuration, which its tests exercise.
`jpegai_decode::check_codestream` accepts a codestream only when its headers select
@ -473,7 +479,10 @@ day.
| Cross-architecture identity of the rANS coder, `int_synth`, and the JPEG AI symbol decode (a reference-encoder codestream, given its distribution indices) | golden-byte assertions passed on Linux x86_64, Linux arm64 and macOS arm64 on main at `afc7ece` (2026-09-27, `wai-conformance.yml`; `wai-byte-equality.yml` last ran on main at `3f50097`, 2026-08-29, all three legs passing), and on Windows x86_64 last on main at `3e7811f` (2026-08-27). Between `3e7811f` and `afc7ece` the suite only gained the video and keyframe goldens; these goldens, the sources they decode and the suite's lockfile are unchanged | `byte-exact-conformance/`; `.github/workflows/wai-conformance.yml`, `wai-byte-equality.yml` |
| Cross-architecture identity of integer inter-frame video, and of `int_hyper` on a small learned keyframe | the same suite, passing on Linux x86_64, Linux arm64 and macOS arm64 on main at `afc7ece` (2026-09-27). Windows x86_64 never decoded the keyframe clip: it was added in `d4c922b` (2026-08-28), whose Windows legs were cancelled. Windows decoded the integer video goldens only on the pre-merge commits `c9be2ad`, `256e5dd`, `7dfca26` and `3d34860` (2026-08-25/26), whose video sources, vectors and golden file match main but whose `byte-exact-conformance/Cargo.lock` differs; the change reached main at `3f50097`, after the last Windows run on main | as above |
| `wai.image.jpegai` exact decoder: the integer derivation of the distribution indices matches the reference decoder exactly; the whole decode stays within a stated bound of it (max\|Δ\| ≤ 2, ≤ 5% of samples) | tests on one machine that skip unless their fixtures (sink-supplied weights, normative tables, codestream) are provided; CI does not provide them. The whole-decode test last ran on macOS arm64 on 2026-09-27 with a local bundle | `wai-rs/tests/jpegai_hyperscale_calibration.rs`, `jpegai_e2e_full.rs` |
| `wai.image.jpegai` scope: the reference codestream is accepted; copies with β, `model_id`, the colour transform or an LSBS flag changed are refused; a flush-to-zero or round-toward-zero environment is detected. The float ONNX path calls the same check | library unit tests over a committed copy of the reference codestream, which never skip and fall under the library-test step of the conformance workflow (`neural_int`); the environment test runs on arm64 only. Run so far on macOS arm64 only. The float path's refusal (β and `model_id` edits) is tested only with its ONNX bundle supplied (`jpegai_scope_ort.rs`, skips otherwise) | `wai-rs/src/jpegai_decode.rs` tests, `wai-rs/tests/vectors/jpegai/` |
| `wai.image.jpegai` exact decoder across platforms: the `jpegai/v1` codestream decodes to the same RGB8 bytes, and to the same bits in the float values the decoder reports after each stage (values inside a stage are not hashed), on each platform measured | measured 2026-09-28 with rustc 1.98.0, release builds, inputs checked against their SHA-256, all four on the source of the change that added this row: macOS arm64 (native, on the development machine); wasm32-wasip2 (wasmtime 49, on the same machine); Linux arm64 (glibc 2.36, a container in an arm64 Linux VM on that machine); Linux x86_64 (glibc 2.36, the x86_64 build run in that container under user-mode emulation, QEMU 7.0, not on x86_64 hardware). All nine pinned hashes matched on each platform. On all four they also matched on an earlier state of that change, before the decode's `exp` and rounding moved to `float_repro`. Before the move the decode called the platform `exp` on every target, and its roundings compiled to calls to the symbols `round`, `roundf` and `rint` on x86_64 and `round` and `roundf` on wasm32, which the link resolves: an x86_64 Linux test executable inspected after the move, whose other code still rounds this way, took them from Rust's compiler builtins, not the C library (for wasm32 this was not checked). Not measured: macOS x86_64 (no x86_64 translation layer is installed on the development machine), Windows, x86_64 hardware, 32-bit targets. The test skips unless the inputs are supplied, and no CI job supplies them to it | `wai-rs/tests/jpegai_cross_platform.rs` |
| `float_repro`, the decode's `exp` and rounding: the same bits over a fixed grid of inputs; `exp` within 2 ULPs of the host math library (1 ULP measured); rounding equal to the standard library's | library unit tests under the library-test step of the conformance workflow (`neural_int`), which has not yet run them on a runner. The pinned-bits and rounding tests never skip. The host comparison asserts the host library's accuracy as much as this module's. Its gate selects by target, not by host: it runs on macOS arm64, on Linux x86_64 and arm64 with glibc, and on WASI, and is reported as ignored, with the reason, elsewhere, Windows included. On the source of the change that added this row it was run against these hosts' `exp`, with a worst case of 1 ULP on each: Apple's math library on macOS arm64; the WASI C library under wasmtime; glibc 2.36 on Linux arm64, and glibc 2.35 in the Ubuntu 22.04 image the workflow's Linux arm64 runner used on 2026-09-28; glibc 2.36 on Linux x86_64 under user-mode emulation, against three of the four `exp` variants glibc chooses among by processor feature (the variant each run used follows from the features glibc's loader reported active): SSE2 (QEMU 7.0, whose emulated processor reports none of AVX, FMA, AVX2 and FMA4), AVX (QEMU 7.2, `-cpu max` with FMA and AVX2 masked by glibc's `hwcaps` tunable, which leaves AVX active) and FMA (QEMU 7.2, `-cpu max`). On a separate probe of 20 million inputs, the SSE2 and AVX variants returned the same bits, and the FMA variant different bits for some of them. Not compared: the FMA4 variant, which glibc chooses on a processor that has FMA4 but not both FMA and AVX2, and which neither emulator offers. Not run on x86_64 hardware or on other glibc versions, where the gate still runs it; a failure there would say that the host's `exp` and this one are more than 2 ULPs apart, not that `float_repro`'s bits changed | `wai-rs/src/float_repro.rs` tests |
| The exact decode path calls no platform math library | two checks. Clippy's `disallowed_methods` (`wai-rs/clippy.toml`), denied in each module on the decode path, rejects any use of a listed float method, including `f64::exp(x)` and `.map(f64::exp)`. `tests/jpegai_decode_symbols.rs` lists the symbols of an executable that links only the decoder's entry points and weight loaders. It rejects any C math-library function the executable imports, and any it defines other than the round-to-integral functions (`floor`, `round`, `rint` and the like), whose results IEEE 754 fixes. It allows those definitions because a definition shows what the linker kept, not what the decode calls: linked with GNU ld 2.40 for x86_64 Linux, the debug executable defined the compiler builtins' `floor`, though nothing in it referred to `floor`. On the source of the change that added this row, both checks pass on macOS arm64, the symbol check in debug and release builds. The symbol check also passes, in debug builds, on Linux arm64 (the container above) and on Linux x86_64 under user-mode emulation, linked with GNU ld (reporting that `floor` as allowed) and with LLD. With a platform `exp` put back as `f64::exp(…)`, both fail on macOS arm64 while the decoder's unit tests still pass, and the symbol check fails on Linux x86_64 with GNU ld. With a float `%` put into the decode, the symbol check fails on macOS arm64 and clippy, which does not see operators, passes. The conformance workflow runs both on its unix legs; neither has run on a runner yet | `wai-rs/clippy.toml`, `wai-rs/tests/jpegai_decode_symbols.rs`, `.github/workflows/wai-conformance.yml` |
| `wai.image.jpegai` scope: the reference codestream is accepted; copies with β, `model_id`, the colour transform or an LSBS flag changed are refused; a flush-to-zero or round-toward-zero environment is detected. The float ONNX path calls the same check | library unit tests over a committed copy of the reference codestream, which never skip and fall under the library-test step of the conformance workflow (`neural_int`); the environment test runs on arm64 only. Run on the four platforms above, on the source of the change that added the cross-platform row (the environment test on macOS arm64 and Linux arm64). The float path's refusal (β and `model_id` edits) is tested only with its ONNX bundle supplied (`jpegai_scope_ort.rs`, skips otherwise) | `wai-rs/src/jpegai_decode.rs` tests, `wai-rs/tests/vectors/jpegai/` |
| SPEC §7's replicate rule: the registry class decides first, so an integer-declared prior over `wai.image.jpegai` or a float codec is refused; `wai.image.jpegai` is the only `NeuralEntropyExact` capability; the standard codecs sit in the classes SPEC §7 lists, and the tolerance and unclassified ones are not refused; an encoder that pins no prior (`wai wrap`) writes `replicate` only over the standard classes, for the capability and for any declared fallback | library unit tests (`codecs`, `container`) that never skip and fall under the same library-test step. The strict-decode test in `tests/neural_native_decode.rs` needs the `neural` feature; `wai-neural-gates.yml` runs it on the Linux runner, in an Ubuntu 24.04 image because the `neural` test binary does not link in that runner's default 22.04 image. The workflow has not run through the forge; its job passed under `forgejo-runner exec` on the Linux runner's host (Linux x86_64, 2026-09-27). The test that runs the `wai wrap` binary needs the `codecs` feature, which no CI job builds. Otherwise run so far on macOS arm64 only | `wai-rs/src/codecs/mod.rs`, `container.rs` tests; `wai-rs/tests/neural_native_decode.rs`, `cli_wrap_intent.rs` |
| `wai.neural.int_hyper` and `wai.video.int_hyper` at full model scale | tests that skip when the exported model is absent; not run in CI | `wai-rs/tests/int_hyper_synth.rs`, `int_video_sota_keyframe.rs` |
| `wai.audio.int_codec`, `wai.splat.int_codec` | committed vectors that never skip, matched against an independent Python integer reference; their cargo features (`audio_int`, `splat_int`) are enabled by no CI job | `wai-rs/tests/int_audio.rs`, `int_splat.rs` |
@ -490,24 +499,65 @@ cross-architecture comparison exists. For full-scale models, identity rests on
construction, not on a cross-machine comparison of decoded output.
For the JPEG AI exact decoder's pinned reconstruction (§5), identity across
machines also rests on construction, not on comparison. The construction is
integer arithmetic for the integer stages and, for the float stages, only IEEE-754
basic operations, which are correctly rounded:
machines rests on construction, and on one comparison: a single codestream on the
four platforms in the table. The construction is integer arithmetic for the integer
stages and, for the float stages, only IEEE-754 basic operations, which are
correctly rounded:
- evaluated single-threaded in the fixed order of the source, each at its own
precision (true of x86_64, AArch64 and wasm32 targets; not of x87);
precision (true of x86_64, AArch64 and wasm32 targets; not of x87, which the
exact decoder refuses);
- with no fused or reassociated operations, which Rust does not emit unless the
source asks for them;
- in the default floating-point environment, which the exact decoder probes before
decoding (§5).
decoding (§5): round to nearest even, subnormals neither flushed nor read as zero;
- with no platform math library on the path. The gain scaler needs `exp`, which
math libraries are not required to round correctly. Rounding to an integer is
exactly specified, but on some targets it compiles to a call: with rustc 1.98,
`f64::round`, `f32::round` and `f64::round_ties_even` become calls to the symbols
`round`, `roundf` and `rint` on x86_64, and the first two do on wasm32, and the
link decides what answers them (in the x86_64 Linux executable inspected, Rust's
compiler builtins did, not the C library). The decoder takes both from
`wai-rs/src/float_repro.rs`, built from basic operations, so their bits are fixed
by the source. Clippy's `disallowed_methods`, denied in each module on the decode
path, and a check of the linked decoder's symbols (`tests/jpegai_decode_symbols.rs`)
keep math-library calls off the path (see the table above).
One float stage calls the platform `exp` (the per-channel gain scaler, rounded to
2⁻¹⁰), which math libraries are not required to round correctly. Its inputs are
exactly the normative gain tables, because the decoder refuses β ≠ 0. Every result
lies more than 10¹⁰ f64 ULPs from a rounding boundary — checked with 60-digit
decimal arithmetic by `tools/wai_jpegai_gain_exp_margin.py` over the tables exported
from the reference software — so a last-place difference between math libraries
cannot change it. Its `--beta-sweep` option checks every β the 12-bit header field
can carry: the smallest margin over those inputs is 3.7 × 10⁸ ULPs. A decoder that
implemented β would therefore not reopen this argument; this one does not implement
it.
That the exact decoder's gain scaler equals the reference decoder's rests on margin,
because the two compute it in different arithmetic. The scaler is
`exp((g + β) · log_k / 2⁷)` rounded to 2⁻¹⁰. The exact decoder evaluates it in f64
with `float_repro::exp`, which was measured within 1 ULP of the host math library
on the four platforms. The reference software evaluates it in float32: the
log-domain gain as float32, times `log_k` rounded to float32, then the float32 `exp`
of its tensor library, then rounding half to even. Neither `exp` is guaranteed to be
correctly rounded. The inputs are exactly the normative gain tables, because the exact
decoder refuses β ≠ 0.
`tools/wai_jpegai_gain_exp_margin.py` computes the exact value of both evaluations
with 60-digit decimal arithmetic, over the gain tables exported from the reference
software, and gates on the result at β = 0. It found the following for every entry
of all eight tables:
- the exact value of the decoder's f64 evaluation lies at least 4 × 10¹⁰ f64 ULPs
from a rounding boundary;
- the exact value of the reference's evaluation, from its float32 argument, lies at
least 77 float32 ULPs from one;
- both round to the same integer.
So any float32 `exp` within 77 ULPs gives the reference the exact decoder's scaler.
The tool also fails unless the decoder's `log_k` is the value the reference computes.
This does not extend to every β. The tool's `--beta-sweep` option reports, without
gating, all 5118 values of `g + β` that the tables and the 12-bit header field can
produce. At four of them (1115, 1922, 2285 and 2289), the exact value of the
reference's evaluation lies within one float32 ULP of a boundary. There the
reference's own scaler depends on the last bit of its `exp`, so it can differ
between the platforms the reference runs on. At 2285 and 2289 the exact value lies
just above the boundary, a correctly rounded float32 `exp` lands exactly on it, and
rounding half to even then gives the integer below it. At every `g + β`, the exact
decoder's formula gives the scaler that a correctly rounded float32 `exp` gives.
The reference's own expression, evaluated on the development machine (macOS arm64),
also gave the exact decoder's scaler at all 5118; its float32 `exp` there was at
most 0.85 ULP from exact on those inputs. A decoder that implements β would still
have to settle what the reference's scaler is at those four inputs. This one does
not implement β.

View file

@ -1,21 +1,46 @@
#!/usr/bin/env python3
"""Check that the one math-library call in the `wai.image.jpegai` decode path cannot
change the decoded output on a different machine.
"""Check that the `wai.image.jpegai` exact decoder's gain scaler equals the reference
decoder's, although the two compute it in different arithmetic.
The per-channel gain scaler (`jpegai_pipeline::gain_unit_scaler_vec`) is
The per-channel gain scaler is
scaler[c] = round( exp( (g[c] + beta) * GAIN_LOG_K / 128 ) * 2^10 ) / 2^10
scaler[c] = round( exp( (g[c] + beta) * log_k / 2^7 ) * 2^10 ) / 2^10
in f64. The exact decoder calls it with `beta = 0`: it refuses any codestream whose
`beta_displacement_log` is not 0 (`jpegai_decode::check_codestream`), because it does
not implement the beta displacement. So its inputs are only the normative
per-channel gain table `g`. The multiply and divide are IEEE-754 basic operations,
correctly rounded on every conforming target; `exp` comes from the platform math
library, which is not required to be correctly rounded. A last-place difference in
`exp` can change the scaler only if the exact value sits within a few ULPs of a
rounding boundary (k + 0.5). This script computes, for every table entry, the
distance from that boundary in ULPs of the f64 value, using 60-digit decimal
arithmetic for `exp`, and fails if any entry is closer than MIN_MARGIN_ULPS.
with `log_k = (ln 54.82 - ln 0.11) / 31`. The two decoders evaluate it differently.
Exact decoder (`jpegai_pipeline::gain_unit_scaler_vec`): f64 throughout, with
`GAIN_LOG_K` as its f64 `log_k`, `exp` from `wai-rs/src/float_repro.rs` (built from
IEEE-754 basic operations, so its bits are the same on every target and within a
few ULPs of the exact value), rounding half away from zero. It calls this with
`beta = 0` only: `jpegai_decode::check_codestream` refuses any codestream whose
`beta_displacement_log` is not 0, because the beta displacement is not implemented.
Reference software: float32. The log-domain gain `g + beta` is an int32 tensor and
`log_k` an f64 scalar, so the product and everything after it are float32:
x32 = f32( f32(g + beta) * f32(log_k) ) / 2^7 (the divide is exact)
t = exp32(x32) * 2^10 (the multiply is exact)
scaler = round_half_even(t) / 2^10
`exp32` is the float32 `exp` of its tensor library on the platform it runs on,
which is not required to be correctly rounded.
The multiplies, divides and conversions are IEEE-754 basic operations, so both `x`
values are fixed. Only the two `exp`s vary, and each can move the scaler only if the
exact value `exp(x) * 2^10` lies close to a rounding boundary (k + 0.5). This script
computes those exact values with 60-digit decimal arithmetic and measures:
* decoder margin: distance from the boundary in ULPs of the f64 value. The
decoder's `exp` is within a few ULPs, so this must be large.
* reference margin: distance from the boundary in float32 ULPs (of the larger of
the value and the boundary). Any float32 `exp` whose error is below this many
ULPs gives the scaler of the exact value. The script also reports what a
correctly rounded float32 `exp` gives, since `t` can land exactly on k + 0.5.
* that both exact values round to the same integer.
It fails unless `GAIN_LOG_K` is the f64 `log_k` the reference computes (f64
logarithms, correctly rounded, then the difference, then / 31), and unless, at
beta = 0 over every table entry, the decoder margin is at least MIN_MARGIN_ULPS,
the reference margin at least MIN_MARGIN_F32_ULPS, and both round to the same
integer.
python wai_jpegai_gain_exp_margin.py <assets_dir> [--beta-sweep]
@ -25,20 +50,23 @@ model/CCS_SGMM/tools_*/model_{y,uv} gain table present is checked; the decoder's
scope is tools_0. GAIN_LOG_K is read from the Rust source so the check follows the
code. Standard library only.
--beta-sweep also reports, without gating, the smallest margin over g + beta for
every beta the 12-bit header field can carry (-2048..2047): what a decoder that
implements the beta displacement would face.
--beta-sweep also reports, without gating, the same measures over g + beta for every
beta the 12-bit header field can carry (-2048..2047): what a decoder that implements
the beta displacement would face.
"""
import glob
import math
import os
import re
import struct
import sys
from decimal import Decimal, getcontext
from decimal import ROUND_FLOOR, ROUND_HALF_EVEN, Decimal, getcontext
getcontext().prec = 60
MIN_MARGIN_ULPS = 1e6 # far beyond any libm's error bound
MIN_MARGIN_ULPS = 1e6 # f64 ULPs; far beyond float_repro::exp's error (≤ 2 ULPs, tested)
MIN_MARGIN_F32_ULPS = 8.0 # float32 ULPs: several times a float32 exp accurate to ~1 ULP
SCALER_PRECISION = 10
SIGMA_PRECISION = 7
HERE = os.path.dirname(os.path.abspath(__file__))
PIPELINE_RS = os.path.join(HERE, "..", "wai-rs", "src", "jpegai_pipeline.rs")
@ -52,13 +80,66 @@ def gain_log_k():
return float(m.group(1).replace("_", ""))
def margin_ulps(g, k):
# Same f64 operations, same order, as the Rust: (((g + beta) as f64) * K) / 128.0,
# where `g` here is already the integer sum g + beta.
x = (float(g) * k) / 128.0
def reference_log_k():
# (ln 54.82 - ln 0.11) / 31 as the reference computes it in f64: each logarithm
# rounded to f64 (here correctly), then the difference, then the division.
ln = lambda v: float(Decimal(v).ln()) # Decimal(v) is the exact f64 value of v
return (ln(54.82) - ln(0.11)) / 31
def f32(v):
return struct.unpack("<f", struct.pack("<f", v))[0] # round to nearest even
def ulp32(v):
return 2.0 ** (math.frexp(v)[1] - 24) # float32 spacing at positive normal v
def boundary(v):
"""The rounding boundary nearest to v > 0 (a half-integer) and the distance."""
b = Decimal(int(v)) + Decimal("0.5")
return b, abs(v - b)
def decoder_side(n, k):
# Same f64 operations, same order, as the Rust: (((g + beta) as f64) * K) / 128.0.
x = (float(n) * k) / 2.0 ** SIGMA_PRECISION
v = Decimal(x).exp() * (1 << SCALER_PRECISION) # exact to 60 digits
dist = abs((v - int(v)) - Decimal("0.5"))
return float(dist) / math.ulp(float(v))
_, d = boundary(v)
r = int((v + Decimal("0.5")).to_integral_value(rounding=ROUND_FLOOR))
return r, float(d) / math.ulp(float(v))
def reference_side(n, k_ref):
# The product of two float32 values is exact in f64, so one rounding gives f32(a*b).
x = f32(f32(float(n)) * f32(k_ref)) / 2.0 ** SIGMA_PRECISION
e = Decimal(x).exp()
v = e * (1 << SCALER_PRECISION)
b, d = boundary(v)
r = int(v.to_integral_value(rounding=ROUND_HALF_EVEN))
margin = float(d) / ulp32(max(float(v), float(b)))
u = Decimal(ulp32(float(e)))
t = (e / u).to_integral_value(rounding=ROUND_HALF_EVEN) * u * (1 << SCALER_PRECISION)
r_cr = int(t.to_integral_value(rounding=ROUND_HALF_EVEN)) # correctly rounded exp32
return r, margin, r_cr
def measure(xs, k, k_ref):
out = {}
for n in xs:
rd, md = decoder_side(n, k)
rr, mr, rcr = reference_side(n, k_ref)
out[n] = (rd, md, rr, mr, rcr)
return out
def summary(res):
md = min(r[1] for r in res.values())
mr_n = min(res, key=lambda n: res[n][3])
return (md, res[mr_n][3], mr_n,
sorted(n for n, r in res.items() if r[0] != r[2]),
sorted(n for n, r in res.items() if r[3] < 1.0),
sorted(n for n, r in res.items() if r[0] != r[4]))
BETA_RANGE = range(-2048, 2048) # 12-bit beta_displacement_log, de-biased by 2048
@ -71,33 +152,63 @@ def main():
if len(args) != 1:
sys.exit(__doc__)
k = gain_log_k()
k_ref = reference_log_k()
pattern = os.path.join(args[0], "model", "CCS_SGMM", "tools_*", "model_*",
"common_modules", "quantizer", "gain_unit_mlog.csv")
tables = sorted(glob.glob(pattern))
if not tables:
sys.exit(f"no gain tables under {pattern}")
worst_all = math.inf
print(f"GAIN_LOG_K = {k!r}; the reference's f64 log_k = {k_ref!r}; "
f"as float32 {f32(k_ref)!r}")
fail = []
if k != k_ref:
fail.append(f"GAIN_LOG_K differs from the reference's log_k by {k - k_ref:.3e} "
f"({(k - k_ref) / math.ulp(k_ref):.3e} f64 ULPs)")
worst_d, worst_r = math.inf, math.inf
swept = {}
for path in tables:
gains = [int(s) for s in open(path).read().split()]
worst = min(margin_ulps(g, k) for g in gains)
worst_all = min(worst_all, worst)
rel = os.path.relpath(path, args[0])
print(f"{rel}: {len(gains)} entries, min margin {worst:.3e} ULPs")
rel = os.path.relpath(os.path.dirname(path), args[0]).split(os.sep + "common_modules")[0]
md, mr, mr_n, differ, under1, cr_differ = summary(measure(gains, k, k_ref))
worst_d, worst_r = min(worst_d, md), min(worst_r, mr)
print(f"{rel}: {len(gains)} entries, range [{min(gains)}, {max(gains)}]")
print(f" beta = 0: decoder margin {md:.3e} f64 ULPs; reference margin "
f"{mr:.2f} float32 ULPs (at g = {mr_n}); exact values round differently "
f"at {differ or 'none'}")
if differ:
fail.append(f"{rel}: decoder and reference scalers differ at g = {differ}")
if sweep:
xs = {g + b for g in gains for b in BETA_RANGE}
for x in xs:
if x not in swept:
swept[x] = margin_ulps(x, k)
w = min(swept[x] for x in xs)
print(f" with beta in [{BETA_RANGE.start}, {BETA_RANGE.stop - 1}]: "
f"{len(xs)} distinct exp inputs, min margin {w:.3e} ULPs (not gated)")
print(f"GAIN_LOG_K = {k!r}; overall min margin {worst_all:.3e} ULPs "
f"(threshold {MIN_MARGIN_ULPS:.0e})")
if worst_all < MIN_MARGIN_ULPS:
sys.exit("FAIL: an entry is close enough to a rounding boundary that a libm "
"difference could change the decoded scaler")
print("OK: no libm last-place difference can change the scaler for these tables")
new = [x for x in xs if x not in swept]
swept.update(measure(new, k, k_ref))
md, mr, mr_n, differ, under1, cr_differ = summary({x: swept[x] for x in xs})
print(f" beta in [{BETA_RANGE.start}, {BETA_RANGE.stop - 1}] (not gated): "
f"{len(xs)} distinct g + beta; decoder margin {md:.3e} f64 ULPs; "
f"reference margin {mr:.2f} float32 ULPs (at g + beta = {mr_n})")
print(f" reference margin under 1 float32 ULP at g + beta = "
f"{under1 or 'none'}; exact values round differently at "
f"{differ or 'none'}; a correctly rounded float32 exp gives a "
f"different scaler from the decoder's at {cr_differ or 'none'}")
print(f"beta = 0, all tables: decoder margin {worst_d:.3e} f64 ULPs (threshold "
f"{MIN_MARGIN_ULPS:.0e}); reference margin {worst_r:.2f} float32 ULPs "
f"(threshold {MIN_MARGIN_F32_ULPS:g})")
if sweep:
md, mr, mr_n, differ, under1, cr_differ = summary(swept)
print(f"beta sweep, all tables (not gated): {len(swept)} distinct g + beta; "
f"decoder margin {md:.3e} f64 ULPs; reference margin {mr:.2f} float32 "
f"ULPs (at {mr_n}); under 1 float32 ULP at {under1 or 'none'}; exact "
f"values round differently at {differ or 'none'}; correctly rounded "
f"float32 exp differs from the decoder at {cr_differ or 'none'}")
if worst_d < MIN_MARGIN_ULPS:
fail.append("an entry is close enough to a rounding boundary that a last-place "
"difference in the decoder's exp could change its scaler")
if worst_r < MIN_MARGIN_F32_ULPS:
fail.append("an entry is close enough to a rounding boundary that the "
"reference's float32 exp could change its scaler")
if fail:
sys.exit("FAIL: " + "; ".join(fail))
print(f"OK: at beta = 0 the decoder's scaler equals the reference's for every "
f"float32 exp within {math.floor(worst_r)} ULPs of the exact value")
if __name__ == "__main__":

View file

@ -15,6 +15,12 @@ categories = ["science", "simulation", "wasm", "multimedia"]
name = "wai"
crate-type = ["lib", "cdylib", "staticlib"] # cdylib + staticlib for FFI/SDK
# clippy.toml's `disallowed-methods` lists the float functions the JPEG AI exact
# decoder must not call. Allowed for the package here; each module on that decode path
# denies it with `#![deny(clippy::disallowed_methods)]`.
[lints.clippy]
disallowed_methods = "allow"
[dependencies]
# Container manifest. preserve_order so JSON round-trips byte-identical.
serde = { version = "1", features = ["derive"] }
@ -518,6 +524,14 @@ name = "wai_bench_diff"
path = "src/bin/wai_bench_diff.rs"
required-features = ["codecs"]
# A program, not a libtest suite: it lists its own executable's symbols, so the test
# harness's code must not be in it (see the file's doc).
[[test]]
name = "jpegai_decode_symbols"
path = "tests/jpegai_decode_symbols.rs"
harness = false
required-features = ["neural_int"]
[dev-dependencies]
serde_json = { version = "1", features = ["preserve_order"] } # construct manifests in tests

104
wai/wai-rs/clippy.toml Normal file
View file

@ -0,0 +1,104 @@
# The float functions the JPEG AI exact decoder must not call.
#
# The exact decoder (`src/jpegai_decode.rs` and the modules it runs through) reproduces
# bit for bit across platforms because its float stages use only IEEE-754 basic
# operations, with `exp` and rounding built from them in `src/float_repro.rs`. The
# methods below would put something else on that path: a platform math-library
# function, whose last bits are not fixed by any standard, or an operation whose
# lowering or result differs between targets.
#
# Clippy resolves each use by type, so it catches `x.exp()`, `f64::exp(x)` and
# `.map(f64::exp)` alike, and not `crate::float_repro::exp` or an integer `max`.
# The lint is allowed for the package (`[lints.clippy]` in Cargo.toml) and denied
# by each module on the decode path (`#![deny(clippy::disallowed_methods)]`), so the
# rest of the crate may use these methods. `.github/workflows/wai-conformance.yml`
# runs clippy on the library. What it does not see (an operator such as a float `%`,
# a call in a module without the attribute or in another crate) is covered by
# `tests/jpegai_decode_symbols.rs`, which inspects the linked executable.
disallowed-methods = [
# Transcendental and power functions: a platform math library is not required to
# round them correctly, so libraries disagree in the last bits.
{ path = "f64::exp", reason = "platform math library; use crate::float_repro::exp" },
{ path = "f32::exp", reason = "platform math library; use crate::float_repro::exp" },
{ path = "f64::exp2", reason = "platform math library" },
{ path = "f32::exp2", reason = "platform math library" },
{ path = "f64::exp_m1", reason = "platform math library" },
{ path = "f32::exp_m1", reason = "platform math library" },
{ path = "f64::ln", reason = "platform math library" },
{ path = "f32::ln", reason = "platform math library" },
{ path = "f64::ln_1p", reason = "platform math library" },
{ path = "f32::ln_1p", reason = "platform math library" },
{ path = "f64::log", reason = "platform math library" },
{ path = "f32::log", reason = "platform math library" },
{ path = "f64::log2", reason = "platform math library" },
{ path = "f32::log2", reason = "platform math library" },
{ path = "f64::log10", reason = "platform math library" },
{ path = "f32::log10", reason = "platform math library" },
{ path = "f64::powf", reason = "platform math library" },
{ path = "f32::powf", reason = "platform math library" },
{ path = "f64::powi", reason = "not correctly rounded, and its expansion depends on the optimiser" },
{ path = "f32::powi", reason = "not correctly rounded, and its expansion depends on the optimiser" },
{ path = "f64::sin", reason = "platform math library" },
{ path = "f32::sin", reason = "platform math library" },
{ path = "f64::cos", reason = "platform math library" },
{ path = "f32::cos", reason = "platform math library" },
{ path = "f64::tan", reason = "platform math library" },
{ path = "f32::tan", reason = "platform math library" },
{ path = "f64::sin_cos", reason = "platform math library" },
{ path = "f32::sin_cos", reason = "platform math library" },
{ path = "f64::asin", reason = "platform math library" },
{ path = "f32::asin", reason = "platform math library" },
{ path = "f64::acos", reason = "platform math library" },
{ path = "f32::acos", reason = "platform math library" },
{ path = "f64::atan", reason = "platform math library" },
{ path = "f32::atan", reason = "platform math library" },
{ path = "f64::atan2", reason = "platform math library" },
{ path = "f32::atan2", reason = "platform math library" },
{ path = "f64::sinh", reason = "platform math library" },
{ path = "f32::sinh", reason = "platform math library" },
{ path = "f64::cosh", reason = "platform math library" },
{ path = "f32::cosh", reason = "platform math library" },
{ path = "f64::tanh", reason = "platform math library" },
{ path = "f32::tanh", reason = "platform math library" },
{ path = "f64::asinh", reason = "platform math library" },
{ path = "f32::asinh", reason = "platform math library" },
{ path = "f64::acosh", reason = "platform math library" },
{ path = "f32::acosh", reason = "platform math library" },
{ path = "f64::atanh", reason = "platform math library" },
{ path = "f32::atanh", reason = "platform math library" },
{ path = "f64::cbrt", reason = "platform math library" },
{ path = "f32::cbrt", reason = "platform math library" },
{ path = "f64::hypot", reason = "platform math library" },
{ path = "f32::hypot", reason = "platform math library" },
{ path = "f64::abs_sub", reason = "platform math library (fdim)" },
{ path = "f32::abs_sub", reason = "platform math library (fdim)" },
# Rounding to an integer and remainders: exactly specified, but on some targets
# (baseline x86_64, wasm32) they compile to a call whose provider depends on the
# link. The decode takes rounding from crate::float_repro.
{ path = "f64::round", reason = "may compile to a library call; use crate::float_repro::round_half_away" },
{ path = "f32::round", reason = "may compile to a library call; use crate::float_repro::round_half_away_f32" },
{ path = "f64::round_ties_even", reason = "may compile to a library call; use crate::float_repro::round_half_even" },
{ path = "f32::round_ties_even", reason = "may compile to a library call" },
{ path = "f64::floor", reason = "may compile to a library call" },
{ path = "f32::floor", reason = "may compile to a library call" },
{ path = "f64::ceil", reason = "may compile to a library call" },
{ path = "f32::ceil", reason = "may compile to a library call" },
{ path = "f64::trunc", reason = "may compile to a library call" },
{ path = "f32::trunc", reason = "may compile to a library call" },
{ path = "f64::fract", reason = "may compile to a library call (trunc)" },
{ path = "f32::fract", reason = "may compile to a library call (trunc)" },
{ path = "f64::rem_euclid", reason = "compiles to a library call (fmod)" },
{ path = "f32::rem_euclid", reason = "compiles to a library call (fmod)" },
{ path = "f64::div_euclid", reason = "may compile to a library call (trunc)" },
{ path = "f32::div_euclid", reason = "may compile to a library call (trunc)" },
# Fused multiply-add rounds once where `a * b + c` rounds twice, and falls back to a
# library `fma` on targets without the instruction.
{ path = "f64::mul_add", reason = "fused: one rounding where the decode's arithmetic has two" },
{ path = "f32::mul_add", reason = "fused: one rounding where the decode's arithmetic has two" },
# IEEE maxNum/minNum leave the sign of a zero result unspecified, and some targets
# call the library's fmax/fmin. The decode clamps with `clamp`, which compares.
{ path = "f64::max", reason = "sign of a zero result unspecified; may call fmax" },
{ path = "f32::max", reason = "sign of a zero result unspecified; may call fmaxf" },
{ path = "f64::min", reason = "sign of a zero result unspecified; may call fmin" },
{ path = "f32::min", reason = "sign of a zero result unspecified; may call fminf" },
]

View file

@ -0,0 +1,275 @@
//! 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");
}
}

View file

@ -12,6 +12,9 @@
//! Fixed-point: values are Q16 (`v_real · 2^16`). Weights/scales are supplied by
//! the sink (16-bit PTQ export); this module is the *runtime*.
// On the JPEG AI exact decode path: no platform math-library call (see clippy.toml).
#![deny(clippy::disallowed_methods)]
/// Fractional bits of the Q16 fixed-point format.
pub const F: u32 = 16;
/// 1.0 in Q16.
@ -397,6 +400,7 @@ pub fn tab(x: &[i64], attn: &MdtaBlockWeights, ffn: &GdfnWeights, eps_q32: u128)
}
#[cfg(test)]
#[allow(clippy::disallowed_methods, reason = "the tests compare with float references")]
mod tests {
use super::*;

View file

@ -11,6 +11,9 @@
//! bit-granular cursor. Reads past the end return the bits available (zero-extended),
//! matching the reference's bounds check.
// On the JPEG AI exact decode path: no platform math-library call (see clippy.toml).
#![deny(clippy::disallowed_methods)]
/// Bits needed to carry `max_value`, per the reference `HeaderCoder::calc_bits_count`:
/// `ceil(log2(max_value + 1))`. `max_value = 0` needs no bits.
pub fn bits_for_max(max_value: u64) -> u32 {

View file

@ -16,19 +16,31 @@
//!
//! - every float operation is an IEEE-754 basic operation (add, subtract, multiply,
//! divide, square root, conversion), which is correctly rounded and so gives the
//! same result for the same operands on every conforming target — except one
//! `exp` in the gain scaler, whose results `tools/wai_jpegai_gain_exp_margin.py`
//! shows lie far from any rounding boundary for the tables this decoder accepts;
//! same result for the same operands on every conforming target. The gain scaler's
//! `exp` and every rounding to an integer come from [`crate::float_repro`], which
//! builds them from those operations, so no platform math library is called. Two
//! guards keep it so. Clippy's `disallowed_methods` (the list is in `clippy.toml`),
//! denied in this module and in the others on the decode path, rejects a call to a
//! listed float method however it is spelled. On unix targets,
//! `tests/jpegai_decode_symbols.rs` fails if the linked decoder imports any C
//! math-library function, whatever made the call, or defines one other than the
//! round-to-integral functions, whose results IEEE 754 fixes;
//! - the operations run single-threaded in the fixed order of this source, at their
//! own precision (true of x86_64 SSE2, AArch64 and wasm32; not of x87), and Rust
//! neither fuses `a * b + c` into a fused multiply-add nor reassociates;
//! own precision (true of x86_64 SSE2, AArch64 and wasm32; not of x87, which
//! [`check_scope`] refuses), and Rust neither fuses `a * b + c` into a fused
//! multiply-add nor reassociates;
//! - the thread runs in the IEEE-754 default floating-point environment
//! (round-to-nearest-even, no flush-to-zero, no denormals-are-zero). A host process
//! can change this through the FFI; [`check_scope`] probes it and refuses to decode
//! otherwise ([`fp_env_is_default`]).
//! (round-to-nearest-even; subnormal results kept and subnormal operands read as
//! such, i.e. no flush-to-zero and no denormals-are-zero). A host process can change
//! this through the FFI; [`check_scope`] probes it and refuses to decode otherwise
//! ([`fp_env_is_default`]). wasm32 has no such modes.
//!
//! `tests/jpegai_e2e_full.rs` drives this and compares the reference decode of
//! `test256.bin` (max|Δ| = 1, ~4.4% of samples; asserted bound max|Δ| ≤ 2).
//! `tests/jpegai_cross_platform.rs` pins the decoded bytes of the `jpegai/v1` inputs
//! and the bits of the float values the decoder reports after each stage
//! ([`decode_with_stages`]; values inside a stage are not hashed), and names the
//! platforms on which they were measured identical. `tests/jpegai_e2e_full.rs`
//! compares the decode with the reference decoder's output on `test256.bin`
//! (max|Δ| = 1 on ~4.4% of samples; asserted bound max|Δ| ≤ 2).
//!
//! Scope: one configuration, the one the reference encoder's `test256.bin` exercises
//! and the stage tests verified. [`check_codestream`] checks every header condition,
@ -39,6 +51,10 @@
//! one, chroma subsampling, and an eICCI model selection other than the one the weight
//! bundle carries.
// On the JPEG AI exact decode path: no platform math-library call (see clippy.toml).
#![deny(clippy::disallowed_methods)]
use crate::float_repro::{round_half_away, round_half_away_f32};
use crate::jpegai_bitstream::{find_segment, parse_container, parse_lsbs_flags,
parse_picture_header, parse_tools_header, Marker, PictureHeader,
ToolsHeader};
@ -99,7 +115,7 @@ pub struct Rgb {
/// Header gains are offset-binary Q11 around 32767.
fn deint(q: u32) -> f32 { (q as f32 - 32767.0) / 2048.0 }
fn q16(f: &[f32]) -> Vec<i64> { f.iter().map(|&x| (x as f64 * 65536.0).round() as i64).collect() }
fn q16(f: &[f32]) -> Vec<i64> { f.iter().map(|&x| round_half_away(x as f64 * 65536.0) as i64).collect() }
/// Decode one plane's front half and value path → its LSBS-refined synthesis latent
/// (f32), returning also `i_s` and the RVS additive for LEF's sigma surface.
@ -289,7 +305,16 @@ fn codestream_scope(data: &[u8]) -> Result<(PictureHeader, ToolsHeader), &'stati
Ok((ph, th))
}
/// False on a target that evaluates `f32`/`f64` arithmetic in x87 extended precision
/// (32-bit x86 without SSE2). There each result is rounded twice, first to the x87
/// register format and again when stored, so the float stages would not reproduce
/// the other targets' bits; the decoder refuses rather than decode differently.
const FLOAT_EVAL_AT_OPERAND_PRECISION: bool =
!cfg!(all(target_arch = "x86", not(target_feature = "sse2")));
fn scope(data: &[u8]) -> Result<(PictureHeader, ToolsHeader), &'static str> {
need(FLOAT_EVAL_AT_OPERAND_PRECISION,
"this target evaluates float arithmetic in x87 extended precision (32-bit x86 without SSE2)")?;
need(fp_env_is_default(),
"floating-point environment is not the IEEE-754 default (flush-to-zero, \
denormals-are-zero or a directed rounding mode is set)")?;
@ -305,9 +330,10 @@ pub fn check_codestream(data: &[u8]) -> Result<(), &'static str> {
codestream_scope(data).map(|_| ())
}
/// Check whether [`decode`] will decode `data`, and if not, why: the floating-point
/// environment must be the default ([`fp_env_is_default`]) and the codestream must
/// pass [`check_codestream`].
/// Check whether [`decode`] will decode `data`, and if not, why: the target must
/// evaluate float arithmetic at the operands' own precision (not x87), the
/// floating-point environment must be the default ([`fp_env_is_default`]), and the
/// codestream must pass [`check_codestream`].
pub fn check_scope(data: &[u8]) -> Result<(), &'static str> {
scope(data).map(|_| ())
}
@ -316,6 +342,53 @@ pub fn check_scope(data: &[u8]) -> Result<(), &'static str> {
/// codestream, or if the tables in `n` do not have the tools_0 shapes (160 luma and
/// 96 chroma gain entries).
pub fn decode(data: &[u8], w: &DecoderWeights, n: &NormTables) -> Option<Rgb> {
decode_with_stages(data, w, n, &mut |_, _| {})
}
/// A point in the decode at which [`decode_with_stages`] reports the float values the
/// next stage reads. The stages run in the order listed; a post-filter stage is
/// reported only when the tool header enables that filter.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Stage {
/// The luma synthesis input after LSBS refinement, `[160 × 16 × 16]`.
LatentY,
/// The chroma synthesis input after LSBS refinement, `[96 × 16 × 16]`.
LatentUv,
/// The Y, U and V planes out of the synthesis networks, in `[0, 255]`, before
/// rounding.
Synthesis,
/// The three planes after the linear enhancement filter.
Efl,
/// The three planes after eICCI.
Icci,
/// The three planes after the nonlinear enhancement filter.
Efn,
/// The three planes after LEF: what the BT.709 conversion reads.
Lef,
}
impl Stage {
/// A short stable name, for logs and pinned-hash tables.
pub fn name(self) -> &'static str {
match self {
Stage::LatentY => "latent_y",
Stage::LatentUv => "latent_uv",
Stage::Synthesis => "synthesis",
Stage::Efl => "efl",
Stage::Icci => "icci",
Stage::Efn => "efn",
Stage::Lef => "lef",
}
}
}
/// [`decode`], also handing `observe` the float values after each [`Stage`], so two
/// machines' decodes can be compared stage by stage and not only by their final bytes
/// (a final byte hides float differences smaller than its rounding step). `observe`
/// sees the planes in the order Y, U, V (one slice for a latent stage). It does not
/// change the decode: the returned image is the one [`decode`] returns.
pub fn decode_with_stages(data: &[u8], w: &DecoderWeights, n: &NormTables,
observe: &mut dyn FnMut(Stage, &[&[f32]])) -> Option<Rgb> {
let (ph, th) = scope(data).ok()?;
if n.gain_y.len() != 160 || n.gain_uv.len() != 96 || n.per_channel == 0 {
return None;
@ -326,14 +399,17 @@ pub fn decode(data: &[u8], w: &DecoderWeights, n: &NormTables) -> Option<Rgb> {
let mcm = [&w.mcm_y[0], &w.mcm_y[1], &w.mcm_y[2], &w.mcm_y[3]];
let (y_lat, y_is, y_rvs) = decode_plane(data, Marker::Sorp, n, &w.luma,
&n.cdfs_y, &n.gain_y, Some(mcm), 160, 0);
observe(Stage::LatentY, &[&y_lat]);
let (uv_lat, ..) = decode_plane(data, Marker::Sors, n, &w.chroma,
&n.cdfs_uv, &n.gain_uv, None, 96, 160);
observe(Stage::LatentUv, &[&uv_lat]);
let mut yp = decode_y_f32(&q16(&y_lat), &w.luma.synthesis);
let mut cat = q16(&y_lat);
cat.extend(q16(&uv_lat));
let uv = decode_uv_f32(&cat, &w.chroma.synthesis);
let (mut up, mut vp) = (uv[..h * w_].to_vec(), uv[h * w_..].to_vec());
observe(Stage::Synthesis, &[&yp, &up, &vp]);
// ---- post-filters, parameters from the tools header ----
// EFElinear — `filters_best2` is the applied group (the naming is inverted).
@ -342,6 +418,7 @@ pub fn decode(data: &[u8], w: &DecoderWeights, n: &NormTables) -> Option<Rgb> {
let (mu, mv) = (efl.b1[0] as f32 / 100.0, efl.b1[1] as f32 / 100.0);
up = efl_chroma(&up, &yp, deint(f.weights_u[0]) + 1.0, deint(f.weights_u2[0]), mu);
vp = efl_chroma(&vp, &yp, deint(f.weights_v[0]) + 1.0, deint(f.weights_v2[0]), mv);
observe(Stage::Efl, &[&yp, &up, &vp]);
}
// eICCI — [0,1] domain: normalise in, scale back out.
if th.icci_enabled {
@ -349,6 +426,7 @@ pub fn decode(data: &[u8], w: &DecoderWeights, n: &NormTables) -> Option<Rgb> {
let (yo, uo, vo) = w.icci.forward(&nrm(&yp), &nrm(&up), &nrm(&vp), h, w_);
let den = |p: Vec<f32>| p.into_iter().map(|x| x * 255.0).collect::<Vec<f32>>();
yp = den(yo); up = den(uo); vp = den(vo);
observe(Stage::Icci, &[&yp, &up, &vp]);
}
// EFEnonlinear — per the header, applied to the enabled chroma plane(s).
if let Some(efn) = th.efn.as_ref() {
@ -360,6 +438,7 @@ pub fn decode(data: &[u8], w: &DecoderWeights, n: &NormTables) -> Option<Rgb> {
let wq: Vec<i64> = efn.weights_u.iter().map(|&x| x as i64).collect();
up = efn_filter(&yp, &up, &wq, efn.luma_min[0] as i64, efn.luma_max[0] as i64);
}
observe(Stage::Efn, &[&yp, &up, &vp]);
}
// LEF — luma; sigma is the raw scale surface (i_s + gain + rvs) at the named channel.
if th.lef_enabled {
@ -368,6 +447,7 @@ pub fn decode(data: &[u8], w: &DecoderWeights, n: &NormTables) -> Option<Rgb> {
.map(|p| (y_is[ch * 256 + p] + n.gain_y[ch] + y_rvs[ch * 256 + p]) as f32).collect();
let thr = [n.lef_thr[0] as f32, n.lef_thr[1] as f32, n.lef_thr[2] as f32];
yp = lef_filter(&yp, &sigma, 16, 16, thr, n.lef_mag, h, w_);
observe(Stage::Lef, &[&yp, &up, &vp]);
}
// ---- BT.709 (full-range) → RGB ----
@ -380,7 +460,7 @@ pub fn decode(data: &[u8], w: &DecoderWeights, n: &NormTables) -> Option<Rgb> {
y01 - (kb * kby / kg) * u01 - (kr * kry / kg) * v01,
y01 + kby * u01];
for (c, &val) in rgb.iter().enumerate() {
out[i * 3 + c] = (val * 255.0).round().clamp(0.0, 255.0) as u8;
out[i * 3 + c] = round_half_away_f32(val * 255.0).clamp(0.0, 255.0) as u8;
}
}
Some(Rgb { w: w_, h, data: out })

View file

@ -13,6 +13,9 @@
//! into its own four level-2 bands, i.e. `x16[i*4 .. i*4+4]` is the level-2 decomposition
//! of level-1 band `i`.
// On the JPEG AI exact decode path: no platform math-library call (see clippy.toml).
#![deny(clippy::disallowed_methods)]
/// One-level Haar DWT: `[c, h, w]` → `[4c, h/2, w/2]`, channels grouped `LL, HL, LH, HH`.
/// `h` and `w` must be even. Returns the coefficients and the output dims.
pub fn haar_dwt(x: &[f32], c: usize, h: usize, w: usize) -> (Vec<f32>, usize, usize) {

View file

@ -9,6 +9,9 @@
//! (value 0) and does not consume the stream. The state threads across calls, so a
//! single [`MansDecoder`] decodes a whole stream over many `decode_sgm` invocations.
// On the JPEG AI exact decode path: no platform math-library call (see clippy.toml).
#![deny(clippy::disallowed_methods)]
/// Low 56..63-bit masks for `flush()` (BIT_MASK_HIGH in the reference).
const BIT_MASK_HIGH: [u64; 8] = [
0x00ff_ffff_ffff_ffff, 0x01ff_ffff_ffff_ffff, 0x03ff_ffff_ffff_ffff, 0x07ff_ffff_ffff_ffff,

View file

@ -32,6 +32,10 @@
//! running the four MCM context stages over a 2×2 checkerboard, each stage predicting
//! a mean from the hyper params plus the already-reconstructed stages.
// On the JPEG AI exact decode path: no platform math-library call (see clippy.toml).
#![deny(clippy::disallowed_methods)]
use crate::float_repro::{exp, round_half_away, round_half_even};
use crate::jpegai_synthesis::{mcm_stage0, mcm_stage_n, Weights};
/// The 2×2 sub-lattice each MCM stage owns, in reference stage order:
@ -158,7 +162,12 @@ const RVS_BASE_SCALES: [i64; 4] = [115, 125, 146, 160];
/// `log_k` for the gain unit — note this is **54.82**, whereas the residual-variance
/// scale's own `log_k` uses 54.85. The two constants differ in the reference and are
/// easy to transpose; the additive table below is built with the 54.82 one.
const GAIN_LOG_K: f64 = 0.200_365_493_161_539_4; // (ln 54.82 − ln 0.11) / 31
///
/// The value is `(ln 54.82 − ln 0.11) / 31` as the reference computes it in f64:
/// each logarithm rounded to f64, then the difference, then the division.
/// `tools/wai_jpegai_gain_exp_margin.py` fails if this literal differs from that
/// computation done with correctly rounded logarithms.
pub(crate) const GAIN_LOG_K: f64 = 0.200_365_484_002_076_13; // (ln 54.82 − ln 0.11) / 31
/// Per-branch RVS scales: `round(base · 1.06)` when the channel's GRFS flag is set,
/// `round(base · 0.98)` otherwise.
@ -167,25 +176,31 @@ const GAIN_LOG_K: f64 = 0.200_365_493_161_539_4; // (ln 54.82 − ln 0.11) / 31
/// exactly `132.5` and `125 · 0.98` is exactly `122.5` in binary64, and the reference
/// (NumPy/Python `round`) resolves both to even — `132` and `122`. Rust's `f64::round`
/// would give `133`/`123` and silently select the wrong scale for one band.
/// ([`round_half_even`] is `f64::round_ties_even` built from basic operations, so no
/// math-library call is on the decode path; see [`crate::float_repro`].)
fn rvs_scales(grfs_flag: bool) -> [i64; 4] {
let mult = if grfs_flag { 1.06 } else { 0.98 };
std::array::from_fn(|i| (RVS_BASE_SCALES[i] as f64 * mult).round_ties_even() as i64)
std::array::from_fn(|i| round_half_even(RVS_BASE_SCALES[i] as f64 * mult) as i64)
}
/// Per-band RVS **dequant** multipliers in Q16, `round(2^7 · 2^16 / scale)`.
/// Applied as `resi = y_q · table[band] / 2^16` (see [`dequantize_resi`]).
fn rvs_dequant_table(grfs_flag: bool) -> [i64; 4] {
let s = rvs_scales(grfs_flag);
std::array::from_fn(|i| ((1i64 << 23) as f64 / s[i] as f64).round_ties_even() as i64)
std::array::from_fn(|i| round_half_even((1i64 << 23) as f64 / s[i] as f64) as i64)
}
/// Per-band RVS log-domain additive, `round(ln(scale/128) / (log_k / 2^7))`.
/// This reproduces [`RVS_ADDITIVE`]; the constant table is kept for decode-time cost
/// and cross-checked against this derivation in the calibration tests.
/// and cross-checked against this derivation in the calibration tests. It is the one
/// platform `ln` in this module, and the decode does not call it: the decode reads
/// [`RVS_ADDITIVE`]. (`tests/jpegai_decode_symbols.rs` checks that no `log` reaches
/// the decoder's executable.)
#[allow(clippy::disallowed_methods, reason = "off the decode path: only rvs_tables calls it")]
fn rvs_additive_table(grfs_flag: bool) -> [i64; 4] {
let s = rvs_scales(grfs_flag);
std::array::from_fn(|i| {
((s[i] as f64 / 128.0).ln() / (GAIN_LOG_K / 128.0)).round_ties_even() as i64
round_half_even((s[i] as f64 / 128.0).ln() / (GAIN_LOG_K / 128.0)) as i64
})
}
@ -199,12 +214,34 @@ fn rvs_additive_table(grfs_flag: bool) -> [i64; 4] {
/// vector this module calls `gain_per_ch`); `beta_displacement_log` comes from the
/// picture header. `scaler_precision` is `gain_vector_precision + beta_displacement_precision`
/// = `5 + 5` = 10.
///
/// This computes in f64, and `exp` and the rounding are [`crate::float_repro`]'s,
/// built from IEEE-754 basic operations rather than taken from the platform math
/// library, so the result is the same on every target.
///
/// The reference computes the same scaler in float32: `gain + β` as float32, times
/// `log_k` rounded to float32, divided by `2^7`, then the float32 `exp` of its tensor
/// library, times `2^10`, rounded half to even. The two agree when the exact value of
/// each `exp(…) · 2^10` lies far enough from a rounding boundary.
/// `tools/wai_jpegai_gain_exp_margin.py` checks this with 60-digit decimal arithmetic
/// over the gain tables exported from the reference. At β = 0, the only β the exact
/// decoder accepts, it finds the following for every entry of all eight tables:
/// - the exact value of this f64 evaluation is at least 4·10¹⁰ f64 ULPs from a
/// boundary, so a `float_repro::exp` a few ULPs from exact rounds to its integer;
/// - the exact value of the reference's evaluation, from its float32 argument, is at
/// least 77 float32 ULPs from a boundary and rounds to the same integer, so any
/// float32 `exp` within 77 ULPs gives the reference this scaler.
///
/// That does not hold for every β: at four values of `gain + β` (1115, 1922, 2285,
/// 2289) the exact value of the reference's evaluation lies within one float32 ULP of
/// a boundary, and there the reference's own scaler depends on the last bit of its
/// `exp`.
pub fn gain_unit_scaler_vec(gain_vector_log: &[i64], beta_displacement_log: i64) -> Vec<f64> {
const SCALER_PRECISION: i32 = 10;
let m = (1i64 << SCALER_PRECISION) as f64;
gain_vector_log
.iter()
.map(|&g| (((g + beta_displacement_log) as f64 * GAIN_LOG_K / 128.0).exp() * m).round() / m)
.map(|&g| round_half_away(exp((g + beta_displacement_log) as f64 * GAIN_LOG_K / 128.0) * m) / m)
.collect()
}
@ -637,9 +674,36 @@ pub fn lsbs_refine(yhat: &[f32], resi: &[f32], likely: &[i64], t0: &[i32], t1: &
}
#[cfg(test)]
#[allow(clippy::disallowed_methods, reason = "the tests compare with the host math library")]
mod tests {
use super::*;
/// The gain scaler through `float_repro::exp` equals the one through the host's
/// `exp` for every `gain + β` in [-4096, 4096], which covers every table entry
/// with every 12-bit β. Over that range each exact `exp(…) · 2^10` is at least
/// 5·10⁶ f64 ULPs from a rounding boundary, so both round to the exact value's
/// integer. This is the decoder's own f64 formula on two `exp`s. How it relates to
/// the reference's float32 scaler is the margin tool's check
/// (`tools/wai_jpegai_gain_exp_margin.py`), not this test's.
#[test]
fn gain_scaler_matches_the_host_exp_for_every_beta() {
let n: Vec<i64> = (-4096..=4096).collect();
let host: Vec<f64> = n.iter()
.map(|&g| ((g as f64 * GAIN_LOG_K / 128.0).exp() * 1024.0).round() / 1024.0)
.collect();
assert_eq!(gain_unit_scaler_vec(&n, 0), host);
}
/// `GAIN_LOG_K` is the reference's `(ln 54.82 − ln 0.11) / 31`. The host's `ln`
/// need not be correctly rounded, so this allows a few ULPs; the margin tool checks
/// the exact value.
#[test]
fn gain_log_k_is_the_reference_expression() {
let host = (54.82f64.ln() - 0.11f64.ln()) / 31.0;
let ulps = (GAIN_LOG_K.to_bits() as i64 - host.to_bits() as i64).unsigned_abs();
assert!(ulps <= 4, "GAIN_LOG_K {GAIN_LOG_K:e} is {ulps} ULPs from the host's {host:e}");
}
#[test]
fn dequant_basic() {
let yq = [2i16, 0, -3, 6];

View file

@ -27,6 +27,9 @@
//! the 16-bit PTQ quantization) on the real weights — the integer decode is faithful
//! AND deterministic (byte-identical on every machine).
// On the JPEG AI exact decode path: no platform math-library call (see clippy.toml).
#![deny(clippy::disallowed_methods)]
use crate::int_transformer as it;
use std::collections::HashMap;

View file

@ -92,6 +92,12 @@ pub mod jpegai_icci;
#[cfg(feature = "neural_int")]
pub mod jpegai_bitstream;
// Float functions (exp, rounding) built only from IEEE-754 basic operations, so their
// bits are fixed by the source rather than by a platform math library. The JPEG-AI
// exact decoder's float stages use them.
#[cfg(feature = "neural_int")]
pub mod float_repro;
// JPEG-AI whole-decoder entry point: composes the entropy, value, synthesis and
// post-filter stages into `decode(codestream, weights, norm) -> Rgb`.
#[cfg(feature = "neural_int")]

View file

@ -0,0 +1,220 @@
//! The JPEG-AI exact decoder's output, pinned byte for byte, so that a platform that
//! decodes differently fails.
//!
//! [`wai::jpegai_decode::decode`] is meant to give the same bytes on every platform:
//! its networks are integer, and its float stages use only IEEE-754 basic operations
//! in a fixed order, plus in-crate `exp` and rounding built from those operations
//! (`wai::float_repro`), so no platform math library is on the path. This test is the
//! check. It decodes the pinned `jpegai/v1` inputs and compares SHA-256 hashes, pinned
//! from a measured decode, of:
//!
//! - the float inputs as parsed (the eICCI weights and the LEF magnitudes), because
//! the JSON-to-float conversion is part of the path;
//! - the float values that [`wai::jpegai_decode::decode_with_stages`] reports after
//! each stage, bit for bit. A final byte can hide a float difference smaller than
//! its rounding step, so these are checked as well. Values inside a stage are not
//! reported, so a difference there that a later stage absorbs goes unseen;
//! - the decoded RGB8 bytes. This is the same value a reconstruction binding over the
//! decode records (SHA-256 of the pixels).
//!
//! On a mismatch it prints every hash, so the first diverging stage names where two
//! platforms part. The pinned values were measured on the platforms listed at
//! [`PINNED`]; a platform not listed there has not been measured.
//!
//! env: WAI_JPEGAI_CODESTREAM = test256.bin
//! WAI_JPEGAI_WEIGHTS_DIR = dir of {hs,hyp}_{y,uv}.json, mcm{0..3}_y.json,
//! synth_{y,uv}.json, icci.json
//! WAI_JPEGAI_NORM = norm.json
//! The inputs are the `jpegai/v1` bundle of WAI's models root; each file is checked
//! against [`INPUTS`] before decoding, because the pinned outputs hold only for those
//! bytes. Without the variables the test skips and says so on stderr; with
//! `WAI_REQUIRE_SOTA_VECTORS=1` a missing input fails it instead.
#![cfg(all(feature = "neural_int", feature = "provenance"))]
use sha2::{Digest, Sha256};
use std::io::Write;
use std::path::Path;
use wai::jpegai_decode::{decode_with_stages, DecoderWeights, NormTables, PlaneWeights};
use wai::jpegai_icci::{Conv, IcciNet, IcciPath};
use wai::jpegai_synthesis::{IntNet, Weights};
/// SHA-256 of each `jpegai/v1` input file.
const INPUTS: [(&str, &str); 13] = [
("hs_uv.json", "f3b325c583c6b7613ccddc2d89bfaa50e0e8314ceecd8d82fc4df7fb159b6083"),
("hs_y.json", "1d193f15b5d8d1531ef9ffef8676904d76cfedbd1d2df931d581dfb26daa2688"),
("hyp_uv.json", "2c5f6f12fcf9a36489553e0e02e95684c339d82bab64cb7bae76a4635f9ec56d"),
("hyp_y.json", "32ab3a153e33ded902b1d8a6c413047634bc39e8b74525dfbb40eded0763433d"),
("icci.json", "bd3aa0c23294a462895416eda38399468781163858229c386deaba58def5a1c3"),
("mcm0_y.json", "562740aba18ba4dccdc007b3d54a669ecd18feac9d0bd1fd7a29e66a0ba498ee"),
("mcm1_y.json", "fbd4b7c05e81cd32df4db4e2f6dfa4d6e3fc2edb6c21150f80ef5ac04765228d"),
("mcm2_y.json", "b42d6fa96bf865dd2db47955aba14d8d65b9152c941be3ee3e92eae5643834a7"),
("mcm3_y.json", "e06766d0a7bff16188a0e96297d6e498e28fbaf7850f68b0a29447a9391c9d9d"),
("norm.json", "5353b76f25e59f52d85adc896c9dc6b4aa8552589e3e4e1d97b8f29692e37b13"),
("synth_uv.json", "8ddf8fe9dd15569910ad5755a5495526df959835bcf86a259d06ab6445f02b4e"),
("synth_y.json", "4f2c9ae807ea22197adaae0263c9d6d4d53ef7d4fccb81848b7bb4da9ef0ea69"),
("test256.bin", "99840e5f472423f5a1261c9ec219d12dc910e9623d752672555dfea69ea4a097"),
];
/// SHA-256 of each checked value, in decode order: `float_inputs`, then one entry
/// per stage ([`wai::jpegai_decode::Stage::name`]) over the f32 bit patterns
/// (little-endian, planes Y, U, V), then `rgb` over the decoded RGB8 bytes.
///
/// Measured identical on macOS arm64 (native), wasm32-wasip2 (a WASI runtime), Linux
/// arm64 (glibc) and Linux x86_64 (glibc, under user-mode emulation);
/// `extensions/determinism-tiers.md` §6 records the toolchain and the method for each,
/// and which state of the decode's source it ran.
const PINNED: [(&str, &str); 9] = [
("float_inputs", "693740148c1269adbf058339deec3ad3aefa8dc4f4c2f0777d0778f770625af6"),
("latent_y", "5889f3806308496c64850aee04f3706178f92d1e0edcda1e6975fe256779061e"),
("latent_uv", "be89a76f726ef362f267aba3dee8f61594cc512576a3864f52f665cc5b6ab77b"),
("synthesis", "d56695726bb6ebed403199f60a79a5698734eef1e5ca2813859d490c0882d2a4"),
("efl", "1783ea77bd4f6b0ac6a599137577a18db2a86753aa3eb3d79ca987e1cf25792e"),
("icci", "3566c51c4042a749d7826df812663c4e3ff7a9e807cbd0c3bb0545c9dbd79f2b"),
("efn", "09dc833c7a6b91ce08da20adb8bbc55e2eeedba7d8a2dc4b133ab0915b267de7"),
("lef", "e2b20c72ac6a59dd12067f75562c82715061f6d7a3444ca075fc0b11e3f56468"),
("rgb", "dbc494a5a670914edf55c07c48afc0dd348cd05d3a82fff293bf384b2bb69ea3"),
];
fn required() -> bool {
std::env::var("WAI_REQUIRE_SOTA_VECTORS")
.map(|v| !matches!(v.trim().to_ascii_lowercase().as_str(), "" | "0" | "false" | "no"))
.unwrap_or(false)
}
fn hex(d: &[u8]) -> String {
d.iter().map(|b| format!("{b:02x}")).collect()
}
fn sha_f32(planes: &[&[f32]]) -> String {
let mut h = Sha256::new();
for p in planes {
for v in p.iter() {
h.update(v.to_bits().to_le_bytes());
}
}
hex(&h.finalize())
}
fn jload(p: &Path) -> serde_json::Value {
serde_json::from_slice(&std::fs::read(p).unwrap_or_else(|e| panic!("read {}: {e}", p.display())))
.unwrap_or_else(|e| panic!("parse {}: {e}", p.display()))
}
fn conv_floats(c: &Conv, out: &mut Vec<f32>) {
out.extend_from_slice(&c.w);
out.extend_from_slice(&c.b);
}
fn path_floats(p: &IcciPath, out: &mut Vec<f32>) {
conv_floats(&p.conv_first, out);
for b in &p.blocks {
conv_floats(&b.conv1, out);
conv_floats(&b.conv2, out);
out.push(b.scale);
}
conv_floats(&p.conv_last, out);
out.push(p.scale);
}
#[test]
fn decode_matches_pinned_hashes() {
let vars = ["WAI_JPEGAI_CODESTREAM", "WAI_JPEGAI_WEIGHTS_DIR", "WAI_JPEGAI_NORM"];
let got: Vec<Option<String>> = vars.iter().map(|v| std::env::var(v).ok()).collect();
if got.iter().any(Option::is_none) {
let msg = format!(
"jpegai_cross_platform: NOT VERIFIED - set {} (the jpegai/v1 inputs)",
vars.join(" + ")
);
if required() {
panic!("{msg}; WAI_REQUIRE_SOTA_VECTORS is set, so a missing input is a failure");
}
// Past libtest's capture, so the skip shows in an ordinary run.
let _ = writeln!(std::io::stderr(), "\n!!! SKIPPED - {msg}\n");
return;
}
let [cs, dir, nf] = [0, 1, 2].map(|i| got[i].clone().unwrap());
let dpath = Path::new(&dir);
// The pinned outputs hold only for these exact input bytes.
for (name, want) in INPUTS {
let p = match name {
"test256.bin" => Path::new(&cs).to_path_buf(),
"norm.json" => Path::new(&nf).to_path_buf(),
_ => dpath.join(name),
};
let bytes = std::fs::read(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display()));
assert_eq!(hex(&Sha256::digest(&bytes)), want, "{} is not the pinned jpegai/v1 {name}", p.display());
}
let w = |n: &str| Weights::from_json(&jload(&dpath.join(n)));
let net = |n: &str| IntNet::from_json(&jload(&dpath.join(n)));
let icci = IcciNet::from_json(&jload(&dpath.join("icci.json")));
let nv = jload(Path::new(&nf));
let i64v = |k: &str| nv[k].as_array().unwrap().iter().map(|x| x.as_i64().unwrap()).collect::<Vec<i64>>();
let u8v = |k: &str| nv[k].as_array().unwrap().iter().map(|x| x.as_u64().unwrap() as u8).collect::<Vec<u8>>();
let i32v = |k: &str| nv[k].as_array().unwrap().iter().map(|x| x.as_i64().unwrap() as i32).collect::<Vec<i32>>();
let a3 = |k: &str| nv[k].as_array().unwrap().clone();
let (thr, mag) = (a3("lef_thr0"), a3("lef_mag0"));
let norm = NormTables {
pmf: nv["pmf"].as_array().unwrap().iter()
.map(|r| r.as_array().unwrap().iter().map(|x| x.as_i64().unwrap()).collect()).collect(),
bounds: u8v("bounds"),
per_channel: nv["per_channel"].as_u64().unwrap() as usize,
cdfs_y: u8v("cdfs_y"), cdfs_uv: u8v("cdfs_uv"),
gain_y: i64v("gain_y"), gain_uv: i64v("gain_uv"),
lsbs_t0: i32v("lsbs_t0"), lsbs_t1: i32v("lsbs_t1"),
lef_thr: [0, 1, 2].map(|i| thr[i].as_i64().unwrap()),
lef_mag: [0, 1, 2].map(|i| mag[i].as_f64().unwrap() as f32),
};
let mut float_inputs = Vec::new();
path_floats(&icci.luma, &mut float_inputs);
path_floats(&icci.chroma, &mut float_inputs);
conv_floats(&icci.conv_last_v, &mut float_inputs);
float_inputs.push(icci.scale_v);
float_inputs.extend_from_slice(&norm.lef_mag);
let mut hashes: Vec<(String, String)> = vec![("float_inputs".into(), sha_f32(&[&float_inputs]))];
let weights = DecoderWeights {
luma: PlaneWeights { hyper_scale: net("hs_y.json"), hyper_decoder: w("hyp_y.json"), synthesis: w("synth_y.json") },
chroma: PlaneWeights { hyper_scale: net("hs_uv.json"), hyper_decoder: w("hyp_uv.json"), synthesis: w("synth_uv.json") },
mcm_y: [w("mcm0_y.json"), w("mcm1_y.json"), w("mcm2_y.json"), w("mcm3_y.json")],
icci,
};
let data = std::fs::read(&cs).unwrap();
let img = decode_with_stages(&data, &weights, &norm, &mut |stage, planes| {
hashes.push((stage.name().into(), sha_f32(planes)));
})
.expect("the jpegai/v1 codestream is in scope and the FP environment is the default");
assert_eq!((img.w, img.h, img.data.len()), (256, 256, 256 * 256 * 3));
hashes.push(("rgb".into(), hex(&Sha256::digest(&img.data))));
let refrgb = u8v("ref_rgb");
let (maxd, off) = img.data.iter().zip(&refrgb).fold((0i32, 0usize), |(m, o), (g, r)| {
let d = (*g as i32 - *r as i32).abs();
(m.max(d), o + (d != 0) as usize)
});
let env = if cfg!(target_env = "gnu") { "-gnu" } else if cfg!(target_env = "musl") { "-musl" }
else if cfg!(target_env = "p2") { "-p2" } else { "" };
let os = if cfg!(target_os = "wasi") { "wasi" } else { std::env::consts::OS };
let platform = format!("{}-{os}{env}", std::env::consts::ARCH);
eprintln!("jpegai_cross_platform on {platform}: vs reference max|Δ| = {maxd}, {off}/{} samples differ",
refrgb.len());
let mut first_diff = None;
for (i, (name, got)) in hashes.iter().enumerate() {
let want = PINNED.iter().find(|(n, _)| n == name).map(|(_, h)| *h);
let ok = want == Some(got.as_str());
if !ok && first_diff.is_none() {
first_diff = Some(name.clone());
}
eprintln!(" {:<2} {:<13} {got} {}", i, name, if ok { "ok" } else { "DIFFERS" });
}
let names: Vec<&str> = hashes.iter().map(|(n, _)| n.as_str()).collect();
let pinned_names: Vec<&str> = PINNED.iter().map(|(n, _)| *n).collect();
assert_eq!(names, pinned_names, "the decode reported a different set of stages than was pinned");
assert!(first_diff.is_none(),
"the decode on {platform} differs from the pinned decode, first at `{}`",
first_diff.unwrap_or_default());
eprintln!(" all {} hashes match the pinned decode", hashes.len());
}

View file

@ -0,0 +1,243 @@
//! The JPEG AI exact decoder's executable imports no C math-library function, and
//! defines none but the exactly specified ones.
//!
//! [`wai::jpegai_decode::decode`] is meant to give the same bytes on every platform
//! because its float stages use only IEEE-754 basic operations, with `exp` and rounding
//! built from them in `wai::float_repro`. A math-library function on that path (`exp`,
//! `log`, `pow`, `fmod`, ...) would tie the output to whichever library a platform
//! links. The pinned-output tests need not notice: the decoder rounds its float stages,
//! and a last-bit difference can vanish in the rounding on the platforms measured.
//!
//! This test checks the linked executable rather than the source, so the way the call
//! is spelled does not matter: a method call, `f64::exp(x)`, a function path, a float
//! `%` (which compiles to `fmod`), or a call made in another module or crate all leave a
//! symbol. It is a program (`harness = false` in Cargo.toml, so none of the test
//! harness's own code is in the executable) that takes the addresses of the exact
//! decoder's public entry points and of the loaders for its weights. The linker keeps
//! those and everything they reach: rustc links every executable with unreferenced
//! sections removed (`--gc-sections`, `-dead_strip`). The program then lists its own
//! executable's symbols with `nm` and fails on:
//! - any C math-library function the executable imports, which is a call into the
//! platform's math library;
//! - any it defines, as Rust's compiler builtins do for some functions on x86_64 Linux,
//! except the round-to-integral functions (`ceil`, `floor`, `trunc`, `round`,
//! `roundeven`, `rint`, `nearbyint`, and their `f`/`l` forms).
//!
//! A definition shows that the linker kept a function, not that anything calls it. On
//! x86_64 Linux, GNU ld (2.40, debug build) kept the compiler builtins' `floor` in this
//! executable although nothing in it referred to `floor`: the only reference had been
//! in a section the linker removed as unused, from code the decoder does not reach. In
//! a minimal program, GNU ld kept such an unused definition only when the C math library
//! was also linked, which other code in the crate's object files can cause. The same
//! build linked with LLD held no `floor`. So a definition of a function whose result is
//! the same whatever computes it does not fail the test: IEEE 754 fixes the
//! round-to-integral results bit for bit (`rint` and `nearbyint` in the current rounding
//! direction, which the decoder checks is the default). The program prints the ones it
//! allows. Every other math function, defined or imported, still fails.
//!
//! Also allowed: `sqrt`, `fabs` and `copysign` and their `f`/`l` forms, defined or
//! imported. IEEE 754 fixes their results bit for bit, like the other basic operations,
//! so the decode may use them however the target compiles them.
//!
//! Two controls keep an incomplete listing from passing. The executable must show the
//! decoder's own `jpegai_decode::decode` symbol, so the decode was linked and the
//! listing includes the executable's own symbols; and it must import `malloc` (Rust's
//! allocator), so the listing includes imports.
//!
//! Scope:
//! - Unix targets, with `nm` on the PATH (it ships with the linker toolchain these
//! targets link with). On other targets (Windows, wasm32) the program prints
//! `SKIPPED` and checks nothing.
//! - It sees calls, not instructions. A rounding the target does with an instruction
//! (AArch64 `frinta`) leaves no symbol. `exp` and the other transcendentals have no
//! instruction on these targets and always leave one.
//! - It does not enforce, where the compiler builtins define the rounding functions
//! (x86_64 Linux), that the decoder's roundings come from `float_repro`; clippy does
//! that in the decode-path modules. A rounding call that slips past it cannot change
//! the output, for the reason above.
//! - It checks the build it is part of (the profile and features `cargo test` was run
//! with). CI runs it in the debug profile.
//! - `clippy.toml` (`disallowed-methods`) is the source-level guard for the same rule.
//! It names the offending line, but sees only method calls, in the modules that deny
//! the lint.
use std::hint::black_box;
use wai::jpegai_decode::{self, DecoderWeights, NormTables, Rgb, Stage};
use wai::jpegai_icci::IcciNet;
use wai::jpegai_synthesis::{IntNet, Weights};
type Decode = fn(&[u8], &DecoderWeights, &NormTables) -> Option<Rgb>;
type DecodeWithStages =
fn(&[u8], &DecoderWeights, &NormTables, &mut dyn FnMut(Stage, &[&[f32]])) -> Option<Rgb>;
type Check = fn(&[u8]) -> Result<(), &'static str>;
/// Take the address of every entry point a sink uses to decode, through `black_box`,
/// so the compiler must emit them and the linker must keep them and what they call.
/// Nothing is run.
fn link_the_decoder() {
black_box(jpegai_decode::decode as Decode);
black_box(jpegai_decode::decode_with_stages as DecodeWithStages);
black_box(jpegai_decode::check_scope as Check);
black_box(jpegai_decode::check_codestream as Check);
black_box(jpegai_decode::fp_env_is_default as fn() -> bool);
black_box(Weights::from_json as fn(&serde_json::Value) -> Weights);
black_box(IntNet::from_json as fn(&serde_json::Value) -> IntNet);
black_box(IcciNet::from_json as fn(&serde_json::Value) -> IcciNet);
}
/// C99 `<math.h>` functions, besides the allowed ones. Each is also checked with the
/// `f` and `l` suffixes.
const MATH_FUNCTIONS: [&str; 66] = [
"acos", "acosh", "asin", "asinh", "atan", "atan2", "atanh", "cbrt", "ceil", "cos",
"cosh", "erf", "erfc", "exp", "exp10", "exp2", "expm1", "fdim", "floor", "fma", "fmax",
"fmin", "fmod", "frexp", "gamma", "hypot", "ilogb", "j0", "j1", "jn", "ldexp",
"lgamma", "llrint", "llround", "log", "log10", "log1p", "log2", "logb", "lrint",
"lround", "modf", "nearbyint", "nextafter", "nexttoward", "pow", "pow10", "remainder",
"remquo", "rint", "round", "roundeven", "scalb", "scalbln", "scalbn", "sin", "sincos",
"sinh", "tan", "tanh", "tgamma", "trunc", "y0", "y1", "yn", "significand",
];
/// Other names under which a math function reaches an executable: the integer-power
/// helpers from the compiler's runtime library (`powi`), and platform variants.
const OTHER_MATH_SYMBOLS: [&str; 8] = [
"__powisf2", "__powidf2", "__powitf2", "__sincos_stret", "__sincosf_stret",
"__exp10", "__exp10f", "__sinpi",
];
/// A symbol name as `nm` prints it, reduced to the C name: without the version
/// (`exp@GLIBC_2.29`, `exp@@GLIBC_2.29`) and, on Apple targets, without the leading
/// underscore Mach-O adds to C names.
fn c_name(sym: &str) -> &str {
let s = sym.split('@').next().unwrap_or(sym);
if cfg!(target_vendor = "apple") { s.strip_prefix('_').unwrap_or(s) } else { s }
}
/// True if `name` (a C name) is a math-library function this test checks.
fn is_math_function(name: &str) -> bool {
let base = name.strip_suffix("_finite").map(|b| b.strip_prefix("__").unwrap_or(b)).unwrap_or(name);
OTHER_MATH_SYMBOLS.contains(&name)
|| MATH_FUNCTIONS.iter().any(|f| {
base == *f
|| base.strip_suffix('f') == Some(f)
|| base.strip_suffix('l') == Some(f)
})
}
/// The round-to-integral functions, which IEEE 754 fixes bit for bit. A definition of
/// one of these is allowed (see the module doc); an import is not.
const ROUND_TO_INTEGRAL: [&str; 7] = ["ceil", "floor", "trunc", "round", "roundeven", "rint", "nearbyint"];
fn is_round_to_integral(name: &str) -> bool {
ROUND_TO_INTEGRAL.iter().any(|f| {
name == *f || name.strip_suffix('f') == Some(f) || name.strip_suffix('l') == Some(f)
})
}
/// `nm -P` type letters for a symbol the executable does not define: `U`, and the
/// lower-case weak forms.
fn is_import(kind: &str) -> bool {
matches!(kind, "U" | "w" | "v")
}
/// True if the symbol `sym` of `nm` type `kind` fails the test.
fn forbidden(sym: &str, kind: &str) -> bool {
let name = c_name(sym);
is_math_function(name) && (is_import(kind) || !is_round_to_integral(name))
}
/// The classifier recognises the forms `nm` prints, and lets the allowed and unrelated
/// names through, before it is trusted with the executable.
fn check_the_classifier() {
let apple = cfg!(target_vendor = "apple");
let c = |s: &'static str| -> String { if apple { format!("_{s}") } else { s.to_string() } };
let exp = if apple { "_exp".to_string() } else { "exp@GLIBC_2.29".to_string() };
for (sym, kind, want) in [
(exp, "U", true),
(if apple { c("fmodf") } else { "fmodf@@GLIBC_2.38".to_string() }, "U", true),
(c("fmod"), "W", true),
(c("exp"), "T", true),
(c("roundf"), "U", true),
(if apple { c("floor") } else { "floor@GLIBC_2.2.5".to_string() }, "U", true),
(c("floor"), "w", true),
(c("floor"), "W", false),
(c("roundf"), "W", false),
(c("rint"), "T", false),
(c("lround"), "W", true),
(c("__powidf2"), "T", true),
(if apple { c("__exp_finite") } else { "__exp_finite@GLIBC_2.15".to_string() }, "U", true),
(if apple { c("sqrt") } else { "sqrt@GLIBC_2.2.5".to_string() }, "U", false),
(c("fabsf"), "W", false),
(c("copysign"), "T", false),
(if apple { c("malloc") } else { "malloc@GLIBC_2.2.5".to_string() }, "U", false),
("_ZN3wai8float_repro3exp17h0123456789abcdefE".to_string(), "T", false),
(c("expand_macro"), "T", false),
] {
assert_eq!(forbidden(&sym, kind), want, "classifier on {sym:?} ({kind})");
}
}
#[cfg(unix)]
fn main() {
link_the_decoder();
check_the_classifier();
let exe = std::env::current_exe().expect("path of this test executable");
let out = std::process::Command::new("nm")
.arg("-P")
.arg(&exe)
.output()
.unwrap_or_else(|e| panic!("cannot run `nm` ({e}); it ships with the linker toolchain \
(binutils, or the Xcode command line tools)"));
assert!(out.status.success(), "nm -P {} failed: {}", exe.display(),
String::from_utf8_lossy(&out.stderr));
let listing = String::from_utf8_lossy(&out.stdout);
// `nm -P`: one symbol per line, `name type [value [size]]`.
let symbols: Vec<(&str, &str)> = listing
.lines()
.filter_map(|l| {
let mut f = l.split_whitespace();
Some((f.next()?, f.next()?))
})
.collect();
let decoder_linked = symbols.iter().any(|(s, _)| s.contains("13jpegai_decode6decode"));
let imports_listed = symbols.iter().any(|(s, t)| *t == "U" && c_name(s) == "malloc");
assert!(decoder_linked,
"{}: no `jpegai_decode::decode` symbol among {} listed, so this check cannot say \
what the decoder links (a stripped executable?)", exe.display(), symbols.len());
assert!(imports_listed,
"{}: `malloc` is not listed as an import among {} symbols, so the listing does not \
show imports", exe.display(), symbols.len());
let describe = |(s, t): &(&str, &str)| {
format!("{s} ({})", if is_import(t) { "imported" } else { "defined here" })
};
let found: Vec<String> = symbols.iter().filter(|(s, t)| forbidden(s, t)).map(describe).collect();
assert!(found.is_empty(),
"the JPEG AI exact decoder's executable holds C math-library functions: {}. Something \
the decode reaches calls the platform math library; use wai::float_repro or basic \
operations instead", found.join(", "));
let allowed: Vec<String> = symbols
.iter()
.filter(|(s, t)| is_math_function(c_name(s)) && !forbidden(s, t))
.map(describe)
.collect();
let allowed = if allowed.is_empty() {
String::new()
} else {
format!(" (allowed, exactly specified: {})", allowed.join(", "))
};
eprintln!("jpegai_decode_symbols: {} symbols in {}; the decoder is linked; no C \
math-library function the test forbids among them{allowed}",
symbols.len(), exe.display());
}
#[cfg(not(unix))]
fn main() {
link_the_decoder();
check_the_classifier();
eprintln!("jpegai_decode_symbols: SKIPPED, not a pass: this check reads the executable's \
symbols with `nm`, which it runs on unix targets only");
}