diff --git a/.github/workflows/wai-byte-equality.yml b/.github/workflows/wai-byte-equality.yml index d1a403c87..b7cce5ab9 100644 --- a/.github/workflows/wai-byte-equality.yml +++ b/.github/workflows/wai-byte-equality.yml @@ -8,6 +8,13 @@ name: wai cross-architecture byte-equality # int_synth output hash produced on the reference machine (macOS arm64). One # diverging byte anywhere fails the suite. # +# 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 +# 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. +# # The conformance crate (`wai/byte-exact-conformance`) #[path]-includes the # pure-integer sources from wai-rs — no system codec libraries, no ONNX runtime # — so every leg runs in about a minute. @@ -18,6 +25,17 @@ on: - 'wai/byte-exact-conformance/**' - 'wai/wai-rs/src/neural/int_synth.rs' - 'wai/wai-rs/src/neural/jpegai_mans.rs' + # Every source the crate #[path]-includes must be able to fire this + # workflow, or a change can break cross-arch equality and merge green. + - 'wai/wai-rs/src/int_synth.rs' + - 'wai/wai-rs/src/int_entropy.rs' + - 'wai/wai-rs/src/jpegai_mans.rs' + - 'wai/wai-rs/src/rans.rs' + - '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/tests/vectors/int_video/**' + - 'wai/wai-rs/tests/vectors/int_hyper/**' - '.github/workflows/wai-byte-equality.yml' push: branches: @@ -26,6 +44,17 @@ on: - 'wai/byte-exact-conformance/**' - 'wai/wai-rs/src/neural/int_synth.rs' - 'wai/wai-rs/src/neural/jpegai_mans.rs' + # Every source the crate #[path]-includes must be able to fire this + # workflow, or a change can break cross-arch equality and merge green. + - 'wai/wai-rs/src/int_synth.rs' + - 'wai/wai-rs/src/int_entropy.rs' + - 'wai/wai-rs/src/jpegai_mans.rs' + - 'wai/wai-rs/src/rans.rs' + - '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/tests/vectors/int_video/**' + - 'wai/wai-rs/tests/vectors/int_hyper/**' - '.github/workflows/wai-byte-equality.yml' jobs: diff --git a/wai/SPEC.md b/wai/SPEC.md index fe512bf4e..4810b7583 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -241,6 +241,11 @@ capabilities are added by registration; the WAI envelope is unchanged. See [`extensions/jwp-receipts.md`](extensions/jwp-receipts.md) for the 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. 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/byte-exact-conformance/src/int_video_golden.rs b/wai/byte-exact-conformance/src/int_video_golden.rs new file mode 100644 index 000000000..ec5272f67 --- /dev/null +++ b/wai/byte-exact-conformance/src/int_video_golden.rs @@ -0,0 +1,119 @@ +//! Cross-architecture byte-equality for `wai.video.int_motion` — the temporal +//! leg of the determinism contract. +//! +//! The still-image goldens next door prove a *single* decode is portable. Video +//! is a strictly harder claim, because inter-frame decode is a feedback loop: +//! every P-frame is predicted from the previously **reconstructed** frame, so an +//! architecture that rounds one half-pel sample differently does not produce one +//! wrong byte — it produces a frame that is then used to predict the next frame, +//! and the error compounds for the rest of the clip. Per-frame hashes are +//! asserted rather than only the clip hash for exactly that reason: they say +//! *where* divergence began, which a single terminal hash cannot. +//! +//! The vectors are the ones `wai-rs/tests/int_video.rs` already commits. That +//! test only ever ran where someone passed `--features video_int`; these run on +//! every leg of the matrix, which is the difference between a determinism claim +//! and a determinism proof. + +#![cfg(test)] + +use std::path::PathBuf; + +use serde_json::Value; + +use crate::int_video::{decode, fnv1a}; + +fn vectors() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../wai-rs/tests/vectors/int_video") +} + +fn jload(name: &str) -> Value { + let path = vectors().join(name); + let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + serde_json::from_slice(&bytes).unwrap_or_else(|e| panic!("parse {}: {e}", path.display())) +} + +/// The 8 raw bytes behind a 16-hex string. The clip hash is taken over the +/// concatenated frame-hash *bytes*, not their hex text. +fn hex16(s: &str) -> [u8; 8] { + let mut o = [0u8; 8]; + for (i, b) in o.iter_mut().enumerate() { + *b = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).expect("hex"); + } + o +} + +/// Decode a committed clip and assert every frame hash and the clip hash. +/// Returns the clip hash so callers can pin it verbatim. +fn assert_clip(bitstream: &str, conf: &str) -> String { + let bs = jload(bitstream); + let conf = jload(conf); + let clip = decode(&bs).expect("decode must succeed on every architecture"); + + let h = conf["h"].as_u64().expect("h") as usize; + let w = conf["w"].as_u64().expect("w") as usize; + let want = conf["frame_hashes"].as_array().expect("frame_hashes"); + + assert_eq!((clip.h, clip.w), (h, w), "frame geometry"); + assert_eq!(clip.frames.len(), want.len(), "frame count"); + + let mut all = Vec::with_capacity(want.len() * 8); + for (i, frame) in clip.frames.iter().enumerate() { + assert_eq!(frame.len(), h * w * 3, "frame {i} size"); + let got = format!("{:016x}", fnv1a(frame)); + assert_eq!( + got, + want[i].as_str().expect("frame hash"), + "frame {i} diverged — inter-frame decode is a feedback loop, so this \ + is also the last frame that can be trusted" + ); + all.extend_from_slice(&hex16(&got)); + } + let clip_hash = format!("{:016x}", fnv1a(&all)); + assert_eq!(clip_hash, conf["clip_hash"].as_str().expect("clip_hash"), "clip hash"); + clip_hash +} + +/// The synthetic clip: 5 frames of 48×48 RGB, whole-pel motion. +#[test] +fn int_video_synthetic_clip_is_byte_identical() { + let clip = assert_clip("int_video_bitstream.json", "int_video.json"); + assert_eq!(clip, "27573cdba1ad6628"); + println!("int_video (synthetic, 5x48x48): clip {clip}"); +} + +/// Real footage, 8 frames of 96×96. This is the load-bearing case: the encoder +/// chose half-pel motion for it (27_960 bytes against 38_232 whole-pel), so the +/// bilinear predictor — the integer rounding most at risk of differing between +/// architectures — is actually exercised rather than merely present. +#[test] +fn int_video_real_footage_exercises_the_half_pel_predictor() { + let conf = jload("video_real.json"); + let half = conf["half_pel_bytes"].as_u64().expect("half_pel_bytes"); + let whole = conf["whole_pel_bytes"].as_u64().expect("whole_pel_bytes"); + assert!( + half < whole, + "vector no longer prefers half-pel ({half} >= {whole}); this test would \ + still pass while silently covering only the whole-pel path" + ); + + let clip = assert_clip("video_real_bitstream.json", "video_real.json"); + assert_eq!(clip, "1ec1f0fa7da0d70f"); + println!("int_video (real footage, 8x96x96, half-pel): clip {clip}"); +} + +// NOT covered here: `video_nk` (`wai.video.int_hyper`, the NEURAL-keyframe +// variant). Its I-frame is produced by `int_hyper_synth::decode_conditional` +// and only its P-frames go through `int_video::decode_pframes`, so it cannot be +// driven by `decode()` — an earlier draft of this file tried and got a slice +// overrun out of the rANS reader, which is what feeding one codec another +// codec'"'"'s bitstream looks like. Covering it means `#[path]`-including +// `int_hyper_synth.rs` as well; that is a real follow-on, not a line to add +// here, and until it lands the neural-keyframe leg of the temporal contract +// remains proven only where someone runs `--features neural_int,video_int` +// locally. +// +// The synthetic/real pair above already separates the two failure modes this +// file needs to distinguish: the synthetic clip is whole-pel only, so a failure +// there implicates the entropy or residual stage, while a failure confined to +// the real-footage clip implicates the half-pel predictor. diff --git a/wai/byte-exact-conformance/src/lib.rs b/wai/byte-exact-conformance/src/lib.rs index 7baa9886e..60da55ebd 100644 --- a/wai/byte-exact-conformance/src/lib.rs +++ b/wai/byte-exact-conformance/src/lib.rs @@ -23,5 +23,37 @@ pub mod int_entropy; #[path = "../../wai-rs/src/jpegai_mans.rs"] pub mod jpegai_mans_exact; +// `int_video.rs` names the rANS decoder as `crate::rans` (its path in wai-rs, +// where rans is a crate-root module). Here rans is included under `neural`, so +// re-export rather than `#[path]`-include a second copy: two includes would +// compile two structurally identical but distinct types, and the whole point of +// this crate is that there is one source of truth per decode path. +pub use neural::rans; + +// The deterministic integer INTER-FRAME video decoder (`wai.video.int_motion`). +// Included so the cross-arch matrix covers temporal decode, not only the +// still-image and synthesis paths. This one matters disproportionately: its +// half-pel predictor is the only place in the integer contract where a +// *rounding* decision (`(a + b + 1) >> 1` over clamped neighbours) sits inside a +// feedback loop — each P-frame predicts from the previously reconstructed +// frame, so a single divergent byte on one architecture does not stay one byte. +// It propagates through every subsequent frame. +#[path = "../../wai-rs/src/int_video.rs"] +pub mod int_video; + +// The integer transform kernels (`igdn` and friends) the learned codecs use. +// Self-contained — no crate-internal dependencies of its own. +#[path = "../../wai-rs/src/int_transform.rs"] +pub mod int_transform; + +// The learned integer conditional codec that decodes a NEURAL keyframe. Needed +// for `wai.video.int_hyper`, whose I-frame comes from here rather than from +// `int_video::decode` — the gap the previous commit named and left open, since +// covering it meant pulling this source in rather than adding a line. +#[path = "../../wai-rs/src/int_hyper_synth.rs"] +pub mod int_hyper_synth; + mod golden; +mod int_video_golden; +mod neural_keyframe_golden; mod jpegai_golden; diff --git a/wai/byte-exact-conformance/src/neural_keyframe_golden.rs b/wai/byte-exact-conformance/src/neural_keyframe_golden.rs new file mode 100644 index 000000000..1f4d6934b --- /dev/null +++ b/wai/byte-exact-conformance/src/neural_keyframe_golden.rs @@ -0,0 +1,108 @@ +//! Cross-architecture byte-equality for `wai.video.int_hyper` — the +//! **neural-keyframe** inter-frame codec. +//! +//! This closes the gap the video goldens next door named and left open. That +//! file covers `wai.video.int_motion`, whose I-frame is an ordinary integer +//! keyframe; this one covers the variant whose I-frame is decoded by the +//! *learned* conditional codec (`int_hyper_synth`) and whose P-frames are +//! integer motion-compensated residuals on top of it. +//! +//! It is the strictest composition in the container: a learned decode feeding a +//! feedback loop. The keyframe comes out of a quantised transform network, and +//! every subsequent frame predicts from the previously reconstructed one — so a +//! single divergent byte anywhere in the learned synthesis does not stay in the +//! keyframe, it propagates through the whole clip. A learned keyframe that +//! reproduces bit-for-bit on four architectures is the claim that makes +//! "a learned codec you can put in a receipt" mean anything. + +#![cfg(test)] + +use std::path::PathBuf; + +use serde_json::Value; + +use crate::int_hyper_synth::{decode_conditional, CondBitstream, CondEntropy, IntModel}; +use crate::int_video::{decode_pframes, fnv1a}; + +fn vectors() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../wai-rs/tests/vectors") +} + +fn jload(rel: &str) -> Value { + let path = vectors().join(rel); + let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + serde_json::from_slice(&bytes).unwrap_or_else(|e| panic!("parse {}: {e}", path.display())) +} + +/// The 8 raw bytes behind a 16-hex string. +fn hex16(s: &str) -> [u8; 8] { + let mut o = [0u8; 8]; + for (i, b) in o.iter_mut().enumerate() { + *b = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).expect("hex"); + } + o +} + +/// Frame 0 from the learned conditional codec, frames 1..N from integer motion +/// compensation on top — every one asserted against its committed hash. +#[test] +fn the_neural_keyframe_clip_is_byte_identical() { + // The learned I-frame: analysis/synthesis models, the conditional entropy + // parameters, and the committed bitstream. + let g_s = IntModel::from_json(&jload("int_hyper/tiny_g_s.json")); + let h_s = IntModel::from_json(&jload("int_hyper/tiny_h_s.json")); + let ent = CondEntropy::from_value(&jload("int_hyper/tiny_entropy.json")); + let bs = CondBitstream::from_value(&jload("int_hyper/tiny_bitstream.json")); + let (frame0, h, w) = decode_conditional(&g_s, &h_s, &ent, &bs).expect("learned I-frame"); + + let conf = jload("int_video/video_nk.json"); + let vbs = jload("int_video/video_nk_bitstream.json"); + assert_eq!(h, conf["h"].as_u64().expect("h") as usize, "keyframe height"); + assert_eq!(w, conf["w"].as_u64().expect("w") as usize, "keyframe width"); + + let clip = decode_pframes(&frame0, h, w, &vbs).expect("integer P-frames"); + let want = conf["frame_hashes"].as_array().expect("frame_hashes"); + assert_eq!(clip.frames.len(), want.len(), "frame count"); + + let mut all = Vec::with_capacity(want.len() * 8); + for (i, frame) in clip.frames.iter().enumerate() { + assert_eq!(frame.len(), h * w * 3, "frame {i} size"); + let got = format!("{:016x}", fnv1a(frame)); + assert_eq!( + got, + want[i].as_str().expect("frame hash"), + "frame {i} diverged — frame 0 is the learned keyframe, so a mismatch there \ + implicates the conditional codec, while a later-only mismatch implicates \ + the motion predictor" + ); + all.extend_from_slice(&hex16(&got)); + } + + let clip_hash = format!("{:016x}", fnv1a(&all)); + assert_eq!(clip_hash, conf["clip_hash"].as_str().expect("clip_hash")); + assert_eq!(clip_hash, "a5f53d477999d159"); + println!("int_hyper keyframe clip ({} frames {h}x{w}): {clip_hash}", clip.frames.len()); +} + +/// Frame 0 of the clip must be exactly what the learned codec produced, with no +/// motion compensation applied. Pinned separately so a regression in the +/// conditional codec is distinguishable from one in the temporal stage. +#[test] +fn the_learned_keyframe_alone_is_byte_identical() { + let g_s = IntModel::from_json(&jload("int_hyper/tiny_g_s.json")); + let h_s = IntModel::from_json(&jload("int_hyper/tiny_h_s.json")); + let ent = CondEntropy::from_value(&jload("int_hyper/tiny_entropy.json")); + let bs = CondBitstream::from_value(&jload("int_hyper/tiny_bitstream.json")); + let (frame0, h, w) = decode_conditional(&g_s, &h_s, &ent, &bs).expect("learned I-frame"); + + let conf = jload("int_video/video_nk.json"); + let want0 = conf["frame_hashes"].as_array().expect("frame_hashes")[0] + .as_str() + .expect("frame 0 hash"); + assert_eq!(frame0.len(), h * w * 3); + assert_eq!( + format!("{:016x}", fnv1a(&frame0)), + want0, + "the learned keyframe itself diverged, before any temporal decode" + ); +} diff --git a/wai/extensions/energy-measurement.md b/wai/extensions/energy-measurement.md new file mode 100644 index 000000000..e5205aa30 --- /dev/null +++ b/wai/extensions/energy-measurement.md @@ -0,0 +1,216 @@ +# WAI Extension: Energy Measurement + +> Status: Draft. Specifies **how a joule figure is acquired**, so that the +> `joules_micro` field the receipt profiles already carry means the same thing +> in two independent implementations. No new wire format; an acquisition +> protocol, a reporting contract, and a quality rule over the existing receipt +> fields. +> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174. + +## 1. Why an acquisition protocol + +[`jwp-receipts.md`](jwp-receipts.md) defines `joules_micro` as "microjoules +consumed producing this object". That fixes the *unit* and the *scope of +attribution*. It does not fix how a number gets into the field, and without that +the field is a number-shaped hole: one implementation can write a counter +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**. + +The contribution is not the number. It is the number *plus its acquisition +class*, and the discipline of withholding both when the measurement fails. + +## 2. The acquisition class (REQUIRED) + +A receipt carrying a non-zero energy figure **MUST** carry an acquisition class +alongside it, from the shared vocabulary: + +| class | meaning | requires | +|---|---|---| +| `HwShunt` | Read from a hardware energy interface — RAPL, NVML, SMC, IOReport, or an external shunt. | a present, non-zero measurement | +| `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. | — | + +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. + +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. + +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 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 verifier **MUST** treat a receipt violating either coupling as invalid, not + merely as unlabelled. + +The reference implementation enforces both couplings 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". + +## 3. The reference protocol: bracketed marginal measurement + +On-die energy counters report the draw of a whole package or SoC, not of one +process. A decode cannot be isolated on such an interface, so the reference +protocol measures the **marginal** draw the work adds to an otherwise-idle +machine: + +1. **Idle bracket.** Sample power for a fixed count with no work running. + Record the mean and the **standard deviation**. +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. +3. **Busy bracket.** Sample power again for a fixed count while the burst runs, + recording the wall-clock window it spans. +4. **Attribute.** `energy = max(0, mean_busy − mean_idle) × window`, then divide + by the number of completions in that window. + +Per-unit energy is total marginal energy ÷ total completions, so the thread +count does not enter the result. 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. + +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. + +## 4. Attribution quality (REQUIRED) + +The marginal method is only meaningful when the burst lifts power **clearly +above the machine's own idle fluctuation**. When it does not, the method does +not degrade gracefully — it returns a confidently-formatted number that is far +too low, because an inflated idle baseline is subtracted from a busy mean that +never rose. + +This is not a theoretical concern. Measuring one fixed clip with one binary, +minutes apart on the same machine: + +| condition | idle | busy | marginal | reported | +|---|---|---|---|---| +| quiet | 20.45 W | 54.89 W | 34.45 W | **10,565 µJ/decode** | +| *(the quiet row predates this rule, so its ratio was not recorded)* | | | | | +| build running | ~23.9 W | 24.26 W | 0.36 W | **497 µJ/decode** | + +A **21×** error, in the flattering direction, with nothing in the output to +distinguish the two. + +So an implementation **MUST** compute the ratio of the marginal draw to the idle +standard deviation and report it: + +| ratio | verdict | +|---|---| +| ≥ 10 | clean — publishable | +| ≥ 3 | usable for relative comparison — **not** publishable | +| ≥ 1 | **noisy — do not publish** | +| < 1 | **unusable — the machine was not idle** | + +The publication bar is 10, not 3, and that is an empirical result rather than a +round number. A later reading of the same clip at a ratio of **3.8** — inside +what an earlier draft of this rule called "usable" — reported 2,469 µJ against +the 10,565 µJ above: a **4.3× disagreement from a reading the rule had passed.** +A ratio in the 3–10 band is sound for comparing two workloads measured +back-to-back on one machine, because the bias applies to both. It is not sound +for an absolute figure. + +The bias has a direction. A busy machine inflates the idle baseline, which is +then subtracted from a busy mean that never rose as far, so a poor bracket +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 + 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 + of 10, and **SHOULD** warn whenever it produces one. + +Emitting an unmetered receipt is the correct outcome of a failed measurement, +not an error state. A tool that says "I did not measure this" is more useful +than one that says "this decode cost 267 µJ" when the true figure is forty times +higher. + +## 5. What the figure is, and is not + +- **It is machine-specific.** The same codestream on different silicon + legitimately measures differently. Energy is a property of an execution, not + of an artifact. +- **It is a marginal figure over an idle baseline**, so it excludes the fixed + cost of having a machine powered on, and attributes shared platform overhead + to the work only insofar as the work raised it. +- **It is scoped to what the interface reports** — a package or an SoC, not a + process. Provenance and scope are the honest limitations, not sensor accuracy. +- **It depends on the build.** A debug build of the reference decoder measured + **4.9×** a release build of the same code. An implementation **MUST** state + the build profile alongside any published figure. +- **It is not verifiable by re-execution.** A third party re-running the decode + reproduces the *result* and the *work*, never the joules. + +That last point is the reason the receipt profiles are shaped as they are. Where +a class's reconstruction is byte-exact and proven across architectures, every +field except the energy is independently checkable, and the energy is the single +attested variable. Pairing a meter with a reproducible outcome is what turns an +energy receipt from a claim into a measurement with one unverifiable term — so +an implementation **SHOULD** report energy against a work unit that a verifier +can recompute from the artifact, and **MUST NOT** report it against a work unit +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 figure published without these is not wrong so much as unusable: nothing about +it can be checked, reproduced, or compared. + +## 7. Worked example + +`wai.video.int_motion`, real-footage vector, 8 frames of 96×96 RGB, release +build, IOReport via a non-privileged reader: + +``` +work : 221,184 subpixel-ops (8 x 96 x 96 x 3, recomputable) +cpu power idle : 24.95 W (sd 0.23 W, 10 samples) +cpu power busy : 25.84 W (14 samples) +marginal (busy-idle): 0.89 W +decodes : 1,525 over 4.23 s +marginal / idle sd : 3.8x (usable for relative comparison — NOT publishable) +-> per decode : 2,468.8 uJ +-> per kilo-subpixel : 11.16 uJ +acquisition class : HwShunt +``` + +**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 +the same clip on a quieter machine differed by 4.3×. It is shown rather than a +clean bracket because a clean bracket is not currently reproducible on the +authoring machine, and substituting an invented one would be the exact failure +this document exists to prevent. An implementation quoting a figure for this +vector **MUST** first obtain a bracket at ≥ 10. + +The receipt seals the codestream hash, the per-frame decoded-pixel hashes, the +work figure, the energy and its class under one signature. A verifier re-derives +everything but the energy from the codestream alone. + +## 8. Reproducing a figure + +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 +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/jwp-receipts.md b/wai/extensions/jwp-receipts.md index 44aea6854..0aeac8fc5 100644 --- a/wai/extensions/jwp-receipts.md +++ b/wai/extensions/jwp-receipts.md @@ -150,7 +150,7 @@ Required fields: | `group_id` | u64 | Mirrors the parent group receipt. | | `object_id` | u64 | Monotonic from 0 within the group. | | `content_hash` | 32 B hex | §2.1 BLAKE3 over the WAI envelope bytes. | -| `joules_micro` | u64 | Microjoules consumed producing this object. | +| `joules_micro` | u64 | Microjoules consumed producing this object. This field fixes the unit and the scope of attribution, not how the number is obtained — see [energy-measurement](energy-measurement.md), which requires an acquisition class beside it and forbids publishing a figure from a measurement that failed. | | `origin` | string | Datacenter / origin label. | | `merkle_proof` | object | Inclusion proof: `{ "siblings": [32 B hex, …], "direction_bits": u64, "leaf_index": u64 }`. `direction_bits` bit `i` is `1` iff the current node at level `i` is the right child. | diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index e34d3c494..84b68d0eb 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -155,6 +155,13 @@ quantum = ["dep:blake3"] # from the sink's meter), both bound to a re-simulatable statevector hash. Reuses # the JWP Merkle + Ed25519 from `world` — reuse, not reinvention. quantum_receipt = ["quantum", "world"] +# The JWP receipt profile for the integer inter-frame video codec. Follows the +# same shape as `quantum_receipt`: the class feature plus `world`, which is what +# supplies blake3, ed25519 and the shared `merkle_root`. Kept OUT of `video_int` +# on purpose — `int_video.rs` is #[path]-included by the byte-exact conformance +# crate, and that crate must keep its three-dependency set to stay runnable on +# every leg of the cross-arch matrix. +video_receipt = ["video_int", "world"] # Balanced-ternary matrix-product-state transform (`wai.ternary.tt`, # extensions/ternary-tt): quantum-INSPIRED (tensor-network) compression whose # novel delta is an INTEGER core scale — the whole decode is pure i64/i128, so a @@ -287,6 +294,11 @@ name = "wai_quantum_meter" path = "src/bin/wai_quantum_meter.rs" required-features = ["quantum_receipt"] +[[bin]] +name = "wai_video_meter" +path = "src/bin/wai_video_meter.rs" +required-features = ["video_receipt"] + [[bin]] name = "wai_meter" path = "src/bin/wai_meter.rs" diff --git a/wai/wai-rs/src/bin/wai_video_meter.rs b/wai/wai-rs/src/bin/wai_video_meter.rs new file mode 100644 index 000000000..1c1ca811a --- /dev/null +++ b/wai/wai-rs/src/bin/wai_video_meter.rs @@ -0,0 +1,236 @@ +//! `wai_video_meter` — the native sink-metering path for `wai.video.int_motion`, +//! the integer inter-frame video codec. +//! +//! Measures the **real CPU energy** a sink spends decoding an integer video +//! codestream to frames, with no elevated privileges. On Apple Silicon the SoC +//! energy counters are readable through IOReport — the same source `macmon`/ +//! `asitop` use — so this meter samples power via `macmon`, brackets a tight +//! decode burst against an idle baseline, and reports the **marginal** CPU +//! energy attributable to the decode. +//! +//! wai_video_meter [receipt_out.json] +//! +//! Video was the last cargo class with no meter: haptics, 4D splats, scores and +//! film are metered by `wai_meter`, worlds/feeds/avatars by `wai_world_meter`, +//! quantum circuits by `wai_quantum_meter`. It also has the strongest claim of +//! any of them, because its decode is not merely deterministic by construction +//! but **proven byte-identical on four architectures** by the cross-arch +//! matrix. So of everything the emitted receipt says, only `joules_micro` rests +//! on trusting this machine — the result and the work can be re-derived from the +//! codestream by anyone, anywhere. +//! +//! 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; + +use ed25519_dalek::SigningKey; +use wai::int_video::decode; +use wai::video_receipt::{work_pixel_ops, Provenance, VideoReceipt}; + +fn main() { + let args: Vec = std::env::args().collect(); + let Some(path) = args.get(1) else { + eprintln!("usage: wai_video_meter [receipt_out.json]"); + std::process::exit(2); + }; + + let bytes = std::fs::read(path).expect("read bitstream"); + let bs: serde_json::Value = serde_json::from_slice(&bytes).expect("parse bitstream JSON"); + // A decode that does not even run is not meterable. + let clip = decode(&bs).expect("codestream decodes"); + let (n, h, w) = (clip.frames.len() as u64, clip.h as u64, clip.w as u64); + let work = work_pixel_ops(n, h, w); + + eprintln!( + "metering {path} ({n} frames {h}x{w} RGB, work {work} subpixel-ops), \ + 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); + } + }) + }) + .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() + }; + 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 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), + None, + ) + .expect("seal receipt"); + assert!(receipt.verify(), "emitted receipt must verify"); + assert!( + receipt.verify_reconstruction(&bytes, &bs), + "emitted receipt must re-decode to the same frames" + ); + std::fs::write(out, receipt.to_json()).expect("write receipt"); + let tag = if measured.is_some() { "MEASURED" } else { "UNMETERED" }; + 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/lib.rs b/wai/wai-rs/src/lib.rs index f20c4c86c..b3ed5b32f 100644 --- a/wai/wai-rs/src/lib.rs +++ b/wai/wai-rs/src/lib.rs @@ -230,6 +230,9 @@ pub mod quantum; #[cfg(feature = "quantum_receipt")] pub mod quantum_receipt; +#[cfg(feature = "video_receipt")] +pub mod video_receipt; + // Balanced-ternary matrix-product-state transform (extensions/ternary-tt, // `wai.ternary.tt`): quantum-INSPIRED (tensor-network) compression. The ternary // weight fabric (BitNet-b1.58, 5 trits/byte) + MPS are ported, not reinvented; diff --git a/wai/wai-rs/src/video_receipt.rs b/wai/wai-rs/src/video_receipt.rs new file mode 100644 index 000000000..b8c4ba91e --- /dev/null +++ b/wai/wai-rs/src/video_receipt.rs @@ -0,0 +1,382 @@ +//! Video receipt — a JWP profile for `wai.video.int_motion`, the integer +//! inter-frame codec. The **codestream hash** is the identity; the group's +//! objects are the per-frame decoded-pixel hashes; one Ed25519 signature covers +//! codestream hash + frame hashes + exact reconstruction work + measured joules. +//! Chains via `parent_receipt_hash` exactly as the other classes — reuse, not +//! reinvention. +//! +//! **What makes this profile different from its siblings.** In every other +//! cargo class the reconstruction is deterministic *by construction* and taken +//! on trust. Here it is deterministic *and proven on real silicon*: the +//! cross-architecture byte-equality matrix decodes these same vectors on +//! linux-x86_64, linux-aarch64, macos-arm64 and windows-x86_64 and asserts +//! identical bytes. So a verifier holding only the codestream can independently +//! recompute both `clip_hash` and `work_pixel_ops` — see +//! [`VideoReceipt::verify_reconstruction`] — and every field except the energy +//! stops being a claim and becomes a checkable fact. +//! +//! That leaves exactly one attested-not-verifiable field, `joules_micro`, and +//! the receipt is shaped so a reader cannot mistake which is which: +//! +//! - `codestream_hash`, `clip_hash`, `frames`, `work_pixel_ops` — **verifiable** +//! by anyone with the bitstream, on any of the four proven architectures. +//! - `joules_micro` — **measured-attested**: the real marginal CPU energy the +//! metering sink spent, vouched for by the signature and by nothing else. A +//! different machine will legitimately measure a different number for a +//! byte-identical decode. +//! +//! This module deliberately lives outside `int_video.rs`. That file is +//! `#[path]`-included by `wai/byte-exact-conformance`, whose whole purpose is to +//! compile the decode path with no codec libraries, no ONNX and a three-crate +//! dependency set; pulling blake3 and ed25519 into it would break the matrix +//! that makes the paragraph above true. + +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use serde_json::Value; + +use crate::int_video::{decode, DecodedClip}; +use crate::world::receipt::merkle_root; + +const DOMAIN_RECEIPT: &[u8] = b"wai:video-receipt\x01"; +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. +/// +/// 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, + } + } +} + +/// `(frame_index, BLAKE3 over the frame's decoded RGB bytes)`. +/// +/// The index is inside the hash, not merely beside it: inter-frame decode is +/// order-dependent, so a receipt whose frames could be permuted without +/// changing a leaf would vouch for a clip nobody can play. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FrameHash { + pub index: u64, + pub hash: [u8; 32], +} + +/// The exact, portable reconstruction work: one op per decoded subpixel. +/// +/// Verified by recomputation from `(frames, h, w)`, so it cannot be inflated to +/// make an energy figure look efficient. It counts *output* subpixels rather +/// than motion-vector or residual operations because only the output is fixed +/// by the format — a future encoder may spend fewer residual ops on the same +/// clip, and the receipt should stay comparable across encoders. +pub fn work_pixel_ops(n_frames: u64, h: u64, w: u64) -> u64 { + n_frames.saturating_mul(h).saturating_mul(w).saturating_mul(3) +} + +/// BLAKE3 over one frame's decoded RGB bytes, bound to its index. +pub fn frame_hash(index: u64, rgb: &[u8]) -> FrameHash { + let mut h = blake3::Hasher::new(); + h.update(DOMAIN_FRAME); + h.update(&index.to_le_bytes()); + h.update(rgb); + FrameHash { index, hash: *h.finalize().as_bytes() } +} + +/// The portable whole-clip commitment: geometry, then every frame hash in order. +pub fn clip_hash(h: u32, w: u32, frames: &[FrameHash]) -> [u8; 32] { + let mut hs = blake3::Hasher::new(); + hs.update(DOMAIN_CLIP); + hs.update(&h.to_le_bytes()); + hs.update(&w.to_le_bytes()); + hs.update(&(frames.len() as u64).to_le_bytes()); + for f in frames { + hs.update(&f.index.to_le_bytes()); + hs.update(&f.hash); + } + *hs.finalize().as_bytes() +} + +#[derive(Clone, Debug, PartialEq)] +pub struct VideoReceipt { + /// Identity: BLAKE3 over the canonical codestream bytes as they were read. + pub codestream_hash: [u8; 32], + /// The reconstruction result: the portable whole-clip commitment. + pub clip_hash: [u8; 32], + pub h: u32, + pub w: u32, + /// Per-frame decoded-pixel hashes, in decode order. + pub frames: Vec, + /// Merkle root over the frame hashes — JWP's exact domains, reused. + pub merkle_root: [u8; 32], + /// Exact, portable reconstruction work (see [`work_pixel_ops`]). + /// Verified by recomputation. + pub work_pixel_ops: u64, + /// Measured microjoules the sink spent reconstructing (`0` = unmetered). + /// Attested by the signature, not independently verifiable. + pub joules_micro: u64, + /// How `joules_micro` was acquired. `None` iff the receipt is unmetered. + /// + /// The coupling is enforced by [`VideoReceipt::verify`], not merely + /// documented: a labelled figure of zero, or an unlabelled figure above + /// zero, is rejected. Either would let a reader take a number at a + /// confidence the producer never claimed. + pub energy_provenance: Option, + pub parent_receipt_hash: Option<[u8; 32]>, + pub signer_pubkey: [u8; 32], + pub signer_id: String, + pub sig: [u8; 64], +} + +impl VideoReceipt { + fn signing_payload(&self) -> Vec { + let mut o = DOMAIN_RECEIPT.to_vec(); + o.extend_from_slice(&self.codestream_hash); + o.extend_from_slice(&self.clip_hash); + o.extend_from_slice(&self.h.to_be_bytes()); + o.extend_from_slice(&self.w.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_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)); + match self.parent_receipt_hash { + Some(h) => { + o.push(1); + o.extend_from_slice(&h); + } + None => o.push(0), + } + for f in &self.frames { + o.extend_from_slice(&f.index.to_be_bytes()); + o.extend_from_slice(&f.hash); + } + o.extend_from_slice(&self.signer_pubkey); + let id = self.signer_id.as_bytes(); + o.extend_from_slice(&(id.len() as u32).to_be_bytes()); + o.extend_from_slice(id); + o + } + + /// Seal a receipt over an already-decoded clip. + /// + /// Prefer [`VideoReceipt::seal_decode`], which decodes the codestream here + /// and so cannot be handed a clip that is not what the codestream produces. + pub fn build( + signer: &SigningKey, + signer_id: impl Into, + codestream_hash: [u8; 32], + clip: &DecodedClip, + joules_micro: u64, + energy_provenance: Option, + parent_receipt_hash: Option<[u8; 32]>, + ) -> VideoReceipt { + let frames: Vec = clip + .frames + .iter() + .enumerate() + .map(|(i, f)| frame_hash(i as u64, f)) + .collect(); + let (h, w) = (clip.h as u32, clip.w as u32); + let leaves: Vec<[u8; 32]> = frames.iter().map(|f| f.hash).collect(); + let mut r = VideoReceipt { + codestream_hash, + clip_hash: clip_hash(h, w, &frames), + h, + w, + merkle_root: merkle_root(&leaves), + work_pixel_ops: work_pixel_ops(frames.len() as u64, h as u64, w as u64), + frames, + joules_micro, + energy_provenance, + parent_receipt_hash, + signer_pubkey: signer.verifying_key().to_bytes(), + signer_id: signer_id.into(), + sig: [0u8; 64], + }; + r.sig = signer.sign(&r.signing_payload()).to_bytes(); + r + } + + /// Decode `codestream` and seal a receipt over what it actually produces. + /// + /// The identity is hashed from `raw` — the codestream bytes exactly as they + /// arrived — rather than from a re-serialisation of the parsed value, so the + /// hash identifies the artifact a verifier will be handed rather than this + /// process's idea of it. + pub fn seal_decode( + signer: &SigningKey, + signer_id: impl Into, + raw: &[u8], + codestream: &Value, + joules_micro: u64, + energy_provenance: Option, + parent_receipt_hash: Option<[u8; 32]>, + ) -> Result { + let clip = decode(codestream)?; + let codestream_hash = *blake3::hash(raw).as_bytes(); + Ok(Self::build( + signer, + signer_id, + codestream_hash, + &clip, + joules_micro, + energy_provenance, + parent_receipt_hash, + )) + } + + /// Signature + internal consistency: the Merkle root, the clip commitment + /// and the work figure must all follow from the frame hashes carried here. + /// + /// This does **not** prove the frames are what the codestream decodes to — + /// only [`VideoReceipt::verify_reconstruction`] does that. + pub fn verify(&self) -> bool { + let leaves: Vec<[u8; 32]> = self.frames.iter().map(|f| f.hash).collect(); + if merkle_root(&leaves) != self.merkle_root { + return false; + } + if clip_hash(self.h, self.w, &self.frames) != self.clip_hash { + return false; + } + if work_pixel_ops(self.frames.len() as u64, self.h as u64, self.w as u64) + != self.work_pixel_ops + { + return false; + } + for (i, f) in self.frames.iter().enumerate() { + if f.index != i as u64 { + return false; + } + } + // 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, + } + let Ok(k) = VerifyingKey::from_bytes(&self.signer_pubkey) else { return false }; + k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)).is_ok() + } + + /// The independent check: re-decode the codestream and confirm this receipt + /// describes it — identity, geometry, every frame, the clip commitment and + /// the work figure. + /// + /// Byte-equality across architectures is what makes this meaningful rather + /// than circular: the caller need not trust the machine that sealed the + /// receipt, because the same bitstream reconstructs the same bytes on any of + /// the proven architectures. `joules_micro` is untouched here — it is not + /// reproducible and is not meant to be. + pub fn verify_reconstruction(&self, raw: &[u8], codestream: &Value) -> bool { + if *blake3::hash(raw).as_bytes() != self.codestream_hash { + return false; + } + let Ok(clip) = decode(codestream) else { return false }; + if clip.h as u32 != self.h || clip.w as u32 != self.w { + return false; + } + if clip.frames.len() != self.frames.len() { + return false; + } + for (i, f) in clip.frames.iter().enumerate() { + if frame_hash(i as u64, f) != self.frames[i] { + return false; + } + } + self.verify() + } + + pub fn receipt_hash(&self) -> [u8; 32] { + let mut h = blake3::Hasher::new(); + h.update(DOMAIN_RECEIPT_ID); + h.update(&self.signing_payload()); + h.update(&self.sig); + *h.finalize().as_bytes() + } + + /// Microjoules per thousand decoded subpixels, **with** its acquisition + /// class — the only form in which the figure is comparable to another. + /// `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) { + (Some(p), w) if self.joules_micro > 0 && w > 0 => { + Some((self.joules_micro as f64 * 1000.0 / w as f64, p)) + } + _ => None, + } + } + + pub fn to_json(&self) -> String { + let hx = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::(); + let objs: Vec = + self.frames.iter().map(|f| format!("\"{}\"", hx(&f.hash))).collect(); + let parent = match self.parent_receipt_hash { + Some(h) => format!("\"{}\"", hx(&h)), + None => "null".to_string(), + }; + let (rate, prov) = match self.uj_per_kilopixel_op() { + Some((r, p)) => (format!("{r:.6}"), format!("\"{}\"", p.label())), + None => ("null".to_string(), "null".to_string()), + }; + 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\":{},", + "\"parent_receipt_hash\":{},\"signer_pubkey\":\"{}\",", + "\"signer_id\":\"{}\",\"sig\":\"{}\",\"receipt_hash\":\"{}\"}}" + ), + hx(&self.codestream_hash), + hx(&self.clip_hash), + self.h, + self.w, + objs.join(","), + hx(&self.merkle_root), + self.work_pixel_ops, + self.joules_micro, + rate, + prov, + parent, + hx(&self.signer_pubkey), + self.signer_id, + hx(&self.sig), + hx(&self.receipt_hash()) + ) + } +} diff --git a/wai/wai-rs/tests/video_receipt.rs b/wai/wai-rs/tests/video_receipt.rs new file mode 100644 index 000000000..a2d6c00b0 --- /dev/null +++ b/wai/wai-rs/tests/video_receipt.rs @@ -0,0 +1,176 @@ +//! `wai.video.int_motion` receipt — the metered leg of the integer video class. +//! +//! The point of these tests is the split the profile rests on: everything except +//! the energy is independently checkable from the codestream, and the energy is +//! attested by the signature alone. Each half is tested for what it actually +//! promises, and the tamper cases confirm the checkable half cannot be bent. +#![cfg(feature = "video_receipt")] + +use std::path::PathBuf; + +use ed25519_dalek::SigningKey; +use wai::video_receipt::{work_pixel_ops, Provenance, VideoReceipt}; + +fn dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/vectors/int_video") +} +fn raw(name: &str) -> Vec { + std::fs::read(dir().join(name)).expect("read vector") +} +fn key(seed: u8) -> SigningKey { + SigningKey::from_bytes(&[seed; 32]) +} + +fn seal(name: &str, uj: u64) -> (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) +} + +#[test] +fn seals_and_verifies_against_the_committed_clip() { + let (r, bytes, bs) = seal("int_video_bitstream.json", 4_200); + + assert!(r.verify(), "signature + internal consistency"); + assert!(r.verify_reconstruction(&bytes, &bs), "re-decode must reproduce every frame"); + + // The work figure follows from the geometry and is recomputable, so it + // cannot be inflated to flatter an energy number. + assert_eq!(r.h, 48); + assert_eq!(r.w, 48); + assert_eq!(r.frames.len(), 5); + assert_eq!(r.work_pixel_ops, work_pixel_ops(5, 48, 48)); + assert_eq!(r.work_pixel_ops, 5 * 48 * 48 * 3); + + 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); +} + +/// An unmetered receipt reports **no** rate. A `0` would read as "this decode +/// was free", which is a stronger and false claim than "nobody measured". +#[test] +fn unmetered_reports_no_rate_rather_than_zero() { + let (r, _, _) = seal("int_video_bitstream.json", 0); + assert!(r.verify()); + assert_eq!(r.joules_micro, 0); + assert!(r.uj_per_kilopixel_op().is_none()); + assert_eq!(r.energy_provenance, None); + assert!(r.to_json().contains("\"uj_per_kilopixel_op\":null")); + assert!(r.to_json().contains("\"energy_provenance\":null")); +} + +/// Real footage, the half-pel path — the clip whose decode is most at risk of +/// differing between architectures, and therefore the one whose receipt is +/// worth the most. +#[test] +fn real_footage_receipt_round_trips() { + let (r, bytes, bs) = seal("video_real_bitstream.json", 91_000); + assert!(r.verify()); + assert!(r.verify_reconstruction(&bytes, &bs)); + assert_eq!((r.h, r.w, r.frames.len()), (96, 96, 8)); + assert_eq!(r.work_pixel_ops, 8 * 96 * 96 * 3); +} + +/// A receipt sealed over one clip must not verify against another. +#[test] +fn a_receipt_does_not_vouch_for_a_different_clip() { + let (r, _, _) = seal("int_video_bitstream.json", 10); + let other = raw("video_real_bitstream.json"); + let other_bs: serde_json::Value = serde_json::from_slice(&other).unwrap(); + assert!(!r.verify_reconstruction(&other, &other_bs)); +} + +/// Tampering with any checkable field must break the receipt. +#[test] +fn every_checkable_field_is_bound() { + let (base, bytes, bs) = seal("int_video_bitstream.json", 4_200); + + // A flipped frame hash breaks the Merkle root. + let mut t = base.clone(); + t.frames[2].hash[0] ^= 1; + assert!(!t.verify(), "flipped frame hash"); + + // Reordering frames breaks the index binding — inter-frame decode is + // order-dependent, so a permuted clip is a different clip. + let mut t = base.clone(); + t.frames.swap(1, 3); + assert!(!t.verify(), "permuted frames"); + + // Inflating the work figure (the denominator of any efficiency claim) + // breaks recomputation. + let mut t = base.clone(); + t.work_pixel_ops *= 10; + assert!(!t.verify(), "inflated work"); + + // Restating the geometry breaks the clip commitment. + let mut t = base.clone(); + t.w += 1; + assert!(!t.verify(), "restated geometry"); + + // A different identity no longer describes this codestream. + let mut t = base.clone(); + t.codestream_hash[0] ^= 1; + assert!(!t.verify_reconstruction(&bytes, &bs), "wrong codestream hash"); + + // And the energy is inside the signature even though it is not + // independently checkable — an intermediary cannot restate it. + let mut t = base.clone(); + t.joules_micro = 1; + assert!(!t.verify(), "restated energy"); +} + +/// Receipts chain like every other class, and the chain link is covered by the +/// signature. +#[test] +fn receipts_chain_by_parent_hash() { + let (first, _, _) = seal("int_video_bitstream.json", 4_200); + let bytes = raw("video_real_bitstream.json"); + let bs: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let second = VideoReceipt::seal_decode( + &key(7), + "did:example:meter", + &bytes, + &bs, + 91_000, + Some(Provenance::HwShunt), + Some(first.receipt_hash()), + ) + .expect("seal"); + + assert!(second.verify()); + assert_eq!(second.parent_receipt_hash, Some(first.receipt_hash())); + + let mut t = second.clone(); + t.parent_receipt_hash = None; + assert!(!t.verify(), "the chain link is signed"); +} + +/// A number and its acquisition class stand or fall together — the doctrine +/// this class exists to enforce, checked rather than merely documented. +#[test] +fn an_energy_figure_and_its_class_are_inseparable() { + let (metered, _, _) = seal("int_video_bitstream.json", 4_200); + let (unmetered, _, _) = seal("int_video_bitstream.json", 0); + assert!(metered.verify() && unmetered.verify()); + + // Stripping the class off a real figure leaves a number nobody can compare. + let mut t = metered.clone(); + t.energy_provenance = None; + assert!(!t.verify(), "unlabelled non-zero figure"); + + // 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); + assert!(!t.verify(), "labelled zero"); + + // The class is inside the signature, so an intermediary cannot promote a + // modelled figure to a measured one. + let mut t = metered.clone(); + t.energy_provenance = Some(Provenance::ModelBased); + assert!(!t.verify(), "restated class"); +}