From d009db9087963bbfaf624087ee59bc5691a7bb26 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 28 Sep 2026 18:50:26 -0400 Subject: [PATCH 1/4] wai: on-chip counters and calibrated instruments are separate energy classes, and every joule-carrying receipt can say which MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single `HwShunt` acquisition class covered both an on-chip vendor counter (RAPL, NVML, SMC, IOReport) and an external calibrated shunt. They are different evidence: a calibrated instrument's uncertainty is traceable to a calibration, while a counter is a vendor's reading whose error the vendor states (RAPL readings are deliberately noised when SGX or TDX is enabled, per security advisory INTEL-SA-00389). - extensions/energy-measurement.md §2 splits hardware evidence into `OnChipCounter` and `CalibratedInstrument`, each with a required declared uncertainty (relative error and integration window). IOReport is an on-chip counter, like RAPL and NVML. `HwShunt` stays readable as the legacy class, ranked below both, and is not sealed into a new WAI receipt. - Every receipt family that carries joules — video, world, film, reel, haptic, score, splat4d, audio scene, ternary, provenance and the wai-quantum receipts — can carry its class. The label is additive: every committed signed vector still verifies, and the signed bytes of unlabelled and legacy-labelled receipts are pinned by tests. A sealed figure declares its 1 µJ resolution, and the floor is stated as the rule verified (0.5/√3 µJ). - The acquisition rules are scoped to the receipt profiles WAI defines, and the text says what enforces each one. - Meters open their window from rest, report an all-zero power reader as unusable rather than clean, and state the running-window count model as conservative. - oer-receipts.md maps OER's evidential grade onto the refined classes. The rule that a producer must not grade `measured` a figure whose class is `Estimator` is kept as settled on main. - wai-quantum verifies a receipt in either form on every in-tree verify path, including wai-quantum-serve's POST /verify. - CI: wai-energy-receipts.yml runs the energy-class, label and legacy-pin tests for wai-rs, wai-quantum and wai-quantum-serve, which no workflow enabled before. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-conformance.yml | 6 + .github/workflows/wai-energy-receipts.yml | 134 ++ wai/CHANGELOG.md | 202 +++ wai/README.md | 8 +- wai/SPEC.md | 13 +- wai/extensions/avatars.md | 4 +- wai/extensions/energy-binding.md | 36 +- wai/extensions/energy-measurement.md | 669 ++++++++- wai/extensions/haptics.md | 6 +- wai/extensions/interactive-worlds.md | 11 +- wai/extensions/jwp-receipts.md | 2 +- wai/extensions/linear-film.md | 6 +- wai/extensions/moq-streaming-format.md | 9 + wai/extensions/oer-receipts.md | 63 +- wai/extensions/provenance.md | 9 + wai/extensions/quantum-ops.md | 7 +- wai/extensions/quantum-sim.md | 9 +- wai/extensions/score.md | 6 +- wai/extensions/spatial-audio.md | 4 +- wai/extensions/state-feeds.md | 4 +- wai/extensions/ternary-tt.md | 4 + wai/extensions/volumetric.md | 6 +- wai/wai-quantum-serve/src/lib.rs | 110 +- wai/wai-quantum/README.md | 11 + wai/wai-quantum/src/lib.rs | 10 + wai/wai-quantum/src/quantum_energy.rs | 870 +++++++++++ wai/wai-quantum/src/quantum_ops.rs | 152 +- wai/wai-quantum/src/quantum_phasor_meter.rs | 29 +- wai/wai-quantum/src/quantum_qbom.rs | 38 +- wai/wai-quantum/src/quantum_receipt.rs | 41 +- wai/wai-quantum/src/receipt_pins.rs | 524 +++++++ wai/wai-rs/include/wai.h | 36 + wai/wai-rs/src/audio_scene/receipt.rs | 44 +- wai/wai-rs/src/bin/wai_meter.rs | 182 ++- wai/wai-rs/src/bin/wai_prov.rs | 107 +- wai/wai-rs/src/bin/wai_quantum.rs | 40 +- wai/wai-rs/src/bin/wai_quantum_meter.rs | 164 +-- wai/wai-rs/src/bin/wai_video_meter.rs | 240 +--- wai/wai-rs/src/bin/wai_world_meter.rs | 143 +- wai/wai-rs/src/c2pa_emit.rs | 109 +- wai/wai-rs/src/energy_binding.rs | 348 ++++- wai/wai-rs/src/energy_burst.rs | 546 +++++++ wai/wai-rs/src/energy_class.rs | 1432 +++++++++++++++++++ wai/wai-rs/src/energy_meter.rs | 335 ++++- wai/wai-rs/src/film/receipt.rs | 49 +- wai/wai-rs/src/haptic/receipt.rs | 49 +- wai/wai-rs/src/lib.rs | 36 +- wai/wai-rs/src/provenance.rs | 85 +- wai/wai-rs/src/receipt_pins.rs | 425 ++++++ wai/wai-rs/src/score/receipt.rs | 49 +- wai/wai-rs/src/splat4d/receipt.rs | 49 +- wai/wai-rs/src/ternary_receipt.rs | 58 +- wai/wai-rs/src/video_receipt.rs | 76 +- wai/wai-rs/src/wai_video_channel.rs | 32 +- wai/wai-rs/src/wai_video_keys.rs | 35 +- wai/wai-rs/src/wai_video_manifest.rs | 175 ++- wai/wai-rs/src/wai_video_package.rs | 33 +- wai/wai-rs/src/wai_video_ssai.rs | 35 +- wai/wai-rs/src/world/receipt.rs | 54 +- wai/wai-rs/src/world_model.rs | 6 +- wai/wai-rs/tests/video_receipt.rs | 81 +- wai/wai-web/src/quantum_ops.rs | 10 +- 62 files changed, 7168 insertions(+), 898 deletions(-) create mode 100644 .github/workflows/wai-energy-receipts.yml create mode 100644 wai/wai-quantum/src/quantum_energy.rs create mode 100644 wai/wai-quantum/src/receipt_pins.rs create mode 100644 wai/wai-rs/src/energy_burst.rs create mode 100644 wai/wai-rs/src/energy_class.rs create mode 100644 wai/wai-rs/src/receipt_pins.rs diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index 2a3ad299b..f3cfa2cd1 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -16,12 +16,18 @@ on: branches: [main] paths: - 'wai/wai-rs/**' + # wai-rs path-depends on wai-quantum, and the quantum corpora below are + # verified through it: a change there changes what this workflow checks. + - 'wai/wai-quantum/**' - 'wai/*-conformance/**' - 'wai/byte-exact-conformance/**' - '.github/workflows/wai-conformance.yml' pull_request: paths: - 'wai/wai-rs/**' + # wai-rs path-depends on wai-quantum, and the quantum corpora below are + # verified through it: a change there changes what this workflow checks. + - 'wai/wai-quantum/**' - 'wai/*-conformance/**' - 'wai/byte-exact-conformance/**' - '.github/workflows/wai-conformance.yml' diff --git a/.github/workflows/wai-energy-receipts.yml b/.github/workflows/wai-energy-receipts.yml new file mode 100644 index 000000000..98eb88a15 --- /dev/null +++ b/.github/workflows/wai-energy-receipts.yml @@ -0,0 +1,134 @@ +name: WAI energy receipts + +# Tests of the energy acquisition class (extensions/energy-measurement.md §2): +# the class rules, the optional labels, the resolution floor, and the pins that +# hold legacy receipts (unlabelled, or labelled with the legacy HwShunt) to the +# signed bytes they already had. Three steps, on linux (x86_64), linux-arm64 +# and macos (arm64): +# +# 1. wai-rs: `cargo test --locked` with the feature set in the step, over +# --lib, --bins and --test video_receipt. That runs the library's unit +# tests under those features (energy_class, energy_burst, energy_meter, +# energy_binding, the label and pin tests of the wai-rs receipt families, +# the video and OTT receipt tests); the unit tests of every bin target +# the set can build (one whose required features are all on), which also +# proves those bins compile; and the one integration test file +# tests/video_receipt.rs. +# 2. wai-quantum: `cargo test --locked --features full --lib`: the crate's +# unit tests, among them the pins and label tests of every quantum +# receipt. The tests already marked #[ignore] stay ignored. +# 3. wai-quantum-serve: `cargo test --locked --lib`, native: the verdict +# logic behind POST /verify, for a receipt in either form. +# +# It does NOT: +# - check that a newly sealed receipt in a family with an optional label +# (§2.8) is labelled. A verifier cannot: there an unlabelled receipt +# sealed today has the bytes of a legacy one. A test of each producer's +# labelling path could, and none is written; those families' +# constructors take no class, and a label is added afterwards. It checks +# the label rules for the receipts that carry one, and that four of the +# five reference meter bins still build (wai_video_meter, wai_meter, +# wai_world_meter, wai_quantum_meter). +# - build wai_prov (needs `codecs`, which links system libjxl) or the +# wai-quantum CLI bin (needs quantum_toolchain, quantum_qir, +# quantum_channel, quantum_comm and quantum_qudit). Cargo skips a bin whose +# required features are off without saying so. +# - run any other integration test in wai/wai-rs/tests (world_model and +# world_replay among them), or a doc-test of any of the three crates. +# - compile c2pa_emit, which carries the energy binding into a C2PA +# manifest; the c2pa-emitter job of wai-conformance.yml tests it. Nor +# `oer`: an OER receipt grades its figure and carries no acquisition +# class, and energy-measurement.md §1 leaves it out of scope. +# - test the JWP group and object receipts. joule-code's joule-moq seals +# them with no class, and energy-measurement.md §1 records that as open. +# - build wai-web, or the wasm32-wasip2 component of wai-quantum-serve. +# - verify the conformance corpora; wai-conformance.yml does that. +# - run on Windows. The pinned bytes are integers and hashes and make no +# OS-specific claim, and the conformance matrix covers Windows for the +# byte-exact decode. +# +# It triggers only on changes under the paths listed below; a change to +# wai-web alone does not trigger it. + +on: + push: + branches: [main] + paths: + - 'wai/wai-rs/**' + - 'wai/wai-quantum/**' + - 'wai/wai-quantum-serve/**' + - 'wai/extensions/**' + - 'wai/SPEC.md' + - 'wai/CHANGELOG.md' + - '.github/workflows/wai-energy-receipts.yml' + pull_request: + paths: + - 'wai/wai-rs/**' + - 'wai/wai-quantum/**' + - 'wai/wai-quantum-serve/**' + - 'wai/extensions/**' + - 'wai/SPEC.md' + - 'wai/CHANGELOG.md' + - '.github/workflows/wai-energy-receipts.yml' + +jobs: + energy-receipts: + name: energy receipts · ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [linux, linux-arm64, macos] + steps: + - uses: actions/checkout@v5 + + # The docker runners (linux / linux-arm64) ship no Rust, so rustup is + # bootstrapped; the macOS host runner has it but launchd's PATH omits + # ~/.cargo/bin. + - name: Install Rust 1.98.0 + shell: bash + run: | + export PATH="$HOME/.cargo/bin:$PATH" + command -v rustup >/dev/null 2>&1 || \ + 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 + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + # One feature set that compiles the wai-rs receipts that carry an + # acquisition class, except the C2PA emitter (see the header): the video + # and OTT receipts and their legacy pins, the energy binding, the energy + # meters, the optional-label families and their pins, and the quantum + # re-exports with the cross-crate encoding test. --bins compiles every + # bin whose required features this set enables — among them four of the five + # reference meters (wai_video_meter, wai_meter, wai_world_meter, + # wai_quantum_meter) — so one of those that stops building fails here. + # The fifth, wai_prov, is NOT built by this or any workflow: it requires + # the `codecs` feature, which links system libjxl (>= 0.11), and no + # workflow enables `codecs` or installs libjxl. Cargo skips a bin whose + # required features are off without saying so. Its metering is + # energy_burst, whose tests run in the lib suite; the bin itself is + # checked only by hand. + - name: wai-rs — energy classes, labels and legacy pins + working-directory: wai/wai-rs + shell: bash + run: | + cargo test --locked --no-default-features \ + --features world,audio_scene,haptic,splat4d,score,provenance,film,ternary_receipt,world_model,video_receipt,video_workflow,energy_meter,quantum_receipt,quantum_ops,quantum_qbom,quantum_phasor_meter \ + --lib --bins --test video_receipt + + # wai-quantum is published separately; its receipts' legacy pins and the + # labelled forms are tested in its own workspace, against its own lock. + - name: wai-quantum — receipt pins and labels + working-directory: wai/wai-quantum + shell: bash + run: cargo test --locked --features full --lib + + # The HTTP handler's POST /verify must accept a receipt in either form — + # as sealed, or labelled as wai_quantum_meter emits it. The verdict + # logic is a plain function, tested natively; the wasm32-wasip2 build of + # the component is not part of this workflow. + - name: wai-quantum-serve — POST /verify accepts either form + working-directory: wai/wai-quantum-serve + shell: bash + run: cargo test --locked --lib diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 9ae761bd7..6f49d38cb 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,208 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### Energy acquisition — the receipts wai-rs and wai-quantum seal can carry their class; meter windows open from rest + +- [`extensions/energy-measurement.md`] §2.8: the world-session, film and reel, + haptic, score, 4D-splat, audio-scene, ternary-reconstruction and + provenance-step receipts, and every `wai-quantum` receipt, carry the + acquisition class as an **optional signed label**. Absent, a receipt's signed + bytes, identity hash and JSON are exactly the legacy ones, and the figure is + unlabelled: accepted as legacy, never read as any class. Present, the signing + domain's version byte becomes `0x02` and the class's §2.7 encoding follows + the figure it labels, inside the signature. A label is refused as legacy + `HwShunt`, beside a zero figure, or as a refined class without its evidence. + Provenance labels each step, and a quantum bill of materials each entry. +- **Wire:** unlabelled receipts are unchanged; tests pin one receipt of every + family to the bytes it had before the label existed, and every committed + conformance corpus still verifies. JSON evidence fields are now + `energy_`-prefixed (`energy_counter_filtering`, `energy_calibration_ref`) in + every snake_case carrier, including the rendered `wai.video.int_motion` + receipt: a quantum job receipt already renders `calibration_ref` for the + calibration receipt it ran under. §2.7 now fixes every field name. +- §2.9: IOReport is classed `OnChipCounter` — the silicon's own reading, whose + undisclosed derivation is part of the systematic error an `OnChipCounter` + declaration already excludes. The section lists the documents elsewhere in + the repository that class IOReport as `ModelBased` or `Estimator`, which this + revision does not change. +- §2.3 and §3: the completion-count error depends on how the burst window + opens. The reference meters read the count the moment their workers started, + with no barrier, so the count read low by about `N/2` and every per-unit + figure read **high** by about `N/(2C)` — the symmetric model did not describe + it. Workers are now held at a start barrier and released as the window opens: + the work lies in `[C, C + N)`, the figure divides by `C + N/2`, and the + declared term is `N / (√3 · (2C + N))`. The window-opening mode is part of + what a published figure reports. +- §2.3: a declaration describes the figure as sealed, and `joules_micro` is a + whole number of microjoules, so the declaration includes the figure's own + resolution: `0.5/√3` µJ for an estimate rounded to the nearest microjoule, + `1/√6` µJ for the difference of two whole-microjoule register readings. The + measurement's own terms are carried across in microjoules and the sum is + divided by the figure sealed. A 0.563 µJ estimate measured to 1.54 % is + sealed as `1` and declared 28.9 %. Every verifier refuses a hardware class + with `joules_micro × relative_ppm < 288 676`, a declaration smaller than the + integer's own rounding. The receipts built on `energy_class`, every + optional-label family, the `wai-quantum` labels and the C2PA energy binding + (bound or parsed) apply that check. +- §2.8: `quantum_energy::MaybeLabelled` parses and verifies a quantum receipt + in whichever form it arrives. It takes the form from the JSON and refuses a + malformed label rather than reading the receipt as unlabelled. The + `wai-quantum verify` CLI and the HTTP handler's `POST /verify` use it, so the + labelled receipt `wai_quantum_meter` emits for a measured figure verifies on + both, and both restate the class. `MaybeLabelled::verify_against_calibration` + checks a job's link to a calibration in either form, by the identity of the + form the calibration is in; the `wai-web` job demo accepts a labelled + calibration the same way. +- §2.2: the `OCMF` example now names what the regime rests on (a + conformity-assessed, legally verified measuring system, not a calibration + certificate); `OnChipCounter` is defined to cover any instrument whose + uncertainty is not traceable, and the class is stated not to record where + the sensor sits. §2.1 names the GREEN draft and its revision. §2 places the + three-name mapping rule on producers. [`extensions/energy-binding.md`] and + [`extensions/moq-streaming-format.md`] say "refined hardware class" where + they meant it, so legacy `HwShunt` is not read as missing its evidence. +- Reference implementation: `energy_class` gains the label rules + (`labelled_domain`, `write_label`, `check_label`, the JSON helpers) and the + window-opening model (`CompletionCount`, `WindowOpening`). Each `wai-rs` + family gains an `energy_provenance` field and `with_energy_class()`, which + re-signs with the receipt's own key; `ProvStep` gains the same. + `wai-quantum` adds `quantum_energy` without changing any existing type: + `Labelled` and `LabelledQbom` re-sign a sealed receipt over the labelled + payload, and the inner receipt's own `verify()` refuses the result, so a + reader unaware of labels fails closed. The new `energy_burst` module runs §3 + once for every meter; `wai_meter`, `wai_world_meter`, `wai_quantum_meter`, + `wai_prov` and `wai_video_meter` now seal `OnChipCounter` with a declared + uncertainty only when §4 and §2.3 allow, and seal unmetered otherwise. The + world and quantum meters no longer count in batches, and the reported + standard deviations are the sample ones the declared uncertainty uses. Each + meter prints the resolution term and, when it seals unmetered, why. +- **Rust API (`wai`):** the receipt structs of the families above gain a + public field, and `per_unit_relative_uncertainty` takes a `CompletionCount`. + `wai_meter`, `wai_world_meter` and `wai_prov` now emit an unmetered receipt + instead of exiting when no power reader is present. +- CI: a new `wai-energy-receipts.yml` runs the video/energy features, the + label and pin tests of every family, `wai-quantum`'s own tests and the HTTP + handler's verify test, on x86_64 and arm64. It builds four of the five + reference meters (`wai_video_meter`, `wai_meter`, `wai_world_meter`, + `wai_quantum_meter`). It does not build `wai_prov`, which needs the `codecs` + feature (system libjxl); no workflow enables that feature. `wai-conformance.yml` now + also triggers on `wai-quantum` changes, which its quantum corpora depend on. +- §2 and §2.8 now say what is enforced. The verifier rules are: a label is + checked wherever one is present, and a receipt defined with a class from the + start is refused when it carries a non-zero figure without one. Labelling a + new figure in an optional-label family is a **SHOULD**, where it was a + **MUST** that nothing checked and the reference implementation did not + meet. An unlabelled figure sealed today has the bytes of an issued one, so + no verifier can tell them apart. The reference implementation does not hold + its producers to the rule either: in these families no constructor takes + the class, and a label is added afterwards, by re-signing, or for a + provenance step before its receipt is built. §2.8 says how a library could + hold its callers to it: a labelled constructor, with the unlabelled path + kept only for recreating issued receipts and deprecated, as `HwShunt` is. + The reference implementation has not done that. + The reference meters label every non-zero figure they seal. §2.8 names the + two producers that do not: `world_model::frame_receipt`, which is not told + its caller's classes, and the `wai-web` demos, which seal illustrative + figures. It also says why a corpus generator that recreates a committed + corpus, such as `wai_quantum_ops_vectors gen`, is not counted: every + committed corpus predates the label. +- [`extensions/energy-measurement.md`] §1 states its scope: every receipt + profile WAI defines that carries an energy figure, a list that is not + closed. An OER/2 receipt is outside it. Its members are fixed, it has no + member for the class, and its evidential axis is the grade. No rule of the + extension binds an OER receipt or its producers and verifiers, so an OER + receipt with a non-zero figure and no class breaks none, and the §4 + publication gate and §6 reporting contract do not reach it either. Only §2's + rule for carrying a WAI figure into a three-name vocabulary reaches past the + governed receipts. §1 also records that two receipts WAI defines fall short + of §2: the JWP group receipt, which signs a non-zero `joules_total`, and the + JWP object receipt, which carries `joules_micro`. Neither has a member for + the class. Where a `WAI_RECEIPT` property accompanies an object over MoQ, + the object's class travels in it, but no signature covers that property, so + it falls short too. Nothing carries a class for a group total, and §2.8's + rule that a total carries no class of its own does not cover it, because a + group's parts are labelled outside the group receipt, if at all. Giving + these a signed place for the class remains open. + [`extensions/jwp-receipts.md`] §2.4.1 points to this from `joules_total`. + [`extensions/interactive-worlds.md`] §8 now names the session receipt's own + signing domain, `wai:world-receipt\x01`, which the reference engine already + used. +- §2.6 says that the `wai.video.int_motion` and `wai.video.*` OTT constructors + accept `HwShunt`, so that issued receipts can be recreated, and that nothing + in them refuses it. The variant's deprecation warning reaches only code that + names it; a class parsed from its label can be `HwShunt` without one. No + producer in the reference implementation emits it. +- The resolution floor is stated as enforced: a declaration must reach the + `0.5/√3` µJ (≈ 0.289 µJ) that rounding the figure to whole microjoules adds, + `joules_micro × relative_ppm ≥ 288 676`. `SPEC.md`, the `wai-quantum` + README, both crates' `BelowResolution` messages and several doc comments + called it a 1 µJ floor, which is stricter than the rule verified. + [`extensions/energy-binding.md`] §4 now states the floor, which `bind()`, + `from_json()` and C2PA `recover()` already applied. The `energy_class` and + `quantum_energy` docs say what the floor is: the rounding term every + whole-microjoule figure carries. A figure read as a register delta carries + the larger `1/√6` µJ, which its producer must declare and no verifier + checks. +- [`extensions/oer-receipts.md`] §2 maps the evidential grade onto the refined + classes: `CalibratedInstrument` and `OnChipCounter` support `measured`, and + so does legacy `HwShunt`, for a figure sealed before the refinement or a + refined figure mapped into the three-name vocabulary. Neither an OER/2 + receipt's `provenance.device` nor its `energy.method` names a class. The OER + wire format, vectors and verifiers are unchanged. +- Not yet covered: the in-browser demos of `wai-web` that seal into an + optional-label family seal illustrative figures, fixed in code or passed in + by the page, without a label, and the OTT manifest demo labels a + page-supplied figure `ModelBased`. The honest class of an illustrative + constant is `Estimator`. The `ModelBased` and + `Estimator` classes still carry no declared uncertainty. + +### Energy acquisition — on-chip counters and calibrated instruments are separate classes + +- [`extensions/energy-measurement.md`] §2 refines the hardware class. `HwShunt` + covered both a counter the measured silicon keeps about itself (RAPL, NVML, + SMC, IOReport) and an external meter, although only the second can carry an + uncertainty traceable to a calibration. It is now two classes: + `OnChipCounter` and `CalibratedInstrument`. Each **must** carry a declared + uncertainty: a standard (k = 1) relative uncertainty in ppm plus the + integration window it holds over. The counter records whether its output + was filtered (`On` / `Off` / `Unknown`; RAPL energy filtering per + INTEL-SA-00389), and the instrument records a reference to its calibration + certificate. For an on-chip counter the declaration covers only what the + implementation could evaluate, never the counter's own systematic error, so + it is not a total-error statement. +- §2.1 keeps the declared uncertainty and the GREEN `data-source-accuracy` + graded bands apart. A band bounds one device reading, while a declared + uncertainty is a standard uncertainty about a per-object marginal figure. A + band is never declared as a figure's uncertainty, and a declared uncertainty + is never used to select a band. +- §2.3 and §3: a per-unit figure from the bracketed protocol declares the + bracket's statistical term combined with a completion-count term, + `N / (√3 · C)` for `N` workers and `C` counted completions. Up to `N` + reconstructions are part-done at each edge of the window, and the count + cannot see them. The thread count is now part of what a published figure + must report. +- **Wire:** new signing tags `4` (`OnChipCounter`) and `5` + (`CalibratedInstrument`), each followed by its evidence (§2.7). Tags `0`–`3` + are unchanged and followed by nothing, so every receipt sealed with `HwShunt`, + `ModelBased` or `Estimator` keeps its exact signed bytes and verifies as + before. Tests pin those bytes. `HwShunt` is accepted when read, means + "hardware, sub-class unspecified, no declared uncertainty", ranks below both + refinements, and must not be emitted. JSON carriers (the C2PA companion + assertion, the rendered int_motion receipt) add evidence fields only for the + refined classes, so legacy bodies are byte-identical. +- Reference implementation: the new `energy_class` module is the one definition + shared by `energy_binding`, `c2pa_emit`, `video_receipt` and the + `wai.video.*` receipts. `verify()` and C2PA `recover()` reject a hardware + class that lacks its uncertainty. `CounterMeter` emits `OnChipCounter` with + the window it measured, declaring its configured relative uncertainty + combined with the register delta's resolution term, `1/√6` µJ, so a short span declares + more than the configured figure. `wai_video_meter` declares the bracket's statistical + (Type A) term combined with the completion-count term, and emits an + unmetered receipt when no decode completed in the window, where it used to + count one. +- The receipt families that sealed `joules_micro` before the class existed now + carry it as an optional label; see the next section. + ### Integer decoders — fast kernels that keep every output integer - `wai-rs` adds `int_kernels`: register-blocked convolution, transposed diff --git a/wai/README.md b/wai/README.md index a14953816..ed96642be 100644 --- a/wai/README.md +++ b/wai/README.md @@ -202,13 +202,17 @@ wasmtime serve -S cli=y --addr 127.0.0.1:8231 \ GET /health GET /algorithms GET /run?algo=ghz&n=5&top=8 POST /run (body = OpenQASM 3.0) GET /qasm?algo=bell GET /receipt?algo=ghz&n=3[&shots=&seed=] -POST /verify (body = receipt JSON; 200 valid / 409 invalid) +POST /verify (body = receipt JSON; 200 valid / 409 invalid / 400 unparseable) ``` The endpoint returns the **same** `statevector_hash` as the native CLI for the same circuit, and a receipt sealed by the HTTP handler verifies with the native `wai-quantum verify` — the JSON content is byte-identical (the CLI adds a -trailing newline; nothing else differs). +trailing newline; nothing else differs). Both verify paths accept a receipt in +either form: as sealed, or labelled with its figure's acquisition class, as +`wai_quantum_meter` emits a measured figure (`quantum_energy::MaybeLabelled`, +[energy-measurement](extensions/energy-measurement.md) §2.8). Both restate the +class, or say the figure has none. That `wasm32-wasip2` build needs `-S cli=y`, because Rust's std imports `wasi:cli/environment` at startup and the bare `wasi:http/proxy` world does not diff --git a/wai/SPEC.md b/wai/SPEC.md index ae83993cd..aec4d50b6 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -267,9 +267,16 @@ receipt-chain sub-spec covering per-object content hashes, per-group Merkle roots, Ed25519 group signatures, and cross-hop preservation. See [`extensions/energy-measurement.md`](extensions/energy-measurement.md) for how a receipt's `joules_micro` is acquired: the required acquisition class -(`HwShunt` / `ModelBased` / `Estimator`), the bracketed marginal protocol, and -the attribution-quality rule that decides whether a figure may be published at -all. +(`CalibratedInstrument` / `OnChipCounter` / `ModelBased` / `Estimator`, with the +legacy `HwShunt` still accepted when read), the declared uncertainty — a +standard relative uncertainty and its integration window, never less than the +`0.5/√3` µJ (≈ 0.289 µJ) that rounding the figure to whole microjoules adds, so +`joules_micro × relative_ppm ≥ 288 676` — that both hardware classes must carry, the counter filtering state and calibration reference that +distinguish them, the bracketed marginal protocol (a burst whose window opens +from rest, divided by the work the window paid for), the attribution-quality +rule that decides whether a figure may be published at all, and how the receipt +families that sealed a figure before the class existed carry it as an optional +signed label that leaves every issued receipt's bytes unchanged. See [`extensions/confidentiality.md`](extensions/confidentiality.md) for the private-channel sub-spec: MLS (RFC 9420) group keys + SFrame (RFC 9605) object encryption, the byte-level framing behind §6. diff --git a/wai/extensions/avatars.md b/wai/extensions/avatars.md index bea0afc09..755beea6f 100644 --- a/wai/extensions/avatars.md +++ b/wai/extensions/avatars.md @@ -98,7 +98,9 @@ so they are legitimately left to presentation and excluded from the hash The interactive-worlds session receipt (`jwp-receipts.md`) applies unchanged: world identity over contract + tick-0 graph; the group's objects are the op batches, Merkle-bound; one Ed25519 seal over world -hash + root + checkpoints + exact signed `work` + carried `joules_micro`; +hash + root + checkpoints + exact signed `work` + carried `joules_micro` +(with its acquisition class as an optional signed label, +[energy-measurement](energy-measurement.md) §2.8); `parent_receipt_hash` chains a performance's seals into a timeline. ## Appendix diff --git a/wai/extensions/energy-binding.md b/wai/extensions/energy-binding.md index c19569e11..ca8ed503c 100644 --- a/wai/extensions/energy-binding.md +++ b/wai/extensions/energy-binding.md @@ -37,9 +37,20 @@ that the number is about anything checkable. An energy binding is the tuple ``` -{ reconstructionHash, alg, microjoules, acquisition, coverage, workUnits, method } +{ reconstructionHash, alg, microjoules, acquisition, [evidence], coverage, workUnits, method } ``` +where `[evidence]` is present exactly when `acquisition` is a refined hardware +class, `OnChipCounter` or `CalibratedInstrument` +([energy-measurement](energy-measurement.md) §2.3–§2.5); legacy `HwShunt` +carries none: + +| field | present for | meaning | +|---|---|---| +| `uncertainty` | `OnChipCounter`, `CalibratedInstrument` | `{ relativePpm, windowUs }` — the declared standard (k = 1) relative uncertainty in ppm and the integration window in µs | +| `counterFiltering` | `OnChipCounter` | `On` / `Off` / `Unknown` | +| `calibrationRef` | `CalibratedInstrument` | identifier of the calibration certificate | + carried as a third-party C2PA assertion under the reverse-domain label `science.transaction.wai.energy.reconstruction`, **alongside** — never instead of — the standard `c2pa.environmental-sustainability` assertion. @@ -76,9 +87,26 @@ measurement. ## 4. Normative rules -- The binding **MUST** carry an acquisition class (`HwShunt` / `ModelBased` / - `Estimator`, per [energy-measurement](energy-measurement.md) §2) and a - coverage statement (`Whole` / `Partial`, §5 there). +- The binding **MUST** carry an acquisition class (`CalibratedInstrument` / + `OnChipCounter` / `ModelBased` / `Estimator`, per + [energy-measurement](energy-measurement.md) §2) and a coverage statement + (`Whole` / `Partial`, §5 there). A refined hardware class **MUST** carry its + evidence as above, and a new binding **MUST NOT** use the legacy `HwShunt` + label. +- A refined hardware class's declared uncertainty **MUST** cover the figure's + own rounding ([energy-measurement](energy-measurement.md) §2.3): + `microjoules` is a whole number of microjoules, so it is uncertain by at + least `0.5/√3` µJ (≈ 0.289 µJ), and `microjoules × uncertainty.relativePpm` + **MUST** be at least 288 676. +- A verifier reading a binding **MUST** accept `HwShunt` — it is what bindings + signed before the hardware class was refined say, and it carries no evidence + fields — and **MUST** reject a refined hardware class without its `uncertainty`, a + refined hardware class whose `microjoules × relativePpm` is below 288 676, a + `CalibratedInstrument` without its `calibrationRef`, and evidence fields beside + a class that carries none. The floor needs nothing but the body and is + computed in integers, so every verifier reaches the same verdict. A C2PA + validator checks the signature over the body; these rules are about what the + body says, and no validator applies them on the implementation's behalf. - `method` **MUST** be present and name the published protocol the figure was obtained under. This is deliberately stricter than the standard assertion's optional `measurementMethod`. diff --git a/wai/extensions/energy-measurement.md b/wai/extensions/energy-measurement.md index dbf7a14f3..107473d65 100644 --- a/wai/extensions/energy-measurement.md +++ b/wai/extensions/energy-measurement.md @@ -17,71 +17,629 @@ reading, another an arithmetic model, a third a constant, and every one of them is conformant. The figures are then incomparable, and — worse — indistinguishable, because nothing in the receipt says which kind it is. -This extension closes that. It requires two things of any receipt carrying -energy: that the figure declare **how it was acquired**, and that the -measurement declare **whether it was good enough to publish**. +This extension closes that. It requires two things of any receipt it governs +that carries energy: that the figure declare **how it was acquired**, and that +the measurement declare **whether it was good enough to publish**. The contribution is not the number. It is the number *plus its acquisition class*, and the discipline of withholding both when the measurement fails. +**Scope.** This extension governs every receipt profile WAI defines that +carries an energy figure, and the producers and verifiers of those receipts. +They include the receipts §2 lists, which carry `joules_micro` or, in the +energy binding, `microjoules`; the MoQ `WAI_RECEIPT` property +([moq-streaming-format](moq-streaming-format.md) §5); and the JWP group and +object receipts ([jwp-receipts](jwp-receipts.md) §2.4), which carry +`joules_total` and `joules_micro`. The list is not closed: a profile WAI +defines is governed as soon as it carries a figure. + +It does not govern a receipt profile that WAI ingests and whose members that +profile fixes. An OER/2 receipt ([oer-receipts](oer-receipts.md)) is one. It +carries a non-zero `energy.joules_total` with no member for the class, and +cannot gain one: a member not listed for its path makes the receipt +non-canonical (§3 there). What an OER receipt carries instead is an +evidential grade, `provenance.how`, and [oer-receipts](oer-receipts.md) §2 +states which grade each class supports. No rule of this extension binds an +OER receipt, or its producers and verifiers as such: not the class of §2, +and not the publication gate of §4 or the reporting contract of §6 either. +OER/2's own rules govern it, and an OER receipt with a non-zero figure and +no class breaks no rule here. + +One rule reaches past the governed receipts. §2's rule for carrying a WAI +figure into a vocabulary with only three class names binds whoever carries +it, although the receipt the figure lands in is not governed. + +Two receipts WAI defines have no member for the class: the JWP group receipt +and the JWP object receipt ([jwp-receipts](jwp-receipts.md) §2.4.1 and +§2.4.2). The group receipt signs `joules_total`, the sum of its objects' +figures. The object receipt carries its own `joules_micro`. Neither meets §2, +however it is carried: + +- Where a `WAI_RECEIPT` property accompanies an object over MoQ, the + object's figure and its class travel together in it. The property is + optional (moq-streaming-format §5), and neither that document nor + jwp-receipts puts it under a signature. The group signature (jwp-receipts + §2.3) covers the Merkle root over the objects' content hashes and the + group total. It covers neither an object's figure nor its class, so the + class is not inside the signature as §2 requires. +- Without that property, over MoQ or any other carriage, an object receipt + has no place for the class. +- Nothing carries a class for the group total. `WAI_RECEIPT` is per object + and holds only a hash of the group receipt. §2.8's rule that a total + carries no class of its own does not cover it. That rule is for a total + whose parts are labelled inside the same receipt, and a group receipt's + parts are its objects, labelled, if at all, outside it. + +The interactive-worlds session receipt is a profile of the group receipt that +carries the class as an optional signed label (interactive-worlds §8, and +§2.8 here). It is signed under its own domain, `wai:world-receipt\x01`, not +the base group receipt's, so it is not among these. A JWP group or object +receipt that carries a non-zero figure falls short of §2. A `WAI_RECEIPT` +property falls short too, because its class is outside any signature. This +extension records that rather than exempting them. Giving them a signed place +for the class remains open. It changes the jwp-receipts wire format and what +its producers seal. + ## 2. The acquisition class (REQUIRED) -A receipt carrying a non-zero energy figure **MUST** carry an acquisition class -alongside it, from the shared vocabulary: +A receipt this extension governs (§1) that carries a non-zero energy figure +**MUST** carry an acquisition class alongside it. The one exception is the +unlabelled legacy form that §2.8 keeps for the receipt families that sealed a +figure before the class existed. The JWP group and object receipts, and the +`WAI_RECEIPT` property that carries an object's class over MoQ, cannot meet +this section as they are defined; §1 says where each falls short, and that it +is open. | class | meaning | requires | |---|---|---| -| `HwShunt` | Read from a hardware energy interface — RAPL, NVML, SMC, IOReport, or an external shunt. | a present, non-zero measurement | +| `CalibratedInstrument` | Read from an instrument external to the measured device — a shunt, a power analyser, a metering socket — that holds a current calibration covering the range and conditions it was used in. | a present, non-zero measurement; a declared uncertainty (§2.3); a calibration reference (§2.5) | +| `OnChipCounter` | Read from an energy counter the measured silicon keeps about itself — RAPL, NVML, SMC, IOReport (§2.9) — or from any other instrument whose uncertainty is not traceable to a current calibration (§2.2). | a present, non-zero measurement; a declared uncertainty (§2.3); the counter's filtering state (§2.4) | | `ModelBased` | Computed from a calibrated per-operation model (e.g. wall time × a power constant). A real number, honestly labelled as derived. | — | | `Estimator` | A coarse constant or load estimator. | — | +| `HwShunt` *(legacy)* | Read from a hardware energy interface, sub-class unspecified, no uncertainty declared. Accepted in receipts sealed before the hardware class was refined; never emitted (§2.6). | a present, non-zero measurement | -These are the same three names the receipt families elsewhere in the ecosystem -already use; this extension deliberately reuses the vocabulary rather than -minting a parallel one for media. +`CalibratedInstrument` and `OnChipCounter` are refinements of `HwShunt`, the +hardware rung of the three-name vocabulary (`HwShunt` / `ModelBased` / +`Estimator`) the receipt families elsewhere in the ecosystem use. A producer +that emits a WAI figure into a vocabulary with only those three names **MUST** +map both refinements to `HwShunt`; that loses the distinction and asserts +nothing false. The vocabulary is extended, not replaced. The evidential grade +of an OER receipt ([oer-receipts](oer-receipts.md) §2) is a separate axis, and +that section states which grade each class supports. A counter reading is **not** an estimate. RAPL, NVML and IOReport are sensor -values, and `HwShunt` says so. What separates it from the rungs below is -provenance and scope, not accuracy — see §5. +values, and `OnChipCounter` says so (§2.9 states where this departs from other +documents in the repository). What separates the two hardware classes +from each other is what backs the figure's uncertainty (§2.2), and what +separates both from the rungs below is provenance and scope, not accuracy — see +§5. + +The classes are ordered by what backs the figure's uncertainty statement, +strongest first: `CalibratedInstrument`, `OnChipCounter`, `HwShunt` (legacy), +`ModelBased`, `Estimator`. Wherever this extension says "the weaker class +applies", it means this order. Normative: -- An implementation **MUST NOT** emit a non-zero energy figure without a class. - An unlabelled number cannot be compared to any other number. +- An implementation **MUST NOT** emit a non-zero energy figure without a class + in a receipt this extension governs (§1), except in the unlabelled form of + §2.8. An unlabelled number cannot be compared to any other number. - An implementation **MUST NOT** emit a class with a zero or absent figure. That claims a measurement which did not occur. - The class **MUST** be inside the signature, so an intermediary cannot promote - a `ModelBased` figure to `HwShunt` in transit. + a `ModelBased` figure to a hardware class, or an `OnChipCounter` figure to a + `CalibratedInstrument` one, in transit. The class's evidence — the declared + uncertainty, the filtering state, the calibration reference — **MUST** be + inside the signature with it (§2.7). - A verifier **MUST** treat a receipt violating either coupling as invalid, not - merely as unlabelled. + merely as unlabelled — with the one exception of a legacy unlabelled figure + in a receipt family that carries the class as an optional label (§2.8) — and + **MUST** treat a `CalibratedInstrument` or + `OnChipCounter` class that lacks its declared uncertainty (§2.3), or a + `CalibratedInstrument` class that lacks its calibration reference (§2.5), the + same way. -The reference implementation enforces both couplings in `verify()` rather than +The reference implementation enforces these rules in `verify()` rather than documenting them, and returns *no rate at all* — never `0` — when unmetered, because a zero reads as "this work was free", which is a stronger and false -claim than "nobody measured". +claim than "nobody measured". The class and its evidence have one definition +(`energy_class` in `wai-rs`, mirrored byte for byte by `quantum_energy` in +`wai-quantum`), and every receipt within this extension's scope (§1) that the +reference implementation seals carries its figure in one of two ways: + +- **Always present**, in the receipts defined with a class from the start: the + energy binding and its C2PA companion assertion + ([energy-binding](energy-binding.md)), the `wai.video.int_motion` receipt, + and the `wai.video.*` OTT receipts. +- **As an optional label** (§2.8), in the receipt families that sealed a figure + before the class existed and whose issued receipts must keep verifying: the + world-session, film and reel, haptic, score, 4D-splat, audio-scene, + ternary-reconstruction and provenance-step receipts in `wai-rs`, and every + quantum receipt of `wai-quantum` (circuit, calibration, job, mitigation, + decode, compile, rearrange, phasor, and the bill of materials per entry). + +Neither crate seals a JWP group or object receipt or writes a `WAI_RECEIPT` +property, the carriers §1 records as falling short of §2. ### 2.1 Relationship to network-layer energy identities -The three classes above are WAI's own vocabulary, and they are coarser than the -graded `data-source-accuracy` identity hierarchy an adopted IETF GREEN -working-group draft defines for device and sub-component energy. That hierarchy -is the network layer's answer to the same question this section asks: *how was -this number obtained?* +The classes above are WAI's own vocabulary. An adopted IETF GREEN working-group +draft (draft-ietf-green-power-and-energy-yang, revision -04 when this section +was written) defines a graded `data-source-accuracy` identity hierarchy for device and +sub-component energy, which is the network layer's answer to the same question +this section asks: *how was this number obtained?* Its graded measured +identities are bounds on the total error of one device reading, of the form +`|actual − sensor| ≤ sensor × X %` or an absolute limit. The declared +uncertainty of §2.3 is a different kind of statement about a different +quantity, and the rules below keep the two apart. - An implementation that also reports under a network-layer energy model **SHOULD** derive its acquisition class from that model's identity rather than assigning one independently, and **MUST** state which identity it derived from. Two vocabularies for one property, assigned separately, will disagree. - A derivation **MUST NOT** improve the class. Where a network-layer identity - maps ambiguously, the **weaker** WAI class applies. -- An implementation **MUST** declare the tolerance its acquisition class implies. - A class without a tolerance is a label; the point of grading provenance is to - say how much the figure can be leaned on, and that requires a number. + maps ambiguously, the **weaker** WAI class applies. A measured identity, + graded or not, says a sensor was read. It does not say that the sensor's + error is traceable to a calibration, so it yields `OnChipCounter` unless the + implementation holds the calibration reference of §2.5. +- An implementation **MUST** state how far its acquisition class lets the figure + be leaned on. A class without that is a label, and grading provenance exists to + put a number on it. For the two hardware classes the number is the declared + uncertainty of §2.3. `ModelBased` and `Estimator` do not yet carry one in any + receipt profile; that remains open. +- A graded band **MUST NOT** be declared as a figure's uncertainty. The band + bounds one reading of the device. A WAI figure is the difference of two + bracket means, attributed per object (§3). A pure gain error carries through + that difference unchanged and a constant offset cancels, but readings that are + each within a band can otherwise differ from the true marginal by far more + than the band, relative to the difference. A band **MAY** inform the counter's + systematic component when the declared uncertainty is evaluated (§2.3); the + declaration remains the implementation's own evaluation of the figure. +- A declared uncertainty **MUST NOT** be used to select or assert a graded + measured identity. No coverage factor makes the step sound, for three + reasons: + 1. A graded band is a limit. A declared uncertainty is a standard + uncertainty. Expanded at k = 2 it corresponds, for a normal distribution, + to a coverage probability of about 95 %, which is not a limit. + 2. An `OnChipCounter` declaration does not cover the counter's own systematic + error (§2.3), so it is not a statement of total error at all. + 3. The declaration is about a per-object marginal figure, and the identity is + about a device or sub-component reading. Treating one as the other is the + conflation the next paragraph forbids. + + A graded identity for the device's reading has to rest on a limit established + for that reading, such as a vendor's stated accuracy for the counter or the + maximum permissible error an instrument has been verified against over its + range and conditions of use, under that model's own definitions. Nothing in a + WAI receipt supplies that limit. WAI does not adopt the network-layer model, and this section does not restate it. The scopes differ — that model is device- and sub-component-scoped, WAI's figures are per-object — and conflating them would breach §4's attribution rules. What is required is that the two do not contradict each other about the same measurement. +### 2.2 Two kinds of hardware evidence + +An on-chip counter and a calibrated external instrument are both hardware +readings, and both are real measurements. They are different evidence, and a +single class put them at one trust level. + +- **An on-chip counter is the measured silicon reporting on itself.** Its + reading is a sensor value. What it cannot offer is an uncertainty traceable to + a reference: the figure's uncertainty is whatever its vendor states or the + implementation can evaluate. Some counters also add deliberate noise to their + output as a side-channel defence (§2.4), so the same counter is noisier over a + short window than a long one. +- **A calibrated instrument's error has been established against a reference** + and written into a certificate a third party can read. A deployed regime that + signs measured energy per transaction shows the same split between signature + and trust: in EV charging, signed meter values in the Open Charge Metering + Format (OCMF) are accepted under German calibration law because they come + from a conformity-assessed, legally verified measuring system — the meter + together with the component that signs its values. The signature binds each + value to that system so a customer can check it later. It does not make the + value accurate; the metrological assessment does. (Legal verification against + maximum permissible errors is not the same statement as a calibration + certificate's uncertainty; §2.1 keeps the two apart, and §2.5 asks for the + latter.) + +A signature settles who said a number. It says nothing about how far the number +can be from the energy actually spent. The declared uncertainty (§2.3) is the +statement about that, and the class says how much of it the declaration covers. +For `CalibratedInstrument` it covers the instrument's error as established by +its calibration, together with the measurement's own scatter. For +`OnChipCounter` it covers only what the implementation could evaluate, which +does not include the counter's own systematic error. + +What separates the two classes is whether the uncertainty is **traceable**, not +where the sensor sits. An external meter with no current calibration **MUST** +be reported as `OnChipCounter`, the weaker class; a sense resistor read by the +device's own firmware and reported through the device's own counter is +`OnChipCounter` whatever its construction. The class therefore does not record +where the sensor sits — at the die or at the wall, with or without supply +losses — and a verifier reasoning about scope (§5) cannot learn it from the +class. + +Neither class is an accuracy grade. A small declared uncertainty on an on-chip +counter says the reading was precise. How accurate it was rests on the counter, +and the class is what tells a reader that. + +### 2.3 The declared uncertainty (REQUIRED for the hardware classes) + +A `CalibratedInstrument` or `OnChipCounter` class **MUST** carry a declared +uncertainty of two parts: + +| field | meaning | +|---|---| +| `relative_ppm` | The **standard** relative uncertainty of the figure (coverage factor k = 1, in the sense of the GUM, JCGM 100:2008), in integer parts per million of the figure. An expanded uncertainty `U` stated at coverage factor `k` — the usual form on a calibration certificate — is declared as `U / k`. | +| `window_us` | The integration window the figure was accumulated over, in integer microseconds. For the protocol of §3, the busy bracket's window. | + +- Both parts **MUST** be non-zero. A relative uncertainty of zero claims a + perfect measurement; a window of zero is not an integration. A verifier + **MUST** treat either as an undeclared uncertainty. +- The window is not optional. Counter noise depends on it — filtered counters + are several times noisier over 100 ms than over 1 s (§2.4) — so a relative + error without its window cannot be compared with anything. +- An implementation converting a fractional uncertainty to `relative_ppm` + **MUST** round up, so the declaration never states less uncertainty than was + evaluated. +- The declaration describes the figure as sealed, and `joules_micro` is a whole + number of microjoules. Whatever the measurement, the integer carries its own + resolution, and the declaration **MUST** include it: + - A figure rounded from an estimate to the nearest microjoule is within + ±0.5 µJ of it. Taken as rectangular over that limit (GUM F.2.2.1), this adds + a standard uncertainty `u_res = 0.5/√3 µJ` (≈ 0.289 µJ). + - A figure read as the difference of two whole-microjoule register readings + is within one step of the energy at each end. Two such offsets differ by a + triangular error over (−1, +1) µJ (GUM 4.3.9), which adds + `u_res = 1/√6 µJ` (≈ 0.408 µJ). A register whose hardware unit is coarser + than a microjoule hides that unit from the reader, and the declaration + **MUST** cover it separately. + + The term is fixed in microjoules, so relative to the figure it grows as the + figure shrinks: a rounded figure of 1 µJ is uncertain by at least 29 % from + its rounding alone, one of 10 µJ by 2.9 %, and one of 1,000 µJ by 0.029 %. +- Because of that, every declaration satisfies + `joules_micro × relative_ppm ≥ 288 676`: the absolute standard uncertainty + `joules_micro × relative_ppm / 10⁶ µJ` is at least `0.5/√3 µJ`, and 288 676 + is the least integer at or above `10⁶ × 0.5/√3`. A verifier **MUST** treat a + hardware class whose declaration is below this floor as invalid. The check + needs nothing but the receipt, and it is in integers, so every verifier + reaches the same verdict. It is a necessary condition, not a sufficient one: + a declaration above the floor can still understate what was not evaluated. + +What the declaration covers depends on the class: + +- **`CalibratedInstrument`**: the instrument's calibrated uncertainty at the + range and conditions of use, combined with the measurement's own + uncertainty as evaluated below. +- **`OnChipCounter`**: the components the implementation can evaluate — at + minimum the measurement's own uncertainty as evaluated below, and any noise + the counter's vendor states it adds at the declared window. It cannot + include an unquantified systematic offset of the counter. An `OnChipCounter` + declaration is therefore not a statement of the figure's total error and + **MUST NOT** be presented as one. A reader **MUST** read it as the evaluated + part of the figure's uncertainty, to which the counter's systematic error + adds an amount nobody has stated. The class name is what tells a reader that. + +For the bracketed marginal protocol of §3, the per-unit estimate is +`E = (mean_busy − mean_idle) × window ÷ W`, where `W` is the work the window +paid for, estimated from the completion count `C` as below. The measurement's +own relative uncertainty combines two components, and the declaration adds the +rounding of `E` to the sealed figure `F`: + +``` +u_power = sqrt(s_idle² / n_idle + s_busy² / n_busy) / (mean_busy − mean_idle) +u_count = u(W) / W +u_meas = sqrt(u_power² + u_count²) relative to E +F = round(E) the figure sealed, in µJ +u_rel = sqrt((E · u_meas)² + (0.5/√3 µJ)²) / F declared, relative to F +``` + +`u_meas` is relative to the estimate and `u_rel` to the figure a reader sees. +The measurement's part is carried across in microjoules, so a figure that +rounds down declares it relative to the smaller number. At `E` = 0.563 µJ with +`u_meas` = 1.54 %, `F` is 1 and `u_rel` is 28.9 %, not 1.54 %. + +- `u_power` is the statistical (Type A) standard uncertainty of the marginal + draw, with `s` the sample standard deviation of each bracket. It cannot be + evaluated from fewer than two samples in either bracket, or without a + positive marginal draw. +- `u_count` is the completion-count term. `C` is the number of reconstructions + counted as completing inside the window, and `N` is the number that can be + in progress at once — the worker count. The count is exact as a count; it is + not exact as a measure of the work the window's energy paid for, because + reconstructions part-done at an edge are invisible to it. What that error can + be depends on how the window opened: + + | window opened | work `W` lies in | estimate `W` | `u(W)` | `u_count` | + |---|---|---|---|---| + | **from rest** — every worker held at a start barrier and released as the window opens | `[C, C + N)` | `C + N/2` | `N / (2√3)` | `N / (√3 · (2C + N))` | + | **on running workers** — the workers' progress at the opening edge spread over a whole reconstruction | `(C − N, C + N)` | `C` | `N / √3` | `N / (√3 · C)` | + + From rest, no reconstruction begins before the window, so only the closing + edge contributes: each worker is part-way through one reconstruction the + count cannot see, and the count reads **low**, never high. On running + workers both edges contribute, in opposite directions. In both cases, with + only the limit known, the error is taken as rectangular over it (GUM + §4.3.7). From rest, that is exact when the workers' progress at the closing + edge moves together and falls anywhere in a reconstruction with equal + likelihood — identical payloads released together do move together — and + conservative when their progress is spread. On running workers it is + conservative in both cases. + + A window that opens the moment the workers are started, without a barrier, + is in neither row. When a reconstruction takes much longer than starting the + workers, they are all near the start of one at the opening edge, so the + count reads low by up to `N` — by about `N/2` on average — and a figure + divided by `C` reads **high** by about `N / (2C)`: 8 % at ten workers and + sixty completions. The symmetric running-window model does not describe + it. The reference meters were built this way until this + revision; their windows now open from rest. + + The term shrinks only as `C` grows, so it is large when an expensive payload + completes only a handful of times (§3). An implementation **MUST** state + which opening it used. An implementation that removes more of this term by + construction **MUST** state how, and **MUST** still declare whatever part it + has not removed. +- The window is timed on a monotonic clock around the busy bracket. An + implementation whose clock resolution is not negligible against the window + **MUST** include it. + +A figure whose `u_rel` cannot be evaluated — too few samples, no positive +marginal draw, no completions, or an estimate that rounds to zero (§4) — +**MUST NOT** be sealed under a hardware class. +`W` is the divisor of the published per-unit figure; the raw count `C` is +reported beside it (§3). + +The declared uncertainty does **not** absorb the attribution bias of §4. That +bias is one-directional — a busy machine always reads low — and no symmetric +uncertainty describes it. (The completion-count bias of a window opened without +a barrier, above, runs the other way: it reads high.) The §4 ratio remains required alongside the +declaration, and a figure that fails §4 is not rescued by a large declared +uncertainty. + +### 2.4 Counter filtering state (`OnChipCounter`) + +Some on-chip counters filter their output as a side-channel defence. Intel's +advisory INTEL-SA-00389 (CVE-2020-8694, CVE-2020-8695) describes RAPL energy +filtering: unpredictable noise added to the energy reported, typically under +2 % standard deviation in readings at 1 s intervals, growing to 5–15 % at +100 ms, with larger variance possible at thermal design power. In most cases +enabling SGX enables it, and TDX uses the filtered energy information. Software +that can read model-specific registers sees it as `IA32_MISC_PACKAGE_CTLS[0]` +(`ENERGY_FILTERING_ENABLE`), supported where `IA32_ARCH_CAPABILITIES[11]` is +set; once set, that bit cannot be cleared by software. + +So an `OnChipCounter` class carries the filtering state as one of: + +| state | meaning | +|---|---| +| `On` | The reader determined that filtering was active. | +| `Off` | The reader positively determined that no filtering was active. | +| `Unknown` | The reader could not determine the state. | + +- An implementation **MUST** report `Off` only on positive evidence. A reader + that cannot see the control state — an unprivileged process, an interface that + does not expose it — **MUST** report `Unknown`, never infer `Off` from + absence. +- A verifier **MUST** read `Unknown` as possibly filtered. +- Where the state is `On` or `Unknown`, the declared uncertainty (§2.3) + **SHOULD** include the noise the vendor states for the declared window. +- A `CalibratedInstrument` class carries no filtering state: an external + instrument has no counter filter. + +### 2.5 Calibration reference (`CalibratedInstrument`) + +A `CalibratedInstrument` class **MUST** carry `calibration_ref`: a non-empty +identifier that resolves to the calibration certificate covering the +instrument as used — a URI, or the issuing laboratory's certificate number. + +The reference makes the calibration claim **checkable**, not true. A verifier +holding it can obtain the certificate and confirm that it covers the +instrument, that it was current when the figure was measured, and that the +declared uncertainty is consistent with it. The receipt's signature proves who +made the claim. It says nothing about the instrument. + +### 2.6 Legacy `HwShunt` + +Receipts sealed before this revision carry `HwShunt`. Its meaning is fixed as +**hardware, sub-class unspecified, no declared uncertainty**. + +- A verifier **MUST** accept `HwShunt` in a receipt that is otherwise valid. + The signed encoding of §2.7 leaves every legacy receipt's signed bytes + unchanged, so such a receipt verifies exactly as it did. +- A verifier **MUST** rank `HwShunt` below both refined hardware classes. It + asserts a hardware reading and nothing about the reading's uncertainty. +- An implementation **MUST NOT** emit `HwShunt` in a new receipt. A producer + that cannot say which kind of hardware evidence it has, or cannot declare an + uncertainty for it, has not established what the refined classes require. + A verifier cannot see when a receipt defined with a class from the start was + sealed, so it accepts `HwShunt` there as above. The reference implementation + meets the rule in its own producers, none of which emits `HwShunt`, and + holds its callers to it in part: its meters cannot produce `HwShunt`, a new + energy binding and an optional label (§2.8) refuse it, and the `wai-rs` + variant is deprecated, so code that names it draws a compiler warning. The + constructors of the `wai.video.int_motion` receipt and the `wai.video.*` OTT + receipts accept `HwShunt`, so that an issued receipt can be recreated byte + for byte, and nothing in them refuses it. The warning reaches only code + that names the variant. A class parsed from its label + (`EnergyProvenance::from_label`, `from_parts`) can be `HwShunt`, as a + verifier needs, and a caller that passes such a class to those + constructors draws no warning. +- `HwShunt` carries no evidence fields. A receipt pairing `HwShunt` with a + declared uncertainty, a filtering state or a calibration reference is + ambiguous and **MUST** be rejected. + +### 2.7 Signed encoding + +Receipts that sign a binary payload encode the class as one tag byte, followed +by the class's evidence: + +| tag | class | followed by | +|---|---|---| +| `0` | unmetered (no class) | — | +| `1` | `HwShunt` (legacy) | — | +| `2` | `ModelBased` | — | +| `3` | `Estimator` | — | +| `4` | `OnChipCounter` | `relative_ppm` u32 BE · `window_us` u64 BE · filtering u8 (`0` Unknown, `1` Off, `2` On) | +| `5` | `CalibratedInstrument` | `relative_ppm` u32 BE · `window_us` u64 BE · reference length u32 BE · `calibration_ref` UTF-8 | + +Tags 0–3 are followed by nothing, exactly as before the hardware class was +refined; that is what keeps legacy receipts verifying without re-signing. + +Receipts carried as JSON carry the same evidence as named fields beside the +class label, and omit them for the classes that carry none, so a legacy body is +byte-identical to one written before this revision. The names are fixed: + +| field | snake_case carriers | C2PA companion assertion (camelCase) | +|---|---|---| +| class label | `energy_provenance` | `acquisition` | +| declared uncertainty | `energy_uncertainty` `{relative_ppm, window_us}` | `uncertainty` `{relativePpm, windowUs}` | +| filtering state | `energy_counter_filtering` | `counterFiltering` | +| calibration reference | `energy_calibration_ref` | `calibrationRef` | + +The snake_case carriers are the rendered `wai.video.int_motion` receipt and +every receipt carrying an optional label (§2.8); in a labelled bill of +materials the fields sit inside each labelled entry. Every snake_case field is +`energy_`-prefixed so that it cannot collide with a field a receipt already +renders — a quantum job receipt's `calibration_ref` names the calibration +receipt the job ran under, not a certificate. A reader **MUST** refuse evidence +fields that appear without a class label, and **MUST NOT** drop a malformed +label and read the receipt as unlabelled. + +### 2.8 Optional labels on receipts that predate the class + +A receipt family that sealed `joules_micro` before this extension existed has +issued receipts that must keep verifying. Such a family carries the class as +an **optional label**: + +- **Absent.** The signing payload, the receipt's identity hash and its JSON are + exactly the legacy ones. The figure is *unlabelled*: a verifier **MUST** + accept the receipt if it is otherwise valid, and **MUST NOT** read the figure + as any class. An unlabelled figure is not comparable with any labelled one; + it is not upgraded to one, and in particular not to `HwShunt`. +- **Present.** The signing domain's final version byte, `0x01` in every such + family (e.g. `wai:world-receipt\x01`), becomes `0x02`, and the class's signed + encoding (§2.7) follows the `joules_micro` field it labels, before the fields + that followed the figure in the legacy payload. The version byte keeps every + labelled payload distinct from every unlabelled one, so a label can be + neither added to nor stripped from a signed receipt; the encoding is + self-delimiting, so the fields after it keep their legacy framing. A receipt + whose identity hash covers its signing payload covers the label with it. +- A label **MUST NOT** be `HwShunt` — none of these receipts ever carried it — + and **MUST NOT** sit beside a zero figure. A verifier **MUST** treat either, + or a refined class without its evidence, as invalid. +- **New receipts.** A producer **SHOULD** label every non-zero figure it seals + in such a family. The unlabelled form exists for receipts issued before the + label did, and an unlabelled figure tells a reader nothing about how it was + obtained. This is a recommendation, not a conformance requirement. No + verifier can check it: a figure sealed unlabelled today has exactly the + bytes of one sealed before the label existed, and a verifier accepts both + (above). That does not set it apart from §2.6's rule on `HwShunt`, which no + verifier can check in a receipt defined with a class from the start either. + What sets it apart is that the reference implementation neither meets it + in all its own producers nor holds its callers to it. Two of its producers + seal new non-zero figures without a label (end of this section). In these + families no constructor takes the class. `build()` and `seal()` in + `wai-rs`, and `seal()` and the helpers that call it in `wai-quantum`, seal + the figure unlabelled, and a label is added afterwards, by re-signing + (below). A provenance step is labelled before its receipt is built instead: + `ProvStep::new` makes the step unlabelled, and + `ProvStep::with_energy_class` labels it. None of these refuses a non-zero + figure without a label, and none of the unlabelled paths is deprecated. A + library can hold its callers to this rule by the means §2.6 describes for + `HwShunt`. It would need a constructor that takes the class and refuses a + non-zero figure without one, and it would keep the unlabelled path only for + recreating issued receipts, deprecated, so that code sealing a new + unlabelled figure draws a compiler warning. The reference implementation + has not done this. + +Where a receipt carries several figures, each is labelled where it sits: + +- A provenance receipt labels each step. A labelled step's leaf hash uses the + `0x02` step domain with the class after the step's figure; an unlabelled step + keeps its legacy leaf. A receipt with any labelled step signs under the `0x02` + receipt domain and writes, after every step's figure, that step's optional + class — `0` for an unlabelled step. +- A quantum bill of materials labels each entry the same way: under the `0x02` + domain, every entry's figure is followed by its optional class, `0` when + unlabelled. An entry's class restates its stage receipt's class, and a + verifier binding the bill to its stage receipts checks that they agree. +- In both, the total carries no class of its own. A sum over figures of + different classes is only as strong as its weakest part. This covers a + total whose parts are labelled inside the same receipt. A JWP group + receipt's total is not one (§1). + +In the reference implementation, each `wai-rs` family has an +`energy_provenance` field and `with_energy_class()`, which re-signs with the +receipt's own key; provenance steps have `ProvStep::with_energy_class()`. +`wai-quantum`, a published crate, adds the label without changing any existing +type: `quantum_energy::Labelled` wraps a sealed receipt and re-signs it +over the labelled payload, and `quantum_energy::LabelledQbom` labels a bill per +entry. A labelled quantum receipt fails the inner receipt's own `verify()` — +its signature covers the labelled payload — so a reader that does not know +about labels refuses it rather than silently dropping the class. A verifier +that accepts both forms parses with `quantum_energy::MaybeLabelled`, which +takes the form from the JSON and refuses a malformed label rather than reading +the receipt as unlabelled. The reference `wai-quantum verify` CLI and the +HTTP handler's `POST /verify` accept both forms this way. A job receipt names +its calibration by the identity of the form the calibration is in, and +`MaybeLabelled::verify_against_calibration` checks the link for either form of +each. Tests pin one unlabelled receipt of every family to the bytes it had +before the label existed. + +What the reference implementation seals in these families: + +- The reference meters that seal into them (`wai_meter`, `wai_world_meter`, + `wai_quantum_meter`, `wai_prov`) label every non-zero figure they seal, and + seal a figure they could not measure as zero, unlabelled. +- Two producers seal a new non-zero figure without a label, and so fall + short of the **SHOULD** above. `world_model::frame_receipt` is handed its + caller's figures without their class; a caller that knows the class builds + the steps itself with `ProvStep::with_energy_class`. The in-browser demos of + `wai-web` seal illustrative figures, fixed in code or passed in by the page. +- A generator that recreates a committed conformance corpus byte for byte + seals that corpus's figures unlabelled; `wai_quantum_ops_vectors gen` seals + non-zero ones. Every committed corpus predates the label, so these + generators recreate issued receipts, which is what the unlabelled form is + for. +- The constructors and sealing helpers above are not counted. Each returns + its receipt, or its provenance step, to a caller that can still label it. + +### 2.9 IOReport, and where this departs from other documents + +IOReport — the SoC energy interface the reference meters read through a +non-privileged reader — is classed `OnChipCounter`. Its readings are the +measured silicon's own report of its energy, and this extension classes a +figure by that provenance. The interface's channels sit in a group named +"Energy Model", and it is a private interface whose derivation is not publicly +documented. That makes the counter's systematic error unstated, which an +`OnChipCounter` declaration already excludes (§2.3). It does not make the +figure a model this implementation computed (`ModelBased`) or a constant +(`Estimator`). + +Other documents in this repository class IOReport differently. This extension +does not change them; where a figure passes between the two, the weaker class +applies (§2): + +| document | classes IOReport as | +|---|---| +| `jouleclaw/SPEC.md` (provenance table; realistic floors) | `ModelBased` — "vendor-provided estimate", "model-based, not measured" | +| `jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs` (the `ModelBased` variant's examples) and `src/apple_ioreport.rs` | `ModelBased` — a vendor model derived from frequency, voltage and utilisation | +| `jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/MEASURED_ENERGY.md`, `conformance/README.md` | `ModelBased` | +| `jouleclaw/jouleclaw-rs/crates/jouleclaw-jcr1/conformance/README.md`, `vectors.json` | `ModelBased` | +| `jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/passport.rs` (`MeasurementMethod::IoReport`) | `Estimator`, pending an in-process reader | +| `jcp/jcp-rs/crates/jcp-energy/src/lib.rs` (`Meter::IoReport`) | `ModelBased` — a real on-die reading, carried at model grade | +| `joule-code/examples/l3-jouleclaw/src/bin/run.rs` (and its README, which describes the host as `Estimator` until IOReport is wired) | `ModelBased` | + +The documents that class IOReport as hardware agree with this section at the +three-name level: `sandbox/README.md`, `proof/README.md`, +`joule-code/spec/receipt.md` (`HwShunt`), and the `eoc-meter` crate +("measured-grade"). `jcp/jcp-rs/crates/jcp-efficiency/MAPPING.md` states the +same position in prose: vendor counters are real sensor readings, whose +derivation and error bounds are undisclosed. + ## 3. The reference protocol: bracketed marginal measurement On-die energy counters report the draw of a whole package or SoC, not of one @@ -94,19 +652,31 @@ machine: 2. **Saturating burst.** Run the reconstruction continuously on *N* threads, counting completions. Many threads are used deliberately: a single-threaded burst on a modern SoC often fails to lift total draw clear of idle drift. + The threads **SHOULD** be held at a start barrier and released as the + window opens, so the window opens from rest (§2.3). 3. **Busy bracket.** Sample power again for a fixed count while the burst runs, - recording the wall-clock window it spans. + recording the wall-clock window it spans and the **standard deviation**. 4. **Attribute.** `energy = max(0, mean_busy − mean_idle) × window`, then divide - by the number of completions in that window. + by the work the window paid for — `C + N/2` from rest, `C` on running + workers (§2.3). -Per-unit energy is total marginal energy ÷ total completions, so the thread -count does not enter the result. Completions **MUST** be counted per +Per-unit energy is total marginal energy ÷ the work it paid for, so the thread +count enters the figure only through the part-done reconstructions at the +window's edges, which the count cannot see. It enters the figure's uncertainty +the same way (§2.3, `u_count`). Completions **MUST** be counted per reconstruction, not per fixed-size batch: a cheap payload runs millions of times -and an expensive one a handful, and a coarse batch collapses the count. +and an expensive one a handful, and a coarse batch collapses the count. For the +same reason an expensive payload **SHOULD** be burst over a window long enough +that its completions far outnumber the threads. With a handful of completions +the count term alone is large: ten threads and sixty completions give about +9.6 %. -An implementation **MUST** report the idle mean, the busy mean, the sample -counts, the window, and the completion count alongside any figure it publishes. -Those five numbers are what make a figure auditable; the figure alone is not. +An implementation **MUST** report the idle mean and standard deviation, the +busy mean and standard deviation (sample standard deviations, as §2.3 uses), +the sample counts, the window, the completion count, the thread count and how +the window opened alongside any figure it publishes. Those numbers are +what make a figure auditable, and they are what its declared uncertainty (§2.3) +is computed from; the figure alone is not auditable. ## 4. Attribution quality (REQUIRED) @@ -152,8 +722,9 @@ always reads **low**. An implementation reporting energy therefore errs toward flattering itself, which is precisely why the check must be automatic rather than left to the operator's judgement. -- An implementation **MUST NOT** seal a `HwShunt` figure when the ratio is below - 1, or when the per-unit figure rounds to zero. Neither is a measurement of low +- An implementation **MUST NOT** seal a figure under a hardware class + (`CalibratedInstrument`, `OnChipCounter`) when the ratio is below 1, or when + the per-unit figure rounds to zero. Neither is a measurement of low energy; both are a failure to measure, and the receipt **MUST** be emitted unmetered instead. - An implementation **MUST NOT** publish an absolute figure taken below a ratio @@ -191,10 +762,12 @@ only the producer can compute. ## 6. Reporting contract -A published figure **MUST** be accompanied by: the acquisition class (§2); the -idle mean and standard deviation, busy mean, sample counts, window and -completion count (§3); the attribution-quality ratio (§4); the build profile -(§5); and the recomputable work unit the rate is expressed against. +A published figure **MUST** be accompanied by: the acquisition class (§2), with +its declared uncertainty and window (§2.3) and its filtering state (§2.4) or +calibration reference (§2.5); the idle mean and standard deviation, busy mean +and standard deviation, sample counts, window, completion count, thread count +and how the window opened (§3); the attribution-quality ratio (§4); the build profile (§5); and the +recomputable work unit the rate is expressed against. A figure published without these is not wrong so much as unusable: nothing about it can be checked, reproduced, or compared. @@ -216,6 +789,19 @@ marginal / idle sd : 3.8x (usable for relative comparison — NOT publishable) acquisition class : HwShunt ``` +The run above predates the refinement of §2.2–§2.6, and its receipt says +`HwShunt`. The reference meter now emits `OnChipCounter` for the same interface, +with filtering state `Unknown` (a non-privileged IOReport reader has no +documented way to read a filtering state, if the interface has one) and the +§2.3 uncertainty: the statistics of both brackets combined with the +completion-count term and the `0.5/√3` µJ that rounding the figure to whole +microjoules adds. It also opens its window from rest and divides by +`C + N/2`; the run above opened on freshly started workers and divided by `C`, +so its per-decode figure also carries the high-reading count bias of §2.3. None +of this can be reconstructed for that run: neither the busy bracket's standard +deviation nor the thread count was recorded, and substituting either would be +the invention this section refuses. + **This example is deliberately a failing one.** It is a complete, honestly reported bracket that this extension nonetheless forbids publishing as an absolute figure, because 3.8 is below the bar of §4 — and the earlier reading of @@ -233,7 +819,8 @@ everything but the energy from the codestream alone. An implementation claiming conformance **SHOULD** publish, with any figure: the vector decoded, the build profile and toolchain, the reader used for the power -interface, and the five bracket parameters of §3. A figure whose bracket is not +interface, the bracket parameters of §3, and the declared uncertainty with the +evidence of §2.3–§2.5. A figure whose bracket is not published cannot be reproduced, and an unreproducible energy number is an assertion, not a measurement — which is the failure this extension exists to prevent. diff --git a/wai/extensions/haptics.md b/wai/extensions/haptics.md index bcbedd719..cdad2a544 100644 --- a/wai/extensions/haptics.md +++ b/wai/extensions/haptics.md @@ -89,7 +89,11 @@ supplementary); there is no perceptual substitute to fall back *to*, so reconstruction blocks, Merkle-bound; one Ed25519 signature over signal_hash + root + `work` (= duration × channels) + carried `joules_micro`; `parent_receipt_hash` chains — the worlds/audio receipt -shape reused. +shape reused. The figure's acquisition class rides beside it as an optional signed label +([energy-measurement](energy-measurement.md) §2.8): absent, the receipt keeps +its legacy bytes and the figure is unlabelled; present, it is inside the +signature. The reference meter labels every figure it measures +`OnChipCounter`, with its declared uncertainty. ## Appendix diff --git a/wai/extensions/interactive-worlds.md b/wai/extensions/interactive-worlds.md index 599c2cf40..c2857b5ea 100644 --- a/wai/extensions/interactive-worlds.md +++ b/wai/extensions/interactive-worlds.md @@ -475,6 +475,9 @@ unchanged): the JWP sink-metering path). - One Ed25519 signature per group, Merkle-bound, relay-preserved byte-for-byte — all inherited. +- The signing domain is the profile's own, `wai:world-receipt\x01` + (`\x02` when the figure carries a label; energy-measurement.md + §2.8), not the base group receipt's `moq-jwp:group-sign\x01`. A receipted checkpoint **seals history**: §5.6 forbids ops landing before it. The receipt chain therefore gives a shared world an @@ -506,7 +509,13 @@ as `SessionReceipt::parent_receipt_hash` + `receipt_hash()` + counters, supplies a wall-clock estimate against a **declared** power coefficient and labels it an estimate; an unmetered sink signs `0`. Joules are derived *from* measurement or a stated model, never - asserted in place of one. + asserted in place of one. The figure's acquisition class rides beside + it as an optional signed label + ([energy-measurement](energy-measurement.md) §2.8): absent, the receipt + keeps its legacy bytes and the figure is unlabelled; `wai_world_meter` + labels what it measures `OnChipCounter`, with its declared uncertainty; + by §2's definitions, a browser's wall-clock figure against a declared + coefficient is `ModelBased`. --- diff --git a/wai/extensions/jwp-receipts.md b/wai/extensions/jwp-receipts.md index 0aeac8fc5..17aeabfd2 100644 --- a/wai/extensions/jwp-receipts.md +++ b/wai/extensions/jwp-receipts.md @@ -131,7 +131,7 @@ Required fields: | `group_id` | u64 | Monotonic group counter under `publisher_key`. | | `root_hash` | 32 B hex | §2.2 Merkle root over object content hashes. | | `leaf_count` | u64 | Number of objects covered by `root_hash`. | -| `joules_total` | u64 | Sum of microjoules across the group's objects. | +| `joules_total` | u64 | Sum of microjoules across the group's objects. No member carries its acquisition class, which [energy-measurement](energy-measurement.md) §2 requires beside a non-zero figure; §1 there records this as open, for this receipt and the object receipt alike. | | `group_start` | string | RFC 3339 timestamp of the first object. | | `group_end` | string | RFC 3339 timestamp of the last object. | | `signer_pubkey` | 32 B hex | Ed25519 verifying key per RFC 8032. | diff --git a/wai/extensions/linear-film.md b/wai/extensions/linear-film.md index 7ba370bb2..262f85514 100644 --- a/wai/extensions/linear-film.md +++ b/wai/extensions/linear-film.md @@ -135,7 +135,11 @@ reconstruction blocks (`frame_hash` per frame), Merkle-bound; one Ed25519 signature over film_hash + root + `work` (= frames) + carried `joules_micro` (the native `wai_meter` supplies a **measured** value); `parent_receipt_hash` chains — the worlds / volumetric receipt shape -reused. +reused. The figure's acquisition class rides beside it as an optional signed label +([energy-measurement](energy-measurement.md) §2.8): absent, the receipt keeps +its legacy bytes and the figure is unlabelled; present, it is inside the +signature. The reference meter labels every figure it measures +`OnChipCounter`, with its declared uncertainty. A reel's receipt is the same profile. ## 7. Reel — a film with cuts (`wai.film.reel`) diff --git a/wai/extensions/moq-streaming-format.md b/wai/extensions/moq-streaming-format.md index c55042f7b..6d45d372a 100644 --- a/wai/extensions/moq-streaming-format.md +++ b/wai/extensions/moq-streaming-format.md @@ -103,11 +103,20 @@ commitment: | `content_hash` | 32 B | `jwp-receipts` §2.1 BLAKE3 over the envelope bytes. | | `joules_micro` | u64 | Microjoules producing this object. | | `acquisition` | string | Acquisition class per [energy-measurement](energy-measurement.md) §2. | +| `acquisition_evidence` | bytes | For `OnChipCounter` and `CalibratedInstrument`, the evidence that follows the tag byte in `energy-measurement.md` §2.7 (declared uncertainty, then filtering state or calibration reference). Empty for every other class. | | `group_receipt` | 32 B | BLAKE3 of the group receipt covering this object. | - `acquisition` **MUST** accompany `joules_micro`. A joule figure without its acquisition class is exactly what `energy-measurement.md` §2 forbids, and putting it on the wire does not make it acceptable. +- `acquisition_evidence` **MUST** be present and non-empty for a refined + hardware class (`OnChipCounter`, `CalibratedInstrument`), and empty + otherwise — including for legacy `HwShunt`, which carries no evidence. A + refined class without its declared uncertainty is refused by + `energy-measurement.md` §2 on the same terms as a figure without a class. A + property written before this field existed has no `acquisition_evidence`; a + reader treats its absence as empty, which is valid only for the classes that + carry none. - A relay **MUST** preserve this property unchanged, per `jwp-receipts` §4.1. A relay that recomputes it is asserting something it did not measure. - Absence of the property is not evidence of absence of a receipt; receipts MAY diff --git a/wai/extensions/oer-receipts.md b/wai/extensions/oer-receipts.md index 0b80e53d1..4b983cff3 100644 --- a/wai/extensions/oer-receipts.md +++ b/wai/extensions/oer-receipts.md @@ -16,7 +16,8 @@ acquired* — the acquisition class. It does not say how much weight the figure can bear. An OER receipt adds that: every figure carries an **evidential grade**, and the receipt as a whole is canonical, content-addressed and chained, so the grade -cannot be quietly upgraded after the fact. +cannot be quietly upgraded after the fact. An OER/2 receipt carries the grade and +no acquisition class; the two are different axes (§2). A receipt describes one unit of work of a declared `kind`, one of the strings `decision`, `task`, `reconstruction`, `calibration`, `job`, `compile`, `decode`, @@ -40,16 +41,56 @@ Three independent implementations check the same corpus: the reference verifier | `measured` | read from an instrument, a datasheet, or a published measurement | A figure's grade is its **evidential standing**; its acquisition class -(`HwShunt` / `ModelBased` / `Estimator`) is **how it was obtained**. They are -different axes and a receipt **SHOULD** carry both where it carries an acquisition -class at all. They correspond in one direction — `HwShunt` supports `measured`, -`ModelBased` is `derived` by [`energy-measurement.md`](energy-measurement.md)'s own -definition, `Estimator` is `projected` — but not the other: `measured` also covers -a datasheet or a published figure, which no acquisition class names, and -`simulated` has no acquisition-class counterpart at all. A producer **MUST NOT** -grade `measured` a figure whose acquisition class is `Estimator`. A receipt carries -no acquisition-class member (§3), so no reason in §7 checks this; it binds the -producer. +([`energy-measurement.md`](energy-measurement.md) §2: `CalibratedInstrument`, +`OnChipCounter`, `ModelBased`, `Estimator`, and the legacy `HwShunt`) is **how it +was obtained**. They are different axes and a receipt **SHOULD** carry both where +it carries an acquisition class at all. An OER/2 receipt has no member for the +class, and cannot gain one without becoming non-canonical (§3). Its descriptive +members name none. `provenance.device` is text; the corpus writes `hardware`, +`emulation` and `simulation`. `energy.method` is text too; the corpus writes +`hardware_meter` and `model_estimated`. In particular, neither `hardware` nor +`hardware_meter` is `HwShunt` or either refined hardware class. + +[`energy-measurement.md`](energy-measurement.md) governs the receipt profiles +WAI defines (§1 there), not an OER/2 receipt. No rule of that extension binds an +OER receipt or its producers and verifiers: an OER receipt that carries a +non-zero `energy.joules_total` and no class breaks none of them, and that +extension's publication gate (§4 there) and reporting contract (§6 there) do not +apply to it either. Its evidential axis is the grade, under this document's +rules. + +The two axes correspond in one direction: + +| acquisition class | supports the grade | +|---|---| +| `CalibratedInstrument` | `measured` | +| `OnChipCounter` | `measured` | +| `HwShunt` (legacy) | `measured`: a figure sealed before the hardware class was refined, or a refined hardware figure mapped into the three-name vocabulary (below) | +| `ModelBased` | `derived`, by [`energy-measurement.md`](energy-measurement.md)'s own definition | +| `Estimator` | `projected` | + +- Both refined hardware classes are readings of an instrument, so both support + `measured`. The grade does not carry what separates them: whether the figure's + uncertainty is traceable to a calibration + ([`energy-measurement.md`](energy-measurement.md) §2.2). The class and its + declared uncertainty (§2.3 there) say that; `measured` does not. +- Legacy `HwShunt` is what WAI receipts sealed before the refinement say. It + ranks below both refined classes, and + [`energy-measurement.md`](energy-measurement.md) §2.6 forbids it in a new WAI + receipt, so a hardware figure a conforming WAI receipt seals today names + `OnChipCounter` or `CalibratedInstrument`. Mapped into a vocabulary that has + only `HwShunt` / `ModelBased` / `Estimator`, both refined classes become + `HwShunt` (§2 there); the grade does not change, so a `HwShunt` figure + produced that way supports `measured` too. An OER receipt records no class + at all, so a figure graded `measured` in one says nothing of which hardware + class, if any, is behind it. +- The correspondence does not run the other way: `measured` also covers a + datasheet or a published figure, which no acquisition class names, and + `simulated` has no acquisition-class counterpart at all. + +A producer **MUST NOT** grade `measured` a figure whose acquisition class is +`Estimator`. A receipt carries no acquisition-class member (§3), so no reason in +§7 checks this; it binds the producer. `provenance.weakest_input` names the lowest-graded input the figure rests on, so a `measured` total built on a `projected` constant is visible as such. diff --git a/wai/extensions/provenance.md b/wai/extensions/provenance.md index 977f43e13..052b465e1 100644 --- a/wai/extensions/provenance.md +++ b/wai/extensions/provenance.md @@ -76,6 +76,15 @@ receipts carry it: `wai_prov` measures the real CPU energy of each encode (IOReport via macmon, no sudo) and signs the figure. An unmetered step carries `0`. The receipt never derives energy it did not measure. +Each step's figure carries its acquisition class as an optional label +([energy-measurement](energy-measurement.md) §2.8), because the steps of one +lineage can be metered by different parties in different ways. A labelled step +binds its class into its leaf hash; a step with no label keeps its legacy leaf +and its figure is unlabelled. `wai_prov` labels each encode it measures +`OnChipCounter`, with its declared uncertainty, and records an encode whose +measurement fails the §4 ratio as unmetered. `total_joules_micro` carries no +class of its own. + ## Appendix The reconstruct classes prove WAI can ship a world, a sound, a body, a diff --git a/wai/extensions/quantum-ops.md b/wai/extensions/quantum-ops.md index c811312eb..44f6d91f9 100644 --- a/wai/extensions/quantum-ops.md +++ b/wai/extensions/quantum-ops.md @@ -36,7 +36,12 @@ A signed record of one bring-up / tune-up run: (RB / T1·T2 / Rabi / readout-GMM) **content-addressed to its raw dataset bytes**, so a fidelity *claim* binds to the exact bytes that produced it (`evidence_matches()`) — checkable, not asserted -- `joules_micro` — measured energy of the run (attested) +- `joules_micro` — measured energy of the run (attested), with its acquisition + class as an optional signed label: `quantum_energy::Labelled` wraps any + receipt here and re-signs it with the class inside the signature, and + `LabelledQbom` labels a bill of materials per entry + ([energy-measurement](energy-measurement.md) §2.8). An unlabelled receipt + keeps its legacy bytes. - `grant` — the authorization (below) - `parent_receipt_hash` — the prior device state this supersedes: the signed "digital twin" lineage vendors keep only as mutable, unsigned data diff --git a/wai/extensions/quantum-sim.md b/wai/extensions/quantum-sim.md index 0dcee61e1..d928aeedf 100644 --- a/wai/extensions/quantum-sim.md +++ b/wai/extensions/quantum-sim.md @@ -223,7 +223,14 @@ energy honestly: on Apple Silicon it samples CPU power via `macmon` (IOReport, n sudo), brackets a reconstruction burst against an idle baseline, and writes the **measured** marginal microjoules — never a fabricated number; where no meter is present it emits an UNMETERED receipt (`joules_micro = 0`) with the exact work -intact. A representative run on the reference QFT-5 vector: **≈43 µJ measured per +intact. A measured figure is sealed with its acquisition class as a +`quantum_energy::Labelled` receipt ([energy-measurement](energy-measurement.md) +§2.8): `OnChipCounter` with its declared uncertainty, inside the signature, +verified with `Labelled::verify` / `Labelled::verify_reconstruction`. The +`wai-quantum verify` CLI and the HTTP handler's `POST /verify` accept a receipt +in either form (`quantum_energy::MaybeLabelled`) and restate its class. A receipt +sealed without a label keeps its legacy bytes and its figure is unlabelled. A +representative run on the reference QFT-5 vector: **≈43 µJ measured per reconstruction, `work_amp_updates = 672`** (21 ops × 2⁵), sealed and self-verified. Because a quantum computation carries no speedup here, this is exactly the value diff --git a/wai/extensions/score.md b/wai/extensions/score.md index 65fa0c339..9797fc7e8 100644 --- a/wai/extensions/score.md +++ b/wai/extensions/score.md @@ -92,7 +92,11 @@ HRTF render or the worlds renderer does not. (per a fixed sample span), Merkle-bound; one Ed25519 signature over score_hash + root + exact work (= duration × notes) + carried `joules_micro`; `parent_receipt_hash` chains — the worlds/audio receipt -shape reused. +shape reused. The figure's acquisition class rides beside it as an optional signed label +([energy-measurement](energy-measurement.md) §2.8): absent, the receipt keeps +its legacy bytes and the figure is unlabelled; present, it is inside the +signature. The reference meter labels every figure it measures +`OnChipCounter`, with its declared uncertainty. ## Appendix diff --git a/wai/extensions/spatial-audio.md b/wai/extensions/spatial-audio.md index 4358c1389..98197d0c1 100644 --- a/wai/extensions/spatial-audio.md +++ b/wai/extensions/spatial-audio.md @@ -154,7 +154,9 @@ impl): the Merkle root, the block list, `work_mac` (= output samples × objects mixed — exact, signed) and `joules_micro` (carried; the native `wai_world_meter` pattern supplies a measured figure, `0` if - unmetered). + unmetered), with the figure's acquisition class as an optional signed + label ([energy-measurement](energy-measurement.md) §2.8) — absent, the + receipt keeps its legacy bytes and the figure is unlabelled. - `parent_receipt_hash` chains successive renders into an append-only signed timeline, exactly as worlds' receipts do. diff --git a/wai/extensions/state-feeds.md b/wai/extensions/state-feeds.md index 50d5a82b7..dd538d4ab 100644 --- a/wai/extensions/state-feeds.md +++ b/wai/extensions/state-feeds.md @@ -100,7 +100,9 @@ state hash. The interactive-worlds session receipt (`jwp-receipts.md`) applies unchanged: world identity over contract + tick-0 graph; the group's objects are the op batches, Merkle-bound; one Ed25519 seal over world -hash + root + checkpoints + exact signed `work` + carried `joules_micro`; +hash + root + checkpoints + exact signed `work` + carried `joules_micro` +(with its acquisition class as an optional signed label, +[energy-measurement](energy-measurement.md) §2.8); `parent_receipt_hash` chains a feed's seals into a timeline. ## Appendix diff --git a/wai/extensions/ternary-tt.md b/wai/extensions/ternary-tt.md index 3e17b1c1c..e9d93a955 100644 --- a/wai/extensions/ternary-tt.md +++ b/wai/extensions/ternary-tt.md @@ -124,6 +124,10 @@ reconstruction independently. Chains via `parent_receipt_hash`. The JWP Merkle + Ed25519 are reused verbatim — the same shape as `QuantumReceipt` ([quantum-sim](quantum-sim.md)) and the world/provenance receipts. Measured energy comes from the same IOReport path as `wai_quantum_meter`; never fabricated. +The figure's acquisition class rides beside it as an optional signed label +([energy-measurement](energy-measurement.md) §2.8): absent, the receipt keeps +its legacy bytes and the figure is unlabelled; present, it is inside the +signature. `with_energy_class` labels and re-signs a sealed receipt. ## 7. Productionize only with a use case diff --git a/wai/extensions/volumetric.md b/wai/extensions/volumetric.md index aa6dfa837..871930463 100644 --- a/wai/extensions/volumetric.md +++ b/wai/extensions/volumetric.md @@ -99,7 +99,11 @@ than imply a compression claim the format does not make. reconstruction blocks (`frame_hash` per frame), Merkle-bound; one Ed25519 signature over clip_hash + root + `work` (= n_frames × n_gaussians) + carried `joules_micro`; `parent_receipt_hash` chains — the -worlds / audio / haptics receipt shape reused. +worlds / audio / haptics receipt shape reused. The figure's acquisition class rides beside it as an optional signed label +([energy-measurement](energy-measurement.md) §2.8): absent, the receipt keeps +its legacy bytes and the figure is unlabelled; present, it is inside the +signature. The reference meter labels every figure it measures +`OnChipCounter`, with its declared uncertainty. ## Appendix diff --git a/wai/wai-quantum-serve/src/lib.rs b/wai/wai-quantum-serve/src/lib.rs index be836996e..9775f0476 100644 --- a/wai/wai-quantum-serve/src/lib.rs +++ b/wai/wai-quantum-serve/src/lib.rs @@ -17,9 +17,14 @@ //! GET /receipt?algo=ghz&n=3 seal a signed, energy-accounted receipt //! POST /verify body = receipt JSON → verdict //! ``` +//! +//! `POST /verify` accepts a receipt in either form: as sealed, or with its +//! joule figure's acquisition class inside the signature +//! (`quantum_energy::Labelled`), and restates the class in its verdict. use ed25519_dalek::SigningKey; use wai::quantum::{Circuit, Measure}; +use wai::quantum_energy::MaybeLabelled; use wai::quantum_receipt::QuantumReceipt; use wai::quantum_toolchain as qt; use wasi::http::types::{ @@ -30,6 +35,41 @@ use wasi::http::types::{ wasi::http::proxy::export!(Component); struct Component; +/// The `POST /verify` verdict for a receipt JSON body: an HTTP status and a +/// JSON body — 200 valid, 409 invalid, 400 unparseable. +/// +/// Either form verifies: a receipt as sealed, or one whose joule figure +/// carries its acquisition class inside the signature +/// (`quantum_energy::Labelled`, as the reference quantum meter emits). The +/// verdict restates the class and its evidence, or `"energy_provenance":null` +/// for a figure with no class. +fn verify_response(src: &str) -> (u16, String) { + let Some(any) = MaybeLabelled::::from_json(src) else { + return (400, "{\"valid\":false,\"reason\":\"unparseable receipt\"}".to_string()); + }; + let ok = any.verify(); + let r = any.receipt(); + let class = match any.energy_class() { + None => "\"energy_provenance\":null".to_string(), + Some(c) => c.json_fields().iter().map(|(k, v)| format!("\"{k}\":{v}")).collect::>().join(","), + }; + ( + if ok { 200 } else { 409 }, + format!( + "{{\"valid\":{},\"circuit_hash\":\"{}\",\"statevector_hash\":\"{}\",\ + \"n_qubits\":{},\"n_ops\":{},\"work_amp_updates\":{},\"joules_micro\":{},\"signer_id\":\"{}\",{class}}}", + ok, + hx(&r.circuit_hash), + hx(&r.statevector_hash), + r.n_qubits, + r.n_ops, + r.work_amp_updates, + r.joules_micro, + esc(&r.signer_id) + ), + ) +} + fn hx(b: &[u8]) -> String { b.iter().map(|x| format!("{x:02x}")).collect() } @@ -259,35 +299,53 @@ impl wasi::exports::http::incoming_handler::Guest for Component { return err(out, 400, "no body"); }; let src = String::from_utf8_lossy(&read_body(body)).into_owned(); - match QuantumReceipt::from_json(&src) { - None => json( - out, - 400, - "{\"valid\":false,\"reason\":\"unparseable receipt\"}".to_string(), - ), - Some(r) => { - let ok = r.verify(); - json( - out, - if ok { 200 } else { 409 }, - format!( - "{{\"valid\":{},\"circuit_hash\":\"{}\",\"statevector_hash\":\"{}\",\ - \"n_qubits\":{},\"n_ops\":{},\"work_amp_updates\":{},\"joules_micro\":{},\"signer_id\":\"{}\"}}", - ok, - hx(&r.circuit_hash), - hx(&r.statevector_hash), - r.n_qubits, - r.n_ops, - r.work_amp_updates, - r.joules_micro, - esc(&r.signer_id) - ), - ) - } - } + let (status, verdict) = verify_response(&src); + json(out, status, verdict) } _ => err(out, 404, "not found — GET / for the endpoint list"), } } } + +#[cfg(test)] +mod tests { + use super::*; + use wai::quantum_energy::{CounterFiltering, EnergyClass, Labelled, Uncertainty}; + + fn ghz3() -> Circuit { + let mut c = Circuit::new(3); + c.h(0).cx(0, 1).cx(1, 2); + c + } + + #[test] + fn verify_accepts_both_forms_and_restates_the_class() { + let key = SigningKey::from_bytes(&[0x71u8; 32]); + // As `/receipt` seals it: unmetered, unlabelled. + let r0 = QuantumReceipt::seal(&key, "did:key:wai-quantum-cli", &ghz3(), None, 0, None).unwrap(); + let (status, body) = verify_response(&r0.to_json()); + assert_eq!(status, 200, "{body}"); + assert!(body.contains("\"valid\":true") && body.ends_with("\"energy_provenance\":null}"), "{body}"); + + // As the reference quantum meter emits it: a 1 µJ figure labelled + // OnChipCounter, declaring its own rounding. + let r1 = QuantumReceipt::seal(&key, "did:web:meter", &ghz3(), None, 1, None).unwrap(); + let class = EnergyClass::OnChipCounter { + uncertainty: Uncertainty { relative_ppm: 288_830, window_us: 2_930_000 }, + filtering: CounterFiltering::Unknown, + }; + let l = Labelled::seal(r1, class, &key).unwrap(); + let j = l.to_json(); + let (status, body) = verify_response(&j); + assert_eq!(status, 200, "{body}"); + assert!(body.contains("\"energy_provenance\":\"OnChipCounter\""), "{body}"); + assert!(body.contains("\"relative_ppm\":288830") && body.contains("\"energy_counter_filtering\":\"Unknown\"")); + // The unlabelled parse alone refuses it: what this handler did before. + assert!(!QuantumReceipt::from_json(&j).unwrap().verify()); + + // A restated label is refused; so is anything unparseable. + assert_eq!(verify_response(&j.replace("288830", "288831")).0, 409); + assert_eq!(verify_response("not a receipt").0, 400); + } +} diff --git a/wai/wai-quantum/README.md b/wai/wai-quantum/README.md index 2826eadd4..32aed7eac 100644 --- a/wai/wai-quantum/README.md +++ b/wai/wai-quantum/README.md @@ -107,6 +107,17 @@ sink cannot inflate it) and **energy** is measured-attested (only the signer can vouch for its own silicon). Signatures travel between substrates because the computation is reproducible — not because two runtimes agreed to trust each other. +An energy figure is only comparable with another when it says how it was +acquired — read from the silicon's own counter, from a calibrated instrument, +from a model, or a constant. `quantum_energy::Labelled` seals that acquisition +class, with its declared uncertainty, inside the receipt's signature, and +`LabelledQbom` does the same per entry of a bill of materials. A receipt sealed +without a label keeps exactly the bytes it always had. `MaybeLabelled` parses +and verifies a receipt in whichever form it arrives, and checks a job's link to +its calibration in either form. A label's declared uncertainty must be at +least the `0.5/√3` µJ (≈ 0.289 µJ) that rounding the figure to whole +microjoules adds: `joules_micro × relative_ppm` of at least 288 676. + ## Also available - A CLI (`wai-quantum` in the [`wai`](https://crates.io/crates/wai) crate) — run diff --git a/wai/wai-quantum/src/lib.rs b/wai/wai-quantum/src/lib.rs index 9289d2481..482d44c64 100644 --- a/wai/wai-quantum/src/lib.rs +++ b/wai/wai-quantum/src/lib.rs @@ -43,6 +43,11 @@ pub mod quantum_kernel; #[cfg(feature = "quantum_mitigate")] pub mod quantum_mitigate; +// The acquisition class of a receipt's joule figure, as an optional signed +// label. Additive: unlabelled receipts keep their exact bytes. +#[cfg(feature = "_signing")] +pub mod quantum_energy; + #[cfg(feature = "quantum_mps")] pub mod quantum_mps; @@ -102,3 +107,8 @@ pub mod quantum_toolchain; #[cfg(feature = "quantum_vml")] pub mod quantum_vml; + +// Signed-byte pins: an unlabelled receipt keeps the exact bytes it had before +// the optional acquisition class existed. Test-only. +#[cfg(all(test, feature = "_signing"))] +mod receipt_pins; diff --git a/wai/wai-quantum/src/quantum_energy.rs b/wai/wai-quantum/src/quantum_energy.rs new file mode 100644 index 000000000..9f9a8311e --- /dev/null +++ b/wai/wai-quantum/src/quantum_energy.rs @@ -0,0 +1,870 @@ +//! The acquisition class of a quantum receipt's joule figure — an optional, +//! signed label (`extensions/energy-measurement.md` §2 and §2.8 in the WAI +//! repository). +//! +//! Every receipt in this crate seals `joules_micro`, and none of them said how +//! the figure was acquired. The same number can be a counter reading, a model +//! or a constant, so without a class it cannot be compared with any other. +//! Receipts sealed before this module existed must keep verifying, so the +//! class is added as a **label** that leaves the unlabelled form untouched: +//! +//! - **Unlabelled** — the receipt types as they always were. Their signed +//! bytes, receipt hashes and JSON are unchanged. The figure is *unlabelled*: +//! a verifier accepts it as legacy and reads it as no class at all. +//! - **Labelled** — [`Labelled`] (and [`LabelledQbom`] for a bill of +//! materials). The receipt is re-signed over its legacy payload with two +//! changes: the signing domain's final version byte `0x01` becomes `0x02`, +//! and the class's signed encoding follows the `joules_micro` field it +//! labels. The class is inside the signature, so it cannot be added, +//! stripped, restated or promoted in transit, and the `0x02` domain keeps +//! every labelled payload distinct from every unlabelled one. +//! +//! The change is additive: no existing type, field or function changes. A +//! labelled receipt is a new type wrapping the old one, and the old type's own +//! `verify()` refuses it (its signature is over the labelled payload), so a +//! reader that does not know about labels fails closed rather than silently +//! dropping the class. A verifier that must accept both forms parses with +//! [`MaybeLabelled`], which reads the form from the JSON and verifies each by +//! its own rules; the `wai-quantum verify` CLI and the HTTP handler's +//! `POST /verify` in the `wai` repository both do. +//! +//! A label's declared uncertainty must reach at least the `0.5/√3` µJ that +//! every whole-microjoule figure carries from its rounding, so a label with +//! `joules_micro × relative_ppm` below [`RESOLUTION_FLOOR_UJ_PPM`] is refused. +//! That is a floor, not the whole of a figure's resolution: a figure read as +//! the difference of two register readings carries more, which the producer +//! must declare and no verifier can check. +//! +//! The class vocabulary and its signed encoding are byte-identical to the +//! `energy_class` module of the `wai` crate: +//! +//! ```text +//! tag 2 ModelBased : — +//! tag 3 Estimator : — +//! tag 4 OnChipCounter : relative_ppm u32 BE · window_us u64 BE · filtering u8 +//! tag 5 CalibratedInstrument : relative_ppm u32 BE · window_us u64 BE · +//! ref_len u32 BE · calibration_ref (UTF-8) +//! ``` +//! +//! Tag 1, the legacy `HwShunt` label, is never a valid label here: these +//! receipts never carried it, and a new label must say which hardware evidence +//! it has. + +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use serde_json::{json, Map, Value}; + +/// A declared uncertainty: the standard relative uncertainty (k = 1) of the +/// figure, and the integration window it holds over. Both parts are required +/// and non-zero. For an on-chip counter it is the part the declarer could +/// evaluate, which excludes the counter's own systematic error; it is not a +/// statement of the figure's total error. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Uncertainty { + /// Standard relative uncertainty in parts per million of the figure. + pub relative_ppm: u32, + /// Integration window in microseconds. + pub window_us: u64, +} + +/// Whether an on-chip counter was adding deliberate noise to its readings. +/// `Unknown` is the only honest value when the control state is out of reach, +/// and a verifier reads it as possibly filtered. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CounterFiltering { + Unknown, + Off, + On, +} + +impl CounterFiltering { + pub fn label(self) -> &'static str { + match self { + CounterFiltering::Unknown => "Unknown", + CounterFiltering::Off => "Off", + CounterFiltering::On => "On", + } + } + + pub fn from_label(s: &str) -> Option { + match s { + "Unknown" => Some(CounterFiltering::Unknown), + "Off" => Some(CounterFiltering::Off), + "On" => Some(CounterFiltering::On), + _ => None, + } + } + + fn tag_byte(self) -> u8 { + match self { + CounterFiltering::Unknown => 0, + CounterFiltering::Off => 1, + CounterFiltering::On => 2, + } + } +} + +/// How a joule figure was acquired. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum EnergyClass { + /// Computed from a calibrated per-operation model (e.g. wall time × a + /// power constant). + ModelBased, + /// A coarse constant or load estimator. + Estimator, + /// Read from an energy counter the measured silicon keeps about itself — + /// RAPL, NVML, SMC, IOReport. A sensor reading, not an estimate, whose + /// declared uncertainty is the declarer's own evaluation. + OnChipCounter { uncertainty: Uncertainty, filtering: CounterFiltering }, + /// Read from an external instrument holding a current calibration, with a + /// resolvable reference to its certificate. + CalibratedInstrument { uncertainty: Uncertainty, calibration_ref: String }, +} + +/// Why a label was refused. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ClassError { + /// A hardware class with no (or a zero) relative uncertainty. + NoUncertainty, + /// A declared uncertainty with no (or a zero) window. + NoWindow, + /// A calibrated instrument with no certificate reference. + NoCalibrationRef, + /// A label this implementation does not know. + UnknownLabel, + /// Evidence beside a class that carries none, or without any class. + UnexpectedEvidence, + /// The legacy `HwShunt` label, which no receipt here ever carried. + LegacyLabel, + /// A class beside a zero figure, which claims a measurement that did not + /// occur. + NoFigure, + /// Labelling with a key other than the one that sealed the receipt. + SignerMismatch, + /// A labelled bill of materials whose class list does not match its + /// entries, or labels none of them. + EntryMismatch, + /// A declared uncertainty below the resolution floor: + /// `joules_micro × relative_ppm` below [`RESOLUTION_FLOOR_UJ_PPM`]. + BelowResolution, +} + +impl core::fmt::Display for ClassError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(match self { + ClassError::NoUncertainty => "a hardware energy class must declare a non-zero relative uncertainty", + ClassError::NoWindow => "a declared uncertainty must state the integration window it holds over", + ClassError::NoCalibrationRef => "a calibrated-instrument figure must reference its calibration certificate", + ClassError::UnknownLabel => "unknown acquisition class", + ClassError::UnexpectedEvidence => "evidence fields attached to a class that does not carry them", + ClassError::LegacyLabel => "HwShunt is a legacy label and cannot label a receipt that never carried it", + ClassError::NoFigure => "a class beside a zero figure claims a measurement that did not occur", + ClassError::SignerMismatch => "only the key that signed a receipt may label it", + ClassError::EntryMismatch => "a labelled bill must give one class slot per entry and label at least one", + ClassError::BelowResolution => "the declared uncertainty is below the 0.5/√3 µJ (about 0.289 µJ) that rounding the figure to whole microjoules adds: joules_micro × relative_ppm must be at least 288 676", + }) + } +} + +impl std::error::Error for ClassError {} + +impl EnergyClass { + /// The wire label. + pub fn label(&self) -> &'static str { + match self { + EnergyClass::ModelBased => "ModelBased", + EnergyClass::Estimator => "Estimator", + EnergyClass::OnChipCounter { .. } => "OnChipCounter", + EnergyClass::CalibratedInstrument { .. } => "CalibratedInstrument", + } + } + + /// The tag byte of the signed encoding. + pub fn tag_byte(&self) -> u8 { + match self { + EnergyClass::ModelBased => 2, + EnergyClass::Estimator => 3, + EnergyClass::OnChipCounter { .. } => 4, + EnergyClass::CalibratedInstrument { .. } => 5, + } + } + + /// The declared uncertainty, where the class carries one. + pub fn uncertainty(&self) -> Option { + match self { + EnergyClass::OnChipCounter { uncertainty, .. } + | EnergyClass::CalibratedInstrument { uncertainty, .. } => Some(*uncertainty), + _ => None, + } + } + + /// The evidence rules: a hardware class carries a non-zero uncertainty and + /// window; a calibrated instrument a non-empty certificate reference. + pub fn validate(&self) -> Result<(), ClassError> { + let check = |u: &Uncertainty| { + if u.relative_ppm == 0 { + Err(ClassError::NoUncertainty) + } else if u.window_us == 0 { + Err(ClassError::NoWindow) + } else { + Ok(()) + } + }; + match self { + EnergyClass::OnChipCounter { uncertainty, .. } => check(uncertainty), + EnergyClass::CalibratedInstrument { uncertainty, calibration_ref } => { + check(uncertainty)?; + if calibration_ref.trim().is_empty() { + return Err(ClassError::NoCalibrationRef); + } + Ok(()) + } + _ => Ok(()), + } + } + + /// Append the signed encoding: the tag byte, then the evidence. + pub fn write_signed(&self, o: &mut Vec) { + o.push(self.tag_byte()); + match self { + EnergyClass::OnChipCounter { uncertainty, filtering } => { + o.extend_from_slice(&uncertainty.relative_ppm.to_be_bytes()); + o.extend_from_slice(&uncertainty.window_us.to_be_bytes()); + o.push(filtering.tag_byte()); + } + EnergyClass::CalibratedInstrument { uncertainty, calibration_ref } => { + o.extend_from_slice(&uncertainty.relative_ppm.to_be_bytes()); + o.extend_from_slice(&uncertainty.window_us.to_be_bytes()); + o.extend_from_slice(&(calibration_ref.len() as u32).to_be_bytes()); + o.extend_from_slice(calibration_ref.as_bytes()); + } + _ => {} + } + } + + /// Rebuild a class from its wire parts, applying every evidence rule. + /// `filtering` absent on an `OnChipCounter` reads as `Unknown`. + pub fn from_parts( + label: &str, + uncertainty: Option, + filtering: Option, + calibration_ref: Option<&str>, + ) -> Result { + let unrefined = |c: EnergyClass| { + if uncertainty.is_some() || filtering.is_some() || calibration_ref.is_some() { + Err(ClassError::UnexpectedEvidence) + } else { + Ok(c) + } + }; + let c = match label { + "HwShunt" => return Err(ClassError::LegacyLabel), + "ModelBased" => unrefined(EnergyClass::ModelBased)?, + "Estimator" => unrefined(EnergyClass::Estimator)?, + "OnChipCounter" => { + if calibration_ref.is_some() { + return Err(ClassError::UnexpectedEvidence); + } + EnergyClass::OnChipCounter { + uncertainty: uncertainty.ok_or(ClassError::NoUncertainty)?, + filtering: filtering.unwrap_or(CounterFiltering::Unknown), + } + } + "CalibratedInstrument" => { + if filtering.is_some() { + return Err(ClassError::UnexpectedEvidence); + } + EnergyClass::CalibratedInstrument { + uncertainty: uncertainty.ok_or(ClassError::NoUncertainty)?, + calibration_ref: calibration_ref.ok_or(ClassError::NoCalibrationRef)?.to_owned(), + } + } + _ => return Err(ClassError::UnknownLabel), + }; + c.validate()?; + Ok(c) + } + + /// The JSON fields the class renders as, under the same names as the WAI + /// receipts: `energy_provenance`, and for a hardware class + /// `energy_uncertainty` `{relative_ppm, window_us}` with + /// `energy_counter_filtering` or `energy_calibration_ref`. The prefix keeps + /// them clear of fields the receipts already render — a job receipt's + /// `calibration_ref` names a calibration receipt, not a certificate. + pub fn json_fields(&self) -> Vec<(&'static str, Value)> { + let mut f = vec![("energy_provenance", Value::from(self.label()))]; + match self { + EnergyClass::OnChipCounter { uncertainty, filtering } => { + f.push(("energy_uncertainty", json!({"relative_ppm": uncertainty.relative_ppm, "window_us": uncertainty.window_us}))); + f.push(("energy_counter_filtering", Value::from(filtering.label()))); + } + EnergyClass::CalibratedInstrument { uncertainty, calibration_ref } => { + f.push(("energy_uncertainty", json!({"relative_ppm": uncertainty.relative_ppm, "window_us": uncertainty.window_us}))); + f.push(("energy_calibration_ref", Value::from(calibration_ref.as_str()))); + } + _ => {} + } + f + } + + /// Read an optional class from a JSON object. No class and no evidence is + /// `Ok(None)`; evidence without a class is refused. + pub fn from_json_object(o: &Map) -> Result, ClassError> { + let present = |k: &str| !matches!(o.get(k), None | Some(Value::Null)); + let label = match o.get("energy_provenance") { + None | Some(Value::Null) => { + if present("energy_uncertainty") || present("energy_counter_filtering") || present("energy_calibration_ref") { + return Err(ClassError::UnexpectedEvidence); + } + return Ok(None); + } + Some(Value::String(s)) => s.as_str(), + Some(_) => return Err(ClassError::UnknownLabel), + }; + let uncertainty = match o.get("energy_uncertainty") { + None | Some(Value::Null) => None, + Some(Value::Object(u)) => { + let ppm = u.get("relative_ppm").and_then(Value::as_u64).ok_or(ClassError::NoUncertainty)?; + let window_us = u.get("window_us").and_then(Value::as_u64).ok_or(ClassError::NoWindow)?; + Some(Uncertainty { relative_ppm: u32::try_from(ppm).map_err(|_| ClassError::NoUncertainty)?, window_us }) + } + Some(_) => return Err(ClassError::NoUncertainty), + }; + let filtering = match o.get("energy_counter_filtering") { + None | Some(Value::Null) => None, + Some(Value::String(s)) => Some(CounterFiltering::from_label(s).ok_or(ClassError::UnknownLabel)?), + Some(_) => return Err(ClassError::UnknownLabel), + }; + let calibration_ref = match o.get("energy_calibration_ref") { + None | Some(Value::Null) => None, + Some(Value::String(s)) => Some(s.as_str()), + Some(_) => return Err(ClassError::NoCalibrationRef), + }; + EnergyClass::from_parts(label, uncertainty, filtering, calibration_ref).map(Some) + } +} + +/// The least `joules_micro × relative_ppm` a declared uncertainty may have +/// (`extensions/energy-measurement.md` §2.3). `joules_micro` is a whole number +/// of microjoules, so a figure is uncertain by at least the rounding to it, +/// ±0.5 µJ taken as rectangular: `0.5/√3` µJ. A declaration of `ppm` on a +/// figure of `F` µJ covers that only when `F · ppm / 10⁶ ≥ 0.5/√3`, which for +/// the integer `F · ppm` is `≥ 288 676`. The same constant as the `wai` +/// crate's `energy_class::RESOLUTION_FLOOR_UJ_PPM`. +pub const RESOLUTION_FLOOR_UJ_PPM: u64 = 288_676; + +/// The rule a label must meet: a valid class beside a non-zero figure, whose +/// declared uncertainty, if it has one, reaches the resolution floor +/// ([`RESOLUTION_FLOOR_UJ_PPM`]). +pub fn check_label(joules_micro: u64, class: &EnergyClass) -> Result<(), ClassError> { + if joules_micro == 0 { + return Err(ClassError::NoFigure); + } + class.validate()?; + match class.uncertainty() { + Some(u) if (joules_micro as u128) * (u.relative_ppm as u128) < RESOLUTION_FLOOR_UJ_PPM as u128 => { + Err(ClassError::BelowResolution) + } + _ => Ok(()), + } +} + +/// The labelled form of a legacy signing domain: its final version byte `0x01` +/// replaced by `0x02`. +pub(crate) fn labelled_domain(domain: &[u8], label: Option<&EnergyClass>) -> Vec { + let mut d = domain.to_vec(); + if label.is_some() { + match d.last_mut() { + Some(v) if *v == 0x01 => *v = 0x02, + _ => panic!("a labelled signing domain must end in the legacy version byte 0x01"), + } + } + d +} + +/// Append an optional label after the figure it labels: nothing when absent. +pub(crate) fn write_label(label: Option<&EnergyClass>, o: &mut Vec) { + if let Some(c) = label { + c.write_signed(o); + } +} + +/// Insert `fields` into a JSON object at their sorted-key positions, leaving +/// every existing key in place; a leading `"kind"` key stays first. +pub(crate) fn insert_sorted(obj: Map, mut fields: Vec<(&'static str, Value)>) -> Map { + fields.sort_by(|a, b| a.0.cmp(b.0)); + let mut pending = fields.into_iter().peekable(); + let mut out = Map::new(); + for (i, (k, v)) in obj.into_iter().enumerate() { + if !(i == 0 && k == "kind") { + while let Some((nk, _)) = pending.peek() { + if *nk < k.as_str() { + let (nk, nv) = pending.next().unwrap(); + out.insert(nk.to_string(), nv); + } else { + break; + } + } + } + out.insert(k, v); + } + for (nk, nv) in pending { + out.insert(nk.to_string(), nv); + } + out +} + +fn hx(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +pub(crate) mod sealed { + pub trait Sealed {} +} + +/// A receipt type of this crate that can carry a label. Implemented for every +/// receipt that seals `joules_micro` except the bill of materials, which +/// labels per entry ([`LabelledQbom`]). Sealed: the methods are the plumbing +/// [`Labelled`] needs and are not a public API. +pub trait Labellable: sealed::Sealed + Clone + core::fmt::Debug + PartialEq + Sized { + #[doc(hidden)] + fn labelled_payload(&self, label: Option<&EnergyClass>) -> Vec; + #[doc(hidden)] + fn id_domain(&self) -> &'static [u8]; + #[doc(hidden)] + fn figure(&self) -> u64; + #[doc(hidden)] + fn body_ok(&self) -> bool; + #[doc(hidden)] + fn signer_key(&self) -> [u8; 32]; + #[doc(hidden)] + fn signature(&self) -> [u8; 64]; + #[doc(hidden)] + fn set_signature(&mut self, sig: [u8; 64]); + #[doc(hidden)] + fn unlabelled_json(&self) -> String; + #[doc(hidden)] + fn parse_unlabelled_json(s: &str) -> Option; + #[doc(hidden)] + fn unlabelled_verify(&self) -> bool; + #[doc(hidden)] + fn unlabelled_receipt_hash(&self) -> [u8; 32]; +} + +macro_rules! labellable { + ($t:ty, $id:expr) => { + impl $crate::quantum_energy::sealed::Sealed for $t {} + impl $crate::quantum_energy::Labellable for $t { + fn labelled_payload(&self, label: Option<&$crate::quantum_energy::EnergyClass>) -> Vec { + self.payload_with(label) + } + fn id_domain(&self) -> &'static [u8] { + $id + } + fn figure(&self) -> u64 { + self.joules_micro + } + fn body_ok(&self) -> bool { + self.body_verifies() + } + fn signer_key(&self) -> [u8; 32] { + self.signer_pubkey + } + fn signature(&self) -> [u8; 64] { + self.sig + } + fn set_signature(&mut self, sig: [u8; 64]) { + self.sig = sig; + } + fn unlabelled_json(&self) -> String { + self.to_json() + } + fn parse_unlabelled_json(s: &str) -> Option { + Self::from_json(s) + } + fn unlabelled_verify(&self) -> bool { + self.verify() + } + fn unlabelled_receipt_hash(&self) -> [u8; 32] { + self.receipt_hash() + } + } + }; +} +pub(crate) use labellable; + +/// A receipt whose joule figure carries its acquisition class, inside the +/// signature. +/// +/// Built with [`Labelled::seal`] from a receipt sealed the usual way, by the +/// same key. `receipt.verify()` on the inner receipt is `false` for a labelled +/// receipt — its signature covers the labelled payload — so always verify +/// through [`Labelled::verify`], and identify it by [`Labelled::receipt_hash`] +/// (e.g. as another receipt's `parent_receipt_hash`). +#[derive(Clone, Debug, PartialEq)] +pub struct Labelled { + pub receipt: R, + pub energy_class: EnergyClass, +} + +impl Labelled { + /// Label a sealed receipt's figure and re-sign it. `signer` must be the key + /// that sealed it. Refuses a class beside a zero figure and a class + /// without its evidence. + pub fn seal(mut receipt: R, energy_class: EnergyClass, signer: &SigningKey) -> Result, ClassError> { + if signer.verifying_key().to_bytes() != receipt.signer_key() { + return Err(ClassError::SignerMismatch); + } + check_label(receipt.figure(), &energy_class)?; + let sig = signer.sign(&receipt.labelled_payload(Some(&energy_class))).to_bytes(); + receipt.set_signature(sig); + Ok(Labelled { receipt, energy_class }) + } + + /// The label rule, the receipt's own consistency checks (Merkle root, + /// recomputed work, grant ceiling — whatever its `verify()` checks), and + /// the signature over the labelled payload. + pub fn verify(&self) -> bool { + if check_label(self.receipt.figure(), &self.energy_class).is_err() || !self.receipt.body_ok() { + return false; + } + let Ok(k) = VerifyingKey::from_bytes(&self.receipt.signer_key()) else { + return false; + }; + k.verify( + &self.receipt.labelled_payload(Some(&self.energy_class)), + &Signature::from_bytes(&self.receipt.signature()), + ) + .is_ok() + } + + /// Stable identity: `BLAKE3(id_domain ‖ labelled payload ‖ sig)`, the + /// receipt type's own identity construction over the labelled payload. + pub fn receipt_hash(&self) -> [u8; 32] { + let mut h = blake3::Hasher::new(); + h.update(self.receipt.id_domain()); + h.update(&self.receipt.labelled_payload(Some(&self.energy_class))); + h.update(&self.receipt.signature()); + *h.finalize().as_bytes() + } + + /// The receipt's canonical JSON with the class and its evidence inserted + /// at their sorted-key positions and `receipt_hash` giving the labelled + /// identity. + pub fn to_json(&self) -> String { + let v: Value = serde_json::from_str(&self.receipt.unlabelled_json()).expect("a receipt renders valid JSON"); + let Value::Object(mut obj) = v else { panic!("a receipt renders a JSON object") }; + if obj.contains_key("receipt_hash") { + obj.insert("receipt_hash".into(), Value::from(hx(&self.receipt_hash()))); + } + Value::Object(insert_sorted(obj, self.energy_class.json_fields())).to_string() + } + + /// Parse a labelled receipt. `None` when the JSON carries no class — parse + /// that with the receipt type's own `from_json` — or a malformed one. + pub fn from_json(s: &str) -> Option> { + let v: Value = serde_json::from_str(s).ok()?; + let energy_class = EnergyClass::from_json_object(v.as_object()?).ok()??; + let receipt = R::parse_unlabelled_json(s)?; + Some(Labelled { receipt, energy_class }) + } +} + +/// A receipt in whichever form it arrived: unlabelled — every receipt issued +/// before labels existed, and every unmetered one — or [`Labelled`]. +/// +/// A verify path that parses only the receipt type's own JSON refuses every +/// labelled receipt (its signature covers the labelled payload), and one that +/// parses only [`Labelled`] refuses every legacy receipt. This type is the one +/// entry point that accepts both and loses neither: [`MaybeLabelled::from_json`] +/// picks the form from the JSON itself, and each form verifies by its own rules. +#[derive(Clone, Debug, PartialEq)] +pub enum MaybeLabelled { + Unlabelled(R), + Labelled(Labelled), +} + +impl MaybeLabelled { + /// Parse either form. JSON carrying `energy_provenance` parses only as + /// [`Labelled`]; JSON carrying `energy_` evidence without a class, or a + /// malformed class, is refused — never read as an unlabelled receipt with + /// its label dropped. + pub fn from_json(s: &str) -> Option> { + let v: Value = serde_json::from_str(s).ok()?; + match EnergyClass::from_json_object(v.as_object()?) { + Ok(None) => R::parse_unlabelled_json(s).map(MaybeLabelled::Unlabelled), + Ok(Some(_)) => Labelled::from_json(s).map(MaybeLabelled::Labelled), + Err(_) => None, + } + } + + /// The receipt's own `verify()` when unlabelled; [`Labelled::verify`] — + /// the label rules, the receipt's consistency checks and the signature + /// over the labelled payload — when labelled. + pub fn verify(&self) -> bool { + match self { + MaybeLabelled::Unlabelled(r) => r.unlabelled_verify(), + MaybeLabelled::Labelled(l) => l.verify(), + } + } + + /// The receipt, whichever form carries it. + pub fn receipt(&self) -> &R { + match self { + MaybeLabelled::Unlabelled(r) => r, + MaybeLabelled::Labelled(l) => &l.receipt, + } + } + + /// The figure's class, or `None` for an unlabelled figure — which is read + /// as no class at all, not as any particular one. + pub fn energy_class(&self) -> Option<&EnergyClass> { + match self { + MaybeLabelled::Unlabelled(_) => None, + MaybeLabelled::Labelled(l) => Some(&l.energy_class), + } + } + + /// The identity another receipt names this one by: the receipt type's own + /// hash when unlabelled, [`Labelled::receipt_hash`] when labelled. + pub fn receipt_hash(&self) -> [u8; 32] { + match self { + MaybeLabelled::Unlabelled(r) => r.unlabelled_receipt_hash(), + MaybeLabelled::Labelled(l) => l.receipt_hash(), + } + } + + /// The canonical JSON of whichever form this is. + pub fn to_json(&self) -> String { + match self { + MaybeLabelled::Unlabelled(r) => r.unlabelled_json(), + MaybeLabelled::Labelled(l) => l.to_json(), + } + } +} + +impl From> for MaybeLabelled { + fn from(l: Labelled) -> MaybeLabelled { + MaybeLabelled::Labelled(l) + } +} + +#[cfg(feature = "quantum_ops")] +impl MaybeLabelled { + /// The cross-link check for either form of job and calibration: the job's + /// `calibration_ref` names the calibration's identity — its labelled + /// identity when it is labelled — and both verify by their own rules. + /// `JobReceipt::verify_against_calibration` is this check for two + /// unlabelled receipts. + pub fn verify_against_calibration(&self, cal: &MaybeLabelled) -> bool { + self.receipt().calibration_ref == Some(cal.receipt_hash()) && cal.verify() && self.verify() + } +} + +#[cfg(feature = "quantum_receipt")] +impl MaybeLabelled { + /// [`QuantumReceipt::verify_reconstruction`](crate::quantum_receipt::QuantumReceipt::verify_reconstruction) + /// or [`Labelled::verify_reconstruction`], whichever form this is. + pub fn verify_reconstruction(&self, circuit: &crate::quantum::Circuit) -> bool { + match self { + MaybeLabelled::Unlabelled(r) => r.verify_reconstruction(circuit), + MaybeLabelled::Labelled(l) => l.verify_reconstruction(circuit), + } + } +} + +#[cfg(feature = "quantum_receipt")] +impl Labelled { + /// The strong, portable check for a labelled circuit receipt: re-simulate + /// `circuit`, confirm it reproduces the receipt's identity, reconstruction + /// and histogram, then [`Labelled::verify`]. + pub fn verify_reconstruction(&self, circuit: &crate::quantum::Circuit) -> bool { + self.receipt.reconstruction_matches(circuit) && self.verify() + } +} + +/// A bill of materials whose entries carry their stages' acquisition classes. +/// +/// A QBOM restates each stage's figure, so each entry is labelled separately: +/// `classes[i]` labels `entries[i]`, and `None` leaves an entry unlabelled. The +/// labelled body is the legacy body under the `0x02` domain with every entry's +/// optional class (`0` when unlabelled) after that entry's figure. The total +/// carries no class of its own; a sum over stages of different classes is only +/// as strong as its weakest stage. +#[cfg(feature = "quantum_qbom")] +#[derive(Clone, Debug, PartialEq)] +pub struct LabelledQbom { + pub qbom: crate::quantum_qbom::Qbom, + pub classes: Vec>, +} + +#[cfg(feature = "quantum_qbom")] +impl LabelledQbom { + fn check(&self) -> Result<(), ClassError> { + if self.classes.len() != self.qbom.entries.len() || self.classes.iter().all(Option::is_none) { + return Err(ClassError::EntryMismatch); + } + for (e, c) in self.qbom.entries.iter().zip(&self.classes) { + if let Some(c) = c { + check_label(e.joules_micro, c)?; + } + } + Ok(()) + } + + /// Label a sealed bill's entries and re-sign it with the key that sealed + /// it. `classes` gives one slot per entry and labels at least one. + pub fn seal( + mut qbom: crate::quantum_qbom::Qbom, + classes: Vec>, + signer: &SigningKey, + ) -> Result { + if signer.verifying_key().to_bytes() != qbom.signer_pubkey { + return Err(ClassError::SignerMismatch); + } + let probe = LabelledQbom { qbom: qbom.clone(), classes }; + probe.check()?; + qbom.sig = signer.sign(&qbom.labelled_signing_payload(&probe.classes)).to_bytes(); + Ok(LabelledQbom { qbom, classes: probe.classes }) + } + + /// The label rules and the signature over the labelled payload. + pub fn verify(&self) -> bool { + if self.check().is_err() { + return false; + } + let Ok(k) = VerifyingKey::from_bytes(&self.qbom.signer_pubkey) else { + return false; + }; + k.verify(&self.qbom.labelled_signing_payload(&self.classes), &Signature::from_bytes(&self.qbom.sig)) + .is_ok() + } + + /// The provenance root over the labelled body. + pub fn provenance_root(&self) -> [u8; 32] { + *blake3::hash(&self.qbom.labelled_body(&self.classes)).as_bytes() + } + + /// Stable identity over the labelled payload. + pub fn receipt_hash(&self) -> [u8; 32] { + let mut h = blake3::Hasher::new(); + h.update(crate::quantum_qbom::DOMAIN_QBOM_ID); + h.update(&self.qbom.labelled_signing_payload(&self.classes)); + h.update(&self.qbom.sig); + *h.finalize().as_bytes() + } + + /// Given the actual stage receipts as `(receipt_hash, verifies, class)`, + /// does the bill bind exactly them — including each entry's class + /// matching its stage receipt's class? + pub fn binds(&self, receipts: &[([u8; 32], bool, Option<&EnergyClass>)]) -> bool { + if !self.verify() || receipts.len() != self.qbom.entries.len() { + return false; + } + self.qbom.entries.iter().zip(&self.classes).all(|(e, c)| { + receipts.iter().any(|(h, ok, rc)| *h == e.receipt_hash && *ok && *rc == c.as_ref()) + }) + } + + /// The bill's canonical JSON with each entry's class inserted into that + /// entry, and the labelled root and identity. + pub fn to_json(&self) -> String { + let v: Value = serde_json::from_str(&self.qbom.to_json()).expect("a qbom renders valid JSON"); + let Value::Object(mut obj) = v else { panic!("a qbom renders a JSON object") }; + if let Some(Value::Array(entries)) = obj.get_mut("entries") { + for (e, c) in entries.iter_mut().zip(&self.classes) { + if let (Value::Object(eo), Some(c)) = (e, c) { + let taken = std::mem::take(eo); + *eo = insert_sorted(taken, c.json_fields()); + } + } + } + obj.insert("provenance_root".into(), Value::from(hx(&self.provenance_root()))); + obj.insert("receipt_hash".into(), Value::from(hx(&self.receipt_hash()))); + Value::Object(obj).to_string() + } + + /// Parse a labelled bill. `None` when no entry carries a class, or a class + /// is malformed. + pub fn from_json(s: &str) -> Option { + let v: Value = serde_json::from_str(s).ok()?; + let mut classes = Vec::new(); + for e in v.as_object()?.get("entries")?.as_array()? { + classes.push(EnergyClass::from_json_object(e.as_object()?).ok()?); + } + if classes.iter().all(Option::is_none) { + return None; + } + let qbom = crate::quantum_qbom::Qbom::from_json(s)?; + Some(LabelledQbom { qbom, classes }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chip(ppm: u32) -> EnergyClass { + EnergyClass::OnChipCounter { uncertainty: Uncertainty { relative_ppm: ppm, window_us: 2_977_104 }, filtering: CounterFiltering::Unknown } + } + + #[test] + fn encoding_matches_the_wai_vocabulary() { + let mut o = Vec::new(); + chip(20_000).write_signed(&mut o); + let mut want = vec![4u8]; + want.extend_from_slice(&20_000u32.to_be_bytes()); + want.extend_from_slice(&2_977_104u64.to_be_bytes()); + want.push(0); + assert_eq!(o, want); + let mut o = Vec::new(); + EnergyClass::ModelBased.write_signed(&mut o); + EnergyClass::Estimator.write_signed(&mut o); + assert_eq!(o, vec![2, 3]); + let c = EnergyClass::CalibratedInstrument { uncertainty: Uncertainty { relative_ppm: 5_000, window_us: 60_000_000 }, calibration_ref: "cert-1".into() }; + let mut o = Vec::new(); + c.write_signed(&mut o); + assert_eq!(o[0], 5); + assert_eq!(&o[13..17], &6u32.to_be_bytes()); + assert_eq!(&o[17..], b"cert-1"); + } + + #[test] + fn labels_are_refused_where_they_would_mislead() { + assert_eq!(check_label(0, &chip(1)), Err(ClassError::NoFigure)); + assert_eq!(check_label(5, &chip(0)), Err(ClassError::NoUncertainty)); + // The resolution floor, at its boundary, from both sides of the product. + assert_eq!(check_label(1, &chip(288_675)), Err(ClassError::BelowResolution)); + assert_eq!(check_label(1, &chip(288_676)), Ok(())); + assert_eq!(check_label(288_675, &chip(1)), Err(ClassError::BelowResolution)); + assert_eq!(check_label(288_676, &chip(1)), Ok(())); + assert_eq!(check_label(u64::MAX, &chip(u32::MAX)), Ok(()), "no overflow"); + assert_eq!(check_label(1, &EnergyClass::ModelBased), Ok(()), "no declaration, nothing to cover"); + assert_eq!(EnergyClass::from_parts("HwShunt", None, None, None), Err(ClassError::LegacyLabel)); + assert_eq!(EnergyClass::from_parts("OnChipCounter", None, None, None), Err(ClassError::NoUncertainty)); + assert_eq!( + EnergyClass::from_parts("ModelBased", Some(Uncertainty { relative_ppm: 1, window_us: 1 }), None, None), + Err(ClassError::UnexpectedEvidence) + ); + let parse = |s: &str| { + let v: Value = serde_json::from_str(s).unwrap(); + EnergyClass::from_json_object(v.as_object().unwrap()) + }; + assert_eq!(parse(r#"{"joules_micro":5}"#), Ok(None)); + assert_eq!(parse(r#"{"energy_counter_filtering":"Off"}"#), Err(ClassError::UnexpectedEvidence)); + // A receipt's own `calibration_ref` field is not class evidence. + assert_eq!(parse(r#"{"calibration_ref":"d2d8"}"#), Ok(None)); + assert_eq!( + parse(r#"{"energy_provenance":"OnChipCounter","energy_uncertainty":{"relative_ppm":7,"window_us":9}}"#), + Ok(Some(EnergyClass::OnChipCounter { uncertainty: Uncertainty { relative_ppm: 7, window_us: 9 }, filtering: CounterFiltering::Unknown })) + ); + } + + #[test] + fn a_labelled_domain_changes_only_the_version_byte() { + let d = b"wai:quantum-receipt\x01"; + assert_eq!(labelled_domain(d, None), d.to_vec()); + let l = labelled_domain(d, Some(&EnergyClass::Estimator)); + assert_eq!(&l[..l.len() - 1], &d[..d.len() - 1]); + assert_eq!(l.last(), Some(&0x02)); + } +} diff --git a/wai/wai-quantum/src/quantum_ops.rs b/wai/wai-quantum/src/quantum_ops.rs index 576d3a07e..85090464e 100644 --- a/wai/wai-quantum/src/quantum_ops.rs +++ b/wai/wai-quantum/src/quantum_ops.rs @@ -236,7 +236,14 @@ impl CalibrationReceipt { } fn signing_payload(&self) -> Vec { - let mut o = DOMAIN_CAL.to_vec(); + self.payload_with(None) + } + + /// The signing payload: unlabelled (the legacy bytes), or labelled + /// (`crate::quantum_energy`) — the `0x02` domain, with the class after + /// the figure it labels. + fn payload_with(&self, label: Option<&crate::quantum_energy::EnergyClass>) -> Vec { + let mut o = crate::quantum_energy::labelled_domain(DOMAIN_CAL, label); o.extend_from_slice(self.device_id.as_bytes()); o.push(0); o.extend_from_slice(self.target.as_bytes()); @@ -245,6 +252,7 @@ impl CalibrationReceipt { o.extend_from_slice(&self.merkle_root); o.extend_from_slice(&(self.evidence.len() as u64).to_be_bytes()); o.extend_from_slice(&self.joules_micro.to_be_bytes()); + crate::quantum_energy::write_label(label, &mut o); self.grant.append_to(&mut o); match self.parent_receipt_hash { Some(h) => { @@ -295,16 +303,25 @@ impl CalibrationReceipt { self.joules_micro <= self.grant.joule_ceiling_micro } - /// Cryptographically valid AND within budget: the Merkle root recomputes, - /// the measured joules are within the grant ceiling, and the signature is - /// valid. A run that blew its energy grant cannot produce a passing receipt. - pub fn verify(&self) -> bool { + /// Every check [`Self::verify`] makes except the signature — shared with + /// the labelled form (`crate::quantum_energy::Labelled`). + fn body_verifies(&self) -> bool { if merkle_root(&self.leaves()) != self.merkle_root { return false; } if !self.honors_budget() { return false; } + true + } + + /// Cryptographically valid AND within budget: the Merkle root recomputes, + /// the measured joules are within the grant ceiling, and the signature is + /// valid. A run that blew its energy grant cannot produce a passing receipt. + pub fn verify(&self) -> bool { + if !self.body_verifies() { + return false; + } let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { return false; }; @@ -440,7 +457,14 @@ impl JobReceipt { } fn signing_payload(&self) -> Vec { - let mut o = DOMAIN_JOB.to_vec(); + self.payload_with(None) + } + + /// The signing payload: unlabelled (the legacy bytes), or labelled + /// (`crate::quantum_energy`) — the `0x02` domain, with the class after + /// the figure it labels. + fn payload_with(&self, label: Option<&crate::quantum_energy::EnergyClass>) -> Vec { + let mut o = crate::quantum_energy::labelled_domain(DOMAIN_JOB, label); o.extend_from_slice(self.backend_id.as_bytes()); o.push(0); o.extend_from_slice(&self.circuit_hash); @@ -455,6 +479,7 @@ impl JobReceipt { o.extend_from_slice(&self.shots.to_be_bytes()); o.extend_from_slice(&self.merkle_root); o.extend_from_slice(&self.joules_micro.to_be_bytes()); + crate::quantum_energy::write_label(label, &mut o); self.grant.append_to(&mut o); match self.parent_receipt_hash { Some(h) => { @@ -504,14 +529,23 @@ impl JobReceipt { self.joules_micro <= self.grant.joule_ceiling_micro } - /// Merkle recomputes, joules within the grant ceiling, signature valid. - pub fn verify(&self) -> bool { + /// Every check [`Self::verify`] makes except the signature — shared with + /// the labelled form (`crate::quantum_energy::Labelled`). + fn body_verifies(&self) -> bool { if merkle_root(&self.leaves()) != self.merkle_root { return false; } if !self.honors_budget() { return false; } + true + } + + /// Merkle recomputes, joules within the grant ceiling, signature valid. + pub fn verify(&self) -> bool { + if !self.body_verifies() { + return false; + } let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { return false; }; @@ -676,7 +710,14 @@ impl MitigationReceipt { } fn signing_payload(&self) -> Vec { - let mut o = DOMAIN_MIT.to_vec(); + self.payload_with(None) + } + + /// The signing payload: unlabelled (the legacy bytes), or labelled + /// (`crate::quantum_energy`) — the `0x02` domain, with the class after + /// the figure it labels. + fn payload_with(&self, label: Option<&crate::quantum_energy::EnergyClass>) -> Vec { + let mut o = crate::quantum_energy::labelled_domain(DOMAIN_MIT, label); o.extend_from_slice(self.backend_id.as_bytes()); o.push(0); o.extend_from_slice(self.method.as_bytes()); @@ -697,6 +738,7 @@ impl MitigationReceipt { o.extend_from_slice(&self.shots.to_be_bytes()); o.extend_from_slice(&self.merkle_root); o.extend_from_slice(&self.joules_micro.to_be_bytes()); + crate::quantum_energy::write_label(label, &mut o); self.grant.append_to(&mut o); match self.parent_receipt_hash { Some(h) => { @@ -755,15 +797,24 @@ impl MitigationReceipt { self.joules_micro <= self.grant.joule_ceiling_micro } + /// Every check [`Self::verify`] makes except the signature — shared with + /// the labelled form (`crate::quantum_energy::Labelled`). + fn body_verifies(&self) -> bool { + if merkle_root(&self.leaves()) != self.merkle_root { + return false; + } + if !self.honors_budget() { + return false; + } + true + } + /// Merkle recomputes (the mitigated number is bound to the exact inputs and /// noise model that produced it), joules within the grant ceiling, signature /// valid. Tampering with the raw data, the noise model, or the reported /// estimate breaks verification. pub fn verify(&self) -> bool { - if merkle_root(&self.leaves()) != self.merkle_root { - return false; - } - if !self.honors_budget() { + if !self.body_verifies() { return false; } let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { @@ -918,7 +969,14 @@ impl DecodeReceipt { } fn signing_payload(&self) -> Vec { - let mut o = DOMAIN_DEC.to_vec(); + self.payload_with(None) + } + + /// The signing payload: unlabelled (the legacy bytes), or labelled + /// (`crate::quantum_energy`) — the `0x02` domain, with the class after + /// the figure it labels. + fn payload_with(&self, label: Option<&crate::quantum_energy::EnergyClass>) -> Vec { + let mut o = crate::quantum_energy::labelled_domain(DOMAIN_DEC, label); o.extend_from_slice(self.code_id.as_bytes()); o.push(0); o.extend_from_slice(self.decoder.as_bytes()); @@ -931,6 +989,7 @@ impl DecodeReceipt { o.extend_from_slice(&self.work_messages.to_be_bytes()); o.extend_from_slice(&self.merkle_root_calc()); o.extend_from_slice(&self.joules_micro.to_be_bytes()); + crate::quantum_energy::write_label(label, &mut o); self.grant.append_to(&mut o); match self.parent_receipt_hash { Some(h) => { @@ -989,10 +1048,19 @@ impl DecodeReceipt { self.joules_micro <= self.grant.joule_ceiling_micro } + /// Every check [`Self::verify`] makes except the signature — shared with + /// the labelled form (`crate::quantum_energy::Labelled`). + fn body_verifies(&self) -> bool { + if !self.honors_budget() { + return false; + } + true + } + /// Merkle recomputes (the correction binds to the exact code + syndrome), /// joules within the grant ceiling, signature valid. pub fn verify(&self) -> bool { - if !self.honors_budget() { + if !self.body_verifies() { return false; } let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { @@ -1130,7 +1198,14 @@ impl CompileReceipt { } fn signing_payload(&self) -> Vec { - let mut o = DOMAIN_CMP.to_vec(); + self.payload_with(None) + } + + /// The signing payload: unlabelled (the legacy bytes), or labelled + /// (`crate::quantum_energy`) — the `0x02` domain, with the class after + /// the figure it labels. + fn payload_with(&self, label: Option<&crate::quantum_energy::EnergyClass>) -> Vec { + let mut o = crate::quantum_energy::labelled_domain(DOMAIN_CMP, label); o.extend_from_slice(&self.source_hash); o.extend_from_slice(&self.target_hash); o.extend_from_slice(&self.coupling_hash); @@ -1141,6 +1216,7 @@ impl CompileReceipt { o.extend_from_slice(&self.work.to_be_bytes()); o.extend_from_slice(&self.merkle()); o.extend_from_slice(&self.joules_micro.to_be_bytes()); + crate::quantum_energy::write_label(label, &mut o); self.grant.append_to(&mut o); match self.parent_receipt_hash { Some(h) => { @@ -1192,11 +1268,20 @@ impl CompileReceipt { self.joules_micro <= self.grant.joule_ceiling_micro } + /// Every check [`Self::verify`] makes except the signature — shared with + /// the labelled form (`crate::quantum_energy::Labelled`). + fn body_verifies(&self) -> bool { + if !self.honors_budget() { + return false; + } + true + } + /// Signature valid, joules within budget. A verifier who re-runs the /// deterministic equivalence check on the bound circuits can confirm the /// `equivalent` verdict independently. pub fn verify(&self) -> bool { - if !self.honors_budget() { + if !self.body_verifies() { return false; } let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { @@ -1325,7 +1410,14 @@ impl RearrangeReceipt { } fn signing_payload(&self) -> Vec { - let mut o = DOMAIN_REA.to_vec(); + self.payload_with(None) + } + + /// The signing payload: unlabelled (the legacy bytes), or labelled + /// (`crate::quantum_energy`) — the `0x02` domain, with the class after + /// the figure it labels. + fn payload_with(&self, label: Option<&crate::quantum_energy::EnergyClass>) -> Vec { + let mut o = crate::quantum_energy::labelled_domain(DOMAIN_REA, label); o.extend_from_slice(&self.initial_hash); o.extend_from_slice(&self.target_hash); o.extend_from_slice(&self.plan_hash); @@ -1336,6 +1428,7 @@ impl RearrangeReceipt { o.extend_from_slice(&self.total_distance.to_be_bytes()); o.extend_from_slice(&self.merkle()); o.extend_from_slice(&self.joules_micro.to_be_bytes()); + crate::quantum_energy::write_label(label, &mut o); self.grant.append_to(&mut o); match self.parent_receipt_hash { Some(h) => { @@ -1387,10 +1480,19 @@ impl RearrangeReceipt { self.joules_micro <= self.grant.joule_ceiling_micro } - pub fn verify(&self) -> bool { + /// Every check [`Self::verify`] makes except the signature — shared with + /// the labelled form (`crate::quantum_energy::Labelled`). + fn body_verifies(&self) -> bool { if !self.honors_budget() { return false; } + true + } + + pub fn verify(&self) -> bool { + if !self.body_verifies() { + return false; + } let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { return false; }; @@ -1818,3 +1920,15 @@ mod tests { assert!(!r.verify(), "tampering with the success verdict must break the receipt"); } } + +crate::quantum_energy::labellable!(CalibrationReceipt, DOMAIN_CAL_ID); + +crate::quantum_energy::labellable!(JobReceipt, DOMAIN_JOB_ID); + +crate::quantum_energy::labellable!(MitigationReceipt, DOMAIN_MIT_ID); + +crate::quantum_energy::labellable!(DecodeReceipt, DOMAIN_DEC_ID); + +crate::quantum_energy::labellable!(CompileReceipt, DOMAIN_CMP_ID); + +crate::quantum_energy::labellable!(RearrangeReceipt, DOMAIN_REA_ID); diff --git a/wai/wai-quantum/src/quantum_phasor_meter.rs b/wai/wai-quantum/src/quantum_phasor_meter.rs index ff0de1c83..90006f197 100644 --- a/wai/wai-quantum/src/quantum_phasor_meter.rs +++ b/wai/wai-quantum/src/quantum_phasor_meter.rs @@ -145,7 +145,14 @@ impl PhasorReceipt { } fn signing_payload(&self) -> Vec { - let mut o = DOMAIN_RECEIPT.to_vec(); + self.payload_with(None) + } + + /// The signing payload: unlabelled (the legacy bytes), or labelled + /// (`crate::quantum_energy`) — the `0x02` domain, with the class after + /// the figure it labels. + fn payload_with(&self, label: Option<&crate::quantum_energy::EnergyClass>) -> Vec { + let mut o = crate::quantum_energy::labelled_domain(DOMAIN_RECEIPT, label); o.extend_from_slice(&self.computation_hash); o.extend_from_slice(&(self.method.len() as u64).to_be_bytes()); o.extend_from_slice(self.method.as_bytes()); @@ -156,6 +163,7 @@ impl PhasorReceipt { o.extend_from_slice(&self.merkle_root); o.extend_from_slice(&self.phasor_ops.to_be_bytes()); o.extend_from_slice(&self.joules_micro.to_be_bytes()); + crate::quantum_energy::write_label(label, &mut o); match self.parent_receipt_hash { Some(h) => { o.push(1); @@ -201,16 +209,25 @@ impl PhasorReceipt { r } - /// Verify the receipt is internally consistent and signed: the Merkle root - /// recomputes over its leaves, the phasor work equals `D·(input_dim+1)·n` (a - /// stated cost a sink cannot inflate), and the Ed25519 signature is valid. - pub fn verify(&self) -> bool { + /// Every check [`Self::verify`] makes except the signature — shared with + /// the labelled form (`crate::quantum_energy::Labelled`). + fn body_verifies(&self) -> bool { if merkle_root(&self.leaves()) != self.merkle_root { return false; } if self.phasor_ops != self.work.phasor_ops() { return false; } + true + } + + /// Verify the receipt is internally consistent and signed: the Merkle root + /// recomputes over its leaves, the phasor work equals `D·(input_dim+1)·n` (a + /// stated cost a sink cannot inflate), and the Ed25519 signature is valid. + pub fn verify(&self) -> bool { + if !self.body_verifies() { + return false; + } let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { return false; }; @@ -400,3 +417,5 @@ mod tests { assert_eq!(child.parent_receipt_hash, Some(root.receipt_hash())); } } + +crate::quantum_energy::labellable!(PhasorReceipt, DOMAIN_RECEIPT_ID); diff --git a/wai/wai-quantum/src/quantum_qbom.rs b/wai/wai-quantum/src/quantum_qbom.rs index c85d15674..01ae51aac 100644 --- a/wai/wai-quantum/src/quantum_qbom.rs +++ b/wai/wai-quantum/src/quantum_qbom.rs @@ -20,6 +20,10 @@ //! stage breaks the bill. It is byte-exact and reproducible: the same computation //! rebuilds the same root from the git repo. //! +//! Each entry restates a stage's figure, so the bill's acquisition classes are +//! per entry: `quantum_energy::LabelledQbom` labels them, and a bill sealed here +//! is unlabelled, with exactly the legacy bytes. +//! //! This is a manifest, not a new physics claim — it inherits exactly the honesty //! of the receipts it bundles, and adds the property that a quantum result now //! carries a single verifiable statement of everything it was made of. @@ -28,7 +32,7 @@ use crate::quantum_ops::content_hash; use ed25519_dalek::{Signature, SigningKey, Verifier, VerifyingKey}; const DOMAIN_QBOM: &[u8] = b"wai:quantum-qbom\x01"; -const DOMAIN_QBOM_ID: &[u8] = b"wai:quantum-qbom-id\x01"; +pub(crate) const DOMAIN_QBOM_ID: &[u8] = b"wai:quantum-qbom-id\x01"; /// The canonical stages of a quantum computation, in pipeline order. A QBOM need /// not carry all of them; `Qbom::completeness` reports which are present. @@ -138,22 +142,50 @@ impl SignPayload for SigningKey { impl Qbom { /// Canonical bytes of the manifest body (everything but the signature). fn body(&self) -> Vec { - let mut o = DOMAIN_QBOM.to_vec(); + self.body_with(None) + } + + /// The body unlabelled (the legacy bytes), or labelled + /// (`crate::quantum_energy::LabelledQbom`): the `0x02` domain, with every + /// entry's optional class — `0` when that entry is unlabelled — after the + /// entry's figure. + fn body_with(&self, classes: Option<&[Option]>) -> Vec { + let any = classes.and_then(|c| c.iter().flatten().next()); + let mut o = crate::quantum_energy::labelled_domain(DOMAIN_QBOM, any); o.extend_from_slice(&(self.job_id.len() as u32).to_le_bytes()); o.extend_from_slice(self.job_id.as_bytes()); o.extend_from_slice(&self.program_hash); o.extend_from_slice(&(self.device_id.len() as u32).to_le_bytes()); o.extend_from_slice(self.device_id.as_bytes()); o.extend_from_slice(&(self.entries.len() as u32).to_le_bytes()); - for e in &self.entries { + for (i, e) in self.entries.iter().enumerate() { o.extend_from_slice(&(e.kind.len() as u32).to_le_bytes()); o.extend_from_slice(e.kind.as_bytes()); o.extend_from_slice(&e.receipt_hash); o.extend_from_slice(&e.joules_micro.to_le_bytes()); + if any.is_some() { + match classes.and_then(|c| c.get(i)).and_then(Option::as_ref) { + Some(c) => c.write_signed(&mut o), + None => o.push(0), + } + } } o } + /// The labelled body (`crate::quantum_energy::LabelledQbom`). + pub(crate) fn labelled_body(&self, classes: &[Option]) -> Vec { + self.body_with(Some(classes)) + } + + /// The labelled signing payload (`crate::quantum_energy::LabelledQbom`). + pub(crate) fn labelled_signing_payload(&self, classes: &[Option]) -> Vec { + let mut o = self.labelled_body(classes); + o.extend_from_slice(&self.signer_pubkey); + o.extend_from_slice(self.signer_id.as_bytes()); + o + } + /// The provenance root: a content hash over the whole manifest body. Stable /// and reproducible — the same computation rebuilds the same root. pub fn provenance_root(&self) -> [u8; 32] { diff --git a/wai/wai-quantum/src/quantum_receipt.rs b/wai/wai-quantum/src/quantum_receipt.rs index f11fff682..3efbafe74 100644 --- a/wai/wai-quantum/src/quantum_receipt.rs +++ b/wai/wai-quantum/src/quantum_receipt.rs @@ -21,6 +21,10 @@ //! statevector [re-checkable], costing THIS exact deterministic work //! [re-checkable], which drew THIS measured energy on my machine \[signed\]."* //! +//! The figure's acquisition class is an optional label: a receipt sealed here +//! is unlabelled, with exactly the legacy bytes, and +//! `quantum_energy::Labelled::seal` labels and re-signs it (`quantum_energy`). +//! //! It is a JWP profile like every other receipt in this crate — the //! worlds/provenance Merkle (`crate::merkle::merkle_root`) + Ed25519, //! reused, not reinvented — and chains across a derivation via @@ -119,7 +123,14 @@ impl QuantumReceipt { } fn signing_payload(&self) -> Vec { - let mut o = DOMAIN_RECEIPT.to_vec(); + self.payload_with(None) + } + + /// The signing payload: unlabelled (the legacy bytes), or labelled + /// (`crate::quantum_energy`) — the `0x02` domain, with the class after + /// the figure it labels. + fn payload_with(&self, label: Option<&crate::quantum_energy::EnergyClass>) -> Vec { + let mut o = crate::quantum_energy::labelled_domain(DOMAIN_RECEIPT, label); o.extend_from_slice(&self.circuit_hash); o.extend_from_slice(&self.statevector_hash); o.push(self.n_qubits); @@ -127,6 +138,7 @@ impl QuantumReceipt { o.extend_from_slice(&self.merkle_root); o.extend_from_slice(&self.work_amp_updates.to_be_bytes()); o.extend_from_slice(&self.joules_micro.to_be_bytes()); + crate::quantum_energy::write_label(label, &mut o); match &self.measurement { Some(m) => { o.push(1); @@ -187,15 +199,24 @@ impl QuantumReceipt { Ok(r) } + /// Every check [`Self::verify`] makes except the signature — shared with + /// the labelled form (`crate::quantum_energy::Labelled`). + fn body_verifies(&self) -> bool { + if merkle_root(&self.leaves()) != self.merkle_root { + return false; + } + if self.work_amp_updates != work_amp_updates(self.n_qubits, self.n_ops) { + return false; + } + true + } + /// Verify the receipt is internally consistent and signed: the Merkle root /// recomputes over its leaves, the work equals `n_ops · 2^n` (a stated cost a /// sink cannot inflate), and the Ed25519 signature is valid. This does NOT /// re-run the simulation — see [`Self::verify_reconstruction`] for that. pub fn verify(&self) -> bool { - if merkle_root(&self.leaves()) != self.merkle_root { - return false; - } - if self.work_amp_updates != work_amp_updates(self.n_qubits, self.n_ops) { + if !self.body_verifies() { return false; } let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { @@ -213,6 +234,12 @@ impl QuantumReceipt { /// (The measured `joules_micro` remains attested-only — re-simulation checks /// the *result and cost*, never the signer's energy draw.) pub fn verify_reconstruction(&self, circuit: &Circuit) -> bool { + self.reconstruction_matches(circuit) && self.verify() + } + + /// The re-simulation half of [`Self::verify_reconstruction`], shared with + /// the labelled form (`crate::quantum_energy::Labelled`). + pub(crate) fn reconstruction_matches(&self, circuit: &Circuit) -> bool { if circuit_hash(circuit) != self.circuit_hash { return false; } @@ -230,7 +257,7 @@ impl QuantumReceipt { { return false; } - self.verify() + true } /// Stable id for chaining a derived receipt via `parent_receipt_hash`. @@ -397,3 +424,5 @@ mod tests { assert_eq!(child.parent_receipt_hash, Some(root.receipt_hash())); } } + +crate::quantum_energy::labellable!(QuantumReceipt, DOMAIN_RECEIPT_ID); diff --git a/wai/wai-quantum/src/receipt_pins.rs b/wai/wai-quantum/src/receipt_pins.rs new file mode 100644 index 000000000..d2f51f9f2 --- /dev/null +++ b/wai/wai-quantum/src/receipt_pins.rs @@ -0,0 +1,524 @@ +//! Signed-byte pins for every receipt in this crate that seals `joules_micro`. +//! +//! These receipts carried their energy figure with no acquisition class +//! before they were given an optional one. A receipt sealed without a class +//! must keep exactly the signed bytes it had before, or every receipt +//! already issued stops verifying. Each test builds one +//! fixed, unlabelled receipt with a non-zero figure and pins, as computed by +//! the code *before* the class was added: +//! +//! - `receipt_hash()`, which covers the whole signing payload and the +//! signature; +//! - a BLAKE3 hash of `to_json()`, so the rendered form is byte-identical too. +//! +//! The hex values must never be edited to make a test pass. A failure here +//! means legacy receipts broke. + +use ed25519_dalek::SigningKey; + +#[allow(dead_code)] +fn key(s: u8) -> SigningKey { + SigningKey::from_bytes(&[s; 32]) +} + +#[allow(dead_code)] +fn hx(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +#[allow(dead_code)] +fn json_hash(j: &str) -> String { + hx(blake3::hash(j.as_bytes()).as_bytes()) +} + +#[cfg(feature = "quantum_receipt")] +#[test] +fn quantum_circuit_legacy_bytes() { + use crate::quantum::{Circuit, Measure}; + use crate::quantum_receipt::QuantumReceipt; + let mut c = Circuit::new(3); + c.h(0).cx(0, 1).cx(1, 2); + let r = QuantumReceipt::seal(&key(1), "did:key:pin", &c, Some(Measure { seed: 7, shots: 1024 }), 117_000, Some([0x21; 32])) + .unwrap(); + assert!(r.verify()); + assert_eq!(hx(&r.receipt_hash()), "5f841f94b57ce254bea9e79cbc55cb48117c88c202a916659e4e8db5fae1fba1"); + assert_eq!(json_hash(&r.to_json()), "bc19d790962cecf2efea9d275f1b9494eb5a44bc1948e1f1bbaf2134f222b37d"); +} + +#[cfg(feature = "quantum_ops")] +mod ops { + use super::*; + use crate::quantum_ops::*; + + fn grant(cap: &str) -> GrantRef { + GrantRef { grant_hash: content_hash(b"pin-grant"), capability: cap.into(), joule_ceiling_micro: 5_000_000, funds_ceiling: Some(100) } + } + + fn evid() -> Vec { + vec![Evidence { kind: "randomized_benchmarking".into(), blob_hash: content_hash(b""), summary: "1q RB 0.9992".into() }] + } + + #[test] + fn calibration_legacy_bytes() { + let r = CalibrationReceipt::seal( + &key(1), "did:key:pin", "qm:opx1000:lab3", "q3", content_hash(b"cfg"), evid(), + 4_200_000, grant("quantum.calibrate"), None, + ); + assert!(r.verify()); + assert_eq!(hx(&r.receipt_hash()), "17e5f4e95928251c01735db5390ca4ce9d0970ce78132b426112de41bfe42c7a"); + assert_eq!(json_hash(&r.to_json()), "5d30fcc5c28a9a6f208dfdcd2e8cb6674927e6c73220d69cd32aaaa002d63185"); + } + + #[test] + fn job_legacy_bytes() { + let r = JobReceipt::seal( + &key(1), "did:key:pin", "wai.quantum.circuit", content_hash(b"c"), content_hash(b"r"), + Some([0x31; 32]), 1000, 250, grant("quantum.job.run"), Some([0x32; 32]), + ); + assert!(r.verify()); + assert_eq!(hx(&r.receipt_hash()), "52a38a0f7d6c9c58a67948b7ae12a92bfcb7a8a51507b50ec623adb13b05a42b"); + assert_eq!(json_hash(&r.to_json()), "9e8d951ad82084deb6f7ac3e48a049bb305de06adc5d77175bf3ce432abc786b"); + } + + #[test] + fn mitigation_legacy_bytes() { + let r = MitigationReceipt::seal( + &key(1), "did:key:pin", "wai.quantum.circuit", "zne.richardson", "ZZ", + content_hash(b"raw"), Some(content_hash(b"model")), 744_178, 912_680, 20_971, 48_000, + 900_000, grant("quantum.mitigate"), None, + ); + assert!(r.verify()); + assert_eq!(hx(&r.receipt_hash()), "df0427389afeb3e7943ab15f7318ab22d5828c2623f5ef7dd6607ce1c1415247"); + assert_eq!(json_hash(&r.to_json()), "99379161a41ecbfb63ecf2cd1217d6589a7566e0dfc923e6fc35b561a4a6dc08"); + } + + #[test] + fn decode_legacy_bytes() { + let r = DecodeReceipt::seal( + &key(1), "did:key:pin", "toric:L7", "relay-bp.min-sum", + content_hash(b"H"), content_hash(b"s"), content_hash(b"c"), + true, 3, 46_656, 900_000, grant("quantum.decode"), None, + ); + assert!(r.verify()); + assert_eq!(hx(&r.receipt_hash()), "27eecec2c2b92d4e4e6e1a6e5025199e000c51ad691b0c3d195a29965a9e17a2"); + assert_eq!(json_hash(&r.to_json()), "6c9a015980792a3baf33a230d009eb00fc2eda65e121edfc788659ee603387d7"); + } + + #[test] + fn compile_legacy_bytes() { + let r = CompileReceipt::seal( + &key(1), "did:key:pin", "clifford-tableau", + content_hash(b"s"), content_hash(b"t"), content_hash(b"cpl"), + true, 3, 4096, 300_000, grant("quantum.compile"), None, + ); + assert!(r.verify()); + assert_eq!(hx(&r.receipt_hash()), "79ba47b4b329b1d0e53967a8c84e95756266e3c47f5366bcdc99836307adcc0f"); + assert_eq!(json_hash(&r.to_json()), "eee5a71d7cdd318841f771fe5d5b435ee0252e70a71cc2bd54f50225d729c0a0"); + } + + #[test] + fn rearrange_legacy_bytes() { + let r = RearrangeReceipt::seal( + &key(1), "did:key:pin", "hungarian-lsap", + content_hash(b"i"), content_hash(b"t"), content_hash(b"p"), + true, 41, 318, 450_000, grant("quantum.rearrange"), None, + ); + assert!(r.verify()); + assert_eq!(hx(&r.receipt_hash()), "a77758a5de40f9684ea0b9618d42417cc318f88ab812020cbc09618095c51efc"); + assert_eq!(json_hash(&r.to_json()), "2f980e1ec6c3cb1eaebfb3a457ad0184b3a040637a398f02bf2a31850f1741fe"); + } +} + +#[cfg(feature = "quantum_phasor_meter")] +#[test] +fn phasor_legacy_bytes() { + use crate::quantum_phasor_meter::{computation_id, result_fingerprint, PhasorReceipt, PhasorWork}; + let work = PhasorWork { n_features: 200, input_dim: 2, n_samples: 300 }; + let r = PhasorReceipt::seal( + &key(1), "did:key:pin", "wai.quantum.kernel", + computation_id("wai.quantum.kernel", &[0.5, 1.25]), result_fingerprint(&[0.1, 0.2, 0.3]), + work, 117_000, None, + ); + assert!(r.verify()); + assert_eq!(hx(&r.receipt_hash()), "b18e364f7793943873c9070c6755709014d2cf6014bfc9610283b20eca9478e8"); + assert_eq!(json_hash(&r.to_json()), "0d74576475bb5d0a9629eac5954ae81d06947b24bb62527fb9789cbcdb9e1cb1"); +} + +#[cfg(feature = "quantum_qbom")] +#[test] +fn qbom_legacy_bytes() { + use crate::quantum_qbom::QbomBuilder; + let q = QbomBuilder::new("job-pin", [0x51; 32], "sim:device") + .stage("compile", [0x52; 32], 120_000) + .stage("execute", [0x53; 32], 90_000) + .seal(&key(1), "did:key:pin"); + assert!(q.verify()); + assert_eq!(hx(&q.receipt_hash()), "fe651bcfa059621f4c237fd7163ec4ceb76a41a66dd55980937c6f7715276901"); + assert_eq!(json_hash(&q.to_json()), "9f16b7b0e72a8d6a0a712de3808d819bc6cdf1712ecbddc10125def0b0168986"); +} + +// --------------------------------------------------------------------------- +// The optional label (`crate::quantum_energy`), exercised identically on every +// receipt type. +// --------------------------------------------------------------------------- + +#[allow(dead_code)] +fn chip(ppm: u32) -> crate::quantum_energy::EnergyClass { + use crate::quantum_energy::{CounterFiltering, EnergyClass, Uncertainty}; + EnergyClass::OnChipCounter { uncertainty: Uncertainty { relative_ppm: ppm, window_us: 2_977_104 }, filtering: CounterFiltering::Unknown } +} + +#[allow(dead_code)] +fn check_label_ok(l: &crate::quantum_energy::Labelled) -> bool { + crate::quantum_energy::check_label(l.receipt.figure(), &l.energy_class).is_ok() +} + +/// Everything the label must do, on one pinned unlabelled receipt sealed by +/// `signer`. `figure` gives mutable access to its `joules_micro`. +#[allow(dead_code)] +fn exercise(legacy: R, signer: &SigningKey, figure: F, verify: fn(&R) -> bool) +where + R: crate::quantum_energy::Labellable, + F: Fn(&mut R) -> &mut u64, +{ + use crate::quantum_energy::{ClassError, CounterFiltering, EnergyClass, Labelled, MaybeLabelled, Uncertainty}; + assert!(verify(&legacy)); + let legacy_json = legacy.unlabelled_json(); + + let l = Labelled::seal(legacy.clone(), chip(15_403), signer).expect("a valid label"); + assert!(l.verify(), "a labelled receipt verifies"); + assert!(!verify(&l.receipt), "the unlabelled verify() refuses a labelled receipt: it fails closed"); + + // Inside the signature: not strippable, restatable or promotable. + let mut restated = l.clone(); + restated.energy_class = chip(15_404); + assert!(check_label_ok(&restated), "the control: the restated label is itself valid"); + assert!(!restated.verify(), "so only the signature refuses it"); + let mut promoted = l.clone(); + promoted.energy_class = EnergyClass::CalibratedInstrument { + uncertainty: Uncertainty { relative_ppm: 15_403, window_us: 2_977_104 }, + calibration_ref: "cert".into(), + }; + assert!(!promoted.verify()); + let mut demoted = l.clone(); + demoted.energy_class = EnergyClass::ModelBased; + assert!(!demoted.verify()); + + // Refusals. + assert_eq!(Labelled::seal(legacy.clone(), chip(15_403), &key(8)).unwrap_err(), ClassError::SignerMismatch); + assert_eq!(Labelled::seal(legacy.clone(), chip(0), signer).unwrap_err(), ClassError::NoUncertainty); + let mut zero = legacy.clone(); + *figure(&mut zero) = 0; + assert_eq!(Labelled::seal(zero, chip(15_403), signer).unwrap_err(), ClassError::NoFigure); + // verify() refuses a correctly signed label beside a zero figure. + let mut zero = l.clone(); + *figure(&mut zero.receipt) = 0; + let resigned = Labelled::seal(zero.receipt.clone(), EnergyClass::Estimator, signer); + assert_eq!(resigned.unwrap_err(), ClassError::NoFigure); + let undeclared = EnergyClass::OnChipCounter { + uncertainty: Uncertainty { relative_ppm: 0, window_us: 1 }, + filtering: CounterFiltering::Unknown, + }; + let mut bad = l.clone(); + bad.energy_class = undeclared.clone(); + assert!(!bad.verify()); + + // And verify() refuses a label that breaks the rules even when the + // signature over it is valid: the rule is checked, not just the bytes. + let signed_over = |mut r: R, class: &EnergyClass| { + use ed25519_dalek::Signer; + let sig = signer.sign(&r.labelled_payload(Some(class))).to_bytes(); + r.set_signature(sig); + Labelled { receipt: r, energy_class: class.clone() } + }; + assert!(signed_over(legacy.clone(), &chip(15_403)).verify(), "the control: a valid label, signed, verifies"); + assert!(!signed_over(legacy.clone(), &undeclared).verify(), "a refined class without its uncertainty"); + let mut zero = legacy.clone(); + *figure(&mut zero) = 0; + assert!(!signed_over(zero, &EnergyClass::Estimator).verify(), "a class beside a zero figure"); + + // A declaration below the 0.5/√3 µJ the figure's rounding adds is refused, + // sealed or signed over: `joules_micro × relative_ppm` must reach 288 676. + let fig = { + let mut probe = legacy.clone(); + *figure(&mut probe) + }; + let least = crate::quantum_energy::RESOLUTION_FLOOR_UJ_PPM.div_ceil(fig); + assert!(signed_over(legacy.clone(), &chip(least as u32)).verify(), "the control: exactly at the floor"); + if least > 1 { + let below = chip((least - 1) as u32); + assert_eq!(Labelled::seal(legacy.clone(), below.clone(), signer).unwrap_err(), ClassError::BelowResolution); + assert!(!signed_over(legacy.clone(), &below).verify(), "a declaration below the figure's resolution"); + } + + // JSON: the unlabelled form is untouched; the labelled form carries the + // class and the labelled identity, and round-trips. + assert!(!legacy_json.contains("energy_provenance")); + let j = l.to_json(); + let v: serde_json::Value = serde_json::from_str(&j).unwrap(); + assert_eq!(v["energy_provenance"], "OnChipCounter"); + assert_eq!(v["receipt_hash"], serde_json::Value::from(hx(&l.receipt_hash()))); + let back = Labelled::::from_json(&j).expect("parse"); + assert_eq!(back, l); + assert!(back.verify()); + assert!(Labelled::::from_json(&legacy_json).is_none(), "no class, no labelled parse"); + + // Either form through the one entry point a verifier uses: the form comes + // from the JSON, and each verifies by its own rules. + let any = MaybeLabelled::::from_json(&legacy_json).expect("the unlabelled form parses"); + assert!(matches!(any, MaybeLabelled::Unlabelled(_)) && any.verify() && any.energy_class().is_none()); + assert_eq!(any.receipt_hash(), legacy.unlabelled_receipt_hash()); + assert_eq!(any.to_json(), legacy_json); + let any = MaybeLabelled::::from_json(&j).expect("the labelled form parses"); + assert!(matches!(any, MaybeLabelled::Labelled(_)) && any.verify()); + assert_eq!(any.energy_class(), Some(&chip(15_403))); + assert_eq!(any.receipt_hash(), l.receipt_hash()); + assert_eq!(any.to_json(), j); + // A label whose evidence is malformed, or evidence whose class was + // stripped, is refused — never read as an unlabelled receipt. + let mut broken = v.clone(); + broken["energy_uncertainty"] = serde_json::Value::Null; + assert!(MaybeLabelled::::from_json(&broken.to_string()).is_none()); + let mut stripped = v.clone(); + stripped.as_object_mut().unwrap().remove("energy_provenance"); + assert!(MaybeLabelled::::from_json(&stripped.to_string()).is_none()); + // A restated label parses and is refused by verify(). + let mut restated_json = v.clone(); + restated_json["energy_uncertainty"]["relative_ppm"] = serde_json::Value::from(15_404); + let any = MaybeLabelled::::from_json(&restated_json.to_string()).expect("a well-formed label parses"); + assert!(!any.verify()); + assert_ne!(l.receipt_hash(), { + let mut h = blake3::Hasher::new(); + h.update(legacy_json.as_bytes()); + *h.finalize().as_bytes() + }); +} + +#[cfg(feature = "quantum_receipt")] +#[test] +fn quantum_circuit_label() { + use crate::quantum::{Circuit, Measure}; + use crate::quantum_energy::Labelled; + use crate::quantum_receipt::QuantumReceipt; + let mut c = Circuit::new(3); + c.h(0).cx(0, 1).cx(1, 2); + let r = QuantumReceipt::seal(&key(1), "did:key:pin", &c, Some(Measure { seed: 7, shots: 1024 }), 117_000, Some([0x21; 32])).unwrap(); + exercise(r.clone(), &key(1), |r| &mut r.joules_micro, QuantumReceipt::verify); + let l = Labelled::seal(r, chip(15_403), &key(1)).unwrap(); + assert!(l.verify_reconstruction(&c)); + let mut other = Circuit::new(3); + other.h(0); + assert!(!l.verify_reconstruction(&other)); +} + +#[cfg(feature = "quantum_ops")] +mod ops_labels { + use super::*; + use crate::quantum_ops::*; + + fn grant(cap: &str) -> GrantRef { + GrantRef { grant_hash: content_hash(b"pin-grant"), capability: cap.into(), joule_ceiling_micro: 5_000_000, funds_ceiling: Some(100) } + } + + #[test] + fn calibration_label() { + let r = CalibrationReceipt::seal( + &key(1), "did:key:pin", "qm:opx1000:lab3", "q3", content_hash(b"cfg"), + vec![Evidence { kind: "rb".into(), blob_hash: content_hash(b""), summary: "s".into() }], + 4_200_000, grant("quantum.calibrate"), None, + ); + exercise(r, &key(1), |r| &mut r.joules_micro, CalibrationReceipt::verify); + } + + #[test] + fn job_label() { + let r = JobReceipt::seal( + &key(1), "did:key:pin", "wai.quantum.circuit", content_hash(b"c"), content_hash(b"r"), + Some([0x31; 32]), 1000, 250, grant("quantum.job.run"), Some([0x32; 32]), + ); + exercise(r, &key(1), |r| &mut r.joules_micro, JobReceipt::verify); + } + + #[test] + fn mitigation_label() { + let r = MitigationReceipt::seal( + &key(1), "did:key:pin", "wai.quantum.circuit", "zne.richardson", "ZZ", + content_hash(b"raw"), Some(content_hash(b"model")), 744_178, 912_680, 20_971, 48_000, + 900_000, grant("quantum.mitigate"), None, + ); + exercise(r, &key(1), |r| &mut r.joules_micro, MitigationReceipt::verify); + } + + #[test] + fn decode_label() { + let r = DecodeReceipt::seal( + &key(1), "did:key:pin", "toric:L7", "relay-bp.min-sum", + content_hash(b"H"), content_hash(b"s"), content_hash(b"c"), + true, 3, 46_656, 900_000, grant("quantum.decode"), None, + ); + exercise(r, &key(1), |r| &mut r.joules_micro, DecodeReceipt::verify); + } + + #[test] + fn compile_label() { + let r = CompileReceipt::seal( + &key(1), "did:key:pin", "clifford-tableau", + content_hash(b"s"), content_hash(b"t"), content_hash(b"cpl"), + true, 3, 4096, 300_000, grant("quantum.compile"), None, + ); + exercise(r, &key(1), |r| &mut r.joules_micro, CompileReceipt::verify); + } + + #[test] + fn rearrange_label() { + let r = RearrangeReceipt::seal( + &key(1), "did:key:pin", "hungarian-lsap", + content_hash(b"i"), content_hash(b"t"), content_hash(b"p"), + true, 41, 318, 450_000, grant("quantum.rearrange"), None, + ); + exercise(r, &key(1), |r| &mut r.joules_micro, RearrangeReceipt::verify); + } + + #[test] + fn a_job_cross_links_to_a_calibration_in_either_form() { + use crate::quantum_energy::{Labelled, MaybeLabelled}; + let cal = CalibrationReceipt::seal( + &key(1), "did:key:pin", "qm:opx1000:lab3", "q3", content_hash(b"cfg"), + vec![Evidence { kind: "rb".into(), blob_hash: content_hash(b""), summary: "s".into() }], + 4_200_000, grant("quantum.calibrate"), None, + ); + let cal_l = Labelled::seal(cal.clone(), chip(15_403), &key(1)).unwrap(); + let job_under = |cal_id: [u8; 32]| { + JobReceipt::seal( + &key(1), "did:key:pin", "wai.quantum.circuit", content_hash(b"c"), content_hash(b"r"), + Some(cal_id), 1000, 250, grant("quantum.job.run"), None, + ) + }; + + // Both unlabelled: the legacy check and the either-form check agree. + let j0 = job_under(cal.receipt_hash()); + assert!(j0.verify_against_calibration(&cal)); + assert!(MaybeLabelled::Unlabelled(j0.clone()).verify_against_calibration(&MaybeLabelled::Unlabelled(cal.clone()))); + + // A job run under a labelled calibration names its labelled identity. + // The unlabelled check cannot see that calibration at all; the + // either-form check binds it, and only it. + let j1 = job_under(cal_l.receipt_hash()); + assert!(!j1.verify_against_calibration(&cal_l.receipt)); + assert!(MaybeLabelled::Unlabelled(j1.clone()).verify_against_calibration(&cal_l.clone().into())); + assert!(!MaybeLabelled::Unlabelled(j1.clone()).verify_against_calibration(&MaybeLabelled::Unlabelled(cal.clone())), "not the unlabelled calibration"); + + // Labelled jobs, under either form of calibration. + let j2 = Labelled::seal(j1.clone(), chip(20_000), &key(1)).unwrap(); + assert!(MaybeLabelled::from(j2.clone()).verify_against_calibration(&cal_l.clone().into())); + let j3 = Labelled::seal(j0.clone(), chip(20_000), &key(1)).unwrap(); + assert!(MaybeLabelled::from(j3).verify_against_calibration(&MaybeLabelled::Unlabelled(cal.clone()))); + + // A calibration whose label was restated no longer verifies, so the + // link fails. + let mut restated = cal_l.clone(); + restated.energy_class = chip(15_404); + assert!(!MaybeLabelled::Unlabelled(j1).verify_against_calibration(&restated.into())); + + // From JSON, as a verifier receives them. + let cal_any = MaybeLabelled::::from_json(&cal_l.to_json()).unwrap(); + let job_any = MaybeLabelled::::from_json(&j2.to_json()).unwrap(); + assert!(job_any.verify_against_calibration(&cal_any)); + } + + #[test] + fn a_labelled_receipt_still_honours_its_grant() { + // The label does not bypass the receipt's own checks: an over-budget + // run stays invalid when labelled. + use crate::quantum_energy::Labelled; + let tight = GrantRef { grant_hash: [0u8; 32], capability: "quantum.decode".into(), joule_ceiling_micro: 10, funds_ceiling: None }; + let r = DecodeReceipt::seal( + &key(1), "m", "toric:L5", "relay-bp.min-sum", + content_hash(b"H"), content_hash(b"s"), content_hash(b"c"), + true, 1, 100, 900_000, tight, None, + ); + let l = Labelled::seal(r, chip(15_403), &key(1)).unwrap(); + assert!(!l.verify(), "a run that blew its grant must not verify, labelled or not"); + } +} + +#[cfg(feature = "quantum_phasor_meter")] +#[test] +fn phasor_label() { + use crate::quantum_phasor_meter::{computation_id, result_fingerprint, PhasorReceipt, PhasorWork}; + let work = PhasorWork { n_features: 200, input_dim: 2, n_samples: 300 }; + let r = PhasorReceipt::seal( + &key(1), "did:key:pin", "wai.quantum.kernel", + computation_id("wai.quantum.kernel", &[0.5, 1.25]), result_fingerprint(&[0.1, 0.2, 0.3]), + work, 117_000, None, + ); + exercise(r, &key(1), |r| &mut r.joules_micro, PhasorReceipt::verify); +} + +#[cfg(feature = "quantum_qbom")] +#[test] +fn qbom_entry_labels() { + use crate::quantum_energy::{ClassError, EnergyClass, LabelledQbom}; + use crate::quantum_qbom::{Qbom, QbomBuilder}; + let q = QbomBuilder::new("job-pin", [0x51; 32], "sim:device") + .stage("compile", [0x52; 32], 120_000) + .stage("execute", [0x53; 32], 90_000) + .seal(&key(1), "did:key:pin"); + let classes = vec![Some(chip(15_403)), None]; + let l = LabelledQbom::seal(q.clone(), classes.clone(), &key(1)).unwrap(); + assert!(l.verify()); + assert!(!l.qbom.verify(), "the unlabelled verify() refuses a labelled bill"); + assert_ne!(l.provenance_root(), q.provenance_root()); + assert_ne!(l.receipt_hash(), q.receipt_hash()); + + // Stripping, moving or restating an entry label breaks the bill. + let mut moved = l.clone(); + moved.classes = vec![None, Some(chip(15_403))]; + assert!(!moved.verify()); + let mut restated = l.clone(); + restated.classes[0] = Some(chip(15_404)); + assert!(!restated.verify()); + // An entry declaring less than its figure's resolution is refused: + // 120,000 µJ needs at least 3 ppm. + assert_eq!( + LabelledQbom::seal(q.clone(), vec![Some(chip(2)), None], &key(1)).unwrap_err(), + ClassError::BelowResolution + ); + assert!(LabelledQbom::seal(q.clone(), vec![Some(chip(3)), None], &key(1)).unwrap().verify()); + + // Refusals: slot count, nothing labelled, zero figure, wrong signer. + assert_eq!(LabelledQbom::seal(q.clone(), vec![Some(chip(1))], &key(1)).unwrap_err(), ClassError::EntryMismatch); + assert_eq!(LabelledQbom::seal(q.clone(), vec![None, None], &key(1)).unwrap_err(), ClassError::EntryMismatch); + assert_eq!(LabelledQbom::seal(q.clone(), classes.clone(), &key(2)).unwrap_err(), ClassError::SignerMismatch); + let zero = QbomBuilder::new("j", [0; 32], "d").stage("compile", [1; 32], 0).seal(&key(1), "id"); + assert_eq!(LabelledQbom::seal(zero, vec![Some(EnergyClass::ModelBased)], &key(1)).unwrap_err(), ClassError::NoFigure); + + // verify() checks the rules, not just the signature: a bill signed over + // classes that break them is refused. + let signed_over = |q: &Qbom, classes: Vec>| { + use ed25519_dalek::Signer; + let mut q = q.clone(); + q.sig = key(1).sign(&q.labelled_signing_payload(&classes)).to_bytes(); + LabelledQbom { qbom: q, classes } + }; + assert!(signed_over(&q, classes.clone()).verify(), "the control verifies"); + assert!(!signed_over(&q, vec![None, None]).verify(), "a labelled bill that labels nothing"); + let zero = QbomBuilder::new("j", [0; 32], "d").stage("compile", [1; 32], 0).seal(&key(1), "id"); + assert!(!signed_over(&zero, vec![Some(EnergyClass::ModelBased)]).verify(), "a class beside a zero figure"); + + // Binding checks each entry's class against its stage receipt's class. + assert!(l.binds(&[([0x52; 32], true, Some(&chip(15_403))), ([0x53; 32], true, None)])); + assert!(!l.binds(&[([0x52; 32], true, None), ([0x53; 32], true, None)]), "a stage whose class differs breaks the bill"); + + // JSON round trip; the unlabelled bill parses as no labelled bill. + let j = l.to_json(); + let v: serde_json::Value = serde_json::from_str(&j).unwrap(); + assert_eq!(v["entries"][0]["energy_provenance"], "OnChipCounter"); + assert!(v["entries"][1].get("energy_provenance").is_none()); + assert_eq!(v["receipt_hash"], serde_json::Value::from(hx(&l.receipt_hash()))); + let back = LabelledQbom::from_json(&j).expect("parse"); + assert_eq!(back, l); + assert!(back.verify()); + assert!(LabelledQbom::from_json(&q.to_json()).is_none()); + let _: Option = Qbom::from_json(&q.to_json()); +} diff --git a/wai/wai-rs/include/wai.h b/wai/wai-rs/include/wai.h index 9bac50ab1..d7ec15749 100644 --- a/wai/wai-rs/include/wai.h +++ b/wai/wai-rs/include/wai.h @@ -245,6 +245,42 @@ #define PULSE25 3 +/** + * The standard uncertainty, in microjoules, that sealing an estimate as a + * whole number of microjoules adds (`extensions/energy-measurement.md` §2.3, + * `u_res`). `joules_micro` is an integer, so a figure rounded to the nearest + * microjoule lies within ±0.5 µJ of the estimate it came from. With only that + * limit known the error is taken as rectangular (GUM F.2.2.1): `0.5/√3` µJ. + * + * It is the same in microjoules whatever the figure, so relative to the + * figure it is large when the figure is small: 29 % of 1 µJ, 2.9 % of 10 µJ. + */ +#define RESOLUTION_UNCERTAINTY_UJ 0.2886751345948129 + +/** + * The standard uncertainty, in microjoules, of the difference of two readings + * of a register that counts whole microjoules. Each reading stands for an + * energy up to one step away from it; two such offsets, each rectangular over + * one step, differ by a triangular error over (−1, +1) µJ (GUM 4.3.9): + * `1/√6` µJ. This is the resolution of a figure read as a counter delta. + */ +#define REGISTER_DELTA_UNCERTAINTY_UJ 0.4082482904638631 + +/** + * The least `joules_micro × relative_ppm` a declaration may have (§2.3). A + * declared relative uncertainty `ppm` on a figure of `F` µJ is an absolute + * standard uncertainty of `F · ppm / 10⁶` µJ, which must be at least the + * least resolution a whole-microjoule figure carries, + * [`RESOLUTION_UNCERTAINTY_UJ`]. That is `F · ppm ≥ 288 675.13…`, and since + * `F · ppm` is an integer, `≥ 288 676`. A figure read as a register delta + * carries more, [`REGISTER_DELTA_UNCERTAINTY_UJ`], which this floor does not + * check. + * + * It is checkable from the receipt alone, in integers, so every verifier + * reaches the same verdict. + */ +#define RESOLUTION_FLOOR_UJ_PPM 288676 + /** * `CausalOp::mutation_kind` for an action input (move/look/buttons + free payload). */ diff --git a/wai/wai-rs/src/audio_scene/receipt.rs b/wai/wai-rs/src/audio_scene/receipt.rs index be43a2fc0..862b5b01a 100644 --- a/wai/wai-rs/src/audio_scene/receipt.rs +++ b/wai/wai-rs/src/audio_scene/receipt.rs @@ -10,6 +10,10 @@ use super::{AudioError, SpatialScene, Sources}; use crate::world::receipt::merkle_root; +use crate::energy_class::{ + check_label, label_coheres, labelled_domain, write_label, AcquisitionError, + EnergyProvenance, +}; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; const DOMAIN_RECEIPT: &[u8] = b"wai:audio-receipt\x01"; @@ -32,6 +36,11 @@ pub struct SceneReceipt { pub work_mac: u64, /// Carried microjoules (native meter supplies; 0 = unmetered). pub joules_micro: u64, + /// How `joules_micro` was acquired — an optional label + /// (`extensions/energy-measurement.md` §2.8). `None` is an unlabelled + /// figure, as every receipt sealed before the label existed carries; it is + /// never read as any class. Inside the signature when present. + pub energy_provenance: Option, pub parent_receipt_hash: Option<[u8; 32]>, pub signer_pubkey: [u8; 32], pub signer_id: String, @@ -66,13 +75,15 @@ impl SpatialScene { impl SceneReceipt { fn signing_payload(&self) -> Vec { - let mut o = DOMAIN_RECEIPT.to_vec(); + let label = self.energy_provenance.as_ref(); + let mut o = labelled_domain(DOMAIN_RECEIPT, label); o.extend_from_slice(&self.scene_hash); o.extend_from_slice(&self.group_id.to_be_bytes()); o.extend_from_slice(&self.merkle_root); o.extend_from_slice(&(self.blocks.len() as u64).to_be_bytes()); o.extend_from_slice(&self.work_mac.to_be_bytes()); o.extend_from_slice(&self.joules_micro.to_be_bytes()); + write_label(label, &mut o); match self.parent_receipt_hash { Some(h) => { o.push(1); @@ -110,6 +121,7 @@ impl SceneReceipt { merkle_root: merkle_root(&leaves), work_mac, joules_micro, + energy_provenance: None, parent_receipt_hash, signer_pubkey: signer.verifying_key().to_bytes(), signer_id: signer_id.into(), @@ -122,6 +134,9 @@ impl SceneReceipt { /// Verify the Merkle root recomputes from the block hashes and the /// signature is valid. pub fn verify(&self) -> bool { + if !label_coheres(self.joules_micro, self.energy_provenance.as_ref()) { + return false; + } let leaves: Vec<[u8; 32]> = self.blocks.iter().map(|b| b.hash).collect(); if merkle_root(&leaves) != self.merkle_root { return false; @@ -130,6 +145,33 @@ impl SceneReceipt { k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)).is_ok() } + /// Label the figure with its acquisition class and re-sign + /// (`extensions/energy-measurement.md` §2.8). `signer` must be the key + /// that sealed the receipt. Refuses whatever [`Self::verify`] would + /// refuse: legacy `HwShunt`, a class beside a zero figure, a refined class + /// without its evidence. + pub fn with_energy_class( + mut self, + signer: &SigningKey, + class: EnergyProvenance, + ) -> Result { + if signer.verifying_key().to_bytes() != self.signer_pubkey { + return Err(AcquisitionError::SignerMismatch); + } + check_label(self.joules_micro, Some(&class))?; + self.energy_provenance = Some(class); + self.sig = signer.sign(&self.signing_payload()).to_bytes(); + Ok(self) + } + + /// Re-sign whatever the fields now say, bypassing every check — so tests + /// can present `verify()` with a correctly signed receipt that breaks a + /// label rule, and see the rule itself refuse it. + #[cfg(test)] + pub(crate) fn resign_for_test(&mut self, signer: &SigningKey) { + self.sig = signer.sign(&self.signing_payload()).to_bytes(); + } + pub fn receipt_hash(&self) -> [u8; 32] { let mut h = blake3::Hasher::new(); h.update(b"wai:audio-receipt-id\x01"); diff --git a/wai/wai-rs/src/bin/wai_meter.rs b/wai/wai-rs/src/bin/wai_meter.rs index ab2775578..f4955166c 100644 --- a/wai/wai-rs/src/bin/wai_meter.rs +++ b/wai/wai-rs/src/bin/wai_meter.rs @@ -13,14 +13,16 @@ //! *frame sequence* (sample the world at each frame tick + the camera pose), //! so it is metered here with the other reconstruct classes. //! It is a measurement with stated bounds (SoC-wide power → the marginal -//! delta over an idle machine ÷ iterations); it never fabricates a -//! number — run it on a quiet box for a clean one. +//! delta over an idle machine ÷ the reconstructions the window paid for; see +//! `wai::energy_burst`); it never fabricates a number — run it on a quiet box +//! for a clean one. A usable figure is sealed with its acquisition class, +//! `OnChipCounter` with a declared uncertainty (IOReport is the SoC's own +//! counter); without `macmon`, or when the §4 ratio or the §2.3 uncertainty +//! cannot support a figure, the receipt is sealed UNMETERED (`0`, no class). use ed25519_dalek::SigningKey; -use std::process::Command; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::Arc; -use std::time::Instant; +use wai::energy_burst::measure_with_macmon; +use wai::energy_class::{CounterFiltering, EnergyProvenance}; use wai::film::{Film, Reel}; use wai::haptic::{HapticReceipt, HapticSignal}; use wai::score::{Score, ScoreReceipt}; @@ -42,80 +44,85 @@ fn main() { let sig = HapticSignal::from_bytes(&bytes).expect("parse whap"); let work = sig.contract.duration * sig.contract.channels as u64; eprintln!("metering {path} (haptic: {} samples × {} ch)", sig.contract.duration, sig.contract.channels); - let per_uj = measure(path, &sig, |s| { + let (uj, class) = meter("reconstruct", &sig, work, "samples×ch", |s| { for c in 0..s.channels.len() { std::hint::black_box(s.reconstruct_channel(c)); } }); - let joules_micro = per_uj.round() as u64; - report("reconstruct", per_uj, joules_micro, work, "samples×ch"); if let Some(out) = args.get(2) { - let r = HapticReceipt::build(&signer, SIGNER_ID, sig.signal_hash(), 0, sig.channel_hashes(), work, joules_micro, None); + let r = HapticReceipt::build(&signer, SIGNER_ID, sig.signal_hash(), 0, sig.channel_hashes(), work, uj, None); + let r = match class { + Some(c) => r.with_energy_class(&signer, c).expect("a usable figure is a valid label"), + None => r, + }; assert!(r.verify(), "emitted receipt must verify"); - std::fs::write(out, r.to_json()).expect("write receipt"); - println!("receipt : {out} (verified, joules_micro MEASURED, work {work})"); + emit(out, &r.to_json(), r.energy_provenance.is_some(), work); } } else if path.ends_with(".w4ds") { let clip = Splat4dClipData::from_bytes(&bytes).expect("parse w4ds"); let work = clip.contract.n_frames * clip.contract.n_gaussians as u64; eprintln!("metering {path} (4D splat: {} frames × {} Gaussians)", clip.contract.n_frames, clip.contract.n_gaussians); - let per_uj = measure(path, &clip, |c| { + let (uj, class) = meter("reconstruct", &clip, work, "frames×gaussians", |c| { for f in 0..c.contract.n_frames { std::hint::black_box(c.frame_hash(f)); } }); - let joules_micro = per_uj.round() as u64; - report("reconstruct", per_uj, joules_micro, work, "frames×gaussians"); if let Some(out) = args.get(2) { - let r = Splat4dReceipt::build(&signer, SIGNER_ID, clip.clip_hash(), 0, clip.frame_hashes(), work, joules_micro, None); + let r = Splat4dReceipt::build(&signer, SIGNER_ID, clip.clip_hash(), 0, clip.frame_hashes(), work, uj, None); + let r = match class { + Some(c) => r.with_energy_class(&signer, c).expect("a usable figure is a valid label"), + None => r, + }; assert!(r.verify(), "emitted receipt must verify"); - std::fs::write(out, r.to_json()).expect("write receipt"); - println!("receipt : {out} (verified, joules_micro MEASURED, work {work})"); + emit(out, &r.to_json(), r.energy_provenance.is_some(), work); } } else if path.ends_with(".wscr") { let score = Score::from_bytes(&bytes).expect("parse wscr"); let work = score.contract.duration * score.notes.len() as u64; eprintln!("metering {path} (score: {} notes → {} samples)", score.notes.len(), score.contract.duration); - let per_uj = measure(path, &score, |s| { + let (uj, class) = meter("reconstruct", &score, work, "samples×notes", |s| { std::hint::black_box(s.mixdown()); }); - let joules_micro = per_uj.round() as u64; - report("reconstruct", per_uj, joules_micro, work, "samples×notes"); if let Some(out) = args.get(2) { - let r = ScoreReceipt::build(&signer, SIGNER_ID, score.score_hash(), 0, score.segment_hashes(), work, joules_micro, None); + let r = ScoreReceipt::build(&signer, SIGNER_ID, score.score_hash(), 0, score.segment_hashes(), work, uj, None); + let r = match class { + Some(c) => r.with_energy_class(&signer, c).expect("a usable figure is a valid label"), + None => r, + }; assert!(r.verify(), "emitted receipt must verify"); - std::fs::write(out, r.to_json()).expect("write receipt"); - println!("receipt : {out} (verified, joules_micro MEASURED, work {work})"); + emit(out, &r.to_json(), r.energy_provenance.is_some(), work); } } else if path.ends_with(".wflm") { let film = Film::from_bytes(&bytes).expect("parse wflm"); let work = film.contract.frames; eprintln!("metering {path} (film: {} frames × ticks_per_frame {})", film.contract.frames, film.contract.ticks_per_frame); - let per_uj = measure(path, &film, |f| { + let (uj, class) = meter("reconstruct", &film, work, "frames", |f| { std::hint::black_box(f.frame_hashes().expect("frame hashes")); }); - let joules_micro = per_uj.round() as u64; - report("reconstruct", per_uj, joules_micro, work, "frames"); if let Some(out) = args.get(2) { - let r = film.receipt(&signer, SIGNER_ID, 0, joules_micro, None).expect("receipt"); + let r = film.receipt(&signer, SIGNER_ID, 0, uj, None).expect("receipt"); + let r = match class { + Some(c) => r.with_energy_class(&signer, c).expect("a usable figure is a valid label"), + None => r, + }; assert!(r.verify(), "emitted receipt must verify"); - std::fs::write(out, r.to_json()).expect("write receipt"); - println!("receipt : {out} (verified, joules_micro MEASURED, work {work})"); + emit(out, &r.to_json(), r.energy_provenance.is_some(), work); } } else if path.ends_with(".wrel") { let reel = Reel::from_bytes(&bytes).expect("parse wrel"); let work = reel.total_frames(); eprintln!("metering {path} (reel: {} shots → {} frames)", reel.shots.len(), work); - let per_uj = measure(path, &reel, |r| { + let (uj, class) = meter("reconstruct", &reel, work, "frames", |r| { std::hint::black_box(r.frame_hashes().expect("frame hashes")); }); - let joules_micro = per_uj.round() as u64; - report("reconstruct", per_uj, joules_micro, work, "frames"); if let Some(out) = args.get(2) { - let r = reel.receipt(&signer, SIGNER_ID, 0, joules_micro, None).expect("receipt"); + let r = reel.receipt(&signer, SIGNER_ID, 0, uj, None).expect("receipt"); + let r = match class { + Some(c) => r.with_energy_class(&signer, c).expect("a usable figure is a valid label"), + None => r, + }; assert!(r.verify(), "emitted receipt must verify"); - std::fs::write(out, r.to_json()).expect("write receipt"); - println!("receipt : {out} (verified, joules_micro MEASURED, work {work})"); + emit(out, &r.to_json(), r.energy_provenance.is_some(), work); } } else { eprintln!("unrecognized file (want .whap, .w4ds, .wscr, .wflm, or .wrel); world classes → wai_world_meter"); @@ -123,75 +130,46 @@ fn main() { } } -/// Bracket an idle baseline against a multi-thread reconstruct burst and -/// return the marginal microjoules per single reconstruction. `item` -/// must be `Clone + Send + Sync`; `work` performs one reconstruction. -fn measure(label: &str, item: &T, work: F) -> f64 +/// Run the §3 protocol on `item` (`energy_burst`), print the report §6 +/// requires, and return what the receipt seals: `(joules_micro, Some(class))` +/// for a usable on-chip figure, `(0, None)` — unmetered — otherwise. +fn meter(verb: &str, item: &T, work: u64, unit: &str, one: W) -> (u64, Option) where - T: Clone + Send + Sync + 'static, - F: Fn(&T) + Send + Sync + Copy + 'static, + T: Clone + Send + 'static, + W: Fn(&T) + Send + Sync + 'static, { - eprintln!(" idle baseline …"); - let idle = sample_cpu_power(10); - if idle.len() < 3 { - eprintln!("macmon unavailable; install `brew install macmon` (the non-sudo IOReport reader)."); - std::process::exit(1); - } - let workers = std::thread::available_parallelism() - .map(|n| n.get().saturating_sub(1).clamp(1, 10)) - .unwrap_or(6); - eprintln!(" {label} burst on {workers} threads …"); - let stop = Arc::new(AtomicBool::new(false)); - let iters = Arc::new(AtomicU64::new(0)); - let handles: Vec<_> = (0..workers) - .map(|_| { - let (stop, iters, it) = (stop.clone(), iters.clone(), item.clone()); - std::thread::spawn(move || { - // Increment per reconstruction: a cheap signal runs millions - // of times, a heavy clip a few thousand — either way `runs` - // tracks reality (a coarse batch would never complete once - // for a heavy clip and collapse the count to 1). - while !stop.load(Ordering::Relaxed) { - work(&it); - iters.fetch_add(1, Ordering::Relaxed); - } - }) - }) - .collect(); - let i0 = iters.load(Ordering::Relaxed); - let t0 = Instant::now(); - let busy = sample_cpu_power(14); - let window = t0.elapsed().as_secs_f64(); - let i1 = iters.load(Ordering::Relaxed); - stop.store(true, Ordering::Relaxed); - for h in handles { - h.join().ok(); - } - let runs = (i1 - i0).max(1); - let mean = |v: &[f64]| v.iter().sum::() / v.len() as f64; - let (idle_w, busy_w) = (mean(&idle), mean(&busy)); - let marginal_w = (busy_w - idle_w).max(0.0); - eprintln!(" idle {idle_w:.2} W · busy {busy_w:.2} W · marginal {marginal_w:.2} W · {runs} runs / {window:.2}s"); - marginal_w * window / runs as f64 * 1e6 -} - -fn report(verb: &str, per_uj: f64, joules_micro: u64, work: u64, unit: &str) { - println!("== wai_meter — measured CPU energy (IOReport via macmon, no sudo) =="); - println!("→ per {verb} : {per_uj:.2} µJ (work {work} {unit})"); - println!("joules_micro (receipt, MEASURED): {joules_micro}"); -} - -fn sample_cpu_power(n: u32) -> Vec { - let Ok(out) = Command::new("macmon").args(["pipe", "-s", &n.to_string(), "-i", "200"]).output() else { - return vec![]; + let Some(b) = measure_with_macmon(verb, item, one) else { + eprintln!( + "macmon unavailable or too few samples; sealing UNMETERED (joules_micro = 0). \ + Install `brew install macmon` (the non-sudo IOReport reader) for a measured figure." + ); + return (0, None); }; - String::from_utf8_lossy(&out.stdout).lines().filter_map(cpu_power_w).collect() + println!("== wai_meter — measured CPU energy (IOReport via macmon, no sudo) =="); + print!("{}", b.report(verb)); + if let Some(per) = b.per_unit_uj() { + println!("→ per {verb} : {per:.2} µJ (work {work} {unit})"); + } + b.warn_if_unpublishable(); + match b.on_chip_figure(CounterFiltering::Unknown) { + Some((uj, class)) => { + println!("joules_micro (receipt, MEASURED): {uj}"); + (uj, Some(class)) + } + None => { + eprintln!( + "no usable figure obtained ({:.1}x noise: {}); sealing UNMETERED \ + rather than a number the measurement does not support.", + b.ratio(), + b.unsealed_reason().unwrap_or("refused") + ); + (0, None) + } + } } -fn cpu_power_w(line: &str) -> Option { - let key = "\"cpu_power\":"; - let i = line.find(key)? + key.len(); - let rest = line[i..].trim_start(); - let end = rest.find([',', '}']).unwrap_or(rest.len()); - rest[..end].trim().parse().ok() +fn emit(out: &str, json: &str, measured: bool, work: u64) { + std::fs::write(out, json).expect("write receipt"); + let tag = if measured { "MEASURED, OnChipCounter" } else { "UNMETERED" }; + println!("receipt : {out} (verified, joules_micro {tag}, work {work})"); } diff --git a/wai/wai-rs/src/bin/wai_prov.rs b/wai/wai-rs/src/bin/wai_prov.rs index fba16928b..0b0b235c9 100644 --- a/wai/wai-rs/src/bin/wai_prov.rs +++ b/wai/wai-rs/src/bin/wai_prov.rs @@ -3,7 +3,9 @@ //! WAI does NOT reconstruct — it carries it) wrapped in a WAI envelope, //! and a signed, energy-accounted lineage: origin → PNG master → published //! JPEG, with the **real measured CPU energy** of each encode (IOReport -//! via macmon, no sudo). +//! via macmon, no sudo), each figure labelled `OnChipCounter` with its +//! declared uncertainty. An encode whose measurement the §4 ratio or the §2.3 +//! uncertainty cannot support is recorded unmetered (`0`, no label). //! //! wai_prov //! @@ -16,11 +18,9 @@ use ed25519_dalek::SigningKey; use std::path::Path; -use std::process::Command; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::Arc; -use std::time::Instant; use wai::codecs::image::{jpeg_encode, png_encode}; +use wai::energy_burst::measure_with_macmon; +use wai::energy_class::{CounterFiltering, EnergyProvenance}; use wai::provenance::{AssetReceipt, ProvStep}; use wai::world::asset_content_hash; use wai::{Conditioning, Manifest, ModelRequirement, Wai}; @@ -40,14 +40,14 @@ fn main() { let raw_hash = blake3::hash(&rgb).into(); // step 1: lossless PNG master — measured. - let png_uj = measure("encode.png", &rgb, |r| { + let png_m = measure("encode.png", &rgb, |r| { let _ = std::hint::black_box(png_encode(r, H, W)); }); let png = png_encode(&rgb, H, W).expect("png"); let png_hash: [u8; 32] = blake3::hash(&png).into(); // step 2: published JPEG (from the master pixels) — measured. - let jpeg_uj = measure("encode.jpeg", &rgb, |r| { + let jpeg_m = measure("encode.jpeg", &rgb, |r| { let _ = std::hint::black_box(jpeg_encode(r, H, W, 90)); }); let jpeg = jpeg_encode(&rgb, H, W, 90).expect("jpeg"); @@ -69,10 +69,19 @@ fn main() { let envelope = Wai::new(manifest, jpeg.clone()).to_bytes().expect("wrap"); let asset_hash = asset_content_hash(&envelope); + // Each measured step carries its figure's class; a step whose + // measurement failed is recorded unmetered, never with a made-up number. + let step = |op: &str, input: [u8; 32], output: [u8; 32], m: Option<(u64, EnergyProvenance)>| { + let s = ProvStep::new(op, input, output, pixels, m.as_ref().map_or(0, |(uj, _)| *uj)); + match m { + Some((_, class)) => s.with_energy_class(class).expect("a usable figure is a valid label"), + None => s, + } + }; let steps = vec![ - ProvStep { op: "origin.synthesize".into(), input_hash: [0u8; 32], output_hash: raw_hash, work: pixels, joules_micro: 0 }, - ProvStep { op: "encode.png".into(), input_hash: raw_hash, output_hash: png_hash, work: pixels, joules_micro: png_uj.round() as u64 }, - ProvStep { op: "encode.jpeg".into(), input_hash: png_hash, output_hash: asset_hash, work: pixels, joules_micro: jpeg_uj.round() as u64 }, + ProvStep::new("origin.synthesize", [0u8; 32], raw_hash, pixels, 0), + step("encode.png", raw_hash, png_hash, png_m.clone()), + step("encode.jpeg", png_hash, asset_hash, jpeg_m.clone()), ]; let signer = SigningKey::from_bytes(&[0x70u8; 32]); // 'p' for prov @@ -93,8 +102,12 @@ fn main() { std::fs::write(dir.join("captured.receipt.json"), receipt.to_json()).expect("write receipt"); println!("captured.jpeg.wai : {} B envelope (wai.image.jpeg), asset {}…", envelope.len(), &hex(&asset_hash)[..16]); - println!("captured.receipt.json : {} steps, total {} µJ MEASURED, receipt {}…", receipt.steps.len(), receipt.total_joules_micro, &hex(&receipt.receipt_hash())[..16]); - println!(" encode.png : {png_uj:.1} µJ encode.jpeg : {jpeg_uj:.1} µJ"); + println!("captured.receipt.json : {} steps, total {} µJ, receipt {}…", receipt.steps.len(), receipt.total_joules_micro, &hex(&receipt.receipt_hash())[..16]); + let shown = |m: &Option<(u64, EnergyProvenance)>| match m { + Some((uj, c)) => format!("{uj} µJ ({})", c.label()), + None => "UNMETERED".to_string(), + }; + println!(" encode.png : {} encode.jpeg : {}", shown(&png_m), shown(&jpeg_m)); } /// Deterministic 512×512 RGB: radial rings over a diagonal gradient. @@ -117,58 +130,30 @@ fn synth_rgb() -> Vec { out } -/// Marginal microjoules per op, measured against an idle baseline (the -/// wai_meter harness). -fn measure) + Send + Sync + Copy + 'static>(label: &str, rgb: &[u8], op: F) -> f64 { - eprintln!(" {label}: idle baseline …"); - let idle = sample_cpu_power(8); - if idle.len() < 3 { - eprintln!("macmon unavailable; install `brew install macmon` (non-sudo IOReport reader)."); - std::process::exit(1); +/// One encode's figure and class, measured by the §3 protocol +/// (`energy_burst`): `Some((joules_micro, OnChipCounter))` for a usable +/// figure, `None` — unmetered — otherwise. +fn measure(label: &str, rgb: &[u8], op: F) -> Option<(u64, EnergyProvenance)> +where + F: Fn(&Vec) + Send + Sync + 'static, +{ + let Some(b) = measure_with_macmon(label, &rgb.to_vec(), op) else { + eprintln!("macmon unavailable; recording {label} UNMETERED. Install `brew install macmon` (non-sudo IOReport reader)."); + return None; + }; + eprint!("{}", b.report(label)); + b.warn_if_unpublishable(); + let m = b.on_chip_figure(CounterFiltering::Unknown); + if m.is_none() { + eprintln!( + " {label}: no usable figure ({:.1}x noise: {}); recorded UNMETERED.", + b.ratio(), + b.unsealed_reason().unwrap_or("refused") + ); } - let workers = std::thread::available_parallelism().map(|n| n.get().saturating_sub(1).clamp(1, 10)).unwrap_or(6); - eprintln!(" {label}: burst on {workers} threads …"); - let stop = Arc::new(AtomicBool::new(false)); - let iters = Arc::new(AtomicU64::new(0)); - let handles: Vec<_> = (0..workers) - .map(|_| { - let (stop, iters, r) = (stop.clone(), iters.clone(), rgb.to_vec()); - std::thread::spawn(move || { - while !stop.load(Ordering::Relaxed) { - op(&r); - iters.fetch_add(1, Ordering::Relaxed); - } - }) - }) - .collect(); - let i0 = iters.load(Ordering::Relaxed); - let t0 = Instant::now(); - let busy = sample_cpu_power(12); - let window = t0.elapsed().as_secs_f64(); - let i1 = iters.load(Ordering::Relaxed); - stop.store(true, Ordering::Relaxed); - for h in handles { - h.join().ok(); - } - let runs = (i1 - i0).max(1); - let mean = |v: &[f64]| v.iter().sum::() / v.len() as f64; - let marginal = (mean(&busy) - mean(&idle)).max(0.0); - marginal * window / runs as f64 * 1e6 + m } -fn sample_cpu_power(n: u32) -> Vec { - let Ok(out) = Command::new("macmon").args(["pipe", "-s", &n.to_string(), "-i", "200"]).output() else { - return vec![]; - }; - String::from_utf8_lossy(&out.stdout).lines().filter_map(cpu_power_w).collect() -} -fn cpu_power_w(line: &str) -> Option { - let key = "\"cpu_power\":"; - let i = line.find(key)? + key.len(); - let rest = line[i..].trim_start(); - let end = rest.find([',', '}']).unwrap_or(rest.len()); - rest[..end].trim().parse().ok() -} fn hex(b: &[u8]) -> String { b.iter().map(|x| format!("{x:02x}")).collect() } diff --git a/wai/wai-rs/src/bin/wai_quantum.rs b/wai/wai-rs/src/bin/wai_quantum.rs index 0a53ecd93..d10953db5 100644 --- a/wai/wai-rs/src/bin/wai_quantum.rs +++ b/wai/wai-rs/src/bin/wai_quantum.rs @@ -40,6 +40,7 @@ use wai::quantum_channel::{tomography, Channel}; use wai::quantum_comm::{bb84, clone_fidelity}; use wai::quantum_info::{chsh, DensityMatrix}; use wai::quantum_qudit::QuditState; +use wai::quantum_energy::{EnergyClass, MaybeLabelled}; use wai::quantum_receipt::QuantumReceipt; use wai::quantum_source::{holevo_bound, schumacher_limit, von_neumann_entropy}; use wai::quantum_toolchain as qt; @@ -61,6 +62,32 @@ fn one_qubit_state(which: &str) -> DensityMatrix { DensityMatrix::from_pure(&c.simulate().unwrap_or_else(|e| die(&format!("simulate: {e:?}")))) } +/// One line saying what the joule figure is: its class and evidence, or that +/// it has none. +fn energy_class_line(class: Option<&EnergyClass>, joules_micro: u64) -> String { + let Some(c) = class else { + return if joules_micro == 0 { + "none (unmetered)".into() + } else { + "unlabelled (legacy form: read as no class, not comparable with a labelled figure)".into() + }; + }; + let mut line = c.label().to_string(); + if let Some(u) = c.uncertainty() { + line += &format!( + " (declared u {:.2} %, k=1, over {:.3} s", + u.relative_ppm as f64 / 1e4, + u.window_us as f64 / 1e6 + ); + match c { + EnergyClass::OnChipCounter { filtering, .. } => line += &format!(", filtering {})", filtering.label()), + EnergyClass::CalibratedInstrument { calibration_ref, .. } => line += &format!(", calibration {calibration_ref})"), + _ => line += ")", + } + } + line +} + fn hx(b: &[u8]) -> String { b.iter().map(|x| format!("{x:02x}")).collect() } @@ -226,14 +253,18 @@ fn main() { } "verify" => { + // Either form: a receipt as sealed, or one whose joule figure + // carries its acquisition class inside the signature + // (`quantum_energy::Labelled`, as `wai_quantum_meter` emits). let src = read_stdin(); - match QuantumReceipt::from_json(&src) { + match MaybeLabelled::::from_json(&src) { None => { - println!("INVALID (could not parse receipt JSON)"); + println!("INVALID (could not parse receipt JSON, or its energy class is malformed)"); std::process::exit(1); } - Some(r) => { - let ok = r.verify(); + Some(any) => { + let ok = any.verify(); + let r = any.receipt(); println!("signature {}", if ok { "VALID" } else { "INVALID" }); println!("circuit-hash {}", hx(&r.circuit_hash)); println!("state-hash {}", hx(&r.statevector_hash)); @@ -241,6 +272,7 @@ fn main() { println!("gates {}", r.n_ops); println!("work {} amplitude-updates", r.work_amp_updates); println!("joules {} uJ", r.joules_micro); + println!("energy class {}", energy_class_line(any.energy_class(), r.joules_micro)); println!("signer {}", r.signer_id); if !ok { std::process::exit(1); diff --git a/wai/wai-rs/src/bin/wai_quantum_meter.rs b/wai/wai-rs/src/bin/wai_quantum_meter.rs index f18bdb2a4..1a47380f2 100644 --- a/wai/wai-rs/src/bin/wai_quantum_meter.rs +++ b/wai/wai-rs/src/bin/wai_quantum_meter.rs @@ -20,20 +20,23 @@ //! //! This is a *measurement* with stated bounds, exactly as `wai_world_meter`: //! IOReport CPU power is SoC-wide, so the meter reports the DELTA over an -//! otherwise-idle machine (busy − idle mean power × wall-time ÷ reconstructions). -//! Run it on a quiet box for a clean number; it never fabricates one. Because a +//! otherwise-idle machine (busy − idle mean power × window ÷ the +//! reconstructions the window paid for; see `wai::energy_burst`). Run it on a +//! quiet box for a clean number; it never fabricates one. A usable figure is +//! sealed with its acquisition class — `OnChipCounter` with a declared +//! uncertainty, as a `quantum_energy::Labelled` receipt; without `macmon`, or +//! when the §4 ratio or the §2.3 uncertainty cannot support a figure, the +//! receipt is sealed UNMETERED (`0`, unlabelled). Because a //! quantum computation carries no speedup here, this energy — and the //! exponential `work_amp_updates = n_ops·2^n` beside it — is precisely what the //! receipt makes auditable. No quantum hardware is involved. -use std::process::Command; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::Arc; -use std::time::Instant; - -use wai_quantum::ed25519_dalek::SigningKey; +use wai::energy_burst::measure_with_macmon; +use wai::energy_class::{to_quantum_class, CounterFiltering, EnergyProvenance}; use wai::quantum::from_wqc; +use wai::quantum_energy::Labelled; use wai::quantum_receipt::{work_amp_updates, QuantumReceipt}; +use wai_quantum::ed25519_dalek::SigningKey; fn main() { let args: Vec = std::env::args().collect(); @@ -55,72 +58,42 @@ fn main() { no sudo — IOReport via macmon" ); - // Phase A: idle baseline (no reconstruction running). - eprintln!(" idle baseline …"); - let idle = sample_cpu_power(10); - let metered = idle.len() >= 3; - if !metered { - eprintln!( - "macmon unavailable or too few samples; emitting an UNMETERED receipt \ - (joules_micro = 0). Install `brew install macmon` (the non-sudo \ - IOReport reader) for a measured figure." - ); - } - - let joules_micro = if metered { - // Phase B: saturate several cores with real reconstructions while - // sampling. Many threads lift the marginal draw above the box's idle - // fluctuation; energy per reconstruction is total measured energy ÷ - // total reconstructions regardless of how many cores ran them. - let workers = std::thread::available_parallelism() - .map(|n| n.get().saturating_sub(1).clamp(1, 10)) - .unwrap_or(6); - eprintln!(" reconstruction burst on {workers} threads …"); - let stop = Arc::new(AtomicBool::new(false)); - let iters = Arc::new(AtomicU64::new(0)); - let handles: Vec<_> = (0..workers) - .map(|_| { - let (stop, iters, c) = (stop.clone(), iters.clone(), circuit.clone()); - std::thread::spawn(move || { - while !stop.load(Ordering::Relaxed) { - for _ in 0..16 { - let _ = c.simulate(); - } - iters.fetch_add(16, Ordering::Relaxed); - } - }) - }) - .collect(); - let i0 = iters.load(Ordering::Relaxed); - let t0 = Instant::now(); - let busy = sample_cpu_power(14); - let window = t0.elapsed().as_secs_f64(); - let i1 = iters.load(Ordering::Relaxed); - stop.store(true, Ordering::Relaxed); - for h in handles { - h.join().ok(); + // The §3 protocol (energy_burst): an idle bracket, then reconstructions on + // several threads released together from a start barrier, counted per + // reconstruction — never per batch — with a busy bracket over them. + let measured: Option<(u64, EnergyProvenance)> = match measure_with_macmon("reconstruction", &circuit, |c| { + let _ = c.simulate(); + }) { + None => { + eprintln!( + "macmon unavailable or too few samples; emitting an UNMETERED receipt \ + (joules_micro = 0). Install `brew install macmon` (the non-sudo \ + IOReport reader) for a measured figure." + ); + None + } + Some(b) => { + println!("== wai_quantum_meter — measured CPU energy (IOReport via macmon, no sudo) =="); + print!("{}", b.report("reconstruction")); + if let Some(per) = b.per_unit_uj() { + println!("→ per reconstruction: {per:.3} µJ ({n} qubits, work {work})"); + } + b.warn_if_unpublishable(); + let m = b.on_chip_figure(CounterFiltering::Unknown); + match &m { + Some((uj, _)) => println!("joules_micro (receipt, MEASURED): {uj}"), + None => eprintln!( + "no usable figure obtained ({:.1}x noise: {}); emitting an \ + UNMETERED receipt rather than sealing a number the measurement does \ + not support.", + b.ratio(), + b.unsealed_reason().unwrap_or("refused") + ), + } + m } - - let runs = (i1 - i0).max(1); - let mean = |v: &[f64]| v.iter().sum::() / v.len() as f64; - let idle_w = mean(&idle); - let busy_w = mean(&busy); - let marginal_w = (busy_w - idle_w).max(0.0); - let energy_j = marginal_w * window; - let per_run_uj = energy_j / runs as f64 * 1e6; - - println!("== wai_quantum_meter — measured CPU energy (IOReport via macmon, no sudo) =="); - println!("reconstructions : {runs} over {window:.2}s"); - println!("cpu power idle : {idle_w:.2} W ({} samples)", idle.len()); - println!("cpu power busy : {busy_w:.2} W ({} samples)", busy.len()); - println!("marginal (busy-idle): {marginal_w:.2} W"); - println!("→ per reconstruction: {per_run_uj:.1} µJ ({n} qubits, work {work})"); - let uj = per_run_uj.round() as u64; - println!("joules_micro (receipt, MEASURED): {uj}"); - uj - } else { - 0 }; + let joules_micro = measured.as_ref().map_or(0, |(uj, _)| *uj); // Optional: emit a receipt carrying the measured (or unmetered) energy. if let Some(out) = args.get(2) { @@ -136,35 +109,24 @@ fn main() { None, ) .expect("seal receipt"); - assert!(receipt.verify(), "emitted receipt must verify"); - assert!( - receipt.verify_reconstruction(&circuit), - "emitted receipt must re-simulate to the same statevector" - ); - std::fs::write(out, receipt.to_json()).expect("write receipt"); - let tag = if metered { "MEASURED" } else { "UNMETERED" }; + let (json, tag) = match &measured { + Some((_, class)) => { + let class = to_quantum_class(class).expect("an on-chip class has a quantum counterpart"); + let l = Labelled::seal(receipt, class, &signer).expect("a usable figure is a valid label"); + assert!(l.verify(), "emitted receipt must verify"); + assert!(l.verify_reconstruction(&circuit), "emitted receipt must re-simulate to the same statevector"); + (l.to_json(), "MEASURED, OnChipCounter") + } + None => { + assert!(receipt.verify(), "emitted receipt must verify"); + assert!( + receipt.verify_reconstruction(&circuit), + "emitted receipt must re-simulate to the same statevector" + ); + (receipt.to_json(), "UNMETERED") + } + }; + std::fs::write(out, json).expect("write receipt"); println!("receipt : {out} (verified, joules_micro {tag}, work {work})"); } } - -/// One IOReport sample burst via `macmon pipe`, returning the CPU-power readings -/// (W). Copied from `wai_world_meter` — the same non-sudo reader, same parse. -fn sample_cpu_power(n: u32) -> Vec { - let out = Command::new("macmon") - .args(["pipe", "-s", &n.to_string(), "-i", "200"]) - .output(); - let Ok(out) = out else { return vec![] }; - String::from_utf8_lossy(&out.stdout) - .lines() - .filter_map(cpu_power_w) - .collect() -} - -/// Pull `"cpu_power": ` out of one macmon JSON line. -fn cpu_power_w(line: &str) -> Option { - let key = "\"cpu_power\":"; - let i = line.find(key)? + key.len(); - let rest = line[i..].trim_start(); - let end = rest.find([',', '}']).unwrap_or(rest.len()); - rest[..end].trim().parse().ok() -} diff --git a/wai/wai-rs/src/bin/wai_video_meter.rs b/wai/wai-rs/src/bin/wai_video_meter.rs index 1c1ca811a..ed42c9730 100644 --- a/wai/wai-rs/src/bin/wai_video_meter.rs +++ b/wai/wai-rs/src/bin/wai_video_meter.rs @@ -21,18 +21,17 @@ //! //! This is a *measurement* with stated bounds, exactly as the sibling meters: //! IOReport CPU power is SoC-wide, so the meter reports the DELTA over an -//! otherwise-idle machine (busy − idle mean power × wall-time ÷ decodes). Run it -//! on a quiet box for a clean number; it never fabricates one. Without `macmon` -//! it emits an UNMETERED receipt (`joules_micro = 0`) rather than an estimate, -//! and that receipt reports no rate at all — an absent measurement must never be -//! readable as "this decode was free". - -use std::process::Command; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::Arc; -use std::time::Instant; +//! otherwise-idle machine (busy − idle mean power × window ÷ the decodes the +//! window paid for; see `wai::energy_burst`). Run it on a quiet box for a clean +//! number; it never fabricates one. Without `macmon`, or when the §4 ratio or +//! the §2.3 uncertainty cannot support a figure, it emits an UNMETERED receipt +//! (`joules_micro = 0`) rather than an estimate, and that receipt reports no +//! rate at all — an absent measurement must never be readable as "this decode +//! was free". use ed25519_dalek::SigningKey; +use wai::energy_burst::measure_with_macmon; +use wai::energy_class::CounterFiltering; use wai::int_video::decode; use wai::video_receipt::{work_pixel_ops, Provenance, VideoReceipt}; @@ -55,139 +54,87 @@ fn main() { no sudo — IOReport via macmon" ); - // Phase A: idle baseline (no decode running). - eprintln!(" idle baseline …"); - let idle = sample_cpu_power(10); - let metered = idle.len() >= 3; - if !metered { - eprintln!( - "macmon unavailable or too few samples; emitting an UNMETERED receipt \ - (joules_micro = 0). Install `brew install macmon` (the non-sudo \ - IOReport reader) for a measured figure." - ); - } - - // `Some(uj)` only when a figure was genuinely obtained. A measurement that - // rounds to zero is not a measurement of zero energy — the decode plainly - // spent some — it is a failure to measure, and it must be reported as one. - let measured: Option = if metered { - // Phase B: saturate several cores with real decodes while sampling. - // Many threads lift the marginal draw above the box's idle fluctuation; - // energy per decode is total measured energy ÷ total decodes regardless - // of how many cores ran them. - let workers = std::thread::available_parallelism() - .map(|n| n.get().saturating_sub(1).clamp(1, 10)) - .unwrap_or(6); - eprintln!(" decode burst on {workers} threads …"); - let stop = Arc::new(AtomicBool::new(false)); - let iters = Arc::new(AtomicU64::new(0)); - let handles: Vec<_> = (0..workers) - .map(|_| { - let (stop, iters, b) = (stop.clone(), iters.clone(), bs.clone()); - std::thread::spawn(move || { - // Increment per decode, not per fixed batch: a short clip - // runs many times and a long one only a few, and `runs` must - // track whichever actually happened. - while !stop.load(Ordering::Relaxed) { - let _ = decode(&b); - iters.fetch_add(1, Ordering::Relaxed); + // The §3 protocol (energy_burst): an idle bracket, then a decode burst on + // several threads released together from a start barrier, with a busy + // bracket over it. Many threads lift the marginal draw above the box's idle + // fluctuation; the figure is the window's marginal energy over the work + // the window paid for — the completions plus the half-finished decodes at + // the close (C + N/2), since the window opened from rest. + // + // `Some((uj, class))` only when a figure was genuinely obtained. A + // measurement that rounds to zero, or that the §4 ratio or the §2.3 + // uncertainty cannot support, is a failure to measure, and it is reported + // as one. + let measured: Option<(u64, Provenance)> = + match measure_with_macmon("decode", &bs, |b| { + let _ = decode(b); + }) { + None => { + eprintln!( + "macmon unavailable or too few samples; emitting an UNMETERED receipt \ + (joules_micro = 0). Install `brew install macmon` (the non-sudo \ + IOReport reader) for a measured figure." + ); + None + } + Some(b) => { + println!("== wai_video_meter — measured CPU energy (IOReport via macmon, no sudo) =="); + print!("{}", b.report("decode")); + match b.per_unit_uj() { + Some(per) => { + println!("→ per decode : {per:.1} µJ ({n} frames {h}x{w}, work {work})"); + if work > 0 { + println!("→ per kilo-subpixel : {:.4} µJ", per * 1000.0 / work as f64); + } } - }) - }) - .collect(); - let i0 = iters.load(Ordering::Relaxed); - let t0 = Instant::now(); - let busy = sample_cpu_power(14); - let window = t0.elapsed().as_secs_f64(); - let i1 = iters.load(Ordering::Relaxed); - stop.store(true, Ordering::Relaxed); - for hd in handles { - hd.join().ok(); - } - - let runs = (i1 - i0).max(1); - let mean = |v: &[f64]| v.iter().sum::() / v.len() as f64; - let sd = |v: &[f64]| { - let m = mean(v); - (v.iter().map(|x| (x - m).powi(2)).sum::() / v.len() as f64).sqrt() + None => println!("→ per decode : no completion in the window"), + } + b.warn_if_unpublishable(); + // IOReport is the SoC's own counter, so a reading from it is + // `OnChipCounter` — a sensor value, not an estimate, whose + // declared uncertainty is this tool's own evaluation and does + // not cover IOReport's systematic error, which is not + // published. A non-privileged reader has no documented way to + // learn whether the interface filters its output, so the state + // is reported as Unknown, never Off. + match b.on_chip_figure(CounterFiltering::Unknown) { + Some((uj, class)) => { + println!("joules_micro (receipt, MEASURED): {uj}"); + Some((uj, class)) + } + None => { + eprintln!( + "no usable figure obtained ({:.1}x noise: {}); emitting \ + an UNMETERED receipt rather than sealing a number the \ + measurement does not support.", + b.ratio(), + b.unsealed_reason().unwrap_or("refused") + ); + None + } + } + } }; - let idle_w = mean(&idle); - let busy_w = mean(&busy); - let idle_sd = sd(&idle); - let marginal_w = (busy_w - idle_w).max(0.0); - let energy_j = marginal_w * window; - let per_run_uj = energy_j / runs as f64 * 1e6; - - // Attribution quality, stated rather than assumed. The marginal method - // is only meaningful when the burst lifts power clearly above the box's - // own idle fluctuation. On a machine that was already busy the baseline - // is inflated, the delta collapses toward zero, and the meter would - // otherwise report a confidently-formatted number that is far too low — - // measured on this very tool, the same clip read 10,565 uJ on a quiet - // box and 497 uJ while a build was running, a 21x error in the - // flattering direction. Silence about that is how a bad number travels. - let snr = if idle_sd > 0.0 { marginal_w / idle_sd } else { f64::INFINITY }; - - println!("== wai_video_meter — measured CPU energy (IOReport via macmon, no sudo) =="); - println!("decodes : {runs} over {window:.2}s"); - println!("cpu power idle : {idle_w:.2} W (sd {idle_sd:.2} W, {} samples)", idle.len()); - println!("cpu power busy : {busy_w:.2} W ({} samples)", busy.len()); - println!("marginal (busy-idle): {marginal_w:.2} W"); - println!("→ per decode : {per_run_uj:.1} µJ ({n} frames {h}x{w}, work {work})"); - if work > 0 { - println!("→ per kilo-subpixel : {:.4} µJ", per_run_uj * 1000.0 / work as f64); - } - println!("marginal / idle sd : {snr:.1}x ({})", quality(snr)); - // The publication bar is 10x, not 3x, and that is an empirical finding - // rather than a round number: on this tool the same clip read 2,469 uJ - // at a 3.8x ratio and 10,565 uJ on a quiet box — a 4.3x disagreement - // from a reading an earlier version of this check called "usable". A - // ratio in the 3-10 band is good enough to compare two workloads - // measured back-to-back on the same machine; it is not good enough to - // publish an absolute figure. - if snr < 10.0 { - eprintln!( - "WARNING: marginal draw {marginal_w:.2} W against idle sd {idle_sd:.2} W \n\ - ({snr:.1}x). Absolute figures are only publishable at >= 10x; below \n\ - that the inflated idle baseline biases the result LOW by a factor \n\ - that has been observed as large as 21x. Close other work and \n\ - re-run on a quiet box before quoting this number." - ); - } - let uj = per_run_uj.round() as u64; - if uj == 0 || snr < 1.0 { - eprintln!( - "no usable figure obtained (per-decode {uj} uJ at {snr:.1}x noise); \n\ - emitting an UNMETERED receipt rather than sealing a number the \n\ - measurement does not support." - ); - None - } else { - println!("joules_micro (receipt, MEASURED): {uj}"); - Some(uj) - } - } else { - None - }; // Optional: emit a receipt carrying the measured (or unmetered) energy. if let Some(out) = args.get(2) { // A native sink signs with its own identity key; this metering tool // uses a fixed, clearly-labeled demo signer. let signer = SigningKey::from_bytes(&[0x76u8; 32]); // 'v' for video-meter + let (uj, class) = match &measured { + Some((uj, class)) => (*uj, Some(class.clone())), + // With no reader present there is no measurement at all, and the + // receipt says so by carrying no class rather than downgrading to a + // label it also did not earn. + None => (0, None), + }; let receipt = VideoReceipt::seal_decode( &signer, "did:web:wai.transaction.science#wai_video_meter", &bytes, &bs, - measured.unwrap_or(0), - // IOReport is a hardware energy interface, so a reading from it is - // `HwShunt` — a sensor value, not an estimate. Its limitation is - // scope (SoC-wide, hence the marginal-over-idle attribution), not - // accuracy. With no reader present there is no measurement at all, - // and the receipt says so by carrying no class rather than - // downgrading to a label it also did not earn. - measured.map(|_| Provenance::HwShunt), + uj, + class, None, ) .expect("seal receipt"); @@ -201,36 +148,3 @@ fn main() { println!("receipt : {out} (verified, joules_micro {tag}, work {work})"); } } - -/// A plain-language verdict on attribution quality: how far the burst's -/// marginal draw rose above the machine's own idle fluctuation. -fn quality(snr: f64) -> &'static str { - match snr { - s if s >= 10.0 => "clean — publishable", - s if s >= 3.0 => "usable for relative comparison — NOT publishable", - s if s >= 1.0 => "NOISY — do not publish", - _ => "UNUSABLE — the box was not idle", - } -} - -/// One IOReport sample burst via `macmon pipe`, returning the CPU-power readings -/// (W). Same non-sudo reader and parse as the sibling meters. -fn sample_cpu_power(n: u32) -> Vec { - let out = Command::new("macmon") - .args(["pipe", "-s", &n.to_string(), "-i", "200"]) - .output(); - let Ok(out) = out else { return vec![] }; - String::from_utf8_lossy(&out.stdout) - .lines() - .filter_map(cpu_power_w) - .collect() -} - -/// Pull `"cpu_power": ` out of one macmon JSON line. -fn cpu_power_w(line: &str) -> Option { - let key = "\"cpu_power\":"; - let i = line.find(key)? + key.len(); - let rest = line[i..].trim_start(); - let end = rest.find([',', '}']).unwrap_or(rest.len()); - rest[..end].trim().parse().ok() -} diff --git a/wai/wai-rs/src/bin/wai_world_meter.rs b/wai/wai-rs/src/bin/wai_world_meter.rs index a5fb90a21..3871d12df 100644 --- a/wai/wai-rs/src/bin/wai_world_meter.rs +++ b/wai/wai-rs/src/bin/wai_world_meter.rs @@ -19,14 +19,16 @@ //! //! This is a *measurement* with stated bounds: IOReport CPU power is //! SoC-wide, so the meter reports the DELTA over an otherwise-idle -//! machine (busy − idle mean power × wall-time ÷ iterations). Run it on -//! a quiet box for a clean number; it never fabricates one. +//! machine (busy − idle mean power × window ÷ the replays the window paid +//! for; see `wai::energy_burst`). Run it on a quiet box for a clean number; +//! it never fabricates one. A usable figure is sealed with its acquisition +//! class, `OnChipCounter` with a declared uncertainty; without `macmon`, or +//! when the §4 ratio or the §2.3 uncertainty cannot support a figure, the +//! receipt is sealed UNMETERED (`0`, no class). use ed25519_dalek::SigningKey; -use std::process::Command; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::Arc; -use std::time::Instant; +use wai::energy_burst::measure_with_macmon; +use wai::energy_class::CounterFiltering; use wai::world::feed::CAP_SIM_FEED0; use wai::world::sim::{CAP_SIM_BALLISTIC0, CAP_SIM_KINEMATIC0}; use wai::world::skeletal::CAP_SIM_SKELETAL0; @@ -39,7 +41,7 @@ use wai::world::{ fn main() { let args: Vec = std::env::args().collect(); let Some(path) = args.get(1) else { - eprintln!("usage: wai_world_meter "); + eprintln!("usage: wai_world_meter [receipt_out.json]"); std::process::exit(2); }; @@ -54,67 +56,45 @@ fn main() { eprintln!("metering {path} ({final_tick} ticks/replay), no sudo — IOReport via macmon"); - // Phase A: idle baseline (no replay running). - eprintln!(" idle baseline …"); - let idle = sample_cpu_power(10); - if idle.len() < 3 { - eprintln!("macmon unavailable or too few samples; install `brew install macmon` \ - (the non-sudo IOReport reader this meter samples)."); - std::process::exit(1); - } - - // Phase B: saturate several cores with real replays while sampling. - // Many threads lift the marginal draw well above the box's idle - // fluctuation, so the signal survives a shared machine; energy per - // replay is total measured energy ÷ total replays regardless of how - // many cores ran them. - let workers = std::thread::available_parallelism() - .map(|n| n.get().saturating_sub(1).clamp(1, 10)) - .unwrap_or(6); - eprintln!(" replay burst on {workers} threads …"); - let stop = Arc::new(AtomicBool::new(false)); - let iters = Arc::new(AtomicU64::new(0)); - let handles: Vec<_> = (0..workers) - .map(|_| { - let (stop, iters, c) = (stop.clone(), iters.clone(), container.clone()); - std::thread::spawn(move || { - while !stop.load(Ordering::Relaxed) { - for _ in 0..64 { - let _ = run_sealed_dispatch(&c); - } - iters.fetch_add(64, Ordering::Relaxed); + // The §3 protocol (energy_burst): an idle bracket, then replays on several + // threads released together from a start barrier, counted per replay — + // never per batch, which would collapse the count for a heavy world — with + // a busy bracket over them. + let (joules_micro, class) = match measure_with_macmon("replay", &container, |c| { + let _ = run_sealed_dispatch(c); + }) { + None => { + eprintln!( + "macmon unavailable or too few samples; sealing UNMETERED (joules_micro = 0). \ + Install `brew install macmon` (the non-sudo IOReport reader this meter samples)." + ); + (0, None) + } + Some(b) => { + println!("== wai_world_meter — measured CPU energy (IOReport via macmon, no sudo) =="); + print!("{}", b.report("replay")); + if let Some(per_replay_uj) = b.per_unit_uj() { + println!("→ per replay : {per_replay_uj:.1} µJ ({final_tick} ticks)"); + println!("→ per tick : {:.3} µJ", per_replay_uj / final_tick as f64); + } + b.warn_if_unpublishable(); + match b.on_chip_figure(CounterFiltering::Unknown) { + Some((uj, class)) => { + println!("joules_micro (receipt, MEASURED): {uj}"); + (uj, Some(class)) } - }) - }) - .collect(); - let i0 = iters.load(Ordering::Relaxed); - let t0 = Instant::now(); - let busy = sample_cpu_power(14); - let window = t0.elapsed().as_secs_f64(); - let i1 = iters.load(Ordering::Relaxed); - stop.store(true, Ordering::Relaxed); - for h in handles { - h.join().ok(); - } - - let runs = (i1 - i0).max(1); - let mean = |v: &[f64]| v.iter().sum::() / v.len() as f64; - let idle_w = mean(&idle); - let busy_w = mean(&busy); - let marginal_w = (busy_w - idle_w).max(0.0); - let energy_j = marginal_w * window; - let per_replay_uj = energy_j / runs as f64 * 1e6; - let per_tick_uj = per_replay_uj / final_tick as f64; - - println!("== wai_world_meter — measured CPU energy (IOReport via macmon, no sudo) =="); - println!("replays in window : {runs} over {window:.2}s"); - println!("cpu power idle : {idle_w:.2} W ({} samples)", idle.len()); - println!("cpu power busy : {busy_w:.2} W ({} samples)", busy.len()); - println!("marginal (busy-idle): {marginal_w:.2} W"); - println!("→ per replay : {per_replay_uj:.1} µJ ({final_tick} ticks)"); - println!("→ per tick : {per_tick_uj:.3} µJ"); - let joules_micro = per_replay_uj.round() as u64; - println!("joules_micro (receipt, MEASURED): {joules_micro}"); + None => { + eprintln!( + "no usable figure obtained ({:.1}x noise: {}); sealing \ + UNMETERED rather than a number the measurement does not support.", + b.ratio(), + b.unsealed_reason().unwrap_or("refused") + ); + (0, None) + } + } + } + }; // Optional: emit a §8 receipt carrying the measured energy. if let Some(out) = args.get(2) { @@ -128,9 +108,14 @@ fn main() { "did:web:wai.transaction.science#wai_world_meter", wh, 0, &container.ops, &checkpoints, joules_micro, work_steps, None, ); + let receipt = match class { + Some(c) => receipt.with_energy_class(&signer, c).expect("a usable figure is a valid label"), + None => receipt, + }; assert!(receipt.verify(), "emitted receipt must verify"); std::fs::write(out, receipt.to_json()).expect("write receipt"); - println!("receipt : {out} (verified, joules_micro MEASURED, work_steps {work_steps})"); + let tag = if receipt.energy_provenance.is_some() { "MEASURED, OnChipCounter" } else { "UNMETERED" }; + println!("receipt : {out} (verified, joules_micro {tag}, work_steps {work_steps})"); } } @@ -149,25 +134,3 @@ fn checkpoints_and_work(c: &WorldContainer, final_tick: u64) -> (Vec }; r.expect("collect checkpoints") } - -/// Run `macmon` for exactly `n` samples (it exits and flushes on -/// completion — avoids stdout-buffering races), parse `cpu_power` (W). -fn sample_cpu_power(n: u32) -> Vec { - let out = Command::new("macmon") - .args(["pipe", "-s", &n.to_string(), "-i", "200"]) - .output(); - let Ok(out) = out else { return vec![] }; - String::from_utf8_lossy(&out.stdout) - .lines() - .filter_map(cpu_power_w) - .collect() -} - -/// Pull `cpu_power` (watts) out of a macmon JSON line without a JSON dep. -fn cpu_power_w(line: &str) -> Option { - let key = "\"cpu_power\":"; - let i = line.find(key)? + key.len(); - let rest = line[i..].trim_start(); - let end = rest.find([',', '}']).unwrap_or(rest.len()); - rest[..end].trim().parse().ok() -} diff --git a/wai/wai-rs/src/c2pa_emit.rs b/wai/wai-rs/src/c2pa_emit.rs index 59734b484..5e15e3077 100644 --- a/wai/wai-rs/src/c2pa_emit.rs +++ b/wai/wai-rs/src/c2pa_emit.rs @@ -54,7 +54,7 @@ use c2pa::{Builder, ClaimGeneratorInfo, EphemeralSigner, Manifest, Signer}; use serde_json::Value; use std::io::{Read, Seek, Write}; -use crate::energy_binding::EnergyBinding; +use crate::energy_binding::{EnergyBinding, EnergyBindingError}; use crate::reconstruction_binding::ReconstructionBinding; /// Errors from emitting or recovering a bound manifest. @@ -74,6 +74,9 @@ pub enum EmitError { C2pa(c2pa::Error), /// An assertion body was not the JSON this crate emits. Malformed(&'static str), + /// The companion energy binding broke one of its own rules — most importantly, a + /// hardware acquisition class with no declared uncertainty. + Energy(EnergyBindingError), } impl core::fmt::Display for EmitError { @@ -94,6 +97,7 @@ impl core::fmt::Display for EmitError { EmitError::NoBinding => write!(f, "manifest carries no WAI binding"), EmitError::C2pa(e) => write!(f, "c2pa: {e}"), EmitError::Malformed(w) => write!(f, "malformed assertion body: {w}"), + EmitError::Energy(e) => write!(f, "energy binding: {e}"), } } } @@ -289,6 +293,13 @@ impl RecoveredBindings { self.energy.get("microjoules")?.as_u64() } + /// The companion energy binding as a typed value, with every evidence rule applied + /// (see [`EnergyBinding::from_json`]). [`recover`] has already run this check, so + /// on a value it returned this cannot fail. + pub fn energy_binding(&self) -> Result { + EnergyBinding::from_json(&self.energy) + } + /// Does the sealed claim describe the pixels a verifier just decoded? /// /// This is the whole construction in one call: re-decode the asset, pass the @@ -308,7 +319,11 @@ impl RecoveredBindings { /// Recover the bindings from a validated manifest, applying the rules the SDK does not. /// /// Reports [`EmitError::UnaccompaniedStandardAssertion`] for a manifest carrying only -/// the standard figure. That manifest is perfectly valid C2PA; it is refused here +/// the standard figure, and [`EmitError::Energy`] for a companion that breaks its own +/// rules — a hardware acquisition class with no declared uncertainty, a calibrated +/// instrument with no calibration reference. A companion written with the legacy +/// `HwShunt` label is accepted, so manifests signed before the hardware class was +/// refined keep validating. That manifest is perfectly valid C2PA; it is refused here /// because the figure in it is self-declared, names no acquisition class, and is not /// about any output a reader can reproduce — which is the situation the companion /// assertion exists to distinguish itself from. @@ -338,6 +353,8 @@ pub fn recover(manifest: &Manifest) -> Result { reconstruction: r.to_string(), }); } + // The SDK validated the signature over the body, not what the body says. + EnergyBinding::from_json(&energy).map_err(EmitError::Energy)?; Ok(RecoveredBindings { standard, energy, reconstruction }) } (Some(_), None, _) => Err(EmitError::UnaccompaniedStandardAssertion), @@ -439,7 +456,7 @@ mod tests { let e = EnergyBinding::bind_pixels( rgb, 10_565, - Acquisition::HwShunt, + crate::energy_class::test_on_chip(), Coverage::Whole, "extensions/energy-measurement.md", ) @@ -486,7 +503,13 @@ mod tests { // The bodies are the ones the extension documents pin, not a restatement. assert_eq!(got.microjoules(), Some(10_565)); - assert_eq!(got.energy.get("acquisition").unwrap(), "HwShunt"); + assert_eq!(got.energy.get("acquisition").unwrap(), "OnChipCounter"); + assert_eq!( + got.energy.get("uncertainty").unwrap(), + &serde_json::json!({"relativePpm": 30_000, "windowUs": 2_000_000}), + "the declared uncertainty survives signing" + ); + assert_eq!(got.energy_binding().unwrap(), e, "and reads back as the binding that was signed"); assert_eq!(got.energy.get("coverage").unwrap(), "Whole"); assert_eq!(got.energy.get("workUnits").unwrap(), &Value::from(rgb.len())); assert_eq!( @@ -526,7 +549,7 @@ mod tests { let e = EnergyBinding::bind_pixels( &other, 10_565, - Acquisition::HwShunt, + crate::energy_class::test_on_chip(), Coverage::Whole, "extensions/energy-measurement.md", ) @@ -685,4 +708,80 @@ mod tests { "no action claims a digitalSourceType: a decoder cannot see past the codestream" ); } + + /// Sign the usual manifest with its companion energy body replaced by `body`, as a + /// foreign producer — or a producer from before the hardware class was refined — + /// could have written it. + fn sign_with_companion(body: Value) -> Vec { + let rgb = pixels(); + let (e, r) = bindings(&rgb); + let mut b = ReconstructionManifest::new(&e, &r) + .unwrap() + .builder(CODESTREAM, "application/octet-stream") + .unwrap(); + b.definition.assertions.retain(|a| a.label != EnergyBinding::C2PA_LABEL); + b.add_assertion(EnergyBinding::C2PA_LABEL, &body).unwrap(); + for a in &mut b.definition.assertions { + a.created = true; + } + let signer = untrusted_local_signer().unwrap(); + let mut src = Cursor::new(png(W, H, &rgb)); + let mut dst = Cursor::new(Vec::new()); + b.sign(&signer, "image/png", &mut src, &mut dst).unwrap(); + dst.into_inner() + } + + fn companion(acquisition_fields: Value) -> Value { + let (e, _) = bindings(&pixels()); + let mut v: Value = serde_json::from_str(&e.to_json()).unwrap(); + let o = v.as_object_mut().unwrap(); + for k in ["acquisition", "uncertainty", "counterFiltering", "calibrationRef"] { + o.remove(k); + } + for (k, x) in acquisition_fields.as_object().unwrap() { + o.insert(k.clone(), x.clone()); + } + v + } + + /// A manifest signed with the legacy `HwShunt` label keeps validating after the + /// hardware class was refined, and reads as the unrefined hardware class. + #[test] + #[allow(deprecated)] // reading a legacy HwShunt manifest is the point + fn a_legacy_hwshunt_manifest_still_recovers() { + let signed = sign_with_companion(companion(serde_json::json!({"acquisition": "HwShunt"}))); + let reader = Reader::from_stream("image/png", Cursor::new(signed)).unwrap(); + assert_eq!(reader.validation_state(), ValidationState::Valid); + let got = recover(reader.active_manifest().unwrap()).expect("legacy manifests still recover"); + assert_eq!(got.energy_binding().unwrap().acquisition, Acquisition::HwShunt); + assert!(got.describes(&pixels())); + } + + /// A manifest whose companion claims a hardware class but declares no uncertainty + /// is well-formed, validly signed C2PA — and refused here, because it says nothing + /// about how far the figure can be leaned on. + #[test] + fn a_hardware_class_without_an_uncertainty_is_refused_on_recovery() { + use crate::energy_class::AcquisitionError; + for (fields, want) in [ + (serde_json::json!({"acquisition": "OnChipCounter"}), AcquisitionError::NoUncertainty), + ( + serde_json::json!({"acquisition": "CalibratedInstrument", "calibrationRef": "lab-cert-0412"}), + AcquisitionError::NoUncertainty, + ), + ( + serde_json::json!({"acquisition": "CalibratedInstrument", + "uncertainty": {"relativePpm": 5000, "windowUs": 60_000_000}}), + AcquisitionError::NoCalibrationRef, + ), + ] { + let signed = sign_with_companion(companion(fields.clone())); + let reader = Reader::from_stream("image/png", Cursor::new(signed)).unwrap(); + assert_eq!(reader.validation_state(), ValidationState::Valid, "the SDK is content: {fields}"); + match recover(reader.active_manifest().unwrap()) { + Err(EmitError::Energy(EnergyBindingError::Acquisition(e))) => assert_eq!(e, want, "{fields}"), + other => panic!("{fields} must be refused, got {other:?}"), + } + } + } } diff --git a/wai/wai-rs/src/energy_binding.rs b/wai/wai-rs/src/energy_binding.rs index bc482b4eb..3138cff2c 100644 --- a/wai/wai-rs/src/energy_binding.rs +++ b/wai/wai-rs/src/energy_binding.rs @@ -27,9 +27,14 @@ //! energy claim about an output nobody can reproduce is not checkable at all; an //! energy claim about an output anyone can reproduce is checkable except for one //! number, and knowing precisely which number that is, is the difference. -//! - **An acquisition class.** `HwShunt` / `ModelBased` / `Estimator` — the family's +//! - **An acquisition class.** `OnChipCounter` / `CalibratedInstrument` / +//! `ModelBased` / `Estimator` (and, read-only, the legacy `HwShunt`) — the family's //! ladder, stated rather than implied. A counter reading and a modelled constant are -//! not the same claim, and the standard assertion has no way to tell them apart. +//! not the same claim, and the standard assertion has no way to tell them apart. The +//! two hardware classes carry their declared uncertainty — and the counter's +//! filtering state or the instrument's calibration reference — because a +//! self-reported counter and a calibrated meter are not the same claim either +//! ([`crate::energy_class`]). //! - **A coverage statement.** Whether the figure accounts for the whole reconstruction //! or part of it. A partial reading is not wrong; presenting it as a total is. //! @@ -44,27 +49,11 @@ use sha2::{Digest, Sha256}; -/// How the energy figure was acquired — the family's provenance ladder. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Acquisition { - /// Read from a hardware energy interface (RAPL / NVML / SMC / IOReport / shunt). - HwShunt, - /// Computed from a calibrated per-operation model. A real number, honestly derived. - ModelBased, - /// A coarse constant or load estimator. - Estimator, -} - -impl Acquisition { - /// The wire label. - pub fn label(self) -> &'static str { - match self { - Acquisition::HwShunt => "HwShunt", - Acquisition::ModelBased => "ModelBased", - Acquisition::Estimator => "Estimator", - } - } -} +/// How the energy figure was acquired — the family's provenance ladder. The one +/// definition lives in [`crate::energy_class`]; the name is kept here for the C2PA +/// surface. +pub use crate::energy_class::EnergyProvenance as Acquisition; +use crate::energy_class::{AcquisitionError, CounterFiltering, Uncertainty}; /// How much of the reconstruction the figure accounts for. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -83,6 +72,15 @@ impl Coverage { Coverage::Partial => "Partial", } } + + /// Parse a wire label. + pub fn from_label(s: &str) -> Option { + match s { + "Whole" => Some(Coverage::Whole), + "Partial" => Some(Coverage::Partial), + _ => None, + } + } } /// Errors from constructing a binding. @@ -94,6 +92,15 @@ pub enum EnergyBindingError { ZeroWork, /// A figure must name the protocol it was obtained under. NoMethod, + /// The acquisition class failed its evidence rules — most importantly, a hardware + /// class with no declared uncertainty. + Acquisition(AcquisitionError), + /// A new binding may not use the legacy `HwShunt` label. It is accepted when + /// *read* ([`EnergyBinding::from_json`]) so existing manifests keep validating, and + /// refused when *minted*. + LegacyClass, + /// An assertion body that is not the shape this crate emits. + Malformed(&'static str), } impl core::fmt::Display for EnergyBindingError { @@ -106,6 +113,13 @@ impl core::fmt::Display for EnergyBindingError { EnergyBindingError::NoMethod => { write!(f, "a figure must name the acquisition protocol it was obtained under") } + EnergyBindingError::Acquisition(e) => write!(f, "acquisition class: {e}"), + EnergyBindingError::LegacyClass => write!( + f, + "HwShunt is a legacy label: bind as OnChipCounter or CalibratedInstrument \ + with a declared uncertainty" + ), + EnergyBindingError::Malformed(w) => write!(f, "malformed energy binding: {w}"), } } } @@ -149,7 +163,11 @@ impl EnergyBinding { /// /// Rejects a zero measurement rather than recording it: a decode that measured 0 µJ /// did not consume nothing, it failed to measure, and the honest artifact in that - /// case is no energy assertion at all. + /// case is no energy assertion at all. Rejects a hardware class without its + /// declared uncertainty or with one below the `0.5/√3` µJ that rounding the figure + /// to whole microjoules adds (`microjoules × relativePpm < 288 676`, + /// `energy_class::check_resolution`), and the legacy `HwShunt` label, which new + /// bindings may not use. pub fn bind( reconstruction_hash: [u8; 32], microjoules: u64, @@ -168,6 +186,11 @@ impl EnergyBinding { if method.trim().is_empty() { return Err(EnergyBindingError::NoMethod); } + if acquisition.is_legacy() { + return Err(EnergyBindingError::LegacyClass); + } + acquisition.validate().map_err(EnergyBindingError::Acquisition)?; + crate::energy_class::check_resolution(microjoules, &acquisition).map_err(EnergyBindingError::Acquisition)?; Ok(EnergyBinding { reconstruction_hash, microjoules, @@ -202,7 +225,7 @@ impl EnergyBinding { /// Microjoules per thousand work units — the comparable rate, with its acquisition /// class, because a rate without one cannot be compared to another. pub fn uj_per_kilo_unit(&self) -> (f64, Acquisition) { - (self.microjoules as f64 * 1000.0 / self.work_units as f64, self.acquisition) + (self.microjoules as f64 * 1000.0 / self.work_units as f64, self.acquisition.clone()) } /// The derived `energy_kwh` for the standard assertion. @@ -239,18 +262,126 @@ impl EnergyBinding { } /// The companion assertion body: everything the standard one cannot express. + /// + /// The hardware classes add their evidence directly after `acquisition`: + /// `uncertainty` (`relativePpm`, `windowUs`) always, then `counterFiltering` for + /// `OnChipCounter` or `calibrationRef` for `CalibratedInstrument`. The other classes + /// add nothing, so a body for them is byte-identical to one written before the + /// hardware class was refined. pub fn to_json(&self) -> String { + let evidence = match &self.acquisition { + Acquisition::OnChipCounter { uncertainty, filtering } => format!( + ",{},\"counterFiltering\":\"{}\"", + uncertainty_json(uncertainty), + filtering.label() + ), + Acquisition::CalibratedInstrument { uncertainty, calibration_ref } => format!( + ",{},\"calibrationRef\":{}", + uncertainty_json(uncertainty), + json_str(calibration_ref) + ), + _ => String::new(), + }; format!( "{{\"reconstructionHash\":\"{}\",\"alg\":\"sha256\",\"microjoules\":{},\ - \"acquisition\":\"{}\",\"coverage\":\"{}\",\"workUnits\":{},\"method\":{}}}", + \"acquisition\":\"{}\"{},\"coverage\":\"{}\",\"workUnits\":{},\"method\":{}}}", self.hash_hex(), self.microjoules, self.acquisition.label(), + evidence, self.coverage.label(), self.work_units, json_str(&self.method) ) } + + /// Read a companion assertion body back into a binding, applying every rule a + /// verifier must apply rather than trusting the producer: + /// + /// - the legacy `HwShunt` label **is accepted** (it is what manifests signed before + /// the hardware class was refined say) and reads as the weakest hardware class; + /// - a refined hardware class **without** its declared uncertainty is refused, as is + /// a calibrated instrument without a calibration reference, and evidence attached + /// to a class that carries none; + /// - zero energy, zero work and an empty method are refused exactly as + /// [`EnergyBinding::bind`] refuses them. + /// + /// Unknown keys are ignored, so a later revision can add fields. + pub fn from_json(v: &serde_json::Value) -> Result { + use serde_json::Value; + let m = EnergyBindingError::Malformed; + let o = v.as_object().ok_or(m("not an object"))?; + let str_of = |k: &'static str| o.get(k).and_then(Value::as_str); + let u64_of = |k: &'static str| o.get(k).and_then(Value::as_u64); + + if str_of("alg") != Some("sha256") { + return Err(m("alg must be sha256")); + } + let hex = str_of("reconstructionHash").ok_or(m("missing reconstructionHash"))?; + let reconstruction_hash = parse_hash(hex).ok_or(m("reconstructionHash is not 32 hex bytes"))?; + let microjoules = u64_of("microjoules").ok_or(m("missing microjoules"))?; + let work_units = u64_of("workUnits").ok_or(m("missing workUnits"))?; + let coverage = str_of("coverage") + .and_then(Coverage::from_label) + .ok_or(m("missing or unknown coverage"))?; + let method = str_of("method").ok_or(m("missing method"))?.to_string(); + + let label = str_of("acquisition").ok_or(m("missing acquisition"))?; + let uncertainty = match o.get("uncertainty") { + None => None, + Some(u) => { + let ppm = u.get("relativePpm").and_then(Value::as_u64).ok_or(m("uncertainty.relativePpm"))?; + let window_us = u.get("windowUs").and_then(Value::as_u64).ok_or(m("uncertainty.windowUs"))?; + let relative_ppm = u32::try_from(ppm) + .map_err(|_| EnergyBindingError::Acquisition(AcquisitionError::UncertaintyOutOfRange))?; + Some(Uncertainty { relative_ppm, window_us }) + } + }; + let filtering = match o.get("counterFiltering") { + None => None, + Some(f) => Some( + f.as_str() + .and_then(CounterFiltering::from_label) + .ok_or(m("unknown counterFiltering"))?, + ), + }; + let calibration_ref = match o.get("calibrationRef") { + None => None, + Some(r) => Some(r.as_str().ok_or(m("calibrationRef must be a string"))?), + }; + let acquisition = Acquisition::from_parts(label, uncertainty, filtering, calibration_ref) + .map_err(EnergyBindingError::Acquisition)?; + + if microjoules == 0 { + return Err(EnergyBindingError::ZeroMeasurement); + } + crate::energy_class::check_resolution(microjoules, &acquisition).map_err(EnergyBindingError::Acquisition)?; + if work_units == 0 { + return Err(EnergyBindingError::ZeroWork); + } + if method.trim().is_empty() { + return Err(EnergyBindingError::NoMethod); + } + Ok(EnergyBinding { reconstruction_hash, microjoules, acquisition, coverage, work_units, method }) + } +} + +fn uncertainty_json(u: &Uncertainty) -> String { + format!( + "\"uncertainty\":{{\"relativePpm\":{},\"windowUs\":{}}}", + u.relative_ppm, u.window_us + ) +} + +fn parse_hash(hex: &str) -> Option<[u8; 32]> { + if hex.len() != 64 || !hex.is_ascii() { + return None; + } + let mut out = [0u8; 32]; + for (i, b) in out.iter_mut().enumerate() { + *b = u8::from_str_radix(&hex[2 * i..2 * i + 2], 16).ok()?; + } + Some(out) } fn json_str(s: &str) -> String { @@ -261,6 +392,12 @@ fn json_str(s: &str) -> String { '"' => o.push_str("\\\""), '\\' => o.push_str("\\\\"), '\n' => o.push_str("\\n"), + // Every other control character must be escaped too, or the body is + // not JSON and a validator cannot read the assertion at all. + c if (c as u32) < 0x20 => { + use std::fmt::Write; + let _ = write!(o, "\\u{:04x}", c as u32); + } c => o.push(c), } } @@ -278,11 +415,13 @@ mod tests { (0..(4 * 4 * 3u32)).map(|i| (i % 251) as u8).collect() } + use crate::energy_class::test_on_chip as chip; + /// The point of the module: the figure is about pixels a verifier can reproduce. #[test] fn the_figure_is_bound_to_the_pixels_it_was_measured_over() { let rgb = pixels(); - let b = EnergyBinding::bind_pixels(&rgb, 10_565, Acquisition::HwShunt, Coverage::Whole, METHOD) + let b = EnergyBinding::bind_pixels(&rgb, 10_565, chip(), Coverage::Whole, METHOD) .unwrap(); assert!(b.describes(&rgb), "must describe the pixels it was bound to"); @@ -316,7 +455,7 @@ mod tests { fn a_zero_measurement_is_refused() { let rgb = pixels(); assert_eq!( - EnergyBinding::bind_pixels(&rgb, 0, Acquisition::HwShunt, Coverage::Whole, METHOD), + EnergyBinding::bind_pixels(&rgb, 0, chip(), Coverage::Whole, METHOD), Err(EnergyBindingError::ZeroMeasurement) ); } @@ -358,7 +497,7 @@ mod tests { #[test] fn kwh_is_derived_and_lossy_while_the_integer_is_exact() { let rgb = pixels(); - let b = EnergyBinding::bind_pixels(&rgb, 10_565, Acquisition::HwShunt, Coverage::Whole, METHOD) + let b = EnergyBinding::bind_pixels(&rgb, 10_565, chip(), Coverage::Whole, METHOD) .unwrap(); assert_eq!(b.microjoules, 10_565, "the integer is carried exactly"); @@ -376,10 +515,157 @@ mod tests { #[test] fn a_rate_carries_its_acquisition_class() { let rgb = pixels(); // 48 bytes of work - let b = EnergyBinding::bind_pixels(&rgb, 4_800, Acquisition::HwShunt, Coverage::Whole, METHOD) + let b = EnergyBinding::bind_pixels(&rgb, 4_800, chip(), Coverage::Whole, METHOD) .unwrap(); let (rate, acq) = b.uj_per_kilo_unit(); - assert_eq!(acq, Acquisition::HwShunt); + assert_eq!(acq, chip()); assert!((rate - 100_000.0).abs() < 1e-6, "4800 uJ over 48 units = 100 uJ/unit"); } + + + /// The body an emitter wrote for `HwShunt` before the hardware class was refined, + /// captured from that emitter and pinned byte for byte. + const LEGACY_HWSHUNT_BODY: &str = "{\"reconstructionHash\":\"4dbdc2b2b62cb00749785bc84202236dbc3777d74660611b8e58812f0cfde6c3\",\"alg\":\"sha256\",\"microjoules\":10565,\"acquisition\":\"HwShunt\",\"coverage\":\"Whole\",\"workUnits\":48,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\"}"; + + /// A manifest signed with the legacy label keeps reading: the body parses, reads as + /// the unrefined hardware class, and re-renders to exactly the bytes that were + /// signed. Minting a *new* binding with it is refused. + #[test] + #[allow(deprecated)] // reading a legacy HwShunt body is the point + fn legacy_assertion_bodies_still_parse_and_are_byte_stable() { + let v: serde_json::Value = serde_json::from_str(LEGACY_HWSHUNT_BODY).unwrap(); + let b = EnergyBinding::from_json(&v).expect("a legacy body must still parse"); + assert_eq!(b.acquisition, Acquisition::HwShunt); + assert!(b.acquisition.is_legacy()); + assert!(b.describes(&pixels()), "and still describes the decode it was measured over"); + assert_eq!(b.to_json(), LEGACY_HWSHUNT_BODY, "legacy rendering is unchanged"); + + assert_eq!( + EnergyBinding::bind_pixels(&pixels(), 10_565, Acquisition::HwShunt, Coverage::Whole, METHOD), + Err(EnergyBindingError::LegacyClass), + "new bindings must say which kind of hardware evidence they are" + ); + } + + /// Both refined classes are emitted with their evidence and read back to the same + /// binding. + #[test] + fn refined_classes_are_emitted_with_their_evidence_and_round_trip() { + let rgb = pixels(); + let chip_b = EnergyBinding::bind_pixels(&rgb, 10_565, chip(), Coverage::Whole, METHOD).unwrap(); + let j = chip_b.to_json(); + assert!(j.contains("\"acquisition\":\"OnChipCounter\""), "{j}"); + assert!(j.contains("\"uncertainty\":{\"relativePpm\":30000,\"windowUs\":2000000}"), "{j}"); + assert!(j.contains("\"counterFiltering\":\"Unknown\""), "{j}"); + + let cal = Acquisition::calibrated_instrument( + Uncertainty::new(5_000, 60_000_000).unwrap(), + "https://lab.example/certificates/0412", + ) + .unwrap(); + let cal_b = EnergyBinding::bind_pixels(&rgb, 10_565, cal, Coverage::Partial, METHOD).unwrap(); + let j2 = cal_b.to_json(); + assert!(j2.contains("\"acquisition\":\"CalibratedInstrument\""), "{j2}"); + assert!(j2.contains("\"calibrationRef\":\"https://lab.example/certificates/0412\""), "{j2}"); + assert!(!j2.contains("counterFiltering"), "an instrument has no counter filter: {j2}"); + + for b in [chip_b, cal_b] { + let v: serde_json::Value = serde_json::from_str(&b.to_json()).expect("valid JSON"); + assert_eq!(EnergyBinding::from_json(&v), Ok(b)); + } + } + + /// The rule this change exists for: a hardware class with no declared uncertainty + /// is refused, whether minted here or read from someone else's manifest. + #[test] + fn a_refined_class_without_an_uncertainty_is_rejected() { + let rgb = pixels(); + let undeclared = Acquisition::OnChipCounter { + uncertainty: Uncertainty { relative_ppm: 0, window_us: 1_000_000 }, + filtering: CounterFiltering::Unknown, + }; + assert_eq!( + EnergyBinding::bind_pixels(&rgb, 10, undeclared, Coverage::Whole, METHOD), + Err(EnergyBindingError::Acquisition(AcquisitionError::NoUncertainty)) + ); + + let body = |extra: &str, acq: &str| { + let s = format!( + "{{\"reconstructionHash\":\"{}\",\"alg\":\"sha256\",\"microjoules\":10565,\ + \"acquisition\":\"{acq}\"{extra},\"coverage\":\"Whole\",\"workUnits\":48,\"method\":\"m\"}}", + "4dbdc2b2b62cb00749785bc84202236dbc3777d74660611b8e58812f0cfde6c3" + ); + serde_json::from_str::(&s).unwrap() + }; + let refused = |extra: &str, acq: &str| EnergyBinding::from_json(&body(extra, acq)).unwrap_err(); + + assert_eq!( + refused("", "OnChipCounter"), + EnergyBindingError::Acquisition(AcquisitionError::NoUncertainty) + ); + assert_eq!( + refused(",\"calibrationRef\":\"c\"", "CalibratedInstrument"), + EnergyBindingError::Acquisition(AcquisitionError::NoUncertainty) + ); + assert_eq!( + refused(",\"uncertainty\":{\"relativePpm\":0,\"windowUs\":1}", "OnChipCounter"), + EnergyBindingError::Acquisition(AcquisitionError::NoUncertainty) + ); + assert_eq!( + refused(",\"uncertainty\":{\"relativePpm\":5000,\"windowUs\":0}", "OnChipCounter"), + EnergyBindingError::Acquisition(AcquisitionError::NoWindow) + ); + assert_eq!( + refused(",\"uncertainty\":{\"relativePpm\":5000,\"windowUs\":1}", "CalibratedInstrument"), + EnergyBindingError::Acquisition(AcquisitionError::NoCalibrationRef) + ); + assert_eq!( + refused(",\"uncertainty\":{\"relativePpm\":5000,\"windowUs\":1}", "HwShunt"), + EnergyBindingError::Acquisition(AcquisitionError::UnexpectedEvidence), + "legacy plus half the new evidence is ambiguous" + ); + assert_eq!( + refused(",\"uncertainty\":{\"relativePpm\":5000,\"windowUs\":1},\"counterFiltering\":\"Maybe\"", "OnChipCounter"), + EnergyBindingError::Malformed("unknown counterFiltering") + ); + // A present uncertainty with an unstated filtering state reads as Unknown, + // never as Off. + let ok = EnergyBinding::from_json(&body(",\"uncertainty\":{\"relativePpm\":5000,\"windowUs\":1}", "OnChipCounter")).unwrap(); + assert!(matches!(ok.acquisition, Acquisition::OnChipCounter { filtering: CounterFiltering::Unknown, .. })); + } + + /// A calibration reference is free text from the producer; it must not be able to + /// break the JSON it is carried in. + #[test] + fn a_calibration_reference_cannot_break_the_body() { + let cal = Acquisition::calibrated_instrument( + Uncertainty::new(5_000, 1_000_000).unwrap(), + "cert \"7\"\t\r\u{1}", + ) + .unwrap(); + let b = EnergyBinding::bind_pixels(&pixels(), 10_565, cal, Coverage::Whole, METHOD).unwrap(); + let v: serde_json::Value = serde_json::from_str(&b.to_json()).expect("still valid JSON"); + assert_eq!(EnergyBinding::from_json(&v), Ok(b)); + } + + /// A declaration below the `0.5/√3` µJ the figure's rounding adds is refused, + /// bound or parsed: `microjoules × relativePpm` must reach 288 676. + #[test] + fn a_declaration_below_the_figures_resolution_is_refused() { + let at = |ppm| { + Acquisition::on_chip_counter(Uncertainty::new(ppm, 1_000_000).unwrap(), CounterFiltering::Unknown) + .unwrap() + }; + let below = || EnergyBindingError::Acquisition(AcquisitionError::BelowResolution); + // 1 µJ at 0.5 % would state 0.005 µJ, a hundredth of its own rounding. + assert_eq!(EnergyBinding::bind_pixels(&pixels(), 1, at(5_000), Coverage::Whole, METHOD), Err(below())); + assert!(EnergyBinding::bind_pixels(&pixels(), 1, at(288_676), Coverage::Whole, METHOD).is_ok()); + // 10,565 µJ needs at least 28 ppm (27 × 10,565 = 285,255). + let b = EnergyBinding::bind_pixels(&pixels(), 10_565, at(28), Coverage::Whole, METHOD).unwrap(); + let v: serde_json::Value = serde_json::from_str(&b.to_json()).unwrap(); + assert_eq!(EnergyBinding::from_json(&v), Ok(b)); + let v: serde_json::Value = + serde_json::from_str(&v.to_string().replace("\"relativePpm\":28", "\"relativePpm\":27")).unwrap(); + assert_eq!(EnergyBinding::from_json(&v), Err(below())); + } } diff --git a/wai/wai-rs/src/energy_burst.rs b/wai/wai-rs/src/energy_burst.rs new file mode 100644 index 000000000..57eb098cd --- /dev/null +++ b/wai/wai-rs/src/energy_burst.rs @@ -0,0 +1,546 @@ +//! The bracketed marginal protocol of `extensions/energy-measurement.md` §3, +//! written once for every reference meter (`wai_video_meter`, `wai_meter`, +//! `wai_world_meter`, `wai_quantum_meter`, `wai_prov`). +//! +//! An on-die energy interface reports a whole package or SoC, so a +//! reconstruction cannot be isolated on it. The protocol measures the +//! **marginal** draw a saturating burst adds to an idle machine: +//! +//! 1. an idle bracket of power samples; +//! 2. a burst of the reconstruction on `N` threads, counted per completion; +//! 3. a busy bracket of power samples over a timed window; +//! 4. `energy = max(0, busy − idle) × window`, divided by the work the +//! window paid for. +//! +//! # The window opens from rest +//! +//! The workers are spawned first and held at a start barrier. The window +//! opens as the barrier releases them, so no reconstruction began before it: +//! the opening edge contributes nothing to the count's error. At the closing +//! edge each worker is part-way through one reconstruction the count cannot +//! see, so the work the window paid for lies in `[C, C + N)`. The figure +//! divides by the midpoint `C + N/2` and declares the rectangular standard +//! uncertainty `N/(2√3)` over that interval +//! ([`crate::energy_class::WindowOpening::FromRest`]). +//! +//! An earlier version of these meters read the count the moment the workers +//! were spawned, with no barrier. That window opened with the workers barely +//! started, so the count read low by up to `N` — by about `N/2` on average — +//! and every per-unit figure read **high** by about `N/(2C)`. Its uncertainty +//! modelled the error as symmetric, which it was not. +//! +//! # The figure sealed, and what its declaration covers +//! +//! A receipt seals the per-unit estimate rounded to whole microjoules. The +//! declared uncertainty describes that sealed figure: the measurement's own +//! terms (`u_power`, `u_count`) carried across in microjoules, and the ±0.5 µJ +//! rounding (`u_res`, rectangular, `0.5/√3` µJ), divided by the figure sealed +//! ([`crate::energy_class::sealed_figure`]). At a figure of a few microjoules +//! the rounding is the largest term — 29 % of a 1 µJ figure — and the +//! declaration says so rather than stating the measurement's precision alone. +//! +//! # What this module refuses to produce +//! +//! A figure is sealed under a hardware class only when §4 and §2.3 allow it: +//! the marginal-to-idle-noise ratio is at least 1, the per-unit figure does +//! not round to zero, and a declared uncertainty can be evaluated. Anything +//! else is a failure to measure, and the caller seals the receipt unmetered +//! ([`Bracket::on_chip_figure`] returns `None`; [`Bracket::unsealed_reason`] +//! says why). + +use std::process::Command; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Barrier}; +use std::time::Instant; + +use crate::energy_class::{ + bracket_relative_uncertainty, check_resolution, per_unit_relative_uncertainty, sealed_figure, CompletionCount, + CounterFiltering, EnergyProvenance, Uncertainty, WindowOpening, RESOLUTION_UNCERTAINTY_UJ, +}; + +/// Idle-bracket sample count the reference meters use. +const IDLE_SAMPLES: u32 = 10; +/// Busy-bracket sample count the reference meters use. +const BUSY_SAMPLES: u32 = 14; +/// Sampling interval of the power reader, in milliseconds. +const SAMPLE_INTERVAL_MS: u32 = 200; + +/// One complete bracketed measurement — everything §3 and §6 require a +/// published figure to be reported with. +#[derive(Clone, Debug, PartialEq)] +pub struct Bracket { + /// Idle-bracket power samples, in watts. + pub idle_w: Vec, + /// Busy-bracket power samples, in watts. + pub busy_w: Vec, + /// The timed window the busy bracket spans, in seconds. + pub window_s: f64, + /// Completions, workers, and how the window opened. + pub count: CompletionCount, +} + +fn mean(v: &[f64]) -> f64 { + v.iter().sum::() / v.len() as f64 +} + +/// Sample standard deviation (divisor `n − 1`) — the statistic §2.3's +/// `u_power` is computed from, so a reported figure reproduces the declared one. +fn sample_sd(v: &[f64]) -> Option { + if v.len() < 2 { + return None; + } + let m = mean(v); + Some((v.iter().map(|x| (x - m).powi(2)).sum::() / (v.len() - 1) as f64).sqrt()) +} + +impl Bracket { + pub fn idle_mean(&self) -> f64 { + mean(&self.idle_w) + } + + pub fn busy_mean(&self) -> f64 { + mean(&self.busy_w) + } + + /// Sample standard deviation of the idle bracket. + pub fn idle_sd(&self) -> Option { + sample_sd(&self.idle_w) + } + + /// Sample standard deviation of the busy bracket. + pub fn busy_sd(&self) -> Option { + sample_sd(&self.busy_w) + } + + /// `max(0, busy − idle)`, in watts. + pub fn marginal_w(&self) -> f64 { + let m = self.busy_mean() - self.idle_mean(); + if m.is_nan() { 0.0 } else { m.max(0.0) } + } + + /// The §4 attribution-quality ratio: marginal draw over the idle bracket's + /// standard deviation. A flat idle bracket gives an infinite ratio only if + /// the burst actually rose above it; a reader returning nothing but zeros + /// has a flat idle *and* a zero marginal, and that is `0`, not "clean". + pub fn ratio(&self) -> f64 { + let m = self.marginal_w(); + match self.idle_sd() { + Some(sd) if sd > 0.0 => m / sd, + _ if m > 0.0 => f64::INFINITY, + _ => 0.0, + } + } + + /// The §4 verdict for [`Bracket::ratio`]. + pub fn verdict(&self) -> &'static str { + match self.ratio() { + r if r >= 10.0 => "clean — publishable", + r if r >= 3.0 => "usable for relative comparison — NOT publishable", + r if r >= 1.0 => "NOISY — do not publish", + _ => "UNUSABLE — the box was not idle, or the reader saw no draw", + } + } + + /// Marginal energy over the window, in joules. + pub fn energy_j(&self) -> f64 { + self.marginal_w() * self.window_s + } + + /// Marginal microjoules per reconstruction: the window's energy over the + /// work it paid for ([`CompletionCount::work`]). `None` with no completion. + pub fn per_unit_uj(&self) -> Option { + let w = self.count.work()?; + Some(self.energy_j() / w * 1e6) + } + + /// `u_power` (§2.3). + pub fn u_power(&self) -> Option { + bracket_relative_uncertainty(&self.idle_w, &self.busy_w) + } + + /// `u_count` (§2.3). + pub fn u_count(&self) -> Option { + self.count.relative_uncertainty() + } + + /// `u_meas`: `u_power` and `u_count` in quadrature — the measurement's own + /// relative uncertainty, relative to the estimate [`Bracket::per_unit_uj`]. + pub fn u_measurement(&self) -> Option { + per_unit_relative_uncertainty(&self.idle_w, &self.busy_w, &self.count) + } + + /// The figure a receipt seals: the per-unit estimate rounded to whole + /// microjoules. `None` when it rounds to zero. + pub fn sealed_uj(&self) -> Option { + let f = self.per_unit_uj()?.round(); + (f.is_finite() && f >= 1.0 && f < u64::MAX as f64).then_some(f as u64) + } + + /// `u_res`: the rounding to [`Bracket::sealed_uj`], relative to it + /// ([`RESOLUTION_UNCERTAINTY_UJ`] ÷ the sealed figure). + pub fn u_resolution(&self) -> Option { + Some(RESOLUTION_UNCERTAINTY_UJ / self.sealed_uj()? as f64) + } + + /// The sealed figure and the declaration that describes it, computed + /// together so they cannot disagree ([`sealed_figure`]). + fn sealed_declaration(&self) -> Option<(u64, Uncertainty)> { + let window_us = (self.window_s * 1e6).round(); + if !window_us.is_finite() || window_us < 1.0 { + return None; + } + let (uj, rel) = sealed_figure(self.per_unit_uj()?, self.u_measurement()?)?; + Some((uj, Uncertainty::from_relative(rel, window_us as u64).ok()?)) + } + + /// The declared uncertainty a receipt carries: the measurement's own + /// uncertainty (`u_power` and `u_count`) and the rounding of the estimate + /// to whole microjoules (`u_res`), relative to the figure sealed, over the + /// busy window, rounded up to whole ppm (§2.3). `None` when there is no + /// figure to declare it for. + pub fn declared(&self) -> Option { + self.sealed_declaration().map(|(_, u)| u) + } + + /// Why [`Bracket::on_chip_figure`] refuses this bracket, or `None` when it + /// seals a figure. + pub fn unsealed_reason(&self) -> Option<&'static str> { + if self.count.work().is_none() { + Some("no completion inside the window") + } else if self.ratio() < 1.0 { + Some("the marginal draw did not rise above the idle noise (ratio below 1)") + } else if self.sealed_uj().is_none() { + Some("the per-unit figure rounds to zero microjoules") + } else if self.u_measurement().is_none() { + Some("no evaluable uncertainty (too few samples in a bracket)") + } else if self.sealed_declaration().is_none() { + Some("no declarable uncertainty over this window") + } else { + None + } + } + + /// What a receipt seals from this bracket when the power reader is the + /// measured SoC's own counter: `(joules_micro, OnChipCounter)`, or `None` + /// when §4 or §2.3 forbid sealing a hardware class — ratio below 1, a + /// per-unit figure that rounds to zero, or no evaluable uncertainty + /// ([`Bracket::unsealed_reason`]). The caller then seals the receipt + /// unmetered. + pub fn on_chip_figure(&self, filtering: CounterFiltering) -> Option<(u64, EnergyProvenance)> { + if self.ratio() < 1.0 { + return None; + } + let (uj, uncertainty) = self.sealed_declaration()?; + let class = EnergyProvenance::on_chip_counter(uncertainty, filtering).ok()?; + check_resolution(uj, &class).ok()?; + Some((uj, class)) + } + + /// The report §3 and §6 require beside any published figure, one field + /// per line. `unit` names one reconstruction (e.g. `"decode"`). + pub fn report(&self, unit: &str) -> String { + let pct = |v: Option| match v { + Some(v) => format!("{:.2} %", v * 100.0), + None => "not evaluable".to_string(), + }; + let sd = |v: Option| match v { + Some(v) => format!("{v:.2} W"), + None => "n/a".to_string(), + }; + let (n, c) = (self.count.in_flight, self.count.completions); + let opening = match self.count.opening { + WindowOpening::FromRest => "window opened from rest", + WindowOpening::Running => "window opened on running workers", + }; + let work = match self.count.work() { + Some(w) => format!("{w:.1} {unit}s"), + None => "none (no completion)".to_string(), + }; + let mut o = String::new(); + let mut line = |label: String, value: String| o += &format!("{label:<20}: {value}\n"); + line(format!("{unit}s completed"), format!("{c} over {:.2}s on {n} threads ({opening})", self.window_s)); + line("work paid for".into(), work); + line( + "cpu power idle".into(), + format!("{:.2} W (sample sd {}, {} samples)", self.idle_mean(), sd(self.idle_sd()), self.idle_w.len()), + ); + line( + "cpu power busy".into(), + format!("{:.2} W (sample sd {}, {} samples)", self.busy_mean(), sd(self.busy_sd()), self.busy_w.len()), + ); + line("marginal (busy-idle)".into(), format!("{:.2} W", self.marginal_w())); + line("marginal / idle sd".into(), format!("{:.1}x ({})", self.ratio(), self.verdict())); + line("u_power (brackets)".into(), pct(self.u_power())); + line(format!("u_count ({n} in flight)"), pct(self.u_count())); + line( + "u_res (whole µJ)".into(), + match (self.u_resolution(), self.sealed_uj()) { + (Some(r), Some(f)) => format!("{:.2} % of the sealed {f} µJ", r * 100.0), + _ => "no figure to seal".into(), + }, + ); + line( + "declared uncertainty".into(), + match self.sealed_declaration() { + Some((f, u)) => format!( + "{:.2} % of the sealed {f} µJ (k=1, evaluated part only) over {:.2} s", + u.relative() * 100.0, + u.window_us as f64 / 1e6 + ), + None => format!("none — {}", self.unsealed_reason().unwrap_or("not evaluable from this run")), + }, + ); + o + } + + /// Warn on stderr when the ratio is below the §4 publication bar. + pub fn warn_if_unpublishable(&self) { + let r = self.ratio(); + if r < 10.0 { + eprintln!( + "WARNING: marginal draw {:.2} W against idle sd {} ({r:.1}x). Absolute \n\ + figures are only publishable at >= 10x; below that the inflated idle \n\ + baseline biases the result LOW by a factor that has been observed as \n\ + large as 21x. Close other work and re-run on a quiet box before quoting \n\ + this number.", + self.marginal_w(), + self.idle_sd().map_or("n/a".to_string(), |s| format!("{s:.2} W")), + ); + } + } +} + +/// The worker count the reference meters burst on: every core but one, at +/// most ten. +pub fn default_workers() -> usize { + std::thread::available_parallelism().map(|n| n.get().saturating_sub(1).clamp(1, 10)).unwrap_or(6) +} + +/// Run `work` continuously on `workers` threads that are held at a start +/// barrier and released together as the window opens; sample the busy bracket +/// with `sample_busy` while they run; close the window; stop the workers. +/// +/// Returns the busy samples, the window in seconds, and the completion count +/// with [`WindowOpening::FromRest`]: every completion counted happened inside +/// the window, and none of the window's work began before it. +pub fn burst_from_rest(workers: usize, item: &T, work: W, sample_busy: S) -> (Vec, f64, CompletionCount) +where + T: Clone + Send + 'static, + W: Fn(&T) + Send + Sync + 'static, + S: FnOnce() -> Vec, +{ + let workers = workers.max(1); + let work = Arc::new(work); + let stop = Arc::new(AtomicBool::new(false)); + let done = Arc::new(AtomicU64::new(0)); + let release = Arc::new(Barrier::new(workers + 1)); + let handles: Vec<_> = (0..workers) + .map(|_| { + let (work, stop, done, release, it) = + (work.clone(), stop.clone(), done.clone(), release.clone(), item.clone()); + std::thread::spawn(move || { + release.wait(); + // Counted per reconstruction, never per batch: a cheap payload + // runs millions of times and an expensive one a handful (§3). + while !stop.load(Ordering::Relaxed) { + work(&it); + done.fetch_add(1, Ordering::SeqCst); + } + }) + }) + .collect(); + release.wait(); + let t0 = Instant::now(); + let busy = sample_busy(); + let window_s = t0.elapsed().as_secs_f64(); + let completions = done.load(Ordering::SeqCst); + stop.store(true, Ordering::Relaxed); + for h in handles { + let _ = h.join(); + } + (busy, window_s, CompletionCount { in_flight: workers as u64, completions, opening: WindowOpening::FromRest }) +} + +/// The whole §3 protocol against the non-privileged IOReport reader +/// (`macmon`): an idle bracket, then a from-rest burst of `work` on +/// [`default_workers`] threads with a busy bracket over it. +/// +/// `None` when the reader is absent or returns too few samples to form an +/// idle bracket — no measurement at all, never an estimate. +pub fn measure_with_macmon(label: &str, item: &T, work: W) -> Option +where + T: Clone + Send + 'static, + W: Fn(&T) + Send + Sync + 'static, +{ + eprintln!(" {label}: idle baseline …"); + let idle_w = macmon_cpu_power(IDLE_SAMPLES); + if idle_w.len() < 3 { + return None; + } + let workers = default_workers(); + eprintln!(" {label}: burst on {workers} threads, released together …"); + let (busy_w, window_s, count) = burst_from_rest(workers, item, work, || macmon_cpu_power(BUSY_SAMPLES)); + Some(Bracket { idle_w, busy_w, window_s, count }) +} + +/// One sample burst from `macmon pipe` (the non-privileged IOReport reader), +/// returning its CPU-power readings in watts. Empty when `macmon` is absent. +pub fn macmon_cpu_power(n: u32) -> Vec { + let out = Command::new("macmon") + .args(["pipe", "-s", &n.to_string(), "-i", &SAMPLE_INTERVAL_MS.to_string()]) + .output(); + let Ok(out) = out else { return vec![] }; + String::from_utf8_lossy(&out.stdout).lines().filter_map(cpu_power_w).collect() +} + +/// Pull `"cpu_power": ` out of one `macmon` JSON line. +pub fn cpu_power_w(line: &str) -> Option { + let key = "\"cpu_power\":"; + let i = line.find(key)? + key.len(); + let rest = line[i..].trim_start(); + let end = rest.find([',', '}']).unwrap_or(rest.len()); + rest[..end].trim().parse().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bracket(idle: &[f64], busy: &[f64], window_s: f64, n: u64, c: u64) -> Bracket { + Bracket { + idle_w: idle.to_vec(), + busy_w: busy.to_vec(), + window_s, + count: CompletionCount { in_flight: n, completions: c, opening: WindowOpening::FromRest }, + } + } + + #[test] + fn the_figure_divides_by_the_work_the_window_paid_for() { + // 10 W marginal over 3 s = 30 J; 10 workers from rest, 60 completions: + // work 65, so 461,538 uJ per unit — not 500,000 (the raw count). + let b = bracket(&[19.9, 20.1, 19.9, 20.1], &[29.9, 30.1, 29.9, 30.1], 3.0, 10, 60); + let per = b.per_unit_uj().unwrap(); + assert!((per - 30.0 / 65.0 * 1e6).abs() < 1e-6, "{per}"); + let (uj, class) = b.on_chip_figure(CounterFiltering::Unknown).unwrap(); + assert_eq!(uj, 461_538); + let u = class.uncertainty().unwrap(); + let u_meas = b.u_power().unwrap().hypot(10.0 / (3f64.sqrt() * 130.0)); + let want = (per * u_meas).hypot(0.5 / 3f64.sqrt()) / 461_538.0; + assert_eq!(u.relative_ppm, (want * 1e6).ceil() as u32); + assert_eq!(u.window_us, 3_000_000); + } + + /// A bracket with a 10 W marginal whose per-unit estimate is `e` µJ: + /// the window and count are chosen so `10 W × window ÷ (C + N/2) = e`. + fn small(e: f64) -> Bracket { + let (n, c) = (10u64, 50_000_000u64); + let window_s = e * 1e-6 * (c as f64 + n as f64 / 2.0) / 10.0; + bracket(&[19.8, 20.2, 19.8, 20.2, 20.0], &[29.5, 30.5, 29.5, 30.5, 30.0], window_s, n, c) + } + + #[test] + fn the_declaration_covers_the_rounding_of_the_sealed_figure() { + // The cases that were sealed with only u_power and u_count declared: + // a 0.563 µJ estimate sealed as 1 (+78 %) under a 1.54 % declaration, + // and a 10.35 µJ estimate sealed as 10 (−3.4 %) under the same. + for (e, sealed) in [(0.563, 1u64), (10.35, 10), (1.49, 1), (42.6, 43), (461_538.46, 461_538)] { + let b = small(e); + let per = b.per_unit_uj().unwrap(); + assert!((per - e).abs() < 1e-9 * e.max(1.0), "{per} vs {e}"); + let (uj, class) = b.on_chip_figure(CounterFiltering::Unknown).unwrap(); + assert_eq!(uj, sealed); + let u = class.uncertainty().unwrap(); + // The declared absolute standard uncertainty, in µJ. + let u_abs = u.relative() * uj as f64; + // The rounding offset is known here and within the rectangular + // limit the declaration implies: |F − E| ≤ √3 · u. + assert!((uj as f64 - per).abs() <= 3f64.sqrt() * u_abs, "{e}: offset beyond √3 u"); + // It is never below the resolution term, nor below the + // measurement's own uncertainty carried across in µJ. + assert!(u_abs >= 0.5 / 3f64.sqrt()); + assert!(u_abs >= per * b.u_measurement().unwrap()); + // Every verifier's floor holds. + assert!(uj * u.relative_ppm as u64 >= crate::energy_class::RESOLUTION_FLOOR_UJ_PPM); + assert_eq!(check_resolution(uj, &class), Ok(())); + } + // The 1 µJ figure is dominated by its resolution: about 29 %. + let u = small(0.563).declared().unwrap(); + assert!(u.relative_ppm >= 288_676 && u.relative_ppm < 290_000, "{}", u.relative_ppm); + } + + #[test] + fn a_figure_that_rounds_to_zero_is_refused_with_its_reason() { + let b = small(0.49); + assert!(b.sealed_uj().is_none() && b.declared().is_none()); + assert!(b.on_chip_figure(CounterFiltering::Unknown).is_none()); + assert_eq!(b.unsealed_reason(), Some("the per-unit figure rounds to zero microjoules")); + assert_eq!(small(0.51).unsealed_reason(), None); + } + + #[test] + fn reported_standard_deviations_reproduce_the_declared_uncertainty() { + let b = bracket(&[19.8, 20.2, 19.8, 20.2, 20.0], &[29.5, 30.5, 29.5, 30.5, 30.0], 2.0, 4, 1000); + let (si, sb) = (b.idle_sd().unwrap(), b.busy_sd().unwrap()); + let (ni, nb) = (b.idle_w.len() as f64, b.busy_w.len() as f64); + let from_report = (si * si / ni + sb * sb / nb).sqrt() / b.marginal_w(); + assert!((from_report - b.u_power().unwrap()).abs() < 1e-12); + } + + #[test] + fn failures_to_measure_seal_nothing() { + // A reader returning only zeros: flat idle, zero marginal — unusable. + let zeros = bracket(&[0.0; 10], &[0.0; 14], 3.0, 10, 5000); + assert_eq!(zeros.ratio(), 0.0); + assert!(zeros.on_chip_figure(CounterFiltering::Unknown).is_none()); + // Busy never rose above a noisy idle: ratio below 1. + let noisy = bracket(&[18.0, 22.0, 18.0, 22.0], &[20.1, 20.3, 20.1, 20.3], 3.0, 10, 5000); + assert!(noisy.ratio() < 1.0); + assert!(noisy.on_chip_figure(CounterFiltering::Unknown).is_none()); + // No completion: no per-unit figure. + let none = bracket(&[19.9, 20.1, 19.9], &[29.9, 30.1, 29.9], 3.0, 10, 0); + assert!(none.per_unit_uj().is_none() && none.on_chip_figure(CounterFiltering::Unknown).is_none()); + // One sample in a bracket: no evaluable uncertainty, so no hardware class. + let one = bracket(&[20.0, 20.1, 19.9], &[30.0], 3.0, 10, 100); + assert!(one.declared().is_none() && one.on_chip_figure(CounterFiltering::Unknown).is_none()); + } + + #[test] + fn a_from_rest_burst_counts_only_work_begun_inside_the_window() { + // Each unit of work records that it started; the sampler records the + // count of starts at the moment the window opens. From rest, no work + // has started before the window opens. + let started = Arc::new(AtomicU64::new(0)); + let s2 = started.clone(); + let at_open = started.clone(); + let (_busy, window_s, count) = burst_from_rest( + 3, + &(), + move |_| { + s2.fetch_add(1, Ordering::SeqCst); + std::thread::sleep(std::time::Duration::from_millis(2)); + }, + move || { + // Called as the window opens: the workers may have begun the + // first unit, but none has completed one. + let begun = at_open.load(Ordering::SeqCst); + assert!(begun <= 3, "no more than one unit per worker can have begun"); + std::thread::sleep(std::time::Duration::from_millis(40)); + vec![1.0, 2.0] + }, + ); + assert_eq!(count.opening, WindowOpening::FromRest); + assert_eq!(count.in_flight, 3); + assert!(count.completions > 0 && window_s > 0.0); + // Every completion started inside the window: completions can never + // exceed starts, and starts exceed completions by at most N. + let starts = started.load(Ordering::SeqCst); + assert!(count.completions <= starts && starts <= count.completions + 3 + 3); + } + + #[test] + fn macmon_lines_parse() { + assert_eq!(cpu_power_w(r#"{"cpu_power": 12.5,"gpu_power":0.1}"#), Some(12.5)); + assert_eq!(cpu_power_w(r#"{"gpu_power":0.1}"#), None); + } +} diff --git a/wai/wai-rs/src/energy_class.rs b/wai/wai-rs/src/energy_class.rs new file mode 100644 index 000000000..36cdb37d1 --- /dev/null +++ b/wai/wai-rs/src/energy_class.rs @@ -0,0 +1,1432 @@ +//! The acquisition class a WAI energy figure is sealed beside — one +//! definition, shared by every receipt in this crate that carries a joule +//! figure. +//! +//! # Why the class exists +//! +//! A joule figure without a statement of how it was obtained cannot be compared +//! with another joule figure: the same number can be a counter reading, a model, +//! or a constant. So a receipt seals the figure *and* its class, and rejects the +//! two out of step (`extensions/energy-measurement.md` §2). +//! +//! # Two ways a receipt carries the class +//! +//! - **Always present.** The energy binding and its C2PA companion assertion, +//! the `wai.video.int_motion` receipt and the `wai.video.*` OTT receipts were +//! defined with a class from the start. The receipts write it through +//! [`write_optional`] (a `0` byte when unmetered) and enforce +//! [`figure_and_class_cohere`]: a non-zero figure without a class is invalid. +//! The binding has no unmetered form: its class is a required field, and it +//! refuses a zero figure outright (`crate::energy_binding`). +//! - **An optional label** (§2.8). The world-session, film and reel, haptic, +//! score, 4D-splat, audio-scene, ternary-reconstruction and provenance-step +//! receipts here, and the quantum receipts of `wai-quantum`, sealed their +//! figure with no class before the class existed, and those receipts must +//! keep verifying. They carry the class as a label: absent, the receipt's +//! signed bytes are exactly the legacy ones and the figure is *unlabelled*; +//! present, the signing domain's version byte becomes `0x02` +//! ([`labelled_domain`]) and the class follows the figure it labels +//! ([`write_label`]). A verifier accepts an unlabelled figure as legacy and +//! never reads it as any class — it is not upgraded to one — and checks a +//! present label with [`check_label`]. +//! +//! # Why hardware evidence is two classes, not one +//! +//! The first version of the ladder had a single hardware rung, `HwShunt`, for +//! "read from a hardware energy interface — RAPL, NVML, SMC, IOReport, or an +//! external shunt". That put two different kinds of evidence at one trust +//! level: +//! +//! - **An on-chip counter** is the measured silicon reporting on itself. It is +//! a real sensor reading, not an estimate. What it cannot offer is a +//! calibration traceable to a reference: its uncertainty is whatever its +//! vendor states or the implementation can evaluate, and some vendors add +//! deliberate noise to it (Intel's RAPL energy filtering, INTEL-SA-00389 — +//! typically under 2 % standard deviation over 1 s, 5–15 % over 100 ms). +//! - **An external calibrated instrument** is a separate meter whose error has +//! been established against a reference and written down in a certificate. +//! Signed per-transaction energy in EV charging shows the same split between +//! signature and trust: OCMF meter values are accepted under German +//! calibration law because they come from a conformity-assessed, legally +//! verified measuring system, not because of the signature, which only says +//! who produced the number. Legal verification is not the same statement as +//! a calibration certificate's uncertainty (`extensions/energy-measurement.md` +//! §2.2). +//! +//! IOReport, the SoC energy interface the reference meters read, is an on-chip +//! counter in this sense and is classed [`EnergyProvenance::OnChipCounter`]. +//! Its channels sit in a group named "Energy Model", and it is a private +//! interface whose derivation is not publicly documented. That is a statement +//! about the counter's undisclosed systematic error, which an `OnChipCounter` +//! declaration already leaves out (§2.3). It does not make the reading a model +//! this implementation computed, which is what +//! [`EnergyProvenance::ModelBased`] means. Documents +//! elsewhere in the monorepo class IOReport as `ModelBased` or `Estimator`; +//! `extensions/energy-measurement.md` §2.9 lists them. +//! +//! The gap between the two is **provenance and uncertainty**, not accuracy: a +//! counter is not a worse sensor by definition. So each refined class carries +//! a REQUIRED declared [`Uncertainty`] — a standard relative uncertainty plus +//! the integration window it holds over — and the class says what backs that +//! declaration and how much it covers: a calibration certificate +//! ([`EnergyProvenance::CalibratedInstrument`], with a reference to it), or the +//! implementation's own evaluation, which leaves out the counter's systematic +//! error ([`EnergyProvenance::OnChipCounter`], with the counter's filtering +//! state). +//! +//! Either way the declaration describes the figure as sealed, a whole number +//! of microjoules, so it includes that integer's own resolution: `0.5/√3` µJ +//! for a figure rounded from an estimate ([`sealed_figure`]), `1/√6` µJ for +//! the difference of two register readings +//! ([`REGISTER_DELTA_UNCERTAINTY_UJ`]). A verifier cannot see how a figure +//! was read, so it checks the smaller term, which every whole-microjoule +//! figure carries: `joules_micro × relative_ppm` must reach +//! [`RESOLUTION_FLOOR_UJ_PPM`] ([`check_resolution`]). Including the larger +//! term for a register delta is the producer's part; no verifier checks it. +//! +//! # Legacy `HwShunt` +//! +//! Receipts sealed before the split say `HwShunt`. They keep verifying: the +//! refined classes use new tag bytes and append their evidence *after* the tag, +//! so no byte of a legacy signing payload changes. `HwShunt` now means +//! "hardware, sub-class unspecified, no declared uncertainty" and ranks below +//! both refined classes. New receipts must not use it — the variant is +//! deprecated, so code that names it draws a warning, although a class parsed +//! from its label ([`EnergyProvenance::from_label`]) draws none — and the +//! meters in `crate::energy_meter` can no longer produce it. It is accepted only where +//! receipts carried it before the split: a receipt family that carries the +//! class as an optional label never did, so a label of `HwShunt` is refused +//! there ([`AcquisitionError::LegacyLabel`]). + +/// A declared uncertainty: the standard uncertainty (k = 1) the declarer +/// evaluated for the figure, and the integration window it holds over. +/// +/// It describes a dispersion, not a limit on the error, and what it covers +/// depends on the class (`extensions/energy-measurement.md` §2.3). For a +/// calibrated instrument it includes the instrument's calibrated error. For an +/// on-chip counter it includes only what the implementation could evaluate and +/// never the counter's own systematic error, so it is not the figure's total +/// uncertainty. It is not a tolerance band either, and must not be used to +/// select one (§2.1). +/// +/// Both parts are required. Counter noise depends on the window — a filtered +/// counter is several times noisier over 100 ms than over 1 s — so a relative +/// error with no window says nothing a verifier can use. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Uncertainty { + /// Standard relative uncertainty of the figure (coverage factor k = 1), in + /// parts per million of the figure. An expanded uncertainty `U` stated at + /// coverage factor `k` — the usual form on a calibration certificate — is + /// declared as `U / k`. Zero is not a declaration and is rejected. + pub relative_ppm: u32, + /// The integration window the figure was accumulated over, in + /// microseconds. Zero is not a window and is rejected. + pub window_us: u64, +} + +impl Uncertainty { + /// A checked declaration. + pub fn new(relative_ppm: u32, window_us: u64) -> Result { + let u = Uncertainty { relative_ppm, window_us }; + u.validate()?; + Ok(u) + } + + /// Declare a relative uncertainty given as a fraction (`0.02` = 2 %). + /// + /// Rounds **up** to the next whole ppm, so the declaration never states less + /// uncertainty than was evaluated. + pub fn from_relative(relative: f64, window_us: u64) -> Result { + if !relative.is_finite() || relative <= 0.0 { + return Err(AcquisitionError::NoUncertainty); + } + let ppm = (relative * 1e6).ceil(); + if ppm > u32::MAX as f64 { + return Err(AcquisitionError::UncertaintyOutOfRange); + } + Uncertainty::new(ppm as u32, window_us) + } + + /// The relative uncertainty as a fraction (`20_000` ppm = `0.02`). + pub fn relative(&self) -> f64 { + self.relative_ppm as f64 / 1e6 + } + + fn validate(&self) -> Result<(), AcquisitionError> { + if self.relative_ppm == 0 { + return Err(AcquisitionError::NoUncertainty); + } + if self.window_us == 0 { + return Err(AcquisitionError::NoWindow); + } + Ok(()) + } +} + +/// Whether an on-chip counter was adding deliberate noise to its readings. +/// +/// Some counters filter their output as a side-channel defence. On Intel +/// processors (INTEL-SA-00389) enabling SGX enables the filter in most cases, +/// and TDX uses the filtered energy information; software with MSR access can +/// read it as `IA32_MISC_PACKAGE_CTLS[0]` (`ENERGY_FILTERING_ENABLE`, which +/// cannot be cleared once set), supported when `IA32_ARCH_CAPABILITIES[11]` is +/// set. An unprivileged reader usually cannot tell, and says so. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CounterFiltering { + /// The reader could not determine the filtering state. A verifier reads + /// this as *possibly filtered*. The only honest value when the control + /// state is out of reach — absence of evidence is not `Off`. + Unknown, + /// The reader positively determined that no filtering was active. + Off, + /// The reader determined that filtering was active. + On, +} + +impl CounterFiltering { + /// The wire label. + pub fn label(self) -> &'static str { + match self { + CounterFiltering::Unknown => "Unknown", + CounterFiltering::Off => "Off", + CounterFiltering::On => "On", + } + } + + /// Parse a wire label. + pub fn from_label(s: &str) -> Option { + match s { + "Unknown" => Some(CounterFiltering::Unknown), + "Off" => Some(CounterFiltering::Off), + "On" => Some(CounterFiltering::On), + _ => None, + } + } + + fn tag_byte(self) -> u8 { + match self { + CounterFiltering::Unknown => 0, + CounterFiltering::Off => 1, + CounterFiltering::On => 2, + } + } +} + +/// How an energy figure was acquired. +/// +/// Ordered by what backs the figure's uncertainty statement, strongest first: +/// [`CalibratedInstrument`](Self::CalibratedInstrument) (a traceable +/// calibration), [`OnChipCounter`](Self::OnChipCounter) (the implementation's +/// own evaluation), legacy [`HwShunt`](Self::HwShunt) (a hardware reading with +/// no uncertainty declared), [`ModelBased`](Self::ModelBased), then +/// [`Estimator`](Self::Estimator). See [`EnergyProvenance::rank`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum EnergyProvenance { + /// **Legacy.** Read from a hardware energy interface, sub-class unspecified, + /// no uncertainty declared. Accepted from receipts sealed before the + /// hardware class was refined; ranked below both refined classes. + #[deprecated( + note = "legacy label: emit OnChipCounter or CalibratedInstrument with a declared uncertainty; \ + HwShunt is only for reading receipts sealed before the split" + )] + HwShunt, + /// Computed from a calibrated per-operation model (e.g. wall time × a power + /// constant). A real number, honestly labelled as derived. + ModelBased, + /// A coarse constant or load estimator. + Estimator, + /// Read from an energy counter the measured silicon keeps about itself — + /// RAPL, NVML, SMC, IOReport. A sensor reading, not an estimate; its + /// uncertainty is the implementation's own evaluation and is not traceable + /// to a calibration. Also the class for an external meter that holds no + /// current calibration: what separates the refined classes is whether the + /// uncertainty is traceable, and a meter's location alone does not make it + /// so. + OnChipCounter { + /// REQUIRED declared uncertainty: the part the implementation could + /// evaluate, which excludes the counter's own systematic error, and + /// includes the figure's own resolution. A verifier refuses one below + /// the `0.5/√3` µJ floor ([`check_resolution`]). + uncertainty: Uncertainty, + /// Whether the counter was filtering its output, where knowable. + filtering: CounterFiltering, + }, + /// Read from an instrument external to the measured device — a shunt, a + /// power analyser, a metering socket — that holds a current calibration + /// covering the range and conditions it was used in. + CalibratedInstrument { + /// REQUIRED declared uncertainty, from the calibration combined with the + /// measurement's own uncertainty (for a per-unit figure, the bracket + /// and completion-count terms of [`per_unit_relative_uncertainty`]) + /// and the figure's own resolution ([`sealed_figure`]). + uncertainty: Uncertainty, + /// A resolvable identifier of the calibration certificate covering the + /// instrument as used: a URI, or the issuing laboratory's certificate + /// number. It makes the calibration claim checkable, not true. + calibration_ref: String, + }, +} + +/// The class without its evidence — what a meter can promise before it has +/// measured anything. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AcquisitionKind { + HwShunt, + ModelBased, + Estimator, + OnChipCounter, + CalibratedInstrument, +} + +/// Why an acquisition class was refused. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AcquisitionError { + /// A refined hardware class declared no uncertainty (a relative error of + /// zero is a claim of a perfect measurement, and nothing is one). + NoUncertainty, + /// A refined hardware class declared an uncertainty without a window. + NoWindow, + /// A relative uncertainty too large to represent in ppm. + UncertaintyOutOfRange, + /// A calibrated-instrument class named no calibration certificate. + NoCalibrationRef, + /// A label this implementation does not know. + UnknownLabel, + /// Evidence fields attached to a class that does not carry them — e.g. an + /// uncertainty beside legacy `HwShunt`. Ambiguous, so refused. + UnexpectedEvidence, + /// Legacy `HwShunt` offered as the optional label of a receipt family that + /// never carried it. A new label must say which hardware evidence it has. + LegacyLabel, + /// A class beside a zero figure, which claims a measurement that did not + /// occur. + NoFigure, + /// A label offered with a key other than the one that signed the receipt. + /// Labelling re-signs the receipt, and only its signer may do that. + SignerMismatch, + /// A declared uncertainty below the resolution floor: a figure in whole + /// microjoules is uncertain by at least `0.5/√3` µJ, so + /// `joules_micro × relative_ppm` below [`RESOLUTION_FLOOR_UJ_PPM`] states + /// less uncertainty than the integer can carry (§2.3). + BelowResolution, +} + +impl core::fmt::Display for AcquisitionError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let s = match self { + AcquisitionError::NoUncertainty => { + "a hardware energy class must declare a non-zero relative uncertainty" + } + AcquisitionError::NoWindow => { + "a declared uncertainty must state the integration window it holds over" + } + AcquisitionError::UncertaintyOutOfRange => "relative uncertainty out of range", + AcquisitionError::NoCalibrationRef => { + "a calibrated-instrument figure must reference its calibration certificate" + } + AcquisitionError::UnknownLabel => "unknown acquisition class", + AcquisitionError::UnexpectedEvidence => { + "evidence fields attached to a class that does not carry them" + } + AcquisitionError::LegacyLabel => { + "HwShunt is a legacy label and cannot label a receipt that never carried it" + } + AcquisitionError::NoFigure => "a class beside a zero figure claims a measurement that did not occur", + AcquisitionError::SignerMismatch => { + "only the key that signed a receipt may label it" + } + AcquisitionError::BelowResolution => { + "the declared uncertainty is below the 0.5/√3 µJ (about 0.289 µJ) that rounding the figure to whole microjoules adds: joules_micro × relative_ppm must be at least 288 676" + } + }; + f.write_str(s) + } +} + +impl std::error::Error for AcquisitionError {} + +#[allow(deprecated)] // HwShunt is matched here so legacy receipts keep verifying. +impl EnergyProvenance { + /// An on-chip counter reading with its declared uncertainty. + pub fn on_chip_counter( + uncertainty: Uncertainty, + filtering: CounterFiltering, + ) -> Result { + let p = EnergyProvenance::OnChipCounter { uncertainty, filtering }; + p.validate()?; + Ok(p) + } + + /// A calibrated-instrument reading with its declared uncertainty and the + /// certificate backing it. + pub fn calibrated_instrument( + uncertainty: Uncertainty, + calibration_ref: impl Into, + ) -> Result { + let p = EnergyProvenance::CalibratedInstrument { + uncertainty, + calibration_ref: calibration_ref.into(), + }; + p.validate()?; + Ok(p) + } + + /// The wire label. + pub fn label(&self) -> &'static str { + match self { + EnergyProvenance::HwShunt => "HwShunt", + EnergyProvenance::ModelBased => "ModelBased", + EnergyProvenance::Estimator => "Estimator", + EnergyProvenance::OnChipCounter { .. } => "OnChipCounter", + EnergyProvenance::CalibratedInstrument { .. } => "CalibratedInstrument", + } + } + + /// The label in the three-rung vocabulary the wider receipt family uses. + /// Both refined classes read as `HwShunt` there: that loses the distinction + /// and asserts nothing false. + pub fn family_label(&self) -> &'static str { + match self { + EnergyProvenance::HwShunt + | EnergyProvenance::OnChipCounter { .. } + | EnergyProvenance::CalibratedInstrument { .. } => "HwShunt", + EnergyProvenance::ModelBased => "ModelBased", + EnergyProvenance::Estimator => "Estimator", + } + } + + /// The class without its evidence. + pub fn kind(&self) -> AcquisitionKind { + match self { + EnergyProvenance::HwShunt => AcquisitionKind::HwShunt, + EnergyProvenance::ModelBased => AcquisitionKind::ModelBased, + EnergyProvenance::Estimator => AcquisitionKind::Estimator, + EnergyProvenance::OnChipCounter { .. } => AcquisitionKind::OnChipCounter, + EnergyProvenance::CalibratedInstrument { .. } => { + AcquisitionKind::CalibratedInstrument + } + } + } + + /// Strength of what backs the figure's uncertainty statement; higher is + /// stronger. Used wherever a rule says "the weaker class applies". + pub fn rank(&self) -> u8 { + match self { + EnergyProvenance::CalibratedInstrument { .. } => 5, + EnergyProvenance::OnChipCounter { .. } => 4, + EnergyProvenance::HwShunt => 3, + EnergyProvenance::ModelBased => 2, + EnergyProvenance::Estimator => 1, + } + } + + /// The declared uncertainty, where the class carries one. + pub fn uncertainty(&self) -> Option { + match self { + EnergyProvenance::OnChipCounter { uncertainty, .. } + | EnergyProvenance::CalibratedInstrument { uncertainty, .. } => Some(*uncertainty), + _ => None, + } + } + + /// Whether the figure is a hardware reading of any sub-class. + pub fn is_hardware(&self) -> bool { + matches!( + self, + EnergyProvenance::HwShunt + | EnergyProvenance::OnChipCounter { .. } + | EnergyProvenance::CalibratedInstrument { .. } + ) + } + + /// Whether this is the legacy, unrefined hardware label. + pub fn is_legacy(&self) -> bool { + matches!(self, EnergyProvenance::HwShunt) + } + + /// The evidence rules: a refined class must carry a non-zero relative + /// uncertainty and window, and a calibrated instrument a non-empty + /// certificate reference. The unrefined classes carry no evidence and + /// always pass. + pub fn validate(&self) -> Result<(), AcquisitionError> { + match self { + EnergyProvenance::OnChipCounter { uncertainty, .. } => uncertainty.validate(), + EnergyProvenance::CalibratedInstrument { uncertainty, calibration_ref } => { + uncertainty.validate()?; + if calibration_ref.trim().is_empty() { + return Err(AcquisitionError::NoCalibrationRef); + } + Ok(()) + } + _ => Ok(()), + } + } + + /// The tag byte written into a signing payload. Tags 1–3 are the legacy + /// ladder and are followed by nothing; 4 and 5 are followed by their + /// evidence (see [`EnergyProvenance::write_signed`]). 0 is reserved for + /// "unmetered" (see [`write_optional`]). + pub fn tag_byte(&self) -> u8 { + match self { + EnergyProvenance::HwShunt => 1, + EnergyProvenance::ModelBased => 2, + EnergyProvenance::Estimator => 3, + EnergyProvenance::OnChipCounter { .. } => 4, + EnergyProvenance::CalibratedInstrument { .. } => 5, + } + } + + /// Append the class to a signing payload: the tag byte, then — for the + /// refined classes only — the evidence, so it is inside the signature and + /// cannot be restated in transit: + /// + /// ```text + /// tag 4 OnChipCounter : relative_ppm u32 BE · window_us u64 BE · filtering u8 + /// tag 5 CalibratedInstrument : relative_ppm u32 BE · window_us u64 BE · + /// ref_len u32 BE · calibration_ref (UTF-8) + /// ``` + /// + /// Tags 1–3 are followed by nothing, exactly as before the split, which is + /// why receipts sealed under the three-rung ladder verify unchanged. + pub fn write_signed(&self, o: &mut Vec) { + o.push(self.tag_byte()); + match self { + EnergyProvenance::OnChipCounter { uncertainty, filtering } => { + o.extend_from_slice(&uncertainty.relative_ppm.to_be_bytes()); + o.extend_from_slice(&uncertainty.window_us.to_be_bytes()); + o.push(filtering.tag_byte()); + } + EnergyProvenance::CalibratedInstrument { uncertainty, calibration_ref } => { + o.extend_from_slice(&uncertainty.relative_ppm.to_be_bytes()); + o.extend_from_slice(&uncertainty.window_us.to_be_bytes()); + o.extend_from_slice(&(calibration_ref.len() as u32).to_be_bytes()); + o.extend_from_slice(calibration_ref.as_bytes()); + } + _ => {} + } + } + + /// Parse a wire label into an unrefined class. The refined classes carry + /// evidence, so their labels alone are refused with + /// [`AcquisitionError::NoUncertainty`] — a caller parsing a refined class + /// must supply the evidence (see [`EnergyProvenance::from_parts`]). + pub fn from_label(label: &str) -> Result { + EnergyProvenance::from_parts(label, None, None, None) + } + + /// Rebuild a class from its wire parts, applying every evidence rule. This + /// is the single place a parsed class is admitted, so every carrier refuses + /// the same things: + /// + /// - a refined label with no uncertainty → [`AcquisitionError::NoUncertainty`]; + /// - a calibrated instrument with no reference → [`AcquisitionError::NoCalibrationRef`]; + /// - evidence beside a class that carries none (including legacy `HwShunt`) + /// → [`AcquisitionError::UnexpectedEvidence`]. + /// + /// `filtering` absent on an `OnChipCounter` reads as + /// [`CounterFiltering::Unknown`] — the value an unprivileged reader reports. + pub fn from_parts( + label: &str, + uncertainty: Option, + filtering: Option, + calibration_ref: Option<&str>, + ) -> Result { + let unrefined = |p: EnergyProvenance| { + if uncertainty.is_some() || filtering.is_some() || calibration_ref.is_some() { + Err(AcquisitionError::UnexpectedEvidence) + } else { + Ok(p) + } + }; + match label { + "HwShunt" => unrefined(EnergyProvenance::HwShunt), + "ModelBased" => unrefined(EnergyProvenance::ModelBased), + "Estimator" => unrefined(EnergyProvenance::Estimator), + "OnChipCounter" => { + if calibration_ref.is_some() { + return Err(AcquisitionError::UnexpectedEvidence); + } + let uncertainty = uncertainty.ok_or(AcquisitionError::NoUncertainty)?; + EnergyProvenance::on_chip_counter( + uncertainty, + filtering.unwrap_or(CounterFiltering::Unknown), + ) + } + "CalibratedInstrument" => { + if filtering.is_some() { + return Err(AcquisitionError::UnexpectedEvidence); + } + let uncertainty = uncertainty.ok_or(AcquisitionError::NoUncertainty)?; + let r = calibration_ref.ok_or(AcquisitionError::NoCalibrationRef)?; + EnergyProvenance::calibrated_instrument(uncertainty, r) + } + _ => Err(AcquisitionError::UnknownLabel), + } + } +} + +/// Append an optional class to a signing payload: `0` when unmetered, +/// otherwise [`EnergyProvenance::write_signed`]. The receipts defined with a +/// class from the start — the `wai.video.int_motion` receipt and the +/// `wai.video.*` OTT receipts — write it through this one function, so they +/// cannot drift apart. The families that carry an optional label use +/// [`write_label`] instead, which writes nothing when unlabelled. +pub fn write_optional(p: Option<&EnergyProvenance>, o: &mut Vec) { + match p { + None => o.push(0), + Some(p) => p.write_signed(o), + } +} + +/// The figure/class coupling the receipts defined with a class from the start +/// enforce in `verify()` (the `wai.video.int_motion` and `wai.video.*` OTT +/// receipts): +/// +/// - unmetered (`0`, no class) is valid; +/// - a non-zero figure must carry a class, the class must pass +/// [`EnergyProvenance::validate`], and a declared uncertainty must reach the +/// resolution floor ([`check_resolution`]); +/// - a class beside a zero figure claims a measurement that did not occur. +pub fn figure_and_class_cohere(joules_micro: u64, p: Option<&EnergyProvenance>) -> bool { + match (p, joules_micro) { + (None, 0) => true, + (Some(p), n) if n > 0 => p.validate().is_ok() && check_resolution(n, p).is_ok(), + _ => false, + } +} + +/// The version byte every legacy signing domain in this crate and in +/// `wai-quantum` ends with, e.g. `b"wai:world-receipt\x01"`. +pub(crate) const LEGACY_DOMAIN_VERSION: u8 = 0x01; + +/// The version byte a signing domain ends with when the receipt carries a +/// label (§2.8). +pub(crate) const LABELLED_DOMAIN_VERSION: u8 = 0x02; + +/// The signing domain of a receipt that carries its class as an optional +/// label (`extensions/energy-measurement.md` §2.8): the family's domain, +/// unchanged when unlabelled, and with its final version byte `0x01` replaced +/// by `0x02` when labelled. +/// +/// The version byte keeps the two payload spaces disjoint from their first +/// bytes, so no labelled payload can equal an unlabelled one and a label can +/// be neither added to nor stripped from a signed receipt. +pub fn labelled_domain(domain: &[u8], label: Option<&EnergyProvenance>) -> Vec { + let mut d = domain.to_vec(); + if label.is_some() { + match d.last_mut() { + Some(v) if *v == LEGACY_DOMAIN_VERSION => *v = LABELLED_DOMAIN_VERSION, + _ => panic!("a labelled signing domain must end in the legacy version byte 0x01"), + } + } + d +} + +/// Append an optional label to a signing payload, immediately after the +/// figure it labels: nothing when unlabelled, so the legacy payload is +/// unchanged, and [`EnergyProvenance::write_signed`] when labelled. The +/// encoding is self-delimiting (the tag fixes the evidence's length, and a +/// calibration reference is length-prefixed), so the fields that follow keep +/// their legacy framing. +pub fn write_label(label: Option<&EnergyProvenance>, o: &mut Vec) { + if let Some(p) = label { + p.write_signed(o); + } +} + +/// The rule a verifier applies to an optional label (§2.8): +/// +/// - absent: an unlabelled legacy figure, accepted as such — it is not read +/// as any class; +/// - present: never legacy `HwShunt` ([`AcquisitionError::LegacyLabel`]), +/// never beside a zero figure ([`AcquisitionError::NoFigure`]), it must +/// pass [`EnergyProvenance::validate`], and a declared uncertainty must +/// reach the resolution floor ([`check_resolution`]). +pub fn check_label(joules_micro: u64, label: Option<&EnergyProvenance>) -> Result<(), AcquisitionError> { + let Some(p) = label else { return Ok(()) }; + if p.is_legacy() { + return Err(AcquisitionError::LegacyLabel); + } + if joules_micro == 0 { + return Err(AcquisitionError::NoFigure); + } + p.validate()?; + check_resolution(joules_micro, p) +} + +/// [`check_label`] as a predicate, for `verify()`. +pub fn label_coheres(joules_micro: u64, label: Option<&EnergyProvenance>) -> bool { + check_label(joules_micro, label).is_ok() +} + +/// JSON field carrying the class label (§2.7). Every evidence field is +/// `energy_`-prefixed as well, so no class field can collide with a field a +/// receipt already renders (a quantum job receipt's `calibration_ref` names the +/// calibration receipt it ran under, not a certificate). +pub const JSON_CLASS: &str = "energy_provenance"; +/// JSON field carrying a refined class's declared uncertainty, as an object +/// `{"relative_ppm": u32, "window_us": u64}`. +pub const JSON_UNCERTAINTY: &str = "energy_uncertainty"; +/// JSON field carrying an `OnChipCounter`'s filtering state. +pub const JSON_FILTERING: &str = "energy_counter_filtering"; +/// JSON field carrying a `CalibratedInstrument`'s calibration reference. +pub const JSON_CALIBRATION_REF: &str = "energy_calibration_ref"; + +/// The JSON fields a class is rendered as: its label, and for a refined class +/// its evidence. The names match the rendered `wai.video.int_motion` receipt. +pub fn json_fields(p: &EnergyProvenance) -> Vec<(&'static str, serde_json::Value)> { + use serde_json::{json, Value}; + let mut f = vec![(JSON_CLASS, Value::from(p.label()))]; + match p { + EnergyProvenance::OnChipCounter { uncertainty, filtering } => { + f.push(( + JSON_UNCERTAINTY, + json!({"relative_ppm": uncertainty.relative_ppm, "window_us": uncertainty.window_us}), + )); + f.push((JSON_FILTERING, Value::from(filtering.label()))); + } + EnergyProvenance::CalibratedInstrument { uncertainty, calibration_ref } => { + f.push(( + JSON_UNCERTAINTY, + json!({"relative_ppm": uncertainty.relative_ppm, "window_us": uncertainty.window_us}), + )); + f.push((JSON_CALIBRATION_REF, Value::from(calibration_ref.as_str()))); + } + _ => {} + } + f +} + +/// Insert `fields` into a JSON object, each at its sorted-key position among +/// the existing keys, leaving every existing key where it was. A leading +/// `"kind"` key stays first, as every receipt here renders it. +pub fn insert_sorted( + obj: serde_json::Map, + mut fields: Vec<(&'static str, serde_json::Value)>, +) -> serde_json::Map { + fields.sort_by(|a, b| a.0.cmp(b.0)); + let mut pending = fields.into_iter().peekable(); + let mut out = serde_json::Map::new(); + for (i, (k, v)) in obj.into_iter().enumerate() { + if !(i == 0 && k == "kind") { + while let Some((nk, _)) = pending.peek() { + if *nk < k.as_str() { + let (nk, nv) = pending.next().unwrap(); + out.insert(nk.to_string(), nv); + } else { + break; + } + } + } + out.insert(k, v); + } + for (nk, nv) in pending { + out.insert(nk.to_string(), nv); + } + out +} + +/// Render a receipt's JSON with its optional label. Unlabelled, the input is +/// returned unchanged, so a legacy receipt renders byte-identically. Labelled, +/// the class and its evidence are inserted at their sorted-key positions. +pub fn json_with_label(json: String, label: Option<&EnergyProvenance>) -> String { + let Some(p) = label else { return json }; + let v: serde_json::Value = serde_json::from_str(&json).expect("a receipt renders valid JSON"); + let serde_json::Value::Object(obj) = v else { panic!("a receipt renders a JSON object") }; + serde_json::Value::Object(insert_sorted(obj, json_fields(p))).to_string() +} + +/// Read an optional label from a receipt's JSON object. An absent (or null) +/// class with no evidence fields is an unlabelled figure: `Ok(None)`. Evidence +/// without a class is ambiguous and refused, and a present class is admitted +/// only through [`EnergyProvenance::from_parts`], so JSON cannot carry a class +/// the signed encoding would refuse. +pub fn label_from_json( + o: &serde_json::Map, +) -> Result, AcquisitionError> { + use serde_json::Value; + let present = |k: &str| !matches!(o.get(k), None | Some(Value::Null)); + let label = match o.get(JSON_CLASS) { + None | Some(Value::Null) => { + if present(JSON_UNCERTAINTY) || present(JSON_FILTERING) || present(JSON_CALIBRATION_REF) { + return Err(AcquisitionError::UnexpectedEvidence); + } + return Ok(None); + } + Some(Value::String(s)) => s.as_str(), + Some(_) => return Err(AcquisitionError::UnknownLabel), + }; + let uncertainty = match o.get(JSON_UNCERTAINTY) { + None | Some(Value::Null) => None, + Some(Value::Object(u)) => { + let ppm = u.get("relative_ppm").and_then(Value::as_u64).ok_or(AcquisitionError::NoUncertainty)?; + let ppm = u32::try_from(ppm).map_err(|_| AcquisitionError::UncertaintyOutOfRange)?; + let win = u.get("window_us").and_then(Value::as_u64).ok_or(AcquisitionError::NoWindow)?; + Some(Uncertainty::new(ppm, win)?) + } + Some(_) => return Err(AcquisitionError::NoUncertainty), + }; + let filtering = match o.get(JSON_FILTERING) { + None | Some(Value::Null) => None, + Some(Value::String(s)) => Some(CounterFiltering::from_label(s).ok_or(AcquisitionError::UnknownLabel)?), + Some(_) => return Err(AcquisitionError::UnknownLabel), + }; + let calibration_ref = match o.get(JSON_CALIBRATION_REF) { + None | Some(Value::Null) => None, + Some(Value::String(s)) => Some(s.as_str()), + Some(_) => return Err(AcquisitionError::NoCalibrationRef), + }; + EnergyProvenance::from_parts(label, uncertainty, filtering, calibration_ref).map(Some) +} + +/// The same class in the vocabulary of `wai-quantum`'s receipt labels +/// (`wai_quantum::quantum_energy::EnergyClass`), whose signed encoding is +/// byte-identical. Legacy `HwShunt` has no counterpart there — no quantum +/// receipt ever carried it — and is refused. +#[cfg(any(feature = "quantum_receipt", feature = "quantum_ops", feature = "quantum_phasor_meter"))] +pub fn to_quantum_class( + p: &EnergyProvenance, +) -> Result { + use wai_quantum::quantum_energy as q; + let u = |u: &Uncertainty| q::Uncertainty { relative_ppm: u.relative_ppm, window_us: u.window_us }; + #[allow(deprecated)] + let c = match p { + EnergyProvenance::HwShunt => return Err(AcquisitionError::LegacyLabel), + EnergyProvenance::ModelBased => q::EnergyClass::ModelBased, + EnergyProvenance::Estimator => q::EnergyClass::Estimator, + EnergyProvenance::OnChipCounter { uncertainty, filtering } => q::EnergyClass::OnChipCounter { + uncertainty: u(uncertainty), + filtering: match filtering { + CounterFiltering::Unknown => q::CounterFiltering::Unknown, + CounterFiltering::Off => q::CounterFiltering::Off, + CounterFiltering::On => q::CounterFiltering::On, + }, + }, + EnergyProvenance::CalibratedInstrument { uncertainty, calibration_ref } => { + q::EnergyClass::CalibratedInstrument { uncertainty: u(uncertainty), calibration_ref: calibration_ref.clone() } + } + }; + Ok(c) +} + +/// The statistical (Type A) relative standard uncertainty of a bracketed +/// marginal power draw — `u_power` in `extensions/energy-measurement.md` §2.3: +/// +/// `u_power = sqrt(s_idle² / n_idle + s_busy² / n_busy) / (mean_busy − mean_idle)` +/// +/// with `s` the sample standard deviation (n − 1). +/// +/// `None` when it cannot be evaluated — fewer than two samples in either +/// bracket, or no positive marginal draw. +/// +/// This is the power component only. A per-unit figure divides the marginal +/// energy by a completion count, which adds its own term +/// ([`completion_count_relative_uncertainty`]); declare the combination +/// ([`per_unit_relative_uncertainty`]), not this alone. Neither includes the +/// counter's own systematic error, which an on-chip counter's vendor rarely +/// states, nor the one-directional idle-baseline bias §4 guards with its ratio +/// rule. +pub fn bracket_relative_uncertainty(idle_w: &[f64], busy_w: &[f64]) -> Option { + if idle_w.len() < 2 || busy_w.len() < 2 { + return None; + } + let mean = |v: &[f64]| v.iter().sum::() / v.len() as f64; + let var = |v: &[f64]| { + let m = mean(v); + v.iter().map(|x| (x - m).powi(2)).sum::() / (v.len() - 1) as f64 + }; + let marginal = mean(busy_w) - mean(idle_w); + if marginal.is_nan() || marginal <= 0.0 { + return None; + } + let u = (var(idle_w) / idle_w.len() as f64 + var(busy_w) / busy_w.len() as f64).sqrt(); + let rel = u / marginal; + rel.is_finite().then_some(rel) +} + +/// How the timed window's opening edge meets the burst's workers — which +/// decides what the completion count can be off by (§2.3, `u_count`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WindowOpening { + /// The workers were already running when the window opened. Up to `N` + /// reconstructions are part-done at *each* edge, so the work the window + /// paid for lies within `N` of the count in either direction. Centred on + /// the count only if the workers' progress at the opening edge is spread + /// over a whole reconstruction; a window opened the moment the workers + /// start is not in this case (it is [`WindowOpening::FromRest`] without the + /// barrier, and its count reads low). + Running, + /// Every worker was held at a start barrier and released as the window + /// opened, so no reconstruction began before it. The opening edge + /// contributes nothing, and only the closing edge's part-done work is + /// uncounted: the work lies in `[C, C + N)` — the count reads low, never + /// high. + FromRest, +} + +/// A bracketed burst's completion count, and what is known about its edges. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CompletionCount { + /// `N`: reconstructions that can be in progress at once — the worker count. + pub in_flight: u64, + /// `C`: reconstructions counted as completing inside the window. + pub completions: u64, + /// How the window opened. + pub opening: WindowOpening, +} + +impl CompletionCount { + /// The best estimate of the reconstructions the window's energy paid for, + /// in reconstructions: `C` for a [`WindowOpening::Running`] window, and the + /// midpoint `C + N/2` of `[C, C + N)` for [`WindowOpening::FromRest`]. + /// + /// `None` when `in_flight` or `completions` is zero: with no worker there + /// was no burst, and with no completion there is no per-unit figure. + pub fn work(&self) -> Option { + if self.in_flight == 0 || self.completions == 0 { + return None; + } + let (n, c) = (self.in_flight as f64, self.completions as f64); + Some(match self.opening { + WindowOpening::Running => c, + WindowOpening::FromRest => c + n / 2.0, + }) + } + + /// The standard uncertainty of [`CompletionCount::work`], in + /// reconstructions, taking the error as rectangular over its known limit + /// (GUM §4.3.7): `N/√3` over `(−N, +N)` for a running window, `N/(2√3)` + /// over `[0, N)` for one opened from rest. + /// + /// From rest, the rectangular model is exact when the workers' progress at + /// the closing edge moves together and falls anywhere in a reconstruction + /// with equal likelihood — identical payloads released together do move + /// together — and conservative when their progress is spread. On running + /// workers it is conservative either way. + pub fn standard_uncertainty(&self) -> Option { + self.work()?; + let n = self.in_flight as f64; + Some(match self.opening { + WindowOpening::Running => n / 3f64.sqrt(), + WindowOpening::FromRest => n / (2.0 * 3f64.sqrt()), + }) + } + + /// `u_count`: the relative standard uncertainty the count contributes to + /// a per-unit figure, [`CompletionCount::standard_uncertainty`] ÷ + /// [`CompletionCount::work`]. `N/(√3·C)` running; `N/(√3·(2C + N))` from + /// rest. + pub fn relative_uncertainty(&self) -> Option { + let rel = self.standard_uncertainty()? / self.work()?; + rel.is_finite().then_some(rel) + } +} + +/// The completion-count component of a per-unit figure's relative standard +/// uncertainty for a window opened on running workers — `u_count` in +/// `extensions/energy-measurement.md` §2.3: +/// +/// `u_count = in_flight / (√3 · completions)` +/// +/// A burst on `in_flight` concurrent workers has up to that many +/// reconstructions part-done at each edge of the timed window. The count read +/// at the two edges cannot see them, so the work the window's energy actually +/// paid for lies within `in_flight` reconstructions of `completions`, in either +/// direction. With only that limit known, the error is treated as rectangular +/// over it (GUM §4.3.7). +/// +/// The count is exact as a count and inexact as a measure of work. The term +/// shrinks only as `completions` grows, so it is large when an expensive +/// payload completes only a handful of times: ten workers and sixty +/// completions give about 9.6 %. +/// +/// This is [`CompletionCount::relative_uncertainty`] for +/// [`WindowOpening::Running`]. A window opened from rest has a one-sided error +/// and a different estimate; see [`WindowOpening::FromRest`]. +/// +/// `None` when `in_flight` or `completions` is zero: with no worker there was +/// no burst, and with no completion there is no per-unit figure. +pub fn completion_count_relative_uncertainty(in_flight: u64, completions: u64) -> Option { + CompletionCount { in_flight, completions, opening: WindowOpening::Running }.relative_uncertainty() +} + +/// The measurement's own relative standard uncertainty for a per-unit +/// estimate from the bracketed marginal protocol +/// (`extensions/energy-measurement.md` §2.3): the bracket's power component +/// and the completion-count component, combined in quadrature, +/// +/// `u_meas = sqrt(u_power² + u_count²)`. +/// +/// The estimate it describes is the marginal energy divided by +/// [`CompletionCount::work`], not by the raw count. It is relative to that +/// estimate, not to the whole-microjoule figure a receipt seals: the +/// declaration adds the rounding to that figure ([`sealed_figure`]), and +/// declaring `u_meas` alone understates a small figure's uncertainty. +/// +/// `None` when either component cannot be evaluated. The figure then has no +/// declarable uncertainty and must not be sealed under a hardware class. +pub fn per_unit_relative_uncertainty( + idle_w: &[f64], + busy_w: &[f64], + count: &CompletionCount, +) -> Option { + let power = bracket_relative_uncertainty(idle_w, busy_w)?; + let count = count.relative_uncertainty()?; + let rel = power.hypot(count); + rel.is_finite().then_some(rel) +} + +/// The standard uncertainty, in microjoules, that sealing an estimate as a +/// whole number of microjoules adds (`extensions/energy-measurement.md` §2.3, +/// `u_res`). `joules_micro` is an integer, so a figure rounded to the nearest +/// microjoule lies within ±0.5 µJ of the estimate it came from. With only that +/// limit known the error is taken as rectangular (GUM F.2.2.1): `0.5/√3` µJ. +/// +/// It is the same in microjoules whatever the figure, so relative to the +/// figure it is large when the figure is small: 29 % of 1 µJ, 2.9 % of 10 µJ. +pub const RESOLUTION_UNCERTAINTY_UJ: f64 = 0.288_675_134_594_812_9; + +/// The standard uncertainty, in microjoules, of the difference of two readings +/// of a register that counts whole microjoules. Each reading stands for an +/// energy up to one step away from it; two such offsets, each rectangular over +/// one step, differ by a triangular error over (−1, +1) µJ (GUM 4.3.9): +/// `1/√6` µJ. This is the resolution of a figure read as a counter delta. +pub const REGISTER_DELTA_UNCERTAINTY_UJ: f64 = 0.408_248_290_463_863_1; + +/// The least `joules_micro × relative_ppm` a declaration may have (§2.3). A +/// declared relative uncertainty `ppm` on a figure of `F` µJ is an absolute +/// standard uncertainty of `F · ppm / 10⁶` µJ, which must be at least the +/// least resolution a whole-microjoule figure carries, +/// [`RESOLUTION_UNCERTAINTY_UJ`]. That is `F · ppm ≥ 288 675.13…`, and since +/// `F · ppm` is an integer, `≥ 288 676`. A figure read as a register delta +/// carries more, [`REGISTER_DELTA_UNCERTAINTY_UJ`], which this floor does not +/// check. +/// +/// It is checkable from the receipt alone, in integers, so every verifier +/// reaches the same verdict. +pub const RESOLUTION_FLOOR_UJ_PPM: u64 = 288_676; + +/// Whether a declared uncertainty reaches the resolution floor (§2.3): the +/// `0.5/√3` µJ that every whole-microjoule figure carries. A verifier cannot +/// see how a figure was read, so this does not check the larger resolution of +/// a register delta ([`REGISTER_DELTA_UNCERTAINTY_UJ`]). A class that declares +/// no uncertainty — `ModelBased`, `Estimator`, legacy `HwShunt` — has nothing +/// to check. +/// +/// A declaration below the floor cannot be honest: whatever the measurement, +/// the integer figure carries at least `0.5/√3` µJ from its own rounding. +pub fn check_resolution(joules_micro: u64, p: &EnergyProvenance) -> Result<(), AcquisitionError> { + match p.uncertainty() { + Some(u) if (joules_micro as u128) * (u.relative_ppm as u128) < RESOLUTION_FLOOR_UJ_PPM as u128 => { + Err(AcquisitionError::BelowResolution) + } + _ => Ok(()), + } +} + +/// Seal an estimate as a whole-microjoule figure and give the relative +/// standard uncertainty to declare for it (§2.3): +/// +/// ```text +/// F = round(E) the figure sealed, in µJ +/// u_rel = sqrt((E · u_meas)² + (0.5/√3)²) / F declared, relative to F +/// ``` +/// +/// `E` is the estimate in microjoules and `u_meas` its relative standard +/// uncertainty (relative to `E`, e.g. [`per_unit_relative_uncertainty`]). The +/// declaration describes the figure a reader sees, `F`: the measurement's own +/// uncertainty is carried across in microjoules, and the ±0.5 µJ rounding from +/// `E` to `F` is added to it ([`RESOLUTION_UNCERTAINTY_UJ`]). Rounded up to +/// whole ppm ([`Uncertainty::from_relative`]) it always meets +/// [`RESOLUTION_FLOOR_UJ_PPM`]. +/// +/// `None` when `E` rounds to zero — §4 makes that a failure to measure, not a +/// measurement of nothing — or when `E` or `u_meas` is negative or not finite. +pub fn sealed_figure(estimate_uj: f64, u_meas: f64) -> Option<(u64, f64)> { + if !estimate_uj.is_finite() || !u_meas.is_finite() || u_meas < 0.0 { + return None; + } + let f = estimate_uj.round(); + if f < 1.0 || f >= u64::MAX as f64 { + return None; + } + let rel = (estimate_uj * u_meas).hypot(RESOLUTION_UNCERTAINTY_UJ) / f; + rel.is_finite().then_some((f as u64, rel)) +} + +/// A valid on-chip reading for other modules' tests: a 3 % standard +/// uncertainty over a 2 s window, filtering state not determinable. +#[cfg(test)] +#[allow(dead_code)] // used only by the tests of feature-gated receipts +pub(crate) fn test_on_chip() -> EnergyProvenance { + EnergyProvenance::on_chip_counter( + Uncertainty { relative_ppm: 30_000, window_us: 2_000_000 }, + CounterFiltering::Unknown, + ) + .unwrap() +} + +#[cfg(test)] +#[allow(deprecated)] +mod tests { + use super::*; + + fn u(ppm: u32, win: u64) -> Uncertainty { + Uncertainty { relative_ppm: ppm, window_us: win } + } + + #[test] + fn refined_classes_require_an_uncertainty_and_a_window() { + assert_eq!(Uncertainty::new(0, 1_000_000), Err(AcquisitionError::NoUncertainty)); + assert_eq!(Uncertainty::new(20_000, 0), Err(AcquisitionError::NoWindow)); + // Bypassing the checked constructor does not bypass validation. + let p = EnergyProvenance::OnChipCounter { uncertainty: u(0, 1), filtering: CounterFiltering::Off }; + assert_eq!(p.validate(), Err(AcquisitionError::NoUncertainty)); + let p = EnergyProvenance::CalibratedInstrument { uncertainty: u(5_000, 0), calibration_ref: "c".into() }; + assert_eq!(p.validate(), Err(AcquisitionError::NoWindow)); + } + + #[test] + fn a_calibrated_instrument_must_name_its_certificate() { + assert_eq!( + EnergyProvenance::calibrated_instrument(u(5_000, 1_000_000), " "), + Err(AcquisitionError::NoCalibrationRef) + ); + assert!(EnergyProvenance::calibrated_instrument(u(5_000, 1_000_000), "lab-cert-0412").is_ok()); + } + + #[test] + fn from_relative_rounds_up_and_refuses_non_declarations() { + let d = Uncertainty::from_relative(0.0200001, 1_000_000).unwrap(); + assert_eq!(d.relative_ppm, 20_001, "never declares less than was evaluated"); + assert_eq!(Uncertainty::from_relative(0.0, 1), Err(AcquisitionError::NoUncertainty)); + assert_eq!(Uncertainty::from_relative(f64::NAN, 1), Err(AcquisitionError::NoUncertainty)); + assert_eq!(Uncertainty::from_relative(1e9, 1), Err(AcquisitionError::UncertaintyOutOfRange)); + } + + #[test] + fn legacy_tags_are_followed_by_nothing() { + for (p, tag) in [ + (EnergyProvenance::HwShunt, 1u8), + (EnergyProvenance::ModelBased, 2), + (EnergyProvenance::Estimator, 3), + ] { + let mut o = Vec::new(); + p.write_signed(&mut o); + assert_eq!(o, vec![tag], "{} must encode exactly as before the split", p.label()); + } + let mut o = Vec::new(); + write_optional(None, &mut o); + assert_eq!(o, vec![0]); + } + + #[test] + fn refined_evidence_is_inside_the_encoding() { + let a = EnergyProvenance::OnChipCounter { uncertainty: u(20_000, 1_000_000), filtering: CounterFiltering::Unknown }; + let mut o = Vec::new(); + a.write_signed(&mut o); + let mut want = vec![4u8]; + want.extend_from_slice(&20_000u32.to_be_bytes()); + want.extend_from_slice(&1_000_000u64.to_be_bytes()); + want.push(0); + assert_eq!(o, want); + + // Every evidence field changes the bytes, so none can be restated + // without breaking a signature over them. + let variants = [ + EnergyProvenance::OnChipCounter { uncertainty: u(20_001, 1_000_000), filtering: CounterFiltering::Unknown }, + EnergyProvenance::OnChipCounter { uncertainty: u(20_000, 100_000), filtering: CounterFiltering::Unknown }, + EnergyProvenance::OnChipCounter { uncertainty: u(20_000, 1_000_000), filtering: CounterFiltering::On }, + ]; + for v in variants { + let mut b = Vec::new(); + v.write_signed(&mut b); + assert_ne!(b, o); + } + + let c = EnergyProvenance::CalibratedInstrument { uncertainty: u(5_000, 60_000_000), calibration_ref: "cert-1".into() }; + let mut o = Vec::new(); + c.write_signed(&mut o); + assert_eq!(o[0], 5); + assert_eq!(&o[13..17], &6u32.to_be_bytes()); + assert_eq!(&o[17..], b"cert-1"); + } + + #[test] + fn the_coupling_rejects_what_it_always_rejected_and_undeclared_evidence() { + let ok = EnergyProvenance::OnChipCounter { uncertainty: u(30_000, 2_000_000), filtering: CounterFiltering::Unknown }; + assert!(figure_and_class_cohere(0, None)); + assert!(figure_and_class_cohere(10, Some(&ok))); + assert!(figure_and_class_cohere(10, Some(&EnergyProvenance::HwShunt)), "legacy still verifies"); + assert!(!figure_and_class_cohere(10, None), "unlabelled figure"); + assert!(!figure_and_class_cohere(0, Some(&ok)), "labelled zero"); + let undeclared = EnergyProvenance::OnChipCounter { uncertainty: u(0, 2_000_000), filtering: CounterFiltering::Unknown }; + assert!(!figure_and_class_cohere(10, Some(&undeclared)), "refined class with no uncertainty"); + } + + #[test] + fn parsing_admits_legacy_and_refuses_undeclared_refinements() { + assert_eq!(EnergyProvenance::from_label("HwShunt"), Ok(EnergyProvenance::HwShunt)); + assert_eq!(EnergyProvenance::from_label("OnChipCounter"), Err(AcquisitionError::NoUncertainty)); + assert_eq!(EnergyProvenance::from_label("CalibratedInstrument"), Err(AcquisitionError::NoUncertainty)); + assert_eq!(EnergyProvenance::from_label("Shunt"), Err(AcquisitionError::UnknownLabel)); + assert_eq!( + EnergyProvenance::from_parts("HwShunt", Some(u(1, 1)), None, None), + Err(AcquisitionError::UnexpectedEvidence), + "legacy plus half the new evidence is ambiguous" + ); + assert_eq!( + EnergyProvenance::from_parts("CalibratedInstrument", Some(u(5_000, 1)), None, None), + Err(AcquisitionError::NoCalibrationRef) + ); + assert_eq!( + EnergyProvenance::from_parts("OnChipCounter", Some(u(5_000, 1)), None, None), + Ok(EnergyProvenance::OnChipCounter { uncertainty: u(5_000, 1), filtering: CounterFiltering::Unknown }), + "an unstated filtering state is Unknown, never Off" + ); + } + + #[test] + fn legacy_ranks_below_both_refinements_and_reads_as_hwshunt_to_the_family() { + let chip = EnergyProvenance::OnChipCounter { uncertainty: u(1, 1), filtering: CounterFiltering::Off }; + let cal = EnergyProvenance::CalibratedInstrument { uncertainty: u(1, 1), calibration_ref: "c".into() }; + assert!(cal.rank() > chip.rank()); + assert!(chip.rank() > EnergyProvenance::HwShunt.rank()); + assert!(EnergyProvenance::HwShunt.rank() > EnergyProvenance::ModelBased.rank()); + assert_eq!(chip.family_label(), "HwShunt"); + assert_eq!(cal.family_label(), "HwShunt"); + assert!(EnergyProvenance::HwShunt.is_legacy() && !chip.is_legacy()); + } + + #[test] + fn bracket_uncertainty_is_the_type_a_standard_error_of_the_marginal() { + // idle mean 20, sample sd 1 (n = 4); busy mean 30, sample sd 2 (n = 4). + let idle = [19.0, 21.0, 19.0, 21.0]; // s² = 4/3 + let busy = [28.0, 32.0, 28.0, 32.0]; // s² = 16/3 + let rel = bracket_relative_uncertainty(&idle, &busy).unwrap(); + let want = ((4.0 / 3.0) / 4.0 + (16.0 / 3.0) / 4.0f64).sqrt() / 10.0; + assert!((rel - want).abs() < 1e-12, "{rel} vs {want}"); + + assert!(bracket_relative_uncertainty(&[1.0], &busy).is_none(), "one idle sample"); + assert!(bracket_relative_uncertainty(&busy, &idle).is_none(), "busy never rose"); + } + + #[test] + fn the_count_term_is_the_edge_limit_taken_as_rectangular() { + // Ten workers, 1051 completions: up to ten part-done decodes at each + // edge, so ±10 of 1051, rectangular: 10 / (√3 · 1051). + let c = completion_count_relative_uncertainty(10, 1051).unwrap(); + assert!((c - 10.0 / (3f64.sqrt() * 1051.0)).abs() < 1e-15, "{c}"); + // An expensive payload that completes a handful of times: the term is + // large, and the declaration must say so. + let few = completion_count_relative_uncertainty(10, 60).unwrap(); + assert!(few > 0.095 && few < 0.097, "{few}"); + assert!(completion_count_relative_uncertainty(10, 0).is_none(), "no completion, no per-unit figure"); + assert!(completion_count_relative_uncertainty(0, 100).is_none(), "no worker, no burst"); + } + + #[test] + fn a_per_unit_declaration_carries_both_terms_and_never_just_the_bracket() { + // Tight brackets: u_power ≈ 0.8 %. + let idle = [19.9, 20.1, 19.9, 20.1]; + let busy = [29.9, 30.1, 29.9, 30.1]; + let power = bracket_relative_uncertainty(&idle, &busy).unwrap(); + let count = completion_count_relative_uncertainty(10, 60).unwrap(); + let running = CompletionCount { in_flight: 10, completions: 60, opening: WindowOpening::Running }; + let both = per_unit_relative_uncertainty(&idle, &busy, &running).unwrap(); + assert!((both - (power * power + count * count).sqrt()).abs() < 1e-15); + // Declaring the bracket term alone understates the uncertainty that + // gets sealed inside the signature. + assert!(both > power && both > count); + // Few completions: the count term, not the bracket, sets the figure. + assert!(count > power, "count {count} vs power {power}"); + let none = CompletionCount { completions: 0, ..running }; + assert!(per_unit_relative_uncertainty(&idle, &busy, &none).is_none()); + assert!(per_unit_relative_uncertainty(&[1.0], &busy, &running).is_none()); + } + + #[test] + fn a_window_opened_from_rest_reads_low_and_is_corrected_to_the_midpoint() { + // Ten workers released together at the window's opening: no work + // precedes the window, and at the close each worker is part-way through + // one more reconstruction the count cannot see. The work lies in + // [C, C + N); the estimate is its midpoint and the standard uncertainty + // the rectangular one over a width of N. + let c = CompletionCount { in_flight: 10, completions: 60, opening: WindowOpening::FromRest }; + assert_eq!(c.work(), Some(65.0)); + let u = c.standard_uncertainty().unwrap(); + assert!((u - 10.0 / (2.0 * 3f64.sqrt())).abs() < 1e-12); + let rel = c.relative_uncertainty().unwrap(); + assert!((rel - 10.0 / (3f64.sqrt() * 130.0)).abs() < 1e-15, "{rel}"); + // Half the width, and a larger denominator, than the running model. + let running = CompletionCount { opening: WindowOpening::Running, ..c }; + assert!(rel < running.relative_uncertainty().unwrap() / 2.0); + assert_eq!(running.work(), Some(60.0)); + + // Simulate the regime directly: N workers with identical period T, + // released at t = 0, window closing at t = w. The true work in the + // window is N·w/T; the count is N·floor(w/T). Across closing phases the + // count is never high, and the midpoint estimate is never off by more + // than N/2. + let (n, t) = (10u64, 1.0f64); + for k in 0..1000 { + let w = 6.0 + k as f64 * 0.001 * t; // six periods plus a phase in [0, 1) + let truth = n as f64 * w / t; + let counted = n * (w / t).floor() as u64; + let est = CompletionCount { in_flight: n, completions: counted, opening: WindowOpening::FromRest }; + assert!(counted as f64 <= truth, "the count never reads high from rest"); + assert!((est.work().unwrap() - truth).abs() <= n as f64 / 2.0 + 1e-9); + } + } + + #[test] + fn a_label_is_refused_beside_nothing_as_legacy_or_without_evidence() { + let ok = EnergyProvenance::OnChipCounter { uncertainty: u(30_000, 2_000_000), filtering: CounterFiltering::Unknown }; + assert_eq!(check_label(0, None), Ok(()), "unmetered, unlabelled"); + assert_eq!(check_label(10, None), Ok(()), "a legacy unlabelled figure is accepted as unlabelled"); + assert_eq!(check_label(10, Some(&ok)), Ok(())); + assert_eq!(check_label(10, Some(&EnergyProvenance::ModelBased)), Ok(())); + assert_eq!(check_label(0, Some(&ok)), Err(AcquisitionError::NoFigure)); + assert_eq!(check_label(10, Some(&EnergyProvenance::HwShunt)), Err(AcquisitionError::LegacyLabel)); + let undeclared = EnergyProvenance::OnChipCounter { uncertainty: u(0, 1), filtering: CounterFiltering::Off }; + assert_eq!(check_label(10, Some(&undeclared)), Err(AcquisitionError::NoUncertainty)); + assert!(label_coheres(10, Some(&ok)) && !label_coheres(0, Some(&ok))); + } + + #[test] + fn a_declaration_below_the_figures_resolution_is_refused_everywhere() { + let at = |ppm| EnergyProvenance::OnChipCounter { uncertainty: u(ppm, 2_000_000), filtering: CounterFiltering::Unknown }; + // The floor, at its boundary, from both sides of the product. + assert_eq!(check_resolution(1, &at(288_675)), Err(AcquisitionError::BelowResolution)); + assert_eq!(check_resolution(1, &at(288_676)), Ok(())); + assert_eq!(check_resolution(288_675, &at(1)), Err(AcquisitionError::BelowResolution)); + assert_eq!(check_resolution(288_676, &at(1)), Ok(())); + assert_eq!(check_resolution(u64::MAX, &at(u32::MAX)), Ok(()), "no overflow"); + // The integer floor is the real-valued rule: the least integer at or + // above 10⁶ · 0.5/√3. + assert!(RESOLUTION_FLOOR_UJ_PPM as f64 >= 1e6 * RESOLUTION_UNCERTAINTY_UJ); + assert!(((RESOLUTION_FLOOR_UJ_PPM - 1) as f64) < 1e6 * RESOLUTION_UNCERTAINTY_UJ); + assert!((RESOLUTION_UNCERTAINTY_UJ - 0.5 / 3f64.sqrt()).abs() < 1e-16); + assert!((REGISTER_DELTA_UNCERTAINTY_UJ - 1.0 / 6f64.sqrt()).abs() < 1e-16); + // Both verifier entry points apply it: a 5 µJ figure at 1.54 % states + // 0.077 µJ, less than its own rounding. + assert_eq!(check_label(5, Some(&at(15_403))), Err(AcquisitionError::BelowResolution)); + assert!(!figure_and_class_cohere(5, Some(&at(15_403)))); + assert!(figure_and_class_cohere(5, Some(&at(57_736)))); + assert!(!figure_and_class_cohere(5, Some(&at(57_735)))); + // A class with no declaration has nothing to cover. + assert_eq!(check_resolution(1, &EnergyProvenance::ModelBased), Ok(())); + assert_eq!(check_resolution(1, &EnergyProvenance::Estimator), Ok(())); + } + + #[test] + fn a_sealed_figure_declares_its_own_rounding() { + // A 0.563 µJ estimate at 1.54 %: sealed as 1, and declared about + // 28.9 %, not 1.54 %. + let (f, rel) = sealed_figure(0.563, 0.0154).unwrap(); + assert_eq!(f, 1); + assert!((rel - (0.563f64 * 0.0154).hypot(0.5 / 3f64.sqrt())).abs() < 1e-15); + // The measurement's part is carried across in µJ, so an estimate that + // rounds down declares it relative to the smaller figure sealed. + let (f, rel) = sealed_figure(1.49, 0.0154).unwrap(); + assert_eq!(f, 1); + assert!((rel - (1.49f64 * 0.0154).hypot(0.5 / 3f64.sqrt())).abs() < 1e-15); + // At a large figure the rounding is negligible. + let (f, rel) = sealed_figure(461_538.46, 0.0154).unwrap(); + assert_eq!(f, 461_538); + assert!((rel - 0.0154).abs() < 1e-6); + // Refused: rounds to zero, negative, not finite. + assert!(sealed_figure(0.49, 0.01).is_none()); + assert!(sealed_figure(-3.0, 0.01).is_none()); + assert!(sealed_figure(f64::NAN, 0.01).is_none()); + assert!(sealed_figure(5.0, f64::INFINITY).is_none()); + assert!(sealed_figure(5.0, -0.1).is_none()); + // Every figure, once its declaration is rounded up to whole ppm, meets + // the floor every verifier checks — even with no measurement + // uncertainty at all, where only the rounding remains. + for f in 1..=300_000u64 { + for e in [f as f64 - 0.4999, f as f64, f as f64 + 0.4999] { + let (sealed, rel) = sealed_figure(e, 0.0).unwrap(); + assert_eq!(sealed, f); + let d = Uncertainty::from_relative(rel, 1).unwrap(); + assert!(f * d.relative_ppm as u64 >= RESOLUTION_FLOOR_UJ_PPM, "{e}"); + } + } + } + + #[test] + fn a_labelled_domain_differs_from_its_legacy_domain_in_the_version_byte_only() { + let d = b"wai:world-receipt\x01"; + assert_eq!(labelled_domain(d, None), d.to_vec(), "unlabelled keeps the legacy domain"); + let l = labelled_domain(d, Some(&EnergyProvenance::ModelBased)); + assert_eq!(&l[..l.len() - 1], &d[..d.len() - 1]); + assert_eq!(*l.last().unwrap(), LABELLED_DOMAIN_VERSION); + let mut o = Vec::new(); + write_label(None, &mut o); + assert!(o.is_empty(), "an absent label writes nothing"); + write_label(Some(&EnergyProvenance::Estimator), &mut o); + assert_eq!(o, vec![3]); + } + + #[cfg(any(feature = "quantum_receipt", feature = "quantum_ops", feature = "quantum_phasor_meter"))] + #[test] + fn the_quantum_vocabulary_encodes_byte_for_byte_the_same() { + let classes = [ + EnergyProvenance::ModelBased, + EnergyProvenance::Estimator, + EnergyProvenance::OnChipCounter { uncertainty: u(15_403, 2_977_104), filtering: CounterFiltering::Unknown }, + EnergyProvenance::OnChipCounter { uncertainty: u(1, 1), filtering: CounterFiltering::Off }, + EnergyProvenance::OnChipCounter { uncertainty: u(u32::MAX, u64::MAX), filtering: CounterFiltering::On }, + EnergyProvenance::CalibratedInstrument { uncertainty: u(5_000, 60_000_000), calibration_ref: "lab-cert-0412".into() }, + ]; + for p in classes { + let q = to_quantum_class(&p).unwrap(); + let (mut a, mut b) = (Vec::new(), Vec::new()); + p.write_signed(&mut a); + q.write_signed(&mut b); + assert_eq!(a, b, "{}", p.label()); + assert_eq!(p.label(), q.label()); + let jp: Vec<_> = json_fields(&p); + let jq: Vec<_> = q.json_fields(); + assert_eq!(jp, jq, "the two vocabularies render the same JSON fields"); + } + assert_eq!(to_quantum_class(&EnergyProvenance::HwShunt), Err(AcquisitionError::LegacyLabel)); + } + + #[test] + fn json_labels_insert_in_sorted_position_and_round_trip() { + let legacy = r#"{"kind":"k","alpha":1,"joules_micro":5,"zeta":"z"}"#.to_string(); + assert_eq!(json_with_label(legacy.clone(), None), legacy, "unlabelled JSON is untouched"); + let p = EnergyProvenance::OnChipCounter { uncertainty: u(15_403, 2_977_104), filtering: CounterFiltering::Unknown }; + let j = json_with_label(legacy, Some(&p)); + assert_eq!( + j, + r#"{"kind":"k","alpha":1,"energy_counter_filtering":"Unknown","energy_provenance":"OnChipCounter","energy_uncertainty":{"relative_ppm":15403,"window_us":2977104},"joules_micro":5,"zeta":"z"}"# + ); + let v: serde_json::Value = serde_json::from_str(&j).unwrap(); + assert_eq!(label_from_json(v.as_object().unwrap()), Ok(Some(p))); + + let c = EnergyProvenance::CalibratedInstrument { uncertainty: u(5_000, 60_000_000), calibration_ref: "lab-cert-0412".into() }; + let j = json_with_label(r#"{"kind":"k","joules_micro":5}"#.into(), Some(&c)); + let v: serde_json::Value = serde_json::from_str(&j).unwrap(); + assert_eq!(label_from_json(v.as_object().unwrap()), Ok(Some(c))); + + let parse = |s: &str| { + let v: serde_json::Value = serde_json::from_str(s).unwrap(); + label_from_json(v.as_object().unwrap()) + }; + assert_eq!(parse(r#"{"joules_micro":5}"#), Ok(None), "no label is unlabelled"); + assert_eq!(parse(r#"{"energy_provenance":null}"#), Ok(None)); + assert_eq!( + parse(r#"{"energy_uncertainty":{"relative_ppm":1,"window_us":1}}"#), + Err(AcquisitionError::UnexpectedEvidence), + "evidence with no class is ambiguous" + ); + assert_eq!(parse(r#"{"energy_provenance":"OnChipCounter"}"#), Err(AcquisitionError::NoUncertainty)); + assert_eq!(parse(r#"{"energy_provenance":"Guess"}"#), Err(AcquisitionError::UnknownLabel)); + assert_eq!( + parse(r#"{"energy_provenance":"OnChipCounter","energy_uncertainty":{"relative_ppm":0,"window_us":1}}"#), + Err(AcquisitionError::NoUncertainty) + ); + } +} diff --git a/wai/wai-rs/src/energy_meter.rs b/wai/wai-rs/src/energy_meter.rs index b433e7a8e..b5aba49ac 100644 --- a/wai/wai-rs/src/energy_meter.rs +++ b/wai/wai-rs/src/energy_meter.rs @@ -1,13 +1,22 @@ //! Energy meters — where a receipt's `joules_micro` actually comes from. //! -//! Every receipt class in WAI seals a joule figure *beside its acquisition -//! class*, and rejects the two out of step: an unlabelled non-zero figure is -//! uncomparable, a labelled zero claims a measurement that did not happen. That -//! discipline is only as good as the thing producing the number, so this module -//! makes the ladder **mechanical rather than conventional**: +//! The receipts built on [`crate::energy_class`] seal a joule figure *beside +//! its acquisition class*, and reject the two out of step: an unlabelled +//! non-zero figure is uncomparable, a labelled zero claims a measurement that +//! did not happen. That discipline is only as good as the thing producing the +//! number, so this module makes the ladder **mechanical rather than +//! conventional**: //! //! - A [`CounterMeter`] reads a real hardware energy counter and is the only -//! meter here that can ever yield [`EnergyProvenance::HwShunt`]. +//! meter here that can ever yield a hardware class: +//! [`EnergyProvenance::OnChipCounter`] when built with [`CounterMeter::new`] +//! or [`CounterMeter::rapl`], and [`EnergyProvenance::CalibratedInstrument`] +//! only when built with [`CounterMeter::calibrated_instrument`] and a +//! calibration-certificate reference. Either way the reading carries the +//! declared relative uncertainty the meter was configured with, combined +//! with the `1/√6` µJ resolution term of a difference of two whole-microjoule +//! register readings, and the integration window it +//! actually measured over. It never yields the legacy `HwShunt`. //! - A [`ModelMeter`] multiplies elapsed time by a calibrated power figure and //! can only ever yield [`EnergyProvenance::ModelBased`] — it has no way to //! claim a measurement. @@ -22,11 +31,28 @@ //! module stays usable on targets with no `Instant` (wasm). The convenience //! constructor [`CounterMeter::rapl`] wires the Linux powercap sysfs interface //! and returns `None` anywhere it is absent. +//! +//! # What a counter meter can and cannot know +//! +//! A counter meter measures the delta and the window. It cannot know the +//! counter's uncertainty — that is a property of the interface, stated by its +//! vendor or evaluated by the operator — so the relative uncertainty is a +//! required constructor argument, typed [`NonZeroU32`] so an undeclared (zero) +//! uncertainty cannot be configured at all. It usually cannot know whether the +//! counter was filtering its output either (reading that state needs MSR +//! access an unprivileged process does not have), so the filtering state +//! defaults to [`CounterFiltering::Unknown`] and is set only by a caller that +//! has actually determined it. +use core::num::NonZeroU32; + +use crate::energy_class::{ + AcquisitionError, AcquisitionKind, CounterFiltering, Uncertainty, REGISTER_DELTA_UNCERTAINTY_UJ, +}; use crate::wai_video_manifest::EnergyProvenance; /// A measured span: the energy, and the class of the measurement. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct EnergyReading { pub joules_micro: u64, pub provenance: EnergyProvenance, @@ -52,67 +78,185 @@ pub trait Meter { /// End the span. `None` when this meter could not measure — never a /// fabricated figure. fn stop(&mut self) -> Option; - /// The class this meter is capable of claiming, whatever happens. - fn provenance(&self) -> EnergyProvenance; + /// The class this meter is capable of claiming, whatever happens. The + /// evidence (window, and so the full [`EnergyProvenance`]) exists only once + /// a span has been measured, so this is the class without it. + fn kind(&self) -> AcquisitionKind; +} + +/// What stands behind a counter meter's readings. +enum CounterSource { + /// The measured silicon's own counter. + OnChip { filtering: CounterFiltering }, + /// An external instrument with a current calibration. + Instrument { calibration_ref: String }, } /// A monotonic hardware energy counter, in microjoules, that wraps at -/// `wrap_at`. The read is injected, so the delta and wraparound arithmetic is -/// exercised in tests without needing the hardware present. +/// `wrap_at`. The read and the clock are injected, so the delta, wraparound and +/// window arithmetic is exercised in tests without needing the hardware present. pub struct CounterMeter { read: Box Option>, + clock_micros: Box u64>, wrap_at: u64, - start: Option, + relative_ppm: NonZeroU32, + source: CounterSource, + /// `(counter, clock)` at the start of the span. + start: Option<(u64, u64)>, } impl CounterMeter { - /// `read` returns the counter's current microjoule value, or `None` when it - /// cannot be read. `wrap_at` is the counter's range (its maximum + 1). - pub fn new(read: impl FnMut() -> Option + 'static, wrap_at: u64) -> CounterMeter { - CounterMeter { read: Box::new(read), wrap_at, start: None } + /// An on-chip counter. `read` returns the counter's current microjoule + /// value, or `None` when it cannot be read; `clock_micros` a monotonic + /// microsecond timestamp; `wrap_at` the counter's range (its maximum + 1); + /// `relative_ppm` the standard relative uncertainty (k = 1) the operator + /// declares for this counter at the windows it will be read over. The + /// filtering state starts as [`CounterFiltering::Unknown`]. + /// + /// A reading declares `relative_ppm` combined with the resolution of a + /// delta of two whole-microjoule register readings + /// ([`crate::energy_class::REGISTER_DELTA_UNCERTAINTY_UJ`]), relative to + /// the delta — so a short span declares more than `relative_ppm`, and a + /// long one about `relative_ppm`, rounded up. A register whose underlying + /// hardware unit is coarser than a microjoule hides that from the meter; + /// `relative_ppm` must cover it. + pub fn new( + read: impl FnMut() -> Option + 'static, + clock_micros: impl FnMut() -> u64 + 'static, + wrap_at: u64, + relative_ppm: NonZeroU32, + ) -> CounterMeter { + CounterMeter { + read: Box::new(read), + clock_micros: Box::new(clock_micros), + wrap_at, + relative_ppm, + source: CounterSource::OnChip { filtering: CounterFiltering::Unknown }, + start: None, + } } - /// Wire the Linux powercap (RAPL) interface for a package domain. `None` - /// when the interface is absent — which is every non-Linux target, and any - /// Linux without powercap — so the caller must fall back explicitly rather - /// than silently receive a made-up figure. - pub fn rapl(domain: &str) -> Option { + /// An external instrument that exposes a cumulative energy register and + /// holds a current calibration. `calibration_ref` identifies the + /// certificate (a URI, or the issuing laboratory's certificate number); + /// `relative_ppm` is the standard uncertainty (k = 1) it states for the + /// range and conditions of use — an expanded uncertainty `U` at coverage + /// factor `k` is declared as `U / k`. + /// + /// Refuses an empty reference: without one, the "calibrated" in the class + /// name is an assertion nobody can check. + pub fn calibrated_instrument( + read: impl FnMut() -> Option + 'static, + clock_micros: impl FnMut() -> u64 + 'static, + wrap_at: u64, + relative_ppm: NonZeroU32, + calibration_ref: impl Into, + ) -> Result { + let calibration_ref = calibration_ref.into(); + if calibration_ref.trim().is_empty() { + return Err(AcquisitionError::NoCalibrationRef); + } + let mut m = CounterMeter::new(read, clock_micros, wrap_at, relative_ppm); + m.source = CounterSource::Instrument { calibration_ref }; + Ok(m) + } + + /// Record the counter's filtering state, for a caller that has determined + /// it (e.g. by reading `IA32_MISC_PACKAGE_CTLS[0]`, or because the platform + /// runs a confidential-computing enclave that enables it). Has no effect on + /// an external instrument, which has no counter filter. + pub fn with_filtering(mut self, filtering: CounterFiltering) -> CounterMeter { + if let CounterSource::OnChip { filtering: f } = &mut self.source { + *f = filtering; + } + self + } + + /// Wire the Linux powercap (RAPL) interface for a package domain, timed by + /// the process monotonic clock. `None` when the interface is absent — which + /// is every non-Linux target, and any Linux without powercap — so the caller + /// must fall back explicitly rather than silently receive a made-up figure. + /// + /// The filtering state is left [`CounterFiltering::Unknown`]: powercap does + /// not expose it, and an unprivileged reader cannot read the MSR that does. + /// Where filtering may be active, Intel states the added noise as typically + /// under 2 % standard deviation over 1 s and 5–15 % over 100 ms + /// (INTEL-SA-00389); `relative_ppm` should cover it at the windows used. + #[cfg(not(target_arch = "wasm32"))] + pub fn rapl(domain: &str, relative_ppm: NonZeroU32) -> Option { let base = format!("/sys/class/powercap/{domain}"); let energy = format!("{base}/energy_uj"); let range = format!("{base}/max_energy_range_uj"); // Both files must be readable now, or this meter does not exist. let wrap_at = read_u64_file(&range)?.saturating_add(1); read_u64_file(&energy)?; - Some(CounterMeter::new(move || read_u64_file(&energy), wrap_at)) + let origin = std::time::Instant::now(); + Some(CounterMeter::new( + move || read_u64_file(&energy), + move || origin.elapsed().as_micros() as u64, + wrap_at, + relative_ppm, + )) } } +#[cfg(not(target_arch = "wasm32"))] fn read_u64_file(path: &str) -> Option { std::fs::read_to_string(path).ok()?.trim().parse::().ok() } impl Meter for CounterMeter { fn start(&mut self) { - self.start = (self.read)(); + let counter = (self.read)(); + let t = (self.clock_micros)(); + self.start = counter.map(|c| (c, t)); } fn stop(&mut self) -> Option { - let started = self.start.take()?; + let (started, t0) = self.start.take()?; let now = (self.read)()?; + let window_us = (self.clock_micros)().saturating_sub(t0); // Energy counters are monotonic until they wrap at their range. let delta = if now >= started { now - started } else { self.wrap_at - started + now }; - Some(EnergyReading { joules_micro: delta, provenance: EnergyProvenance::HwShunt }) + // A zero delta or a zero window is a failure to measure, not a + // measurement of nothing; sealing either would be refused downstream + // anyway, so it is refused here where the cause is known. + if delta == 0 || window_us == 0 { + return None; + } + // The declaration describes the figure sealed, the delta of two whole- + // microjoule readings: the operator's relative uncertainty for the + // counter, carried across in microjoules, and the readings' own + // resolution (§2.3), divided by the delta. Without the second term a + // small delta would declare less than its integer can carry, and the + // receipts refuse that (`check_resolution`). + let operator = self.relative_ppm.get() as f64 / 1e6; + let rel = (operator * delta as f64).hypot(REGISTER_DELTA_UNCERTAINTY_UJ) / delta as f64; + let uncertainty = Uncertainty::from_relative(rel, window_us).ok()?; + let provenance = match &self.source { + CounterSource::OnChip { filtering } => { + EnergyProvenance::OnChipCounter { uncertainty, filtering: *filtering } + } + CounterSource::Instrument { calibration_ref } => EnergyProvenance::CalibratedInstrument { + uncertainty, + calibration_ref: calibration_ref.clone(), + }, + }; + Some(EnergyReading { joules_micro: delta, provenance }) } - fn provenance(&self) -> EnergyProvenance { - EnergyProvenance::HwShunt + fn kind(&self) -> AcquisitionKind { + match self.source { + CounterSource::OnChip { .. } => AcquisitionKind::OnChipCounter, + CounterSource::Instrument { .. } => AcquisitionKind::CalibratedInstrument, + } } } /// Elapsed time multiplied by a calibrated power figure. A real number, and -/// honestly labelled as derived — it cannot claim `HwShunt` however it is -/// configured. +/// honestly labelled as derived — it cannot claim a hardware class however it +/// is configured. pub struct ModelMeter { clock_micros: Box u64>, milliwatts: u64, @@ -147,8 +291,8 @@ impl Meter for ModelMeter { Some(EnergyReading { joules_micro, provenance: EnergyProvenance::ModelBased }) } - fn provenance(&self) -> EnergyProvenance { - EnergyProvenance::ModelBased + fn kind(&self) -> AcquisitionKind { + AcquisitionKind::ModelBased } } @@ -164,19 +308,108 @@ mod tests { move || q.borrow_mut().next().flatten() } + fn scripted_clock(values: Vec) -> impl FnMut() -> u64 { + let q = Rc::new(RefCell::new(values.into_iter())); + move || q.borrow_mut().next().unwrap_or(0) + } + + fn ppm(n: u32) -> NonZeroU32 { + NonZeroU32::new(n).unwrap() + } + #[test] - fn counter_measures_a_real_delta_as_hwshunt() { - let mut m = CounterMeter::new(scripted(vec![Some(1_000), Some(4_500)]), 1 << 32); + fn counter_measures_a_real_delta_as_an_on_chip_reading_with_its_uncertainty() { + let mut m = CounterMeter::new( + scripted(vec![Some(1_000), Some(4_500)]), + scripted_clock(vec![10, 1_000_010]), + 1 << 32, + ppm(20_000), + ); m.start(); let r = m.stop().unwrap(); assert_eq!(r.joules_micro, 3_500); - assert_eq!(r.provenance, EnergyProvenance::HwShunt); + // 2 % of 3,500 µJ is 70 µJ; with the 1/√6 µJ resolution of a register + // delta in quadrature, 70.0012 µJ, which is 20,000.34 ppm, rounded up. + assert_eq!( + r.provenance, + EnergyProvenance::OnChipCounter { + uncertainty: Uncertainty { relative_ppm: 20_001, window_us: 1_000_000 }, + filtering: CounterFiltering::Unknown, + }, + "the declared uncertainty travels with the reading, over the window actually measured" + ); + assert_eq!(m.kind(), AcquisitionKind::OnChipCounter); + assert!(!r.provenance.is_legacy(), "a meter never produces the legacy label"); + } + + #[test] + fn a_known_filtering_state_is_recorded_and_the_default_is_unknown() { + let mut m = CounterMeter::new(scripted(vec![Some(0), Some(9)]), scripted_clock(vec![0, 100_000]), 1 << 32, ppm(150_000)) + .with_filtering(CounterFiltering::On); + m.start(); + match m.stop().unwrap().provenance { + EnergyProvenance::OnChipCounter { filtering, uncertainty } => { + assert_eq!(filtering, CounterFiltering::On); + assert_eq!(uncertainty.window_us, 100_000); + } + other => panic!("unexpected class {other:?}"), + } + } + + #[test] + fn a_calibrated_instrument_needs_its_certificate_and_says_so() { + assert!(matches!( + CounterMeter::calibrated_instrument(scripted(vec![]), scripted_clock(vec![]), 1 << 32, ppm(5_000), " "), + Err(AcquisitionError::NoCalibrationRef) + )); + let mut m = CounterMeter::calibrated_instrument( + scripted(vec![Some(100), Some(700)]), + scripted_clock(vec![0, 60_000_000]), + 1 << 32, + ppm(5_000), + "lab-cert-0412", + ) + .unwrap() + .with_filtering(CounterFiltering::On); // no effect on an instrument + assert_eq!(m.kind(), AcquisitionKind::CalibratedInstrument); + m.start(); + let r = m.stop().unwrap(); + assert_eq!( + r.provenance, + EnergyProvenance::CalibratedInstrument { + uncertainty: Uncertainty { relative_ppm: 5_047, window_us: 60_000_000 }, + calibration_ref: "lab-cert-0412".into(), + }, + "0.5 % of 600 µJ with the register delta's resolution: 5,046.08 ppm, rounded up" + ); + } + + #[test] + fn a_short_span_declares_its_register_resolution() { + // A 3 µJ delta: the operator's 2 % is 0.06 µJ, far below the 1/√6 µJ + // the two whole-microjoule readings leave. Declaring 2 % alone would + // state less than the integer can carry, and the receipts refuse that. + let mut m = CounterMeter::new(scripted(vec![Some(10), Some(13)]), scripted_clock(vec![0, 500]), 1 << 32, ppm(20_000)); + m.start(); + let r = m.stop().unwrap(); + assert_eq!(r.joules_micro, 3); + let u = r.provenance.uncertainty().unwrap(); + assert_eq!(u.relative_ppm, 137_545); + assert_eq!(crate::energy_class::check_resolution(r.joules_micro, &r.provenance), Ok(())); + assert!(crate::energy_class::figure_and_class_cohere(r.joules_micro, Some(&r.provenance))); + // Every span meets the floor, whatever the operator declared. + for delta in [1u64, 2, 5, 17, 288, 289, 1_000, 288_676, 10_000_000] { + let mut m = CounterMeter::new(scripted(vec![Some(0), Some(delta)]), scripted_clock(vec![0, 1]), u64::MAX, ppm(1)); + m.start(); + let r = m.stop().unwrap(); + assert_eq!(crate::energy_class::check_resolution(r.joules_micro, &r.provenance), Ok(()), "delta {delta}"); + } } #[test] fn counter_handles_wraparound() { // Range 10_000 (wrap_at 10_000): 9_800 -> 200 is a 400 uJ span. - let mut m = CounterMeter::new(scripted(vec![Some(9_800), Some(200)]), 10_000); + let mut m = CounterMeter::new(scripted(vec![Some(9_800), Some(200)]), scripted_clock(vec![0, 5]), 10_000, ppm(1)); m.start(); assert_eq!(m.stop().unwrap().joules_micro, 400); } @@ -184,15 +417,25 @@ mod tests { #[test] fn a_meter_that_cannot_read_reports_nothing_not_a_guess() { // No hardware: the read fails. The meter must yield None so the caller - // falls back explicitly rather than sealing a fabricated HwShunt. - let mut m = CounterMeter::new(scripted(vec![None, None]), 1 << 32); + // falls back explicitly rather than sealing a fabricated hardware figure. + let mut m = CounterMeter::new(scripted(vec![None, None]), scripted_clock(vec![0, 5]), 1 << 32, ppm(1)); m.start(); assert!(m.stop().is_none()); // And a stop with no start is likewise nothing. - let mut m2 = CounterMeter::new(scripted(vec![Some(5)]), 1 << 32); + let mut m2 = CounterMeter::new(scripted(vec![Some(5)]), scripted_clock(vec![0]), 1 << 32, ppm(1)); assert!(m2.stop().is_none()); } + #[test] + fn a_zero_delta_or_a_zero_window_is_not_a_measurement() { + let mut m = CounterMeter::new(scripted(vec![Some(7), Some(7)]), scripted_clock(vec![0, 1_000]), 1 << 32, ppm(1)); + m.start(); + assert!(m.stop().is_none(), "no energy counted is a failure to measure"); + let mut m = CounterMeter::new(scripted(vec![Some(7), Some(9)]), scripted_clock(vec![50, 50]), 1 << 32, ppm(1)); + m.start(); + assert!(m.stop().is_none(), "no window is not an integration"); + } + #[test] fn model_meter_is_derived_and_says_so() { // 2000 mW over 5_000 us = 10_000 uJ. @@ -202,18 +445,14 @@ mod tests { assert_eq!(r.joules_micro, 10_000); assert_eq!(r.provenance, EnergyProvenance::ModelBased); // The ladder is mechanical: this meter cannot claim a measurement. - assert_eq!(m.provenance(), EnergyProvenance::ModelBased); - } - - fn scripted_clock(values: Vec) -> impl FnMut() -> u64 { - let q = Rc::new(RefCell::new(values.into_iter())); - move || q.borrow_mut().next().unwrap_or(0) + assert_eq!(m.kind(), AcquisitionKind::ModelBased); } #[test] fn seal_args_round_trip_to_the_receipt_contract() { - let r = EnergyReading { joules_micro: 42, provenance: EnergyProvenance::HwShunt }; - assert_eq!(r.as_seal_args(), (42, Some(EnergyProvenance::HwShunt))); + let p = crate::energy_class::test_on_chip(); + let r = EnergyReading { joules_micro: 42, provenance: p.clone() }; + assert_eq!(r.as_seal_args(), (42, Some(p))); // The only unmetered form the receipts accept. assert_eq!(EnergyReading::unmetered(), (0, None)); } @@ -221,9 +460,9 @@ mod tests { #[test] fn rapl_is_absent_here_and_says_so_rather_than_pretending() { // This box has no Linux powercap interface; the constructor must decline - // rather than hand back a meter that invents HwShunt figures. + // rather than hand back a meter that invents hardware figures. if !std::path::Path::new("/sys/class/powercap/intel-rapl:0").exists() { - assert!(CounterMeter::rapl("intel-rapl:0").is_none()); + assert!(CounterMeter::rapl("intel-rapl:0", ppm(20_000)).is_none()); } } } diff --git a/wai/wai-rs/src/film/receipt.rs b/wai/wai-rs/src/film/receipt.rs index de5b2e35e..4166012eb 100644 --- a/wai/wai-rs/src/film/receipt.rs +++ b/wai/wai-rs/src/film/receipt.rs @@ -9,6 +9,10 @@ use super::Film; use crate::world::receipt::merkle_root; +use crate::energy_class::{ + check_label, json_with_label, label_coheres, labelled_domain, write_label, AcquisitionError, + EnergyProvenance, +}; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; const DOMAIN_RECEIPT: &[u8] = b"wai:film-receipt\x01"; @@ -30,6 +34,11 @@ pub struct FilmReceipt { pub work_frames: u64, /// Carried microjoules (native meter supplies; 0 = unmetered). pub joules_micro: u64, + /// How `joules_micro` was acquired — an optional label + /// (`extensions/energy-measurement.md` §2.8). `None` is an unlabelled + /// figure, as every receipt sealed before the label existed carries; it is + /// never read as any class. Inside the signature when present. + pub energy_provenance: Option, pub parent_receipt_hash: Option<[u8; 32]>, pub signer_pubkey: [u8; 32], pub signer_id: String, @@ -38,13 +47,15 @@ pub struct FilmReceipt { impl FilmReceipt { fn signing_payload(&self) -> Vec { - let mut o = DOMAIN_RECEIPT.to_vec(); + let label = self.energy_provenance.as_ref(); + let mut o = labelled_domain(DOMAIN_RECEIPT, label); o.extend_from_slice(&self.film_hash); o.extend_from_slice(&self.group_id.to_be_bytes()); o.extend_from_slice(&self.merkle_root); o.extend_from_slice(&(self.frames.len() as u64).to_be_bytes()); o.extend_from_slice(&self.work_frames.to_be_bytes()); o.extend_from_slice(&self.joules_micro.to_be_bytes()); + write_label(label, &mut o); match self.parent_receipt_hash { Some(h) => { o.push(1); @@ -82,6 +93,7 @@ impl FilmReceipt { merkle_root: merkle_root(&leaves), work_frames, joules_micro, + energy_provenance: None, parent_receipt_hash, signer_pubkey: signer.verifying_key().to_bytes(), signer_id: signer_id.into(), @@ -92,6 +104,9 @@ impl FilmReceipt { } pub fn verify(&self) -> bool { + if !label_coheres(self.joules_micro, self.energy_provenance.as_ref()) { + return false; + } let leaves: Vec<[u8; 32]> = self.frames.iter().map(|b| b.hash).collect(); if merkle_root(&leaves) != self.merkle_root { return false; @@ -100,6 +115,33 @@ impl FilmReceipt { k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)).is_ok() } + /// Label the figure with its acquisition class and re-sign + /// (`extensions/energy-measurement.md` §2.8). `signer` must be the key + /// that sealed the receipt. Refuses whatever [`Self::verify`] would + /// refuse: legacy `HwShunt`, a class beside a zero figure, a refined class + /// without its evidence. + pub fn with_energy_class( + mut self, + signer: &SigningKey, + class: EnergyProvenance, + ) -> Result { + if signer.verifying_key().to_bytes() != self.signer_pubkey { + return Err(AcquisitionError::SignerMismatch); + } + check_label(self.joules_micro, Some(&class))?; + self.energy_provenance = Some(class); + self.sig = signer.sign(&self.signing_payload()).to_bytes(); + Ok(self) + } + + /// Re-sign whatever the fields now say, bypassing every check — so tests + /// can present `verify()` with a correctly signed receipt that breaks a + /// label rule, and see the rule itself refuse it. + #[cfg(test)] + pub(crate) fn resign_for_test(&mut self, signer: &SigningKey) { + self.sig = signer.sign(&self.signing_payload()).to_bytes(); + } + pub fn receipt_hash(&self) -> [u8; 32] { let mut h = blake3::Hasher::new(); h.update(b"wai:film-receipt-id\x01"); @@ -115,7 +157,7 @@ impl FilmReceipt { Some(h) => format!("\"{}\"", hx(&h)), None => "null".into(), }; - format!( + let json = format!( "{{\"kind\":\"linear-film\",\"film_hash\":\"{}\",\"frame_hashes\":[{}],\"group_id\":{},\"joules_micro\":{},\"leaf_count\":{},\"parent_receipt_hash\":{},\"receipt_hash\":\"{}\",\"root_hash\":\"{}\",\"sig\":\"{}\",\"signer_id\":{},\"signer_pubkey\":\"{}\",\"work_frames\":{}}}", hx(&self.film_hash), objs.join(","), @@ -129,7 +171,8 @@ impl FilmReceipt { serde_json::to_string(&self.signer_id).unwrap(), hx(&self.signer_pubkey), self.work_frames, - ) + ); + json_with_label(json, self.energy_provenance.as_ref()) } } diff --git a/wai/wai-rs/src/haptic/receipt.rs b/wai/wai-rs/src/haptic/receipt.rs index 43df93b72..f3a08a566 100644 --- a/wai/wai-rs/src/haptic/receipt.rs +++ b/wai/wai-rs/src/haptic/receipt.rs @@ -8,6 +8,10 @@ use super::HapticSignal; use crate::world::receipt::merkle_root; +use crate::energy_class::{ + check_label, json_with_label, label_coheres, labelled_domain, write_label, AcquisitionError, + EnergyProvenance, +}; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; const DOMAIN_RECEIPT: &[u8] = b"wai:haptic-receipt\x01"; @@ -30,6 +34,11 @@ pub struct HapticReceipt { pub work_samples: u64, /// Carried microjoules (native meter supplies; 0 = unmetered). pub joules_micro: u64, + /// How `joules_micro` was acquired — an optional label + /// (`extensions/energy-measurement.md` §2.8). `None` is an unlabelled + /// figure, as every receipt sealed before the label existed carries; it is + /// never read as any class. Inside the signature when present. + pub energy_provenance: Option, pub parent_receipt_hash: Option<[u8; 32]>, pub signer_pubkey: [u8; 32], pub signer_id: String, @@ -53,13 +62,15 @@ impl HapticSignal { impl HapticReceipt { fn signing_payload(&self) -> Vec { - let mut o = DOMAIN_RECEIPT.to_vec(); + let label = self.energy_provenance.as_ref(); + let mut o = labelled_domain(DOMAIN_RECEIPT, label); o.extend_from_slice(&self.signal_hash); o.extend_from_slice(&self.group_id.to_be_bytes()); o.extend_from_slice(&self.merkle_root); o.extend_from_slice(&(self.channels.len() as u64).to_be_bytes()); o.extend_from_slice(&self.work_samples.to_be_bytes()); o.extend_from_slice(&self.joules_micro.to_be_bytes()); + write_label(label, &mut o); match self.parent_receipt_hash { Some(h) => { o.push(1); @@ -97,6 +108,7 @@ impl HapticReceipt { merkle_root: merkle_root(&leaves), work_samples, joules_micro, + energy_provenance: None, parent_receipt_hash, signer_pubkey: signer.verifying_key().to_bytes(), signer_id: signer_id.into(), @@ -107,6 +119,9 @@ impl HapticReceipt { } pub fn verify(&self) -> bool { + if !label_coheres(self.joules_micro, self.energy_provenance.as_ref()) { + return false; + } let leaves: Vec<[u8; 32]> = self.channels.iter().map(|b| b.hash).collect(); if merkle_root(&leaves) != self.merkle_root { return false; @@ -115,6 +130,33 @@ impl HapticReceipt { k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)).is_ok() } + /// Label the figure with its acquisition class and re-sign + /// (`extensions/energy-measurement.md` §2.8). `signer` must be the key + /// that sealed the receipt. Refuses whatever [`Self::verify`] would + /// refuse: legacy `HwShunt`, a class beside a zero figure, a refined class + /// without its evidence. + pub fn with_energy_class( + mut self, + signer: &SigningKey, + class: EnergyProvenance, + ) -> Result { + if signer.verifying_key().to_bytes() != self.signer_pubkey { + return Err(AcquisitionError::SignerMismatch); + } + check_label(self.joules_micro, Some(&class))?; + self.energy_provenance = Some(class); + self.sig = signer.sign(&self.signing_payload()).to_bytes(); + Ok(self) + } + + /// Re-sign whatever the fields now say, bypassing every check — so tests + /// can present `verify()` with a correctly signed receipt that breaks a + /// label rule, and see the rule itself refuse it. + #[cfg(test)] + pub(crate) fn resign_for_test(&mut self, signer: &SigningKey) { + self.sig = signer.sign(&self.signing_payload()).to_bytes(); + } + pub fn receipt_hash(&self) -> [u8; 32] { let mut h = blake3::Hasher::new(); h.update(b"wai:haptic-receipt-id\x01"); @@ -130,7 +172,7 @@ impl HapticReceipt { Some(h) => format!("\"{}\"", hx(&h)), None => "null".into(), }; - format!( + let json = format!( "{{\"kind\":\"haptic-signal\",\"channel_hashes\":[{}],\"group_id\":{},\"joules_micro\":{},\"leaf_count\":{},\"parent_receipt_hash\":{},\"receipt_hash\":\"{}\",\"root_hash\":\"{}\",\"sig\":\"{}\",\"signal_hash\":\"{}\",\"signer_id\":{},\"signer_pubkey\":\"{}\",\"work_samples\":{}}}", objs.join(","), self.group_id, @@ -144,7 +186,8 @@ impl HapticReceipt { serde_json::to_string(&self.signer_id).unwrap(), hx(&self.signer_pubkey), self.work_samples, - ) + ); + json_with_label(json, self.energy_provenance.as_ref()) } } diff --git a/wai/wai-rs/src/lib.rs b/wai/wai-rs/src/lib.rs index de6a513f4..b1d964a46 100644 --- a/wai/wai-rs/src/lib.rs +++ b/wai/wai-rs/src/lib.rs @@ -214,6 +214,28 @@ pub mod provenance; #[cfg(feature = "provenance")] pub mod reconstruction_binding; +// The acquisition class an energy figure is sealed beside — one definition +// shared by every receipt here that carries `joules_micro`: always present in the +// C2PA energy binding, the int_motion receipt and the OTT receipts; an optional +// label (absent = legacy, unlabelled) in the families that predate it. Splits hardware evidence into an on-chip counter class and a calibrated +// external-instrument class, each with a required declared uncertainty, and keeps +// the legacy `HwShunt` label verifiable. Pure std with no optional +// dependencies, so it is compiled unconditionally. +pub mod energy_class; + +// The bracketed marginal protocol (energy-measurement §3) every reference meter +// runs: idle bracket, a burst released from a start barrier, busy bracket, and +// the §4 / §2.3 rules for when a figure may be sealed under a hardware class. +// Native only — it spawns threads and runs the power reader as a subprocess. +#[cfg(not(target_arch = "wasm32"))] +pub mod energy_burst; + +// Signed-byte pins for the receipt families that seal `joules_micro`: an +// unlabelled receipt must keep the exact bytes it had before the optional +// acquisition class existed. Test-only. +#[cfg(all(test, feature = "world"))] +mod receipt_pins; + // The joules a reconstruction cost, bound to that reconstruction. Fills the slot // C2PA 2.4's `c2pa.environmental-sustainability` opened but left self-declared: // same digest as the reconstruction binding, plus the acquisition class and @@ -264,6 +286,12 @@ pub mod quantum_envelope; #[cfg(feature = "quantum_receipt")] pub use wai_quantum::quantum_receipt; +// The acquisition class of a quantum receipt's joule figure, as an optional +// signed label (`Labelled`, `LabelledQbom`). Present with any quantum feature +// that seals receipts; unlabelled receipts keep their exact bytes. +#[cfg(any(feature = "quantum_receipt", feature = "quantum_ops", feature = "quantum_phasor_meter"))] +pub use wai_quantum::quantum_energy; + #[cfg(feature = "video_receipt")] pub mod video_receipt; @@ -301,9 +329,11 @@ pub use wai_quantum::quantum_ops; pub mod wai_video_manifest; // Where a receipt's joules_micro actually comes from. Makes the provenance -// ladder mechanical: only a hardware counter can yield HwShunt, a model meter -// can only ever say ModelBased, and a meter with no hardware returns None -// rather than a fabricated figure. +// ladder mechanical: only a counter meter can yield a hardware class (on-chip, +// or a calibrated instrument when constructed with a certificate reference), +// each with its declared uncertainty; a model meter can only ever say +// ModelBased; and a meter with no hardware returns None rather than a +// fabricated figure. #[cfg(feature = "energy_meter")] pub mod energy_meter; diff --git a/wai/wai-rs/src/provenance.rs b/wai/wai-rs/src/provenance.rs index 6ce66a68e..67ec2b0c5 100644 --- a/wai/wai-rs/src/provenance.rs +++ b/wai/wai-rs/src/provenance.rs @@ -20,7 +20,22 @@ //! the asset's content hash, compare), **lineage** (the signed step //! chain), and **energy accounting** (measured joules per step) — for //! bytes WAI never has to understand. +//! +//! Each step's figure carries its acquisition class as an optional label +//! (`extensions/energy-measurement.md` §2.8), because the steps of one +//! lineage can be metered by different parties in different ways. A step +//! with no label keeps the legacy leaf hash; a labelled step hashes under the +//! `0x02` step domain with its class after the figure. A receipt with no +//! labelled step keeps the legacy signing payload byte for byte; one with any +//! labelled step signs under the `0x02` receipt domain and writes every +//! step's optional class (`0` when unlabelled) after that step's figure. +//! `total_joules_micro` carries no label of its own: a sum over steps of +//! different classes is only as strong as its weakest step. +use crate::energy_class::{ + check_label, json_with_label, label_coheres, label_from_json, labelled_domain, write_label, + write_optional, AcquisitionError, EnergyProvenance, +}; use crate::world::receipt::merkle_root; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; @@ -55,13 +70,39 @@ pub struct ProvStep { pub work: u64, /// Measured/carried microjoules for the step (0 = unmetered). pub joules_micro: u64, + /// How the step's `joules_micro` was acquired — an optional label + /// (`extensions/energy-measurement.md` §2.8). `None` is an unlabelled + /// figure, as every step sealed before the label existed carries; it is + /// never read as any class. Bound into the step's leaf hash when present. + pub energy_provenance: Option, } impl ProvStep { + /// An unlabelled step, as every step was before the label existed. + pub fn new( + op: impl Into, + input_hash: [u8; 32], + output_hash: [u8; 32], + work: u64, + joules_micro: u64, + ) -> ProvStep { + ProvStep { op: op.into(), input_hash, output_hash, work, joules_micro, energy_provenance: None } + } + + /// Label the step's figure with its acquisition class. Refuses whatever + /// [`AssetReceipt::verify`] would refuse: legacy `HwShunt`, a class beside + /// a zero figure, a refined class without its evidence. + pub fn with_energy_class(mut self, class: EnergyProvenance) -> Result { + check_label(self.joules_micro, Some(&class))?; + self.energy_provenance = Some(class); + Ok(self) + } + /// Content hash of the step — the Merkle leaf. pub fn content_hash(&self) -> [u8; 32] { + let label = self.energy_provenance.as_ref(); let mut h = blake3::Hasher::new(); - h.update(DOMAIN_STEP); + h.update(&labelled_domain(DOMAIN_STEP, label)); let op = self.op.as_bytes(); h.update(&(op.len() as u32).to_be_bytes()); h.update(op); @@ -69,6 +110,9 @@ impl ProvStep { h.update(&self.output_hash); h.update(&self.work.to_be_bytes()); h.update(&self.joules_micro.to_be_bytes()); + let mut l = Vec::new(); + write_label(label, &mut l); + h.update(&l); *h.finalize().as_bytes() } } @@ -96,8 +140,15 @@ pub struct AssetReceipt { } impl AssetReceipt { + /// Whether any step carries a label — which puts the receipt's signing + /// payload under the `0x02` domain. + fn labelled(&self) -> Option<&EnergyProvenance> { + self.steps.iter().find_map(|s| s.energy_provenance.as_ref()) + } + fn signing_payload(&self) -> Vec { - let mut o = DOMAIN_RECEIPT.to_vec(); + let any_label = self.labelled(); + let mut o = labelled_domain(DOMAIN_RECEIPT, any_label); o.extend_from_slice(&self.asset_hash); for s in [self.media.as_bytes(), self.capability.as_bytes()] { o.extend_from_slice(&(s.len() as u32).to_be_bytes()); @@ -122,6 +173,9 @@ impl AssetReceipt { o.extend_from_slice(&s.output_hash); o.extend_from_slice(&s.work.to_be_bytes()); o.extend_from_slice(&s.joules_micro.to_be_bytes()); + if any_label.is_some() { + write_optional(s.energy_provenance.as_ref(), &mut o); + } } o.extend_from_slice(&self.signer_pubkey); let id = self.signer_id.as_bytes(); @@ -185,6 +239,9 @@ impl AssetReceipt { if self.steps.iter().map(|s| s.joules_micro).sum::() != self.total_joules_micro { return false; } + if !self.steps.iter().all(|s| label_coheres(s.joules_micro, s.energy_provenance.as_ref())) { + return false; + } let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { return false }; k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)).is_ok() } @@ -196,6 +253,14 @@ impl AssetReceipt { crate::world::asset_content_hash(envelope_bytes) == self.asset_hash } + /// Re-sign whatever the fields now say, bypassing every check — so tests + /// can present `verify()` with a correctly signed receipt that breaks a + /// label rule, and see the rule itself refuse it. + #[cfg(test)] + pub(crate) fn resign_for_test(&mut self, signer: &SigningKey) { + self.sig = signer.sign(&self.signing_payload()).to_bytes(); + } + pub fn receipt_hash(&self) -> [u8; 32] { let mut h = blake3::Hasher::new(); h.update(b"wai:asset-receipt-id\x01"); @@ -209,14 +274,15 @@ impl AssetReceipt { .steps .iter() .map(|s| { - format!( + let step = format!( "{{\"input_hash\":\"{}\",\"joules_micro\":{},\"op\":{},\"output_hash\":\"{}\",\"work\":{}}}", hx(&s.input_hash), s.joules_micro, serde_json::to_string(&s.op).unwrap(), hx(&s.output_hash), s.work - ) + ); + json_with_label(step, s.energy_provenance.as_ref()) }) .collect(); let parent = match self.parent_receipt_hash { @@ -240,7 +306,9 @@ impl AssetReceipt { ) } - /// Parse a receipt from its canonical JSON (for a verifying sink). + /// Parse a receipt from its canonical JSON (for a verifying sink). A + /// malformed step label is refused rather than dropped, so a parse can + /// never turn a labelled step into an unlabelled one. pub fn from_json(s: &str) -> Option { let v: serde_json::Value = serde_json::from_str(s).ok()?; let o = v.as_object()?; @@ -256,6 +324,7 @@ impl AssetReceipt { output_hash: from_hex32(so.get("output_hash")?.as_str()?)?, work: so.get("work")?.as_u64()?, joules_micro: so.get("joules_micro")?.as_u64()?, + energy_provenance: label_from_json(so).ok()?, }); } let parent = match o.get("parent_receipt_hash") { @@ -302,9 +371,9 @@ mod tests { let jpeg = [0x22u8; 32]; let thumb = [0x33u8; 32]; let steps = vec![ - ProvStep { op: "capture".into(), input_hash: [0u8; 32], output_hash: raw, work: 12_000_000, joules_micro: 0 }, - ProvStep { op: "encode.jpeg".into(), input_hash: raw, output_hash: jpeg, work: 12_000_000, joules_micro: 4200 }, - ProvStep { op: "derive.thumbnail".into(), input_hash: jpeg, output_hash: thumb, work: 65_536, joules_micro: 90 }, + ProvStep { op: "capture".into(), input_hash: [0u8; 32], output_hash: raw, work: 12_000_000, joules_micro: 0, energy_provenance: None }, + ProvStep { op: "encode.jpeg".into(), input_hash: raw, output_hash: jpeg, work: 12_000_000, joules_micro: 4200, energy_provenance: None }, + ProvStep { op: "derive.thumbnail".into(), input_hash: jpeg, output_hash: thumb, work: 65_536, joules_micro: 90, energy_provenance: None }, ]; (steps, thumb) } diff --git a/wai/wai-rs/src/receipt_pins.rs b/wai/wai-rs/src/receipt_pins.rs new file mode 100644 index 000000000..514e5f377 --- /dev/null +++ b/wai/wai-rs/src/receipt_pins.rs @@ -0,0 +1,425 @@ +//! Signed-byte pins for every receipt family that seals `joules_micro`. +//! +//! Each family below carried its energy figure with no acquisition class +//! before it was given an optional one. A receipt sealed without a class must +//! keep exactly the signed bytes it had before: otherwise every receipt +//! already issued stops verifying. So each +//! test builds one fixed, unlabelled receipt with a non-zero figure and pins +//! two things computed from the code *before* the class was added: +//! +//! - `receipt_hash()`, which covers the whole signing payload and the +//! signature, so any change to a signed byte changes it; +//! - where the family renders JSON, a BLAKE3 hash of `to_json()`, so the +//! rendered form of a legacy receipt is byte-identical too. +//! +//! The hex values were produced by the pre-change code and must never be +//! edited to make a test pass. A failure here means legacy receipts broke. + +use ed25519_dalek::SigningKey; + +fn key(s: u8) -> SigningKey { + SigningKey::from_bytes(&[s; 32]) +} + +fn hx(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +fn json_hash(j: &str) -> String { + hx(blake3::hash(j.as_bytes()).as_bytes()) +} + +// --------------------------------------------------------------------------- +// One fixed, unlabelled receipt per family, with a non-zero figure. +// --------------------------------------------------------------------------- + +#[cfg(feature = "world")] +fn world() -> crate::world::receipt::SessionReceipt { + use crate::world::Checkpoint; + let cps = vec![ + Checkpoint { tick: 100, hash: [0x11; 32] }, + Checkpoint { tick: 200, hash: [0x22; 32] }, + ]; + crate::world::receipt::SessionReceipt::build(&key(9), "did:key:pin", [0xab; 32], 3, &[], &cps, 12_345, 999, Some([0x44; 32])) +} + +#[cfg(feature = "audio_scene")] +fn audio_scene() -> crate::audio_scene::SceneReceipt { + use crate::audio_scene::{BlockHash, SceneReceipt}; + let blocks: Vec = (0..3u64).map(|i| BlockHash { index: i, hash: [i as u8 + 7; 32] }).collect(); + SceneReceipt::build(&key(9), "did:web:pin#audio", [0x5a; 32], 1, blocks, 777, 4_321, None) +} + +#[cfg(feature = "film")] +fn film() -> crate::film::FilmReceipt { + use crate::film::{FilmReceipt, FrameHash}; + let frames: Vec = (0..5u64).map(|i| FrameHash { index: i, hash: [i as u8 + 1; 32] }).collect(); + FilmReceipt::build(&key(9), "did:web:pin#film", [0xab; 32], 2, frames, 5, 8_080, Some([7; 32])) +} + +#[cfg(feature = "haptic")] +fn haptic() -> crate::haptic::HapticReceipt { + use crate::haptic::{ChannelHash, HapticReceipt}; + let ch: Vec = (0..2u64).map(|i| ChannelHash { index: i, hash: [i as u8 + 3; 32] }).collect(); + HapticReceipt::build(&key(9), "did:web:pin#haptic", [0x3c; 32], 0, ch, 960, 555, None) +} + +#[cfg(feature = "score")] +fn score() -> crate::score::ScoreReceipt { + use crate::score::{ScoreReceipt, SegmentHash}; + let seg: Vec = (0..4u64).map(|i| SegmentHash { index: i, hash: [i as u8 + 9; 32] }).collect(); + ScoreReceipt::build(&key(9), "did:web:pin#score", [0x2e; 32], 5, seg, 48_000, 2_468, Some([1; 32])) +} + +#[cfg(feature = "splat4d")] +fn splat4d() -> crate::splat4d::Splat4dReceipt { + use crate::splat4d::{FrameHash, Splat4dReceipt}; + let fr: Vec = (0..3u64).map(|i| FrameHash { index: i, hash: [i as u8 + 0x40; 32] }).collect(); + Splat4dReceipt::build(&key(9), "did:web:pin#splat4d", [0x4d; 32], 0, fr, 3_000, 9_999, None) +} + +#[cfg(feature = "ternary_receipt")] +fn ternary() -> crate::ternary_receipt::TernaryReceipt { + use crate::ternary::encode_residual; + let stream: Vec = (0..256).map(|i| ((i * 37) % 1400) as i64 - 700).collect(); + let stack = encode_residual(&stream, 8, 2, 4, 3); + crate::ternary_receipt::TernaryReceipt::seal(&key(1), "did:key:pin", &stack, 4_200, Some([0x66; 32])) +} + +#[cfg(feature = "provenance")] +fn provenance_steps() -> Vec { + use crate::provenance::ProvStep; + let raw = [0x10; 32]; + let png = [0x20; 32]; + let jpeg = [0x30; 32]; + vec![ + ProvStep { op: "capture".into(), input_hash: [0u8; 32], output_hash: raw, work: 12_000_000, joules_micro: 0, energy_provenance: None }, + ProvStep { op: "encode.png".into(), input_hash: raw, output_hash: png, work: 12_000_000, joules_micro: 4_200, energy_provenance: None }, + ProvStep { op: "encode.jpeg".into(), input_hash: png, output_hash: jpeg, work: 12_000_000, joules_micro: 90, energy_provenance: None }, + ] +} + +#[cfg(feature = "provenance")] +fn provenance(steps: Vec) -> crate::provenance::AssetReceipt { + crate::provenance::AssetReceipt::build(&key(9), "did:web:pin#prov", [0x30; 32], "image", "wai.image.jpeg", 0, steps, None) +} + +// --------------------------------------------------------------------------- +// The legacy pins. +// --------------------------------------------------------------------------- + +#[cfg(feature = "world")] +#[test] +fn world_session_legacy_bytes() { + let r = world(); + assert!(r.verify()); + assert_eq!(hx(&r.receipt_hash()), "e9fad41501e4d009b436e4f9830fd4f72fd29af8dbb333a6cba919ad6d2a0e72"); + assert_eq!(json_hash(&r.to_json()), "f36e2358488181caac445253cb9c4df138cf174ce52f725ed550ed59d5178d55"); +} + +#[cfg(feature = "audio_scene")] +#[test] +fn audio_scene_legacy_bytes() { + let r = audio_scene(); + assert!(r.verify()); + assert_eq!(hx(&r.receipt_hash()), "0b13c8d93ea2207461af14e5589402d389837839383cfdbad09081a21dd21ab5"); +} + +#[cfg(feature = "film")] +#[test] +fn film_legacy_bytes() { + let r = film(); + assert!(r.verify()); + assert_eq!(hx(&r.receipt_hash()), "e92507a9c11b0b4d2a7f3b940d256fb8a7d22f49705991af358911401f4ebf62"); + assert_eq!(json_hash(&r.to_json()), "58516902783835bb7f36b175bbb1f37589971d3afa402864f26025039c74a030"); +} + +#[cfg(feature = "haptic")] +#[test] +fn haptic_legacy_bytes() { + let r = haptic(); + assert!(r.verify()); + assert_eq!(hx(&r.receipt_hash()), "f06b8e9f5592be27fd953ce168b6d42ef50aa6bc65ee5fb5900543b9b24ccfec"); + assert_eq!(json_hash(&r.to_json()), "20a6aa26ae7cb62fedbe5064e329fedcf7aa5a60a3829a530a5fd0287824c623"); +} + +#[cfg(feature = "score")] +#[test] +fn score_legacy_bytes() { + let r = score(); + assert!(r.verify()); + assert_eq!(hx(&r.receipt_hash()), "61069b0b9e2d341b855a5230c681ceae9358492e4e5f9c883407dbea206f11e9"); + assert_eq!(json_hash(&r.to_json()), "3fd5d6ff397d646efee0d625801706b4c90753973a80d0f494ea5f3c18b3e26d"); +} + +#[cfg(feature = "splat4d")] +#[test] +fn splat4d_legacy_bytes() { + let r = splat4d(); + assert!(r.verify()); + assert_eq!(hx(&r.receipt_hash()), "fa9fb6e12674b6b9e5e662fa51cb99f98df5d2e57046e1436239a926ebbf4655"); + assert_eq!(json_hash(&r.to_json()), "1e2a9d497299e2f2d41eca827d845f568376a30bcbbcf0fc5001a8f9e8a4360a"); +} + +#[cfg(feature = "ternary_receipt")] +#[test] +fn ternary_legacy_bytes() { + let r = ternary(); + assert!(r.verify()); + assert_eq!(hx(&r.receipt_hash()), "3e4d15dc85b6a223cbd7d409161bfaeeb660e625736ffc0aa86f54db8aa6bd41"); + assert_eq!(json_hash(&r.to_json()), "78112ecd5b127992955c1247e17ea3b8f93a8e73c76df4f79de9395c8769b734"); +} + +#[cfg(feature = "provenance")] +#[test] +fn provenance_legacy_bytes() { + let r = provenance(provenance_steps()); + assert!(r.verify()); + assert_eq!(hx(&r.receipt_hash()), "d22c9b26323f3e529d13290c66c8af3cb1e7c4c39debc41650190acfab1340c5"); + assert_eq!(json_hash(&r.to_json()), "70c753ccf508dc652504147dee940131cfbed02e782c194d7e34174c00048c8e"); +} + +// --------------------------------------------------------------------------- +// The optional label, exercised identically on every family. +// --------------------------------------------------------------------------- + +mod labels { + use super::*; + use crate::energy_class::{ + label_from_json, AcquisitionError, CounterFiltering, EnergyProvenance, Uncertainty, + }; + + /// Uniform access to the families' label machinery, for one generic check. + pub(super) trait Family: Clone + std::fmt::Debug { + fn ok(&self) -> bool; + fn id(&self) -> [u8; 32]; + fn label(&mut self) -> &mut Option; + fn figure(&mut self) -> &mut u64; + fn relabel(self, k: &SigningKey, c: EnergyProvenance) -> Result; + fn resign(&mut self, k: &SigningKey); + fn json(&self) -> Option; + } + + macro_rules! family { + ($t:ty, json) => { + family!(@impl $t, |r: &$t| Some(r.to_json())); + }; + ($t:ty, nojson) => { + family!(@impl $t, |_r: &$t| None); + }; + (@impl $t:ty, $json:expr) => { + impl Family for $t { + fn ok(&self) -> bool { self.verify() } + fn id(&self) -> [u8; 32] { self.receipt_hash() } + fn label(&mut self) -> &mut Option { &mut self.energy_provenance } + fn figure(&mut self) -> &mut u64 { &mut self.joules_micro } + fn relabel(self, k: &SigningKey, c: EnergyProvenance) -> Result { + self.with_energy_class(k, c) + } + fn resign(&mut self, k: &SigningKey) { self.resign_for_test(k) } + fn json(&self) -> Option { ($json)(self) } + } + }; + } + + #[cfg(feature = "world")] + family!(crate::world::receipt::SessionReceipt, json); + #[cfg(feature = "audio_scene")] + family!(crate::audio_scene::SceneReceipt, nojson); + #[cfg(feature = "film")] + family!(crate::film::FilmReceipt, json); + #[cfg(feature = "haptic")] + family!(crate::haptic::HapticReceipt, json); + #[cfg(feature = "score")] + family!(crate::score::ScoreReceipt, json); + #[cfg(feature = "splat4d")] + family!(crate::splat4d::Splat4dReceipt, json); + #[cfg(feature = "ternary_receipt")] + family!(crate::ternary_receipt::TernaryReceipt, json); + + pub(super) fn chip(ppm: u32) -> EnergyProvenance { + EnergyProvenance::OnChipCounter { + uncertainty: Uncertainty { relative_ppm: ppm, window_us: 2_977_104 }, + filtering: CounterFiltering::Unknown, + } + } + + /// Everything the label must do, on one family's pinned legacy receipt + /// sealed by `signer`. + pub(super) fn exercise(legacy: R, signer: &SigningKey) { + assert!(legacy.ok()); + let legacy_id = legacy.id(); + + // Labelling re-signs, and the labelled receipt is a different receipt. + let l = legacy.clone().relabel(signer, chip(15_403)).expect("a valid label"); + assert!(l.ok(), "a labelled receipt verifies"); + assert_ne!(l.id(), legacy_id); + + // The label is inside the signature: it cannot be stripped, added, + // restated or promoted in transit. + let mut stripped = l.clone(); + *stripped.label() = None; + assert!(!stripped.ok(), "stripping the label breaks the signature"); + let mut added = legacy.clone(); + *added.label() = Some(chip(15_403)); + assert!(!added.ok(), "adding a label without the signer breaks the signature"); + let mut restated = l.clone(); + *restated.label() = Some(chip(1)); + assert!(!restated.ok(), "restating the declared uncertainty breaks the signature"); + let mut promoted = l.clone(); + *promoted.label() = Some( + EnergyProvenance::calibrated_instrument(Uncertainty { relative_ppm: 15_403, window_us: 2_977_104 }, "cert") + .unwrap(), + ); + assert!(!promoted.ok(), "promoting the class breaks the signature"); + + // Labelling refuses what verify() would refuse. + #[allow(deprecated)] + let hw = EnergyProvenance::HwShunt; + assert_eq!(legacy.clone().relabel(signer, hw.clone()).unwrap_err(), AcquisitionError::LegacyLabel); + assert_eq!(legacy.clone().relabel(&key(8), chip(15_403)).unwrap_err(), AcquisitionError::SignerMismatch); + let mut zero = legacy.clone(); + *zero.figure() = 0; + assert_eq!(zero.relabel(signer, chip(15_403)).unwrap_err(), AcquisitionError::NoFigure); + let undeclared = EnergyProvenance::OnChipCounter { + uncertainty: Uncertainty { relative_ppm: 0, window_us: 1 }, + filtering: CounterFiltering::Unknown, + }; + assert_eq!(legacy.clone().relabel(signer, undeclared.clone()).unwrap_err(), AcquisitionError::NoUncertainty); + + // And verify() itself refuses them even when correctly signed. + for bad in [Some(hw), Some(undeclared)] { + let mut r = legacy.clone(); + *r.label() = bad; + r.resign(signer); + assert!(!r.ok(), "a correctly signed receipt with a refused label must not verify"); + } + let mut zero = l.clone(); + *zero.figure() = 0; + zero.resign(signer); + assert!(!zero.ok(), "a class beside a zero figure must not verify"); + // An unlabelled zero is unmetered and fine, as it always was. + let mut unmetered = legacy.clone(); + *unmetered.figure() = 0; + unmetered.resign(signer); + assert!(unmetered.ok()); + + // JSON: unlabelled renders no class; labelled renders the class, its + // evidence and the labelled receipt's own hash. + if let (Some(lj), Some(j)) = (legacy.json(), l.json()) { + assert!(!lj.contains("energy_provenance") && !lj.contains("energy_uncertainty")); + let v: serde_json::Value = serde_json::from_str(&j).unwrap(); + assert_eq!(label_from_json(v.as_object().unwrap()), Ok(Some(chip(15_403)))); + assert_eq!(v["receipt_hash"], serde_json::Value::from(hx(&l.id()))); + } + } + + #[cfg(feature = "world")] + #[test] + fn world_label() { + exercise(world(), &key(9)); + } + + #[cfg(feature = "audio_scene")] + #[test] + fn audio_scene_label() { + exercise(audio_scene(), &key(9)); + } + + #[cfg(feature = "film")] + #[test] + fn film_label() { + exercise(film(), &key(9)); + } + + #[cfg(feature = "haptic")] + #[test] + fn haptic_label() { + exercise(haptic(), &key(9)); + } + + #[cfg(feature = "score")] + #[test] + fn score_label() { + exercise(score(), &key(9)); + } + + #[cfg(feature = "splat4d")] + #[test] + fn splat4d_label() { + exercise(splat4d(), &key(9)); + } + + #[cfg(feature = "ternary_receipt")] + #[test] + fn ternary_label() { + exercise(ternary(), &key(1)); + // The parser keeps the label, and refuses a malformed one rather than + // dropping it. + use crate::ternary_receipt::TernaryReceipt; + let l = ternary().with_energy_class(&key(1), chip(15_403)).unwrap(); + let back = TernaryReceipt::from_json(&l.to_json()).expect("parse"); + assert_eq!(back, l); + assert!(back.verify()); + let broken = l.to_json().replace("\"energy_provenance\":\"OnChipCounter\",", ""); + assert!(TernaryReceipt::from_json(&broken).is_none(), "evidence without a class is refused"); + let legacy = TernaryReceipt::from_json(&ternary().to_json()).unwrap(); + assert_eq!(legacy.energy_provenance, None); + assert!(legacy.verify()); + } + + #[cfg(feature = "provenance")] + #[test] + fn provenance_step_labels() { + use crate::provenance::AssetReceipt; + let legacy = provenance(provenance_steps()); + // Label only the measured encode step; the capture step stays unmetered. + let mut steps = provenance_steps(); + steps[1] = steps[1].clone().with_energy_class(chip(15_403)).unwrap(); + let l = provenance(steps.clone()); + assert!(l.verify()); + assert_ne!(l.receipt_hash(), legacy.receipt_hash()); + assert_ne!(l.steps[1].content_hash(), legacy.steps[1].content_hash(), "the label is in the leaf"); + assert_eq!(l.steps[2].content_hash(), legacy.steps[2].content_hash(), "an unlabelled step keeps its leaf"); + + // Stripping, adding or restating a step label breaks the receipt. + let mut stripped = l.clone(); + stripped.steps[1].energy_provenance = None; + assert!(!stripped.verify()); + let mut added = legacy.clone(); + added.steps[2].energy_provenance = Some(chip(1)); + assert!(!added.verify()); + let mut restated = l.clone(); + restated.steps[1].energy_provenance = Some(chip(1)); + assert!(!restated.verify()); + + // A step label is refused beside a zero figure or as legacy HwShunt, + // both at labelling and in verify() when correctly signed. + assert_eq!(steps[0].clone().with_energy_class(chip(15_403)).unwrap_err(), AcquisitionError::NoFigure); + #[allow(deprecated)] + let hw = EnergyProvenance::HwShunt; + assert_eq!(steps[2].clone().with_energy_class(hw.clone()).unwrap_err(), AcquisitionError::LegacyLabel); + let mut bad = l.clone(); + bad.steps[0].energy_provenance = Some(chip(15_403)); + bad.merkle_root = crate::world::receipt::merkle_root( + &bad.steps.iter().map(|s| s.content_hash()).collect::>(), + ); + bad.resign_for_test(&key(9)); + assert!(!bad.verify(), "a labelled zero-figure step must not verify"); + let mut bad = l.clone(); + bad.steps[2].energy_provenance = Some(hw); + bad.merkle_root = crate::world::receipt::merkle_root( + &bad.steps.iter().map(|s| s.content_hash()).collect::>(), + ); + bad.resign_for_test(&key(9)); + assert!(!bad.verify(), "a legacy HwShunt step label must not verify"); + + // JSON round trip keeps the step label; a legacy receipt parses unlabelled. + let back = AssetReceipt::from_json(&l.to_json()).expect("parse"); + assert_eq!(back, l); + assert!(back.verify()); + let legacy_back = AssetReceipt::from_json(&legacy.to_json()).unwrap(); + assert!(legacy_back.steps.iter().all(|s| s.energy_provenance.is_none())); + assert!(legacy_back.verify()); + } +} diff --git a/wai/wai-rs/src/score/receipt.rs b/wai/wai-rs/src/score/receipt.rs index 7f64602eb..b14df0d41 100644 --- a/wai/wai-rs/src/score/receipt.rs +++ b/wai/wai-rs/src/score/receipt.rs @@ -17,6 +17,10 @@ use super::Score; use crate::world::receipt::merkle_root; +use crate::energy_class::{ + check_label, json_with_label, label_coheres, labelled_domain, write_label, AcquisitionError, + EnergyProvenance, +}; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; const DOMAIN_RECEIPT: &[u8] = b"wai:score-receipt\x01"; @@ -44,6 +48,11 @@ pub struct ScoreReceipt { pub work_note_samples: u64, /// Carried microjoules (native meter supplies; 0 = unmetered). pub joules_micro: u64, + /// How `joules_micro` was acquired — an optional label + /// (`extensions/energy-measurement.md` §2.8). `None` is an unlabelled + /// figure, as every receipt sealed before the label existed carries; it is + /// never read as any class. Inside the signature when present. + pub energy_provenance: Option, pub parent_receipt_hash: Option<[u8; 32]>, pub signer_pubkey: [u8; 32], pub signer_id: String, @@ -73,13 +82,15 @@ impl Score { impl ScoreReceipt { fn signing_payload(&self) -> Vec { - let mut o = DOMAIN_RECEIPT.to_vec(); + let label = self.energy_provenance.as_ref(); + let mut o = labelled_domain(DOMAIN_RECEIPT, label); o.extend_from_slice(&self.score_hash); o.extend_from_slice(&self.group_id.to_be_bytes()); o.extend_from_slice(&self.merkle_root); o.extend_from_slice(&(self.segments.len() as u64).to_be_bytes()); o.extend_from_slice(&self.work_note_samples.to_be_bytes()); o.extend_from_slice(&self.joules_micro.to_be_bytes()); + write_label(label, &mut o); match self.parent_receipt_hash { Some(h) => { o.push(1); @@ -117,6 +128,7 @@ impl ScoreReceipt { merkle_root: merkle_root(&leaves), work_note_samples, joules_micro, + energy_provenance: None, parent_receipt_hash, signer_pubkey: signer.verifying_key().to_bytes(), signer_id: signer_id.into(), @@ -127,6 +139,9 @@ impl ScoreReceipt { } pub fn verify(&self) -> bool { + if !label_coheres(self.joules_micro, self.energy_provenance.as_ref()) { + return false; + } let leaves: Vec<[u8; 32]> = self.segments.iter().map(|b| b.hash).collect(); if merkle_root(&leaves) != self.merkle_root { return false; @@ -135,6 +150,33 @@ impl ScoreReceipt { k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)).is_ok() } + /// Label the figure with its acquisition class and re-sign + /// (`extensions/energy-measurement.md` §2.8). `signer` must be the key + /// that sealed the receipt. Refuses whatever [`Self::verify`] would + /// refuse: legacy `HwShunt`, a class beside a zero figure, a refined class + /// without its evidence. + pub fn with_energy_class( + mut self, + signer: &SigningKey, + class: EnergyProvenance, + ) -> Result { + if signer.verifying_key().to_bytes() != self.signer_pubkey { + return Err(AcquisitionError::SignerMismatch); + } + check_label(self.joules_micro, Some(&class))?; + self.energy_provenance = Some(class); + self.sig = signer.sign(&self.signing_payload()).to_bytes(); + Ok(self) + } + + /// Re-sign whatever the fields now say, bypassing every check — so tests + /// can present `verify()` with a correctly signed receipt that breaks a + /// label rule, and see the rule itself refuse it. + #[cfg(test)] + pub(crate) fn resign_for_test(&mut self, signer: &SigningKey) { + self.sig = signer.sign(&self.signing_payload()).to_bytes(); + } + pub fn receipt_hash(&self) -> [u8; 32] { let mut h = blake3::Hasher::new(); h.update(b"wai:score-receipt-id\x01"); @@ -150,7 +192,7 @@ impl ScoreReceipt { Some(h) => format!("\"{}\"", hx(&h)), None => "null".into(), }; - format!( + let json = format!( "{{\"kind\":\"audio-score\",\"group_id\":{},\"joules_micro\":{},\"leaf_count\":{},\"parent_receipt_hash\":{},\"receipt_hash\":\"{}\",\"root_hash\":\"{}\",\"score_hash\":\"{}\",\"segment_hashes\":[{}],\"sig\":\"{}\",\"signer_id\":{},\"signer_pubkey\":\"{}\",\"work_note_samples\":{}}}", self.group_id, self.joules_micro, @@ -164,7 +206,8 @@ impl ScoreReceipt { serde_json::to_string(&self.signer_id).unwrap(), hx(&self.signer_pubkey), self.work_note_samples, - ) + ); + json_with_label(json, self.energy_provenance.as_ref()) } } diff --git a/wai/wai-rs/src/splat4d/receipt.rs b/wai/wai-rs/src/splat4d/receipt.rs index a76452fbe..88f343500 100644 --- a/wai/wai-rs/src/splat4d/receipt.rs +++ b/wai/wai-rs/src/splat4d/receipt.rs @@ -8,6 +8,10 @@ use super::Splat4dClipData; use crate::world::receipt::merkle_root; +use crate::energy_class::{ + check_label, json_with_label, label_coheres, labelled_domain, write_label, AcquisitionError, + EnergyProvenance, +}; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; const DOMAIN_RECEIPT: &[u8] = b"wai:splat4d-receipt\x01"; @@ -29,6 +33,11 @@ pub struct Splat4dReceipt { pub work_gaussian_frames: u64, /// Carried microjoules (native meter supplies; 0 = unmetered). pub joules_micro: u64, + /// How `joules_micro` was acquired — an optional label + /// (`extensions/energy-measurement.md` §2.8). `None` is an unlabelled + /// figure, as every receipt sealed before the label existed carries; it is + /// never read as any class. Inside the signature when present. + pub energy_provenance: Option, pub parent_receipt_hash: Option<[u8; 32]>, pub signer_pubkey: [u8; 32], pub signer_id: String, @@ -46,13 +55,15 @@ impl Splat4dClipData { impl Splat4dReceipt { fn signing_payload(&self) -> Vec { - let mut o = DOMAIN_RECEIPT.to_vec(); + let label = self.energy_provenance.as_ref(); + let mut o = labelled_domain(DOMAIN_RECEIPT, label); o.extend_from_slice(&self.clip_hash); o.extend_from_slice(&self.group_id.to_be_bytes()); o.extend_from_slice(&self.merkle_root); o.extend_from_slice(&(self.frames.len() as u64).to_be_bytes()); o.extend_from_slice(&self.work_gaussian_frames.to_be_bytes()); o.extend_from_slice(&self.joules_micro.to_be_bytes()); + write_label(label, &mut o); match self.parent_receipt_hash { Some(h) => { o.push(1); @@ -90,6 +101,7 @@ impl Splat4dReceipt { merkle_root: merkle_root(&leaves), work_gaussian_frames, joules_micro, + energy_provenance: None, parent_receipt_hash, signer_pubkey: signer.verifying_key().to_bytes(), signer_id: signer_id.into(), @@ -100,6 +112,9 @@ impl Splat4dReceipt { } pub fn verify(&self) -> bool { + if !label_coheres(self.joules_micro, self.energy_provenance.as_ref()) { + return false; + } let leaves: Vec<[u8; 32]> = self.frames.iter().map(|b| b.hash).collect(); if merkle_root(&leaves) != self.merkle_root { return false; @@ -108,6 +123,33 @@ impl Splat4dReceipt { k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)).is_ok() } + /// Label the figure with its acquisition class and re-sign + /// (`extensions/energy-measurement.md` §2.8). `signer` must be the key + /// that sealed the receipt. Refuses whatever [`Self::verify`] would + /// refuse: legacy `HwShunt`, a class beside a zero figure, a refined class + /// without its evidence. + pub fn with_energy_class( + mut self, + signer: &SigningKey, + class: EnergyProvenance, + ) -> Result { + if signer.verifying_key().to_bytes() != self.signer_pubkey { + return Err(AcquisitionError::SignerMismatch); + } + check_label(self.joules_micro, Some(&class))?; + self.energy_provenance = Some(class); + self.sig = signer.sign(&self.signing_payload()).to_bytes(); + Ok(self) + } + + /// Re-sign whatever the fields now say, bypassing every check — so tests + /// can present `verify()` with a correctly signed receipt that breaks a + /// label rule, and see the rule itself refuse it. + #[cfg(test)] + pub(crate) fn resign_for_test(&mut self, signer: &SigningKey) { + self.sig = signer.sign(&self.signing_payload()).to_bytes(); + } + pub fn receipt_hash(&self) -> [u8; 32] { let mut h = blake3::Hasher::new(); h.update(b"wai:splat4d-receipt-id\x01"); @@ -123,7 +165,7 @@ impl Splat4dReceipt { Some(h) => format!("\"{}\"", hx(&h)), None => "null".into(), }; - format!( + let json = format!( "{{\"kind\":\"splat4d-clip\",\"clip_hash\":\"{}\",\"frame_hashes\":[{}],\"group_id\":{},\"joules_micro\":{},\"leaf_count\":{},\"parent_receipt_hash\":{},\"receipt_hash\":\"{}\",\"root_hash\":\"{}\",\"sig\":\"{}\",\"signer_id\":{},\"signer_pubkey\":\"{}\",\"work_gaussian_frames\":{}}}", hx(&self.clip_hash), objs.join(","), @@ -137,7 +179,8 @@ impl Splat4dReceipt { serde_json::to_string(&self.signer_id).unwrap(), hx(&self.signer_pubkey), self.work_gaussian_frames, - ) + ); + json_with_label(json, self.energy_provenance.as_ref()) } } diff --git a/wai/wai-rs/src/ternary_receipt.rs b/wai/wai-rs/src/ternary_receipt.rs index 4f6652192..b66d0439b 100644 --- a/wai/wai-rs/src/ternary_receipt.rs +++ b/wai/wai-rs/src/ternary_receipt.rs @@ -11,7 +11,8 @@ //! by anyone from the container, a sink cannot inflate it. **Verified.** //! - **joules_micro** — measured marginal energy the sink spent reconstructing. //! **Attested** by signature (the `wai_quantum_meter` IOReport pattern applies -//! unchanged). +//! unchanged), with its acquisition class as an optional label +//! (`extensions/energy-measurement.md` §2.8). //! //! Both bind to two hashes: `payload_hash` (BLAKE3 of the canonical `WQTT` //! bytes — tamper-evidence) and `reconstruction_hash` (the decoded signal — @@ -19,6 +20,10 @@ //! Ed25519, reused, chaining via `parent_receipt_hash`. use crate::ternary::{from_wqtt, TernaryStack}; +use crate::energy_class::{ + check_label, json_with_label, label_coheres, label_from_json, labelled_domain, write_label, + AcquisitionError, EnergyProvenance, +}; use crate::world::receipt::merkle_root; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; @@ -66,6 +71,11 @@ pub struct TernaryReceipt { /// Measured microjoules the sink spent reconstructing (`0` = unmetered). /// Attested by the signature, not independently verifiable. pub joules_micro: u64, + /// How `joules_micro` was acquired — an optional label + /// (`extensions/energy-measurement.md` §2.8). `None` is an unlabelled + /// figure, as every receipt sealed before the label existed carries; it is + /// never read as any class. Inside the signature when present. + pub energy_provenance: Option, pub merkle_root: [u8; 32], pub parent_receipt_hash: Option<[u8; 32]>, pub signer_pubkey: [u8; 32], @@ -79,13 +89,15 @@ impl TernaryReceipt { } fn signing_payload(&self) -> Vec { - let mut o = DOMAIN_RECEIPT.to_vec(); + let label = self.energy_provenance.as_ref(); + let mut o = labelled_domain(DOMAIN_RECEIPT, label); o.extend_from_slice(&self.payload_hash); o.extend_from_slice(&self.reconstruction_hash); o.extend_from_slice(&self.n_signal.to_be_bytes()); o.extend_from_slice(&self.n_layers.to_be_bytes()); o.extend_from_slice(&self.trit_macs.to_be_bytes()); o.extend_from_slice(&self.joules_micro.to_be_bytes()); + write_label(label, &mut o); o.extend_from_slice(&self.merkle_root); match self.parent_receipt_hash { Some(h) => { @@ -120,6 +132,7 @@ impl TernaryReceipt { n_layers: stack.layers.len() as u32, trit_macs: stack.trit_macs(), joules_micro, + energy_provenance: None, merkle_root: [0u8; 32], parent_receipt_hash, signer_pubkey: signer.verifying_key().to_bytes(), @@ -134,6 +147,9 @@ impl TernaryReceipt { /// Merkle root recomputes and the Ed25519 signature is valid. Does not /// re-decode — see [`Self::verify_reconstruction`]. pub fn verify(&self) -> bool { + if !label_coheres(self.joules_micro, self.energy_provenance.as_ref()) { + return false; + } if merkle_root(&self.leaves()) != self.merkle_root { return false; } @@ -169,6 +185,33 @@ impl TernaryReceipt { self.verify() } + /// Label the figure with its acquisition class and re-sign + /// (`extensions/energy-measurement.md` §2.8). `signer` must be the key + /// that sealed the receipt. Refuses whatever [`Self::verify`] would + /// refuse: legacy `HwShunt`, a class beside a zero figure, a refined class + /// without its evidence. + pub fn with_energy_class( + mut self, + signer: &SigningKey, + class: EnergyProvenance, + ) -> Result { + if signer.verifying_key().to_bytes() != self.signer_pubkey { + return Err(AcquisitionError::SignerMismatch); + } + check_label(self.joules_micro, Some(&class))?; + self.energy_provenance = Some(class); + self.sig = signer.sign(&self.signing_payload()).to_bytes(); + Ok(self) + } + + /// Re-sign whatever the fields now say, bypassing every check — so tests + /// can present `verify()` with a correctly signed receipt that breaks a + /// label rule, and see the rule itself refuse it. + #[cfg(test)] + pub(crate) fn resign_for_test(&mut self, signer: &SigningKey) { + self.sig = signer.sign(&self.signing_payload()).to_bytes(); + } + /// Stable id for chaining a derived receipt via `parent_receipt_hash`. pub fn receipt_hash(&self) -> [u8; 32] { let mut h = blake3::Hasher::new(); @@ -184,7 +227,7 @@ impl TernaryReceipt { Some(h) => format!("\"{}\"", hx(&h)), None => "null".into(), }; - format!( + let json = format!( "{{\"kind\":\"ternary-tt\",\"joules_micro\":{},\"merkle_root\":\"{}\",\ \"n_layers\":{},\"n_signal\":{},\"parent_receipt_hash\":{},\ \"payload_hash\":\"{}\",\"receipt_hash\":\"{}\",\ @@ -202,10 +245,14 @@ impl TernaryReceipt { serde_json::to_string(&self.signer_id).unwrap(), hx(&self.signer_pubkey), self.trit_macs, - ) + ); + json_with_label(json, self.energy_provenance.as_ref()) } - /// Parse a receipt from its canonical JSON. + /// Parse a receipt from its canonical JSON. A malformed label (evidence + /// without a class, a refined class without its evidence, an unknown + /// label) is refused rather than dropped, so a parse can never turn a + /// labelled receipt into an unlabelled one. pub fn from_json(s: &str) -> Option { let v: serde_json::Value = serde_json::from_str(s).ok()?; let o = v.as_object()?; @@ -221,6 +268,7 @@ impl TernaryReceipt { n_layers: u("n_layers")? as u32, trit_macs: u("trit_macs")?, joules_micro: u("joules_micro")?, + energy_provenance: label_from_json(o).ok()?, merkle_root: from_hex32(o.get("merkle_root")?.as_str()?)?, parent_receipt_hash: parent, signer_pubkey: from_hex32(o.get("signer_pubkey")?.as_str()?)?, diff --git a/wai/wai-rs/src/video_receipt.rs b/wai/wai-rs/src/video_receipt.rs index b8c4ba91e..4ae13a7a9 100644 --- a/wai/wai-rs/src/video_receipt.rs +++ b/wai/wai-rs/src/video_receipt.rs @@ -42,44 +42,16 @@ const DOMAIN_RECEIPT_ID: &[u8] = b"wai:video-receipt-id\x01"; const DOMAIN_FRAME: &[u8] = b"wai:video-rframe\x01"; const DOMAIN_CLIP: &[u8] = b"wai:video-clip\x01"; -/// How an energy figure was acquired — the family's ladder, reused verbatim -/// from `mesh-schema` / `proof-schema` rather than reinvented for this class. +/// How an energy figure was acquired — the family's ladder, with the hardware +/// rung refined into an on-chip counter class and a calibrated-instrument class +/// (see [`crate::energy_class`], where the one definition lives). /// /// A joule figure without this label is not comparable to another: the same /// number can mean a counter reading, an arithmetic model, or a constant. /// Sealing the label beside the number is the whole point — the number is not /// the contribution, the number *plus its acquisition class* is. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Provenance { - /// Read from a hardware energy interface (RAPL / NVML / SMC / IOReport / - /// shunt). Requires a present, non-zero measurement. - HwShunt, - /// Computed from a calibrated per-operation model (e.g. wall time x a power - /// constant). A real number, honestly labelled as derived. - ModelBased, - /// A coarse constant or load estimator. - Estimator, -} - -impl Provenance { - /// The receipt wire label. - pub fn label(self) -> &'static str { - match self { - Provenance::HwShunt => "HwShunt", - Provenance::ModelBased => "ModelBased", - Provenance::Estimator => "Estimator", - } - } - - /// The byte written into the signing payload. - fn tag(self) -> u8 { - match self { - Provenance::HwShunt => 1, - Provenance::ModelBased => 2, - Provenance::Estimator => 3, - } - } -} +pub use crate::energy_class::EnergyProvenance as Provenance; +use crate::energy_class::{figure_and_class_cohere, write_optional}; /// `(frame_index, BLAKE3 over the frame's decoded RGB bytes)`. /// @@ -168,7 +140,7 @@ impl VideoReceipt { o.extend_from_slice(&(self.frames.len() as u64).to_be_bytes()); o.extend_from_slice(&self.work_pixel_ops.to_be_bytes()); o.extend_from_slice(&self.joules_micro.to_be_bytes()); - o.push(self.energy_provenance.map_or(0, Provenance::tag)); + write_optional(self.energy_provenance.as_ref(), &mut o); match self.parent_receipt_hash { Some(h) => { o.push(1); @@ -280,12 +252,12 @@ impl VideoReceipt { } // A figure and its acquisition class stand or fall together. An // unlabelled non-zero figure is a number nobody can compare; a labelled - // zero claims a measurement that did not happen. Both are rejected here - // rather than left to a reader's charity. - match (self.energy_provenance, self.joules_micro) { - (None, 0) => {} - (Some(_), n) if n > 0 => {} - _ => return false, + // zero claims a measurement that did not happen; a hardware class with + // no declared uncertainty says nothing about how far the figure can be + // leaned on. All are rejected here rather than left to a reader's + // charity. + if !figure_and_class_cohere(self.joules_micro, self.energy_provenance.as_ref()) { + return false; } let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { return false }; k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)).is_ok() @@ -332,9 +304,9 @@ impl VideoReceipt { /// `None` when unmetered, so an absent measurement can never be read as /// "zero energy" (SPEC: state what you did, never claim what you did not). pub fn uj_per_kilopixel_op(&self) -> Option<(f64, Provenance)> { - match (self.energy_provenance, self.work_pixel_ops) { + match (&self.energy_provenance, self.work_pixel_ops) { (Some(p), w) if self.joules_micro > 0 && w > 0 => { - Some((self.joules_micro as f64 * 1000.0 / w as f64, p)) + Some((self.joules_micro as f64 * 1000.0 / w as f64, p.clone())) } _ => None, } @@ -352,13 +324,30 @@ impl VideoReceipt { Some((r, p)) => (format!("{r:.6}"), format!("\"{}\"", p.label())), None => ("null".to_string(), "null".to_string()), }; + // The refined hardware classes carry their evidence; the legacy labels + // carry none, and their rendering is left exactly as it was. + let evidence = match &self.energy_provenance { + Some(Provenance::OnChipCounter { uncertainty, filtering }) => format!( + ",\"energy_uncertainty\":{{\"relative_ppm\":{},\"window_us\":{}}},\"energy_counter_filtering\":\"{}\"", + uncertainty.relative_ppm, + uncertainty.window_us, + filtering.label() + ), + Some(Provenance::CalibratedInstrument { uncertainty, calibration_ref }) => format!( + ",\"energy_uncertainty\":{{\"relative_ppm\":{},\"window_us\":{}}},\"energy_calibration_ref\":{}", + uncertainty.relative_ppm, + uncertainty.window_us, + serde_json::Value::from(calibration_ref.as_str()) + ), + _ => String::new(), + }; format!( concat!( "{{\"profile\":\"wai.video.int_motion/receipt\",", "\"codestream_hash\":\"{}\",\"clip_hash\":\"{}\",", "\"h\":{},\"w\":{},\"frames\":[{}],\"merkle_root\":\"{}\",", "\"work_pixel_ops\":{},\"joules_micro\":{},\"uj_per_kilopixel_op\":{},", - "\"energy_provenance\":{},", + "\"energy_provenance\":{}{},", "\"parent_receipt_hash\":{},\"signer_pubkey\":\"{}\",", "\"signer_id\":\"{}\",\"sig\":\"{}\",\"receipt_hash\":\"{}\"}}" ), @@ -372,6 +361,7 @@ impl VideoReceipt { self.joules_micro, rate, prov, + evidence, parent, hx(&self.signer_pubkey), self.signer_id, diff --git a/wai/wai-rs/src/wai_video_channel.rs b/wai/wai-rs/src/wai_video_channel.rs index 9d6bd399e..457111d21 100644 --- a/wai/wai-rs/src/wai_video_channel.rs +++ b/wai/wai-rs/src/wai_video_channel.rs @@ -27,6 +27,7 @@ use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use crate::quantum_ops::GrantRef; +use crate::energy_class::{figure_and_class_cohere, write_optional}; use crate::wai_video_manifest::EnergyProvenance; use crate::world::receipt::merkle_root; @@ -136,7 +137,7 @@ impl ChannelReceipt { o.extend_from_slice(&self.window_end_ms.to_be_bytes()); o.extend_from_slice(&self.work_schedule_ops.to_be_bytes()); o.extend_from_slice(&self.joules_micro.to_be_bytes()); - o.push(self.energy_provenance.map_or(0, EnergyProvenance::tag_byte)); + write_optional(self.energy_provenance.as_ref(), &mut o); o.extend_from_slice(&self.grant.grant_hash); o.extend_from_slice(&(self.grant.capability.len() as u32).to_be_bytes()); o.extend_from_slice(self.grant.capability.as_bytes()); @@ -229,10 +230,10 @@ impl ChannelReceipt { if aired_in(&self.schedule, self.window_start_ms, self.window_end_ms) != self.aired { return false; } - match (self.energy_provenance, self.joules_micro) { - (None, 0) => {} - (Some(_), n) if n > 0 => {} - _ => return false, + // The figure and its class stand or fall together, and a hardware + // class must carry its declared uncertainty (energy_class). + if !figure_and_class_cohere(self.joules_micro, self.energy_provenance.as_ref()) { + return false; } let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { return false }; k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)).is_ok() @@ -247,9 +248,9 @@ impl ChannelReceipt { /// acquisition class. `None` when unmetered or nothing aired. pub fn uj_per_aired(&self) -> Option<(f64, EnergyProvenance)> { let n = self.aired.len() as u64; - match (self.energy_provenance, n) { + match (&self.energy_provenance, n) { (Some(p), a) if self.joules_micro > 0 && a > 0 => { - Some((self.joules_micro as f64 / a as f64, p)) + Some((self.joules_micro as f64 / a as f64, p.clone())) } _ => None, } @@ -294,7 +295,7 @@ mod tests { fn program_as_run_is_recomputable() { // Window [8s, 13s) spans the tail of A, the whole ad, the head of B. let g = GrantRef::unbounded("video.channel"); - let r = ChannelReceipt::seal(&key(1), "did:key:chan", "fast-42", schedule(), 8_000, 13_000, 6_000, Some(EnergyProvenance::HwShunt), g, None); + let r = ChannelReceipt::seal(&key(1), "did:key:chan", "fast-42", schedule(), 8_000, 13_000, 6_000, Some(crate::energy_class::test_on_chip()), g, None); assert!(r.verify()); assert_eq!(r.aired, vec![0, 1, 2], "all three entries touch the window"); assert_eq!(r.aired_entries().iter().map(|e| e.asset_id.as_str()).collect::>(), ["show-A", "ad-1", "show-B"]); @@ -333,4 +334,19 @@ mod tests { let (uj, _) = w2.uj_per_aired().unwrap(); assert!((uj - 3000.0).abs() < 1e-9, "6000 uJ / 2 aired (ad-1, show-B) = 3000 each"); } + + + fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() + } + + /// A receipt sealed with the legacy `HwShunt` label keeps its signed bytes. + #[test] + #[allow(deprecated)] // sealing a legacy HwShunt receipt is the point + fn legacy_hwshunt_receipt_keeps_its_signed_bytes() { + let g = GrantRef::unbounded("video.channel"); + let r = ChannelReceipt::seal(&key(1), "did:key:chan", "fast-42", schedule(), 8_000, 13_000, 6_000, Some(EnergyProvenance::HwShunt), g, None); + assert!(r.verify()); + assert_eq!(hex(&r.receipt_hash()), "4850a8245f9d3e62227a56791e68689dfa7a566e7d18665bf5dc89521a8e2841"); + } } diff --git a/wai/wai-rs/src/wai_video_keys.rs b/wai/wai-rs/src/wai_video_keys.rs index ae0980cfe..dd09ace8f 100644 --- a/wai/wai-rs/src/wai_video_keys.rs +++ b/wai/wai-rs/src/wai_video_keys.rs @@ -26,6 +26,7 @@ use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use crate::quantum_ops::GrantRef; +use crate::energy_class::{figure_and_class_cohere, write_optional}; use crate::wai_video_manifest::EnergyProvenance; const DOMAIN_RECEIPT: &[u8] = b"wai:video-keys-receipt\x01"; @@ -164,7 +165,7 @@ impl KeyReleaseReceipt { o.extend_from_slice(&self.expiry_epoch.to_be_bytes()); o.extend_from_slice(&self.issued_epoch.to_be_bytes()); o.extend_from_slice(&self.joules_micro.to_be_bytes()); - o.push(self.energy_provenance.map_or(0, EnergyProvenance::tag_byte)); + write_optional(self.energy_provenance.as_ref(), &mut o); o.extend_from_slice(&self.grant.grant_hash); o.extend_from_slice(&(self.grant.capability.len() as u32).to_be_bytes()); o.extend_from_slice(self.grant.capability.as_bytes()); @@ -252,10 +253,10 @@ impl KeyReleaseReceipt { if self.decision.is_granted() != self.key_commit.is_some() { return false; } - match (self.energy_provenance, self.joules_micro) { - (None, 0) => {} - (Some(_), n) if n > 0 => {} - _ => return false, + // The figure and its class stand or fall together, and a hardware + // class must carry its declared uncertainty (energy_class). + if !figure_and_class_cohere(self.joules_micro, self.energy_provenance.as_ref()) { + return false; } let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { return false }; k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)).is_ok() @@ -271,8 +272,8 @@ impl KeyReleaseReceipt { /// Amortised microjoules of decision energy per release, with its /// acquisition class. `None` when unmetered. pub fn uj_per_release(&self) -> Option<(f64, EnergyProvenance)> { - match self.energy_provenance { - Some(p) if self.joules_micro > 0 => Some((self.joules_micro as f64, p)), + match &self.energy_provenance { + Some(p) if self.joules_micro > 0 => Some((self.joules_micro as f64, p.clone())), _ => None, } } @@ -304,7 +305,7 @@ mod tests { fn entitled_and_unexpired_releases_and_commits() { let r = KeyReleaseReceipt::seal( &key(1), "did:key:license", [0xAB; 16], "asset-abc", DrmSystem::Widevine, - "svod:tier2", 1000, 2000, CEK, 300, Some(EnergyProvenance::HwShunt), + "svod:tier2", 1000, 2000, CEK, 300, Some(crate::energy_class::test_on_chip()), grant("svod:tier2"), None, ); assert_eq!(r.decision, ReleaseDecision::Granted); @@ -360,4 +361,22 @@ mod tests { r.sig = key(5).sign(&r.signing_payload()).to_bytes(); assert!(!r.verify()); } + + + fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() + } + + /// A receipt sealed with the legacy `HwShunt` label keeps its signed bytes. + #[test] + #[allow(deprecated)] // sealing a legacy HwShunt receipt is the point + fn legacy_hwshunt_receipt_keeps_its_signed_bytes() { + let r = KeyReleaseReceipt::seal( + &key(1), "did:key:license", [0xAB; 16], "asset-abc", DrmSystem::Widevine, + "svod:tier2", 1000, 2000, CEK, 300, Some(EnergyProvenance::HwShunt), + grant("svod:tier2"), None, + ); + assert!(r.verify()); + assert_eq!(hex(&r.receipt_hash()), "12c976fb6f742aeefb1f71af9a7b4eca01be5ba726bd80e1a8ed22cf1ac28249"); + } } diff --git a/wai/wai-rs/src/wai_video_manifest.rs b/wai/wai-rs/src/wai_video_manifest.rs index e64b67bfa..1d6f52f76 100644 --- a/wai/wai-rs/src/wai_video_manifest.rs +++ b/wai/wai-rs/src/wai_video_manifest.rs @@ -43,40 +43,13 @@ const DOMAIN_RECEIPT_ID: &[u8] = b"wai:video-manifest-receipt-id\x01"; const DOMAIN_CLASS: &[u8] = b"wai:video-manifest-class\x01"; const DOMAIN_SEG: &[u8] = b"wai:video-manifest-seg\x01"; -/// How an energy figure was acquired — the family's ladder, mirrored here rather -/// than pulled from [`crate::video_receipt`] so this module carries no -/// pixel-decode dependency. A joule figure without this label is not comparable -/// to another: the same number can mean a counter reading, a model, or a -/// constant. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum EnergyProvenance { - /// Read from a hardware energy interface (RAPL / NVML / SMC / IOReport). - HwShunt, - /// Computed from a calibrated per-operation model (real, honestly derived). - ModelBased, - /// A coarse constant or load estimator. - Estimator, -} - -impl EnergyProvenance { - pub fn label(self) -> &'static str { - match self { - EnergyProvenance::HwShunt => "HwShunt", - EnergyProvenance::ModelBased => "ModelBased", - EnergyProvenance::Estimator => "Estimator", - } - } - /// The byte written into a signing payload — public so sibling video - /// capabilities (e.g. `wai.video.ssai`) seal the same energy label the same - /// way rather than each keeping a private copy. - pub fn tag_byte(self) -> u8 { - match self { - EnergyProvenance::HwShunt => 1, - EnergyProvenance::ModelBased => 2, - EnergyProvenance::Estimator => 3, - } - } -} +/// How an energy figure was acquired. The one definition lives in +/// [`crate::energy_class`] (no pixel-decode dependency, so this module stays +/// light); re-exported here because every sibling video capability names it +/// through this path. A joule figure without this label is not comparable to +/// another: the same number can mean a counter reading, a model, or a constant. +pub use crate::energy_class::EnergyProvenance; +use crate::energy_class::{figure_and_class_cohere, write_optional}; /// Whether a segment carries programme content or a stitched ad. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -252,7 +225,7 @@ impl ManifestReceipt { o.extend_from_slice(&self.work_compose_ops.to_be_bytes()); o.extend_from_slice(&self.viewers_served.to_be_bytes()); o.extend_from_slice(&self.joules_micro.to_be_bytes()); - o.push(self.energy_provenance.map_or(0, EnergyProvenance::tag_byte)); + write_optional(self.energy_provenance.as_ref(), &mut o); // Grant binding: hash, capability, both ceilings. o.extend_from_slice(&self.grant.grant_hash); o.extend_from_slice(&(self.grant.capability.len() as u32).to_be_bytes()); @@ -355,11 +328,11 @@ impl ManifestReceipt { } // A figure and its acquisition class stand or fall together: an // unlabelled non-zero figure is uncomparable; a labelled zero claims a - // measurement that did not happen. Both rejected. - match (self.energy_provenance, self.joules_micro) { - (None, 0) => {} - (Some(_), n) if n > 0 => {} - _ => return false, + // measurement that did not happen; a hardware class with no declared + // uncertainty says nothing about how far the figure can be leaned on. + // All rejected. + if !figure_and_class_cohere(self.joules_micro, self.energy_provenance.as_ref()) { + return false; } let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { return false }; k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)).is_ok() @@ -396,9 +369,9 @@ impl ManifestReceipt { /// every one of `viewers_served` viewers received identical bytes from one /// generation. `None` when unmetered. pub fn uj_per_viewer(&self) -> Option<(f64, EnergyProvenance)> { - match (self.energy_provenance, self.viewers_served) { + match (&self.energy_provenance, self.viewers_served) { (Some(p), v) if self.joules_micro > 0 && v > 0 => { - Some((self.joules_micro as f64 / v as f64, p)) + Some((self.joules_micro as f64 / v as f64, p.clone())) } _ => None, } @@ -432,6 +405,8 @@ mod tests { } } + use crate::energy_class::test_on_chip as chip; + fn segments() -> Vec { vec![ Segment { index: 0, kind: SegmentKind::Content, uri: "seg0.ts".into(), duration_ms: 6000, ad_id: None }, @@ -443,7 +418,7 @@ mod tests { #[test] fn seal_verify_roundtrip() { let g = GrantRef { grant_hash: [9u8; 32], capability: "video.manifest".into(), joule_ceiling_micro: 5_000_000, funds_ceiling: Some(250) }; - let r = ManifestReceipt::seal(&key(1), "did:key:origin", class(), segments(), 12_000, 800, Some(EnergyProvenance::HwShunt), g, None); + let r = ManifestReceipt::seal(&key(1), "did:key:origin", class(), segments(), 12_000, 800, Some(chip()), g, None); assert!(r.verify()); // The bytes a player received verify against the receipt. let rendered = render_hls(&r.segments); @@ -488,7 +463,7 @@ mod tests { #[test] fn labelled_zero_and_unlabelled_figure_are_rejected() { let g = GrantRef::unbounded("video.manifest"); - let mut r = ManifestReceipt::seal(&key(4), "o", class(), segments(), 1, 500, Some(EnergyProvenance::HwShunt), g, None); + let mut r = ManifestReceipt::seal(&key(4), "o", class(), segments(), 1, 500, Some(chip()), g, None); assert!(r.verify()); // Unlabelled non-zero: uncomparable number. r.energy_provenance = None; @@ -496,8 +471,118 @@ mod tests { assert!(!r.verify()); // Labelled zero: a measurement that did not happen. r.joules_micro = 0; - r.energy_provenance = Some(EnergyProvenance::HwShunt); + r.energy_provenance = Some(chip()); r.sig = key(4).sign(&r.signing_payload()).to_bytes(); assert!(!r.verify()); } + + /// A hardware class with no declared uncertainty is refused even when the + /// signature over it is valid: it says nothing about how far the figure can + /// be leaned on. Built by hand, bypassing the checked constructor, the way + /// a foreign producer could. + #[test] + fn a_refined_class_without_an_uncertainty_is_rejected() { + use crate::energy_class::{CounterFiltering, Uncertainty}; + let g = GrantRef::unbounded("video.manifest"); + for undeclared in [ + EnergyProvenance::OnChipCounter { + uncertainty: Uncertainty { relative_ppm: 0, window_us: 2_000_000 }, + filtering: CounterFiltering::Unknown, + }, + EnergyProvenance::OnChipCounter { + uncertainty: Uncertainty { relative_ppm: 30_000, window_us: 0 }, + filtering: CounterFiltering::Unknown, + }, + EnergyProvenance::CalibratedInstrument { + uncertainty: Uncertainty { relative_ppm: 0, window_us: 60_000_000 }, + calibration_ref: "lab-cert-0412".into(), + }, + EnergyProvenance::CalibratedInstrument { + uncertainty: Uncertainty { relative_ppm: 5_000, window_us: 60_000_000 }, + calibration_ref: String::new(), + }, + ] { + let r = ManifestReceipt::seal(&key(5), "o", class(), segments(), 1, 500, Some(undeclared.clone()), g.clone(), None); + assert!(!r.verify(), "{undeclared:?} must not verify"); + } + } + + /// The declared uncertainty, the window, the filtering state and the + /// calibration reference are all inside the signature: an intermediary + /// cannot tighten an uncertainty or swap a certificate in transit. + #[test] + fn the_evidence_is_signed() { + use crate::energy_class::{CounterFiltering, Uncertainty}; + let g = GrantRef::unbounded("video.manifest"); + let cal = EnergyProvenance::calibrated_instrument( + Uncertainty::new(5_000, 60_000_000).unwrap(), + "https://lab.example/certificates/0412", + ) + .unwrap(); + let r = ManifestReceipt::seal(&key(6), "o", class(), segments(), 1, 500, Some(cal), g.clone(), None); + assert!(r.verify()); + let (_, p) = r.uj_per_viewer().unwrap(); + assert_eq!(p.label(), "CalibratedInstrument"); + + let mut t = r.clone(); + t.energy_provenance = Some(EnergyProvenance::CalibratedInstrument { + uncertainty: Uncertainty { relative_ppm: 1_000, window_us: 60_000_000 }, + calibration_ref: "https://lab.example/certificates/0412".into(), + }); + assert!(!t.verify(), "a tightened uncertainty must break the signature"); + + let mut t = r.clone(); + t.energy_provenance = Some(EnergyProvenance::CalibratedInstrument { + uncertainty: Uncertainty { relative_ppm: 5_000, window_us: 60_000_000 }, + calibration_ref: "https://lab.example/certificates/9999".into(), + }); + assert!(!t.verify(), "a swapped certificate must break the signature"); + + let c = ManifestReceipt::seal(&key(6), "o", class(), segments(), 1, 500, Some(chip()), g, None); + let mut t = c.clone(); + t.energy_provenance = Some(EnergyProvenance::OnChipCounter { + uncertainty: Uncertainty { relative_ppm: 30_000, window_us: 2_000_000 }, + filtering: CounterFiltering::Off, + }); + assert!(!t.verify(), "a restated filtering state must break the signature"); + + // A class cannot be promoted, even between the two hardware refinements. + let mut t = c; + t.energy_provenance = Some(EnergyProvenance::CalibratedInstrument { + uncertainty: Uncertainty { relative_ppm: 30_000, window_us: 2_000_000 }, + calibration_ref: "x".into(), + }); + assert!(!t.verify(), "an on-chip reading cannot be promoted to a calibrated one"); + } + + + fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() + } + + /// Receipts sealed under the three-rung ladder must keep verifying byte for + /// byte. The legacy tags (0 unmetered, 1 `HwShunt`, 2 `ModelBased`, 3 + /// `Estimator`) are followed by no evidence bytes; these digests were + /// captured from that encoding and pin it. + #[test] + #[allow(deprecated)] // sealing a legacy HwShunt receipt is the point + fn legacy_energy_labels_keep_their_signed_bytes() { + let g = GrantRef { grant_hash: [9u8; 32], capability: "video.manifest".into(), joule_ceiling_micro: 5_000_000, funds_ceiling: Some(250) }; + let cases = [ + (Some(EnergyProvenance::HwShunt), 800, "99d75b02f0b9d2fa52f56c20cff05c2b8fe45b3f8531df7fac5acad168681fde"), + (Some(EnergyProvenance::ModelBased), 800, "c3b9339aac2e622d8440aa2433491d37bfdb1afd24f31d67feac377e9926a466"), + (Some(EnergyProvenance::Estimator), 800, "3ec944889b6e766ebd15321dbcdce1f2d3d341f219bc09b3c3e46482f588c051"), + (None, 0, "e327f5a93b08d97ae262a39188151cc941362284a275ca332ff5bc58a910ee97"), + ]; + let want: Vec<&str> = cases.iter().map(|c| c.2).collect(); + let got: Vec = cases + .into_iter() + .map(|(prov, uj, _)| { + let r = ManifestReceipt::seal(&key(1), "did:key:origin", class(), segments(), 12_000, uj, prov, g.clone(), None); + assert!(r.verify()); + hex(&r.receipt_hash()) + }) + .collect(); + assert_eq!(got, want); + } } diff --git a/wai/wai-rs/src/wai_video_package.rs b/wai/wai-rs/src/wai_video_package.rs index 0a1bf5a1c..43b02dec7 100644 --- a/wai/wai-rs/src/wai_video_package.rs +++ b/wai/wai-rs/src/wai_video_package.rs @@ -25,6 +25,7 @@ use std::collections::BTreeSet; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use crate::quantum_ops::GrantRef; +use crate::energy_class::{figure_and_class_cohere, write_optional}; use crate::wai_video_manifest::EnergyProvenance; use crate::world::receipt::merkle_root; @@ -155,7 +156,7 @@ impl PackageReceipt { o.extend_from_slice(&self.stored_bytes.to_be_bytes()); o.extend_from_slice(&self.work_segment_ops.to_be_bytes()); o.extend_from_slice(&self.joules_micro.to_be_bytes()); - o.push(self.energy_provenance.map_or(0, EnergyProvenance::tag_byte)); + write_optional(self.energy_provenance.as_ref(), &mut o); o.extend_from_slice(&self.grant.grant_hash); o.extend_from_slice(&(self.grant.capability.len() as u32).to_be_bytes()); o.extend_from_slice(self.grant.capability.as_bytes()); @@ -248,10 +249,10 @@ impl PackageReceipt { return false; } } - match (self.energy_provenance, self.joules_micro) { - (None, 0) => {} - (Some(_), n) if n > 0 => {} - _ => return false, + // The figure and its class stand or fall together, and a hardware + // class must carry its declared uncertainty (energy_class). + if !figure_and_class_cohere(self.joules_micro, self.energy_provenance.as_ref()) { + return false; } let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { return false }; k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)).is_ok() @@ -272,9 +273,9 @@ impl PackageReceipt { /// Encode-once energy: microjoules per *distinct* segment, with its /// acquisition class. `None` when unmetered. pub fn uj_per_unique_segment(&self) -> Option<(f64, EnergyProvenance)> { - match (self.energy_provenance, self.unique_segments) { + match (&self.energy_provenance, self.unique_segments) { (Some(p), u) if self.joules_micro > 0 && u > 0 => { - Some((self.joules_micro as f64 / u as f64, p)) + Some((self.joules_micro as f64 / u as f64, p.clone())) } _ => None, } @@ -323,7 +324,7 @@ mod tests { fn seal_verify_and_dedup_is_a_fact() { let segs = segment(&source(), 2000); let g = GrantRef::unbounded("video.package"); - let r = PackageReceipt::seal(&key(1), "did:key:pkg", "asset-abc", segs, 12_000, Some(EnergyProvenance::HwShunt), g, None); + let r = PackageReceipt::seal(&key(1), "did:key:pkg", "asset-abc", segs, 12_000, Some(crate::energy_class::test_on_chip()), g, None); assert!(r.verify()); assert_eq!(r.total_segments, 4); assert_eq!(r.unique_segments, 3); @@ -365,4 +366,20 @@ mod tests { assert_eq!(prov, EnergyProvenance::ModelBased); assert!((uj - 3000.0).abs() < 1e-9, "9000 uJ / 3 unique = 3000 uJ each"); } + + + fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() + } + + /// A receipt sealed with the legacy `HwShunt` label keeps its signed bytes. + #[test] + #[allow(deprecated)] // sealing a legacy HwShunt receipt is the point + fn legacy_hwshunt_receipt_keeps_its_signed_bytes() { + let segs = segment(&source(), 2000); + let g = GrantRef::unbounded("video.package"); + let r = PackageReceipt::seal(&key(1), "did:key:pkg", "asset-abc", segs, 12_000, Some(EnergyProvenance::HwShunt), g, None); + assert!(r.verify()); + assert_eq!(hex(&r.receipt_hash()), "71a9da59a3202abb2c3fd22ee692b1485a9c674bb8c311f4c82455e6be29ae2b"); + } } diff --git a/wai/wai-rs/src/wai_video_ssai.rs b/wai/wai-rs/src/wai_video_ssai.rs index f06f57560..75abd565c 100644 --- a/wai/wai-rs/src/wai_video_ssai.rs +++ b/wai/wai-rs/src/wai_video_ssai.rs @@ -33,6 +33,7 @@ use crate::quantum_ops::GrantRef; // capability's public surface is unchanged. pub use crate::wai_video_policy::{decide, AdCandidate, Impression}; use crate::wai_video_policy::billable_spend; +use crate::energy_class::{figure_and_class_cohere, write_optional}; use crate::wai_video_manifest::EnergyProvenance; use crate::world::receipt::merkle_root; @@ -114,7 +115,7 @@ impl SsaiReceipt { o.extend_from_slice(&self.budget_spent_micros.to_be_bytes()); o.extend_from_slice(&self.work_decide_ops.to_be_bytes()); o.extend_from_slice(&self.joules_micro.to_be_bytes()); - o.push(self.energy_provenance.map_or(0, |p| p.tag_byte())); + write_optional(self.energy_provenance.as_ref(), &mut o); o.extend_from_slice(&self.grant.grant_hash); o.extend_from_slice(&(self.grant.capability.len() as u32).to_be_bytes()); o.extend_from_slice(self.grant.capability.as_bytes()); @@ -213,10 +214,10 @@ impl SsaiReceipt { return false; } } - match (self.energy_provenance, self.joules_micro) { - (None, 0) => {} - (Some(_), n) if n > 0 => {} - _ => return false, + // The figure and its class stand or fall together, and a hardware + // class must carry its declared uncertainty (energy_class). + if !figure_and_class_cohere(self.joules_micro, self.energy_provenance.as_ref()) { + return false; } let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { return false }; k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)).is_ok() @@ -232,9 +233,9 @@ impl SsaiReceipt { /// or when nothing billable played. pub fn uj_per_impression(&self) -> Option<(f64, EnergyProvenance)> { let n = self.billable_impressions() as u64; - match (self.energy_provenance, n) { + match (&self.energy_provenance, n) { (Some(p), v) if self.joules_micro > 0 && v > 0 => { - Some((self.joules_micro as f64 / v as f64, p)) + Some((self.joules_micro as f64 / v as f64, p.clone())) } _ => None, } @@ -282,7 +283,7 @@ mod tests { imps[0].quartiles_reached = 5; // ad-a played to complete -> billable imps[1].quartiles_reached = 0; // ad-b never rendered -> not billed let g = GrantRef { grant_hash: [7u8; 32], capability: "video.ssai".into(), joule_ceiling_micro: 1_000_000, funds_ceiling: Some(80_000) }; - let r = SsaiReceipt::seal(&key(1), "did:key:ssai", "avail-1", [9u8; 32], pod_hash(&pod()), imps, 3, 500, Some(EnergyProvenance::HwShunt), g, None); + let r = SsaiReceipt::seal(&key(1), "did:key:ssai", "avail-1", [9u8; 32], pod_hash(&pod()), imps, 3, 500, Some(crate::energy_class::test_on_chip()), g, None); assert!(r.verify()); assert_eq!(r.billable_impressions(), 1); assert_eq!(r.budget_spent_micros, 40_000, "only the started ad bills"); @@ -321,4 +322,22 @@ mod tests { assert_eq!(prov, EnergyProvenance::ModelBased); assert!((uj - 450.0).abs() < 1e-9, "900 uJ / 2 impressions = 450 uJ each"); } + + + fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() + } + + /// A receipt sealed with the legacy `HwShunt` label keeps its signed bytes. + #[test] + #[allow(deprecated)] // sealing a legacy HwShunt receipt is the point + fn legacy_hwshunt_receipt_keeps_its_signed_bytes() { + let mut imps = decide(&pod(), 2, 30_000, 80_000); + imps[0].quartiles_reached = 5; + imps[1].quartiles_reached = 0; + let g = GrantRef { grant_hash: [7u8; 32], capability: "video.ssai".into(), joule_ceiling_micro: 1_000_000, funds_ceiling: Some(80_000) }; + let r = SsaiReceipt::seal(&key(1), "did:key:ssai", "avail-1", [9u8; 32], pod_hash(&pod()), imps, 3, 500, Some(EnergyProvenance::HwShunt), g, None); + assert!(r.verify()); + assert_eq!(hex(&r.receipt_hash()), "7f24dda0e0b85bc01d27fbb98b701c8ed0ff84cde280a24d653f43963f2f25c2"); + } } diff --git a/wai/wai-rs/src/world/receipt.rs b/wai/wai-rs/src/world/receipt.rs index fbaf76133..2d56bdcb4 100644 --- a/wai/wai-rs/src/world/receipt.rs +++ b/wai/wai-rs/src/world/receipt.rs @@ -17,10 +17,19 @@ //! Joules are a **carried** quantity, never fabricated: the builder //! takes a microjoule figure from the caller (the sink-metering path). //! An unmetered session passes `0` and the receipt says so honestly. +//! +//! The figure's acquisition class is an optional label +//! (`extensions/energy-measurement.md` §2.8): [`SessionReceipt::build`] seals +//! an unlabelled receipt whose signed bytes are exactly the legacy ones, and +//! [`SessionReceipt::with_energy_class`] labels and re-signs it. use super::op::CausalOp; use super::replay::Checkpoint; use super::DOMAIN_OPS_BATCH; +use crate::energy_class::{ + check_label, json_with_label, label_coheres, labelled_domain, write_label, AcquisitionError, + EnergyProvenance, +}; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; /// JWP Merkle domain separators (jwp-receipts §2.2), reused verbatim — @@ -54,6 +63,11 @@ pub struct SessionReceipt { /// it is NOT derived inside this builder, and a sink that didn't /// measure signs `0` honestly. pub joules_micro: u64, + /// How `joules_micro` was acquired — an optional label + /// (`extensions/energy-measurement.md` §2.8). `None` is an unlabelled + /// figure, as every receipt sealed before the label existed carries; it is + /// never read as any class. Inside the signature when present. + pub energy_provenance: Option, /// Deterministic node-steps simulated (Σ over ticks of node count) — /// exact and reproducible on any sink, unlike `joules_micro`. This /// is the honest work measure: joules are derived FROM it with a @@ -136,12 +150,14 @@ impl SessionReceipt { /// The signed bytes: world-profile signing payload (extends JWP /// §2.3 with the world hash, checkpoint list, and joules). fn signing_payload(&self) -> Vec { - let mut out = DOMAIN_RECEIPT.to_vec(); + let label = self.energy_provenance.as_ref(); + let mut out = labelled_domain(DOMAIN_RECEIPT, label); out.extend_from_slice(&self.world_hash); out.extend_from_slice(&self.group_id.to_be_bytes()); out.extend_from_slice(&self.merkle_root); out.extend_from_slice(&(self.object_hashes.len() as u64).to_be_bytes()); out.extend_from_slice(&self.joules_micro.to_be_bytes()); + write_label(label, &mut out); out.extend_from_slice(&self.work_steps.to_be_bytes()); // parent: 1-byte present-flag then the hash (domain-stable: a // first receipt and a chained one can never collide). @@ -193,6 +209,7 @@ impl SessionReceipt { object_hashes, merkle_root, joules_micro, + energy_provenance: None, work_steps, parent_receipt_hash, signer_pubkey: signer.verifying_key().to_bytes(), @@ -203,6 +220,33 @@ impl SessionReceipt { receipt } + /// Label the figure with its acquisition class and re-sign + /// (`extensions/energy-measurement.md` §2.8). `signer` must be the key + /// that sealed the receipt. Refuses whatever [`Self::verify`] would + /// refuse: legacy `HwShunt`, a class beside a zero figure, a refined class + /// without its evidence. + pub fn with_energy_class( + mut self, + signer: &SigningKey, + class: EnergyProvenance, + ) -> Result { + if signer.verifying_key().to_bytes() != self.signer_pubkey { + return Err(AcquisitionError::SignerMismatch); + } + check_label(self.joules_micro, Some(&class))?; + self.energy_provenance = Some(class); + self.sig = signer.sign(&self.signing_payload()).to_bytes(); + Ok(self) + } + + /// Re-sign whatever the fields now say, bypassing every check — so tests + /// can present `verify()` with a correctly signed receipt that breaks a + /// label rule, and see the rule itself refuse it. + #[cfg(test)] + pub(crate) fn resign_for_test(&mut self, signer: &SigningKey) { + self.sig = signer.sign(&self.signing_payload()).to_bytes(); + } + /// Stable identity of this receipt: `BLAKE3("wai:world-receipt-id" || /// signing_payload || sig)`. The next receipt in the chain names /// this value as its `parent_receipt_hash`. @@ -221,6 +265,9 @@ impl SessionReceipt { if merkle_root(&self.object_hashes) != self.merkle_root { return false; } + if !label_coheres(self.joules_micro, self.energy_provenance.as_ref()) { + return false; + } let Ok(key) = VerifyingKey::from_bytes(&self.signer_pubkey) else { return false; }; @@ -258,7 +305,7 @@ impl SessionReceipt { Some(h) => format!("\"{}\"", hx(&h)), None => "null".into(), }; - format!( + let json = format!( "{{\"kind\":\"world-session\",\"checkpoints\":[{}],\"group_id\":{},\"joules_micro\":{},\"leaf_count\":{},\"object_hashes\":[{}],\"parent_receipt_hash\":{},\"receipt_hash\":\"{}\",\"root_hash\":\"{}\",\"sig\":\"{}\",\"signer_id\":{},\"signer_pubkey\":\"{}\",\"work_steps\":{},\"world_hash\":\"{}\"}}", cps.join(","), self.group_id, @@ -273,7 +320,8 @@ impl SessionReceipt { hx(&self.signer_pubkey), self.work_steps, hx(&self.world_hash), - ) + ); + json_with_label(json, self.energy_provenance.as_ref()) } } diff --git a/wai/wai-rs/src/world_model.rs b/wai/wai-rs/src/world_model.rs index 7003c3d76..034fe4200 100644 --- a/wai/wai-rs/src/world_model.rs +++ b/wai/wai-rs/src/world_model.rs @@ -168,14 +168,16 @@ pub fn replay(header: &ModelHeader, ops: &[CausalOp], verify_sigs: bool) -> Resu /// [`AssetReceipt`]. When the renderer is a float model the frames aren't /// reproducible, so this receipt is the trust: who rendered, from which session, at /// what energy. `frames` is `(frame content hash, microjoules)`, in display order; -/// must be non-empty. The receipt's `asset_hash` is the last frame. +/// must be non-empty. The receipt's `asset_hash` is the last frame. The frames' figures +/// are unlabelled: this function is not told how they were acquired, and a caller +/// that knows builds the steps itself with [`ProvStep::with_energy_class`]. pub fn frame_receipt( signer: &SigningKey, signer_id: impl Into, session_hash: [u8; 32], frames: &[([u8; 32], u64)], ) -> AssetReceipt { let mut steps = Vec::with_capacity(frames.len()); let mut input = session_hash; // the session is the originating input for (i, (fh, j)) in frames.iter().enumerate() { - steps.push(ProvStep { op: format!("render.frame.{i}"), input_hash: input, output_hash: *fh, work: i as u64, joules_micro: *j }); + steps.push(ProvStep { op: format!("render.frame.{i}"), input_hash: input, output_hash: *fh, work: i as u64, joules_micro: *j, energy_provenance: None }); input = *fh; } let asset = frames.last().map(|f| f.0).unwrap_or(session_hash); diff --git a/wai/wai-rs/tests/video_receipt.rs b/wai/wai-rs/tests/video_receipt.rs index a2d6c00b0..31c6b1fb0 100644 --- a/wai/wai-rs/tests/video_receipt.rs +++ b/wai/wai-rs/tests/video_receipt.rs @@ -9,6 +9,7 @@ use std::path::PathBuf; use ed25519_dalek::SigningKey; +use wai::energy_class::{CounterFiltering, Uncertainty}; use wai::video_receipt::{work_pixel_ops, Provenance, VideoReceipt}; fn dir() -> PathBuf { @@ -21,15 +22,26 @@ fn key(seed: u8) -> SigningKey { SigningKey::from_bytes(&[seed; 32]) } -fn seal(name: &str, uj: u64) -> (VideoReceipt, Vec, serde_json::Value) { +/// An on-chip counter reading as the native meter emits it: a declared 3 % +/// standard uncertainty over a 2.8 s bracket, filtering state not determinable +/// by an unprivileged reader. +fn chip() -> Provenance { + Provenance::on_chip_counter(Uncertainty::new(30_000, 2_800_000).unwrap(), CounterFiltering::Unknown) + .unwrap() +} + +fn seal_as(name: &str, uj: u64, prov: Option) -> (VideoReceipt, Vec, serde_json::Value) { let bytes = raw(name); let bs: serde_json::Value = serde_json::from_slice(&bytes).expect("parse"); - let prov = (uj > 0).then_some(Provenance::HwShunt); let r = VideoReceipt::seal_decode(&key(7), "did:example:meter", &bytes, &bs, uj, prov, None) .expect("seal"); (r, bytes, bs) } +fn seal(name: &str, uj: u64) -> (VideoReceipt, Vec, serde_json::Value) { + seal_as(name, uj, (uj > 0).then(chip)) +} + #[test] fn seals_and_verifies_against_the_committed_clip() { let (r, bytes, bs) = seal("int_video_bitstream.json", 4_200); @@ -47,7 +59,7 @@ fn seals_and_verifies_against_the_committed_clip() { let (rate, prov) = r.uj_per_kilopixel_op().expect("metered"); assert!((rate - 4_200.0 * 1000.0 / 34_560.0).abs() < 1e-9); - assert_eq!(prov, Provenance::HwShunt); + assert_eq!(prov, chip()); } /// An unmetered receipt reports **no** rate. A `0` would read as "this decode @@ -136,7 +148,7 @@ fn receipts_chain_by_parent_hash() { &bytes, &bs, 91_000, - Some(Provenance::HwShunt), + Some(chip()), Some(first.receipt_hash()), ) .expect("seal"); @@ -165,7 +177,7 @@ fn an_energy_figure_and_its_class_are_inseparable() { // Labelling a measurement that never happened is the more dangerous half: // it claims a sensor was in the loop when none was. let mut t = unmetered.clone(); - t.energy_provenance = Some(Provenance::HwShunt); + t.energy_provenance = Some(chip()); assert!(!t.verify(), "labelled zero"); // The class is inside the signature, so an intermediary cannot promote a @@ -174,3 +186,62 @@ fn an_energy_figure_and_its_class_are_inseparable() { t.energy_provenance = Some(Provenance::ModelBased); assert!(!t.verify(), "restated class"); } + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +/// A receipt sealed under the three-rung ladder keeps its signed bytes and its +/// JSON rendering. The digests were captured from the legacy encoding (tag +/// byte, no trailing evidence) and pin it: a verifier holding such a receipt +/// must reach the same verdict after the acquisition-class split as before. +#[test] +#[allow(deprecated)] // sealing a legacy HwShunt receipt is the point +fn legacy_labels_keep_their_signed_bytes() { + let (hw, _, _) = seal_as("int_video_bitstream.json", 4_200, Some(Provenance::HwShunt)); + let (none, _, _) = seal("int_video_bitstream.json", 0); + assert!(hw.verify() && none.verify()); + assert_eq!( + [ + hex(&hw.receipt_hash()), + hex(blake3::hash(hw.to_json().as_bytes()).as_bytes()), + hex(&none.receipt_hash()), + ], + [ + "0dc851f603666496b1358ab427d8a84163c1841eade8970d537bcdc0567b1745", + "1cdcf1ebd778c39aca8614c4aa4d470dc9c5ad7ff680eaf3e1ee7302c39db15b", + "03a88242e999887d0ad9e515d9980755e6aad00989bed8e0ccba35b4bb2dd710", + ] + ); +} + +/// The refined hardware class carries its declared uncertainty into the +/// signature and into the rendered receipt; the same class with no uncertainty +/// does not verify. +#[test] +fn a_refined_class_carries_its_uncertainty_and_cannot_omit_it() { + let (r, _, _) = seal("int_video_bitstream.json", 4_200); + assert!(r.verify()); + let j = r.to_json(); + assert!(j.contains("\"energy_provenance\":\"OnChipCounter\""), "{j}"); + assert!(j.contains("\"energy_uncertainty\":{\"relative_ppm\":30000,\"window_us\":2800000}"), "{j}"); + assert!(j.contains("\"energy_counter_filtering\":\"Unknown\""), "{j}"); + serde_json::from_str::(&j).expect("the receipt renders as valid JSON"); + + // Sealed without an uncertainty (by hand, bypassing the checked + // constructor): the signature is valid, the receipt is not. + let undeclared = Provenance::OnChipCounter { + uncertainty: Uncertainty { relative_ppm: 0, window_us: 2_800_000 }, + filtering: CounterFiltering::Unknown, + }; + let (bad, _, _) = seal_as("int_video_bitstream.json", 4_200, Some(undeclared)); + assert!(!bad.verify(), "a hardware class with no declared uncertainty must not verify"); + + // And a declared uncertainty cannot be tightened after sealing. + let mut t = r.clone(); + t.energy_provenance = Some( + Provenance::on_chip_counter(Uncertainty::new(1_000, 2_800_000).unwrap(), CounterFiltering::Unknown) + .unwrap(), + ); + assert!(!t.verify(), "restated uncertainty"); +} diff --git a/wai/wai-web/src/quantum_ops.rs b/wai/wai-web/src/quantum_ops.rs index 8be86373a..794324dbf 100644 --- a/wai/wai-web/src/quantum_ops.rs +++ b/wai/wai-web/src/quantum_ops.rs @@ -7,6 +7,7 @@ //! owns the receipt. use ed25519_dalek::SigningKey; +use wai::quantum_energy::MaybeLabelled; use wai::quantum_ops::{content_hash, CalibrationReceipt, Evidence, GrantRef, JobReceipt}; use wasm_bindgen::prelude::*; @@ -164,10 +165,13 @@ impl QuantumJob { hex(&self.inner.receipt_hash()) } /// The cross-link: does this job bind to the given calibration state? Confirms - /// the job names that calibration's hash and both receipts verify. + /// the job names that calibration's hash and both receipts verify. The + /// calibration may be in either form — as sealed, or with its joule figure's + /// acquisition class inside the signature — and is named by the identity of the + /// form it is in. pub fn verify_against_calibration(&self, cal_json: &str) -> bool { - match CalibrationReceipt::from_json(cal_json) { - Some(cal) => self.inner.verify_against_calibration(&cal), + match MaybeLabelled::::from_json(cal_json) { + Some(cal) => MaybeLabelled::Unlabelled(self.inner.clone()).verify_against_calibration(&cal), None => false, } } -- 2.54.0 (Apple Git-157) From 50667cd2a48ade414e0503838c7102210c1c7241 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 01:39:56 -0400 Subject: [PATCH 2/4] wai: add wai.neural.int_mlicpp, the MLIC++ entropy topology integer end to end The float wai.neural.mlicpp decodes correctly only while its encoder evaluates the entropy network bit-identically to the decoder. Its validation run, with the encoder at the runtime's default thread count and the decoder single-threaded, put one scale on the other side of a table edge at symbol 416 of 12,288 and decoded garbage from there. Matching the execution settings fixes that run, but it stays a convention between two implementations. int_mlicpp makes the agreement a property of the arithmetic: the hyper-decoder, the entropy network re-run before each of the 2*K channel-slice x checkerboard passes, and the synthesis are all integer networks, so every decoder derives the encoder's scale bucket for every element, on any platform and at any thread count. - wai-rs/src/int_mlicpp.rs: the decoder (a WIM1 payload; a one-file WMB1 model bundle, so a prior's SHA-256 pins every network and table), built from int_hyper_synth's networks, int_entropy's bucketing and the rANS decoder. - Totality. Before the decode allocates anything, each network's shapes are derived from its layer geometry (the new int_hyper_synth::net_shape, which shares its geometry with the checked run), and a header whose shapes the model does not produce, or on which any network would form a buffer over Header::MAX_TENSOR (2^26) elements, is refused. Latents go through int_entropy::dequantize (sym * 2^frac + base, None where i64 cannot hold it; Kani harness), and every network through int_hyper_synth::run_net_checked (run_net layer by layer, only while no value can leave its type). So a decode returns an image or an error for any payload, the same one with and without overflow checks. - IntModel::from_bytes, the compact network form every integer neural decoder loads, is total on damaged bytes: it no longer reserves for the layer count it reads before checking the bytes hold that many (about 480 GB for 2^32 - 1), and forms layer sizes and read offsets in checked arithmetic (they overflowed a 32-bit usize). - run_net and decode share a per-layer function and to_rgb8; every committed vector still reproduces its hash. - Registry: NeuralIntegerExact, Lossy, royalty-free (the decoder is this crate's; the patent status of the techniques was not examined). A new registry test records the basis of every NeuralIntegerExact capability. - The neural dispatch decodes it and re-checks a pinned prior against the exact bytes it decodes with. - tools/wai_int_mlicpp_export.py writes the committed vector tests/vectors/int_mlicpp/. Its weights are synthetic and seeded (no trained model is available), so the vector shows decode-equivalence and nothing about compression quality. - Tests: tests/int_mlicpp.rs (the pinned image, bucket and symbol hashes under every kernel policy and thread count; one unit in each of four chosen parameters, one per value path, changes the image; damaged payloads never panic), tests/int_mlicpp_preflight.rs (a counting global allocator: refusing a header the model cannot fit, or parsing a damaged network, allocates a few KB at most; the decoder without the pre-flight fails it, and aborts on a 3.4 PB allocation for a synthesis wider than the cap), a byte-exact-conformance golden, a neural dispatch test and unit tests. CI runs them on the conformance matrix in both profiles, on the byte-equality matrix, and in neural-gates. - Evidence: all of the above pass, and the pinned hashes reproduce, on macOS arm64 and on wasm32-wasip2 under wasmtime 49.0.1, in dev and release. x86_64 was not measured here (no x86_64 translation layer is installed); the Linux legs come from the byte-exact matrix, and the Windows runner is offline. - SPEC 5 and 7, the README, determinism-tiers 3, 5 and 6, and the CHANGELOG record it. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-byte-equality.yml | 9 +- .github/workflows/wai-conformance.yml | 11 +- .github/workflows/wai-neural-gates.yml | 14 +- wai/CHANGELOG.md | 71 ++ wai/README.md | 1 + wai/SPEC.md | 7 +- .../src/int_mlicpp_golden.rs | 83 ++ wai/byte-exact-conformance/src/lib.rs | 8 + wai/extensions/determinism-tiers.md | 13 +- wai/tools/wai_int_mlicpp_export.py | 478 +++++++++++ wai/wai-rs/src/codecs/mod.rs | 56 +- wai/wai-rs/src/int_entropy.rs | 63 ++ wai/wai-rs/src/int_hyper_synth.rs | 582 +++++++++++-- wai/wai-rs/src/int_mlicpp.rs | 777 ++++++++++++++++++ wai/wai-rs/src/lib.rs | 8 + wai/wai-rs/src/neural/int_mlicpp.rs | 31 + wai/wai-rs/src/neural/mod.rs | 5 + wai/wai-rs/tests/int_kernels_vectors.rs | 13 +- wai/wai-rs/tests/int_mlicpp.rs | 236 ++++++ wai/wai-rs/tests/int_mlicpp_preflight.rs | 139 ++++ wai/wai-rs/tests/neural_native_decode.rs | 42 + .../tests/vectors/int_mlicpp/expected.json | 18 + wai/wai-rs/tests/vectors/int_mlicpp/model.wmb | Bin 0 -> 41781 bytes .../tests/vectors/int_mlicpp/stream.wai | Bin 0 -> 859 bytes 24 files changed, 2587 insertions(+), 78 deletions(-) create mode 100644 wai/byte-exact-conformance/src/int_mlicpp_golden.rs create mode 100644 wai/tools/wai_int_mlicpp_export.py create mode 100644 wai/wai-rs/src/int_mlicpp.rs create mode 100644 wai/wai-rs/src/neural/int_mlicpp.rs create mode 100644 wai/wai-rs/tests/int_mlicpp.rs create mode 100644 wai/wai-rs/tests/int_mlicpp_preflight.rs create mode 100644 wai/wai-rs/tests/vectors/int_mlicpp/expected.json create mode 100644 wai/wai-rs/tests/vectors/int_mlicpp/model.wmb create mode 100644 wai/wai-rs/tests/vectors/int_mlicpp/stream.wai diff --git a/.github/workflows/wai-byte-equality.yml b/.github/workflows/wai-byte-equality.yml index fbe4a7638..6b7b16078 100644 --- a/.github/workflows/wai-byte-equality.yml +++ b/.github/workflows/wai-byte-equality.yml @@ -11,7 +11,10 @@ name: wai cross-architecture byte-equality # Coverage is still-image AND temporal, classical AND learned: the rANS coder, # integer fixed-point synthesis, the JPEG-AI entropy stage, the inter-frame video # decoder (`wai.video.int_motion`), and the neural-keyframe variant -# (`wai.video.int_hyper`) whose I-frame comes out of a learned conditional codec. Video is the strictest leg — inter-frame decode is a +# (`wai.video.int_hyper`) whose I-frame comes out of a learned conditional codec, +# and the MLIC++-topology codec (`wai.neural.int_mlicpp`), whose entropy network runs +# before every checkerboard pass and must give every platform the encoder's scale +# bucket for every element. Video is the strictest leg — inter-frame decode is a # feedback loop, so one divergent half-pel rounding does not stay one byte, it # predicts the next frame and compounds for the rest of the clip. # @@ -34,11 +37,13 @@ on: - 'wai/wai-rs/src/int_video.rs' - 'wai/wai-rs/src/int_transform.rs' - 'wai/wai-rs/src/int_hyper_synth.rs' + - 'wai/wai-rs/src/int_mlicpp.rs' # The fast kernels int_synth, int_transform and int_hyper_synth dispatch to # by default: the goldens here decode through them. - 'wai/wai-rs/src/int_kernels.rs' - 'wai/wai-rs/tests/vectors/int_video/**' - 'wai/wai-rs/tests/vectors/int_hyper/**' + - 'wai/wai-rs/tests/vectors/int_mlicpp/**' - '.github/workflows/wai-byte-equality.yml' push: branches: @@ -56,11 +61,13 @@ on: - 'wai/wai-rs/src/int_video.rs' - 'wai/wai-rs/src/int_transform.rs' - 'wai/wai-rs/src/int_hyper_synth.rs' + - 'wai/wai-rs/src/int_mlicpp.rs' # The fast kernels int_synth, int_transform and int_hyper_synth dispatch to # by default: the goldens here decode through them. - 'wai/wai-rs/src/int_kernels.rs' - 'wai/wai-rs/tests/vectors/int_video/**' - 'wai/wai-rs/tests/vectors/int_hyper/**' + - 'wai/wai-rs/tests/vectors/int_mlicpp/**' - '.github/workflows/wai-byte-equality.yml' jobs: diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index f3cfa2cd1..3e34ba3b9 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -118,12 +118,19 @@ jobs: # different things in each: with overflow checks on (dev) that the fast path panics # where the reference panics, with them off (release) that it returns the # same integers. All pure Rust, so every leg runs them. + # tests/int_mlicpp.rs decodes the committed wai.neural.int_mlicpp vector against + # its pinned hashes (image, every scale bucket, every symbol) under every kernel + # policy and thread count, and checks that damaged payloads are refused rather than + # overflowing — which is why it runs in both profiles too. + # tests/int_mlicpp_preflight.rs (its own binary: it installs a counting global + # allocator) checks that a header the model cannot fit is refused before the + # decode allocates anything sized from it. - name: Fast integer kernels ≡ reference kernels (overflow checks on) working-directory: wai/wai-rs - run: cargo test --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int --lib --test int_kernels_vectors --test int_kernels_geometry + run: cargo test --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int --lib --test int_kernels_vectors --test int_kernels_geometry --test int_mlicpp --test int_mlicpp_preflight - name: Fast integer kernels ≡ reference kernels (release, overflow checks off) working-directory: wai/wai-rs - run: cargo test --release --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int --lib --test int_kernels_vectors --test int_kernels_geometry + run: cargo test --release --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int --lib --test int_kernels_vectors --test int_kernels_geometry --test int_mlicpp --test int_mlicpp_preflight - name: Carriage conformance — every registered capability round-trips byte-exact working-directory: wai/wai-rs run: cargo test --no-default-features --test carriage diff --git a/.github/workflows/wai-neural-gates.yml b/.github/workflows/wai-neural-gates.yml index deb814350..b3d9573ac 100644 --- a/.github/workflows/wai-neural-gates.yml +++ b/.github/workflows/wai-neural-gates.yml @@ -11,6 +11,11 @@ name: WAI neural sink gates # exercise them need no model, so wai-neural-e2e's reference filter never selects # them. This job runs exactly those tests and fails if any of them is not run: # a filter that matches nothing reports "0 passed" and exits 0. +# +# It also runs the one `neural` dispatch test that needs no model download: +# `wai.neural.int_mlicpp` through the strict gate on its committed conformance vector +# (the pinned image; a one-byte-different model bundle refused by the prior pin; +# `replicate` without a prior refused). on: push: @@ -21,6 +26,8 @@ on: - 'wai/wai-rs/src/codecs/mod.rs' - 'wai/wai-rs/src/codecs/prior_form.rs' - 'wai/wai-rs/tests/neural_native_decode.rs' + - 'wai/wai-rs/src/int_mlicpp.rs' + - 'wai/wai-rs/tests/vectors/int_mlicpp/**' - 'wai/wai-rs/Cargo.toml' - 'wai/wai-rs/Cargo.lock' - '.github/workflows/wai-neural-gates.yml' @@ -31,6 +38,8 @@ on: - 'wai/wai-rs/src/codecs/mod.rs' - 'wai/wai-rs/src/codecs/prior_form.rs' - 'wai/wai-rs/tests/neural_native_decode.rs' + - 'wai/wai-rs/src/int_mlicpp.rs' + - 'wai/wai-rs/tests/vectors/int_mlicpp/**' - 'wai/wai-rs/Cargo.toml' - 'wai/wai-rs/Cargo.lock' - '.github/workflows/wai-neural-gates.yml' @@ -56,7 +65,7 @@ jobs: rustup toolchain install 1.98.0 --profile minimal rustup default 1.98.0 echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - - name: Prior-hash refusal and the strict replicate gate + - name: Prior-hash refusal, the strict replicate gate, and the int_mlicpp dispatch working-directory: wai/wai-rs shell: bash # --no-default-features: `neural` needs only `ort`, not the C codec chain. @@ -66,5 +75,6 @@ jobs: set -euo pipefail cargo test --no-default-features --features neural --test neural_native_decode -- \ --exact resolve_checked_verifies_prior_hash strict_decode_gates_unsound_replicate \ + int_mlicpp_decodes_through_the_strict_dispatch \ 2>&1 | tee "$RUNNER_TEMP/neural-gates.log" - grep -q "test result: ok. 2 passed; 0 failed" "$RUNNER_TEMP/neural-gates.log" + grep -q "test result: ok. 3 passed; 0 failed" "$RUNNER_TEMP/neural-gates.log" diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 89b8c2387..9ed97c1f8 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,77 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### `wai.neural.int_mlicpp` — the MLIC++ entropy topology at decode-equivalence + +- New capability `wai.neural.int_mlicpp` (`wai-rs/src/int_mlicpp.rs`, behind + `neural_int`): the channel-autoregressive × checkerboard entropy model of + `wai.neural.mlicpp` — `K` channel slices, each decoded as an anchor pass then a + non-anchor pass, an entropy network over concat[latent decoded so far, hyper] + re-run before every pass — with the hyper-decoder, the entropy network and the + synthesis all integer networks (`int_hyper_synth`), the scale → distribution step + `int_entropy::det_build_index`, and the integer rANS. Registry: `NeuralIntegerExact` + (decode-equivalence), `Lossy`, royalty-free (its decoder is this crate's; the + patent status of the techniques it implements was not examined). +- Why: the float capability's validation run decoded to garbage while its encoder + evaluated the entropy network at its runtime's default thread count and the + decoder single-threaded — one scale fell on the other side of a table edge at + symbol 416 of 12,288, and every later symbol decoded wrong. Matching the execution + settings fixes that run; it remains a convention. Here every scale is an integer + both sides compute exactly, so no thread count, runtime or platform can move a + bucket. +- Wire: a `WIM1` payload (36-byte header, then the hyper-latent and latent rANS + streams) and a one-file `WMB1` model bundle (three `WHM1` networks and the entropy + tables), so a pinned prior's SHA-256 covers everything the reconstruction depends + on. The neural dispatch decodes it (`decode_envelope`, `decode_envelope_strict`) + and checks a pinned prior against the very bytes it decodes with. +- Totality: every value the stream controls is range-checked. Before the decode + allocates anything, each network's shapes are derived from its layer geometry (the + new `int_hyper_synth::net_shape`, which shares its geometry with the checked run): + a header whose shapes the model does not produce, or on which any network would + form a buffer — an activation, a transposed convolution's uncropped plane — over + `Header::MAX_TENSOR` elements, is refused as a model that does not fit the payload. + `tests/int_mlicpp_preflight.rs` counts allocations: refusing such a header + allocates a few kilobytes at most, where the decode without the check allocated + the tensors the header asked for, and on a synthesis wider than the cap aborted + the process on a 3.4-petabyte allocation. Latents go through + the new `int_entropy::dequantize` (`sym · 2^frac + base`, `None` where `i64` cannot + hold it; Kani harness `kani_dequantize_is_exact_or_refused`), and every network + through the new `int_hyper_synth::run_net_checked`, which runs `run_net` layer by + layer only while no product, partial sum or requantised value can leave its type + and refuses otherwise. A test moves one channel's scales across table edges: the + stream leaves the encoder at the first moved bucket, and the decode is refused + when the desynchronised latents would overflow the entropy network. With the + accumulation check removed, a damaged copy of the committed stream panics the + overflow-checked build. +- Conformance vector `wai-rs/tests/vectors/int_mlicpp/` (from + `tools/wai_int_mlicpp_export.py`, deterministic in its seed): **synthetic + weights**, quantised like a trained model's; no trained MLIC++ model is used, so it + shows decode-equivalence and nothing about compression quality. A 64×64 image, 8 + latent channels in 4 slices, all 16 scale buckets used, 47 of 512 residuals and 5 + of 16 hyper-latent symbols coded by the rANS escape. The export computes its + reference in numpy integer arithmetic under explicit int64 range checks and decodes + its own rANS bytes back, pass by pass, before writing. Pinned: the RGB (FNV-1a and + SHA-256) and FNV-1a over the bucket and symbol sequences. +- Evidence: decoded to the pinned bytes on macOS arm64 and on wasm32-wasip2 under + wasmtime; x86_64 not measured locally (determinism-tiers §6). A golden in + `byte-exact-conformance` puts it on the four-platform matrix; + `tests/int_mlicpp.rs` runs on every `wai-conformance` leg in both profiles, and the + dispatch test in `wai-neural-gates`. +- Registry tests now pin a recorded basis for every `NeuralIntegerExact` capability + (`NEURAL_INTEGER_EXACT_EVIDENCE`), as `STANDARD_DEFINED_EVIDENCE` does for + `StandardDefined`. +- `int_hyper_synth::IntModel::from_bytes`, the compact network form the integer + neural decoders load, is total on damaged bytes. It reserved room for the layer + count it read before checking that the bytes held that many layers — about 480 GB + for a count of 2^32 − 1, which aborts wherever that allocation fails — and formed + layer sizes and read offsets in unchecked arithmetic, which overflows a 32-bit + `usize` (on wasm32 the first case panicked with a capacity overflow). Both are + checked now, and a valid model parses to the same layers. +- `int_hyper_synth::run_net` now applies each layer through one function that + `run_net_checked` shares, `run_net_checked` takes each layer's geometry from the + function `net_shape` uses, and `decode`'s pixel quantisation is `to_rgb8`; all + refactors, and every committed vector reproduces its hash. + ### Energy acquisition — the receipts wai-rs and wai-quantum seal can carry their class; meter windows open from rest - [`extensions/energy-measurement.md`] §2.8: the world-session, film and reel, diff --git a/wai/README.md b/wai/README.md index ed96642be..7fc84a63a 100644 --- a/wai/README.md +++ b/wai/README.md @@ -100,6 +100,7 @@ hash: a deployer can require `intent=replicate` to dispatch only to these. | Capability | Integer-exact decode of | Conformance | |---|---|---| | `wai.neural.int_hyper` | image — integer hyperprior σ source + integer entropy + integer synthesis | byte-exact (hash-portable) | +| `wai.neural.int_mlicpp` | image — MLIC++ entropy topology (channel slices × checkerboard passes, an entropy network re-run before each pass) on integer networks | byte-exact (hash-portable) | | `wai.neural.int_synth` | image — integer synthesis only | byte-exact (hash-portable) | | `wai.video.int_hyper` | video — `int_hyper` I-frame + integer half-pel motion-compensated P-frames | byte-exact (hash-portable) | | `wai.audio.int_codec` | audio — learned 1-D transform, integer synthesis → int16 PCM | byte-exact (hash-portable) | diff --git a/wai/SPEC.md b/wai/SPEC.md index aec4d50b6..f5e714355 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -213,6 +213,7 @@ decode route for each is implemented in the reference sink (see the | `wai.neural.mbt2018_mean` | mean-scale hyperprior (Gaussian-conditional entropy) | conditional-entropy family, stronger than factorized | `neural/hyperprior.rs` | | `wai.neural.int_synth` | integer fixed-point synthesis (all-i64 transposed-conv, no float/ONNX) | bit-identical on every machine — soundly backs `intent = replicate` | `neural/int_synth.rs` (QAT weights sink-supplied) | | `wai.neural.int_hyper` | integer fixed-point **conditional** codec — whole bitstream→image path integer (integer hyper-decoder σ → integer-σ entropy → integer synthesis) | the integer-entropy contract fully assembled; high quality (PTQ of mbt2018_mean) bit-identical on every machine | `int_hyper_synth.rs` (tables sink-supplied) | +| `wai.neural.int_mlicpp` | integer fixed-point **MLIC++ entropy topology** — `K` channel slices of the latent, each decoded as a checkerboard anchor pass then a non-anchor pass, with an entropy network over concat[latent decoded so far, hyper] re-run before every pass to give σ‖μ; hyper-decoder, entropy network and synthesis all integer | the channel-autoregressive × checkerboard entropy model at **decode-equivalence**: the encoder and every decoder compute the same σ, so the same distribution for every element, at any thread count; bit-identical on every machine. The committed conformance vector has synthetic weights, so it demonstrates decode-equivalence and states no quality figure | `int_mlicpp.rs` (one-file model bundle sink-supplied; `tools/wai_int_mlicpp_export.py`) | | `wai.image.jpegai` | ISO/IEC 6048 JPEG-AI codestream → RGB — integer entropy front end (mANS, with an integer scale-index derivation) + 16-bit-PTQ integer hyper-decoder, context-model and synthesis networks + dequantisation, latent refinement, post-filters (the eICCI CNN among them) and colour conversion in IEEE-754 float with a fixed evaluation order. Scope: one configuration — 256×256 4:4:4, `model_id` 0 (tools_0), `beta_displacement_log` 0, the high-operation-point synthesis, the eICCI model pair the weight bundle carries, and the other header conditions in [determinism-tiers](extensions/determinism-tiers.md) §5; both decode paths refuse any other codestream. Within it, a sink without the integer weight bundle, or in a non-default floating-point environment, takes a float ONNX decode instead. Only checkpoint constants are sink-supplied | **entropy-consistency** (registry class `NeuralEntropyExact`): the entropy decode, including the scale-index derivation, is integer on both decode paths, so the decoded symbols reproduce on every machine. The exact decoder also pins one reconstruction to RGB, reproducible across machines under the build and floating-point conditions in [determinism-tiers](extensions/determinism-tiers.md) §5–§6 (it checks the floating-point environment before decoding) — a property of that decoder, not of the capability, so `wai.image.jpegai` does not back `replicate` (§7). On one reference codestream the exact decoder stays within max\|Δ\| ≤ 2 of the reference decoder (recorded max\|Δ\| = 1 on ~4.4% of samples); evidence in [determinism-tiers](extensions/determinism-tiers.md) §6. **Downgraded in v0.4.0:** this row previously claimed decode-equivalence, and the registry classed the capability `StandardDefined` | `jpegai_decode.rs` (+ `jpegai_{bitstream,mans,pipeline,synthesis,icci}.rs`) | | `wai.generate.spectral` | deterministic integer GENERATIVE synthesis (`intent=create`) — prompt+seed → integer-LUT spectral composition, no float/ONNX | byte-identical on every machine, pinned to (model, prompt, seed, steps); the create-side of "conformance is a hash" (generation-equivalence) | `generate.rs` (small pinned model bundle) | | `wai.neural.elic` | checkerboard context model (hyperprior + 2-pass spatial context) | the JPEG-AI / MLIC++ entropy class | `neural/context.rs` | @@ -722,8 +723,10 @@ registry as `codecs::capability_replicate_class(cap) -> ReplicateClass`: machine; registered at `decode-equivalence`, and the only neural class that backs `replicate`. (`wai.neural.int_synth` is synthesis only; `wai.neural.int_hyper` is the full conditional codec — σ source, entropy, and - synthesis all integer; `wai.video.int_hyper`, `wai.audio.int_codec` and - `wai.splat.int_codec` are integer throughout as well.) + synthesis all integer; `wai.neural.int_mlicpp` is the channel-autoregressive × + checkerboard entropy model on integer networks, so the σ it re-derives before + each of its `2·K` passes is integer too; `wai.video.int_hyper`, + `wai.audio.int_codec` and `wai.splat.int_codec` are integer throughout as well.) - **`NeuralEntropyExact`** — the entropy decode is integer by construction, so the decoded symbols are byte-identical on every machine; later stages run float. Registered at `entropy-consistency`. An encoder MUST NOT emit `intent = diff --git a/wai/byte-exact-conformance/src/int_mlicpp_golden.rs b/wai/byte-exact-conformance/src/int_mlicpp_golden.rs new file mode 100644 index 000000000..51e5ca23c --- /dev/null +++ b/wai/byte-exact-conformance/src/int_mlicpp_golden.rs @@ -0,0 +1,83 @@ +//! Cross-architecture byte-equality for `wai.neural.int_mlicpp` — the MLIC++ +//! entropy topology (channel slices × checkerboard passes, an entropy network over +//! the latent decoded so far) with every decode-side network integer. +//! +//! The float version of this topology decodes correctly only while its encoder +//! evaluates the entropy network bit-identically to the decoder: the float +//! validation run with a different thread count moved one scale across a table edge +//! at symbol 416 of 12,288, and every later symbol decoded wrong. Here every scale is +//! computed by integer networks, so each platform in the matrix must derive the same +//! bucket for every element as the encoder did — pinned below as a hash over the +//! bucket sequence — and reconstruct the same pixels. +//! +//! The vector (`wai-rs/tests/vectors/int_mlicpp/`, from +//! `tools/wai_int_mlicpp_export.py`) has synthetic weights: it checks +//! decode-equivalence across platforms, not compression quality. + +#![cfg(test)] + +use std::path::PathBuf; + +use sha2::{Digest, Sha256}; + +use crate::int_hyper_synth::fnv1a; +use crate::int_mlicpp::{decode_traced, IntMlicModel}; + +/// SHA-256 of the committed model bundle and payload: the golden holds for these bytes. +const MODEL_SHA256: &str = "7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4"; +const PAYLOAD_SHA256: &str = "5b2bf2dbd36a683d4a62292d1a179e695c511c4971be6859cec41cd980e4f971"; +/// FNV-1a over the y stream's bucket indices (u16 LE) and symbols (i32 LE), in coding order. +const BUCKET_FNV1A: &str = "2d30432f4fb206a0"; +const SYMBOL_FNV1A: &str = "14ace1d4ad3a2446"; +/// The decoded 64×64 RGB8 pixels: FNV-1a (the export tool's integer reference) and +/// SHA-256 (the digest a reconstruction binding records). +const RGB_FNV1A: &str = "7c0b9a4b3423d775"; +const RGB_SHA256: &str = "45040d17b4178b91a0547b435e57a50fa1021101e290a94d1c57e6fbb4583a10"; + +fn read(name: &str) -> Vec { + let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../wai-rs/tests/vectors/int_mlicpp").join(name); + std::fs::read(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display())) +} + +fn sha256(b: &[u8]) -> String { + Sha256::digest(b).iter().map(|x| format!("{x:02x}")).collect() +} + +/// The payload of a WAI1 envelope: magic, u32 manifest length, manifest, u32 payload +/// length, payload (SPEC §2). +fn payload(env: &[u8]) -> &[u8] { + assert_eq!(&env[..4], b"WAI1"); + let ml = u32::from_le_bytes(env[4..8].try_into().unwrap()) as usize; + let p = 8 + ml; + let pl = u32::from_le_bytes(env[p..p + 4].try_into().unwrap()) as usize; + assert_eq!(env.len(), p + 4 + pl, "envelope length"); + &env[p + 4..] +} + +#[test] +fn the_int_mlicpp_vector_decodes_identically() { + let (bundle, env) = (read("model.wmb"), read("stream.wai")); + let pl = payload(&env); + assert_eq!(sha256(&bundle), MODEL_SHA256, "the model bundle is the committed one"); + assert_eq!(sha256(pl), PAYLOAD_SHA256, "the payload is the committed one"); + + let model = IntMlicModel::from_bundle(&bundle).expect("model bundle"); + let (trace, img) = decode_traced(&model, pl); + let img = img.expect("decode"); + let buckets: Vec = trace.buckets.iter().flat_map(|&b| (b as u16).to_le_bytes()).collect(); + let symbols: Vec = trace.symbols.iter().flat_map(|s| s.to_le_bytes()).collect(); + let got = [ + ("buckets", format!("{:016x}", fnv1a(&buckets))), + ("symbols", format!("{:016x}", fnv1a(&symbols))), + ("rgb fnv1a", format!("{:016x}", fnv1a(&img.rgb))), + ("rgb sha256", sha256(&img.rgb)), + ]; + for (what, v) in &got { + println!("int_mlicpp {what}: {v}"); + } + assert_eq!((img.width, img.height), (64, 64)); + assert_eq!(got[0].1, BUCKET_FNV1A, "a bucket differs from the encoder's: the entropy parameters diverged"); + assert_eq!(got[1].1, SYMBOL_FNV1A, "a symbol differs under the encoder's buckets: the stream diverged"); + assert_eq!(got[2].1, RGB_FNV1A, "the pixels differ: the synthesis diverged"); + assert_eq!(got[3].1, RGB_SHA256); +} diff --git a/wai/byte-exact-conformance/src/lib.rs b/wai/byte-exact-conformance/src/lib.rs index 8d1a7acde..4088a4701 100644 --- a/wai/byte-exact-conformance/src/lib.rs +++ b/wai/byte-exact-conformance/src/lib.rs @@ -62,7 +62,15 @@ pub mod int_transform; #[path = "../../wai-rs/src/int_hyper_synth.rs"] pub mod int_hyper_synth; +// The integer MLIC++-topology codec (`wai.neural.int_mlicpp`): channel slices × +// checkerboard passes over int_hyper_synth's networks, int_entropy's bucketing and +// the rANS decoder, all already included here — so the matrix checks that every +// platform derives the encoder's scale bucket for every element. +#[path = "../../wai-rs/src/int_mlicpp.rs"] +pub mod int_mlicpp; + mod golden; mod int_video_golden; mod neural_keyframe_golden; mod jpegai_golden; +mod int_mlicpp_golden; diff --git a/wai/extensions/determinism-tiers.md b/wai/extensions/determinism-tiers.md index f7b99c544..315f8aef9 100644 --- a/wai/extensions/determinism-tiers.md +++ b/wai/extensions/determinism-tiers.md @@ -304,9 +304,9 @@ thing. classification is a substring match (a contract containing `int`, `fixed` or `integer` and none of `f32`, `f64`, `float`), so on its own it verifies the declaration, not the arithmetic. Neither step inspects the decode path a sink - actually runs; for `wai.neural.int_synth`, the one `NeuralIntegerExact` - capability the reference neural dispatch decodes, there is only the integer - path. + actually runs; for `wai.neural.int_synth` and `wai.neural.int_mlicpp`, the + `NeuralIntegerExact` capabilities the reference neural dispatch decodes, there is + only the integer path. - **Receipts.** A receipt that carries a decoded-medium hash as reproducible by any conforming sink MUST come from a `decode-equivalence` capability. A hash over a reconstruction that one implementation pins (§5) is that @@ -350,8 +350,9 @@ tier costs them. | `wai.video.int_hyper` | per-frame | an `int_hyper` keyframe, then integer motion-compensated P-frames | | `wai.audio.int_codec` | int16 PCM | integer synthesis over a factorized rANS latent | | `wai.splat.int_codec` | attribute tensor | integer synthesis over a factorized rANS latent | +| `wai.neural.int_mlicpp` | RGB | integer hyper-decoder; an integer entropy network re-run before each of the `2·K` channel-slice × checkerboard passes, integer scale → distribution step; integer synthesis. Stream values an exact decode cannot hold are refused, not wrapped | -These five are integer throughout, are the capabilities the registry classifies as +These six are integer throughout, are the capabilities the registry classifies as `NeuralIntegerExact`, and are the neural capabilities that back `replicate`. ### `entropy-consistency`: `wai.image.jpegai`, and its exact decoder's pinned reconstruction @@ -492,6 +493,7 @@ day. | 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, or, in the `WAI2` companion form, for the base payload's capability and each companion's | 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 (the pretrained `mbt2018_mean`, exported to integers): the Rust decode reproduces the export tool's integer reference byte for byte (synthesis, hyper-decoder, the whole conditional bitstream, an eight-frame clip), and costs at most 1 dB against the same model's float decode on one pinned photograph | `wai-sota-vectors.yml` regenerates the vectors on the runner from pinned inputs (the photograph's pixels and the checkpoint's bytes by SHA-256, the Python stack by version) and runs both tests with `WAI_REQUIRE_SOTA_VECTORS=1`, under which a missing vector fails instead of skipping. The job also fails on any skip banner in the test log, and runs `require_vectors_rule.rs`, which checks that without the switch a variable naming absent or incomplete inputs fails as well. Rust is checked against the export of the same run on the same machine: the vectors come from float computation, and some of them differed between machines and between runs, so none of their hashes is pinned and none is compared across machines. The workflow has not run through the forge. Its job passed under `forgejo-runner exec` on the Linux runner's host (x86_64 hardware, 2026-09-27) in an earlier form that did not yet run `require_vectors_rule.rs`; its current steps have run on macOS arm64 only, outside a runner | `wai-rs/tests/int_hyper_synth.rs`, `int_video_sota_keyframe.rs`, `require_vectors_rule.rs`; `.github/workflows/wai-sota-vectors.yml` | | `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` | +| `wai.neural.int_mlicpp` on its committed vector (synthetic seeded weights; a 64×64 image, 8 latent channels in 4 slices): the decode reproduces the export tool's independent integer reference byte for byte — the RGB, and every scale bucket and symbol of the latent stream in coding order (512 symbols over all 16 buckets of the scale table, 47 of the symbols coded by the rANS escape) — under the reference kernels and under the fast kernels on 1, 2, 3, 5 and all available threads; one integer unit changed in each of four chosen parameters, one on each path that feeds a value (a hyper-decoder weight, an entropy-network weight, a mean-head bias, a synthesis weight), changes the image; a header whose shapes the model cannot produce, or on which a network would form a buffer over the cap, is refused before anything is allocated (`tests/int_mlicpp_preflight.rs`, which counts allocations); damaged payloads are refused or decode, and never panic in an overflow-checked build | measured 2026-09-29 with rustc 1.98.0 on the source of the change that added this row: macOS arm64 native (the wai-rs tests, with overflow checks on and off; the `byte-exact-conformance` golden, release) and wasm32-wasip2 under wasmtime 49.0.1 (the same tests and golden, release; on wasm the fast kernels run single-threaded as portable scalar code, and `usize` is 32-bit). All pinned hashes matched on both. Not measured: x86_64 — the development machine has no x86_64 translation layer installed, and an x86_64-apple-darwin build of the golden compiles but cannot execute there — nor Linux or Windows. The golden runs on every leg of the byte-exact matrix (`wai-conformance.yml`, `wai-byte-equality.yml`) and `tests/int_mlicpp.rs` on every leg of `wai-conformance.yml` in both profiles; neither has run through the forge yet. The dispatch through `decode_envelope_strict` (the prior pinning the one-file model bundle, the strict gate) runs in `wai-neural-gates.yml`. The weights are synthetic, so nothing here measures compression quality | `wai-rs/tests/int_mlicpp.rs`, `wai-rs/tests/int_mlicpp_preflight.rs`, `byte-exact-conformance/src/int_mlicpp_golden.rs`, `wai-rs/tests/neural_native_decode.rs`; `tools/wai_int_mlicpp_export.py` | So at `entropy-consistency`, WAI's JPEG AI symbol decode is demonstrated across architectures (x86_64 against arm64) once the distribution indices are given; the @@ -504,6 +506,9 @@ reproduce a separate implementation's output, but no CI job runs them and no cross-architecture comparison exists. For full-scale `int_hyper`, identity rests on construction, not on a cross-machine comparison of decoded output: CI checks its decode against the export's integer reference on one machine per run. +`wai.neural.int_mlicpp`'s committed vector decodes to the same pinned bytes on macOS +arm64 and on wasm32-wasip2 (a 32-bit target), measured on the development machine; +its x86_64 and Linux legs await the byte-exact matrix. For the JPEG AI exact decoder's pinned reconstruction (§5), identity across machines rests on construction, and on one comparison: a single codestream on the diff --git a/wai/tools/wai_int_mlicpp_export.py b/wai/tools/wai_int_mlicpp_export.py new file mode 100644 index 000000000..32fd1237d --- /dev/null +++ b/wai/tools/wai_int_mlicpp_export.py @@ -0,0 +1,478 @@ +#!/usr/bin/env python3 +"""Export a small `wai.neural.int_mlicpp` conformance vector. + +`wai.neural.int_mlicpp` is the MLIC++ entropy topology made integer end to end: +the latent `y` is split into K channel slices decoded in order, each slice in two +checkerboard passes (anchors, where (row + col) is even, then non-anchors), and +before every pass an entropy network over concat[y decoded so far, hyper] gives +the scale sigma and the mean mu of every element. The scale picks the element's +distribution by integer comparison (`int_entropy::det_build_index`), the rANS +coder decodes the residual r, and y = r * 2^afrac + mu. Every network on the +decode side is an integer network (per-output-channel int12 weights, +(mult, shift) requantisation, integer IGDN), so the decoder computes exactly the +sigma the encoder computed, on every platform and at every thread count. + +The weights are SYNTHETIC. They are drawn from a fixed seed and quantised the way +a trained model's would be; no trained MLIC++ checkpoint is used. The vector +therefore shows decode-equivalence -- the Rust decoder reproduces this tool's +integer reference byte for byte -- and says nothing about compression quality. + + python tools/wai_int_mlicpp_export.py [--stats] + +Writes into : + model.wmb the decode-side model bundle: magic `WMB1`, then four sections, + each a u32 length and its bytes, in order h_s, entropy, g_s (WHM1 + integer networks, `IntModel::from_bytes`) and the entropy tables + (JSON, the fields of `int_hyper_synth::CondEntropy`). + stream.wai a WAI1 envelope over `wai.neural.int_mlicpp`, intent `replicate`, + pinning model.wmb by SHA-256 as an integer-exact prior. Its payload + is the `WIM1` codestream (see `wai-rs/src/int_mlicpp.rs`). + expected.json what the decode must reproduce: the RGB hashes (FNV-1a and + SHA-256), and FNV-1a over the y stream's bucket indices (u16 LE) + and symbols (i32 LE) in coding order. + +How the expected values are made. The weights are drawn in float from the seed and +quantised; from the quantised weights on, everything is integer. The encoder's +analysis transforms (g_a, h_a) are integer networks too, so the latents, symbols +and reconstruction are integer functions of the quantised weights and the +(integer) source pattern. Two runs on the development machine wrote identical +files; whether another machine's float draws and CompressAI tables come out the +same was not checked, and the conformance check does not depend on it -- the +committed files are the vector. The encoder computes each sigma and mu with the +same integer networks as the decoder; the reference reconstruction runs the +integer synthesis in numpy int64 arithmetic, under explicit range checks that keep +every intermediate inside int64 (numpy wraps silently, so the checks are what make +this reference exact). Before anything is written, the rANS bytes are decoded back +in Python, pass by pass, with CompressAI's decoder and the integer networks, and +must give the encoder's latent exactly. The rANS bytes come from CompressAI's +encoder, whose bitstream the Rust `RansDecoder` reads (bypass-coded escapes +included). The Gaussian tables are CompressAI's `GaussianConditional` tables over +a 16-level scale table; the hyper-latent's per-channel tables come from +`pmf_to_quantized_cdf` over discretised Gaussians. +""" +import argparse +import hashlib +import json +import os +import struct +import sys + +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from wai_sota_int_synth_export import ( # noqa: E402 (the shared integer ops) + conv_int, conv_t_raw, fnv1a, igdn_int, isqrt_vec, model_to_bin, q_weight_conv, q_weight_pc, + det_build_index_vec, +) + +AFRAC = 12 # activations at 2^12 (the int_hyper family's fixed point) +GF = 16 # IGDN gamma at 2^16 +SHIFT = 24 # requantisation shift +SEED = 20260929 + +H = W = 64 # image +CY = 8 # latent channels (M) +CZ = 4 # hyper-latent channels (N) +K = 4 # channel slices, CY / K channels each +SCALE_MIN, SCALE_MAX, SCALE_LEVELS = 0.11, 16.0, 16 +# Gains of the synthetic networks' weight draws, and the scale head's per-channel +# base scales: chosen so the latent spans several integers, the scales spread over +# the table, and a few residuals fall outside their table (escapes). +GA_GAIN, HA_GAIN, HS_GAIN, E1_GAIN, E2_GAIN, GS_OUT_GAIN = 4.0, 1.5, 1.4, 1.0, 0.6, 0.1 +SIGMA_BASE = (0.7, 10.0) +Z_WIDTH = 1.0 # hyper-latent table half-width, in units of the channel's spread +I64_MAX = (1 << 63) - 1 + +CAPABILITY = "wai.neural.int_mlicpp" +PRIOR_ID = "int_mlicpp/synthetic-s20260929" +DETERMINISM = "int-rans/p16+int-mlicpp/i64" + + +# ---------------------------------------------------------------- synthetic float model + +def xavier(rng, shape, fan_in, fan_out, gain): + a = gain * np.sqrt(6.0 / (fan_in + fan_out)) + return rng.uniform(-a, a, size=shape) + + +def f_conv(rng, cin, cout, k, stride, pad, gain, bias=0.05): + return {'kind': 'conv', 'w': xavier(rng, (cout, cin, k, k), cin * k * k, cout * k * k, gain), + 'b': rng.uniform(-bias, bias, size=cout), 'stride': stride, 'pad': pad} + + +def f_convT(rng, cin, cout, k, stride, pad, opad, gain, bias=0.05): + return {'kind': 'convT', 'w': xavier(rng, (cin, cout, k, k), cin * k * k, cout * k * k, gain), + 'b': rng.uniform(-bias, bias, size=cout), 'stride': stride, 'pad': pad, 'opad': opad} + + +def f_igdn(rng, c): + gamma = 0.1 * np.eye(c) + rng.uniform(0.0, 0.02, size=(c, c)) # non-negative + beta = 1.0 + rng.uniform(0.0, 0.2, size=c) # positive + return {'kind': 'igdn', 'gamma': gamma, 'beta': beta} + + +def ptq(layers): + """Quantise a synthetic float network the way `wai_sota_int_synth_export.py` + quantises a trained one: per-output-channel int12 weights, mult = round(scale * + 2^SHIFT), bias at 2^AFRAC, IGDN gamma at 2^GF and beta at 2^(2*AFRAC). Returns + the integer model dict `IntModel::from_json` / `model_to_bin` take.""" + out = {'afrac': AFRAC, 'gf': GF, 'shift': SHIFT, 'layers': []} + for L in layers: + k = L['kind'] + if k == 'convT': + wi, sc = q_weight_pc(L['w']) + out['layers'].append({'type': 'convT', 'in_ch': int(wi.shape[0]), 'out_ch': int(wi.shape[1]), + 'k': int(wi.shape[2]), 'stride': L['stride'], 'pad': L['pad'], 'opad': L['opad'], + 'weight': wi.reshape(-1).tolist(), + 'mult': np.round(sc * 2 ** SHIFT).astype(np.int64).tolist(), + 'bias': np.round(L['b'] * 2 ** AFRAC).astype(np.int64).tolist()}) + elif k == 'conv': + wi, sc = q_weight_conv(L['w']) + out['layers'].append({'type': 'conv', 'in_ch': int(wi.shape[1]), 'out_ch': int(wi.shape[0]), + 'k': int(wi.shape[2]), 'stride': L['stride'], 'pad': L['pad'], + 'weight': wi.reshape(-1).tolist(), + 'mult': np.round(sc * 2 ** SHIFT).astype(np.int64).tolist(), + 'bias': np.round(L['b'] * 2 ** AFRAC).astype(np.int64).tolist()}) + elif k == 'igdn': + c = L['gamma'].shape[0] + out['layers'].append({'type': 'igdn', 'c': int(c), + 'gamma': np.round(L['gamma'] * 2 ** GF).astype(np.int64).reshape(-1).tolist(), + 'beta': np.round(L['beta'] * 2 ** (2 * AFRAC)).astype(np.int64).tolist()}) + elif k == 'relu': + out['layers'].append({'type': 'relu'}) + elif k == 'leaky': # slope 0.01 as 655 / 2^16, as the int_hyper export writes it + out['layers'].append({'type': 'leaky', 'slope_q': 655, 'slope_frac': 16}) + else: + raise ValueError(k) + return out + + +def build_model(rng): + """The synthetic networks. Topology mirrors the float `wai.neural.mlicpp` + validation model (`tools/wai_validate_synthetic.py`, MiniMlic) at a smaller + scale: 3 stride-2 stages instead of 4 (a 64x64 image -> an 8x8 latent), 8 latent + channels, a 2-layer entropy network whose first layer is MiniMlic's 5x5 conv over + concat[y, hyper], and IGDN in the synthesis.""" + M, N = CY, CZ + g_a = [f_conv(rng, 3, M, 5, 2, 2, GA_GAIN), {'kind': 'relu'}, + f_conv(rng, M, M, 5, 2, 2, GA_GAIN), {'kind': 'relu'}, + f_conv(rng, M, M, 5, 2, 2, GA_GAIN)] + h_a = [f_conv(rng, M, N, 5, 2, 2, HA_GAIN), {'kind': 'relu'}, + f_conv(rng, N, N, 5, 2, 2, HA_GAIN)] + h_s = [f_convT(rng, N, M, 5, 2, 2, 1, HS_GAIN), {'kind': 'relu'}, + f_convT(rng, M, 2 * M, 5, 2, 2, 1, HS_GAIN)] + e1 = f_conv(rng, 3 * M, 2 * M, 5, 1, 2, E1_GAIN) + e2 = f_conv(rng, 2 * M, 2 * M, 1, 1, 0, E2_GAIN) + # The sigma half starts positive with a different base scale per channel, as a + # trained scale head's would: some channels carry more entropy than others. + e2['b'][:M] += np.geomspace(SIGMA_BASE[0], SIGMA_BASE[1], M) + entropy = [e1, {'kind': 'leaky'}, e2] + g_s = [f_convT(rng, M, M, 5, 2, 2, 1, 1.0), f_igdn(rng, M), + f_convT(rng, M, M, 5, 2, 2, 1, 1.0), f_igdn(rng, M), + f_convT(rng, M, 3, 5, 2, 2, 1, GS_OUT_GAIN)] + g_s[-1]['b'] += 0.5 # pixels centred in [0, 1] + return {name: ptq(layers) for name, layers in + (('g_a', g_a), ('h_a', h_a), ('h_s', h_s), ('entropy', entropy), ('g_s', g_s))} + + +# ---------------------------------------------------------------- checked integer reference + +def max_abs(x): + return int(np.max(np.abs(x.astype(object)))) if x.size else 0 + + +def run_checked(model, x, c): + """Run an integer model on x ([c, h, w] int64 at 2^AFRAC) layer by layer with the + ops `run_int_net` uses, after checking before every layer that no intermediate + can leave int64 -- numpy int64 wraps silently, so these checks are what make the + result exact. They are at least as strict as the Rust decoder's + `int_hyper_synth::run_net_checked` (which accumulates IGDN in i128), so a vector + that passes here decodes there without refusal.""" + for i, L in enumerate(model['layers']): + t = L['type'] + xm = max_abs(x) + if t in ('conv', 'convT'): + cin, co, k = L['in_ch'], L['out_ch'], L['k'] + assert cin == c, f'layer {i}: in_ch {cin} != running channels {c}' + wm = max(abs(v) for v in L['weight']) + acc = cin * k * k * wm * xm + assert acc <= I64_MAX, f'layer {i}: accumulation bound {acc} > i64' + q = ((acc * max(abs(v) for v in L['mult'])) >> model['shift']) + 1 + assert q + max(abs(v) for v in L['bias']) <= I64_MAX, f'layer {i}: requant bound' + w = np.array(L['weight'], dtype=np.int64) + mult = np.array(L['mult'], dtype=np.int64) + bias = np.array(L['bias'], dtype=np.int64) + if t == 'convT': + x = conv_t_raw(x, w.reshape(cin, co, k, k), mult, bias, L['stride'], L['pad'], L['opad'], + model['shift']) + else: + x = conv_int(x, w.reshape(co, cin, k, k), mult, bias, L['stride'], L['pad'], model['shift']) + c = co + elif t == 'igdn': + C = L['c'] + assert C == c + gm = max(abs(v) for v in L['gamma']) + bm = max(abs(v) for v in L['beta']) + x2 = xm * xm + norm = bm + C * (((gm * x2) >> model['gf']) + 1) + assert x2 <= I64_MAX and gm * x2 <= I64_MAX and norm <= I64_MAX, f'layer {i}: igdn bound' + s = int(isqrt_vec(np.array([norm], dtype=np.int64))[0]) + assert xm * s <= I64_MAX, f'layer {i}: igdn product bound' + assert model['afrac'] == AFRAC and model['gf'] == GF # igdn_int reads the module constants + x = igdn_int(x, np.array(L['gamma'], dtype=np.int64).reshape(C, C), + np.array(L['beta'], dtype=np.int64)) + elif t == 'relu': + x = np.maximum(x, 0) + elif t == 'leaky': + assert xm * abs(L['slope_q']) <= I64_MAX + x = np.where(x >= 0, x, (x * L['slope_q']) >> L['slope_frac']) + else: + raise ValueError(t) + return x, c + + +def to_rgb8(x): + """int_hyper_synth::to_rgb8: clamp to [0, 2^afrac], round half up to 8 bits, CHW -> HWC.""" + one = 1 << AFRAC + pix = ((np.clip(x, 0, one) * 255 + (1 << (AFRAC - 1))) >> AFRAC).clip(0, 255).astype(np.uint8) + return np.ascontiguousarray(pix.transpose(1, 2, 0)) + + +def is_anchor(r, c): + return (r + c) % 2 == 0 + + +def coding_order(cy, yh, yw, k): + """(slice, anchor, [flat indices]) in the order the y stream codes them.""" + sw = cy // k + for ki in range(k): + for anchor in (True, False): + idx = [ch * yh * yw + r * yw + c for ch in range(ki * sw, (ki + 1) * sw) + for r in range(yh) for c in range(yw) if is_anchor(r, c) == anchor] + yield ki, anchor, idx + + +# ---------------------------------------------------------------- entropy tables + +def gaussian_tables(): + import torch + from compressai.entropy_models import GaussianConditional + scale_table = torch.exp(torch.linspace(np.log(SCALE_MIN), np.log(SCALE_MAX), SCALE_LEVELS)) + gc = GaussianConditional(scale_table.tolist()) + gc.update() + lengths = gc._cdf_length.reshape(-1).int().tolist() + cdfs_padded = gc._quantized_cdf.int().tolist() + cdfs = [row[:lengths[i]] for i, row in enumerate(cdfs_padded)] + offsets = gc._offset.reshape(-1).int().tolist() + edges = np.floor(scale_table.numpy().astype(np.float64) * 2 ** AFRAC).astype(np.int64) + return edges, cdfs_padded, cdfs, lengths, offsets + + +def z_tables(z_sym): + """One discretised-Gaussian table per hyper-latent channel, over symbols + -half..half with offset -half; the width follows the channel's spread, and a + symbol outside it is coded by the rANS escape (bypass).""" + from compressai._CXX import pmf_to_quantized_cdf + cdfs, lengths, offsets = [], [], [] + for ch in range(z_sym.shape[0]): + spread = max(0.6, float(np.std(z_sym[ch].astype(np.float64)))) + half = max(1, int(np.ceil(Z_WIDTH * spread))) + v = np.arange(-half, half + 1, dtype=np.float64) + pmf = np.exp(-(v * v) / (2.0 * spread * spread)) + pmf = pmf / pmf.sum() * (1.0 - 1e-3) + cdf = list(pmf_to_quantized_cdf(pmf.tolist() + [1e-3], 16)) + cdfs.append(cdf) + lengths.append(len(cdf)) + offsets.append(-half) + return cdfs, lengths, offsets + + +def check_cdf(cdf, n): + assert n >= 2 and len(cdf) >= n and cdf[0] == 0 and cdf[n - 1] == 1 << 16 + assert all(cdf[i] < cdf[i + 1] for i in range(n - 1)), 'CDF must be strictly increasing' + + +# ---------------------------------------------------------------- encode + +def source_pattern(): + """A 64x64 RGB test pattern built from integers only (so it is the same bytes on + every machine): two ramps, an xor texture and a disc.""" + yy, xx = np.mgrid[0:H, 0:W] + img = np.stack([xx * 255 // (W - 1), yy * 255 // (H - 1), ((xx ^ yy) * 4) & 255], -1) + img[(yy - H // 2) ** 2 + (xx - W // 2) ** 2 < (H // 5) ** 2] = [200, 40, 120] + return img.astype(np.uint8) + + +def encode(models, stats=False): + from compressai.ans import RansDecoder, RansEncoder + one = 1 << AFRAC + half = 1 << (AFRAC - 1) + img = source_pattern() + x = (img.transpose(2, 0, 1).astype(np.int64) << AFRAC) // 255 # [3, H, W] at 2^AFRAC + + y_q, c = run_checked(models['g_a'], x, 3) # encoder-side analysis + assert c == CY + z_q, c = run_checked(models['h_a'], y_q, CY) + assert c == CZ + _, yh, yw = y_q.shape + _, zh, zw = z_q.shape + + # hyper-latent: per-channel median (a multiple of 2^(AFRAC-2)), symbols, tables + median_q = np.array([int(np.median(z_q[ch])) >> (AFRAC - 2) << (AFRAC - 2) for ch in range(CZ)], + dtype=np.int64) + z_sym = (z_q - median_q[:, None, None] + half) >> AFRAC # round half up + z_cdfs, z_lengths, z_offsets = z_tables(z_sym) + for cdf, n in zip(z_cdfs, z_lengths): + check_cdf(cdf, n) + z_int = z_sym * one + median_q[:, None, None] # what the decoder rebuilds + z_idx = np.repeat(np.arange(CZ), zh * zw) + z_bits = RansEncoder().encode_with_indexes(z_sym.reshape(-1).tolist(), z_idx.tolist(), + z_cdfs, z_lengths, z_offsets) + + hyper, c = run_checked(models['h_s'], z_int, CZ) # decode-side from here on + assert hyper.shape == (2 * CY, yh, yw), hyper.shape + + edges, cdfs_padded, cdfs, lengths, offsets = gaussian_tables() + for cdf, n in zip(cdfs, lengths): + check_cdf(cdf, n) + + n = CY * yh * yw + yq = y_q.reshape(-1) + y_int = np.zeros(n, dtype=np.int64) + hyper_flat = hyper.reshape(-1) + syms, idxs, sig_all = [], [], [] + for _, _, pos in coding_order(CY, yh, yw, K): + ctx = np.concatenate([y_int, hyper_flat]).reshape(3 * CY, yh, yw) + params, c = run_checked(models['entropy'], ctx, 3 * CY) + assert params.shape == (2 * CY, yh, yw) + params = params.reshape(-1) + sigma, mu = params[:n], params[n:] + p = np.array(pos) + b = det_build_index_vec(sigma[p], edges) + r = (yq[p] - mu[p] + half) >> AFRAC # round half up + y_int[p] = r * one + mu[p] + syms.extend(int(v) for v in r) + idxs.extend(int(v) for v in b) + sig_all.extend(int(v) for v in sigma[p]) + y_bits = RansEncoder().encode_with_indexes(syms, idxs, cdfs_padded, lengths, offsets) + + # ---- decode the rANS bytes back, pass by pass, as the decoder does + dz = RansDecoder() + dz.set_stream(z_bits) + z_dec = np.array(dz.decode_stream(z_idx.tolist(), z_cdfs, z_lengths, z_offsets), dtype=np.int64) + assert np.array_equal(z_dec, z_sym.reshape(-1)), 'z round trip' + hyper2, _ = run_checked(models['h_s'], z_dec.reshape(CZ, zh, zw) * one + median_q[:, None, None], CZ) + assert np.array_equal(hyper2, hyper) + dy = RansDecoder() + dy.set_stream(y_bits) + y_dec = np.zeros(n, dtype=np.int64) + for _, _, pos in coding_order(CY, yh, yw, K): + ctx = np.concatenate([y_dec, hyper2.reshape(-1)]).reshape(3 * CY, yh, yw) + params, _ = run_checked(models['entropy'], ctx, 3 * CY) + params = params.reshape(-1) + p = np.array(pos) + b = det_build_index_vec(params[:n][p], edges) + vals = np.array(dy.decode_stream(b.tolist(), cdfs_padded, lengths, offsets), dtype=np.int64) + y_dec[p] = vals * one + params[n:][p] + assert np.array_equal(y_dec, y_int), 'y round trip: the Python decode must give the encoder latent' + + rec, c = run_checked(models['g_s'], y_int.reshape(CY, yh, yw), CY) + assert rec.shape == (3, H, W), rec.shape + rgb = to_rgb8(rec) + + # escapes: a symbol the table cannot hold is coded by the rANS bypass + y_escape = sum(1 for s, b in zip(syms, idxs) if not (0 <= s - offsets[b] < lengths[b] - 2)) + z_escape = sum(1 for s, ch in zip(z_sym.reshape(-1).tolist(), z_idx.tolist()) + if not (0 <= s - z_offsets[ch] < z_lengths[ch] - 2)) + if stats: + sg = np.array(sig_all) / one + print(f'y/2^A: std {np.std(y_q / one):.2f}, max {np.max(np.abs(y_q)) / one:.1f}; ' + f'z/2^A: std {np.std(z_q / one):.2f}; hyper max {np.max(np.abs(hyper)) / one:.1f}') + print(f'sigma: min {sg.min():.3f} median {np.median(sg):.3f} max {sg.max():.3f}; ' + f'buckets used {len(set(idxs))}/{SCALE_LEVELS}: {np.bincount(idxs, minlength=SCALE_LEVELS).tolist()}') + print(f'|r| max {max(abs(s) for s in syms)}; escapes y {y_escape}/{len(syms)}, z {z_escape}/{z_sym.size}') + print(f'pixels: min {rgb.min()} max {rgb.max()} mean {rgb.mean():.1f}; ' + f'clamped {int(np.sum((rgb == 0) | (rgb == 255)))}/{rgb.size}') + tables = {'afrac': AFRAC, 'scale_edges': edges.tolist(), 'cdfs': cdfs, 'cdf_lengths': lengths, + 'offsets': offsets, 'z_cdfs': z_cdfs, 'z_cdf_lengths': z_lengths, 'z_offsets': z_offsets, + 'z_median_q': median_q.tolist()} + return { + 'tables': tables, 'z_bits': z_bits, 'y_bits': y_bits, 'rgb': rgb, + 'dims': (zh, zw, yh, yw), 'syms': syms, 'idxs': idxs, + 'counts': {'y_symbols': len(syms), 'z_symbols': int(z_sym.size), 'y_escapes': y_escape, + 'z_escapes': z_escape, 'buckets_used': len(set(idxs))}, + } + + +# ---------------------------------------------------------------- containers + +def bundle(models, tables): + """model.wmb: magic WMB1, then h_s, entropy, g_s (WHM1) and the tables (JSON), + each as a u32 length and its bytes.""" + out = bytearray(b'WMB1') + tj = json.dumps(tables, separators=(',', ':')).encode() + for part in (model_to_bin(models['h_s']), model_to_bin(models['entropy']), model_to_bin(models['g_s']), tj): + out += struct.pack(' &'static [&'static str] { CAP_NEURAL_ENCODEC32, CAP_NEURAL_DAC, CAP_NEURAL_MIMI, CAP_NEURAL_WAVTOKENIZER, CAP_NEURAL_SNAC, CAP_NEURAL_BMSHJ2018, CAP_NEURAL_MBT2018_MEAN, CAP_NEURAL_INT_SYNTH, CAP_NEURAL_INT_HYPER, + CAP_NEURAL_INT_MLICPP, CAP_NEURAL_ELIC, CAP_NEURAL_MLICPP, CAP_NEURAL_MLICV2, CAP_NEURAL_CMIC, CAP_NEURAL_VIDEO_BMSHJ2018, CAP_NEURAL_GLC, CAP_NEURAL_DCVC_RT, CAP_NEURAL_DCVC_FM, @@ -686,7 +688,7 @@ CAP_WORLD_SCENE | CAP_WORLD_REPLAY | CAP_WORLD_LIVE | CAP_WORLD_MODEL | CAP_MESH CAP_IMAGE_JPEG | CAP_IMAGE_AVIF | CAP_IMAGE_JPEGAI | CAP_AUDIO_OPUS | CAP_VIDEO_AV1 | CAP_NEURAL_ENCODEC32 | CAP_NEURAL_DAC | CAP_NEURAL_MIMI | CAP_NEURAL_WAVTOKENIZER | CAP_NEURAL_SNAC | CAP_NEURAL_BMSHJ2018 | CAP_NEURAL_MBT2018_MEAN | CAP_NEURAL_INT_SYNTH - | CAP_NEURAL_INT_HYPER + | CAP_NEURAL_INT_HYPER | CAP_NEURAL_INT_MLICPP | CAP_NEURAL_ELIC | CAP_NEURAL_MLICPP | CAP_NEURAL_MLICV2 | CAP_NEURAL_CMIC | CAP_NEURAL_VIDEO_BMSHJ2018 | CAP_NEURAL_GLC | CAP_NEURAL_DCVC_RT | CAP_NEURAL_DCVC_FM @@ -797,6 +799,11 @@ pub fn capability_license(cap: &str) -> Option { | CAP_NEURAL_ELIC | CAP_NEURAL_MLICPP | CAP_NEURAL_MLICV2 | CAP_NEURAL_CMIC | CAP_NEURAL_VIDEO_BMSHJ2018 | CAP_NEURAL_GLC | CAP_NEURAL_DCVC_RT | CAP_NEURAL_DCVC_FM | CAP_SEMANTIC_CLIP | CAP_GENERATE_SPECTRAL + // wai.neural.int_mlicpp, classified when it was registered: its decoder is this + // crate's (Apache-2.0) and no model is registered with it (the conformance + // vector's weights are WAI's own synthetic ones). The patent status of the + // entropy-model techniques it implements was not examined. + | CAP_NEURAL_INT_MLICPP // WAI-native classes: the reference implementation is Apache-2.0. | CAP_VIDEO_INT_MOTION | CAP_VIDEO_INT_HYPER | CAP_AUDIO_INT_CODEC | CAP_SPLAT_INT_CODEC | CAP_WORLD_SCENE | CAP_WORLD_REPLAY | CAP_WORLD_LIVE | CAP_WORLD_MODEL @@ -1091,8 +1098,12 @@ pub fn capability_replicate_class(cap: &str) -> Option { // transform codec, integer synthesis → byte-exact int16 PCM. // wai.splat.int_codec is the int_hyper of 3D: a learned splat-attribute // transform, integer synthesis → byte-exact attribute tensor. + // wai.neural.int_mlicpp is the MLIC++ entropy topology over the same integer + // networks: every σ the entropy network gives is integer, so the bucket is the + // encoder's on every decoder. Every member's basis is one row of + // NEURAL_INTEGER_EXACT_EVIDENCE in the tests. CAP_NEURAL_INT_SYNTH | CAP_NEURAL_INT_HYPER | CAP_VIDEO_INT_HYPER - | CAP_AUDIO_INT_CODEC | CAP_SPLAT_INT_CODEC => NeuralIntegerExact, + | CAP_AUDIO_INT_CODEC | CAP_SPLAT_INT_CODEC | CAP_NEURAL_INT_MLICPP => NeuralIntegerExact, // JPEG AI: the entropy decode, including the scale-index derivation (an // integer hyper-scale network), is integer on both reference decode paths, // so the decoded symbols reproduce everywhere. Dequantisation, latent @@ -1675,7 +1686,8 @@ mod tests { // int_synth (synthesis), int_hyper (the full conditional codec), // video.int_hyper (a learned int_hyper keyframe + integer inter-frames), // and audio.int_codec (the learned 1-D audio transform codec). - for &c in &[CAP_NEURAL_INT_SYNTH, CAP_NEURAL_INT_HYPER, CAP_VIDEO_INT_HYPER, CAP_AUDIO_INT_CODEC, CAP_SPLAT_INT_CODEC] { + for &c in &[CAP_NEURAL_INT_SYNTH, CAP_NEURAL_INT_HYPER, CAP_VIDEO_INT_HYPER, CAP_AUDIO_INT_CODEC, CAP_SPLAT_INT_CODEC, + CAP_NEURAL_INT_MLICPP] { assert_eq!(capability_replicate_class(c), Some(NeuralIntegerExact), "{c}"); assert!(is_neural_integer_exact(c), "{c}"); } @@ -1683,11 +1695,12 @@ mod tests { let mut nie: Vec<_> = known_capabilities().iter() .filter(|c| is_neural_integer_exact(c)).copied().collect(); nie.sort_unstable(); - let mut want = vec![CAP_NEURAL_INT_SYNTH, CAP_NEURAL_INT_HYPER, CAP_VIDEO_INT_HYPER, CAP_AUDIO_INT_CODEC, CAP_SPLAT_INT_CODEC]; + let mut want = vec![CAP_NEURAL_INT_SYNTH, CAP_NEURAL_INT_HYPER, CAP_VIDEO_INT_HYPER, CAP_AUDIO_INT_CODEC, CAP_SPLAT_INT_CODEC, + CAP_NEURAL_INT_MLICPP]; want.sort_unstable(); assert_eq!(nie, want); // The SOTA learned codecs run float inference → not cross-hardware sound. - for &c in &[CAP_NEURAL_MLICV2, CAP_NEURAL_CMIC, CAP_NEURAL_ELIC, CAP_NEURAL_GLC, + for &c in &[CAP_NEURAL_MLICPP, CAP_NEURAL_MLICV2, CAP_NEURAL_CMIC, CAP_NEURAL_ELIC, CAP_NEURAL_GLC, CAP_NEURAL_MBT2018_MEAN, CAP_NEURAL_DCVC_FM, CAP_NEURAL_SNAC] { assert_eq!(capability_replicate_class(c), Some(NeuralFloat), "{c}"); assert!(!is_neural_integer_exact(c), "{c} must not claim integer-exact"); @@ -1892,6 +1905,39 @@ mod tests { (CAP_TERNARY_TT, "extensions/ternary-tt.md: reconstruction-equivalence"), ]; + /// Every capability classed NeuralIntegerExact, with the basis its class rests + /// on: the integer decode path that makes it byte-identical by construction, and + /// what pins that decode (a committed vector matched against an independent + /// integer reference, a cross-architecture golden). Where each was run is recorded + /// in `extensions/determinism-tiers.md` §6. A capability classed NeuralIntegerExact + /// without a row here fails `neural_integer_exact_rests_on_recorded_evidence`. + const NEURAL_INTEGER_EXACT_EVIDENCE: &[(&str, &str)] = &[ + (CAP_NEURAL_INT_SYNTH, "int_synth.rs: integer synthesis; pinned by the byte-exact-conformance golden"), + (CAP_NEURAL_INT_HYPER, "int_hyper_synth.rs: integer hyper-decoder, bucketing, rANS and synthesis; pinned by the committed tiny vector and its keyframe golden in byte-exact-conformance"), + (CAP_VIDEO_INT_HYPER, "int_hyper_synth.rs + int_video.rs: an int_hyper keyframe, then integer P-frames; pinned by the neural-keyframe clip golden in byte-exact-conformance"), + (CAP_AUDIO_INT_CODEC, "int_audio.rs: integer synthesis over a factorized rANS latent; pinned by a committed vector against an independent Python integer reference"), + (CAP_SPLAT_INT_CODEC, "int_splat.rs: integer synthesis over a factorized rANS latent; pinned by a committed vector against an independent Python integer reference"), + (CAP_NEURAL_INT_MLICPP, "int_mlicpp.rs: integer hyper-decoder, entropy network, bucketing, rANS and synthesis; pinned by a committed vector against an independent Python integer reference and by its byte-exact-conformance golden; decode measured identical on macOS arm64 and wasm32-wasip2"), + ]; + + #[test] + fn neural_integer_exact_rests_on_recorded_evidence() { + for &c in known_capabilities() { + if capability_replicate_class(c) == Some(ReplicateClass::NeuralIntegerExact) { + assert!(NEURAL_INTEGER_EXACT_EVIDENCE.iter().any(|&(e, _)| e == c), + "{c} is classed NeuralIntegerExact with no recorded basis"); + } + } + for &(c, basis) in NEURAL_INTEGER_EXACT_EVIDENCE { + assert_eq!(capability_replicate_class(c), Some(ReplicateClass::NeuralIntegerExact), "{c}: {basis}"); + assert!(basis.contains(".rs:"), "{c}: the basis names the decode path"); + } + let mut rows: Vec<&str> = NEURAL_INTEGER_EXACT_EVIDENCE.iter().map(|&(c, _)| c).collect(); + rows.sort_unstable(); + rows.dedup(); + assert_eq!(rows.len(), NEURAL_INTEGER_EXACT_EVIDENCE.len(), "duplicate evidence row"); + } + #[test] fn standard_defined_rests_on_recorded_evidence() { use ReplicateClass::*; diff --git a/wai/wai-rs/src/int_entropy.rs b/wai/wai-rs/src/int_entropy.rs index 181a8235c..c36bb5325 100644 --- a/wai/wai-rs/src/int_entropy.rs +++ b/wai/wai-rs/src/int_entropy.rs @@ -77,6 +77,25 @@ pub fn det_build_index(sigma_q: i64, edges_q: &[i64]) -> usize { idx } +/// Rebuild a latent element from its decoded symbol: `sym · 2^frac + base`, where +/// `base` is the element's integer mean (a conditional codec's μ) or its channel's +/// median (a factorized prior's), both at `2^frac`. +/// +/// `None` when `frac > 62`, or when the exact value does not fit `i64`. The symbol +/// is read from the stream, and the bypass escape lets a stream carry any 32-bit +/// value, so an untrusted stream can ask for a value `i64` cannot hold; the caller +/// then refuses the stream. A plain `sym as i64 * one + base` would panic there in +/// an overflow-checked build and wrap without the checks, so the two builds would +/// decode the same bytes differently. Here both refuse. The arithmetic is `i128`, +/// where no step can overflow (`|sym · 2^62| < 2^94`); the Kani harness +/// `kani_dequantize_is_exact_or_refused` checks that for every input. +pub fn dequantize(sym: i32, frac: u32, base: i64) -> Option { + if frac > 62 { + return None; + } + i64::try_from(((sym as i128) << frac) + base as i128).ok() +} + // ───────────────────────────────────────────────────────────────────── // Kani proof harnesses — the soundness hinge, checked rather than asserted // ───────────────────────────────────────────────────────────────────── @@ -138,6 +157,32 @@ fn kani_det_build_index_singleton_table() { kani::assert(det_build_index(sigma, &edges) == 0, "a one-entry table has one bucket"); } +/// `dequantize` over every symbol, scale and base: it never panics, and it returns +/// the exact `sym · 2^frac + base` precisely when that value fits `i64` (and +/// `frac ≤ 62`), `None` otherwise. The expected value is formed by multiplication +/// by a power of two, not by the shift the function uses, so the harness states the +/// specification rather than restating the code. +#[cfg(kani)] +#[kani::proof] +fn kani_dequantize_is_exact_or_refused() { + let sym: i32 = kani::any(); + let frac: u32 = kani::any(); + let base: i64 = kani::any(); + let got = dequantize(sym, frac, base); + if frac <= 62 { + let exact = sym as i128 * (1i128 << frac) + base as i128; + let fits = exact >= i64::MIN as i128 && exact <= i64::MAX as i128; + kani::assert(got.is_some() == fits, "refused exactly when the value leaves i64"); + if let Some(v) = got { + kani::assert(v as i128 == exact, "the value returned is the exact one"); + } + } else { + kani::assert(got.is_none(), "a scale above 2^62 is refused"); + } + kani::cover!(got.is_some(), "an in-range value is returned"); + kani::cover!(got.is_none() && frac <= 62, "an out-of-range value is refused"); +} + #[cfg(test)] mod tests { use super::*; @@ -228,4 +273,22 @@ mod tests { assert_eq!(det_build_index(i64::MIN / 2, &edges), 0); assert_eq!(det_build_index(i64::MAX / 2, &edges), edges.len() - 1); } + + #[test] + fn dequantize_is_the_fixed_point_latent_or_a_refusal() { + // In range it is exactly the expression the conditional decoders use. + for (sym, base) in [(0, 0), (5, -3000), (-7, 12_345), (i32::MAX, 0), (i32::MIN, 0)] { + assert_eq!(dequantize(sym, 12, base), Some(sym as i64 * (1 << 12) + base)); + } + // At the edge of i64: the largest value that fits, and one past it. + assert_eq!(dequantize(1, 62, i64::MAX - (1 << 62)), Some(i64::MAX)); + assert_eq!(dequantize(1, 62, i64::MAX - (1 << 62) + 1), None); + assert_eq!(dequantize(-2, 62, 0), Some(i64::MIN)); + assert_eq!(dequantize(-2, 62, -1), None); + // A stream-supplied symbol that no latent at this scale can be. + assert_eq!(dequantize(i32::MAX, 40, 0), None); + // Scales beyond 2^62 are refused rather than shifted out of range. + assert_eq!(dequantize(0, 63, 0), None); + assert_eq!(dequantize(1, u32::MAX, 0), None); + } } diff --git a/wai/wai-rs/src/int_hyper_synth.rs b/wai/wai-rs/src/int_hyper_synth.rs index e6dd5b1b3..e44b9076b 100644 --- a/wai/wai-rs/src/int_hyper_synth.rs +++ b/wai/wai-rs/src/int_hyper_synth.rs @@ -226,43 +226,43 @@ pub enum IntLayer { Leaky { slope_q: i64, slope_frac: u32 }, } -/// Little-endian cursor over the binary model, bounds-checked. +/// Little-endian cursor over the binary model, bounds-checked: every read is of bytes +/// that are present, with its end formed in checked arithmetic, so a damaged model is +/// refused and never makes the parser allocate more than the bytes it holds. struct Cur<'a> { b: &'a [u8], p: usize, } -impl Cur<'_> { - fn u32(&mut self) -> Result { - let e = self.p + 4; +impl<'a> Cur<'a> { + /// The next `n` elements of `size` bytes each. + fn take(&mut self, n: usize, size: usize) -> Result<&'a [u8], &'static str> { + let e = n.checked_mul(size).and_then(|len| self.p.checked_add(len)).ok_or("model truncated")?; let s = self.b.get(self.p..e).ok_or("model truncated")?; self.p = e; - Ok(u32::from_le_bytes(s.try_into().unwrap()) as usize) + Ok(s) + } + fn u32(&mut self) -> Result { + Ok(u32::from_le_bytes(self.take(1, 4)?.try_into().unwrap()) as usize) } fn u8(&mut self) -> Result { - let v = *self.b.get(self.p).ok_or("model truncated")?; - self.p += 1; - Ok(v) + Ok(self.take(1, 1)?[0]) } fn i16v(&mut self, n: usize) -> Result, &'static str> { - let e = self.p + 2 * n; - let s = self.b.get(self.p..e).ok_or("model truncated")?; - self.p = e; - Ok(s.chunks_exact(2).map(|c| i16::from_le_bytes([c[0], c[1]]) as i64).collect()) + Ok(self.take(n, 2)?.chunks_exact(2).map(|c| i16::from_le_bytes([c[0], c[1]]) as i64).collect()) } fn i32v(&mut self, n: usize) -> Result, &'static str> { - let e = self.p + 4 * n; - let s = self.b.get(self.p..e).ok_or("model truncated")?; - self.p = e; - Ok(s.chunks_exact(4).map(|c| i32::from_le_bytes([c[0], c[1], c[2], c[3]]) as i64).collect()) + Ok(self.take(n, 4)?.chunks_exact(4).map(|c| i32::from_le_bytes([c[0], c[1], c[2], c[3]]) as i64).collect()) } fn i64v(&mut self, n: usize) -> Result, &'static str> { - let e = self.p + 8 * n; - let s = self.b.get(self.p..e).ok_or("model truncated")?; - self.p = e; - Ok(s.chunks_exact(8).map(|c| i64::from_le_bytes(c.try_into().unwrap())).collect()) + Ok(self.take(n, 8)?.chunks_exact(8).map(|c| i64::from_le_bytes(c.try_into().unwrap())).collect()) } } +/// The product of a layer's dimensions, refused where it overflows `usize`. +fn count(dims: &[usize]) -> Result { + dims.iter().try_fold(1usize, |a, &d| a.checked_mul(d)).ok_or("layer size overflows") +} + impl IntModel { /// Parse the export tool's JSON model (`int_synth_sota_model.json` / `int_hs_model.json`). pub fn from_json(v: &Value) -> Self { @@ -299,7 +299,10 @@ impl IntModel { let mut c = Cur { b, p: 4 }; let (afrac, gf, shift) = (c.u32()? as u32, c.u32()? as u32, c.u32()? as u32); let n = c.u32()?; - let mut layers = Vec::with_capacity(n); + // Each layer is at least its tag byte, so a count beyond the bytes left is a + // truncated model; reserving for it would let four damaged bytes ask for an + // allocation of hundreds of gigabytes. + let mut layers = Vec::with_capacity(n.min(b.len() - c.p)); for _ in 0..n { layers.push(match c.u8()? { 0 => { @@ -307,18 +310,18 @@ impl IntModel { let out = c.u32()?; let (k, stride, pad, opad) = (c.u32()?, c.u32()?, c.u32()?, c.u32()?); IntLayer::ConvT { out_ch: out, k, stride, pad, opad, - weight: c.i16v(inc * out * k * k)?, mult: c.i64v(out)?, bias: c.i64v(out)? } + weight: c.i16v(count(&[inc, out, k, k])?)?, mult: c.i64v(out)?, bias: c.i64v(out)? } } 1 => { let inc = c.u32()?; let out = c.u32()?; let (k, stride, pad) = (c.u32()?, c.u32()?, c.u32()?); IntLayer::Conv { out_ch: out, k, stride, pad, - weight: c.i16v(out * inc * k * k)?, mult: c.i64v(out)?, bias: c.i64v(out)? } + weight: c.i16v(count(&[out, inc, k, k])?)?, mult: c.i64v(out)?, bias: c.i64v(out)? } } 2 => { let cc = c.u32()?; - IntLayer::Igdn { gamma: c.i32v(cc * cc)?, beta: c.i64v(cc)? } + IntLayer::Igdn { gamma: c.i32v(count(&[cc, cc])?)?, beta: c.i64v(cc)? } } 3 => IntLayer::Relu, 4 => IntLayer::Leaky { slope_q: c.i64v(1)?[0], slope_frac: c.u32()? as u32 }, @@ -335,52 +338,295 @@ impl IntModel { /// synthesis `g_s` (→ pixels via [`decode`]) and the hyper-decoder `h_s` /// (→ σ‖μ). Pure i64; byte-identical on every machine. pub fn run_net(model: &IntModel, input: &[i64], mut c: usize, mut h: usize, mut w: usize) -> (Vec, usize, usize, usize) { - let (afrac, gf, shift) = (model.afrac, model.gf, model.shift); let mut x = input.to_vec(); for layer in &model.layers { - match layer { - IntLayer::ConvT { out_ch, k, stride, pad, opad, weight, mult, bias } => { - let (nx, ho, wo) = conv_t(&x, c, h, w, weight, *out_ch, *k, *stride, *pad, *opad, mult, bias, shift); - x = nx; - c = *out_ch; - h = ho; - w = wo; - } - IntLayer::Conv { out_ch, k, stride, pad, weight, mult, bias } => { - let (nx, ho, wo) = conv(&x, c, h, w, weight, *out_ch, *k, *stride, *pad, mult, bias, shift); - x = nx; - c = *out_ch; - h = ho; - w = wo; - } - IntLayer::Igdn { gamma, beta } => { - x = igdn(&x, c, h * w, afrac, gamma, beta, gf); - } - IntLayer::Relu => { - for v in &mut x { - if *v < 0 { - *v = 0; - } - } - } - IntLayer::Leaky { slope_q, slope_frac } => { - for v in &mut x { - if *v < 0 { - *v = (*v * *slope_q) >> *slope_frac; - } - } - } - } + (x, c, h, w) = apply_layer(model, layer, x, c, h, w); } (x, c, h, w) } +/// One layer of [`run_net`] on `x` (`[c, h, w]`): the new activations and shape. +/// [`run_net_checked`] runs layers through this same function, so the two cannot +/// compute different integers. +fn apply_layer(model: &IntModel, layer: &IntLayer, mut x: Vec, c: usize, h: usize, w: usize) -> (Vec, usize, usize, usize) { + let (afrac, gf, shift) = (model.afrac, model.gf, model.shift); + match layer { + IntLayer::ConvT { out_ch, k, stride, pad, opad, weight, mult, bias } => { + let (nx, ho, wo) = conv_t(&x, c, h, w, weight, *out_ch, *k, *stride, *pad, *opad, mult, bias, shift); + (nx, *out_ch, ho, wo) + } + IntLayer::Conv { out_ch, k, stride, pad, weight, mult, bias } => { + let (nx, ho, wo) = conv(&x, c, h, w, weight, *out_ch, *k, *stride, *pad, mult, bias, shift); + (nx, *out_ch, ho, wo) + } + IntLayer::Igdn { gamma, beta } => (igdn(&x, c, h * w, afrac, gamma, beta, gf), c, h, w), + IntLayer::Relu => { + for v in &mut x { + if *v < 0 { + *v = 0; + } + } + (x, c, h, w) + } + IntLayer::Leaky { slope_q, slope_frac } => { + for v in &mut x { + if *v < 0 { + *v = (*v * *slope_q) >> *slope_frac; + } + } + (x, c, h, w) + } + } +} + +/// Why [`run_net_checked`] declined to run a network: the index of the first layer +/// it would not run, the check that failed, and whether that check was about the +/// values presented (`out_of_range`: the layer is well formed, but these magnitudes +/// could overflow its arithmetic) or about the layer itself (malformed, or not +/// matching the input's shape). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NetRefusal { + pub layer: usize, + pub reason: &'static str, + pub out_of_range: bool, +} + +impl std::fmt::Display for NetRefusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "layer {}: {}", self.layer, self.reason) + } +} + +/// [`run_net`], run only while every step stays exact. +/// +/// Before each layer it checks, on the activations actually present, that: +/// +/// - the geometry is well formed, and every size the layer computes fits `usize` +/// (a transposed convolution's uncropped plane included); +/// - the weight, requantisation and IGDN buffers have exactly the lengths the layer +/// reads for the running channel count; +/// - every shift is below its operand's width; +/// - no product, partial sum or requantised value can leave the type the reference +/// kernel forms it in. For a convolution with `terms = c·k·k` inputs per output, +/// `terms · max|w| · max|x|` bounds every product and partial sum (each output +/// element adds at most `terms` products, whatever their order) and must fit `i64`; +/// the requantised value is at most `(bound · max|mult|) >> shift` plus one (the +/// floor of a negative) plus `max|bias|`, and must fit `i64` too. For IGDN, with +/// `X = max|x|`, `G = max|gamma|`, `B = max|beta|`: `G·X²` and +/// `B + c·((G·X²) >> gf + 1)` must fit `i128`, its integer square root (which the +/// reference narrows to `i64`) must fit `i64`, and the output `(X·root) >> afrac` +/// plus one must fit `i64`. For the leaky ReLU, `max|x| · |slope|` must fit `i64`. +/// +/// A layer that fails a check is not run, and the refusal says which and why. On +/// `Ok` the output is [`run_net`]'s, computed by the same code ([`apply_layer`], which +/// dispatches to the fast kernels or the reference as `run_net` does), and no step +/// overflowed, so a build with overflow checks and one without compute the same +/// integers and neither panics. The fast kernels, which narrow only under their own +/// bounds, form no value the reference does not (see [`crate::int_kernels`]). +/// +/// The checks are one layer deep, on real values, so they refuse only activations +/// that come within a factor `terms` (or, for IGDN, `c`) of the type's range, not +/// a network whose worst case over all inputs would. A decoder that feeds values it +/// read from an untrusted stream into a network uses this, and refuses the stream on +/// `Err`. +pub fn run_net_checked( + model: &IntModel, input: &[i64], mut c: usize, mut h: usize, mut w: usize, +) -> Result<(Vec, usize, usize, usize), NetRefusal> { + let n = c.checked_mul(h).and_then(|v| v.checked_mul(w)); + if c == 0 || h == 0 || w == 0 || n != Some(input.len()) { + return Err(NetRefusal { layer: 0, reason: "input length is not channels × height × width", out_of_range: false }); + } + let mut x = input.to_vec(); + for (i, layer) in model.layers.iter().enumerate() { + let out = check_layer(model, layer, &x, c, h, w) + .map_err(|(reason, out_of_range)| NetRefusal { layer: i, reason, out_of_range })?; + let (nx, nc, nh, nw) = apply_layer(model, layer, x, c, h, w); + debug_assert_eq!((nc, nh, nw), out, "check_layer and the layer disagree on the output shape"); + (x, c, h, w) = (nx, nc, nh, nw); + } + Ok((x, c, h, w)) +} + +/// A check [`check_layer`] failed: its reason, and whether it concerned the values +/// presented (`true`) or the layer itself (`false`). +type Failed = (&'static str, bool); +const fn shape(reason: &'static str) -> Failed { + (reason, false) +} +const fn range(reason: &'static str) -> Failed { + (reason, true) +} + +/// One layer's geometry on a `[c, h, w]` input, from its parameters alone: the output +/// shape, and the most elements the reference kernel holds in one buffer while +/// running it — for a transposed convolution the uncropped plane it accumulates into +/// before cropping, otherwise the output. Every size is formed with checked +/// arithmetic; a geometry that is malformed or overflows `usize` is refused. Shared +/// by [`check_layer`] and [`net_shape`], so the shape a caller plans for is the shape +/// the layer runs at. +fn layer_geometry(layer: &IntLayer, c: usize, h: usize, w: usize) -> Result<((usize, usize, usize), usize), Failed> { + let plane = |c: usize, h: usize, w: usize| c.checked_mul(h).and_then(|v| v.checked_mul(w)); + match layer { + IntLayer::ConvT { out_ch, k, stride, pad, opad, .. } => { + let (cout, k, s, p, op) = (*out_ch, *k, *stride, *pad, *opad); + if cout == 0 || k == 0 || s == 0 { + return Err(shape("zero output channels, kernel or stride")); + } + // The reference sizes the uncropped plane (h-1)·s + k + op per side and + // crops 2·pad from it; both must be formed without overflow and leave a + // positive output. + let full = |n: usize| (n - 1).checked_mul(s)?.checked_add(k)?.checked_add(op); + let (fh, fw) = (full(h).ok_or(shape("output size overflows"))?, full(w).ok_or(shape("output size overflows"))?); + let crop = p.checked_mul(2).ok_or(shape("padding overflows"))?; + if fh <= crop || fw <= crop { + return Err(shape("padding leaves no output")); + } + let uncropped = plane(cout, fh, fw).ok_or(shape("uncropped plane overflows"))?; + Ok(((cout, fh - crop, fw - crop), uncropped)) + } + IntLayer::Conv { out_ch, k, stride, pad, .. } => { + let (cout, k, s, p) = (*out_ch, *k, *stride, *pad); + if cout == 0 || k == 0 || s == 0 { + return Err(shape("zero output channels, kernel or stride")); + } + let padded = |n: usize| n.checked_add(p.checked_mul(2)?); + let (hp, wp) = (padded(h).ok_or(shape("padding overflows"))?, padded(w).ok_or(shape("padding overflows"))?); + if hp < k || wp < k { + return Err(shape("kernel larger than the padded input")); + } + let (ho, wo) = ((hp - k) / s + 1, (wp - k) / s + 1); + let out = plane(cout, ho, wo).ok_or(shape("output size overflows"))?; + Ok(((cout, ho, wo), out)) + } + IntLayer::Igdn { .. } | IntLayer::Relu | IntLayer::Leaky { .. } => { + Ok(((c, h, w), plane(c, h, w).ok_or(shape("input size overflows"))?)) + } + } +} + +/// What a network does to a `[c, h, w]` input's shape, from its layer geometry alone +/// (see [`net_shape`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NetShape { + /// The output `[c, h, w]`. + pub out: (usize, usize, usize), + /// The most elements any one buffer holds while the network runs on the reference + /// kernels: the input, each layer's output, and each transposed convolution's + /// uncropped plane. (The fast kernels allocate no buffer the reference does not, + /// except a padded copy of a layer's input, which [`crate::int_kernels`] holds to a + /// fixed multiple of that layer's input, weights and output.) + pub peak: usize, +} + +/// [`NetShape`] for `model` on a `[c, h, w]` input, walking the layers' geometry +/// without allocating an activation or reading a value. Refuses what +/// [`run_net_checked`] would refuse on geometry — an empty input, a malformed layer, a +/// size that overflows `usize` — naming the same layer and reason; the parameter-length +/// and value checks are `run_net_checked`'s, made before each layer runs. +/// +/// A decoder that sizes a network's input from an untrusted header calls this first, +/// and refuses before running anything a header whose shapes the network does not +/// produce, or on which it would form a buffer larger than the decoder allows. +pub fn net_shape(model: &IntModel, mut c: usize, mut h: usize, mut w: usize) -> Result { + let refuse = |layer: usize, (reason, out_of_range): Failed| NetRefusal { layer, reason, out_of_range }; + if c == 0 || h == 0 || w == 0 { + return Err(refuse(0, shape("input length is not channels × height × width"))); + } + let mut peak = c.checked_mul(h).and_then(|v| v.checked_mul(w)) + .ok_or_else(|| refuse(0, shape("input length is not channels × height × width")))?; + for (i, layer) in model.layers.iter().enumerate() { + let (out, largest) = layer_geometry(layer, c, h, w).map_err(|f| refuse(i, f))?; + peak = peak.max(largest); + (c, h, w) = out; + } + Ok(NetShape { out: (c, h, w), peak }) +} + +/// The checks [`run_net_checked`] documents, for one layer on activations `x` +/// (`[c, h, w]`). Returns the layer's output shape. +fn check_layer(model: &IntModel, layer: &IntLayer, x: &[i64], c: usize, h: usize, w: usize) -> Result<(usize, usize, usize), Failed> { + use crate::int_kernels::max_abs; + let (out, _) = layer_geometry(layer, c, h, w)?; + const I64: u128 = i64::MAX as u128; + const I128: u128 = i128::MAX as u128; + let xm = max_abs(x) as u128; + // One (transposed) convolution's arithmetic: every product and partial sum, then + // the requantised value, must fit i64 (the reference's accumulator and result). + let conv_range = |cout: usize, k: usize, weight: &[i64], mult: &[i64], bias: &[i64]| -> Result<(), Failed> { + let w_len = c.checked_mul(cout).and_then(|v| v.checked_mul(k)).and_then(|v| v.checked_mul(k)); + if w_len != Some(weight.len()) || mult.len() != cout || bias.len() != cout { + return Err(shape("parameter lengths do not match the running channel count")); + } + if model.shift > 127 { + return Err(shape("requantisation shift of 128 or more")); + } + // c·k·k ≤ weight.len() (checked above), but saturate regardless. + let terms = (c as u128).saturating_mul(k as u128).saturating_mul(k as u128); + let acc = terms.saturating_mul(max_abs(weight) as u128).saturating_mul(xm); + if acc > I64 { + return Err(range("accumulation could leave i64")); + } + // acc ≤ 2^63 and |mult| ≤ 2^63, so the product fits u128. + let q = ((acc * max_abs(mult) as u128) >> model.shift) + 1; + if q.saturating_add(max_abs(bias) as u128) > I64 { + return Err(range("requantised value could leave i64")); + } + Ok(()) + }; + match layer { + IntLayer::ConvT { out_ch, k, weight, mult, bias, .. } | IntLayer::Conv { out_ch, k, weight, mult, bias, .. } => { + conv_range(*out_ch, *k, weight, mult, bias)?; + Ok(out) + } + IntLayer::Igdn { gamma, beta } => { + if c.checked_mul(c) != Some(gamma.len()) || beta.len() != c { + return Err(shape("IGDN parameter lengths do not match the channel count")); + } + if model.gf > 127 || model.afrac > 127 { + return Err(shape("IGDN shift of 128 or more")); + } + // x² ≤ 2^126 always fits; each term gamma·x², and the norm, must fit i128. + let x2 = xm * xm; + let term = (max_abs(gamma) as u128).checked_mul(x2).filter(|&t| t <= I128).ok_or(range("IGDN term could leave i128"))?; + let norm = (max_abs(beta) as u128).saturating_add((c as u128).saturating_mul((term >> model.gf) + 1)); + if norm > I128 { + return Err(range("IGDN norm could leave i128")); + } + // The reference narrows the root to i64 (`isqrt(norm) as i64`), so it must + // fit; then |x · root| < 2^63 · 2^63 fits u128, and the result must fit i64. + let root = norm.isqrt(); + if root > I64 { + return Err(range("IGDN root could leave i64")); + } + if ((xm * root) >> model.afrac) + 1 > I64 { + return Err(range("IGDN output could leave i64")); + } + Ok((c, h, w)) + } + IntLayer::Relu => Ok((c, h, w)), + IntLayer::Leaky { slope_q, slope_frac } => { + if *slope_frac > 63 { + return Err(shape("leaky slope shift of 64 or more")); + } + if xm.saturating_mul(slope_q.unsigned_abs() as u128) > I64 { + return Err(range("leaky product could leave i64")); + } + Ok((c, h, w)) + } + } +} + /// Decode the integer synthesis `model` on the latent `input` (`[c, h, w]` at /// `2^afrac`) → RGB, row-major **HWC** `u8`. Byte-identical on every machine. pub fn decode(model: &IntModel, input: &[i64], c: usize, h: usize, w: usize) -> (Vec, usize, usize) { - let afrac = model.afrac; let (x, c, h, w) = run_net(model, input, c, h, w); - // integer pixel quantization (round-half-up), CHW → HWC u8. + (to_rgb8(&x, c, h, w, model.afrac), h, w) +} + +/// The integer pixel quantisation that ends a synthesis: each value clamped to +/// `[0, 2^afrac]`, scaled to 8 bits rounding half up, CHW → row-major HWC `u8`. +/// `afrac` must be in `1..=54`, so that `2^afrac · 255 + 2^(afrac-1)` fits `i64`. +pub fn to_rgb8(x: &[i64], c: usize, h: usize, w: usize, afrac: u32) -> Vec { let (one, half) = (1i64 << afrac, 1i64 << (afrac - 1)); let mut rgb = vec![0u8; h * w * c]; for cc in 0..c { @@ -389,7 +635,7 @@ pub fn decode(model: &IntModel, input: &[i64], c: usize, h: usize, w: usize) -> rgb[p * c + cc] = ((v * 255 + half) >> afrac).clamp(0, 255) as u8; } } - (rgb, h, w) + rgb } /// FNV-1a 64-bit of `bytes` — the dependency-free checksum the export tool and @@ -781,3 +1027,217 @@ mod kernel_equivalence { } } } + +/// [`run_net_checked`]: it computes [`run_net`]'s integers wherever it runs, runs +/// right up to each bound, and refuses — without panicking — past it and on +/// malformed layers, including an input on which `run_net` itself overflows. +#[cfg(test)] +mod checked { + use super::*; + use crate::int_kernels::testkit::Rng; + + fn conv1(weight: Vec, mult: i64, bias: i64) -> IntLayer { + IntLayer::Conv { out_ch: 1, k: 1, stride: 1, pad: 0, weight, mult: vec![mult], bias: vec![bias] } + } + fn net(shift: u32, layers: Vec) -> IntModel { + IntModel { afrac: 12, gf: 16, shift, layers } + } + + /// A random well-formed net of every layer kind, at operand sizes the checks pass. + fn random_net(g: &mut Rng, cin: usize) -> (IntModel, usize) { + let mut layers = Vec::new(); + let mut c = cin; + for _ in 0..(2 + g.below(4)) { + let cout = 1 + g.below(5); + let k = 1 + g.below(3) * 2; + let (weight, mult, bias) = (g.vec(c * cout * k * k, 2047, 10), (0..cout).map(|_| 1 + g.below(1 << 16) as i64).collect(), g.vec(cout, 1 << 12, 0)); + layers.push(match g.below(5) { + 0 | 1 => IntLayer::ConvT { out_ch: cout, k, stride: 1 + g.below(2), pad: k / 2, opad: 0, weight, mult, bias }, + _ => IntLayer::Conv { out_ch: cout, k, stride: 1 + g.below(2), pad: k / 2, weight, mult, bias }, + }); + c = cout; + match g.below(4) { + 0 => layers.push(IntLayer::Relu), + 1 => layers.push(IntLayer::Leaky { slope_q: 655, slope_frac: 16 }), + 2 => layers.push(IntLayer::Igdn { + gamma: (0..c * c).map(|_| g.below(1 << 12) as i64).collect(), + beta: (0..c).map(|_| (1 << 24) + g.below(1 << 20) as i64).collect(), + }), + _ => {} + } + } + (net(24, layers), c) + } + + #[test] + fn where_it_runs_it_is_run_net() { + let mut g = Rng(0x5EED_0001); + let mut ran = 0; + for _ in 0..200 { + let cin = 1 + g.below(4); + let (model, _) = random_net(&mut g, cin); + let (h, w) = (1 + g.below(6), 1 + g.below(6)); + let x = g.vec(cin * h * w, 1 << 16, 20); + if let Ok(got) = run_net_checked(&model, &x, cin, h, w) { + assert_eq!(got, run_net(&model, &x, cin, h, w)); + ran += 1; + } + } + assert!(ran > 150, "too few nets passed the checks ({ran})"); + } + + /// `net_shape` plans the shapes the checked run runs at: the same output, and a + /// peak that is the largest buffer the reference kernels form on the way — each + /// activation, and each transposed convolution's uncropped plane (sized here from + /// the reference kernel's own formula). + #[test] + fn net_shape_is_the_shape_the_net_runs_at() { + let mut g = Rng(0x5EED_0002); + let mut ran = 0; + for _ in 0..200 { + let cin = 1 + g.below(4); + let (model, _) = random_net(&mut g, cin); + let (h, w) = (1 + g.below(6), 1 + g.below(6)); + let x = g.vec(cin * h * w, 1 << 16, 20); + let Ok((out, oc, oh, ow)) = run_net_checked(&model, &x, cin, h, w) else { continue }; + let plan = net_shape(&model, cin, h, w).expect("the checked run ran, so the geometry is well formed"); + assert_eq!(plan.out, (oc, oh, ow)); + assert_eq!(out.len(), oc * oh * ow); + let (mut a, mut c, mut hh, mut ww) = (x.clone(), cin, h, w); + let mut peak = a.len(); + for layer in &model.layers { + if let IntLayer::ConvT { out_ch, k, stride, opad, .. } = layer { + peak = peak.max(out_ch * ((hh - 1) * stride + k + opad) * ((ww - 1) * stride + k + opad)); + } + (a, c, hh, ww) = apply_layer(&model, layer, a, c, hh, ww); + peak = peak.max(a.len()); + } + assert_eq!(plan.peak, peak); + ran += 1; + } + assert!(ran > 150, "too few nets passed the checks ({ran})"); + } + + #[test] + fn a_convolution_runs_up_to_the_accumulation_bound_and_no_further() { + // One term: the bound is |w|·|x|. Exactly i64::MAX runs; one past refuses. + let at = net(1, vec![conv1(vec![1], 1, 0)]); + assert_eq!(run_net_checked(&at, &[i64::MAX], 1, 1, 1), Ok(run_net(&at, &[i64::MAX], 1, 1, 1))); + let past = net(1, vec![conv1(vec![2], 1, 0)]); + let x = [(i64::MAX >> 1) + 1]; + assert_eq!(run_net_checked(&past, &x, 1, 1, 1).unwrap_err(), + NetRefusal { layer: 0, reason: "accumulation could leave i64", out_of_range: true }); + } + + // It observes run_net's panic, which needs unwinding (wasm32 aborts on a panic). + #[cfg(panic = "unwind")] + #[test] + fn it_refuses_an_input_on_which_run_net_overflows() { + // Two terms of i64::MAX and 1 sum past i64::MAX: run_net panics with overflow + // checks on and wraps without them — two decoders. The checked run refuses. + let model = net(0, vec![IntLayer::Conv { out_ch: 1, k: 1, stride: 1, pad: 0, weight: vec![1, 1], mult: vec![1], bias: vec![0] }]); + let x = [i64::MAX, 1]; + assert!(run_net_checked(&model, &x, 2, 1, 1).is_err()); + let plain = std::panic::catch_unwind(|| run_net(&model, &x, 2, 1, 1)); + assert_eq!(plain.is_err(), crate::int_kernels::testkit::overflow_checks(), "run_net's own behaviour here depends on the build"); + } + + #[test] + fn the_requantised_value_must_fit() { + // Accumulation fits (2^40), but ·2^30 with no shift leaves i64. + let model = net(0, vec![conv1(vec![1], 1 << 30, 0)]); + assert_eq!(run_net_checked(&model, &[1 << 40], 1, 1, 1).unwrap_err().reason, "requantised value could leave i64"); + // The bias counts, and so does one for the floor of a negative value: the + // bound is conservative by that one, and refuses a value that could reach + // i64::MAX once the bias is added. + let edge = net(0, vec![conv1(vec![1], 1, 1)]); + assert_eq!(run_net_checked(&edge, &[i64::MAX - 1], 1, 1, 1).unwrap_err().reason, "requantised value could leave i64"); + } + + #[test] + fn igdn_and_leaky_have_their_own_bounds() { + let igdn = |gamma: i64| net(24, vec![IntLayer::Igdn { gamma: vec![gamma], beta: vec![1 << 24] }]); + // Moderate: runs and equals run_net. + let x = [123_456i64]; + assert_eq!(run_net_checked(&igdn(1 << 14), &x, 1, 1, 1), Ok(run_net(&igdn(1 << 14), &x, 1, 1, 1))); + // gamma·x² beyond i128. + assert_eq!(run_net_checked(&igdn(i64::MAX), &[i64::MAX], 1, 1, 1).unwrap_err().reason, "IGDN term could leave i128"); + // A large x (2^50) whose terms and norm fit i128 but whose normalised output, + // about 2^50 · 2^50 / 2^12, leaves i64. + assert_eq!(run_net_checked(&igdn(1 << 16), &[1 << 50], 1, 1, 1).unwrap_err().reason, "IGDN output could leave i64"); + // A norm between 2^126 and 2^127 fits i128, but its root (about 2^63.2) does + // not fit the i64 the reference narrows it to. + let wide = IntModel { afrac: 12, gf: 0, shift: 24, layers: vec![IntLayer::Igdn { gamma: vec![5], beta: vec![1] }] }; + assert_eq!(run_net_checked(&wide, &[1 << 62], 1, 1, 1).unwrap_err().reason, "IGDN root could leave i64"); + let leaky = net(24, vec![IntLayer::Leaky { slope_q: 1 << 20, slope_frac: 16 }]); + assert_eq!(run_net_checked(&leaky, &[-(1 << 50)], 1, 1, 1).unwrap_err().reason, "leaky product could leave i64"); + assert!(run_net_checked(&leaky, &[-(1 << 40)], 1, 1, 1).is_ok()); + } + + #[test] + fn malformed_layers_are_refused_not_run() { + let conv = |weight: Vec, k: usize, stride: usize, pad: usize| IntLayer::Conv { out_ch: 1, k, stride, pad, weight, mult: vec![1], bias: vec![0] }; + let cases: [(IntModel, &str); 7] = [ + (net(0, vec![conv(vec![1, 1], 1, 1, 0)]), "parameter lengths do not match the running channel count"), + (net(0, vec![conv(vec![1; 9], 3, 0, 1)]), "zero output channels, kernel or stride"), + (net(0, vec![conv(vec![1; 25], 5, 1, 0)]), "kernel larger than the padded input"), + (net(0, vec![IntLayer::ConvT { out_ch: 1, k: 1, stride: 1, pad: 1, opad: 0, weight: vec![1], mult: vec![1], bias: vec![0] }]), + "padding leaves no output"), + (net(128, vec![conv(vec![1], 1, 1, 0)]), "requantisation shift of 128 or more"), + (net(0, vec![IntLayer::Leaky { slope_q: 1, slope_frac: 64 }]), "leaky slope shift of 64 or more"), + (net(0, vec![IntLayer::Igdn { gamma: vec![1, 2], beta: vec![1] }]), "IGDN parameter lengths do not match the channel count"), + ]; + for (model, why) in cases { + let got = std::panic::catch_unwind(|| run_net_checked(&model, &[5], 1, 1, 1)); + let refusal = got.expect("a refusal, not a panic").unwrap_err(); + assert_eq!(refusal.reason, why); + assert!(!refusal.out_of_range, "{why}: a malformed layer is not a range refusal"); + // net_shape refuses the geometry cases alike, and leaves the parameter + // checks to the run. + let geometric = ["zero output", "kernel larger", "padding leaves"].iter().any(|g| why.starts_with(g)); + match net_shape(&model, 1, 1, 1) { + Err(r) => assert!(geometric && r == refusal, "{why}: net_shape refused with {r:?}"), + Ok(_) => assert!(!geometric, "{why}: net_shape accepted a malformed geometry"), + } + } + // An input whose length is not c·h·w. + assert!(run_net_checked(&net(0, vec![]), &[1, 2, 3], 2, 1, 1).is_err()); + } + + /// A damaged compact model is refused: a layer count or a layer size far beyond + /// the bytes present, or one whose product overflows `usize`, never panics and + /// never has the parser reserve for it. (Run on wasm32 too, where `usize` is 32 + /// bits and these products do overflow.) + #[test] + fn a_damaged_compact_model_is_refused() { + let head = |n_layers: u32| [b"WHM1".as_slice(), &12u32.to_le_bytes(), &16u32.to_le_bytes(), &24u32.to_le_bytes(), + &n_layers.to_le_bytes()].concat(); + let u32s = |v: &[u32]| v.iter().flat_map(|x| x.to_le_bytes()).collect::>(); + let cases: [(Vec, &str); 4] = [ + (head(u32::MAX), "model truncated"), + // conv, in 2^31 × out 2^31 × k 1 × 1: the weight count overflows 32-bit usize + // and is far past the bytes on 64-bit. + ([head(1), vec![1], u32s(&[1 << 31, 1 << 31, 1, 1, 0])].concat(), ""), + // conv with a weight count that fits but whose byte length (·2) does not + // fit 32-bit usize, and far exceeds the bytes on 64-bit. + ([head(1), vec![1], u32s(&[u32::MAX, 1, 1, 1, 0])].concat(), "model truncated"), + // IGDN with 2^31 channels: c² overflows 32-bit usize. + ([head(1), vec![2], u32s(&[1 << 31])].concat(), ""), + ]; + for (bytes, why) in cases { + let got = IntModel::from_bytes(&bytes).err().expect("a damaged model is refused"); + assert!(got == "model truncated" || got == "layer size overflows", "{got}"); + assert!(why.is_empty() || got == why, "{got}, expected {why}"); + } + } + + #[test] + fn the_committed_int_hyper_nets_run_unrefused() { + // The tiny int_hyper vector's decode-side nets, on its own z: the checked run + // gives run_net's integers (the vector's values sit far inside every bound). + let d = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../wai-rs/tests/vectors/int_hyper"); + let h_s = IntModel::from_bytes(&std::fs::read(d.join("tiny_h_s.bin")).unwrap()).unwrap(); + let z: Vec = (0..8).map(|i| ((i % 5) - 2) << 12).collect(); + assert_eq!(run_net_checked(&h_s, &z, 2, 2, 2), Ok(run_net(&h_s, &z, 2, 2, 2))); + } +} diff --git a/wai/wai-rs/src/int_mlicpp.rs b/wai/wai-rs/src/int_mlicpp.rs new file mode 100644 index 000000000..a6a457e0d --- /dev/null +++ b/wai/wai-rs/src/int_mlicpp.rs @@ -0,0 +1,777 @@ +//! Integer-exact MLIC++-topology image codec — `wai.neural.int_mlicpp`: the +//! channel-autoregressive × checkerboard entropy model of MLIC++, with every +//! network on the decode side integer, so the decode is byte-identical on every +//! machine, runtime and thread count by construction. +//! +//! ## Why an integer entropy path +//! +//! The float capability (`wai.neural.mlicpp`, `neural/mlicpp.rs`) runs its entropy +//! network `2·K` times through a float runtime, and buckets each float scale against +//! the scale table. A decoder reproduces the encoder's symbols only while it evaluates +//! that network bit-identically to the encoder. With a validation encoder at its +//! runtime's default thread count and the decoder single-threaded, the first scale to +//! straddle a table edge (symbol 416 of 12,288, still in the first pass) selected a +//! different distribution, and every later symbol decoded wrong. Pinning the encoder +//! to the decoder's execution settings fixes that case, but it stays a convention +//! between two implementations. +//! +//! Here the scale σ and the mean μ come from integer networks, so every conforming +//! decoder computes exactly the σ the encoder computed, and the bucket +//! ([`det_build_index`]) is the same by construction — at any thread count, since the +//! fast kernels only re-partition work across output elements. That is WAI's +//! integer-entropy contract (SPEC §7): the capability is `NeuralIntegerExact`, +//! registered at *decode-equivalence* (`extensions/determinism-tiers.md`). +//! +//! ## Decode +//! +//! 1. `z`: per channel, rANS-decode against the channel's table; +//! `z = s·2^afrac + median_q` ([`dequantize`]). +//! 2. `hyper = h_s(z)`, `[2·Cy, Yh, Yw]`. +//! 3. For each of the `K` channel slices in order, two passes — the checkerboard +//! anchors (`(row + col)` even), then the non-anchors. Before each pass +//! `σ‖μ = entropy(concat[y decoded so far, hyper])`, where `y` is zero at every +//! element not yet decoded; then for each element of the slice in the pass, +//! channel-major then raster: `b = det_build_index(σ, scale_edges)`, `r =` rANS +//! decode against table `b`, `y = r·2^afrac + μ`. One `y` stream carries every +//! slice's two passes in that order. +//! 4. `g_s(y)`, clamped to `[0, 2^afrac]` and rounded to 8 bits +//! ([`int_hyper_synth::to_rgb8`](crate::int_hyper_synth::to_rgb8)): RGB, HWC. +//! +//! This mirrors the float decode orchestration of `neural/mlicpp.rs` step for step; +//! the networks are the sink-supplied model. The reference encoder, which also writes +//! the committed conformance vector, is `tools/wai_int_mlicpp_export.py`. +//! +//! ## Payload (little-endian) +//! +//! ```text +//! 0 [u8;4] "WIM1" +//! 4 u32 H, u32 W image +//! 12 u16 Cz, u16 Zh, u16 Zw hyper-latent z +//! 18 u16 Cy, u16 Yh, u16 Yw latent y +//! 24 u16 K channel slices; Cy is a multiple of K +//! 26 u16 reserved, 0 +//! 28 u32 Lz, u32 Ly byte lengths of the two rANS streams +//! 36 Lz bytes z stream, then Ly bytes y stream +//! ``` +//! +//! ## Model bundle +//! +//! One file, so that a prior's SHA-256 pins everything that determines the +//! reconstruction: magic `WMB1`, then four sections, each a `u32` length and its +//! bytes, in order `h_s`, `entropy`, `g_s` (integer networks in the compact `WHM1` +//! form, [`IntModel::from_bytes`]) and the entropy tables (JSON with the fields of +//! [`CondEntropy`], plus `afrac`). +//! +//! ## Totality +//! +//! The payload is untrusted, and every value it controls is range-checked before +//! use. Its header sizes are formed with checked arithmetic, and before the decode +//! allocates anything each network's shapes are derived from its layer geometry +//! ([`net_shape`](crate::int_hyper_synth::net_shape)), and the decode is refused — +//! the model does not fit this payload — unless they are the ones the header declares +//! and no buffer the decode forms (a tensor the header sizes, or any activation on the +//! way) exceeds [`Header::MAX_TENSOR`] elements. Every latent goes through [`dequantize`], which refuses a value `i64` +//! cannot hold, and every network through +//! [`run_net_checked`](crate::int_hyper_synth::run_net_checked), which refuses +//! activations a layer's arithmetic could overflow on. The model is checked when it +//! is loaded: its networks are parsed without trusting the counts they carry, and its +//! tables validated (well-formed CDFs, consistent lengths), which is the +//! precondition under which the rANS decoder's arithmetic is proved total +//! (`rans.rs`). So `decode` returns an image or an error for any payload, and a build +//! with overflow checks and one without return the same one. Pure integer, no I/O. + +use serde_json::Value; + +use crate::int_entropy::{dequantize, det_build_index}; +use crate::int_hyper_synth::{net_shape, run_net_checked, to_rgb8, CondEntropy, IntModel}; +use crate::rans::RansDecoder; + +/// Payload magic. +pub const PAYLOAD_MAGIC: [u8; 4] = *b"WIM1"; +/// Model-bundle magic. +pub const BUNDLE_MAGIC: [u8; 4] = *b"WMB1"; + +/// Why a payload or a model was refused. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IntMlicError { + /// The payload is malformed, or asks for values outside the exact integer range. + Payload(String), + /// The model bundle is malformed, or does not fit the payload's shapes. + Model(String), +} + +impl std::fmt::Display for IntMlicError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Payload(e) => write!(f, "int_mlicpp payload: {e}"), + Self::Model(e) => write!(f, "int_mlicpp model: {e}"), + } + } +} + +impl std::error::Error for IntMlicError {} + +fn payload_err(e: impl Into) -> IntMlicError { + IntMlicError::Payload(e.into()) +} +fn model_err(e: impl Into) -> IntMlicError { + IntMlicError::Model(e.into()) +} + +/// The decode-side model: the three integer networks and the entropy tables. +pub struct IntMlicModel { + /// Hyper-decoder: `z` (`Cz` channels) → `hyper` (`2·Cy` channels at the latent's size). + pub h_s: IntModel, + /// Entropy network: `concat[y, hyper]` (`3·Cy` channels) → `σ‖μ` (`2·Cy`). + pub entropy: IntModel, + /// Synthesis: `y` (`Cy` channels) → RGB (3 channels). + pub g_s: IntModel, + /// The scale edges, the per-bucket Gaussian tables, and the per-channel tables and + /// medians of `z`. + pub tables: CondEntropy, + afrac: u32, +} + +impl IntMlicModel { + /// Largest fixed-point scale a model may use. The decoder multiplies symbols by + /// `2^afrac` and rounds pixels at `2^afrac`; 30 bits leaves every such step far + /// from `i64`'s range. + /// + /// cbindgen:ignore + pub const MAX_AFRAC: u32 = 30; + + /// Assemble a model, validating what the decoder relies on: the three networks + /// share one fixed-point scale `afrac` in `1..=`[`Self::MAX_AFRAC`]; the scale table is + /// non-empty with one Gaussian table and offset per edge; `z` has one table, + /// offset and median per channel; and every table is a well-formed quantised + /// CDF (see [`check_cdf`]). + pub fn new(h_s: IntModel, entropy: IntModel, g_s: IntModel, tables: CondEntropy) -> Result { + let afrac = g_s.afrac; + if h_s.afrac != afrac || entropy.afrac != afrac { + return Err(model_err("the three networks must share one fixed-point scale")); + } + if !(1..=Self::MAX_AFRAC).contains(&afrac) { + return Err(model_err(format!("fixed-point scale 2^{afrac} outside 2^1..=2^{}", Self::MAX_AFRAC))); + } + let t = &tables; + let nb = t.edges.len(); + if nb == 0 || nb > u16::MAX as usize + 1 { + return Err(model_err("the scale table must have 1..=65536 entries")); + } + if t.cdfs.len() != nb || t.cdf_lengths.len() != nb || t.offsets.len() != nb { + return Err(model_err("one Gaussian table, length and offset per scale edge")); + } + let nz = t.z_cdfs.len(); + if nz == 0 || t.z_cdf_lengths.len() != nz || t.z_offsets.len() != nz || t.z_median_q.len() != nz { + return Err(model_err("one z table, length, offset and median per channel")); + } + for (cdf, &len) in t.cdfs.iter().zip(&t.cdf_lengths).chain(t.z_cdfs.iter().zip(&t.z_cdf_lengths)) { + check_cdf(cdf, len)?; + } + Ok(Self { h_s, entropy, g_s, tables, afrac }) + } + + /// Parse a `WMB1` model bundle (see the module docs). + pub fn from_bundle(bytes: &[u8]) -> Result { + if bytes.get(0..4) != Some(&BUNDLE_MAGIC[..]) { + return Err(model_err("not a WMB1 model bundle")); + } + let mut p = 4usize; + let mut section = |what: &str| -> Result<&[u8], IntMlicError> { + let len = bytes.get(p..p + 4).ok_or_else(|| model_err(format!("{what}: truncated length")))?; + let len = u32::from_le_bytes([len[0], len[1], len[2], len[3]]) as usize; + let start = p + 4; + let end = start.checked_add(len).filter(|&e| e <= bytes.len()) + .ok_or_else(|| model_err(format!("{what}: section runs past the bundle")))?; + p = end; + Ok(&bytes[start..end]) + }; + let net = |b: &[u8], what: &str| IntModel::from_bytes(b).map_err(|e| model_err(format!("{what}: {e}"))); + let h_s = net(section("h_s")?, "h_s")?; + let entropy = net(section("entropy")?, "entropy")?; + let g_s = net(section("g_s")?, "g_s")?; + let tables_json: Value = serde_json::from_slice(section("tables")?) + .map_err(|e| model_err(format!("tables: {e}")))?; + if p != bytes.len() { + return Err(model_err("trailing bytes after the tables section")); + } + let (tables, afrac) = parse_tables(&tables_json)?; + if afrac != g_s.afrac { + return Err(model_err("the tables' fixed-point scale differs from the networks'")); + } + Self::new(h_s, entropy, g_s, tables) + } + + /// The fixed-point scale every latent and activation is carried at (`2^afrac`). + pub fn afrac(&self) -> u32 { + self.afrac + } +} + +/// A well-formed quantised CDF for the rANS decoder: at least two entries in use and +/// no more than the row holds, `cdf[0] = 0`, strictly increasing, and +/// `cdf[len - 1] = 2^16` — the tables CompressAI's `pmf_to_quantized_cdf` produces. +/// Under it the decoder's symbol search and state update stay in range (the +/// admissible-table assumption of the proofs in `rans.rs`). +pub fn check_cdf(cdf: &[i32], len: usize) -> Result<(), IntMlicError> { + if len < 2 || len > cdf.len() || cdf[0] != 0 || cdf[len - 1] != 1 << 16 { + return Err(model_err("a quantised CDF must run from 0 to 2^16 over at least two entries")); + } + if cdf[..len].windows(2).any(|p| p[0] >= p[1]) { + return Err(model_err("a quantised CDF must be strictly increasing")); + } + Ok(()) +} + +/// Read the tables section: `afrac`, `scale_edges`, `cdfs`, `cdf_lengths`, `offsets`, +/// `z_cdfs`, `z_cdf_lengths`, `z_offsets`, `z_median_q` — the fields of +/// `int_hyper_synth`'s `cond_entropy.json`. Refuses (does not panic on) a missing +/// field, a non-integer, or a value out of its type's range. +fn parse_tables(v: &Value) -> Result<(CondEntropy, u32), IntMlicError> { + fn arr<'a>(v: &'a Value, k: &str) -> Result<&'a Vec, IntMlicError> { + v.get(k).and_then(Value::as_array).ok_or_else(|| model_err(format!("tables: `{k}` is not an array"))) + } + fn int>(x: &Value, k: &str) -> Result { + x.as_i64().and_then(|i| T::try_from(i).ok()).ok_or_else(|| model_err(format!("tables: `{k}` holds a value out of range"))) + } + fn ints>(v: &Value, k: &str) -> Result, IntMlicError> { + arr(v, k)?.iter().map(|x| int(x, k)).collect() + } + fn rows(v: &Value, k: &str) -> Result>, IntMlicError> { + arr(v, k)?.iter().map(|r| r.as_array().ok_or_else(|| model_err(format!("tables: `{k}` row is not an array"))) + .and_then(|r| r.iter().map(|x| int(x, k)).collect())).collect() + } + let afrac = v.get("afrac").ok_or_else(|| model_err("tables: no `afrac`")) + .and_then(|a| int::(a, "afrac"))?; + let t = CondEntropy { + edges: ints(v, "scale_edges")?, + cdfs: rows(v, "cdfs")?, + cdf_lengths: ints(v, "cdf_lengths")?, + offsets: ints(v, "offsets")?, + z_cdfs: rows(v, "z_cdfs")?, + z_cdf_lengths: ints(v, "z_cdf_lengths")?, + z_offsets: ints(v, "z_offsets")?, + z_median_q: ints(v, "z_median_q")?, + }; + Ok((t, afrac)) +} + +/// A parsed payload header, with the byte ranges of the two rANS streams. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Header { + pub height: usize, + pub width: usize, + pub cz: usize, + pub zh: usize, + pub zw: usize, + pub cy: usize, + pub yh: usize, + pub yw: usize, + /// Channel slices. + pub slices: usize, + pub z: std::ops::Range, + pub y: std::ops::Range, +} + +impl Header { + /// Payload header length in bytes. + /// + /// cbindgen:ignore + pub const LEN: usize = 36; + /// Largest element count of any one buffer a decode allocates: each tensor the + /// header sizes (the hyper-latent, the latent and its context, the image) and each + /// activation the networks form on the way (see [`decode`]). A sink policy, the + /// same on every platform so that a payload is accepted or refused alike + /// everywhere: `2^26` elements is 512 MiB of `i64`, which a 32-bit target can + /// still address several of. The image tensor alone reaches it at about 4700 × + /// 4700; how large an image a model can decode under it depends on its widest + /// activation (a synthesis with `C` channels at half resolution holds `C/4` + /// elements per pixel), so a wide model reaches it well before that. + /// + /// cbindgen:ignore + pub const MAX_TENSOR: usize = 1 << 26; +} + +/// Parse and check the payload header. Every size is formed with checked arithmetic, +/// and each tensor the header sizes directly — the hyper-latent, the latent's context +/// (three latent-sized planes), the image — is capped at [`Header::MAX_TENSOR`] +/// elements. Whether the model's networks produce these shapes, and what they +/// allocate on the way, depends on the model too: [`decode`] checks that before it +/// allocates anything. +pub fn parse_header(p: &[u8]) -> Result { + if p.len() < Header::LEN { + return Err(payload_err(format!("{} bytes, shorter than the {}-byte header", p.len(), Header::LEN))); + } + if p[0..4] != PAYLOAD_MAGIC { + return Err(payload_err("not a WIM1 payload")); + } + let u32at = |o: usize| u32::from_le_bytes([p[o], p[o + 1], p[o + 2], p[o + 3]]) as usize; + let u16at = |o: usize| u16::from_le_bytes([p[o], p[o + 1]]) as usize; + let (height, width) = (u32at(4), u32at(8)); + let (cz, zh, zw) = (u16at(12), u16at(14), u16at(16)); + let (cy, yh, yw) = (u16at(18), u16at(20), u16at(22)); + let (slices, reserved) = (u16at(24), u16at(26)); + let (lz, ly) = (u32at(28), u32at(32)); + if reserved != 0 { + return Err(payload_err("reserved header field is not zero")); + } + if [height, width, cz, zh, zw, cy, yh, yw, slices].contains(&0) { + return Err(payload_err("a dimension is zero")); + } + if !cy.is_multiple_of(slices) { + return Err(payload_err(format!("{cy} latent channels do not split into {slices} slices"))); + } + let size = |dims: &[usize]| dims.iter().try_fold(1usize, |a, &d| a.checked_mul(d)).filter(|&n| n <= Header::MAX_TENSOR); + if size(&[cz, zh, zw]).is_none() || size(&[3, cy, yh, yw]).is_none() || size(&[3, height, width]).is_none() { + return Err(payload_err(format!("a tensor exceeds {} elements", Header::MAX_TENSOR))); + } + let end = Header::LEN.checked_add(lz).and_then(|v| v.checked_add(ly)); + if end != Some(p.len()) { + return Err(payload_err(format!("header lengths {}+{lz}+{ly} do not match {} payload bytes", Header::LEN, p.len()))); + } + Ok(Header { + height, width, cz, zh, zw, cy, yh, yw, slices, + z: Header::LEN..Header::LEN + lz, + y: Header::LEN + lz..Header::LEN + lz + ly, + }) +} + +/// The checkerboard parity: an element is an anchor when `(row + col)` is even. The +/// same test as the float decoder's (`neural/mlicpp.rs`). +#[inline] +pub fn is_anchor(row: usize, col: usize) -> bool { + (row + col).is_multiple_of(2) +} + +/// Decoded RGB, row-major HWC `u8`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IntMlicImage { + pub rgb: Vec, + pub width: usize, + pub height: usize, +} + +/// What the `y` stream decoded, in coding order: the bucket each element's +/// distribution came from, and the residual symbol read with it. A conformance +/// check compares these with the encoder's, which names the first element two +/// implementations disagree on. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Trace { + pub buckets: Vec, + pub symbols: Vec, +} + +/// Decode a `WIM1` payload with `model` → RGB. Byte-identical on every machine. +/// +/// Before it allocates anything, the decode derives from the model's layer geometry +/// the shape each network gives on the header's shapes. Unless `h_s` gives the +/// hyper-parameters `[2·Cy, Yh, Yw]`, the entropy network maps `[3·Cy, Yh, Yw]` to +/// `[2·Cy, Yh, Yw]` and `g_s` gives the image `[3, H, W]`, and no buffer any of them +/// forms — an input, a layer's output, a transposed convolution's uncropped plane — +/// exceeds [`Header::MAX_TENSOR`] elements, it returns +/// [`IntMlicError::Model`]: this model does not fit this payload. +pub fn decode(model: &IntMlicModel, payload: &[u8]) -> Result { + decode_inner(model, payload, None) +} + +/// [`decode`], also returning the `y` stream's [`Trace`] — as far as the decode got, +/// so that when two implementations disagree, or a stream is refused, the first +/// element that differs can be found (a differing bucket means the entropy +/// parameters diverged; a differing symbol under the same bucket means the stream +/// itself did). +pub fn decode_traced(model: &IntMlicModel, payload: &[u8]) -> (Trace, Result) { + let mut trace = Trace::default(); + let img = decode_inner(model, payload, Some(&mut trace)); + (trace, img) +} + +/// Check, from `net`'s layer geometry alone, that on a `[c, h, w]` input it gives +/// `want` and forms no buffer over [`Header::MAX_TENSOR`] elements. Allocates no +/// activation. The model is refused if it does not fit the payload's shapes this way. +fn preflight(net: &IntModel, what: &str, (c, h, w): (usize, usize, usize), want: (usize, usize, usize)) -> Result<(), IntMlicError> { + let plan = net_shape(net, c, h, w).map_err(|why| model_err(format!("{what} refused, {why}")))?; + if plan.out != want { + let (oc, oh, ow) = plan.out; + return Err(model_err(format!("{what} gives [{oc}, {oh}, {ow}], the payload needs {want:?}"))); + } + if plan.peak > Header::MAX_TENSOR { + return Err(model_err(format!( + "{what} would form a {}-element tensor on [{c}, {h}, {w}], over the {}-element cap", plan.peak, Header::MAX_TENSOR))); + } + Ok(()) +} + +/// Run `net` on `x` (`[c, h, w]`) through [`run_net_checked`], after [`preflight`]: +/// the payload is refused if its values could take a layer out of the exact integer +/// range, the model if a layer does not fit the payload's shapes. +fn run(net: &IntModel, what: &str, x: &[i64], (c, h, w): (usize, usize, usize), want: (usize, usize, usize)) -> Result, IntMlicError> { + preflight(net, what, (c, h, w), want)?; + let (out, oc, oh, ow) = run_net_checked(net, x, c, h, w).map_err(|why| { + let msg = format!("{what} refused, {why}"); + if why.out_of_range { payload_err(msg) } else { model_err(msg) } + })?; + debug_assert_eq!((oc, oh, ow), want, "net_shape and run_net_checked disagree on {what}'s output shape"); + Ok(out) +} + +fn decode_inner(model: &IntMlicModel, payload: &[u8], mut trace: Option<&mut Trace>) -> Result { + let hd = parse_header(payload)?; + let (t, afrac) = (&model.tables, model.afrac); + if hd.cz > t.z_cdfs.len() { + return Err(model_err(format!("z has {} channels, the model's prior {}", hd.cz, t.z_cdfs.len()))); + } + let (cy, yh, yw) = (hd.cy, hd.yh, hd.yw); + // Every network against the header's shapes, before anything is allocated + // (parse_header bounded the products below). + preflight(&model.h_s, "h_s", (hd.cz, hd.zh, hd.zw), (2 * cy, yh, yw))?; + preflight(&model.entropy, "entropy", (3 * cy, yh, yw), (2 * cy, yh, yw))?; + preflight(&model.g_s, "g_s", (cy, yh, yw), (3, hd.height, hd.width))?; + let (plane, zplane) = (yh * yw, hd.zh * hd.zw); + let n = cy * plane; + + // 1. z: factorized, per channel. + let mut z = vec![0i64; hd.cz * zplane]; + let mut dz = RansDecoder::new(&payload[hd.z.clone()]).map_err(|e| payload_err(format!("z stream: {e}")))?; + for (ch, zc) in z.chunks_mut(zplane).enumerate() { + for v in zc { + let s = dz.decode(&t.z_cdfs[ch], t.z_cdf_lengths[ch], t.z_offsets[ch]); + *v = dequantize(s, afrac, t.z_median_q[ch]).ok_or_else(|| payload_err("a z value leaves i64"))?; + } + } + if dz.is_exhausted() { + return Err(payload_err("z stream ended before its last symbol")); + } + + // 2. hyper = h_s(z), then 3. the slices, each an anchor and a non-anchor pass. `ctx` + // is the entropy network's input, concat[y, hyper]; y is decoded into its first + // `n` elements in place, and is zero wherever not yet decoded. + let hyper = run(&model.h_s, "h_s", &z, (hd.cz, hd.zh, hd.zw), (2 * cy, yh, yw))?; + let mut ctx = vec![0i64; 3 * n]; + ctx[n..].copy_from_slice(&hyper); + let mut dy = RansDecoder::new(&payload[hd.y.clone()]).map_err(|e| payload_err(format!("y stream: {e}")))?; + let width = cy / hd.slices; + for k in 0..hd.slices { + for anchor in [true, false] { + let params = run(&model.entropy, "entropy", &ctx, (3 * cy, yh, yw), (2 * cy, yh, yw))?; + let (sigma, mu) = params.split_at(n); + for ch in k * width..(k + 1) * width { + for row in 0..yh { + for col in 0..yw { + if is_anchor(row, col) != anchor { + continue; + } + let e = ch * plane + row * yw + col; + let b = det_build_index(sigma[e], &t.edges); + let r = dy.decode(&t.cdfs[b], t.cdf_lengths[b], t.offsets[b]); + ctx[e] = dequantize(r, afrac, mu[e]).ok_or_else(|| payload_err("a y value leaves i64"))?; + if let Some(tr) = trace.as_deref_mut() { + tr.buckets.push(b); + tr.symbols.push(r); + } + } + } + } + } + } + if dy.is_exhausted() { + return Err(payload_err("y stream ended before its last symbol")); + } + + // 4. synthesis → RGB. + let x = run(&model.g_s, "g_s", &ctx[..n], (cy, yh, yw), (3, hd.height, hd.width))?; + Ok(IntMlicImage { rgb: to_rgb8(&x, 3, hd.height, hd.width, afrac), width: hd.width, height: hd.height }) +} + +/// A small synthetic model and payload, built in memory, so these tests need no files +/// (the committed vector is decoded by `tests/int_mlicpp.rs` and the +/// `byte-exact-conformance` golden). +#[cfg(test)] +mod tests { + use super::*; + use crate::int_hyper_synth::IntLayer; + use crate::int_kernels::testkit::{Rng, FAST}; + use crate::int_kernels::{with_policy, Policy}; + use crate::rans::RansEncoder; + + const AF: u32 = 12; + + fn conv_t(g: &mut Rng, cin: usize, cout: usize, bias: i64) -> IntLayer { + IntLayer::ConvT { out_ch: cout, k: 3, stride: 2, pad: 1, opad: 1, weight: g.vec(cin * cout * 9, 400, 0), + mult: vec![1 << 17; cout], bias: vec![bias; cout] } + } + + /// A Gaussian-ish quantised CDF over `n` symbols plus the escape: frequencies + /// fall off from the centre, each at least 1, summing to 2^16. + fn table(n: usize, spread: usize) -> Vec { + let mut f: Vec = (0..n).map(|i| (4096i32 >> (i.abs_diff(n / 2) / spread).min(12)).max(1)).collect(); + f.push(1); + let total: i32 = f.iter().sum(); + f[n / 2] += (1 << 16) - total; + let mut cdf = vec![0i32]; + for x in f { + cdf.push(cdf.last().unwrap() + x); + } + cdf + } + + /// Cy = 4 in K = 2 slices over a 4×4 latent, z 2×2×2, a 16×16 image. + fn model(g: &mut Rng) -> IntMlicModel { + let net = |layers| IntModel { afrac: AF, gf: 16, shift: 24, layers }; + let h_s = net(vec![conv_t(g, 2, 4, 0), IntLayer::Relu, + IntLayer::Conv { out_ch: 8, k: 3, stride: 1, pad: 1, weight: g.vec(4 * 8 * 9, 400, 0), mult: vec![1 << 17; 8], bias: vec![0; 8] }]); + let entropy = net(vec![ + IntLayer::Conv { out_ch: 8, k: 3, stride: 1, pad: 1, weight: g.vec(12 * 8 * 9, 300, 0), mult: vec![1 << 16; 8], + bias: vec![4 << AF, 3 << AF, 2 << AF, 1 << AF, 0, 0, 0, 0] }, + IntLayer::Leaky { slope_q: 655, slope_frac: 16 }]); + let g_s = net(vec![conv_t(g, 4, 4, 0), IntLayer::Relu, conv_t(g, 4, 3, 1 << (AF - 1))]); + let edges: Vec = [0.25f64, 0.5, 1.0, 2.0, 4.0, 8.0].iter().map(|s| (s * (1 << AF) as f64) as i64).collect(); + let cdfs: Vec> = (0..edges.len()).map(|i| table(9 + 2 * i, 1 + i)).collect(); + let tables = CondEntropy { + cdf_lengths: cdfs.iter().map(Vec::len).collect(), + offsets: (0..edges.len()).map(|i| -(4 + i as i32)).collect(), + edges, cdfs, + z_cdfs: vec![table(9, 2); 2], z_cdf_lengths: vec![11; 2], z_offsets: vec![-4; 2], z_median_q: vec![0, 1 << (AF - 2)], + }; + IntMlicModel::new(h_s, entropy, g_s, tables).unwrap() + } + + /// Encode a random latent the way `tools/wai_int_mlicpp_export.py` does (same + /// order, the decoder's own networks for σ and μ), returning the payload and the + /// encoder's trace. In-range symbols only (the in-crate encoder has no escape). + fn encode(m: &IntMlicModel, g: &mut Rng) -> (Vec, Trace) { + let (cy, s, k) = (4usize, 4usize, 2usize); + let n = cy * s * s; + let t = &m.tables; + let mut ze = RansEncoder::new(); + let mut z = vec![0i64; 8]; + for (i, v) in z.iter_mut().enumerate() { + let sym = g.below(7) as i32 - 3; + assert!(ze.encode(sym, &t.z_cdfs[i / 4], t.z_cdf_lengths[i / 4], t.z_offsets[i / 4])); + *v = dequantize(sym, AF, t.z_median_q[i / 4]).unwrap(); + } + let hyper = run(&m.h_s, "h_s", &z, (2, 2, 2), (8, s, s)).unwrap(); + let mut ctx = vec![0i64; 3 * n]; + ctx[n..].copy_from_slice(&hyper); + let (mut ye, mut tr) = (RansEncoder::new(), Trace::default()); + for ki in 0..k { + for anchor in [true, false] { + let p = run(&m.entropy, "entropy", &ctx, (12, s, s), (8, s, s)).unwrap(); + for ch in ki * 2..ki * 2 + 2 { + for (row, col) in (0..s).flat_map(|r| (0..s).map(move |c| (r, c))) { + if is_anchor(row, col) != anchor { + continue; + } + let e = ch * s * s + row * s + col; + let b = det_build_index(p[e], &t.edges); + let sym = g.below(7) as i32 - 3; + assert!(ye.encode(sym, &t.cdfs[b], t.cdf_lengths[b], t.offsets[b])); + ctx[e] = dequantize(sym, AF, p[n + e]).unwrap(); + tr.buckets.push(b); + tr.symbols.push(sym); + } + } + } + } + let (zb, yb) = (ze.flush(), ye.flush()); + let mut pl = PAYLOAD_MAGIC.to_vec(); + for v in [16u32, 16] { + pl.extend(v.to_le_bytes()); + } + for v in [2u16, 2, 2, cy as u16, s as u16, s as u16, k as u16, 0] { + pl.extend(v.to_le_bytes()); + } + pl.extend((zb.len() as u32).to_le_bytes()); + pl.extend((yb.len() as u32).to_le_bytes()); + pl.extend(zb); + pl.extend(yb); + (pl, tr) + } + + #[test] + fn the_decoder_reads_back_what_the_encoder_wrote() { + let mut g = Rng(0x1A7E_4C00); + let m = model(&mut g); + let (pl, want) = encode(&m, &mut g); + let (got, img) = decode_traced(&m, &pl); + let img = img.unwrap(); + assert_eq!(got, want, "every bucket and every symbol, in coding order"); + assert_eq!((img.width, img.height, img.rgb.len()), (16, 16, 16 * 16 * 3)); + assert!(want.buckets.iter().any(|&b| b != want.buckets[0]), "the test must span several buckets"); + } + + #[test] + fn the_decode_is_the_same_under_every_kernel_policy() { + let mut g = Rng(0x1A7E_4C01); + let m = model(&mut g); + let (pl, _) = encode(&m, &mut g); + let want = with_policy(Policy::reference(), || decode_traced(&m, &pl)); + assert!(want.1.is_ok()); + for p in FAST { + assert_eq!(with_policy(p, || decode_traced(&m, &pl)), want, "{p:?}"); + } + } + + #[test] + fn a_malformed_header_is_refused() { + let mut g = Rng(0x1A7E_4C02); + let m = model(&mut g); + let (pl, _) = encode(&m, &mut g); + let with = |off: usize, bytes: &[u8]| { let mut p = pl.clone(); p[off..off + bytes.len()].copy_from_slice(bytes); p }; + for (bad, why) in [ + (pl[..Header::LEN - 1].to_vec(), "shorter than"), + (with(0, b"WIM2"), "not a WIM1"), + (with(26, &1u16.to_le_bytes()), "reserved"), + (with(18, &0u16.to_le_bytes()), "zero"), + (with(24, &3u16.to_le_bytes()), "slices"), + (with(4, &u32::MAX.to_le_bytes()), "exceeds"), + (with(28, &0u32.to_le_bytes()), "do not match"), + ([pl.as_slice(), &[0]].concat(), "do not match"), + ] { + match decode(&m, &bad) { + Err(IntMlicError::Payload(e)) => assert!(e.contains(why), "{why}: {e}"), + other => panic!("{why}: expected a payload refusal, got {other:?}"), + } + } + } + + #[test] + fn a_model_that_does_not_fit_the_payload_is_refused() { + let mut g = Rng(0x1A7E_4C03); + let m = model(&mut g); + let (pl, _) = encode(&m, &mut g); + // Claim 5 latent channels in 5 slices: h_s gives 8 channels where the payload + // needs 2·5 = 10, so the model is refused for this payload before any y symbol + // is read. + let mut bad = pl.clone(); + bad[18..20].copy_from_slice(&5u16.to_le_bytes()); + bad[24..26].copy_from_slice(&5u16.to_le_bytes()); + match decode(&m, &bad) { + Err(IntMlicError::Model(e)) => assert!(e.contains("h_s"), "{e}"), + other => panic!("expected a model refusal, got {other:?}"), + } + } + + /// Every byte of the payload changed in turn, and random multi-byte changes: the + /// decode returns an image or an error, never panics — in this (overflow-checked) + /// test build as in a release build. + #[test] + fn corrupted_payloads_never_panic() { + let mut g = Rng(0x1A7E_4C04); + let m = model(&mut g); + let (pl, _) = encode(&m, &mut g); + let (mut decoded, mut refused) = (0, 0); + let mut tally = |r: Result| if r.is_ok() { decoded += 1 } else { refused += 1 }; + for i in 0..pl.len() { + for x in [0x01u8, 0x80, 0xFF] { + let mut p = pl.clone(); + p[i] ^= x; + tally(decode(&m, &p)); + } + } + for _ in 0..1000 { + let mut p = pl.clone(); + for _ in 0..1 + g.below(6) { + let i = g.below(p.len()); + p[i] = g.next() as u8; + } + tally(decode(&m, &p)); + } + // Both outcomes occur: the changes reach past the header into the streams + // (which then decode to some image) as well as into what is refused. + assert!(decoded > 0 && refused > 0, "{decoded} decoded, {refused} refused"); + } + + #[test] + fn a_bad_table_is_refused_when_the_model_is_built() { + let mut g = Rng(0x1A7E_4C05); + let m = model(&mut g); + let rebuild = |f: &dyn Fn(&mut CondEntropy)| { + let mut t = CondEntropy { edges: m.tables.edges.clone(), cdfs: m.tables.cdfs.clone(), cdf_lengths: m.tables.cdf_lengths.clone(), + offsets: m.tables.offsets.clone(), z_cdfs: m.tables.z_cdfs.clone(), z_cdf_lengths: m.tables.z_cdf_lengths.clone(), + z_offsets: m.tables.z_offsets.clone(), z_median_q: m.tables.z_median_q.clone() }; + f(&mut t); + let net = |n: &IntModel| IntModel::from_bytes(&to_whm1(n)).unwrap(); + IntMlicModel::new(net(&m.h_s), net(&m.entropy), net(&m.g_s), t).err() + }; + assert!(rebuild(&|t| t.cdfs[0][3] = t.cdfs[0][2]).is_some(), "not strictly increasing"); + assert!(rebuild(&|t| { let l = t.cdf_lengths[1]; t.cdfs[1][l - 1] -= 1 }).is_some(), "does not end at 2^16"); + assert!(rebuild(&|t| t.cdf_lengths[2] = 99).is_some(), "length past the row"); + assert!(rebuild(&|t| { t.edges.pop(); }).is_some(), "an edge without a table"); + assert!(rebuild(&|t| { t.z_median_q.pop(); }).is_some(), "a z channel without a median"); + assert!(rebuild(&|_| {}).is_none(), "the unmodified tables are accepted"); + } + + /// The compact WHM1 bytes of a model (the export tool's `model_to_bin`). + fn to_whm1(m: &IntModel) -> Vec { + let mut b = b"WHM1".to_vec(); + for v in [m.afrac, m.gf, m.shift, m.layers.len() as u32] { + b.extend(v.to_le_bytes()); + } + let mut cin = match &m.layers[0] { IntLayer::ConvT { weight, out_ch, k, .. } | IntLayer::Conv { weight, out_ch, k, .. } => weight.len() / (out_ch * k * k), _ => 0 }; + for l in &m.layers { + match l { + IntLayer::ConvT { out_ch, k, stride, pad, opad, weight, mult, bias } => { + b.push(0); + for v in [cin, *out_ch, *k, *stride, *pad, *opad] { b.extend((v as u32).to_le_bytes()); } + for w in weight { b.extend((*w as i16).to_le_bytes()); } + for v in mult.iter().chain(bias) { b.extend(v.to_le_bytes()); } + cin = *out_ch; + } + IntLayer::Conv { out_ch, k, stride, pad, weight, mult, bias } => { + b.push(1); + for v in [cin, *out_ch, *k, *stride, *pad] { b.extend((v as u32).to_le_bytes()); } + for w in weight { b.extend((*w as i16).to_le_bytes()); } + for v in mult.iter().chain(bias) { b.extend(v.to_le_bytes()); } + cin = *out_ch; + } + IntLayer::Igdn { gamma, beta } => { + b.push(2); + b.extend((beta.len() as u32).to_le_bytes()); + for g in gamma { b.extend((*g as i32).to_le_bytes()); } + for v in beta { b.extend(v.to_le_bytes()); } + } + IntLayer::Relu => b.push(3), + IntLayer::Leaky { slope_q, slope_frac } => { + b.push(4); + b.extend(slope_q.to_le_bytes()); + b.extend(slope_frac.to_le_bytes()); + } + } + } + b + } + + #[test] + fn a_bundle_round_trips_and_a_damaged_one_is_refused() { + let mut g = Rng(0x1A7E_4C06); + let m = model(&mut g); + let (pl, _) = encode(&m, &mut g); + let t = &m.tables; + let tables = serde_json::json!({ + "afrac": AF, "scale_edges": t.edges, "cdfs": t.cdfs, "cdf_lengths": t.cdf_lengths, "offsets": t.offsets, + "z_cdfs": t.z_cdfs, "z_cdf_lengths": t.z_cdf_lengths, "z_offsets": t.z_offsets, "z_median_q": t.z_median_q, + }); + let mut bundle = BUNDLE_MAGIC.to_vec(); + for part in [to_whm1(&m.h_s), to_whm1(&m.entropy), to_whm1(&m.g_s), serde_json::to_vec(&tables).unwrap()] { + bundle.extend((part.len() as u32).to_le_bytes()); + bundle.extend(part); + } + let back = IntMlicModel::from_bundle(&bundle).unwrap(); + assert_eq!(decode(&back, &pl).unwrap(), decode(&m, &pl).unwrap()); + // Truncated anywhere, or with a byte appended: refused, never a panic. + for cut in [0, 3, 4, 7, 40, bundle.len() / 2, bundle.len() - 1] { + assert!(IntMlicModel::from_bundle(&bundle[..cut]).is_err(), "cut at {cut}"); + } + assert!(IntMlicModel::from_bundle(&[bundle.as_slice(), &[0]].concat()).is_err()); + // A tables section that names a different fixed-point scale. + let mut other = tables.clone(); + other["afrac"] = 13.into(); + let tj = serde_json::to_vec(&other).unwrap(); + let mut b2 = BUNDLE_MAGIC.to_vec(); + for part in [to_whm1(&m.h_s), to_whm1(&m.entropy), to_whm1(&m.g_s), tj] { + b2.extend((part.len() as u32).to_le_bytes()); + b2.extend(part); + } + assert!(IntMlicModel::from_bundle(&b2).is_err()); + } +} diff --git a/wai/wai-rs/src/lib.rs b/wai/wai-rs/src/lib.rs index b1d964a46..2c76a6043 100644 --- a/wai/wai-rs/src/lib.rs +++ b/wai/wai-rs/src/lib.rs @@ -71,6 +71,14 @@ pub mod int_transform; #[cfg(feature = "neural_int")] pub mod int_hyper_synth; +// The MLIC++ entropy topology (channel-autoregressive slices × checkerboard passes, +// an entropy network over the latent decoded so far) with every decode-side network +// integer: `wai.neural.int_mlicpp`. Built from int_hyper_synth's networks, +// int_entropy's bucketing and the integer rANS; refuses any stream value outside the +// exact integer range rather than overflowing. Pure integer, no `ort`. +#[cfg(feature = "neural_int")] +pub mod int_mlicpp; + // Byte-exact integer TRANSFORMER kernels (softmax / attention / LayerNorm / // L2-norm / ELU / Sigmoid / MDTA) — the pure-i64 arithmetic a learned ATTENTION // image codec (JPEG-AI's Restormer synthesis) needs beyond the conv/IGDN stack diff --git a/wai/wai-rs/src/neural/int_mlicpp.rs b/wai/wai-rs/src/neural/int_mlicpp.rs new file mode 100644 index 000000000..54055ea59 --- /dev/null +++ b/wai/wai-rs/src/neural/int_mlicpp.rs @@ -0,0 +1,31 @@ +//! Neural-dispatch shim for `wai.neural.int_mlicpp`. +//! +//! The decoder lives at the crate root in [`crate::int_mlicpp`] behind the light +//! `neural_int` feature (no ONNX runtime). This shim loads the registered model +//! bundle and maps the result onto the neural module's types. The registered path is +//! the one-file `WMB1` bundle, so a pinned prior's SHA-256 covers every network and +//! table the decode uses; the shim checks the digest of the very bytes it then +//! parses, rather than of a separate read of the same path. + +use std::path::Path; + +use crate::container::Prior; + +use super::{image::DecodedImage, DecodeError}; + +pub(crate) fn decode(payload: &[u8], model_path: &Path, prior: Option<&Prior>) -> Result { + use sha2::{Digest, Sha256}; + let bytes = std::fs::read(model_path) + .map_err(|e| DecodeError::InvalidPayload(format!("int_mlicpp model {}: {e}", model_path.display())))?; + if let Some(p) = prior { + let got: String = Sha256::digest(&bytes).iter().map(|b| format!("{b:02x}")).collect(); + if !got.eq_ignore_ascii_case(&p.sha256) { + return Err(DecodeError::PriorMismatch(format!( + "wai.neural.int_mlicpp: model {} hashes to {got}, the manifest pins {}", model_path.display(), p.sha256))); + } + } + let model = crate::int_mlicpp::IntMlicModel::from_bundle(&bytes) + .map_err(|e| DecodeError::InvalidPayload(e.to_string()))?; + let img = crate::int_mlicpp::decode(&model, payload).map_err(|e| DecodeError::InvalidPayload(e.to_string()))?; + Ok(DecodedImage { rgb: img.rgb, width: img.width as u32, height: img.height as u32 }) +} diff --git a/wai/wai-rs/src/neural/mod.rs b/wai/wai-rs/src/neural/mod.rs index d1ec2df1b..eb0a4400d 100644 --- a/wai/wai-rs/src/neural/mod.rs +++ b/wai/wai-rs/src/neural/mod.rs @@ -35,6 +35,7 @@ pub mod jpegai_hyper; pub mod jpegai_mans; pub mod jpegai_bitstream; mod int_synth; +mod int_mlicpp; mod video; mod video_dcvc; pub(crate) use crate::rans; @@ -191,6 +192,10 @@ pub fn decode_envelope(env: &Wai, registry: &ModelRegistry) -> Result image::decode(&env.payload, path).map(Decoded::Image), "wai.neural.mbt2018_mean" => hyperprior::decode(&env.payload, path).map(Decoded::Image), "wai.neural.int_synth" => int_synth::decode(&env.payload, path).map(Decoded::Image), + // The MLIC++ entropy topology, integer end to end (crate::int_mlicpp). `path` is + // the one-file WMB1 model bundle; the shim re-checks a pinned prior against the + // exact bytes it decodes with. + "wai.neural.int_mlicpp" => int_mlicpp::decode(&env.payload, path, env.prior()).map(Decoded::Image), "wai.neural.elic" => context::decode(&env.payload, path).map(Decoded::Image), "wai.neural.mlicpp" => mlicpp::decode(&env.payload, path).map(Decoded::Image), // MLICv2 (current SOTA, −20% vs VTM) is the SAME multi-reference diff --git a/wai/wai-rs/tests/int_kernels_vectors.rs b/wai/wai-rs/tests/int_kernels_vectors.rs index 9333c73dd..b65b4ccde 100644 --- a/wai/wai-rs/tests/int_kernels_vectors.rs +++ b/wai/wai-rs/tests/int_kernels_vectors.rs @@ -1,5 +1,5 @@ //! Every committed integer-decoder vector whose decode reaches the fast kernels -//! (int_hyper, int_audio, int_splat, int_world one and two agents, the +//! (int_hyper, int_mlicpp, int_audio, int_splat, int_world one and two agents, the //! neural-keyframe video, the int_synth corpus), plus the real-footage video, //! decoded under the scalar reference kernels AND under each fast-kernel policy //! (serial, explicit multi-threaded tilings, automatic): the outputs must be @@ -49,6 +49,17 @@ fn int_hyper_tiny_conditional_codec() { assert_eq!(format!("{:016x}", fnv1a(&rgb)), want); } +#[test] +fn int_mlicpp_conformance_vector() { + use wai::int_hyper_synth::fnv1a; + use wai::int_mlicpp::{decode, IntMlicModel}; + let d = vroot().join("int_mlicpp"); + let model = IntMlicModel::from_bundle(&std::fs::read(d.join("model.wmb")).unwrap()).unwrap(); + let env = wai::container::Wai::from_bytes(&std::fs::read(d.join("stream.wai")).unwrap()).unwrap(); + let img = same_under_every_policy("int_mlicpp", || decode(&model, &env.payload).unwrap()); + assert_eq!(format!("{:016x}", fnv1a(&img.rgb)), jload(&d.join("expected.json"))["rgb_fnv1a"].as_str().unwrap()); +} + #[test] fn int_audio_clip() { use wai::int_audio::{decode, pcm_fnv1a, AudioModel}; diff --git a/wai/wai-rs/tests/int_mlicpp.rs b/wai/wai-rs/tests/int_mlicpp.rs new file mode 100644 index 000000000..08c73d20f --- /dev/null +++ b/wai/wai-rs/tests/int_mlicpp.rs @@ -0,0 +1,236 @@ +//! `wai.neural.int_mlicpp` on its committed conformance vector +//! (`tests/vectors/int_mlicpp/`, written by `tools/wai_int_mlicpp_export.py`). No +//! test here skips: the vector is in the repository. +//! +//! The vector's weights are synthetic (a fixed seed, quantised the way a trained +//! model's would be). These tests establish decode-equivalence — that this decoder +//! reproduces the export tool's independent integer reference byte for byte, derives +//! every scale bucket the encoder used, and does so under every kernel policy and +//! thread count — and nothing about compression quality. +//! +//! What they pin, in `expected.json` and again as literals below so that a changed +//! vector cannot change them unnoticed: FNV-1a of the decoded RGB, and FNV-1a over the +//! y stream's bucket indices (u16 LE) and residual symbols (i32 LE) in coding order. +//! The SHA-256 of the pixels, of the model bundle and of the payload are checked by +//! the `byte-exact-conformance` golden, whose platform matrix runs the same decode. +#![cfg(feature = "neural_int")] + +use std::path::PathBuf; + +use wai::container::Wai; +use wai::int_hyper_synth::{fnv1a, IntLayer}; +use wai::int_kernels::{with_policy, Policy}; +use wai::int_mlicpp::{decode, decode_traced, parse_header, Header, IntMlicImage, IntMlicModel, Trace}; + +const RGB_FNV1A: &str = "7c0b9a4b3423d775"; +const BUCKET_FNV1A: &str = "2d30432f4fb206a0"; +const SYMBOL_FNV1A: &str = "14ace1d4ad3a2446"; +const MODEL_SHA256: &str = "7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4"; + +fn dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/vectors/int_mlicpp") +} +fn read(name: &str) -> Vec { + let p = dir().join(name); + std::fs::read(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display())) +} +fn model() -> IntMlicModel { + IntMlicModel::from_bundle(&read("model.wmb")).expect("the committed model bundle") +} +fn envelope() -> Wai { + Wai::from_bytes(&read("stream.wai")).expect("the committed envelope") +} +fn expected() -> serde_json::Value { + serde_json::from_slice(&read("expected.json")).expect("expected.json") +} +fn hex(h: u64) -> String { + format!("{h:016x}") +} +fn bucket_hash(t: &Trace) -> String { + let b: Vec = t.buckets.iter().flat_map(|&b| u16::try_from(b).expect("bucket fits u16").to_le_bytes()).collect(); + hex(fnv1a(&b)) +} +fn symbol_hash(t: &Trace) -> String { + let b: Vec = t.symbols.iter().flat_map(|s| s.to_le_bytes()).collect(); + hex(fnv1a(&b)) +} + +#[test] +fn the_committed_vector_decodes_to_its_pinned_image() { + let (env, want) = (envelope(), expected()); + let img = decode(&model(), &env.payload).expect("decode"); + assert_eq!((img.width, img.height), (64, 64)); + assert_eq!(img.rgb.len(), 64 * 64 * 3); + let got = hex(fnv1a(&img.rgb)); + assert_eq!(got, want["rgb_fnv1a"].as_str().unwrap(), "the export tool's integer reference"); + assert_eq!(got, RGB_FNV1A, "the pinned value"); +} + +/// The property the float capability lacks: the decoder derives, from its own +/// integer networks, exactly the bucket the encoder used for every element — and +/// reads exactly the encoder's symbol with it. +#[test] +fn the_decoder_derives_every_bucket_the_encoder_used() { + let (env, want, m) = (envelope(), expected(), model()); + let (trace, img) = decode_traced(&m, &env.payload); + img.expect("decode"); + assert_eq!(trace.buckets.len() as u64, want["y_symbols"].as_u64().unwrap()); + assert_eq!(bucket_hash(&trace), want["bucket_fnv1a"].as_str().unwrap()); + assert_eq!(bucket_hash(&trace), BUCKET_FNV1A); + assert_eq!(symbol_hash(&trace), want["symbol_fnv1a"].as_str().unwrap()); + assert_eq!(symbol_hash(&trace), SYMBOL_FNV1A); + // What the vector exercises: every scale bucket, and the rANS escape (a residual + // outside its table, coded in bypass). + let used: std::collections::BTreeSet<_> = trace.buckets.iter().copied().collect(); + assert_eq!(used.len() as u64, want["buckets_used"].as_u64().unwrap()); + assert_eq!(used.len(), m.tables.edges.len(), "every bucket of the scale table is used"); + let escapes = trace.buckets.iter().zip(&trace.symbols) + .filter(|&(&b, &s)| { let v = s - m.tables.offsets[b]; v < 0 || v >= m.tables.cdf_lengths[b] as i32 - 2 }) + .count(); + assert_eq!(escapes as u64, want["y_escapes"].as_u64().unwrap()); + assert!(escapes > 0, "the vector exercises the escape path"); +} + +/// The float MLIC++ decode desynchronised when its encoder evaluated the entropy +/// network with a different thread count from the decoder's. Here the fast kernels +/// split the work across 1, 2, 3, 5 or all available threads, or the scalar reference +/// runs: every bucket, every symbol and every pixel is the same. +#[test] +fn thread_count_cannot_move_a_bucket() { + let (env, m) = (envelope(), model()); + let run = |p: Policy| -> (Trace, IntMlicImage) { + let (t, img) = with_policy(p, || decode_traced(&m, &env.payload)); + (t, img.expect("decode")) + }; + let want = run(Policy::reference()); + assert_eq!(hex(fnv1a(&want.1.rgb)), RGB_FNV1A); + for p in [Policy::fast(1), Policy::fast(2), Policy::fast(3), Policy::fast(5), Policy::fast(0)] { + assert!(run(p) == want, "{p:?} differs from the reference kernels"); + } +} + +fn bump(l: &mut IntLayer, i: usize, by: i64, bias: bool) { + match l { + IntLayer::Conv { weight, bias: b, .. } | IntLayer::ConvT { weight, bias: b, .. } => { + if bias { b[i] += by } else { weight[i] += by } + } + _ => panic!("not a convolution"), + } +} + +/// The pinned image is not vacuous: each of four model parameters, one on each path +/// that feeds a latent value or a pixel — a weight of the hyper-decoder's first layer, +/// of the entropy network's shared layer, a bias of its mean head, and a weight of the +/// synthesis's first layer — changed by one integer unit, and the decode no longer +/// reproduces it (it decodes a different image, or refuses the stream once its symbols +/// stop making sense). Four chosen parameters, not every one: a weight that multiplies +/// only zeros, or a unit a later rounding absorbs, can leave the image as it was. The +/// scale head is different by design; see `a_scale_matters_only_through_its_bucket`. +#[test] +fn one_unit_on_each_value_path_changes_the_image() { + let env = envelope(); + type Change = fn(&mut IntMlicModel); + let cases: [(&str, Change); 4] = [ + ("h_s weight", |m| bump(&mut m.h_s.layers[0], 7, 1, false)), + ("entropy weight", |m| bump(&mut m.entropy.layers[0], 1234, 1, false)), + ("entropy mean bias", |m| bump(&mut m.entropy.layers[2], 8, 1, true)), + ("g_s weight", |m| bump(&mut m.g_s.layers[0], 99, 1, false)), + ]; + for (what, change) in cases { + let mut m = model(); + change(&mut m); + let same = decode(&m, &env.payload).is_ok_and(|img| hex(fnv1a(&img.rgb)) == RGB_FNV1A); + assert!(!same, "{what}: a one-unit change left the pinned image unchanged"); + } +} + +/// How the float decode failed, and why the integer one cannot: a scale selects a +/// distribution only through its bucket. Moved by one unit without crossing a table +/// edge, channel 0's scales change nothing — same buckets, same symbols, same image. +/// Moved across edges (by one whole unit of scale, `2^afrac`), the first element +/// whose bucket moves is where the decode leaves the encoder: its symbol is read +/// against another distribution, and so is everything after it. Two float decoders +/// that compute a scale on either side of an edge do exactly this (the float MLIC++ +/// validation run diverged at symbol 416 of 12,288); two integer decoders compute +/// the same scale, so they cannot. +#[test] +fn a_scale_matters_only_through_its_bucket() { + let env = envelope(); + let (truth, img) = decode_traced(&model(), &env.payload); + img.expect("decode"); + let sigma_bias_ch0 = |by: i64| { + let mut m = model(); + bump(&mut m.entropy.layers[2], 0, by, true); + decode_traced(&m, &env.payload) + }; + + let (t, img) = sigma_bias_ch0(1); + assert_eq!(t, truth, "one unit within the buckets: the same buckets and symbols"); + assert_eq!(hex(fnv1a(&img.expect("decode").rgb)), RGB_FNV1A); + + let (t, img) = sigma_bias_ch0(1 << model().afrac()); + let first = (0..truth.buckets.len()) + .find(|&i| t.buckets.get(i) != Some(&truth.buckets[i]) || t.symbols.get(i) != Some(&truth.symbols[i])) + .expect("the stream leaves the encoder somewhere"); + assert!(first < t.buckets.len(), "the divergence is within what was decoded"); + assert_ne!(t.buckets[first], truth.buckets[first], "the first difference is a bucket, not a symbol"); + // Channel 0's anchors are the first 32 elements coded (slice 0, anchor pass). + assert!(first < 32, "the first moved bucket is one of channel 0's, at element {first}"); + let outcome = match &img { + Ok(img) => format!("decodes to {}", hex(fnv1a(&img.rgb))), + Err(e) => format!("is refused ({e})"), + }; + println!("scale +1.0 on channel 0: first moved bucket at element {first} ({} -> {}); the stream then {outcome}", + truth.buckets[first], t.buckets[first]); + assert!(img.map_or(true, |img| hex(fnv1a(&img.rgb)) != RGB_FNV1A), "the image is not the pinned one"); +} + +#[test] +fn the_envelope_pins_the_model_as_an_integer_exact_prior() { + let (env, want) = (envelope(), expected()); + assert_eq!(env.capability(), "wai.neural.int_mlicpp"); + assert_eq!(env.manifest.intent, "replicate"); + let prior = env.prior().expect("a pinned prior"); + assert_eq!(prior.sha256, MODEL_SHA256); + assert_eq!(prior.sha256, want["model_sha256"].as_str().unwrap()); + assert!(prior.is_cross_hardware_exact(), "an integer-exact contract, in no declared form"); + // SPEC §7: the registry class (NeuralIntegerExact) and the prior both back replicate. + assert_eq!(env.replicate_refusal(), None); + let h = parse_header(&env.payload).expect("header"); + assert_eq!((h.cz, h.zh, h.zw, h.cy, h.yh, h.yw, h.slices), (4, 2, 2, 8, 8, 8, 4)); + assert_eq!((h.width, h.height), (64, 64)); +} + +/// Damage to the committed payload — each header byte three ways, and random changes +/// in both streams — returns an image or a refusal, never a panic, in this +/// overflow-checked test build and in release alike. +#[test] +fn damaging_the_committed_payload_never_panics() { + let (pl, m) = (envelope().payload, model()); + let mut state = 0x1A7E_4C10u64; + let mut next = move || { + state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + }; + let (mut decoded, mut refused) = (0, 0); + let mut tally = |r: Result| if r.is_ok() { decoded += 1 } else { refused += 1 }; + for i in 0..Header::LEN { + for x in [0x01u8, 0x80, 0xFF] { + let mut p = pl.clone(); + p[i] ^= x; + tally(decode(&m, &p)); + } + } + for _ in 0..200 { + let mut p = pl.clone(); + for _ in 0..1 + next() % 4 { + let i = Header::LEN + (next() as usize) % (p.len() - Header::LEN); + p[i] = next() as u8; + } + tally(decode(&m, &p)); + } + assert!(decoded > 0 && refused > 0, "{decoded} decoded, {refused} refused"); +} diff --git a/wai/wai-rs/tests/int_mlicpp_preflight.rs b/wai/wai-rs/tests/int_mlicpp_preflight.rs new file mode 100644 index 000000000..ded630379 --- /dev/null +++ b/wai/wai-rs/tests/int_mlicpp_preflight.rs @@ -0,0 +1,139 @@ +//! A `wai.neural.int_mlicpp` payload whose header the model cannot fit is refused +//! before the decode allocates anything sized from it. +//! +//! The header caps each tensor it sizes directly, but what the networks allocate +//! depends on the model as well: a small, well-formed header can declare a hyper-latent +//! that `h_s` would expand far past the cap, or an image or latent that does not match +//! what the networks produce. The decode derives every network's shapes from its layer +//! geometry first (`int_hyper_synth::net_shape`) and refuses such a payload with +//! nothing allocated. This file checks that directly: it installs a global allocator +//! that records the largest single allocation, and asserts that refusing each +//! malformed payload allocates no more than a few kilobytes. On a 32-bit target, where +//! the allocation that check prevents would abort the process rather than fail, the +//! same test shows the decode returns an error instead. +//! +//! The model is loaded the same way: a damaged compact network (`WHM1`) is refused +//! without the parser reserving for what its counts claim. +//! +//! Its own test binary with a single test, so that no other test's allocations land in +//! the window it measures. +#![cfg(feature = "neural_int")] + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering::Relaxed}; + +use wai::container::Wai; +use wai::int_hyper_synth::{IntLayer, IntModel}; +use wai::int_mlicpp::{decode, Header, IntMlicError, IntMlicModel}; + +/// The system allocator, recording the largest single request. +struct Largest; +static LARGEST: AtomicUsize = AtomicUsize::new(0); + +unsafe impl GlobalAlloc for Largest { + unsafe fn alloc(&self, l: Layout) -> *mut u8 { + LARGEST.fetch_max(l.size(), Relaxed); + unsafe { System.alloc(l) } + } + unsafe fn alloc_zeroed(&self, l: Layout) -> *mut u8 { + LARGEST.fetch_max(l.size(), Relaxed); + unsafe { System.alloc_zeroed(l) } + } + unsafe fn realloc(&self, p: *mut u8, l: Layout, new: usize) -> *mut u8 { + LARGEST.fetch_max(new, Relaxed); + unsafe { System.realloc(p, l, new) } + } + unsafe fn dealloc(&self, p: *mut u8, l: Layout) { + unsafe { System.dealloc(p, l) } + } +} + +#[global_allocator] +static ALLOC: Largest = Largest; + +/// The largest single allocation `f` makes, and its result. +fn largest_in(f: impl FnOnce() -> R) -> (R, usize) { + LARGEST.store(0, Relaxed); + let r = f(); + (r, LARGEST.load(Relaxed)) +} + +fn read(name: &str) -> Vec { + let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/vectors/int_mlicpp").join(name); + std::fs::read(&p).unwrap_or_else(|e| panic!("{}: {e}", p.display())) +} + +/// A header field of the committed payload replaced (offsets as in `parse_header`). +fn with(p: &[u8], off: usize, bytes: &[u8]) -> Vec { + let mut p = p.to_vec(); + p[off..off + bytes.len()].copy_from_slice(bytes); + p +} + +const FEW_KB: usize = 16 << 10; + +#[test] +fn a_header_the_model_cannot_fit_is_refused_before_anything_is_allocated() { + let model = IntMlicModel::from_bundle(&read("model.wmb")).expect("model"); + let pl = Wai::from_bytes(&read("stream.wai")).expect("envelope").payload; + + // The counter sees the decode's allocations: the committed payload's largest is at + // least its 64 × 64 RGB image. + let (img, largest) = largest_in(|| decode(&model, &pl)); + img.expect("the committed payload decodes"); + assert!(largest >= 64 * 64 * 3, "the counter saw a {largest}-byte allocation at most"); + + // A compact network claiming 2^32 − 1 layers in 20 bytes: refused as truncated, + // with nothing reserved for the layers it claims (about 480 GB of them). + let whm1 = [b"WHM1".as_slice(), &12u32.to_le_bytes(), &16u32.to_le_bytes(), &24u32.to_le_bytes(), + &u32::MAX.to_le_bytes()].concat(); + let (r, largest) = largest_in(|| IntModel::from_bytes(&whm1).err()); + assert!(largest <= FEW_KB, "parsing a damaged network reserved {largest} bytes"); + assert_eq!(r, Some("model truncated")); + + let u16le = |v: u16| v.to_le_bytes(); + let u32le = |v: u32| v.to_le_bytes(); + let cases: [(&str, Vec, &str); 3] = [ + // z [4, 4000, 4000]: 64 Mi elements, within the header's cap, but h_s would + // expand it to [16, 16000, 16000] where the latent is [16, 8, 8]. + ("an inflated hyper-latent", with(&with(&pl, 14, &u16le(4000)), 16, &u16le(4000)), "h_s gives"), + // A 1000 × 1000 latent where h_s gives 8 × 8. + ("an inflated latent", with(&with(&pl, 20, &u16le(1000)), 22, &u16le(1000)), "h_s gives"), + // A 4000 × 4000 image where g_s gives 64 × 64. + ("an inflated image", with(&with(&pl, 4, &u32le(4000)), 8, &u32le(4000)), "g_s gives"), + ]; + for (what, bad, why) in cases { + let (r, largest) = largest_in(|| decode(&model, &bad)); + // Allocation first: a decoder can also refuse these later, once a stream runs + // out, but only after allocating and filling what the header asked for. + assert!(largest <= FEW_KB, "{what}: refusing it allocated {largest} bytes at once ({r:?})"); + match r { + Err(IntMlicError::Model(e)) => assert!(e.contains(why), "{what}: {e}"), + other => panic!("{what}: expected a model refusal, got {other:?}"), + } + } + + // A model whose synthesis, on the committed (well-formed) header's own shapes, + // would form a buffer far past the cap on the way to the right output: a stride-2^20 + // transposed convolution spreads the 8 × 8 latent over a plane of about 7.3 million + // squared, and a stride-2^20 convolution brings it back to 8 × 8 before the rest of + // g_s. Its output shape is the image's, so only the size check can refuse it. + let s = 1usize << 20; + let mut wide = IntMlicModel::from_bundle(&read("model.wmb")).expect("model"); + let unit = |out_ch| (vec![1i64; 8 * out_ch], vec![1i64; out_ch], vec![0i64; out_ch]); + let (weight, mult, bias) = unit(8); + wide.g_s.layers.insert(0, IntLayer::ConvT { out_ch: 8, k: 1, stride: s, pad: 0, opad: 0, weight, mult, bias }); + let (weight, mult, bias) = unit(8); + wide.g_s.layers.insert(1, IntLayer::Conv { out_ch: 8, k: 1, stride: s, pad: 0, weight, mult, bias }); + let (r, largest) = largest_in(|| decode(&wide, &pl)); + assert!(largest <= FEW_KB, "a synthesis wider than the cap: refusing it allocated {largest} bytes at once ({r:?})"); + match r { + // On a 64-bit target the plane's size is formed, and is over the cap; on a + // 32-bit one it does not fit `usize`, and the geometry is refused as such. + Err(IntMlicError::Model(e)) => assert!( + e.contains("g_s") && (e.contains(&format!("over the {}-element cap", Header::MAX_TENSOR)) || e.contains("overflows")), + "{e}"), + other => panic!("a synthesis wider than the cap: expected a model refusal, got {other:?}"), + } +} diff --git a/wai/wai-rs/tests/neural_native_decode.rs b/wai/wai-rs/tests/neural_native_decode.rs index fede9ea4a..221ea18a3 100644 --- a/wai/wai-rs/tests/neural_native_decode.rs +++ b/wai/wai-rs/tests/neural_native_decode.rs @@ -384,6 +384,48 @@ fn strict_decode_gates_unsound_replicate() { println!(" strict gate: class first (float, entropy-only refused), then prior; non-replicate passes"); } +/// `wai.neural.int_mlicpp` through the neural dispatch and the strict `replicate` +/// gate, on the committed conformance vector (`tests/vectors/int_mlicpp/`, no model +/// to fetch, so this never skips). The registered model is the one-file bundle the +/// envelope's prior pins: the decode must reproduce the pinned image, a bundle that +/// differs by one byte must be refused before anything is decoded, and `replicate` +/// with the prior removed must be refused by the gate. The vector's weights are +/// synthetic, so this checks the dispatch and the pin, not compression quality. +#[test] +fn int_mlicpp_decodes_through_the_strict_dispatch() { + let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/vectors/int_mlicpp"); + let bundle = d.join("model.wmb"); + let env = Wai::from_bytes(&std::fs::read(d.join("stream.wai")).unwrap()).unwrap(); + let expected: serde_json::Value = serde_json::from_slice(&std::fs::read(d.join("expected.json")).unwrap()).unwrap(); + let reg = ModelRegistry::new().register("wai.neural.int_mlicpp", &bundle); + assert_eq!(model_sha256(&bundle).unwrap(), env.prior().expect("pinned prior").sha256); + match decode_envelope_strict(&env, ®) { + Ok(Decoded::Image(img)) => { + assert_eq!((img.width, img.height), (64, 64)); + assert_eq!(format!("{:016x}", wai::int_hyper_synth::fnv1a(&img.rgb)), + expected["rgb_fnv1a"].as_str().unwrap(), "the pinned image"); + } + Ok(_) => panic!("int_mlicpp must decode to an image"), + Err(e) => panic!("int_mlicpp strict decode failed: {e}"), + } + + let tampered = std::env::temp_dir().join(format!("wai-int-mlicpp-{}.wmb", std::process::id())); + let mut bytes = std::fs::read(&bundle).unwrap(); + let at = bytes.len() - 2; + bytes[at] ^= 1; + std::fs::write(&tampered, &bytes).unwrap(); + let refused = decode_envelope_strict(&env, &ModelRegistry::new().register("wai.neural.int_mlicpp", &tampered)); + std::fs::remove_file(&tampered).ok(); + assert!(matches!(refused, Err(DecodeError::PriorMismatch(_))), "a one-byte-different bundle must be refused"); + + let mut manifest = env.manifest.clone(); + manifest.conditioning.prior = None; + let unpinned = Wai::new(manifest, env.payload.clone()); + assert!(matches!(decode_envelope_strict(&unpinned, ®), Err(DecodeError::UnsoundReplicate(_))), + "replicate without a pinned prior must be refused"); + println!(" int_mlicpp: strict dispatch decodes the pinned image; tampered bundle and unpinned replicate refused"); +} + /// END-TO-END validation against the real CompressAI mbt2018-mean model: /// decode the SAME envelope in Rust and compare to CompressAI's own decode. /// The Gaussian-conditional entropy decode must be EXACT (same rANS bytes → diff --git a/wai/wai-rs/tests/vectors/int_mlicpp/expected.json b/wai/wai-rs/tests/vectors/int_mlicpp/expected.json new file mode 100644 index 000000000..37ae6d87c --- /dev/null +++ b/wai/wai-rs/tests/vectors/int_mlicpp/expected.json @@ -0,0 +1,18 @@ +{ + "capability": "wai.neural.int_mlicpp", + "width": 64, + "height": 64, + "rgb_fnv1a": "7c0b9a4b3423d775", + "rgb_sha256": "45040d17b4178b91a0547b435e57a50fa1021101e290a94d1c57e6fbb4583a10", + "bucket_fnv1a": "2d30432f4fb206a0", + "symbol_fnv1a": "14ace1d4ad3a2446", + "model_sha256": "7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4", + "payload_sha256": "5b2bf2dbd36a683d4a62292d1a179e695c511c4971be6859cec41cd980e4f971", + "y_symbols": 512, + "z_symbols": 16, + "y_escapes": 47, + "z_escapes": 5, + "buckets_used": 16, + "seed": 20260929, + "weights": "synthetic (seeded), not a trained model: decode-equivalence only, no quality claim" +} diff --git a/wai/wai-rs/tests/vectors/int_mlicpp/model.wmb b/wai/wai-rs/tests/vectors/int_mlicpp/model.wmb new file mode 100644 index 0000000000000000000000000000000000000000..4e335fe23531548d91d5a200f1f479e25da77e1b GIT binary patch literal 41781 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 I9^d`*FU?T1EdT%j literal 0 HcmV?d00001 diff --git a/wai/wai-rs/tests/vectors/int_mlicpp/stream.wai b/wai/wai-rs/tests/vectors/int_mlicpp/stream.wai new file mode 100644 index 0000000000000000000000000000000000000000..808357456be34e4d789de0441d2adc663c986b90 GIT binary patch literal 859 zcmY*VZAepL6u!+VX~hcq+OOqZBSAOs?&j6)M>?#?Af*B=O6}df@7!D4^?G+}4V|=# z{uDuFWJ*y4){h`avs#G;g;5Zd6n&BgeLy3uBr3OidS^c(4u|(W=XuU^J{o*A&P}rz zrk#xzLDk(jInu|`CDgy(pH^Y9)zO(cl;GJ%Se8aQU49(T#X1Ac!vP@(UGifeVy* z%0z@+lFKRj%L5+HSs{2lnD^jvSB1;X@#Q>nQ!NQ=SPc`IM8bckva3jrlqyc4n2u=w zyGJPz+-#gKqan2g)8o(+vR=Vm!UnTXJf5a!ZKzrAtYR1@>qf>vZT5^AX}pm79m;W3 zI+rz#EM~@`cx?HB`6PNYc)D>E*`i;13;^go_2c0M@5XL5S{9#x)c2{JYrV1Q-CrjG zJ|V-DBawy-z*qPImZ{sh`ieq;{_PJ<0|1490Dk$p>mK%G(r*skx8;xbDOb4Z#yyF= zdi&(yK=Wkirb>&!cku2L9oA;D{{(=cZNgIh4JG@b<0@7n;{MCeC~Vn_!)+Yl&* z3bqYDfOMr+t3Ta;4xpfLk?mQw?}jreE25OX{_#Glm`qcC;n7YjV3z3SBP=k>KV-+q&|Beuc3)Zc5e2A6DJmFnn9KG8S(b7I%u9WpECIh9SZ zu2g;MwY}0i*NB&o98+EvtsK#-FJ|5r%lx)=U=xjX7V GB>n(z;eL|< literal 0 HcmV?d00001 -- 2.54.0 (Apple Git-157) From df0c0dfb29511b36c93aab86982df35258158c7a Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 01:40:16 -0400 Subject: [PATCH 3/4] wai-web: refresh Cargo.lock for wai-quantum 0.3.21 main's wai-web/Cargo.lock still records the path dependency wai-quantum at 0.3.18; its Cargo.toml has said 0.3.21 since f0591f1b, so any --locked build of wai-web refuses to start. The refresh changes only that version line. Co-Authored-By: Claude Opus 5.5 --- wai/wai-web/Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wai/wai-web/Cargo.lock b/wai/wai-web/Cargo.lock index 4e9a7d0c2..33e706052 100644 --- a/wai/wai-web/Cargo.lock +++ b/wai/wai-web/Cargo.lock @@ -661,7 +661,7 @@ dependencies = [ [[package]] name = "wai-quantum" -version = "0.3.18" +version = "0.3.21" dependencies = [ "blake3", "ed25519-dalek", -- 2.54.0 (Apple Git-157) From e709627da3b1ee4adfa2d51b21910cc455271bca Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 07:39:05 -0400 Subject: [PATCH 4/4] wai: square the deviations in the energy statistics without powi The linux-arm64 leg of wai-conformance failed from this link on: the JPEG AI decoder's symbol guard (tests/jpegai_decode_symbols.rs) found __powidf2, the compiler builtin behind f64::powi, defined in its executable. Nothing calls it there: in the executable built in the runner's own image, no instruction branches to it, no relocation refers to it and no address is formed for it. It is the case the test's documentation describes for floor on x86_64 Linux: GNU ld keeps a compiler-builtin definition that a removed section referred to. The references were the two sample-variance loops this link added, energy_class::bracket_relative_uncertainty and energy_burst::sample_sd, which squared with powi(2). They now multiply the deviation by itself. For an exponent of 2, __powidf2 returns exactly d * d, so every figure is unchanged. In the runner's image (catthehacker/ubuntu:act-22.04, arm64, rustc 1.98.0) the guard fails before this change and passes after it, with no __powidf2 in the executable. On macOS arm64: the guard, and wai-energy-receipts' three suites with --locked, pass. Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/src/energy_burst.rs | 2 +- wai/wai-rs/src/energy_class.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/wai/wai-rs/src/energy_burst.rs b/wai/wai-rs/src/energy_burst.rs index 57eb098cd..a8e33de3c 100644 --- a/wai/wai-rs/src/energy_burst.rs +++ b/wai/wai-rs/src/energy_burst.rs @@ -90,7 +90,7 @@ fn sample_sd(v: &[f64]) -> Option { return None; } let m = mean(v); - Some((v.iter().map(|x| (x - m).powi(2)).sum::() / (v.len() - 1) as f64).sqrt()) + Some((v.iter().map(|x| { let d = x - m; d * d }).sum::() / (v.len() - 1) as f64).sqrt()) } impl Bracket { diff --git a/wai/wai-rs/src/energy_class.rs b/wai/wai-rs/src/energy_class.rs index 36cdb37d1..0f27c0c80 100644 --- a/wai/wai-rs/src/energy_class.rs +++ b/wai/wai-rs/src/energy_class.rs @@ -833,7 +833,7 @@ pub fn bracket_relative_uncertainty(idle_w: &[f64], busy_w: &[f64]) -> Option() / v.len() as f64; let var = |v: &[f64]| { let m = mean(v); - v.iter().map(|x| (x - m).powi(2)).sum::() / (v.len() - 1) as f64 + v.iter().map(|x| { let d = x - m; d * d }).sum::() / (v.len() - 1) as f64 }; let marginal = mean(busy_w) - mean(idle_w); if marginal.is_nan() || marginal <= 0.0 { -- 2.54.0 (Apple Git-157)