From cc371d815ae42fd375bc53cfba0a4d717d56033e Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 06:37:25 -0400 Subject: [PATCH 1/3] wai: energy reports, labelled JWP leaves and what signatures cover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two claim classes report on one energy figure without changing the receipt that seals it. `measurement` carries a bracket's values as integers with each bracket's liveness facts, the build and the power reader, and is accepted only against the receipt that carries its figure. `operating-point` records the requested, committed and delivered operating points and a prediction. The ratio, verdict and residual are derived by every reader, never stated. energy-measurement §6.2 gives the figure and the least declaration a report's integers support as exact integer formulas; wai-rs `energy_report` implements them with no floating-point arithmetic. §3.1 defines a live power reader: a rail must advance in two consecutive intervals, in at least five and in at least four of every five, over at least 300 ms. A frozen or held rail is refused rather than sealed under a hardware class. An unmetered `0` is never a figure of zero joules. A JWP object's figure, class and origin are bound into its Merkle leaf (`\x02`), and a labelled group payload signs the total's class, its coverage and the parent link. An object must sit at the position its inclusion proof walks, and group signatures follow the strict Ed25519 rule claims use. `jwp_delivery` and `energy_figure` implement this in wai-rs; joule-moq 0.2.0 builds and verifies both labelled forms. New corpus `jwp-conformance/` with `jwp_verify.py`, more claim vectors with the earlier ones pinned, and a CI job that runs claim_verify.py, differential.py and jwp_verify.py. Legacy receipts keep their bytes. External JWP implementations must adopt the `\x02` leaf and the labelled group payload before verifying objects or groups that use them. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/joule-code-conformance.yml | 16 + .github/workflows/wai-conformance.yml | 58 +- .github/workflows/wai-energy-receipts.yml | 39 +- joule-code/CHANGELOG.md | 42 + joule-code/examples/transport-moqt/Cargo.lock | 2 +- joule-code/joule-code-rs/Cargo.lock | 2 +- .../joule-code-rs/crates/joule-moq/Cargo.toml | 2 +- .../crates/joule-moq/examples/quic_xnet.rs | 3 +- .../joule-code-rs/crates/joule-moq/src/lib.rs | 9 +- .../crates/joule-moq/src/mock.rs | 1 + .../crates/joule-moq/src/receipt.rs | 703 ++- .../crates/joule-moq/src/transport_quic.rs | 1 + .../crates/joule-moq/tests/wai_jwp_corpus.rs | 317 ++ wai/CHANGELOG.md | 111 + wai/SPEC.md | 43 +- wai/claim-conformance/README.md | 69 +- wai/claim-conformance/claim_verify.py | 300 +- wai/claim-conformance/differential.py | 179 +- wai/claim-conformance/vectors.json | 1243 ++++- wai/extensions/energy-binding.md | 110 +- wai/extensions/energy-measurement.md | 383 +- wai/extensions/interactive-worlds.md | 4 +- wai/extensions/jwp-receipts.md | 609 ++- wai/extensions/moq-streaming-format.md | 38 +- wai/extensions/provenance.md | 12 +- wai/extensions/quantum-sim.md | 18 +- wai/jwp-conformance/.gitattributes | 3 + wai/jwp-conformance/README.md | 64 + wai/jwp-conformance/jwp_verify.py | 544 ++ wai/jwp-conformance/vectors.json | 4554 +++++++++++++++++ wai/research/2026-09-global-sweep.md | 10 +- wai/research/energy-exposure-mapping.md | 23 + wai/wai-quantum-serve/src/lib.rs | 26 +- wai/wai-rs/Cargo.toml | 15 +- wai/wai-rs/build.rs | 31 + wai/wai-rs/src/bin/wai_claim_vectors.rs | 20 +- wai/wai-rs/src/bin/wai_jwp_vectors.rs | 98 + wai/wai-rs/src/bin/wai_meter.rs | 102 +- wai/wai-rs/src/bin/wai_prov.rs | 58 +- wai/wai-rs/src/bin/wai_quantum_meter.rs | 57 +- wai/wai-rs/src/bin/wai_video_meter.rs | 64 +- wai/wai-rs/src/bin/wai_world_meter.rs | 54 +- wai/wai-rs/src/build_info.rs | 36 + wai/wai-rs/src/c2pa_emit.rs | 4 +- wai/wai-rs/src/claim_receipt.rs | 1400 ++++- wai/wai-rs/src/energy_binding.rs | 12 +- wai/wai-rs/src/energy_burst.rs | 448 +- wai/wai-rs/src/energy_class.rs | 38 +- wai/wai-rs/src/energy_figure.rs | 473 ++ wai/wai-rs/src/energy_figure/tests.rs | 336 ++ wai/wai-rs/src/energy_meter.rs | 324 +- wai/wai-rs/src/energy_report.rs | 808 +++ wai/wai-rs/src/film/receipt.rs | 3 +- wai/wai-rs/src/jwp_delivery.rs | 1228 +++++ wai/wai-rs/src/jwp_delivery/corpus.rs | 684 +++ wai/wai-rs/src/jwp_delivery/tests.rs | 525 ++ wai/wai-rs/src/lib.rs | 35 +- wai/wai-rs/src/provenance.rs | 45 +- wai/wai-rs/src/receipt_pins.rs | 91 + wai/wai-rs/src/ternary_receipt.rs | 4 +- wai/wai-rs/src/video_receipt.rs | 17 +- wai/wai-rs/src/wai_video_channel.rs | 5 +- wai/wai-rs/src/wai_video_keys.rs | 5 +- wai/wai-rs/src/wai_video_manifest.rs | 5 +- wai/wai-rs/src/wai_video_package.rs | 5 +- wai/wai-rs/src/wai_video_ssai.rs | 5 +- wai/wai-rs/src/world/receipt.rs | 3 +- wai/wai-rs/tests/video_receipt.rs | 32 + 68 files changed, 16025 insertions(+), 583 deletions(-) create mode 100644 joule-code/joule-code-rs/crates/joule-moq/tests/wai_jwp_corpus.rs create mode 100644 wai/jwp-conformance/.gitattributes create mode 100644 wai/jwp-conformance/README.md create mode 100644 wai/jwp-conformance/jwp_verify.py create mode 100644 wai/jwp-conformance/vectors.json create mode 100644 wai/wai-rs/src/bin/wai_jwp_vectors.rs create mode 100644 wai/wai-rs/src/build_info.rs create mode 100644 wai/wai-rs/src/energy_figure.rs create mode 100644 wai/wai-rs/src/energy_figure/tests.rs create mode 100644 wai/wai-rs/src/energy_report.rs create mode 100644 wai/wai-rs/src/jwp_delivery.rs create mode 100644 wai/wai-rs/src/jwp_delivery/corpus.rs create mode 100644 wai/wai-rs/src/jwp_delivery/tests.rs diff --git a/.github/workflows/joule-code-conformance.yml b/.github/workflows/joule-code-conformance.yml index 1f2aed039..2c433079c 100644 --- a/.github/workflows/joule-code-conformance.yml +++ b/.github/workflows/joule-code-conformance.yml @@ -9,12 +9,20 @@ on: pull_request: paths: - 'joule-code/**' + # joule-moq's receipt tests pin leaves, roots and signatures copied from WAI's + # jwp-conformance corpus, and run every leaf, group and object vector in it: + # a change there must re-run them. + - 'wai/jwp-conformance/**' - '.github/workflows/joule-code-conformance.yml' push: branches: - main paths: - 'joule-code/**' + # joule-moq's receipt tests pin leaves, roots and signatures copied from WAI's + # jwp-conformance corpus, and run every leaf, group and object vector in it: + # a change there must re-run them. + - 'wai/jwp-conformance/**' - '.github/workflows/joule-code-conformance.yml' jobs: @@ -49,6 +57,14 @@ jobs: working-directory: joule-code/joule-code-rs run: cargo test --workspace + # joule-moq's QUIC transport and its example compile only with `quic`. Named + # here so they stay built and tested whatever the workspace's other members + # happen to enable; this also runs tests/wai_jwp_corpus.rs, every leaf, group + # and object vector of wai/jwp-conformance through the crate. + - name: joule-moq with the QUIC transport, every target + working-directory: joule-code/joule-code-rs + run: cargo test -p joule-moq --features quic --all-targets + - name: Round-trip the v1 conformance pack (C1-C4) working-directory: joule-code/joule-code-rs run: | diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index 8ffe3309b..e4c01c8ea 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -105,8 +105,11 @@ jobs: working-directory: wai/wai-rs # neural_int joins the pure-Rust set: the integer-synthesis decoder is # i64-only (no ONNX, no float), so it carries the same bit-exact promise - # and is checkable on this matrix with no ONNX Runtime present. - run: cargo build --no-default-features --features world,audio_scene,haptic,splat4d,score,provenance,neural_int,film + # and is checkable on this matrix with no ONNX Runtime present. receipt_claims + # is in the set because the reference meters (wai_meter, wai_world_meter) write a + # measurement report by default and require it: without it they would drop out + # of this build, on Windows too, without a word. + run: cargo build --no-default-features --features world,audio_scene,haptic,splat4d,score,provenance,neural_int,film,receipt_claims - name: Library unit tests (the fixed-point determinism floors) working-directory: wai/wai-rs run: cargo test --lib --no-default-features --features world,audio_scene,haptic,splat4d,score,provenance,neural_int,film,oer,receipt_claims @@ -223,6 +226,7 @@ jobs: v quantum wai_quantum_vectors ../quantum-conformance v quantum-ops wai_quantum_ops_vectors ../quantum-ops-conformance v oer wai_oer_vectors ../oer-conformance + v jwp wai_jwp_vectors ../jwp-conformance v claims wai_claim_vectors ../claim-conformance v pin wai_pin_vectors ../pin-conformance # The pin corpus's second implementation: Python standard library only, sharing no @@ -272,6 +276,7 @@ jobs: v quantum wai_quantum_vectors ../quantum-conformance v quantum-ops wai_quantum_ops_vectors ../quantum-ops-conformance v oer wai_oer_vectors ../oer-conformance + v jwp wai_jwp_vectors ../jwp-conformance v claims wai_claim_vectors ../claim-conformance v pin wai_pin_vectors ../pin-conformance @@ -296,6 +301,55 @@ jobs: shell: bash run: python wai/vocab/check_shapes.py + # The corpora above are generated and verified by wai-rs, one implementation. These are + # the second implementations, written from the extension text in Python and sharing no + # code with wai-rs: claim_verify.py (every claim vector, the canonical fixture, and the + # bracket arithmetic of energy-measurement §6.2 recomputed with Python's integers), + # differential.py (both verifiers' verdicts on generated inputs the corpus does not hold, + # under a fixed seed, so a run is reproducible), and jwp_verify.py (every JWP vector). + # Each exits non-zero on any failure or any difference, and the step fails with it. + # One platform: what they check is the reading of the text, not the architecture. + independent-verifiers: + name: independent verifiers · claims and JWP (Python) + runs-on: linux + steps: + - uses: actions/checkout@v5 + + - 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" + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + # The verifiers check Ed25519's equation with the `cryptography` package and make the + # rest of jwp-receipts §3.1 step 3 themselves. + - name: Install the Ed25519 library + shell: bash + run: python -m pip install cryptography + + # differential.py drives wai_claim_vectors' `verdicts` mode. + - name: Build the corpus verifier wai-rs ships + working-directory: wai/wai-rs + shell: bash + run: cargo build --no-default-features --features receipt_claims --bin wai_claim_vectors --bin wai_jwp_vectors + + - name: claim_verify.py, differential.py, jwp_verify.py + working-directory: wai + shell: bash + run: | + set -euo pipefail + python3 claim-conformance/claim_verify.py claim-conformance/vectors.json + python3 claim-conformance/differential.py "$GITHUB_WORKSPACE/wai/wai-rs/target/debug/wai_claim_vectors" --random 2000 --seed 1 + python3 jwp-conformance/jwp_verify.py jwp-conformance/vectors.json + byte-exact: name: cross-arch byte-equality · ${{ matrix.os }} runs-on: ${{ matrix.os }} diff --git a/.github/workflows/wai-energy-receipts.yml b/.github/workflows/wai-energy-receipts.yml index 07dbf3bf7..8e432ec3f 100644 --- a/.github/workflows/wai-energy-receipts.yml +++ b/.github/workflows/wai-energy-receipts.yml @@ -1,16 +1,21 @@ 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): +# the class rules, the optional labels, the resolution floor, the pins that +# hold legacy receipts (unlabelled, or labelled with the legacy HwShunt) and +# labelled ones to the signed bytes they have; the measurement report's +# arithmetic and the liveness rule (§3.1, §6.2) with their fault-injection +# tests; the figure views a report is checked against, for every receipt +# family the set compiles; and the reference meters' report path. 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 +# tests under those features (energy_class, energy_report, energy_burst, +# energy_meter, energy_binding, energy_figure, the label and pin tests of +# the wai-rs receipt families, the video and OTT receipt tests, and — with +# receipt_claims — the claim classes and jwp_delivery); 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. @@ -18,7 +23,9 @@ name: WAI energy receipts # 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. +# logic behind POST /verify, for a receipt in either form, and that an +# unmetered receipt's 0 is rendered as the unmetered marker (null), never +# as zero joules. # # It does NOT: # - check that a newly sealed receipt in a family with an optional label @@ -27,8 +34,11 @@ name: WAI energy receipts # 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). +# five reference meter bins still build with their report path +# (wai_video_meter, wai_meter, wai_world_meter, wai_quantum_meter). +# - run a meter against a power reader. The report path is tested in the +# library (energy_figure::meter_report), and the protocol against +# scripted readers (energy_burst, energy_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 @@ -39,8 +49,8 @@ name: WAI energy receipts # 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. +# - verify the JWP corpus; wai-conformance.yml does that, and joule-code's +# workflow tests joule-moq against literals copied from it. # - 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 @@ -102,7 +112,8 @@ jobs: # 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. + # wai_quantum_meter), whose report path needs receipt_claims — 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 @@ -114,7 +125,7 @@ jobs: 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 \ + --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,receipt_claims \ --lib --bins --test video_receipt # wai-quantum is published separately; its receipts' legacy pins and the diff --git a/joule-code/CHANGELOG.md b/joule-code/CHANGELOG.md index 7ea6b8030..f2f43723e 100644 --- a/joule-code/CHANGELOG.md +++ b/joule-code/CHANGELOG.md @@ -14,6 +14,48 @@ All notable changes to the Joule Code standard are recorded here. The format fol - `joule-code-collab` (Research) lands the **federated-collaboration interface** (`Collab` trait: submit / committed / membership), a **single-node reference cluster** (`LocalCluster`), and the **BFT consensus core** (`LockstepCluster`): a multi-node *lockstep deterministic simulation with BFT supermajority commit* — an op commits only when ≥`2f+1` of `n=3f+1` honest nodes independently validate it, and committed ops are `op_id`-ordered so every honest node derives a byte-identical log. Tested: commits with honest agreement, tolerates `f` Byzantine nodes (safety), loses liveness only beyond `f`, rejects non-members despite quorum, deterministic round ordering. It also adds `protected` — **FROST threshold authorization** of protected operations: a `t`-of-`n` FROST (Ed25519) aggregate signature over an op's `op_id`, verifiable against the cluster's single group key (no member holds the whole key; fewer than `t` cannot authorize). Tested: a 2-of-3 threshold authorizes and binds exactly one op_id; below threshold cannot. And **MLS group keying** (RFC 9420, via `openmls`, `tests/mls_keying.rs`): the cluster members form an MLS group and derive a shared group key with no central server — verified that members agree on the exported secret, that adding a member advances the epoch, and that a member an epoch behind derives a different (forward-secret) key. (SPEC §7, `spec/collab.md`.) - `examples/transport-moqt/` — the **CommunicationOS (MoQT) transport binding**, the last of the four federation layers. A consumer binding that carries a collaboration's committed (consensus-ordered) coding operations over [`inv-moq`](../../joulesperbit/crates/inv-moq) — CommunicationOS's MoQT (Media over QUIC Transport) protocol with its JWP receipt extension. The end-to-end test wires the whole stack in-process: `LockstepCluster` commits ops → each is published as a MoQT object → every object carries a real Ed25519/BLAKE3 **signed receipt** → a subscriber receives all ops, verifies the receipts against the publisher key, and recovers the original `SignedOp` intact; a second test shows a receipt-requiring subscriber rejecting an untrusted publisher's group (ownerless authenticity). Driven through `inv-moq`'s `MockFabric` (real signatures + BLAKE3 merkle receipts, no QUIC; the `quic` feature is the real-wire path behind the same trait surface). Standalone workspace, so the reference library stays free of a joulesperbit dependency. **The four federation layers — consensus, threshold authorization, group keying, transport — are now all implemented and tested.** (SPEC §7, `spec/collab.md`.) +### Changed — `joule-moq` 0.2.0: WAI's labelled JWP forms, and a strict verifier + +`joule-moq` reads and writes the forms of WAI's `extensions/jwp-receipts.md` +that sign an object's energy figure, its class and its origin, and a group +total's class. Every receipt in the legacy forms keeps its bytes and verifies as +before; a verifier that predates the labelled forms refuses them. The crate's +version moves to 0.2.0 because its public API changes: + +- `ObjectReceipt` gains `energy_class: Option>` (the class in WAI + energy-measurement §2.7's signed encoding). Present, the object's leaf is + `BLAKE3("moq-jwp:merkle-leaf\x02" ‖ content_hash ‖ u64_be(joules_micro) ‖ + class ‖ u32_be(len(origin)) ‖ origin)`; absent, the legacy leaf. New: + `ObjectReceipt::leaf`, `ObjectReceipt::check_label`, + `ReceiptBuilder::add_labelled_object`, `ReceiptBuilder::class_for`. +- `GroupReceipt` gains `energy_class`, `energy_coverage` and + `parent_receipt_hash`. With a class, the group is signed under + `moq-jwp:group-sign\x02`, which covers the class, the coverage and the + parent link. `energy_coverage` is written only when it is non-zero, and a + legacy group carrying a non-zero coverage is refused: its signature does not + cover one. New: `ReceiptBuilder::finalize_labelled`, `GroupReceipt::link_hash` + and `compose_group_class`, the rule a labelled group's class is composed by. +- `ReceiptVerifier::verify_group` returns a `VerifiedGroup` (the root with the + group's leaf count and coordinates) instead of the bare root, and + `verify_object` and `verify_payload` take `&VerifiedGroup`. An object is + accepted only at the position its proof walks: its coordinates are the + group's, `object_id` is its leaf index, the proof has the group's depth and + its direction bits are the index's. +- `MerkleProof::verify` is replaced by `MerkleProof::verify_leaf`, which takes + the leaf in its form and the group's leaf count, and + `MerkleProof::walks_its_position`. +- `verify_group` holds the key and the signature to WAI jwp-receipts §3.1 + step 3's rule: the publisher key must be the canonical encoding of a point + not of small order (a `y` coordinate ≥ p is refused), `S` must be below L + (checked by the crate as well as by `verify_strict`), `R` must not be of + small order, and the cofactorless equation must hold. +- `JWP_MOQ_EXTENSION_VERSION` is 2. +- `tests/wai_jwp_corpus.rs` runs every leaf, group and object vector of + `wai/jwp-conformance/vectors.json` through the crate. + +External JWP implementations need the same update to verify labelled leaves and +groups, and to refuse what this verifier refuses. + ## [0.2.0] — draft ### Changed diff --git a/joule-code/examples/transport-moqt/Cargo.lock b/joule-code/examples/transport-moqt/Cargo.lock index 4d76805c6..56094914c 100644 --- a/joule-code/examples/transport-moqt/Cargo.lock +++ b/joule-code/examples/transport-moqt/Cargo.lock @@ -654,7 +654,7 @@ dependencies = [ [[package]] name = "joule-moq" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-trait", "blake3", diff --git a/joule-code/joule-code-rs/Cargo.lock b/joule-code/joule-code-rs/Cargo.lock index 76d0c8aaa..2a79280ef 100644 --- a/joule-code/joule-code-rs/Cargo.lock +++ b/joule-code/joule-code-rs/Cargo.lock @@ -1367,7 +1367,7 @@ dependencies = [ [[package]] name = "joule-moq" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-trait", "blake3", diff --git a/joule-code/joule-code-rs/crates/joule-moq/Cargo.toml b/joule-code/joule-code-rs/crates/joule-moq/Cargo.toml index 35dc6f16b..f2151ce62 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/Cargo.toml +++ b/joule-code/joule-code-rs/crates/joule-moq/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "joule-moq" -version = "0.1.0" +version = "0.2.0" description = "MoQT (Media over QUIC Transport) wire protocol + JWP receipt extension for CommunicationOS broadcast media. Trait surface lets the Realtime stack publish, subscribe, and relay tracks with object-level signed provenance and joule receipts." edition = "2024" rust-version = "1.97" diff --git a/joule-code/joule-code-rs/crates/joule-moq/examples/quic_xnet.rs b/joule-code/joule-code-rs/crates/joule-moq/examples/quic_xnet.rs index 393a4ff90..d0bf94bd9 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/examples/quic_xnet.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/examples/quic_xnet.rs @@ -120,6 +120,7 @@ async fn run() -> Result<(), Box> { joules_micro: joules, origin: origin.to_string(), merkle_proof: proof, + energy_class: None, }; write_object_to_uni(&mut uni, &header, Some(&receipt), &payload).await?; } @@ -153,7 +154,7 @@ async fn run() -> Result<(), Box> { "subscriber: group receipt verified vs pinned key — {} objects, {} µJ, root {}", group_receipt.leaf_count, group_receipt.total_joules_micro, - hex_encode(&root[..8]), + hex_encode(&root.root[..8]), ); for i in 0..group_receipt.leaf_count { diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/lib.rs b/joule-code/joule-code-rs/crates/joule-moq/src/lib.rs index 70dee8896..8765279b6 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/lib.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/lib.rs @@ -62,8 +62,8 @@ pub use messages::{ SubscribeMessage, SubscribeOk, }; pub use receipt::{ - GroupReceipt, ObjectReceipt, ReceiptVerifier, ReceiptBuilder, - MerkleProof, + compose_group_class, GroupReceipt, ObjectReceipt, ReceiptVerifier, ReceiptBuilder, + MerkleProof, VerifiedGroup, }; pub use session::{MoqSession, SessionRole, SessionState}; pub use track::{ @@ -85,8 +85,9 @@ pub const MOQT_DRAFT_VERSION: u32 = 0xff00_0008; // matches draft-08-style versi /// JWP receipt extension version. /// /// Independent of the MoQT draft version. Bumped when the receipt header -/// schema changes. -pub const JWP_MOQ_EXTENSION_VERSION: u8 = 1; +/// schema changes. 2: the labelled object leaf and the labelled group payload +/// (optional members; a receipt without them keeps its bytes). +pub const JWP_MOQ_EXTENSION_VERSION: u8 = 2; /// Crate version, exposed for receipt-emitting tooling. pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/mock.rs b/joule-code/joule-code-rs/crates/joule-moq/src/mock.rs index 79f51ad37..2b729cdac 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/mock.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/mock.rs @@ -216,6 +216,7 @@ impl GroupHandle for MockGroupHandle { joules_micro: joules, origin: origin.clone(), merkle_proof: proof, + energy_class: None, }; fabric.deliver( &key, diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/receipt.rs b/joule-code/joule-code-rs/crates/joule-moq/src/receipt.rs index 15cccbf4f..2c2b0e5d8 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/receipt.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/receipt.rs @@ -20,20 +20,50 @@ //! signed root. //! //! O(log n) verification per object, O(1) signature operations per group. +//! +//! # What the signature covers +//! +//! The wire forms are those of WAI's `extensions/jwp-receipts.md`. An object's +//! leaf takes one of two forms (§2.2): the legacy +//! `BLAKE3("moq-jwp:merkle-leaf\x01" ‖ content_hash)`, which binds the payload +//! only, and the labelled +//! `BLAKE3("moq-jwp:merkle-leaf\x02" ‖ content_hash ‖ u64_be(joules_micro) ‖ class +//! ‖ u32_be(len(origin)) ‖ origin)`, which also binds the object's joule figure, +//! the figure's acquisition class with its evidence, and the origin. A group's +//! signing payload takes one of two forms too (§2.3): the legacy one, which signs +//! the total and no class, and the labelled one (`moq-jwp:group-sign\x02`), which +//! also signs the total's class (composed from the objects' classes, §2.4.1), its +//! coverage and the parent receipt hash. Both new forms are opt-in, and every +//! receipt in a legacy form keeps its bytes. A verifier that predates them +//! refuses a labelled object and a labelled group. +//! +//! The class bytes are WAI's `energy-measurement.md` §2.7 encoding: a tag (0 +//! unmetered, 2 ModelBased, 3 Estimator, 4 OnChipCounter, 5 CalibratedInstrument; +//! 1, the legacy HwShunt, is refused here) and the class's evidence. use crate::error::{MoqError, MoqResult}; use crate::track::{GroupId, ObjectId, TrackAlias}; -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; use serde::{Deserialize, Serialize}; /// Domain separator for Merkle internal nodes. const MERKLE_NODE_DOMAIN: &[u8] = b"moq-jwp:merkle-node\x01"; /// Domain separator for Merkle leaves (object content hashes). const MERKLE_LEAF_DOMAIN: &[u8] = b"moq-jwp:merkle-leaf\x01"; +/// Domain separator for labelled Merkle leaves (content hash, figure, class, +/// origin). +const MERKLE_LEAF_LABELLED_DOMAIN: &[u8] = b"moq-jwp:merkle-leaf\x02"; /// Domain separator for object payload hashing. pub const OBJECT_HASH_DOMAIN: &[u8] = b"moq-jwp:object\x01"; /// Domain separator for the group payload that gets signed. const GROUP_SIGNING_DOMAIN: &[u8] = b"moq-jwp:group-sign\x01"; +/// Domain separator for the labelled group payload. +const GROUP_SIGNING_LABELLED_DOMAIN: &[u8] = b"moq-jwp:group-sign\x02"; +/// Domain of a group receipt's link hash (its identity for `parent_receipt_hash`). +const RECEIPT_LINK_DOMAIN: &[u8] = b"moq-jwp:receipt-link\x01"; +/// `joules_micro × relative_ppm` may not be below this: the 0.5/√3 µJ every +/// whole-microjoule figure carries from its rounding (WAI energy-measurement §2.3). +const RESOLUTION_FLOOR_UJ_PPM: u128 = 288_676; /// Per-object receipt header that travels with the object payload. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -49,9 +79,33 @@ pub struct ObjectReceipt { pub origin: String, /// Inclusion proof binding `content_hash` to the group's Merkle root. pub merkle_proof: MerkleProof, + /// The figure's acquisition class, in its signed encoding (tag and + /// evidence). Present, the object's leaf is the labelled one, which binds + /// `joules_micro`, this class and `origin`; absent, the legacy one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub energy_class: Option>, } impl ObjectReceipt { + /// The object's Merkle leaf, in the form its label gives. + pub fn leaf(&self) -> [u8; 32] { + match &self.energy_class { + None => hash_leaf(&self.content_hash), + Some(c) => hash_labelled_leaf(&self.content_hash, self.joules_micro, c, &self.origin), + } + } + + /// The label's structural checks: a class this crate can read, tag 0 (the + /// unmetered marker) exactly beside a zero figure, never the legacy + /// HwShunt, and a declared uncertainty at the resolution floor. + pub fn check_label(&self) -> MoqResult<()> { + match &self.energy_class { + None => Ok(()), + Some(c) => check_class(c, self.joules_micro) + .map_err(|e| MoqError::ReceiptVerification(format!("object label: {e}"))), + } + } + /// Compute a properly-domained content hash for an object payload. pub fn hash_payload(payload: &[u8]) -> [u8; 32] { let mut hasher = blake3::Hasher::new(); @@ -81,15 +135,36 @@ pub struct GroupReceipt { pub publisher_id: String, /// Ed25519 signature over [`GroupReceipt::signing_payload`] (must be 64 bytes). pub signature: Vec, + /// The total's acquisition class, in its signed encoding. Present, the + /// group is signed in the labelled form, which covers this class, + /// `energy_coverage` and `parent_receipt_hash`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub energy_class: Option>, + /// The total's coverage in the labelled form: 0 the whole of the work, 1 + /// only the measured parts. Signed only in the labelled form, so a legacy + /// group carries 0 and does not write it; a legacy group read with any + /// other value is refused. + #[serde(default, skip_serializing_if = "is_zero")] + pub energy_coverage: u8, + /// The link hash of the previous group receipt under this publisher key. + /// Signed in the labelled form only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_receipt_hash: Option<[u8; 32]>, } impl GroupReceipt { /// Canonical serialization that gets signed (and re-derived for verification). /// Order: domain || track_alias || group_id || merkle_root || leaf_count - /// || total_joules_micro || group_start || group_end || publisher_key || publisher_id. + /// || total_joules_micro || group_start || group_end || publisher_key || publisher_id, + /// and in the labelled form, under its own domain, then + /// || class || coverage || has_parent || parent_receipt_hash (zeros when none). pub fn signing_payload(&self) -> Vec { let mut buf = Vec::with_capacity(256); - buf.extend_from_slice(GROUP_SIGNING_DOMAIN); + buf.extend_from_slice(if self.energy_class.is_some() { + GROUP_SIGNING_LABELLED_DOMAIN + } else { + GROUP_SIGNING_DOMAIN + }); buf.extend_from_slice(&self.track_alias.0.to_be_bytes()); buf.extend_from_slice(&self.group_id.0.to_be_bytes()); buf.extend_from_slice(&self.merkle_root); @@ -102,8 +177,204 @@ impl GroupReceipt { buf.extend_from_slice(&self.publisher_key); buf.extend_from_slice(&(self.publisher_id.len() as u32).to_be_bytes()); buf.extend_from_slice(self.publisher_id.as_bytes()); + if let Some(c) = &self.energy_class { + buf.extend_from_slice(c); + buf.push(self.energy_coverage); + match self.parent_receipt_hash { + Some(h) => { + buf.push(1); + buf.extend_from_slice(&h); + } + None => { + buf.push(0); + buf.extend_from_slice(&[0u8; 32]); + } + } + } buf } + + /// The group's link hash: `BLAKE3("moq-jwp:receipt-link\x01" ‖ + /// signing_payload ‖ signature)`, its identity for `parent_receipt_hash`. + pub fn link_hash(&self) -> [u8; 32] { + let mut h = blake3::Hasher::new(); + h.update(RECEIPT_LINK_DOMAIN); + h.update(&self.signing_payload()); + h.update(&self.signature); + h.finalize().into() + } + + /// A labelled group's own checks: a class this crate can read, tag 0 + /// exactly beside a zero total and then coverage 0, coverage 0 or 1, no + /// legacy HwShunt, at the resolution floor. A legacy group carries no + /// coverage: its signature does not cover one, so a non-zero value is an + /// unsigned member and is refused. + fn check_label(&self) -> MoqResult<()> { + let e = |m: &str| MoqError::ReceiptVerification(format!("group label: {m}")); + let Some(c) = &self.energy_class else { + return if self.energy_coverage == 0 { Ok(()) } else { Err(e("coverage on a legacy group")) }; + }; + check_class(c, self.total_joules_micro).map_err(|m| e(&m))?; + if self.energy_coverage > 1 || (c[0] == 0 && self.energy_coverage != 0) { + return Err(e("coverage")); + } + Ok(()) + } +} + +fn is_zero(v: &u8) -> bool { + *v == 0 +} + +/// The order of the Ed25519 base point, L, little-endian (RFC 8032 §5.1). +const ED25519_L: [u8; 32] = [ + 0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, 0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, 0x14, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0x10, +]; + +/// Whether the little-endian scalar `s` is below L: RFC 8032 §5.1.7's range +/// check on a signature's `S`. +fn scalar_is_canonical(s: &[u8]) -> bool { + for (a, l) in s.iter().zip(ED25519_L.iter()).rev() { + if a != l { + return a < l; + } + } + false +} + +/// A publisher key under WAI jwp-receipts §3.1 step 3's key rule: it decodes +/// under RFC 8032 §5.1.3 — which refuses a `y` coordinate ≥ p, so the bytes are +/// the point's one encoding — and is not of small order. +fn publisher_key(bytes: &[u8; 32]) -> MoqResult { + let e = |m: &str| MoqError::ReceiptVerification(format!("publisher key: {m}")); + let key = VerifyingKey::from_bytes(bytes).map_err(|x| e(&x.to_string()))?; + if key.to_edwards().compress().to_bytes() != *bytes { + return Err(e("not the canonical encoding of its point")); + } + if key.is_weak() { + return Err(e("of small order")); + } + Ok(key) +} + +/// A class's signed encoding, read: `(tag, relative_ppm, window_us, filtering +/// byte or calibration reference)`. `None` where the bytes are not one class. +fn read_class(c: &[u8]) -> Option<(u8, Option<(u32, u64)>, ClassEvidence)> { + let (&tag, rest) = c.split_first()?; + let be32 = |b: &[u8]| u32::from_be_bytes(b.try_into().expect("4")); + let be64 = |b: &[u8]| u64::from_be_bytes(b.try_into().expect("8")); + match tag { + 0 | 2 | 3 if rest.is_empty() => Some((tag, None, ClassEvidence::None)), + 4 if rest.len() == 13 && rest[12] <= 2 => { + Some((tag, Some((be32(&rest[0..4]), be64(&rest[4..12]))), ClassEvidence::Filtering(rest[12]))) + } + 5 if rest.len() >= 16 && be32(&rest[12..16]) as usize == rest.len() - 16 => { + let r = std::str::from_utf8(&rest[16..]).ok()?; + Some((tag, Some((be32(&rest[0..4]), be64(&rest[4..12]))), ClassEvidence::CalibrationRef(r.to_owned()))) + } + _ => None, + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum ClassEvidence { + None, + Filtering(u8), + CalibrationRef(String), +} + +/// The structural checks a class's signed encoding must pass beside `figure`. +fn check_class(c: &[u8], figure: u64) -> Result<(), String> { + let (tag, u, ev) = read_class(c).ok_or("unreadable class")?; + match tag { + 0 if figure == 0 => return Ok(()), + 0 => return Err("the unmetered marker beside a figure".into()), + _ if figure == 0 => return Err("a class beside a zero figure".into()), + _ => {} + } + if let Some((ppm, window)) = u { + if ppm == 0 || window == 0 { + return Err("an undeclared uncertainty".into()); + } + if (figure as u128) * (ppm as u128) < RESOLUTION_FLOOR_UJ_PPM { + return Err("a declaration below the resolution floor".into()); + } + } + if let ClassEvidence::CalibrationRef(r) = ev { + if r.trim().is_empty() { + return Err("a calibration reference that is blank".into()); + } + } + Ok(()) +} + +/// The class a group's parts compose to (WAI jwp-receipts §2.4.1), as its +/// signed encoding and coverage byte, or `None` where the parts compose to no +/// class: a non-zero part unlabelled, or calibrated instruments under different +/// certificates. Every non-zero part zero gives the unmetered marker. Otherwise +/// the weakest class; for a hardware class the least declaration +/// `⌈Σ F·p / Σ F⌉` over the longest window, an on-chip counter's filtering the +/// parts' common one or Unknown; coverage 1 exactly when a part is zero. +pub fn compose_group_class(parts: &[(u64, Option<&[u8]>)]) -> Option<(Vec, u8)> { + let any_zero = parts.iter().any(|(f, _)| *f == 0); + let mut classes = Vec::new(); + for (f, c) in parts { + if *f == 0 { + continue; + } + let (tag, u, ev) = read_class((*c)?)?; + if tag == 0 || tag == 1 { + return None; + } + classes.push((*f, tag, u, ev)); + } + if classes.is_empty() { + return Some((vec![0], 0)); + } + let coverage = u8::from(any_zero); + // Rank: 3 Estimator < 2 ModelBased < 4 OnChipCounter < 5 CalibratedInstrument. + let rank = |t: u8| match t { + 3 => 1, + 2 => 2, + 4 => 4, + _ => 5, + }; + let weakest = classes.iter().map(|c| c.1).min_by_key(|t| rank(*t))?; + if weakest == 2 || weakest == 3 { + return Some((vec![weakest], coverage)); + } + let (mut num, mut den, mut window) = (0u128, 0u128, 0u64); + for (f, _, u, _) in &classes { + let (ppm, w) = (*u)?; + num = num.checked_add((*f as u128).checked_mul(ppm as u128)?)?; + den = den.checked_add(*f as u128)?; + window = window.max(w); + } + let ppm = u32::try_from(num.div_ceil(den)).ok()?; + let mut out = vec![weakest]; + out.extend_from_slice(&ppm.to_be_bytes()); + out.extend_from_slice(&window.to_be_bytes()); + if weakest == 4 { + let mut fs = classes.iter().filter_map(|c| match c.3 { + ClassEvidence::Filtering(f) => Some(f), + _ => None, + }); + let first = fs.next()?; + out.push(if fs.all(|f| f == first) { first } else { 0 }); + } else { + let mut refs = classes.iter().map(|c| match &c.3 { + ClassEvidence::CalibrationRef(r) => r.as_str(), + _ => "", + }); + let first = refs.next()?; + if !refs.all(|r| r == first) { + return None; + } + out.extend_from_slice(&(first.len() as u32).to_be_bytes()); + out.extend_from_slice(first.as_bytes()); + } + Some((out, coverage)) } /// Merkle inclusion proof: list of sibling hashes from leaf to root, @@ -117,9 +388,26 @@ pub struct MerkleProof { } impl MerkleProof { - /// Verify that `leaf_hash` hashes up to `expected_root` along this proof. - pub fn verify(&self, leaf_hash: &[u8; 32], expected_root: &[u8; 32]) -> bool { - let mut current = hash_leaf(leaf_hash); + /// Whether the proof walks position `leaf_index` of a group of `leaf_count` + /// leaves: the index is inside the group, the proof has the group's depth + /// (⌈log₂ leaf_count⌉), and the direction bits are the index's bits and no + /// others. Without it the duplicated last leaf of an odd level walks two + /// positions, and an object can be presented at another's index. + pub fn walks_its_position(&self, leaf_count: u64) -> bool { + let depth = if leaf_count <= 1 { 0 } else { 64 - (leaf_count - 1).leading_zeros() }; + self.leaf_index < leaf_count + && self.siblings.len() as u64 == depth as u64 + && self.direction_bits == self.leaf_index + } + + /// Verify that `leaf` — an object's leaf, in its form ([`ObjectReceipt::leaf`]) + /// — hashes up to `expected_root` at this proof's position in a group of + /// `leaf_count` leaves. + pub fn verify_leaf(&self, leaf: &[u8; 32], expected_root: &[u8; 32], leaf_count: u64) -> bool { + if !self.walks_its_position(leaf_count) { + return false; + } + let mut current = *leaf; for (level, sibling) in self.siblings.iter().enumerate() { let is_right = (self.direction_bits >> level) & 1 == 1; current = if is_right { @@ -140,6 +428,18 @@ fn hash_leaf(content_hash: &[u8; 32]) -> [u8; 32] { hasher.finalize().into() } +/// Hash an object's labelled leaf: its content hash, figure, class and origin. +fn hash_labelled_leaf(content_hash: &[u8; 32], joules_micro: u64, class: &[u8], origin: &str) -> [u8; 32] { + let mut hasher = blake3::Hasher::new(); + hasher.update(MERKLE_LEAF_LABELLED_DOMAIN); + hasher.update(content_hash); + hasher.update(&joules_micro.to_be_bytes()); + hasher.update(class); + hasher.update(&(origin.len() as u32).to_be_bytes()); + hasher.update(origin.as_bytes()); + hasher.finalize().into() +} + /// Hash an internal Merkle node (left, right children). fn hash_node(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { let mut hasher = blake3::Hasher::new(); @@ -154,9 +454,11 @@ fn hash_node(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { pub struct ReceiptBuilder { track_alias: TrackAlias, group_id: GroupId, + /// Each object's leaf, already in its form. leaves: Vec<[u8; 32]>, joules_per_object: Vec, origins: Vec, + classes: Vec>>, started_at: Option, } @@ -168,6 +470,7 @@ impl ReceiptBuilder { leaves: Vec::new(), joules_per_object: Vec::new(), origins: Vec::new(), + classes: Vec::new(), started_at: None, } } @@ -185,12 +488,42 @@ impl ReceiptBuilder { if self.started_at.is_none() { self.started_at = Some(timestamp.into()); } - self.leaves.push(content_hash); + self.leaves.push(hash_leaf(&content_hash)); self.joules_per_object.push(joules_micro); self.origins.push(origin.into()); + self.classes.push(None); id } + /// Record an object with its figure's acquisition class, in its signed + /// encoding: the object's leaf is the labelled one, binding the figure, the + /// class and the origin. Refuses a class the verifier would refuse. + pub fn add_labelled_object( + &mut self, + content_hash: [u8; 32], + joules_micro: u64, + origin: impl Into, + timestamp: impl Into, + class: Vec, + ) -> MoqResult { + check_class(&class, joules_micro).map_err(MoqError::ReceiptVerification)?; + let origin = origin.into(); + let id = ObjectId(self.leaves.len() as u64); + if self.started_at.is_none() { + self.started_at = Some(timestamp.into()); + } + self.leaves.push(hash_labelled_leaf(&content_hash, joules_micro, &class, &origin)); + self.joules_per_object.push(joules_micro); + self.origins.push(origin); + self.classes.push(Some(class)); + Ok(id) + } + + /// The class object `index` was recorded with, for its receipt. + pub fn class_for(&self, index: usize) -> Option> { + self.classes.get(index).cloned().flatten() + } + /// Number of objects accumulated so far. pub fn len(&self) -> usize { self.leaves.len() @@ -222,6 +555,28 @@ impl ReceiptBuilder { Ok(compute_merkle_proof(&self.leaves, index)) } + /// Finalize and sign the group in the labelled form: the total's class is + /// the one the objects compose to ([`compose_group_class`]), signed with its + /// coverage and `parent`. Refuses a group whose objects compose to no class. + pub fn finalize_labelled( + self, + signing_key: &SigningKey, + publisher_id: impl Into, + group_end: impl Into, + parent: Option<[u8; 32]>, + ) -> MoqResult { + let parts: Vec<(u64, Option<&[u8]>)> = + self.joules_per_object.iter().zip(&self.classes).map(|(f, c)| (*f, c.as_deref())).collect(); + let (class, coverage) = compose_group_class(&parts) + .ok_or_else(|| MoqError::ReceiptVerification("the objects compose to no class".into()))?; + let mut receipt = self.finalize(signing_key, publisher_id, group_end); + receipt.energy_class = Some(class); + receipt.energy_coverage = coverage; + receipt.parent_receipt_hash = parent; + receipt.signature = signing_key.sign(&receipt.signing_payload()).to_bytes().to_vec(); + Ok(receipt) + } + /// Finalize and sign the group with the publisher's Ed25519 key. pub fn finalize( self, @@ -245,6 +600,9 @@ impl ReceiptBuilder { publisher_key, publisher_id: publisher_id.into(), signature: Vec::new(), + energy_class: None, + energy_coverage: 0, + parent_receipt_hash: None, }; let payload = receipt.signing_payload(); let sig: Signature = signing_key.sign(&payload); @@ -286,11 +644,18 @@ impl ReceiptVerifier { } } - /// Verify a group receipt's signature. Returns the verified Merkle root - /// on success. - pub fn verify_group(&self, receipt: &GroupReceipt) -> MoqResult<[u8; 32]> { - let publisher_key = VerifyingKey::from_bytes(&receipt.publisher_key) - .map_err(|e| MoqError::ReceiptVerification(format!("publisher key: {}", e)))?; + /// Verify a group receipt's signature, in either form, and a labelled + /// group's own label. Returns the verified root with the position facts an + /// object is checked against. + /// + /// The key and signature are held to the rule WAI's jwp-receipts §3.1 + /// step 3 holds every receipt signature to: the key is the canonical + /// encoding of a point not of small order; `S` < L, checked here as well + /// as inside `verify_strict`, whose own range check weakens when any crate + /// in a build enables ed25519-dalek's `legacy_compatibility`; `R` not of + /// small order; and the cofactorless equation. + pub fn verify_group(&self, receipt: &GroupReceipt) -> MoqResult { + let publisher_key = publisher_key(&receipt.publisher_key)?; if self.require_known_publisher { if !self.trust_anchors.contains(&publisher_key) { return Err(MoqError::ReceiptVerification( @@ -306,33 +671,48 @@ impl ReceiptVerifier { } let mut sig_bytes = [0u8; 64]; sig_bytes.copy_from_slice(&receipt.signature); + if !scalar_is_canonical(&sig_bytes[32..]) { + return Err(MoqError::ReceiptVerification("signature: S is not below L".into())); + } let payload = receipt.signing_payload(); let signature = Signature::from_bytes(&sig_bytes); publisher_key - .verify(&payload, &signature) + .verify_strict(&payload, &signature) .map_err(|e| MoqError::ReceiptVerification(format!("signature: {}", e)))?; - Ok(receipt.merkle_root) + receipt.check_label()?; + Ok(VerifiedGroup { + root: receipt.merkle_root, + leaf_count: receipt.leaf_count, + track_alias: receipt.track_alias, + group_id: receipt.group_id, + }) } - /// Verify a per-object receipt against a previously-verified group root. - pub fn verify_object( - &self, - receipt: &ObjectReceipt, - verified_root: &[u8; 32], - ) -> MoqResult<()> { - if !receipt.merkle_proof.verify(&receipt.content_hash, verified_root) { + /// Verify a per-object receipt against a verified group: its label, its + /// position (its coordinates are the group's, `object_id` is its leaf + /// index, and its proof walks that index), and its leaf — in its form — + /// up to the verified root. + pub fn verify_object(&self, receipt: &ObjectReceipt, group: &VerifiedGroup) -> MoqResult<()> { + receipt.check_label()?; + if receipt.track_alias != group.track_alias + || receipt.group_id != group.group_id + || receipt.object_id.0 != receipt.merkle_proof.leaf_index + { + return Err(MoqError::MerkleProofInvalid); + } + if !receipt.merkle_proof.verify_leaf(&receipt.leaf(), &group.root, group.leaf_count) { return Err(MoqError::MerkleProofInvalid); } Ok(()) } /// Convenience: verify a payload directly against an object receipt and - /// a verified group root. Recomputes the content hash from `payload`. + /// a verified group. Recomputes the content hash from `payload`. pub fn verify_payload( &self, payload: &[u8], receipt: &ObjectReceipt, - verified_root: &[u8; 32], + verified_root: &VerifiedGroup, ) -> MoqResult<()> { let computed = ObjectReceipt::hash_payload(payload); if computed != receipt.content_hash { @@ -344,6 +724,16 @@ impl ReceiptVerifier { } } +/// What [`ReceiptVerifier::verify_group`] establishes: the signed root, and the +/// group's size and coordinates an object's position is checked against. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VerifiedGroup { + pub root: [u8; 32], + pub leaf_count: u64, + pub track_alias: TrackAlias, + pub group_id: GroupId, +} + impl Default for ReceiptVerifier { fn default() -> Self { Self::new() @@ -352,11 +742,12 @@ impl Default for ReceiptVerifier { // ── Merkle tree helpers ── +/// The root over leaves already hashed (in their forms). fn compute_merkle_root(leaves: &[[u8; 32]]) -> [u8; 32] { if leaves.is_empty() { return [0u8; 32]; } - let mut level: Vec<[u8; 32]> = leaves.iter().map(hash_leaf).collect(); + let mut level: Vec<[u8; 32]> = leaves.to_vec(); while level.len() > 1 { if level.len() % 2 == 1 { level.push(*level.last().unwrap()); @@ -370,10 +761,11 @@ fn compute_merkle_root(leaves: &[[u8; 32]]) -> [u8; 32] { level[0] } +/// The proof for leaf `leaf_index` over leaves already hashed. fn compute_merkle_proof(leaves: &[[u8; 32]], leaf_index: usize) -> MerkleProof { let mut siblings = Vec::new(); let mut direction_bits: u64 = 0; - let mut level: Vec<[u8; 32]> = leaves.iter().map(hash_leaf).collect(); + let mut level: Vec<[u8; 32]> = leaves.to_vec(); let mut idx = leaf_index; let mut bit_pos = 0; while level.len() > 1 { @@ -414,24 +806,20 @@ mod tests { #[test] fn merkle_proof_verifies_for_each_leaf() { - let leaves = vec![h(1), h(2), h(3), h(4), h(5)]; + let leaves: Vec<[u8; 32]> = [h(1), h(2), h(3), h(4), h(5)].iter().map(hash_leaf).collect(); let root = compute_merkle_root(&leaves); for (i, leaf) in leaves.iter().enumerate() { let proof = compute_merkle_proof(&leaves, i); - assert!( - proof.verify(leaf, &root), - "proof for leaf {} should verify", - i - ); + assert!(proof.verify_leaf(leaf, &root, 5), "proof for leaf {} should verify", i); } } #[test] fn merkle_proof_rejects_wrong_leaf() { - let leaves = vec![h(1), h(2), h(3), h(4)]; + let leaves: Vec<[u8; 32]> = [h(1), h(2), h(3), h(4)].iter().map(hash_leaf).collect(); let root = compute_merkle_root(&leaves); let proof = compute_merkle_proof(&leaves, 0); - assert!(!proof.verify(&h(99), &root)); + assert!(!proof.verify_leaf(&hash_leaf(&h(99)), &root, 4)); } #[test] @@ -462,6 +850,7 @@ mod tests { joules_micro: 150, origin: "joule-cloud-us-west-2".into(), merkle_proof: proof_for_1, + energy_class: None, }; verifier.verify_payload(b"frame-2", &object_receipt, &root).unwrap(); } @@ -513,6 +902,7 @@ mod tests { joules_micro: 1, origin: "dc1".into(), merkle_proof: proof, + energy_class: None, }; // Verify against a *different* payload — must fail. assert!(matches!( @@ -520,4 +910,251 @@ mod tests { Err(MoqError::ReceiptVerification(_)) )); } + + // ── the labelled forms, pinned to WAI's jwp-conformance corpus ── + // + // The literals are copied from `wai/jwp-conformance/vectors.json`, which + // WAI's reference implementation generates and an independent script + // verifies. Both implementations must give the same leaves, root and + // signatures for the same inputs. + + const T0: &str = "2026-09-30T12:00:00Z"; + const T1: &str = "2026-09-30T12:00:01Z"; + const CAL_REF: &str = "https://calibration.example.org/certificates/0001"; + + fn hx(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() + } + + fn envelope(n: u8) -> Vec { + format!("WAI1 jwp-conformance object {n}").into_bytes() + } + + /// An OnChipCounter class: ppm, window, filtering Unknown. + fn chip(ppm: u32, window_us: u64) -> Vec { + let mut c = vec![4]; + c.extend_from_slice(&ppm.to_be_bytes()); + c.extend_from_slice(&window_us.to_be_bytes()); + c.push(0); + c + } + + fn cal(ppm: u32, window_us: u64, r: &str) -> Vec { + let mut c = vec![5]; + c.extend_from_slice(&ppm.to_be_bytes()); + c.extend_from_slice(&window_us.to_be_bytes()); + c.extend_from_slice(&(r.len() as u32).to_be_bytes()); + c.extend_from_slice(r.as_bytes()); + c + } + + /// The corpus's mixed group: a counter, an instrument, a legacy object, an + /// object explicitly unmetered and a model. + fn mixed() -> (ReceiptBuilder, Vec) { + let parts: [(u64, Option>); 5] = + [(1_200, Some(chip(20_000, 1_000_000))), (800, Some(cal(5_000, 2_000_000, CAL_REF))), (500, None), (0, Some(vec![0])), (300, Some(vec![2]))]; + build(0, 3, &parts) + } + + fn build(first: u8, group: u64, parts: &[(u64, Option>)]) -> (ReceiptBuilder, Vec) { + let mut b = ReceiptBuilder::new(TrackAlias(7), GroupId(group)); + for (i, (j, c)) in parts.iter().enumerate() { + let n = first + i as u8; + let h = ObjectReceipt::hash_payload(&envelope(n)); + let origin = format!("dc-{n}.example"); + match c { + None => { + b.add_object(h, *j, origin, T0); + } + Some(c) => { + b.add_labelled_object(h, *j, origin, T0, c.clone()).unwrap(); + } + } + } + let objects = parts + .iter() + .enumerate() + .map(|(i, (j, c))| { + let n = first + i as u8; + ObjectReceipt { + track_alias: TrackAlias(7), + group_id: GroupId(group), + object_id: ObjectId(i as u64), + content_hash: ObjectReceipt::hash_payload(&envelope(n)), + joules_micro: *j, + origin: format!("dc-{n}.example"), + merkle_proof: b.proof_for(i).unwrap(), + energy_class: c.clone(), + } + }) + .collect(); + (b, objects) + } + + #[test] + fn leaves_match_the_wai_corpus() { + let (_, o) = mixed(); + assert_eq!(hx(&o[2].leaf()), "65dbe4a732e01bd69de3104e03d9822107318551714d055c8a7acc45313796e8", "legacy"); + assert_eq!(hx(&o[3].leaf()), "339ea521d795afe4a4ec26042a2827fc9e8052b581fdbb1636b2315ee9953e93", "tag 0"); + assert_eq!(hx(&o[0].leaf()), "eeb5d0cf357a3bca9437eff04cf82570a6338652c842d9a20a7670f409a80760", "tag 4"); + assert_eq!(hx(&o[1].leaf()), "281de97b5d3b5f872298b30812bf65f6a14d2cb223c135d17034e19e20988c9f", "tag 5"); + assert_eq!(hx(&o[4].leaf()), "3049a2823be995b9ef123c82675c47e2df96a77b50be6a71d676246325d46a76", "tag 2"); + } + + #[test] + fn groups_match_the_wai_corpus() { + let key = test_signing_key(0x11); + let (b, objects) = mixed(); + let legacy = b.finalize(&key, "did:example:publisher", T1); + assert_eq!(hx(&legacy.merkle_root), "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d"); + assert_eq!( + hx(&legacy.signature), + "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a" + ); + assert_eq!(hx(&legacy.link_hash()), "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5"); + let verifier = ReceiptVerifier::new(); + let g = verifier.verify_group(&legacy).unwrap(); + for (i, o) in objects.iter().enumerate() { + verifier.verify_payload(&envelope(i as u8), o, &g).unwrap(); + } + + let parts = [(1_200, Some(chip(20_000, 1_000_000))), (800, Some(cal(5_000, 2_000_000, CAL_REF))), (0, Some(vec![0]))]; + let (b, objects) = build(10, 4, &parts); + let labelled = b.finalize_labelled(&key, "did:example:publisher", T1, Some(legacy.link_hash())).unwrap(); + // OnChipCounter, ⌈(1200·20000 + 800·5000) / 2000⌉ = 14000 ppm over 2 s; Partial. + assert_eq!(labelled.energy_class.as_deref(), Some(chip(14_000, 2_000_000).as_slice())); + assert_eq!(labelled.energy_coverage, 1); + assert_eq!(hx(&labelled.merkle_root), "5a8870c49a7ce232b1a4cd4bced3b0ee70b33535bcb8f9f563f617279ae9ce29"); + assert_eq!( + hx(&labelled.signature), + "cd89ea4722e276b4458604faaf7d92a2fb1286c69a62dde021dfaaed9d6fc2975150865e71dffa91526ca52e2844a74197dc87102a5f489dcf32dc3394fd6509" + ); + assert_eq!(hx(&labelled.link_hash()), "9c49baff6e40a5d24222e49679dad204f036e356b402449a4047e9bafcdaa7b7"); + let g = verifier.verify_group(&labelled).unwrap(); + for (i, o) in objects.iter().enumerate() { + verifier.verify_payload(&envelope(10 + i as u8), o, &g).unwrap(); + } + } + + #[test] + fn a_label_or_the_origin_cannot_be_restated_stripped_or_added() { + let (b, objects) = mixed(); + let receipt = b.finalize(&test_signing_key(0x11), "p", T1); + let verifier = ReceiptVerifier::new(); + let g = verifier.verify_group(&receipt).unwrap(); + let fails = |o: &ObjectReceipt, n: u8| verifier.verify_payload(&envelope(n), o, &g).is_err(); + let mut t = objects[0].clone(); + t.energy_class = Some(chip(19_000, 1_000_000)); + assert!(fails(&t, 0), "restated"); + let mut t = objects[0].clone(); + t.energy_class = None; + assert!(fails(&t, 0), "stripped"); + let mut t = objects[2].clone(); + t.energy_class = Some(vec![2]); + assert!(fails(&t, 2), "added"); + let mut t = objects[3].clone(); + t.origin = "elsewhere.example".into(); + assert!(fails(&t, 3), "origin altered"); + let mut t = objects[1].clone(); + t.joules_micro += 1; + assert!(fails(&t, 1), "figure altered"); + // The legacy leaf binds neither. + let mut t = objects[2].clone(); + t.joules_micro = 9_999; + t.origin = "elsewhere.example".into(); + assert!(!fails(&t, 2)); + } + + #[test] + fn an_object_must_be_at_its_position() { + let mut b = ReceiptBuilder::new(TrackAlias(0), GroupId(0)); + for i in 0..3u8 { + b.add_object(h(i), 1, "dc1", "t"); + } + let proofs: Vec<_> = (0..3).map(|i| b.proof_for(i).unwrap()).collect(); + let receipt = b.finalize(&test_signing_key(0x05), "p", "t"); + let verifier = ReceiptVerifier::new(); + let g = verifier.verify_group(&receipt).unwrap(); + let obj = |i: u64, proof: MerkleProof, content: [u8; 32]| ObjectReceipt { + track_alias: TrackAlias(0), + group_id: GroupId(0), + object_id: ObjectId(i), + content_hash: content, + joules_micro: 1, + origin: "dc1".into(), + merkle_proof: proof, + energy_class: None, + }; + verifier.verify_object(&obj(2, proofs[2].clone(), h(2)), &g).unwrap(); + // The duplicated last leaf of the odd level, presented past the end. + let mut past = proofs[2].clone(); + past.leaf_index = 3; + past.direction_bits = 3; + assert!(verifier.verify_object(&obj(3, past, h(2)), &g).is_err()); + // An object at its neighbour's index, with its own proof. + let mut moved = proofs[0].clone(); + moved.leaf_index = 1; + assert!(verifier.verify_object(&obj(1, moved, h(0)), &g).is_err()); + // object_id other than the index. + assert!(verifier.verify_object(&obj(0, proofs[1].clone(), h(1)), &g).is_err()); + } + + #[test] + fn labels_that_break_the_rules_are_refused() { + let h0 = h(1); + let mut b = ReceiptBuilder::new(TrackAlias(0), GroupId(0)); + assert!(b.add_labelled_object(h0, 100, "o", "t", vec![1]).is_err(), "legacy HwShunt"); + assert!(b.add_labelled_object(h0, 0, "o", "t", vec![2]).is_err(), "a class beside zero"); + assert!(b.add_labelled_object(h0, 5, "o", "t", vec![0]).is_err(), "the unmetered marker beside a figure"); + assert!(b.add_labelled_object(h0, 1, "o", "t", chip(288_675, 1)).is_err(), "below the resolution floor"); + assert!(b.add_labelled_object(h0, 1, "o", "t", vec![4, 0]).is_err(), "unreadable evidence"); + assert!(b.add_labelled_object(h0, 1, "o", "t", chip(288_676, 1)).is_ok()); + // A labelled group whose parts compose to no class is refused. + let mut b = ReceiptBuilder::new(TrackAlias(0), GroupId(0)); + b.add_labelled_object(h(1), 10, "o", "t", vec![2]).unwrap(); + b.add_object(h(2), 10, "o", "t"); + assert!(b.finalize_labelled(&test_signing_key(0x05), "p", "t", None).is_err()); + assert_eq!(compose_group_class(&[(1, Some(&cal(300_000, 1, "a")[..])), (1, Some(&cal(300_000, 1, "b")[..]))]), None); + } + + #[test] + fn a_labelled_group_signs_its_class_coverage_and_parent() { + let key = test_signing_key(0x11); + let mut b = ReceiptBuilder::new(TrackAlias(0), GroupId(0)); + b.add_labelled_object(h(1), 100, "o", "t", vec![2]).unwrap(); + let g = b.finalize_labelled(&key, "p", "t", Some([3; 32])).unwrap(); + let verifier = ReceiptVerifier::new(); + verifier.verify_group(&g).unwrap(); + let mut t = g.clone(); + t.energy_class = Some(vec![3]); + assert!(verifier.verify_group(&t).is_err(), "class"); + let mut t = g.clone(); + t.energy_coverage = 1; + assert!(verifier.verify_group(&t).is_err(), "coverage"); + let mut t = g.clone(); + t.parent_receipt_hash = None; + assert!(verifier.verify_group(&t).is_err(), "parent"); + // A verifier that predates the labelled form computes the legacy + // payload, and refuses the signature. + let mut legacy = g.clone(); + legacy.energy_class = None; + assert!(verifier.verify_group(&legacy).is_err()); + // The JSON of a legacy receipt is unchanged: the new members are omitted. + let (b, _) = build(0, 3, &[(5, None)]); + let r = b.finalize(&key, "p", T1); + let j = serde_json::to_string(&r).unwrap(); + assert!( + !j.contains("energy_class") && !j.contains("energy_coverage") && !j.contains("parent_receipt_hash"), + "{j}" + ); + assert_eq!(serde_json::from_str::(&j).unwrap(), r, "and reads back"); + // A legacy group's signature covers no coverage: a value beside it is refused. + let mut t = r.clone(); + t.energy_coverage = 1; + assert!(verifier.verify_group(&r).is_ok()); + assert!(verifier.verify_group(&t).is_err(), "coverage on a legacy group"); + let jt = serde_json::to_string(&t).unwrap(); + assert!(jt.contains("\"energy_coverage\":1"), "{jt}"); + assert!(verifier.verify_group(&serde_json::from_str(&jt).unwrap()).is_err()); + } } diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/transport_quic.rs b/joule-code/joule-code-rs/crates/joule-moq/src/transport_quic.rs index d25e6cae0..25dbe2e0a 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/transport_quic.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/transport_quic.rs @@ -684,6 +684,7 @@ mod tests { joules_micro: joules, origin: origin.to_string(), merkle_proof: proof, + energy_class: None, }; write_object_to_uni(&mut uni, &header, Some(&receipt), &payload) .await diff --git a/joule-code/joule-code-rs/crates/joule-moq/tests/wai_jwp_corpus.rs b/joule-code/joule-code-rs/crates/joule-moq/tests/wai_jwp_corpus.rs new file mode 100644 index 000000000..933755bd6 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/tests/wai_jwp_corpus.rs @@ -0,0 +1,317 @@ +//! joule-moq against every leaf, group and object vector of WAI's JWP corpus +//! (`wai/jwp-conformance/vectors.json`, jwp-receipts §5.7). +//! +//! The corpus is generated by WAI's reference implementation and checked by an +//! independent script; this file reads it as a third implementation. Each +//! vector's receipt is read from the corpus's JSON form into this crate's +//! types, and the outcome compared: +//! +//! - a `leaf` vector: the leaf this crate computes; +//! - a `group` vector: accepted with the corpus's signing payload and link hash, +//! or refused where the corpus refuses it at §3 step 1 or 2. Step 3 (the +//! parent chain) is the holder's, not this crate's, so a group the corpus +//! refuses there is one this crate accepts; +//! - an `object` vector: accepted against its verified group with its envelope, +//! or refused at any step. +//! +//! A refusal while reading the JSON counts as a refusal: it is where the corpus +//! refuses an unreadable member (§3 step 1, step 4 for an object). +//! +//! The corpus lives beside this crate in the monorepo. If it cannot be read the +//! test fails; it never passes on nothing. + +use joule_moq::{GroupId, GroupReceipt, MerkleProof, ObjectId, ObjectReceipt, ReceiptVerifier, TrackAlias}; +use serde_json::{Map, Value}; + +const CORPUS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../../../wai/jwp-conformance/vectors.json"); + +const CLASS_KEYS: [&str; 4] = ["energy_provenance", "energy_uncertainty", "energy_counter_filtering", "energy_calibration_ref"]; +const GROUP_MEMBERS: [&str; 12] = [ + "kind", + "track_alias", + "group_id", + "root_hash", + "leaf_count", + "joules_total", + "group_start", + "group_end", + "signer_pubkey", + "signer_id", + "sig", + "parent_receipt_hash", +]; +const OBJECT_MEMBERS: [&str; 8] = + ["kind", "track_alias", "group_id", "object_id", "content_hash", "joules_micro", "origin", "merkle_proof"]; + +fn unhex(v: &Value) -> Option> { + let s = v.as_str()?; + if s.len() % 2 != 0 || !s.bytes().all(|c| matches!(c, b'0'..=b'9' | b'a'..=b'f')) { + return None; + } + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect() +} + +fn hash(v: &Value) -> Option<[u8; 32]> { + unhex(v)?.try_into().ok() +} + +fn hx(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +fn present(m: &Map, k: &str) -> bool { + !matches!(m.get(k), None | Some(Value::Null)) +} + +/// A class in energy-measurement §2.7's JSON form, as its signed encoding: the +/// tag and its evidence. Tag 0 (the unmetered marker) where no class is +/// named. `None` where the members are not one class. +fn class_bytes(m: &Map) -> Option> { + if !present(m, "energy_provenance") { + return (!CLASS_KEYS.iter().any(|k| present(m, k))).then(|| vec![0]); + } + let uncertainty = |out: &mut Vec| -> Option<()> { + let u = m.get("energy_uncertainty")?.as_object()?; + if u.len() != 2 { + return None; + } + let ppm = u32::try_from(u.get("relative_ppm")?.as_u64()?).ok()?; + let window = u.get("window_us")?.as_u64()?; + out.extend_from_slice(&ppm.to_be_bytes()); + out.extend_from_slice(&window.to_be_bytes()); + Some(()) + }; + match m.get("energy_provenance")?.as_str()? { + label @ ("HwShunt" | "ModelBased" | "Estimator") => { + if ["energy_uncertainty", "energy_counter_filtering", "energy_calibration_ref"].iter().any(|k| present(m, k)) { + return None; + } + Some(vec![match label { + "HwShunt" => 1, + "ModelBased" => 2, + _ => 3, + }]) + } + "OnChipCounter" => { + if present(m, "energy_calibration_ref") { + return None; + } + let mut out = vec![4]; + uncertainty(&mut out)?; + out.push(match m.get("energy_counter_filtering") { + None | Some(Value::Null) => 0, + Some(f) => match f.as_str()? { + "Unknown" => 0, + "Off" => 1, + "On" => 2, + _ => return None, + }, + }); + Some(out) + } + "CalibratedInstrument" => { + if present(m, "energy_counter_filtering") { + return None; + } + let mut out = vec![5]; + uncertainty(&mut out)?; + let r = m.get("energy_calibration_ref")?.as_str()?; + out.extend_from_slice(&(r.len() as u32).to_be_bytes()); + out.extend_from_slice(r.as_bytes()); + Some(out) + } + _ => None, + } +} + +/// The corpus's group JSON as a [`GroupReceipt`], or `None` where its reading +/// refuses it (§3 step 1). +fn group_of(v: &Value) -> Option { + let m = v.as_object()?; + if m.get("kind")?.as_str()? != "group" { + return None; + } + let label_members: Vec<&str> = std::iter::once("sign_version").chain(CLASS_KEYS).chain(["energy_coverage"]).collect(); + if GROUP_MEMBERS.iter().any(|k| !m.contains_key(*k)) + || m.keys().any(|k| !GROUP_MEMBERS.contains(&k.as_str()) && !label_members.contains(&k.as_str())) + { + return None; + } + let s = |k: &str| m.get(k).and_then(Value::as_str).map(str::to_owned); + let u = |k: &str| m.get(k).and_then(Value::as_u64); + let signature = unhex(m.get("sig")?)?; + if signature.len() != 64 { + return None; + } + let (energy_class, energy_coverage) = match m.get("sign_version") { + None | Some(Value::Null) => { + if label_members.iter().any(|k| present(m, k)) { + return None; + } + (None, 0) + } + Some(Value::Number(n)) if n.as_u64() == Some(2) => { + let c = class_bytes(m)?; + let coverage = match (c[0], m.get("energy_coverage")) { + (0, None | Some(Value::Null)) => 0, + (0, _) => return None, + (_, Some(Value::String(c))) if c == "Whole" => 0, + (_, Some(Value::String(c))) if c == "Partial" => 1, + _ => return None, + }; + (Some(c), coverage) + } + Some(_) => return None, + }; + Some(GroupReceipt { + track_alias: TrackAlias(u("track_alias")?), + group_id: GroupId(u("group_id")?), + merkle_root: hash(m.get("root_hash")?)?, + leaf_count: u("leaf_count")?, + total_joules_micro: u("joules_total")?, + group_start: s("group_start")?, + group_end: s("group_end")?, + publisher_key: hash(m.get("signer_pubkey")?)?, + publisher_id: s("signer_id")?, + signature, + energy_class, + energy_coverage, + parent_receipt_hash: match m.get("parent_receipt_hash")? { + Value::Null => None, + h => Some(hash(h)?), + }, + }) +} + +/// The corpus's object JSON as an [`ObjectReceipt`], or `None` where its +/// reading refuses it (§3 steps 4–5). +fn object_of(v: &Value) -> Option { + let m = v.as_object()?; + if m.get("kind")?.as_str()? != "object" { + return None; + } + let label_members: Vec<&str> = std::iter::once("leaf_version").chain(CLASS_KEYS).collect(); + if OBJECT_MEMBERS.iter().any(|k| !m.contains_key(*k)) + || m.keys().any(|k| !OBJECT_MEMBERS.contains(&k.as_str()) && !label_members.contains(&k.as_str())) + { + return None; + } + let energy_class = match m.get("leaf_version") { + None | Some(Value::Null) => { + if CLASS_KEYS.iter().any(|k| present(m, k)) { + return None; + } + None + } + Some(Value::Number(n)) if n.as_u64() == Some(2) => Some(class_bytes(m)?), + Some(_) => return None, + }; + let p = m.get("merkle_proof")?.as_object()?; + if p.len() != 3 { + return None; + } + Some(ObjectReceipt { + track_alias: TrackAlias(m.get("track_alias")?.as_u64()?), + group_id: GroupId(m.get("group_id")?.as_u64()?), + object_id: ObjectId(m.get("object_id")?.as_u64()?), + content_hash: hash(m.get("content_hash")?)?, + joules_micro: m.get("joules_micro")?.as_u64()?, + origin: m.get("origin")?.as_str()?.to_owned(), + merkle_proof: MerkleProof { + siblings: p.get("siblings")?.as_array()?.iter().map(hash).collect::>>()?, + direction_bits: p.get("direction_bits")?.as_u64()?, + leaf_index: p.get("leaf_index")?.as_u64()?, + }, + energy_class, + }) +} + +#[test] +fn every_leaf_group_and_object_vector_of_the_wai_corpus() { + let text = std::fs::read_to_string(CORPUS).unwrap_or_else(|e| panic!("read {CORPUS}: {e}")); + let doc: Value = serde_json::from_str(&text).expect("the corpus is JSON"); + let vectors = doc["vectors"].as_array().expect("vectors"); + let verifier = ReceiptVerifier::new(); + let (mut ran, mut failures) = (std::collections::BTreeMap::<&str, usize>::new(), Vec::new()); + for v in vectors { + let name = v["name"].as_str().expect("name"); + let procedure = v["procedure"].as_str().expect("procedure"); + let input = &v["input"]; + let expected = &v["expected"]; + let accepts = expected["outcome"] == "accept"; + let outcome: Result<(), String> = match procedure { + "leaf" => { + let got = object_of(&input["object"]).map(|o| hx(&o.leaf())); + let want = expected["leaf"].as_str().map(str::to_owned); + if got == want { Ok(()) } else { Err(format!("leaf {got:?}, the corpus {want:?}")) } + } + "group" => { + let step = expected["step"].as_u64(); + let verified = group_of(&input["group"]).ok_or("unreadable".to_string()).and_then(|g| { + verifier.verify_group(&g).map(|_| g).map_err(|e| e.to_string()) + }); + match (accepts || step == Some(3), verified) { + (true, Ok(g)) if accepts => { + let pl = hx(&g.signing_payload()); + let lh = hx(&g.link_hash()); + if Some(pl.as_str()) != expected["signing_payload"].as_str() { + Err(format!("signing payload {pl}")) + } else if Some(lh.as_str()) != expected["link_hash"].as_str() { + Err(format!("link hash {lh}")) + } else { + Ok(()) + } + } + (true, Ok(_)) => Ok(()), + (true, Err(e)) => Err(format!("refused ({e}); the corpus passes steps 1-2")), + (false, Err(_)) => Ok(()), + (false, Ok(_)) => Err(format!("accepted; the corpus refuses at step {step:?}")), + } + } + "object" => { + let envelope = unhex(&input["envelope"]).expect("envelope hex"); + let r = group_of(&input["group"]) + .ok_or("group unreadable".to_string()) + .and_then(|g| verifier.verify_group(&g).map_err(|e| e.to_string())) + .and_then(|vg| { + let o = object_of(&input["object"]).ok_or("object unreadable".to_string())?; + verifier.verify_payload(&envelope, &o, &vg).map_err(|e| e.to_string()) + }); + match (accepts, r) { + (true, Ok(())) | (false, Err(_)) => Ok(()), + (true, Err(e)) => Err(format!("refused ({e}); the corpus accepts")), + (false, Ok(())) => Err(format!("accepted; the corpus refuses: {expected}")), + } + } + // The group total and the WAI_RECEIPT property are readings this crate + // does not give. + _ => continue, + }; + *ran.entry(procedure).or_default() += 1; + if let Err(e) = outcome { + failures.push(format!("{name}: {e}")); + } + } + assert!(failures.is_empty(), "{} vector(s) disagree:\n{}", failures.len(), failures.join("\n")); + for p in ["leaf", "group", "object"] { + assert!(ran.get(p).copied().unwrap_or(0) > 0, "no {p} vector ran: {ran:?}"); + } +} + +/// The corpus's non-canonical key vector, pinned: a key whose `y` is p + 3 is +/// refused as a key, not reached as a signature. +#[test] +fn a_non_canonical_publisher_key_is_refused_as_a_key() { + let text = std::fs::read_to_string(CORPUS).unwrap_or_else(|e| panic!("read {CORPUS}: {e}")); + let doc: Value = serde_json::from_str(&text).expect("the corpus is JSON"); + let v = doc["vectors"] + .as_array() + .expect("vectors") + .iter() + .find(|v| v["name"] == "group_signer_key_non_canonical") + .expect("the corpus holds group_signer_key_non_canonical"); + assert_eq!(v["expected"]["reason"], "bad_signer_key"); + let g = group_of(&v["input"]["group"]).expect("its JSON reads"); + let e = ReceiptVerifier::new().verify_group(&g).unwrap_err().to_string(); + assert!(e.contains("publisher key"), "{e}"); +} diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 3021fa55d..463d31a6c 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -250,6 +250,117 @@ wire format may change and such changes are recorded here. second model-identity construct; it is to fold into the pin once that renderer is registered as decoding against a parameter set. +### Energy reports, labelled object leaves, and what the signatures cover + +- **Reports about a figure.** Two claim classes, [`extensions/jwp-receipts.md`](extensions/jwp-receipts.md) + §2.4.8–§2.4.9, report on one energy figure without changing the receipt that seals + it. A claim names that receipt by a **figure profile** — the receipt's identity domain + less its version byte, so the registry is the code's own + ([`extensions/energy-measurement.md`](extensions/energy-measurement.md) §6.1) — and its + identity, and restates the figure with its class. `measurement` carries the bracket's + values as integers with each bracket's liveness facts, the build (profile, toolchain, + target) and the power reader; only the key that signed the figure may sign it, so a + figure no Ed25519 key signs (an energy binding's) has no `measurement` claim. A + hardware figure's report must name its power reader (`measurement_no_power_reader`), + with or without a bracket, and a `null` bracket says the figure was not obtained under + the bracketed protocol. Because only §3.1 step 6 shows the signer is the figure's, a + `measurement` claim is accepted only against the receipt that carries its figure: + checked without it, it is `figure_not_held`, and `ClaimReceipt::attribution` gives a + verdict only for a claim accepted against that figure. + `operating-point` records the requested, committed and delivered operating points, + from a vocabulary named by URI, and a prediction. energy-measurement §6 makes the + `measurement` claim the carrier of a class-bearing figure's report and lists the + producers that fall short. +- **Exact report arithmetic.** energy-measurement §6.2 gives the figure and the least + declaration a report's integers support as exact integer formulas, at whatever width + their products need. Producers seal from the same integers; computed from unrounded + samples, a declaration could come out one ppm below what the published integers + support. `wai-rs` `energy_report` implements it with no floating-point arithmetic, and + `claim-conformance/claim_verify.py` recomputes the pinned brackets with Python's + integers. +- **Derived, never stated.** The §4 ratio and verdict, whether a commitment was met, + and a prediction's residual are derived by every reader from what a claim carries. A + report carrying its ratio is refused (`unknown_member`). +- **A live power reader.** energy-measurement §3.1: a rail is live in a bracket only if + it advanced in two consecutive intervals, in at least five and in at least four of + every five, over at least 300 ms, in both brackets; a figure from a bracket that is not + live is never sealed under a hardware class. A power sample advances only when it is + above zero and differs from the sample before it, so a reader that holds its last + value never advances; a reader of a report refuses, under a hardware class, a bracket + whose `sd_uw` is 0 whatever liveness facts it carries. A frozen rail, a stale value + held, one batch refresh, and one refresh published in two steps all used to give a + flat idle bracket and an infinite ratio that sealed as `OnChipCounter`; `energy_burst` + and `energy_meter` now refuse them, with fault-injection tests, and a counter meter reads its counter during a span + rather than only at its ends. On a host whose reader reports a rail that does not + advance, the reference meters seal unmetered and say why. +- **The unmetered marker.** energy-measurement §2: an unmetered figure is not a figure + of zero joules. A `0` that marks unmetered is never presented, totalled or compared as + 0 J; a total over parts that include one covers only the measured parts; new forms + mark unmetered with class tag `0`. quantum-sim, provenance, interactive-worlds, + moq-streaming-format and SPEC read `0` this way. `ModelMeter` returns no reading rather + than a labelled zero or a saturated product, and `wai-quantum-serve`'s `POST /verify` + renders an unmetered receipt as `"joules_micro": null, "metered": false`. +- **The labelled JWP leaf.** An object's class rides in its Merkle leaf, + `BLAKE3("moq-jwp:merkle-leaf\x02" ‖ content_hash ‖ u64_be(joules_micro) ‖ class ‖ + u32_be(len(origin)) ‖ origin)`, so the group signature covers the object's figure, its + class with its evidence, and its origin (jwp-receipts §2.2, §2.4.2). A label, or the + origin, can be neither added, stripped nor altered in transit. +- **The labelled group payload.** `moq-jwp:group-sign\x02` signs the legacy fields and + then the total's class, its coverage and the parent link (§2.3, §2.4.1); the legacy + payload never signed the parent. The class is composed from the objects' classes by an + integer rule — the weakest class, and for a hardware class `⌈Σ F·p / Σ F⌉` ppm over the + longest window — and a verifier holding every object checks it (§3, *The group + total*), alongside the sum. +- **Position.** An object must sit at the position its inclusion proof walks (§3 step + 5, `proof_position_mismatch`), which closes the duplicated last leaf of an odd level + and an object presented at another object's index — the latter could hide a weaker + class from the group total. Group signatures are held to the strict Ed25519 rule + claims use. §3.3 lists the delivery reasons; a group's link hash is defined over its + signing payload and signature. +- **`WAI_RECEIPT`** ([`extensions/moq-streaming-format.md`](extensions/moq-streaming-format.md) + §5): `0` is the unmetered marker, `group_receipt` is the group's link hash, and the + property's class reads as signed only where it equals the object's leaf label or an + accepted `energy` claim; otherwise it is discarded (`property_disagrees`) or read as + unlabelled. A class read as signed names the key that signed it — the group's for a + leaf, the claim's for a claim — so a class a relay signs into the property under its + own key is never read as the publisher's. +- **What stays unverifiable.** energy-measurement §5 and energy-binding §3 now say it is + the measurement — the joules and every field describing how they were obtained — not + one number, and that a report's values can be checked for consistency, which is not + truth. energy-binding §7.4 no longer says green metadata is registered; it is not yet. +- **Implementation.** `wai-rs` (feature `receipt_claims`): `jwp_delivery` reads and + verifies both forms of each JWP receipt, the group total and the property; + `energy_figure` holds the profile registry and views every figure-bearing receipt + family, the quantum receipts through `MaybeLabelled`; the reference meters write a + measurement report beside the receipt by default (`--no-report` to opt out; + `--previous-report` with no file is a usage error), and `wai_prov` presents its total as + `Partial` over the measured steps, or `UNMETERED`, never as 0 µJ. joule-code's + `joule-moq` (0.2.0) builds and verifies both labelled forms, checks position, holds + publisher keys and group signatures to the strict rule (a non-canonical key encoding is + refused as a key), refuses an unsigned coverage on a legacy group, and runs every + leaf, group and object vector of the new `jwp-conformance/` corpus. +- **Conformance and CI.** `claim-conformance/` gains vectors for every new reason, the + arithmetic at its boundaries, liveness faults (a held value among them) and the + labelled leaf; the earlier vectors are unchanged and pinned. `jwp-conformance/` holds the JWP corpus + (`wai_jwp_vectors`) and `jwp_verify.py`, an independent verifier. CI now runs + `claim_verify.py`, `differential.py` (fixed seed) and `jwp_verify.py` on the linux + runner, and the JWP corpus on every platform of the conformance matrix. The + conformance build includes `receipt_claims`, so the reference meters are built on + every platform, and joule-code's CI runs `joule-moq` with its QUIC transport. + +**Wire.** Every receipt issued in a legacy form keeps its bytes and verifies as before: +v1 groups, `\x01` leaves, every claim and corpus vector already pinned, `wai-quantum`'s +receipts (no `wai-quantum` code changes). The new forms are refused by software that +predates them: a verifier or relay refuses a labelled group at §3 step 2 and a labelled +object at §3 step 5, and a claim verifier refuses `measurement` and `operating-point` as +`unknown_class`. A group's `sign_version` other than 2 is `group_label_invalid`. +**External JWP implementations must adopt the `\x02` leaf and the labelled group payload +before verifying objects or groups that use them, and hold publisher keys to the +canonical-encoding rule**; the in-tree `joule-moq` does, and its public API changes are +listed in joule-code's CHANGELOG. New meter receipts can declare one ppm more than before, because they +seal from the published integers, and a meter now refuses a rail that is not live where +it used to seal a figure. + ### Typed receipt classes, and a second (soft) binding class - [`extensions/jwp-receipts.md`](extensions/jwp-receipts.md) §2.4.3–§2.4.7: every diff --git a/wai/SPEC.md b/wai/SPEC.md index a3195818d..5afd5eb49 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -509,11 +509,11 @@ registration and makes no conforming envelope non-conforming (§8). | `wai.neural.dcvc_fm` | DCVC-FM (feature modulation), wide single-model quality range (RGB + YUV) | a wide single-model quality range; shares the inter-frame route | research code, `neural/video_dcvc.rs` | | `wai.video.int_motion` | DETERMINISTIC INTEGER inter-frame — I-frame + integer block motion-compensated P-frames + integer-quantized residual over rANS, no float/ONNX; motion is whole-pel or **half-pel** (`mv_precision`), the latter predicting at the half-grid by integer bilinear interpolation (`(a+b+1)>>1`, `(a+b+c+d+2)>>2`) so real footage — which pans a non-integer number of pixels per frame — costs fewer residual bits while staying byte-exact | byte-identical frame-by-frame on every machine (*frame-state-equivalence*, a per-frame portable hash); the temporal extension of the integer contract, where a float inter-frame decode desyncs down the GOP | `int_video.rs` (whole-pel `tools/wai_int_video_encode.py`; half-pel from real footage `tools/wai_int_video_real_encode.py`) | | `wai.video.int_hyper` | NEURAL-KEYFRAME inter-frame — the I-frame is the LEARNED integer codec `int_hyper` decoded byte-exact, the P-frames are integer block-MC residuals on top | a learned keyframe you can put in a receipt + integer inter-frames; whole decode integer, byte-identical everywhere | `int_video::decode_pframes` ∘ `int_hyper_synth::decode_conditional` | -| `wai.video.manifest` | ATTESTED PER-SESSION MANIFEST — the OTT delivery layer, not a codec: a personalised HLS manifest (ads stitched at `EXT-X-DISCONTINUITY`, blackouts + geo enforced) rendered as a **pure function of the session *class*** `(content, entitlement, geo, blackout set, ad pod, window)` rather than the viewer, and sealed in an Ed25519 receipt that binds the JCP grant (capability, joule ceiling, `funds_ceiling` = ad budget), the composition, the stitched ads and the policy applied | **manifest-equivalence**: `manifest_hash == hash(render(segments))` and `session_class_hash` are recomputable by anyone holding the bytes, so the ads, blackouts and compose-work stop being dashboard claims; class-determinism means one generation serves every viewer of the class, and `uj_per_viewer` is the amortised energy that a 1:1-unique generator cannot report below its per-viewer cost. Only `joules_micro` is measured-attested | `wai_video_manifest.rs` | -| `wai.video.ssai` | ATTESTED SERVER-SIDE AD INSERTION — a **deterministic** ad decision (greedy by price within the avail duration and the funds remaining) plus a per-impression Ed25519 receipt binding the candidate pod, the manifest it belongs to, the served `creative_hash`es and the VAST quartile each reached | **spend-equivalence**: `budget_spent_micros` is recomputed from the *billable* impressions and rejected if it exceeds the grant's `funds_ceiling` — an overspent receipt fails verification; every creative and quartile is checkable, so a buyer and seller reconcile impressions from the same bytes instead of two logs that only a dashboard joins. Only `joules_micro` is measured-attested | `wai_video_ssai.rs` | -| `wai.video.keys` | ATTESTED CONTENT-KEY RELEASE — the DRM licensing decision as a record: every key release, and every refusal, is an Ed25519 receipt binding the KID, the DRM system, the entitlement demanded and the window enforced, with a BLAKE3 `key_commit` proving *which* key was released without carrying it | **decision-equivalence**: `verify()` re-runs the release policy over the receipt's own `(grant capability, entitlement, issued, expiry)` and rejects any misreported outcome, and enforces granted-iff-committed — so "keys went only to entitled, unexpired sessions" is checkable against the signed log, not asserted. Only `joules_micro` is measured-attested | `wai_video_keys.rs` | -| `wai.video.package` | DETERMINISTIC PACKAGING + ATTESTED SEGMENTMAP — a source sliced into content-hashed segments on GOP boundaries; the ordered `(index → content_hash)` map is a signed Merkle commitment, and identical content (ads, bumpers, replays, simulcast overlap) dedups to one stored/encoded copy | **map-equivalence + verifiable dedup**: `segment_map_root` lets a `wai.video.manifest` reference be checked against the package that produced it, and `verify()` recomputes `unique_segments`/`stored_bytes` from the segments so the storage/encode saving cannot be overstated; `uj_per_unique_segment` is the encode-once energy. Only `joules_micro` is measured-attested | `wai_video_package.rs` | -| `wai.video.channel` | VIRTUAL LINEAR / FAST ASSEMBLY — a continuous schedule of assets over wall-clock time, sliced into attested playback windows; windows chain via `parent_receipt_hash`, so a channel's history is a hash chain of program-as-run records | **schedule-continuity + recomputable program-as-run**: `verify()` rejects a schedule with a gap or overlap (no dead air, no double-booking) and recomputes `aired` from the schedule and the window, so what actually aired is checkable by a licensor, advertiser or regulator against the signed chain rather than an operator-held play-out log. Only `joules_micro` is measured-attested | `wai_video_channel.rs` | +| `wai.video.manifest` | ATTESTED PER-SESSION MANIFEST — the OTT delivery layer, not a codec: a personalised HLS manifest (ads stitched at `EXT-X-DISCONTINUITY`, blackouts + geo enforced) rendered as a **pure function of the session *class*** `(content, entitlement, geo, blackout set, ad pod, window)` rather than the viewer, and sealed in an Ed25519 receipt that binds the JCP grant (capability, joule ceiling, `funds_ceiling` = ad budget), the composition, the stitched ads and the policy applied | **manifest-equivalence**: `manifest_hash == hash(render(segments))` and `session_class_hash` are recomputable by anyone holding the bytes, so the ads, blackouts and compose-work stop being dashboard claims; class-determinism means one generation serves every viewer of the class, and `uj_per_viewer` is the amortised energy that a 1:1-unique generator cannot report below its per-viewer cost. Only the measurement — `joules_micro`, its acquisition class and the class's evidence — is measured-attested: signed, and not re-measurable | `wai_video_manifest.rs` | +| `wai.video.ssai` | ATTESTED SERVER-SIDE AD INSERTION — a **deterministic** ad decision (greedy by price within the avail duration and the funds remaining) plus a per-impression Ed25519 receipt binding the candidate pod, the manifest it belongs to, the served `creative_hash`es and the VAST quartile each reached | **spend-equivalence**: `budget_spent_micros` is recomputed from the *billable* impressions and rejected if it exceeds the grant's `funds_ceiling` — an overspent receipt fails verification; every creative and quartile is checkable, so a buyer and seller reconcile impressions from the same bytes instead of two logs that only a dashboard joins. Only the measurement — `joules_micro`, its acquisition class and the class's evidence — is measured-attested: signed, and not re-measurable | `wai_video_ssai.rs` | +| `wai.video.keys` | ATTESTED CONTENT-KEY RELEASE — the DRM licensing decision as a record: every key release, and every refusal, is an Ed25519 receipt binding the KID, the DRM system, the entitlement demanded and the window enforced, with a BLAKE3 `key_commit` proving *which* key was released without carrying it | **decision-equivalence**: `verify()` re-runs the release policy over the receipt's own `(grant capability, entitlement, issued, expiry)` and rejects any misreported outcome, and enforces granted-iff-committed — so "keys went only to entitled, unexpired sessions" is checkable against the signed log, not asserted. Only the measurement — `joules_micro`, its acquisition class and the class's evidence — is measured-attested: signed, and not re-measurable | `wai_video_keys.rs` | +| `wai.video.package` | DETERMINISTIC PACKAGING + ATTESTED SEGMENTMAP — a source sliced into content-hashed segments on GOP boundaries; the ordered `(index → content_hash)` map is a signed Merkle commitment, and identical content (ads, bumpers, replays, simulcast overlap) dedups to one stored/encoded copy | **map-equivalence + verifiable dedup**: `segment_map_root` lets a `wai.video.manifest` reference be checked against the package that produced it, and `verify()` recomputes `unique_segments`/`stored_bytes` from the segments so the storage/encode saving cannot be overstated; `uj_per_unique_segment` is the encode-once energy. Only the measurement — `joules_micro`, its acquisition class and the class's evidence — is measured-attested: signed, and not re-measurable | `wai_video_package.rs` | +| `wai.video.channel` | VIRTUAL LINEAR / FAST ASSEMBLY — a continuous schedule of assets over wall-clock time, sliced into attested playback windows; windows chain via `parent_receipt_hash`, so a channel's history is a hash chain of program-as-run records | **schedule-continuity + recomputable program-as-run**: `verify()` rejects a schedule with a gap or overlap (no dead air, no double-booking) and recomputes `aired` from the schedule and the window, so what actually aired is checkable by a licensor, advertiser or regulator against the signed chain rather than an operator-held play-out log. Only the measurement — `joules_micro`, its acquisition class and the class's evidence — is measured-attested: signed, and not re-measurable | `wai_video_channel.rs` | **Sink-architecture note:** the inter-frame neural video codecs (DCVC-RT, DCVC-FM) decode through `ort` and are **CPU-capable** for native sinks — @@ -535,11 +535,14 @@ 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; the +Merkle roots, Ed25519 group signatures, and cross-hop preservation; an +object's figure, acquisition class and origin bound into its Merkle leaf, +and a group total's class signed under a labelled group payload; the typed receipt classes beside delivery (`energy`, `lawful-basis`, -`log-inclusion`) carried as `claim` receipts; and a second, weaker -binding class (a registry-named soft binding with a resolution endpoint) -whose recovered receipts attest the ancestor, never the object in hand. +`log-inclusion`, `measurement`, `operating-point`) carried as `claim` +receipts; and a second, weaker binding class (a registry-named soft +binding with a resolution endpoint) whose recovered receipts attest the +ancestor, never the object in hand. See [`extensions/energy-measurement.md`](extensions/energy-measurement.md) for how a receipt's `joules_micro` is acquired: the required acquisition class (`CalibratedInstrument` / `OnChipCounter` / `ModelBased` / `Estimator`, with the @@ -548,10 +551,13 @@ 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. +from rest, divided by the work the window paid for, from brackets in which the +power reader's rail was live), the attribution-quality rule that decides whether +a figure may be published at all, the signed measurement report that publishes a +figure's bracket, build and power reader beside the receipt it names, 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. A +`joules_micro` of `0` is the unmetered marker, never a figure of zero joules. 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. @@ -909,9 +915,14 @@ A WAI relay or multiplexer that carries an encrypted payload MUST treat it as opaque: it MUST NOT require the plaintext to route, meter, or attest, and MUST NOT attempt capability dispatch on ciphertext. The JWP receipt chain ([`extensions/jwp-receipts.md`](extensions/jwp-receipts.md)) -commits over the **ciphertext** content hash, so per-object delivery, -energy, and integrity are attested end-to-end without any relay holding -the key. Content secrecy (SFrame/MLS) and delivery attestation (JWP) are +commits over the **ciphertext** content hash, so per-object delivery and +integrity are attested end-to-end without any relay holding the key. Each +object's figure, class and origin are signed where its leaf is labelled, and +the group total, its class, its coverage and the parent link are signed under +the labelled group payload (jwp-receipts §2.2–§2.3). An object's figure and +origin beside a legacy leaf are the publisher's statement, unsigned beside the +signed bytes; a legacy group payload signs the total, but no class, coverage or +parent link. Content secrecy (SFrame/MLS) and delivery attestation (JWP) are independent layers over the same bytes and compose: a relay proves it forwarded the exact sealed bytes it was given while never learning what they say. diff --git a/wai/claim-conformance/README.md b/wai/claim-conformance/README.md index b199dc994..8fe3aee03 100644 --- a/wai/claim-conformance/README.md +++ b/wai/claim-conformance/README.md @@ -1,9 +1,9 @@ # claim-conformance Conformance for [typed receipt classes and the soft binding class](../extensions/jwp-receipts.md) -(§2.4.3–§2.4.7, §2.6, §3.1–§3.2): `claim` receipts of class `energy`, -`lawful-basis`, `log-inclusion`, `soft-binding` and `binding-recovery`, and recovery -through a soft binding under the ancestor rule. +(§2.4.3–§2.4.9, §2.6, §3.1–§3.2): `claim` receipts of class `energy`, +`lawful-basis`, `log-inclusion`, `soft-binding`, `binding-recovery`, `measurement` and +`operating-point`, and recovery through a soft binding under the ancestor rule. Each vector declares **one outcome**: `accept` — with the claim's `link_hash`, which pins the canonical form, or for a recovery the ancestor it attests — or `reject` at a @@ -14,7 +14,7 @@ another step, does not conform. | file | what it is | |---|---| -| `vectors.json` | 154 vectors: 27 accepted claims, 119 rejected claims, and 8 recoveries (2 accepted, 6 rejected). Every reason of §3.1 is exercised at least once. | +| `vectors.json` | The corpus. Every vector declares one outcome, and every reason of §3.1 is exercised at least once. | | `claim_verify.py` | A second verifier, written from the extension text, that shares no code with `wai-rs`. | | `differential.py` | Generates inputs the corpus does not hold, runs both verifiers on them, and reports every input on which their verdicts differ. | @@ -26,7 +26,14 @@ signer keys), `declaration` (the claim a decision or a recovery record names), declaration's subject — §2.6.3 makes the declaration context only together with it, so a vector that gives a recovery record one without the other is malformed), and `exact_match_algs` (the soft-binding algorithms the verifier has a matcher for; the -corpus's matcher is byte equality). `recover` runs §3.2 from a `declaration`, the +corpus's matcher is byte equality), and `figure` (the receipt that carries the figure a +`measurement` or `operating-point` claim names). An `object` context may also carry the +class its leaf is labelled with (jwp-receipts §2.2), in energy-measurement §2.7's JSON +members, read as that section reads them. A `figure` context is one of two forms: +`claim`, an `energy` claim's wire form, viewed under the `moq-jwp:claim-link` profile; +or `view`, a receipt the verifier has already verified, given as `{profile, identity, +group, figures: [{index, joules_micro, energy_* members}], signer}`, so that neither +verifier needs to parse every receipt family a figure profile names. `recover` runs §3.2 from a `declaration`, the `publisher` key taken from a verified delivery receipt, the `observed_value`, and the object in hand's `observed_hash` (or `null`). Both procedures report `recovery_signer_mismatch` for a declaration the ancestor's publisher did not sign: @@ -44,7 +51,7 @@ illustrative and no verifier here checks them against a vocabulary. An `energy` claim carries its class as energy-measurement §2.7 writes a class in JSON: `energy_provenance`, with `energy_uncertainty`, `energy_counter_filtering` and `energy_calibration_ref` present and `null` where the class has no such evidence. -Twenty-eight vectors are about the class and its evidence: +These vectors are about the class and its evidence: - **Accepted:** an `OnChipCounter` figure with a declared uncertainty (2 % over 1 s) and its filtering state `Unknown`, and with `Off`; a `CalibratedInstrument` figure with a @@ -67,14 +74,56 @@ Twenty-eight vectors are about the class and its evidence: - **Rejected at step 3:** a `ModelBased` claim rewritten in transit to `OnChipCounter` with an uncertainty and a filtering state: the class and its evidence are signed. +## Reports about a figure: `measurement` and `operating-point` + +A `measurement` claim publishes how one figure was measured — its bracket's values as +integers, with the liveness facts of each bracket, the build and the power reader — and +an `operating-point` claim records the operating point it was spent at. Both name the +receipt that seals the figure by a figure profile (energy-measurement §6.1) and restate +the figure with its class. The vectors cover: + +- **The arithmetic of energy-measurement §6.2:** declarations at the least a bracket + supports and one ppm below it; a figure at a half microjoule, which rounds up; brackets + whose products exceed 128 and 200 bits; a calibrated instrument declaring more than the + least; and the declaration a sealing path computes from unrounded samples, which the + rule refuses. Those values are checked by `claim_verify.py` with Python's integers, and + the bracket table `wai-rs` pins in `energy_report` is recomputed by it too. +- **Liveness (§3.1):** a bracket whose rail advanced in too few samples; a frozen rail + refreshed once, which gives a flat idle bracket and an infinite ratio; a reader holding + a stale non-zero value, stepped once by a batch refresh, with its honest facts and with + facts claiming every sample live; a flat idle bracket whose facts claim the rail live + (a repeated sample is not an advance, so a bracket with `sd_uw` 0 is never live); a + refresh published in two steps in each bracket, which passes a rule of two consecutive + advances; facts that describe no sequence; and, accepted, a `ModelBased` figure whose + bracket shows a dead rail, since liveness binds the hardware classes only. +- **The power reader (§2.4.8 rule 4):** a hardware figure's report that names no reader + is refused, with a bracket and without one; accepted, an `OnChipCounter` report with no + bracket that names its reader. +- **Every other rule of §2.4.8 rule 5 and §2.4.9,** each with its reason, and two defects + at once. +- **Step 2:** derived quantities are never carried (a bracket with its ratio is + `unknown_member`); the opening and build-profile enumerations; integers, terms and + absolute URIs where the tables require them. +- **Step 6:** against an `energy` claim and against views of a provenance receipt, a + delivered object (named by its leaf index and its group), and an energy binding, which + no Ed25519 key signs — a `measurement` claim about it is `figure_signer_unknown`, an + `operating-point` claim about it is accepted; a report by a key other than the figure's + signer; and an operating point recorded against an unmetered or unlabelled figure. A + `measurement` claim checked without the receipt that carries its figure is + `figure_not_held`, whether a stranger or the figure's own signer signed it: only step 6 + shows a report is the figure signer's. +- **The labelled delivery leaf:** an `energy` claim against an object whose leaf carries + a class, agreeing, and disagreeing in figure, class or evidence; an `OnChipCounter` + label with no filtering state reads as `Unknown`. + ## Steps 1 and 2: types, and the order a body is read in §3.1 step 2 checks exactly the type a member table gives, member by member in the table's order; whatever a class's rules add to a type is step 4's, with the rule's own -reason. Four step-4 vectors pin that boundary from the rules' side: a zero figure +reason. Step-4 vectors pin that boundary from the rules' side: a zero figure (`energy_zero_figure`), a zero work count (`energy_zero_work`), an empty basis (`lawful_basis_purpose_without_basis`) and an `http` endpoint -(`soft_binding_endpoint_not_https`). Eight more pin what the text settles on the other +(`soft_binding_endpoint_not_https`). Others pin what the text settles on the other side, each signed again after its edit so the edit is its only defect: - **Step 1:** a byte-order mark before the text is `malformed` (RFC 8259 lets a parser @@ -158,8 +207,8 @@ cargo build --no-default-features --features receipt_claims --bin wai_claim_vect python3 differential.py /debug/wai_claim_vectors [--random N] [--seed S] [--keep FILE] ``` -CI runs the first on the conformance matrix (Linux x86_64, Linux arm64, macOS arm64). The second and third are -run by hand; they are not CI steps. +CI runs the first on the conformance matrix (Linux x86_64, Linux arm64, macOS arm64), and the second +and third on the linux runner, with a fixed seed, in `.github/workflows/wai-conformance.yml`. `differential.py` writes its inputs in the corpus's format with no declared outcome and runs each verifier's `verdicts` mode (`wai_claim_vectors verdicts `, diff --git a/wai/claim-conformance/claim_verify.py b/wai/claim-conformance/claim_verify.py index b684172b0..0ccfd8930 100644 --- a/wai/claim-conformance/claim_verify.py +++ b/wai/claim-conformance/claim_verify.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 """Second verifier for claim-conformance/ — typed claim receipts, the soft binding class, -and recovery under the ancestor rule (extensions/jwp-receipts.md §2.4.3-§2.4.7, §2.6, -§3.1-§3.2). +and recovery under the ancestor rule (extensions/jwp-receipts.md §2.4.3-§2.4.9, §2.6, +§3.1-§3.2), and the arithmetic of a measurement report (energy-measurement.md §3.1, §6.2), +computed with Python's integers. Written from the extension text. It shares no code with wai-rs (`crate::claim_receipt`), which generates the corpus: the canonical form is Python's JSON serialiser with members @@ -14,12 +15,14 @@ reference verifier (`../oer-conformance/oer2_verify.py`), under oer-receipts' ow python3 claim_verify.py [vectors.json] verify every vector and the canonical fixture python3 claim_verify.py fixture print the fixture's canonical bytes, signature and link hash + python3 claim_verify.py brackets print the figure and least declaration of + each bracket wai-rs pins python3 claim_verify.py verdicts print `nameverdict` for every vector of a corpus-format file, ignoring any declared outcome (the input to differential.py) -Exit status 0 only if every vector reports its declared outcome and the fixture this -script computes equals the literals pinned in wai-rs. +Exit status 0 only if every vector reports its declared outcome, and the fixture and the +bracket arithmetic this script computes equal the literals pinned in wai-rs. A corpus-format file is read as the OER reference reads a vector document (`OER.load`, oer-receipts §4 "The document"), since it holds OER receipts: a file that reading refuses @@ -279,14 +282,35 @@ def parse_strict(text): ENVELOPE = ["kind", "class", "subject_type", "subject", "seq", "issued_at", "body", "signer_pubkey", "signer_id", "parent_receipt_hash", "sig"] ADMITS = {"energy": {"object"}, "lawful-basis": {"object", "receipt"}, "log-inclusion": {"receipt"}, - "soft-binding": {"object"}, "binding-recovery": {"object"}} + "soft-binding": {"object"}, "binding-recovery": {"object"}, + "measurement": {"object", "receipt"}, "operating-point": {"object", "receipt"}} +FIGURE = ["figure_profile", "figure_index", "group", "joules_micro", "energy_provenance", "energy_uncertainty", + "energy_counter_filtering", "energy_calibration_ref"] BODY = { "energy": ["joules_micro", "energy_provenance", "energy_uncertainty", "energy_counter_filtering", "energy_calibration_ref", "coverage", "grade", "method", "work_units", "work_unit", "oer_receipt"], "log-inclusion": ["log", "profile", "entry", "log_receipt"], "soft-binding": ["alg", "registry", "binding_type", "value", "resolution_endpoint"], "binding-recovery": ["alg", "binding_type", "value", "declaration", "resolution_endpoint", "observed", "attests"], + "measurement": FIGURE + ["bracket", "build", "power_reader"], + "operating-point": FIGURE + ["vocabulary", "op_requested", "op_committed", "op_delivered", "prediction"], } +BRACKET = ["idle", "busy", "window_us", "completions", "in_flight", "opening"] +SUMMARY = ["n", "mean_uw", "sd_uw", "live_intervals", "longest_live_run"] +BUILD = ["profile", "toolchain", "target"] +PREDICTION = ["predictor", "descriptor_hash", "predicted_joules_micro"] +# energy-measurement §6.1: profile -> (subject_type, carries several figures, an Ed25519 +# key signs the figure, names a group). +PROFILES = {"moq-jwp:object": ("object", True, True, True), "moq-jwp:claim-link": ("receipt", False, True, False), + "wai:energy-binding-id": ("receipt", False, False, False)} +for _n in ["video-receipt-id", "video-manifest-receipt-id", "video-ssai-receipt-id", "video-keys-receipt-id", + "video-package-receipt-id", "video-channel-receipt-id", "world-receipt-id", "film-receipt-id", + "haptic-receipt-id", "score-receipt-id", "splat4d-receipt-id", "audio-receipt-id", "ternary-receipt-id", + "quantum-receipt-id", "quantum-calibration-id", "quantum-job-id", "quantum-mitigate-id", + "quantum-decode-id", "quantum-compile-id", "quantum-rearrange-id", "phasor-receipt-id"]: + PROFILES["wai:" + _n] = ("receipt", False, True, False) +for _n in ["asset-receipt-id", "quantum-qbom-id"]: + PROFILES["wai:" + _n] = ("receipt", True, True, False) DECLARATION = ["record", "vocabulary", "fragment", "purposes"] PURPOSE = ["purpose", "basis", "data"] DECISION = ["record", "vocabulary", "declaration", "purpose", "outcome"] @@ -423,6 +447,42 @@ def check_body(cls, b): raise Reject(2, "field_type") return members(b, BODY[cls], 2) + if cls in ("measurement", "operating-point"): + check_figure_header(b) + if cls == "measurement": + br = b["bracket"] + if br is not None: + need(isinstance(br, dict), 2) + members(br, BRACKET, 2) + for side in ("idle", "busy"): + need(isinstance(br[side], dict), 2) + members(br[side], SUMMARY, 2) + need(all(is_int(br[side][k]) for k in SUMMARY), 2) + need(all(is_int(br[k]) for k in ("window_us", "completions", "in_flight")), 2) + need(br["opening"] in ("from-rest", "running"), 2) + bd = b["build"] + need(isinstance(bd, dict), 2) + members(bd, BUILD, 2) + need(bd["profile"] in ("release", "debug"), 2) + need(bd["toolchain"] is None or isinstance(bd["toolchain"], str), 2) + need(bd["target"] is None or is_token(bd["target"]), 2) + need(b["power_reader"] is None or isinstance(b["power_reader"], str), 2) + else: + need(is_uri(b["vocabulary"]), 2) + for k in ("op_requested", "op_committed", "op_delivered"): + pt = b[k] + if pt is not None: + need(isinstance(pt, dict), 2) + for name, val in pt.items(): + need(is_token(name) and is_int(val), 2) + pr = b["prediction"] + if pr is not None: + need(isinstance(pr, dict), 2) + members(pr, PREDICTION, 2) + need(is_uri(pr["predictor"]), 2) + need(is_hex(pr["descriptor_hash"], 32), 2) + need(is_int(pr["predicted_joules_micro"]), 2) + return if cls == "energy": # In the member table's order: a nested object's member check can report another # reason than a type check, so the order is the one every verifier uses. @@ -458,6 +518,105 @@ def check_body(cls, b): need(is_token(b["alg"]) and is_uri(b["resolution_endpoint"]), 2) +def check_figure_header(b): + """§2.4.8's header at step 2, in its table's order: `figure_profile` a term, as it is + read; the index an integer or null; the group a hash or null; then the figure and the + class's members as §2.4.5 types them.""" + need(is_token(b["figure_profile"]), 2) + need(b["figure_index"] is None or is_int(b["figure_index"]), 2) + need(b["group"] is None or is_hex(b["group"], 32), 2) + need(is_int(b["joules_micro"]), 2) + need(isinstance(b["energy_provenance"], str), 2) + u = b["energy_uncertainty"] + if u is not None: + need(isinstance(u, dict), 2) + members(u, UNCERTAINTY, 2) + need(is_int(u["relative_ppm"]) and is_int(u["window_us"]), 2) + need(b["energy_counter_filtering"] is None or isinstance(b["energy_counter_filtering"], str), 2) + need(b["energy_calibration_ref"] is None or isinstance(b["energy_calibration_ref"], str), 2) + + +# ------------------------------------------- a measurement report's arithmetic (§3.1, §6.2) + +def summary_live(s): + """energy-measurement §3.1: a rail is live over a bracket only if it advanced in two + consecutive intervals, in at least five, and in at least four of every five — facts + that describe one sequence of `n` intervals. A power sample advances only where it + differs from the one before it, so a bracket whose samples are all equal (`sd_uw` 0) + advanced once at most, and is never live whatever facts it carries.""" + n, live, run = s["n"], s["live_intervals"], s["longest_live_run"] + return s["sd_uw"] > 0 and run <= live <= n and run >= 2 and live >= 5 and live * 5 >= n * 4 + + +def work(br): + c, n = br["completions"], br["in_flight"] + return (2 * c + n, 2) if br["opening"] == "from-rest" else (c, 1) + + +def bracket_figure(br): + """F = floor((2·M·T·Wd + 10^6·Wn) / (2·10^6·Wn)), or None.""" + m = br["busy"]["mean_uw"] - br["idle"]["mean_uw"] + t, c, n = br["window_us"], br["completions"], br["in_flight"] + if m <= 0 or t == 0 or c == 0 or n == 0: + return None + wn, wd = work(br) + f = (2 * m * t * wd + 10 ** 6 * wn) // (2 * 10 ** 6 * wn) + return f if 0 < f <= MAX_INT else None + + +def k_and_p(br, f): + m = br["busy"]["mean_uw"] - br["idle"]["mean_uw"] + t, n = br["window_us"], br["in_flight"] + wn, wd = work(br) + ni, nb, si, sb = br["idle"]["n"], br["busy"]["n"], br["idle"]["sd_uw"], br["busy"]["sd_uw"] + k = 12 * ni * nb * wn ** 4 * f * f + p = 12 * wn ** 2 * t ** 2 * wd ** 2 * (si * si * nb + sb * sb * ni) + 4 * ni * nb * m * m * t * t * wd * wd * n * n \ + + 10 ** 12 * ni * nb * wn ** 4 + return k, p + + +def least_ppm(br, f): + """The least p with p^2·K >= P, or None above 2^32 - 1.""" + k, p = k_and_p(br, f) + if k == 0: + return None + q = -(-p // k) # p^2 >= ceil(P / K) + r = __import__("math").isqrt(q) + while r * r * k < p: + r += 1 + while r > 1 and (r - 1) * (r - 1) * k >= p: + r -= 1 + return r if 1 <= r <= 2 ** 32 - 1 else None + + +def bracket_rules(br, joules, label, u): + """§2.4.8 rule 5, in its order.""" + hw = label in ("OnChipCounter", "CalibratedInstrument") + need(br["idle"]["n"] >= 2 and br["busy"]["n"] >= 2, 4, "measurement_too_few_samples") + need(not hw or (summary_live(br["idle"]) and summary_live(br["busy"])), 4, "measurement_rail_not_live") + need(br["window_us"] != 0 and br["completions"] != 0 and br["in_flight"] != 0, 4, "measurement_empty_burst") + m = br["busy"]["mean_uw"] - br["idle"]["mean_uw"] + need(m > 0, 4, "measurement_no_marginal") + need(not hw or m >= br["idle"]["sd_uw"], 4, "measurement_below_attribution_floor") + need(bracket_figure(br) == joules, 4, "measurement_figure_mismatch") + if hw: + need(u["window_us"] == br["window_us"], 4, "measurement_window_mismatch") + k, p = k_and_p(br, joules) + need(u["relative_ppm"] ** 2 * k >= p, 4, "measurement_declaration_below_bracket") + + +def figure_rules(c): + """§2.4.8 rules 1-3: a non-zero figure, a profile for the subject type with an index + and a group exactly where it has them, and the class.""" + b = c["body"] + need(b["joules_micro"] != 0, 4, "energy_zero_figure") + prof = PROFILES.get(b["figure_profile"]) + need(prof is not None and prof[0] == c["subject_type"], 4, "figure_unknown_profile") + need((b["figure_index"] is not None) == prof[1], 4, "figure_index_mismatch") + need((b["group"] is not None) == prof[3], 4, "figure_group_mismatch") + acquisition(b) + + def acquisition(b): """§2.4.5 rule 2: the class is one energy-measurement.md §2 names, not the legacy HwShunt, and carries exactly the evidence §2.3-§2.5 give it: a refined hardware class @@ -499,6 +658,21 @@ def class_rules(c): need(p["basis"] != "", 4, "lawful_basis_purpose_without_basis") need(p["purpose"] not in seen, 4, "lawful_basis_duplicate_purpose") seen.add(p["purpose"]) + elif cls == "measurement": + figure_rules(c) + # Rule 4: a hardware figure is a reading, and its report names the reader. + need(b["energy_provenance"] not in ("OnChipCounter", "CalibratedInstrument") or b["power_reader"] is not None, + 4, "measurement_no_power_reader") + if b["bracket"] is not None: + bracket_rules(b["bracket"], b["joules_micro"], b["energy_provenance"], b["energy_uncertainty"]) + elif cls == "operating-point": + figure_rules(c) + pts = [b[k] for k in ("op_requested", "op_committed", "op_delivered")] + need(all(p is None or len(p) > 0 for p in pts), 4, "operating_point_empty_point") + req, com, dlv = pts + need(not (dlv is not None and com is None), 4, "operating_point_delivered_alone") + need(not ((req is not None or com is not None) and dlv is None), 4, "operating_point_incomplete") + need(dlv is not None or b["prediction"] is not None, 4, "operating_point_empty") elif cls == "log-inclusion": need(b["log_receipt"] != "", 4, "log_inclusion_no_evidence") elif cls in ("soft-binding", "binding-recovery"): @@ -592,6 +766,14 @@ def check_context(c, ctx): raise ValueError("bad object.joules_micro") need(c["class"] == "energy" and o["content_hash"] == c["subject"], 6, "context_mismatch") need(o["joules_micro"] == b["joules_micro"], 6, "energy_disagrees_with_delivery") + label = class_of_json(o) # the label in the object's leaf, where its leaf is labelled + need(label is None or label == class_of_body(b), 6, "energy_class_disagrees_with_delivery") + if "figure" in ctx: + check_figure(c, figure_view(ctx["figure"])) + elif c["class"] == "measurement": + # §2.4.8: a report is its figure signer's only once step 6 has run against the + # figure (check 5); checked without it, it is not accepted. + raise Reject(6, "figure_not_held") if "oer" in ctx: r, keys = ctx["oer"]["receipt"], ctx["oer"]["keys"] need(c["class"] == "energy" and b["oer_receipt"] is not None @@ -636,6 +818,71 @@ def check_context(c, ctx): need(any(p["purpose"] == b["purpose"] for p in db["purposes"]), 6, "decision_purpose_not_declared") +def class_of_json(m): + """A class as energy-measurement §2.7 reads one from a receipt's JSON members: None + for no class; (label, ppm, window, filtering, reference) otherwise, an on-chip + counter's absent filtering state read as Unknown. A label that cannot be read is a + malformed corpus (ValueError), not a verdict.""" + label = m.get("energy_provenance") + u, f, r = m.get("energy_uncertainty"), m.get("energy_counter_filtering"), m.get("energy_calibration_ref") + if label is None: + if u is not None or f is not None or r is not None: + raise ValueError("evidence without a class") + return None + if label in ("OnChipCounter", "CalibratedInstrument"): + if not isinstance(u, dict): + raise ValueError("a refined class without its uncertainty") + ppm, win = u["relative_ppm"], u["window_us"] + if label == "OnChipCounter": + return (label, ppm, win, "Unknown" if f is None else f, None) + return (label, ppm, win, None, r) + if label not in STRONGEST: + raise ValueError("unknown class " + str(label)) + return (label, None, None, None, None) + + +def class_of_body(b): + """The class a claim's body restates, in class_of_json's form (step 4 has admitted it).""" + return class_of_json(b) + + +def figure_view(f): + """A `figure` context as (profile, identity, group, {index: (joules, class)}, signer), + or None for a receipt that does not verify. A `claim` is an energy claim's wire form, + viewed under `moq-jwp:claim-link`; a `view` is given as the verifier holds it.""" + if "claim" in f: + if not isinstance(f["claim"], str): + raise TypeError("a figure claim is given as its wire form") + try: + e = verify_claim(f["claim"]) + except Reject: + return None + figs = {} + if e["class"] == "energy": + figs[None] = (e["body"]["joules_micro"], class_of_body(e["body"])) + return ("moq-jwp:claim-link", link_hash(e), None, figs, e["signer_pubkey"]) + v = f["view"] + figs = {x["index"]: (x["joules_micro"], class_of_json(x)) for x in v["figures"]} + return (v["profile"], v["identity"], v["group"], figs, v["signer"]) + + +def check_figure(c, view): + """§2.4.8 / §2.4.9 against the figure, in order.""" + b = c["body"] + need(c["class"] in ("measurement", "operating-point"), 6, "context_mismatch") + need(view is not None, 6, "context_invalid") + profile, identity, group, figs, signer = view + need(profile == b["figure_profile"] and identity == c["subject"] and group == b["group"] + and b["figure_index"] in figs, 6, "context_mismatch") + joules, cls = figs[b["figure_index"]] + need(joules == b["joules_micro"], 6, "figure_disagrees") + need(cls is not None, 6, "figure_unclassified") + need(cls == class_of_body(b), 6, "figure_class_disagrees") + if c["class"] == "measurement": + need(PROFILES[b["figure_profile"]][2], 6, "figure_signer_unknown") + need(signer == c["signer_pubkey"], 6, "figure_signer_mismatch") + + def held_previous(ctx): """§3.1 step 5's previous claim: one that does not itself pass steps 1-4 is not held — failing steps 1-3 it is not known to be its signer's, failing step 4 it breaks its @@ -759,7 +1006,47 @@ def fixture_matches_wai_rs(): return fails +def bracket_fixture(): + """The brackets wai-rs pins in `energy_report::pinned_brackets`, read from its source, + with the figure and least declaration this script computes for each: + [(name, values, F, p)].""" + src_path = os.path.join(HERE, "..", "wai-rs", "src", "energy_report.rs") + src = open(src_path, encoding="utf-8").read() + body = src[src.index("fn pinned_brackets()"):] + body = body[:body.index("\n}\n")] + out = [] + num = r"(x|[0-9_]+)" + pat = (r'\("(\w+)", v\(s\(' + num + r', ' + num + r', ' + num + r'\), s\(' + num + r', ' + num + r', ' + num + + r'\), ' + num + r', ' + num + r', ' + num + r', (FromRest|Running)\), ' + num + r', ' + num + r'\)') + for m in re.finditer(pat, body): + g = [MAX_INT if x == "x" else int(x.replace("_", "")) if x not in ("FromRest", "Running") else x for x in m.groups()[1:]] + br = {"idle": {"n": g[0], "mean_uw": g[1], "sd_uw": g[2]}, "busy": {"n": g[3], "mean_uw": g[4], "sd_uw": g[5]}, + "window_us": g[6], "completions": g[7], "in_flight": g[8], + "opening": "from-rest" if g[9] == "FromRest" else "running"} + out.append((m.group(1), br, g[10], g[11])) + return out + + +def brackets_match_wai_rs(): + """The figures and least declarations wai-rs pins equal what this script computes, and + one ppm less is not supported.""" + fails, cases = [], bracket_fixture() + if len(cases) < 7: + return ["fewer pinned brackets than expected were read from energy_report.rs"] + for name, br, f, p in cases: + got_f = bracket_figure(br) + got_p = least_ppm(br, f) if got_f is not None else None + if got_f != f or got_p != p: + fails.append(f"{name}: computed F={got_f} p={got_p}, pinned F={f} p={p}") + return fails + + def main(argv): + if argv[1:2] == ["brackets"]: + for name, br, _, _ in bracket_fixture(): + f = bracket_figure(br) + print(name, f, least_ppm(br, f)) + return 0 if argv[1:2] == ["verdicts"] and len(argv) == 3: return verdicts(argv[2]) if argv[1:2] == ["fixture"]: @@ -777,6 +1064,9 @@ def main(argv): ff = fixture_matches_wai_rs() print(("PASS" if not ff else "FAIL") + " canonical fixture equals the literals pinned in wai-rs " + ",".join(ff)) fail += bool(ff) + bm = brackets_match_wai_rs() + print(("PASS" if not bm else "FAIL") + " bracket arithmetic equals the literals pinned in wai-rs " + ",".join(bm)) + fail += bool(bm) rd = OER.check_canonical_form() # the OER reference's own fixtures, its reading of -0 among them print(("PASS" if not rd else "FAIL") + " the OER reference's canonical form and number reading hold " + ",".join(rd)) fail += bool(rd) diff --git a/wai/claim-conformance/differential.py b/wai/claim-conformance/differential.py index 4eaa8a72b..534721443 100644 --- a/wai/claim-conformance/differential.py +++ b/wai/claim-conformance/differential.py @@ -11,7 +11,12 @@ half-microjoule and at the edges of oer-receipts §4's quantities, OER receipts and floors sit at §6's tolerances, OER receipts holding numbers §4 forbids in a body (`-0`, fractions, integers beyond I-JSON's range) in members of every kind, context receipts that do not themselves verify, and bodies of every class with one defect, or two -at different positions, where the order §3.1 step 2 reads a body in decides the reason — +at different positions, where the order §3.1 step 2 reads a body in decides the reason; +measurement reports whose brackets sit at the least declaration and one ppm below it, at a +half microjoule, at the ratio thresholds, with a flat idle bracket, with products far past +128 bits and with every pattern of liveness facts; operating-point claims with every +presence combination of their points and prediction; reports checked against figure views +that agree and disagree in each member; and energy claims against labelled objects — writes them in the corpus's format with no declared outcome, runs both verifiers' `verdicts` mode, and reports every input on which they differ. @@ -630,6 +635,177 @@ def documents(corpus): return out +def reports(ins, rng, n): + """measurement and operating-point claims (§2.4.8-§2.4.9) around the edges of their + rules: brackets at and one ppm below the least declaration, figures one off, ties at a + half microjoule, the ratio thresholds, flat brackets and a held value, products past 128 bits, + every pattern of liveness facts; operating points in every presence combination; and + reports and energy claims against figure views and labelled objects.""" + m0 = ins.receipt("measurement_accept") + fig0 = ins.by_name["measurement_accept"]["context"]["figure"] + op0 = ins.receipt("operating_point_accept") + hw = ["OnChipCounter", "CalibratedInstrument"] + + def summary(n_, mean, sd, live=None, run=None): + live = n_ if live is None else live + return {"n": n_, "mean_uw": mean, "sd_uw": sd, "live_intervals": live, "longest_live_run": n_ if run is None else run} + + def class_fields(label, ppm, window, figure): + b = {"energy_provenance": label, "energy_uncertainty": None, "energy_counter_filtering": None, + "energy_calibration_ref": None} + if label == "OnChipCounter": + b.update(energy_uncertainty={"relative_ppm": ppm, "window_us": window}, energy_counter_filtering="Unknown") + elif label == "CalibratedInstrument": + b.update(energy_uncertainty={"relative_ppm": ppm, "window_us": window}, + energy_calibration_ref="https://calibration.example.org/certificates/0001") + return dict(b, joules_micro=figure) + + def report(br, label, ppm, window, figure, name, ctx=None): + body = dict(m0["body"], bracket=br, **class_fields(label, ppm, window, figure)) + ins.add(name, CV.jcs(sign(dict(m0, body=body), METER)), ctx) + + def no_reader(br, label, ppm, window, figure, name, ctx=None): + body = dict(m0["body"], bracket=br, power_reader=None, **class_fields(label, ppm, window, figure)) + ins.add(name, CV.jcs(sign(dict(m0, body=body), METER)), ctx) + + def around(name, br, labels=("OnChipCounter", "CalibratedInstrument", "ModelBased")): + f = CV.bracket_figure(br) + for label in labels: + if f is None: + report(br, label, 300_000, br["window_us"], 1, f"{name}_nofig_{label}") + continue + p = CV.least_ppm(br, f) or 2 ** 32 - 1 + for d_ppm, d_fig, d_win in [(0, 0, 0), (-1, 0, 0), (1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1)]: + ppm = max(1, min(2 ** 32 - 1, p + d_ppm)) + fig = f + d_fig + if fig <= 0 or fig > CV.MAX_INT: + continue + report(br, label, ppm, br["window_us"] + d_win, fig, f"{name}_{label}_{d_ppm}_{d_fig}_{d_win}") + + def bracket(idle, busy, t, c, nn, opening): + return {"idle": idle, "busy": busy, "window_us": t, "completions": c, "in_flight": nn, "opening": opening} + + # Fixed edges. + around("half_tie", bracket(summary(10, 20_000_000, 1_000), summary(14, 21_000_000, 1_000), 2_500_000, 1_000_000, 1, "running")) + around("flat_idle", bracket(summary(10, 20_000_000, 0), summary(14, 30_000_000, 115_471), 3_000_000, 60, 10, "from-rest")) + around("flat_busy", bracket(summary(10, 20_000_000, 115_471), summary(14, 30_000_000, 0), 3_000_000, 60, 10, "from-rest")) + around("held_value", bracket(summary(10, 5_000_000, 0, 1, 1), summary(14, 10_500_000, 2_980_708, 2, 1), 3_000_000, 60, 10, "from-rest")) + # A report that names no power reader, with and without a bracket, under each class. + for label in ("OnChipCounter", "CalibratedInstrument", "ModelBased", "Estimator"): + unit_br = m0["body"]["bracket"] + f = CV.bracket_figure(unit_br) + p = CV.least_ppm(unit_br, f) + for br in (unit_br, None): + no_reader(br, label, p, unit_br["window_us"], f, f"no_reader_{label}_{br is None}", fig0) + around("wide", bracket(summary(10, 20_000_000, 115_471), summary(14, 30_000_000, 115_471), 10 ** 11, 10 ** 13, 64, "from-rest")) + for k in (10, 3, 1): + for d in (0, -1): + around(f"ratio_{k}_{d}", bracket(summary(10, 20_000_000, 100_000), summary(14, 20_000_000 + k * 100_000 + d, 100_000), + 3_000_000, 60, 10, "from-rest"), labels=("OnChipCounter", "ModelBased")) + # Liveness facts: every pattern around the thresholds, in each bracket. + for n_ in (4, 5, 6, 10, 14): + for live in sorted({0, 1, 2, 4, 5, n_ * 4 // 5, n_ * 4 // 5 + 1, n_ - 1, n_, n_ + 1}): + for run in sorted({0, 1, 2, 3, live, live + 1}): + idle = summary(10, 20_000_000, 115_471) + busy = summary(n_, 30_000_000, 115_471, live, run) + br = bracket(idle, busy, 3_000_000, 60, 10, "from-rest") + f = CV.bracket_figure(br) + p = CV.least_ppm(br, f) + report(br, "OnChipCounter", p, 3_000_000, f, f"live_busy_{n_}_{live}_{run}") + br2 = bracket(summary(n_, 20_000_000, 115_471, live, run), summary(14, 30_000_000, 115_471), 3_000_000, 60, 10, "from-rest") + report(br2, "OnChipCounter", CV.least_ppm(br2, f), 3_000_000, f, f"live_idle_{n_}_{live}_{run}") + # Random brackets. + for i in range(n): + ni, nb = rng.choice([1, 2, 3, 5, 10, 14, 50]), rng.choice([1, 2, 3, 5, 10, 14, 50]) + idle_mean = rng.randrange(0, 50_000_000) + busy_mean = idle_mean + rng.choice([-1, 0, 1, rng.randrange(1, 40_000_000)]) + sd = lambda: rng.choice([0, 1, rng.randrange(1, 5_000_000)]) # noqa: E731 + live = lambda m: rng.choice([m, m - 1, 0, 2, 5]) # noqa: E731 + idle = summary(ni, idle_mean, sd(), live(ni)) + busy = summary(nb, max(0, busy_mean), sd(), live(nb)) + t = rng.choice([0, 1, rng.randrange(1, 10 ** 7), rng.randrange(1, 10 ** 12)]) + c = rng.choice([0, 1, rng.randrange(1, 10 ** 6), rng.randrange(1, 10 ** 13)]) + nn = rng.choice([0, 1, rng.randrange(1, 65)]) + br = bracket(idle, busy, t, c, nn, rng.choice(["from-rest", "running"])) + f = CV.bracket_figure(br) + label = rng.choice(hw + ["ModelBased", "Estimator"]) + if f is None: + report(br, label, 300_000, max(1, t), rng.randrange(1, 10 ** 6), f"random_{i}_nofig") + continue + p = CV.least_ppm(br, f) + ppm = max(1, min(2 ** 32 - 1, (p or 2 ** 32 - 1) + rng.choice([0, 0, -1, 1, 1000]))) + if f * ppm < 288_676: + ppm = 288_676 // f + 1 + report(br, label, ppm, rng.choice([max(1, t), t + 1]), f + rng.choice([0, 0, 0, 1, -1]) or 1, f"random_{i}") + + # Operating points: every presence combination, and points that are empty, not terms, + # or not integers. + pts = [None, {}, {"height": 1080}, {"height": 720}, {"frame rate": 1}, {"height": "1080"}, {"height": -1}] + pred = op0["body"]["prediction"] + for ri, r in enumerate(pts): + for ci, c in enumerate(pts): + for di, d in enumerate(pts): + for pi, pr in enumerate([None, pred]): + body = dict(op0["body"], op_requested=r, op_committed=c, op_delivered=d, prediction=pr) + ins.add(f"op_{ri}{ci}{di}{pi}", CV.jcs(sign(dict(op0, body=body), METER)) if all( + not isinstance(x, dict) or all(isinstance(v, int) and v >= 0 for v in x.values()) for x in (r, c, d)) + else text_of(sign_raw(dict(op0, body=body), METER)), {"figure": fig0}) + + # Figure views that agree and disagree in each member. + e = json.loads(fig0["claim"]) + eb = e["body"] + base = {"profile": "moq-jwp:claim-link", "identity": CV.link_hash(e), "group": None, + "figures": [{"index": None, "joules_micro": eb["joules_micro"], "energy_provenance": eb["energy_provenance"], + "energy_uncertainty": eb["energy_uncertainty"], + "energy_counter_filtering": eb["energy_counter_filtering"]}], + "signer": pk(METER)} + variants = [("same", {}), ("profile", {"profile": "wai:asset-receipt-id"}), ("identity", {"identity": "ab" * 32}), + ("group", {"group": "cd" * 32}), ("signer", {"signer": pk(OPERATOR)}), ("no_signer", {"signer": None})] + fig_variants = [("figure_plus", {"joules_micro": eb["joules_micro"] + 1}), ("unlabelled", {"energy_provenance": None, + "energy_uncertainty": None, "energy_counter_filtering": None}), + ("filtering_absent", {"energy_counter_filtering": None}), ("filtering_off", {"energy_counter_filtering": "Off"}), + ("ppm", {"energy_uncertainty": dict(eb["energy_uncertainty"], relative_ppm=eb["energy_uncertainty"]["relative_ppm"] + 1)}), + ("index", {"index": 0})] + for name, edit in variants: + ins.add("view_" + name, CV.jcs(m0), {"figure": {"view": dict(base, **edit)}}) + ins.add("view_op_" + name, CV.jcs(op0), {"figure": {"view": dict(base, **edit)}}) + for name, edit in fig_variants: + fv = [{k: v for k, v in dict(base["figures"][0], **edit).items() if v is not None or k == "index"}] + ins.add("view_fig_" + name, CV.jcs(m0), {"figure": {"view": dict(base, figures=fv)}}) + ins.add("view_none_given_claim_not_json", CV.jcs(m0), {"figure": {"claim": "not json"}}) + + # Energy claims against labelled objects. + ec = ins.receipt("energy_on_chip_counter_accept") + b = ec["body"] + lab = {"energy_provenance": "OnChipCounter", "energy_uncertainty": b["energy_uncertainty"], "energy_counter_filtering": "Unknown"} + for name, j, extra in [("same", b["joules_micro"], lab), ("no_label", b["joules_micro"], {}), + ("filtering_absent", b["joules_micro"], {k: v for k, v in lab.items() if k != "energy_counter_filtering"}), + ("model", b["joules_micro"], {"energy_provenance": "ModelBased"}), + ("ppm", b["joules_micro"], dict(lab, energy_uncertainty={"relative_ppm": 20_001, "window_us": 1_000_000})), + ("figure", b["joules_micro"] + 1, lab)]: + ins.add("labelled_object_" + name, CV.jcs(ec), {"object": dict({"content_hash": ec["subject"], "joules_micro": j}, **extra)}) + + +def sign_raw(c, k): + """`c` signed over its JCS where JCS can serialise it, with a member JCS refuses (a + string or negative point value) left for the verifier to refuse at step 2. The + signature covers a canonical form computed with that member as written.""" + c = copy.deepcopy(c) + c.pop("sig", None) + c["signer_pubkey"] = pk(k) + def canon(v): + if isinstance(v, int) and not isinstance(v, bool) and v < 0: + return str(v) + if isinstance(v, dict): + keys = sorted(v, key=lambda x: x.encode("utf-16-be")) + return "{" + ",".join(json.dumps(x, ensure_ascii=False) + ":" + canon(v[x]) for x in keys) + "}" + if isinstance(v, list): + return "[" + ",".join(canon(x) for x in v) + "]" + return CV.jcs(v) + c["sig"] = k.sign(b"moq-jwp:claim\x01" + canon(c).encode()).hex() + return c + + def contexts(ins): decl = ins.receipt("soft_binding_accept") record = ins.receipt("binding_recovery_accept") @@ -735,6 +911,7 @@ def main(argv): readings(ins) contexts(ins) bodies(ins) + reports(ins, random.Random(f"reports {a.seed}"), a.random) # its own stream, as classes() doc = text_of({"about": "differential inputs; no outcome is declared", "vectors": ins.out}) with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as f: f.write(doc) diff --git a/wai/claim-conformance/vectors.json b/wai/claim-conformance/vectors.json index d3522e64e..4e9e5dd34 100644 --- a/wai/claim-conformance/vectors.json +++ b/wai/claim-conformance/vectors.json @@ -1,5 +1,5 @@ { - "about": "jwp-receipts section 2.4.3-2.4.7, 2.6 and 3.1-3.2: typed claim receipts and the soft binding class. Every vector is synthetic: keys are Ed25519 from fixed 32-byte seeds (0x11 publisher, 0x22 meter, 0x33 operator, 0x44 sink, 0x55 resolver) and attest nothing; figures are invented; algorithm names, endpoints, logs and registries use reserved example domains; vocabulary terms are illustrative and are not checked against the vocabulary. Expected outcomes are declared per vector in the generator, not copied from a verifier. Regenerate with `wai_claim_vectors gen`.", + "about": "jwp-receipts section 2.4.3-2.4.9, 2.6 and 3.1-3.2: typed claim receipts and the soft binding class. Every vector is synthetic: keys are Ed25519 from fixed 32-byte seeds (0x11 publisher, 0x22 meter, 0x33 operator, 0x44 sink, 0x55 resolver) and attest nothing; figures are invented; algorithm names, endpoints, logs and registries use reserved example domains; vocabulary terms are illustrative and are not checked against the vocabulary. Expected outcomes are declared per vector in the generator, not copied from a verifier. Regenerate with `wai_claim_vectors gen`.", "vectors": [ { "name": "energy_accept", @@ -3049,6 +3049,1247 @@ "step": 2, "reason": "field_type" } + }, + { + "name": "measurement_accept", + "about": "a report on the meter's own energy claim: ten workers from rest, sixty completions over 3 s, a 10 W marginal; the figure and the declaration are the least the bracket's integers give, every sample live", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"1e3f79ad3571073e64b4ff97733c472c030d241377367a511e2a7be4f5975ea0fb34f2612fc78271941281fa185c3ba1252e8b8c96e3b3c61ee7854580e56e0f\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "claim": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"grade\":\"measured\",\"joules_micro\":461538,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"e31eb3b79ac562baf7d65c207ac1fbc39be6ae444f1fa2686e01356ccc68b9d462a3d2860b4303baa5f4dcd4933c0a9a64085f7ae349123bf872e5f88b66a307\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}" + } + }, + "expected": { + "outcome": "accept", + "link_hash": "31cfac4ec8c104a7db59c4156cd7968f539c690c7b5df0c69e34cf2c48529db9" + } + }, + { + "name": "measurement_running_accept", + "about": "the same bracket opened on running workers: the work is C, not C + N/2", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"running\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":96344,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":500000,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"eabce776312d55a29506d7a9d426d0d53a12240c1ecb763c5b0c69adf173384ef5637d267027d55fdf4ab0ddf3b30830f71c4d90560fc56819eb1e4eee38e203\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"e8736d864cd715c5a50527390cd9f8f3dfc8409aecc46090fa26e6cdc38fe5ec\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "claim": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":96344,\"window_us\":3000000},\"grade\":\"measured\",\"joules_micro\":500000,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"5bb5d00aed33f28d1a8435bfad14dc80b6e31b3447e819803db0c5958ae043763490f43ef7e7e0e98f999d57f3241553059282edcf239272faf08d074863710d\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}" + } + }, + "expected": { + "outcome": "accept", + "link_hash": "a9501d42ed88da48e7e4fa6c90d304041bddc93fd0c396a7a42f2524d4cd7c56" + } + }, + { + "name": "measurement_one_microjoule_accept", + "about": "a 0.563 uJ estimate sealed as 1 uJ: its declaration is dominated by the rounding, and its products exceed 128 bits", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":500000},\"completions\":50000000,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":200000},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":2815000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":288796,\"window_us\":2815000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":1,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"c4da40a84d34bd6caca1fe0abeb0fd73cd0bb0e7112cc5be0b3149f5df154adf77d1a2d6fc6d7ddfc5d1167b216e3ec08185b82782e185c22883500b32429e0a\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"0333fccf983324e5bdb71c2b3c66ae4a136760c0677f34213020bb8079570372\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "claim": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":288796,\"window_us\":2815000},\"grade\":\"measured\",\"joules_micro\":1,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"8315cdbc7849c5e9df31fb5306474abd93ae6e605bcf035d04f5230221813d5b5cc4eff76e29ba6fa92d75b3437eb6596f3464e8d11385be0a775a1cf662420c\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}" + } + }, + "expected": { + "outcome": "accept", + "link_hash": "f1192ec4089fd7522d4e2d25dcee337116da5d27af5974ee94463e6743612ff3" + } + }, + { + "name": "measurement_half_up_accept", + "about": "an estimate of exactly 2.5 uJ: a half rounds up, to 3", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":21000000,\"n\":14,\"sd_uw\":1000},\"completions\":1000000,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":1000},\"in_flight\":1,\"opening\":\"running\",\"window_us\":2500000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":96226,\"window_us\":2500000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":3,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"fd7360b82ff38b1bf97c9242e7fca6742b65d0658e181db52b65b2ba8e2126c0a1c45b444a919e2a3d5201899d9ee9eedafe864402f4ed161f9f90905325750d\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"9eb58773fccef67a99e96c1c4d5212afaf5151b0c311c9647dd20b9615267d84\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "claim": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":96226,\"window_us\":2500000},\"grade\":\"measured\",\"joules_micro\":3,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"a83ae8b8b013296d14641f5dd6864743d33d7f7c44b9468540ad836136f00a8475bea5222adba19d279818536491e1c1105c52668f35df09dc794eb69625e70a\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}" + } + }, + "expected": { + "outcome": "accept", + "link_hash": "aa4a132e9a381c3b8d03c8c3e3dd9cae22db5262777a66695a8bb79f5238162e" + } + }, + { + "name": "measurement_wide_accept", + "about": "a window of 10^11 us and 10^12 completions: products past 200 bits", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":1000000000000,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":100000000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":288715,\"window_us\":100000000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":1,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"597740325e6e5600a70028d3e027e01030301acbf5df5594110a46ba06ccbe3a036e7a451016d55b08e53ffa726845ffe491ead5eb703606446930b24587da09\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"a2104c02f0556626cce21a9af66529c73c37a3cef0a7ab7c3364180002f8fcf9\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "claim": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":288715,\"window_us\":100000000000},\"grade\":\"measured\",\"joules_micro\":1,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"9dfdc75e41137c9be555aa2fe571a29911211a27fac06535e319d2ea36b80d2739d883ddbc8ed723a2615db57d4357424040ff71e5104925e751570f8d9b9a0a\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}" + } + }, + "expected": { + "outcome": "accept", + "link_hash": "93a20b0a6d6f0a1fc8acbae65b92ede02b50c45c41a77acf47bf43787b146a78" + } + }, + { + "name": "measurement_declared_above_least_accept", + "about": "a calibrated instrument declaring more than the bracket's least: its calibration covers more than the bracket does", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":\"https://calibration.example.org/certificates/0001\",\"energy_counter_filtering\":null,\"energy_provenance\":\"CalibratedInstrument\",\"energy_uncertainty\":{\"relative_ppm\":45669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"90b755c7c9fbe7b25a0e3de149a8e05f945dc701614470af787a2064d355d0620ec2a5d50c6b1cfc7dadba464f9255d7072d2e6788bbe744885ec592b77c7202\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"a6d676c805901d84d6797d60c862619ee4c1160df0eeb97a58c10bc83da73031\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "claim": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":\"https://calibration.example.org/certificates/0001\",\"energy_counter_filtering\":null,\"energy_provenance\":\"CalibratedInstrument\",\"energy_uncertainty\":{\"relative_ppm\":45669,\"window_us\":3000000},\"grade\":\"measured\",\"joules_micro\":461538,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"63b4c4975ee05e58715c96faead6623dfc860f9ec06a6998b400df8c0471ea4f6d12bfa945ead835dd1e640e5291729a007a2ec1f088d7859cf1ff2feca4c906\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}" + } + }, + "expected": { + "outcome": "accept", + "link_hash": "fcac38966a4ec4ebce5153aaf515c3919adf19b98a561d4f9c5b241bad9fcb79" + } + }, + { + "name": "measurement_model_based_bracket_accept", + "about": "a ModelBased figure with a bracket of four samples each: no declaration to check, and liveness binds the hardware classes only", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":4,\"longest_live_run\":4,\"mean_uw\":30000000,\"n\":4,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":4,\"longest_live_run\":4,\"mean_uw\":20000000,\"n\":4,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"4b97fcbd81fd8f2e2fbea5cdc21dc0d7eb422c42c372d34fc2dc76a170a03d524735d88a4276f126148cba2c1690fd4b4ecf4ce351f42106070a6237aadea60d\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"8fa4deea6e8c07b3d762aff6a998238a4bfbe88b3f2f03a30e7fb5637db09700\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "claim": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"grade\":\"derived\",\"joules_micro\":461538,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"d59e5b819800812917ab3023ef4c015c1ac77a18b00601922359e98555a051413ba908d50ae306cdb7510f3021e94b8b1188ced8bf49c3c6b63f60872fa28e04\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}" + } + }, + "expected": { + "outcome": "accept", + "link_hash": "a3beac76f237b03ee42f9eec93881e96f07dea0ce752702bd441dcfd60166372" + } + }, + { + "name": "measurement_model_based_dead_rail_accept", + "about": "a ModelBased figure whose bracket shows a rail that was not live: a model's figure is not a reading, so liveness is not its rule", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":1,\"longest_live_run\":1,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":0,\"longest_live_run\":0,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"e70d483679d77025e2b0be1bb0a846c2eaeefd0a1b45fbbd5cf37710ce97145641114017ed3dc4d6d780c70254df5041f2c932631e76e2299b8fd2a13cd4d408\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"8fa4deea6e8c07b3d762aff6a998238a4bfbe88b3f2f03a30e7fb5637db09700\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "claim": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"grade\":\"derived\",\"joules_micro\":461538,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"d59e5b819800812917ab3023ef4c015c1ac77a18b00601922359e98555a051413ba908d50ae306cdb7510f3021e94b8b1188ced8bf49c3c6b63f60872fa28e04\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}" + } + }, + "expected": { + "outcome": "accept", + "link_hash": "c4e6d317be47bc126bd6fb30521fc6426ff2dcb8836d27b2062013f365b47fcf" + } + }, + { + "name": "measurement_no_bracket_accept", + "about": "an Estimator figure obtained under no bracket: the report states the build alone, a debug build, its toolchain, target and reader unknown", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":null,\"build\":{\"profile\":\"debug\",\"target\":null,\"toolchain\":null},\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"Estimator\",\"energy_uncertainty\":null,\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":9000,\"power_reader\":null},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"736932eac49fcaacdaf93ef2baf563f0167e5c594bcd6c99b9a079b9a8ff29d60a3e6ff6c1e92aecd40ecafa7e896fcadb7b6dd2a798f19eaada4ee7dc80dc0a\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"4f17f1bd9380d3758551f1975e0e9a5b8838d634d97611790bcfc72ce04499a7\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "claim": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"Estimator\",\"energy_uncertainty\":null,\"grade\":\"projected\",\"joules_micro\":9000,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"a808cee408e0f73c8852a581cc487a791333dbb26feba9e02bf1720e4650c26145e9be23be2bc9e118338ce3930550001436cda4b3ea8329aec6b7f452989f06\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}" + } + }, + "expected": { + "outcome": "accept", + "link_hash": "f1a607e8617c465ab84d95502f2b196b30d4b08091fbb2d3a96d5773bb3a27a4" + } + }, + { + "name": "measurement_counter_no_bracket_accept", + "about": "an OnChipCounter figure not obtained under a bracket (a counter read through its span): the report names the reader and carries no bracket, so the rail's liveness rests on its producer, and no verdict is derived", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":null,\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":20000,\"window_us\":1000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":9000,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"2bc40664cf691d9ad903703895ace1d340c1148b235788d0c6e8b81e2851c5055db5c93bf26ae96b557f92730a9ce7197166147b4b7961b8537facc3d58c6509\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"96825459681c70064d27722850b4f269be31d0dafa47c71155df88781dacf0b3\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "claim": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":20000,\"window_us\":1000000},\"grade\":\"measured\",\"joules_micro\":9000,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"e33fa31adb9ce8372eee85500de189cc437ded7b1c52c3d6cba0c4784ee1b6c6a151fc1c550d68d89c15e709c93df08be0bb6f968b8954b498f32fa0e059ec0f\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}" + } + }, + "expected": { + "outcome": "accept", + "link_hash": "7c617b37f8502db82afe457d0b706568311e633859586015b7b3f9ce57d31864" + } + }, + { + "name": "measurement_step_accept", + "about": "a report on step 1 of a provenance receipt, given as the view of a receipt the verifier has verified", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":null,\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"figure_index\":1,\"figure_profile\":\"wai:asset-receipt-id\",\"group\":null,\"joules_micro\":4200,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"86364996bbe532596bc9b4971b5edac37da55a585bef2d502f147e27df4f079bb60b37d4915bcab5684b06a699f1068daf7af5f884d157e4e30a5eb9ee8e8109\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"ac0a2d0072577e64b8350c275d7b0e4e57aab6550fb3958ad9959a58bc258fea\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "view": { + "profile": "wai:asset-receipt-id", + "identity": "ac0a2d0072577e64b8350c275d7b0e4e57aab6550fb3958ad9959a58bc258fea", + "group": null, + "figures": [ + { + "index": 0, + "joules_micro": 0 + }, + { + "index": 1, + "joules_micro": 4200, + "energy_provenance": "ModelBased" + }, + { + "index": 2, + "joules_micro": 90 + } + ], + "signer": "a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0" + } + } + }, + "expected": { + "outcome": "accept", + "link_hash": "85dfa4e6a78ce0b567f252cdd89b70867fad0d9994842d51b91b60eee48abbe7" + } + }, + { + "name": "measurement_object_accept", + "about": "a report on a delivered object: its subject is the object's content hash, its index the object's leaf, its group the group receipt's link hash, and the publisher who signed the leaf signs it", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":2,\"figure_profile\":\"moq-jwp:object\",\"group\":\"000b73b044437005c34711736a91e1d99fb9513d4052378e43f947c75d3b5261\",\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"c1760a152995a802acddb2f0b8582f63bb733841b3da71416eaeb830ecb7c9b50f4db78bdd210e4ea484c86d42bc5e6e6f695a8c1b8fab0e147286e7cc85b80f\",\"signer_id\":\"did:example:publisher\",\"signer_pubkey\":\"d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}", + "context": { + "figure": { + "view": { + "profile": "moq-jwp:object", + "identity": "5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e", + "group": "000b73b044437005c34711736a91e1d99fb9513d4052378e43f947c75d3b5261", + "figures": [ + { + "index": 2, + "joules_micro": 461538, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 44669, + "window_us": 3000000 + }, + "energy_counter_filtering": "Unknown" + } + ], + "signer": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737" + } + } + }, + "expected": { + "outcome": "accept", + "link_hash": "9325d323c6fe2d20a48ae6905a776637879d429c5f135805453d1f4bfb4a7843" + } + }, + { + "name": "operating_point_accept", + "about": "requested, committed and delivered the same point, and the energy a declared descriptor predicted: the commitment was met, which no claim states", + "procedure": "claim", + "receipt": "{\"body\":{\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"op_committed\":{\"frame_rate_milli\":30000,\"height\":1080},\"op_delivered\":{\"frame_rate_milli\":30000,\"height\":1080},\"op_requested\":{\"frame_rate_milli\":30000,\"height\":1080},\"prediction\":{\"descriptor_hash\":\"1122437cacd17ba56225e487fcb4f5350858024b1fa7051cdd09dd526cba2f32\",\"predicted_joules_micro\":450000,\"predictor\":\"https://models.example.org/complexity-to-energy\"},\"vocabulary\":\"https://vocabulary.example.org/operating-points\"},\"class\":\"operating-point\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":2,\"sig\":\"33b1b8ada30b658abc288e09346d904b570b3f7ba95a4b74f7858d494dbd0d400a261ec371b3e98a6b9a37b2a45d96288600bbb1af496b4d1513e65c9d416001\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "claim": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"grade\":\"measured\",\"joules_micro\":461538,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"e31eb3b79ac562baf7d65c207ac1fbc39be6ae444f1fa2686e01356ccc68b9d462a3d2860b4303baa5f4dcd4933c0a9a64085f7ae349123bf872e5f88b66a307\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}" + } + }, + "expected": { + "outcome": "accept", + "link_hash": "1add710393534d82433f29bade194d1bebf899759cfbfc4447ea58ce7b7cca4c" + } + }, + { + "name": "operating_point_not_met_accept", + "about": "committed 1080 lines, delivered 720: both are recorded, and a reader derives that the commitment was not met", + "procedure": "claim", + "receipt": "{\"body\":{\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"op_committed\":{\"frame_rate_milli\":30000,\"height\":1080},\"op_delivered\":{\"frame_rate_milli\":30000,\"height\":720},\"op_requested\":{\"frame_rate_milli\":30000,\"height\":1080},\"prediction\":null,\"vocabulary\":\"https://vocabulary.example.org/operating-points\"},\"class\":\"operating-point\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":2,\"sig\":\"ee58e9673e6ed767a25916a6cac7814862a43e83e592fbb9201e21c1af7e81fcd6cf35a4443a7b3a1f6a55fe925198735ed3b9a8dd2031b4d312b73ee5f76f06\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "claim": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"grade\":\"measured\",\"joules_micro\":461538,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"e31eb3b79ac562baf7d65c207ac1fbc39be6ae444f1fa2686e01356ccc68b9d462a3d2860b4303baa5f4dcd4933c0a9a64085f7ae349123bf872e5f88b66a307\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}" + } + }, + "expected": { + "outcome": "accept", + "link_hash": "fab835014e0b5c6d436b21e73add65d399fed640d7cfc4d9d4a55b0b8ace0274" + } + }, + { + "name": "operating_point_own_account_accept", + "about": "no request: the acting party's own commitment and what it delivered", + "procedure": "claim", + "receipt": "{\"body\":{\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"op_committed\":{\"frame_rate_milli\":30000,\"height\":720},\"op_delivered\":{\"frame_rate_milli\":30000,\"height\":720},\"op_requested\":null,\"prediction\":null,\"vocabulary\":\"https://vocabulary.example.org/operating-points\"},\"class\":\"operating-point\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":2,\"sig\":\"8ea1a1fe049f31616ea4b02149e6f5d4b0494dd7c677adcf2ccc2a21133931217fc69617c740360de188b85bf81e9451453b58df4d5685f62d86f9f461b95502\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "claim": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"grade\":\"measured\",\"joules_micro\":461538,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"e31eb3b79ac562baf7d65c207ac1fbc39be6ae444f1fa2686e01356ccc68b9d462a3d2860b4303baa5f4dcd4933c0a9a64085f7ae349123bf872e5f88b66a307\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}" + } + }, + "expected": { + "outcome": "accept", + "link_hash": "23665602489e4918c7519ac8122162ad59eecc25a38af870a625065cfc0d8860" + } + }, + { + "name": "operating_point_prediction_only_accept", + "about": "a prediction alone, above the figure: the residual a reader derives is negative", + "procedure": "claim", + "receipt": "{\"body\":{\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"op_committed\":null,\"op_delivered\":null,\"op_requested\":null,\"prediction\":{\"descriptor_hash\":\"1122437cacd17ba56225e487fcb4f5350858024b1fa7051cdd09dd526cba2f32\",\"predicted_joules_micro\":500000,\"predictor\":\"https://models.example.org/complexity-to-energy\"},\"vocabulary\":\"https://vocabulary.example.org/operating-points\"},\"class\":\"operating-point\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":2,\"sig\":\"2a58588c91788e5ef6d2c1643d216096fff5aa1abbaf2159b9a876dd71480c3baa1880844b2ea411fa381b750db450549be13c0f70b196077ecfb6f649a8650c\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "claim": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"grade\":\"measured\",\"joules_micro\":461538,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"e31eb3b79ac562baf7d65c207ac1fbc39be6ae444f1fa2686e01356ccc68b9d462a3d2860b4303baa5f4dcd4933c0a9a64085f7ae349123bf872e5f88b66a307\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}" + } + }, + "expected": { + "outcome": "accept", + "link_hash": "0b23db31f16ae8ddb46375b05152bc63a1a8b2ccf5ce9836091aee25d2989763" + } + }, + { + "name": "operating_point_acting_party_accept", + "about": "recorded by the operator, not by the meter that signed the figure: an operating-point claim may be", + "procedure": "claim", + "receipt": "{\"body\":{\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"op_committed\":{\"frame_rate_milli\":30000,\"height\":1080},\"op_delivered\":{\"frame_rate_milli\":30000,\"height\":1080},\"op_requested\":{\"frame_rate_milli\":30000,\"height\":1080},\"prediction\":null,\"vocabulary\":\"https://vocabulary.example.org/operating-points\"},\"class\":\"operating-point\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":2,\"sig\":\"6d4146281934a7f8c00932098eaa910bd93c4ca61da058667114a6f846b1f86eb42ee3a5fb3fb01beb32f44f0b162b78e454de93111d3a8f6a46d1a89deb570d\",\"signer_id\":\"did:example:operator\",\"signer_pubkey\":\"17cb79fb2b4120f2b1ec65e4198d6e08b28e813feb01e4a400839b85e18080ce\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "claim": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"grade\":\"measured\",\"joules_micro\":461538,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"e31eb3b79ac562baf7d65c207ac1fbc39be6ae444f1fa2686e01356ccc68b9d462a3d2860b4303baa5f4dcd4933c0a9a64085f7ae349123bf872e5f88b66a307\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}" + } + }, + "expected": { + "outcome": "accept", + "link_hash": "9ddda7105a88f29d4408fe36a6c658562fec67fd9050b57c8bbd9d44efb40340" + } + }, + { + "name": "operating_point_on_binding_accept", + "about": "an operating point recorded against an energy binding's figure, which no Ed25519 key signs", + "procedure": "claim", + "receipt": "{\"body\":{\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"wai:energy-binding-id\",\"group\":null,\"joules_micro\":461538,\"op_committed\":{\"frame_rate_milli\":30000,\"height\":720},\"op_delivered\":{\"frame_rate_milli\":30000,\"height\":720},\"op_requested\":null,\"prediction\":null,\"vocabulary\":\"https://vocabulary.example.org/operating-points\"},\"class\":\"operating-point\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":2,\"sig\":\"1183095b52b632307a477346e313055425d24b7c6c9e778d418dab9812526d2222154e0e039de94afd2bb5271d0cb43cf9a8116e359530e42c93b6280f963d00\",\"signer_id\":\"did:example:operator\",\"signer_pubkey\":\"17cb79fb2b4120f2b1ec65e4198d6e08b28e813feb01e4a400839b85e18080ce\",\"subject\":\"1b03d02ea38bba751f7afbaac147f98bb493271a8e29494f5fee5e0e4ec6a940\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "view": { + "profile": "wai:energy-binding-id", + "identity": "1b03d02ea38bba751f7afbaac147f98bb493271a8e29494f5fee5e0e4ec6a940", + "group": null, + "figures": [ + { + "index": null, + "joules_micro": 461538, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 44669, + "window_us": 3000000 + }, + "energy_counter_filtering": "Unknown" + } + ], + "signer": null + } + } + }, + "expected": { + "outcome": "accept", + "link_hash": "3bec775a94182a755d917491b80555f7110a5c1451a7b1ba586b4f5c7e719fd8" + } + }, + { + "name": "energy_matches_labelled_delivery_accept", + "about": "the object's leaf is labelled, and the claim restates the leaf's figure, class and evidence", + "procedure": "claim", + "receipt": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":20000,\"window_us\":1000000},\"grade\":\"measured\",\"joules_micro\":12000,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"b4b6fc41b35a63093bb6e7fb0ee9c426cfb96dcda04dd0e2ccd3ee221a2414a33edc038515da39efba721695f3480c1fa4a875c64d14b83269af355a895e3100\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}", + "context": { + "object": { + "content_hash": "5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e", + "joules_micro": 12000, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + } + }, + "expected": { + "outcome": "accept", + "link_hash": "2835bfa8d59cfacbc89cf2eb3a54c91cc114b78198e5830d84ee295dd2659eb0" + } + }, + { + "name": "energy_filtering_absent_reads_unknown_accept", + "about": "the leaf's OnChipCounter label states no filtering state, which reads as Unknown, as the claim writes it", + "procedure": "claim", + "receipt": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":20000,\"window_us\":1000000},\"grade\":\"measured\",\"joules_micro\":12000,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"b4b6fc41b35a63093bb6e7fb0ee9c426cfb96dcda04dd0e2ccd3ee221a2414a33edc038515da39efba721695f3480c1fa4a875c64d14b83269af355a895e3100\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}", + "context": { + "object": { + "content_hash": "5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e", + "joules_micro": 12000, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + } + } + }, + "expected": { + "outcome": "accept", + "link_hash": "2835bfa8d59cfacbc89cf2eb3a54c91cc114b78198e5830d84ee295dd2659eb0" + } + }, + { + "name": "energy_class_disagrees_with_labelled_delivery", + "about": "the leaf labels the figure ModelBased; the claim says OnChipCounter", + "procedure": "claim", + "receipt": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":20000,\"window_us\":1000000},\"grade\":\"measured\",\"joules_micro\":12000,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"b4b6fc41b35a63093bb6e7fb0ee9c426cfb96dcda04dd0e2ccd3ee221a2414a33edc038515da39efba721695f3480c1fa4a875c64d14b83269af355a895e3100\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}", + "context": { + "object": { + "content_hash": "5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e", + "joules_micro": 12000, + "energy_provenance": "ModelBased" + } + }, + "expected": { + "outcome": "reject", + "step": 6, + "reason": "energy_class_disagrees_with_delivery" + } + }, + { + "name": "energy_evidence_disagrees_with_labelled_delivery", + "about": "the leaf's declared uncertainty is 20,001 ppm; the claim's is 20,000", + "procedure": "claim", + "receipt": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":20000,\"window_us\":1000000},\"grade\":\"measured\",\"joules_micro\":12000,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"b4b6fc41b35a63093bb6e7fb0ee9c426cfb96dcda04dd0e2ccd3ee221a2414a33edc038515da39efba721695f3480c1fa4a875c64d14b83269af355a895e3100\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}", + "context": { + "object": { + "content_hash": "5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e", + "joules_micro": 12000, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20001, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + } + }, + "expected": { + "outcome": "reject", + "step": 6, + "reason": "energy_class_disagrees_with_delivery" + } + }, + { + "name": "energy_labelled_delivery_figure_disagrees", + "about": "the labelled leaf's figure differs: the figure is compared before the class", + "procedure": "claim", + "receipt": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":20000,\"window_us\":1000000},\"grade\":\"measured\",\"joules_micro\":12000,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"b4b6fc41b35a63093bb6e7fb0ee9c426cfb96dcda04dd0e2ccd3ee221a2414a33edc038515da39efba721695f3480c1fa4a875c64d14b83269af355a895e3100\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}", + "context": { + "object": { + "content_hash": "5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e", + "joules_micro": 11000, + "energy_provenance": "ModelBased" + } + }, + "expected": { + "outcome": "reject", + "step": 6, + "reason": "energy_disagrees_with_delivery" + } + }, + { + "name": "measurement_missing_member", + "about": "a report without power_reader: a nullable member is present and null", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"dbfc80b2eadd82f7723e7ff4eb4c0f38fc7c7335abdd111ca8df17cd6771ba457b06b397f915831f5db4dd9b9c764ab89f8f65e47734048d56eeaf80c36c090d\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 2, + "reason": "missing_required_field" + } + }, + { + "name": "measurement_bracket_unknown_member", + "about": "a bracket carrying its ratio: derived quantities are never carried", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"ratio_milli\":86602,\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"e50e69d760956cd228574ed3a060970818a58b7770e30c7dd3c3baf4fbcc596cf5c8b8faad70c4953d4963594aac93891e99f24f1111147babc88639c453c307\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 2, + "reason": "unknown_member" + } + }, + { + "name": "measurement_summary_missing_liveness", + "about": "an idle summary without live_intervals", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"3460fc6cefd32685c97908b60154120fd71170a140c722f8ac7f320024dfbb09505ec942c7c40cbdcf2575bc0cb8468cc87cfeb8ddb39a199050914d3824d509\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 2, + "reason": "missing_required_field" + } + }, + { + "name": "measurement_opening_outside_enumeration", + "about": "opening is from-rest or running", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"a0cf180a392dac57bab37fc304018a68420bf929005153bbd7380575abcc2aff2c532a542c410c533efe52f04ac8f7a266da8827486961917669904a2a0c3f08\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 2, + "reason": "field_type" + } + }, + { + "name": "measurement_build_profile_outside_enumeration", + "about": "a build profile is release or debug, in that case", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"Release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"9555f75f6a5f810cfaf5a89649007a2fec4e04124f0f4b52c96ec1683c04f7c50ab8778f994920d4307e824735a206d6ef5cc6986d719668ca5b9052131a270f\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 2, + "reason": "field_type" + } + }, + { + "name": "measurement_mean_fractional", + "about": "a mean in microwatts written as a string: a bracket carries integers", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":\"30000000.5\",\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"fe1847cbd285ccd693349b7fbf10c11f59523a19714da25d987ee660b6c33cb6b59bbc3bbef8207f9448d861a4b23f29c58b7693f62d2d7a6734d49b657a6002\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 2, + "reason": "field_type" + } + }, + { + "name": "measurement_profile_not_a_term", + "about": "a figure profile holding a space", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp: claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"ce3385a46e72dfb687a0aac0670ee9803fa9d64adb9c394eaf0c003a728ef5a09ac7c49197d8a61893e09dbc9edc06eb5b70d13ebd5b6f1eac5c8bd20c92770f\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 2, + "reason": "field_type" + } + }, + { + "name": "measurement_target_not_a_term", + "about": "a target holding a space", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86 64\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"7d04f6790efae4f741112d95f12f6e28b626d2a333a84db5a610160b4d4feda79d23e5e5cf9e0b29c3e6d8656e84f64ae935956933121cf27cf2e4f9b4dfcc09\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 2, + "reason": "field_type" + } + }, + { + "name": "operating_point_key_not_a_term", + "about": "a parameter name holding a space", + "procedure": "claim", + "receipt": "{\"body\":{\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"op_committed\":{\"frame_rate_milli\":30000,\"height\":1080},\"op_delivered\":{\"frame rate\":30000},\"op_requested\":{\"frame_rate_milli\":30000,\"height\":1080},\"prediction\":{\"descriptor_hash\":\"1122437cacd17ba56225e487fcb4f5350858024b1fa7051cdd09dd526cba2f32\",\"predicted_joules_micro\":450000,\"predictor\":\"https://models.example.org/complexity-to-energy\"},\"vocabulary\":\"https://vocabulary.example.org/operating-points\"},\"class\":\"operating-point\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":2,\"sig\":\"5de0d20ae6a8e49b7f3527fe5e55e8cfeb3c7cca06305a2d0810329f9df0e4620a4dd907e9602cc0b6b17341ce236adbbe396d9163e7634b02a9d568820acb08\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 2, + "reason": "field_type" + } + }, + { + "name": "operating_point_value_not_integer", + "about": "a parameter value written as a string", + "procedure": "claim", + "receipt": "{\"body\":{\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"op_committed\":{\"frame_rate_milli\":30000,\"height\":1080},\"op_delivered\":{\"height\":\"1080\"},\"op_requested\":{\"frame_rate_milli\":30000,\"height\":1080},\"prediction\":{\"descriptor_hash\":\"1122437cacd17ba56225e487fcb4f5350858024b1fa7051cdd09dd526cba2f32\",\"predicted_joules_micro\":450000,\"predictor\":\"https://models.example.org/complexity-to-energy\"},\"vocabulary\":\"https://vocabulary.example.org/operating-points\"},\"class\":\"operating-point\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":2,\"sig\":\"3bac08ce2d7048174dde21a87e69e733252c8557d7fad64c45912dfffa9e03edac6b3c9704cc2523c47373ff62de866a88cb797939c16ccbde31c49cfd921400\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 2, + "reason": "field_type" + } + }, + { + "name": "operating_point_descriptor_hash_not_hex", + "about": "a descriptor hash in uppercase hex", + "procedure": "claim", + "receipt": "{\"body\":{\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"op_committed\":{\"frame_rate_milli\":30000,\"height\":1080},\"op_delivered\":{\"frame_rate_milli\":30000,\"height\":1080},\"op_requested\":{\"frame_rate_milli\":30000,\"height\":1080},\"prediction\":{\"descriptor_hash\":\"1122437CACD17BA56225E487FCB4F5350858024B1FA7051CDD09DD526CBA2F32\",\"predicted_joules_micro\":450000,\"predictor\":\"https://models.example.org/complexity-to-energy\"},\"vocabulary\":\"https://vocabulary.example.org/operating-points\"},\"class\":\"operating-point\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":2,\"sig\":\"f46510f1c99c9c01904ba4817bc3936ab139a56ef6cc4ff5794f01d339b2e13dada3a665627a17dbe078ed1dd6e4b5387908aed56d95cfd0408b3ac707d58b09\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 2, + "reason": "field_type" + } + }, + { + "name": "operating_point_vocabulary_not_absolute", + "about": "a vocabulary with no scheme", + "procedure": "claim", + "receipt": "{\"body\":{\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"op_committed\":{\"frame_rate_milli\":30000,\"height\":1080},\"op_delivered\":{\"frame_rate_milli\":30000,\"height\":1080},\"op_requested\":{\"frame_rate_milli\":30000,\"height\":1080},\"prediction\":{\"descriptor_hash\":\"1122437cacd17ba56225e487fcb4f5350858024b1fa7051cdd09dd526cba2f32\",\"predicted_joules_micro\":450000,\"predictor\":\"https://models.example.org/complexity-to-energy\"},\"vocabulary\":\"vocabulary.example.org/operating-points\"},\"class\":\"operating-point\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":2,\"sig\":\"7bf6462e2923e9beea1a885058a0a99730349c5a74a11c6bbc3796897ef79d964744aeb400282302d84014d72968682a7e9df129ee218c7d29b2afd1be9fed06\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 2, + "reason": "field_type" + } + }, + { + "name": "measurement_values_rewritten", + "about": "the busy mean rewritten after signing: every value is inside the signature", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":31000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"1e3f79ad3571073e64b4ff97733c472c030d241377367a511e2a7be4f5975ea0fb34f2612fc78271941281fa185c3ba1252e8b8c96e3b3c61ee7854580e56e0f\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 3, + "reason": "signature_verify_failed" + } + }, + { + "name": "measurement_zero_figure", + "about": "a zero figure is the unmetered marker or a failed measurement; it gets no report", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":null,\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":0,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"71ff37a8dd19e2151df2a0060838b5196b3600a9dc7ece2406cfb487a1bfd4acf12340d8c76b38380d000134e689a4a7ea1b9e89abc8056bc5cc36c4298d7d02\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "energy_zero_figure" + } + }, + { + "name": "measurement_unknown_profile", + "about": "a figure profile energy-measurement section 6.1 does not define", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"wai:nonexistent-id\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"0b518d76c88d586ffe51ee9d80cd1cdf2bdef05cf2a8fc1a44b1ae404459a24fd510fcd2f53b2b29c5652ce780beeb5069ce24ebd3785580cd40c84387714003\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "figure_unknown_profile" + } + }, + { + "name": "measurement_object_profile_as_receipt", + "about": "moq-jwp:object names an object; a report naming it about a receipt names no profile", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":0,\"figure_profile\":\"moq-jwp:object\",\"group\":\"000b73b044437005c34711736a91e1d99fb9513d4052378e43f947c75d3b5261\",\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"f20c51bbd9e27590b2369276658b41eb26fd26b08d430eadfbdd10512ad61fdb8249620a2e78e232a977a479ddff51bdfd734c11383e9ab14c53d01ca665f80b\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "figure_unknown_profile" + } + }, + { + "name": "measurement_index_for_single_figure", + "about": "an index under a profile that carries one figure", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":0,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"330d4ca0dc8c96e156267898399340350c785accca40f5fc17c3fd2a2a4ace92be8225969fdc95348fa91164b919c46ddb887d3bb0b5ef953ab138c7687d1502\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "figure_index_mismatch" + } + }, + { + "name": "measurement_no_index_for_steps", + "about": "no index under a profile that carries a figure per step", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":null,\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"figure_index\":null,\"figure_profile\":\"wai:asset-receipt-id\",\"group\":null,\"joules_micro\":4200,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"4c382ceec0f6205f038bec777b4f72bee0f7321dcaf28258975e29d2c1bf34c8fee5e67183ff1839bb7901985e16664820ee8cc5d76932b23f6c6e15dee56b06\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"ac0a2d0072577e64b8350c275d7b0e4e57aab6550fb3958ad9959a58bc258fea\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "figure_index_mismatch" + } + }, + { + "name": "measurement_group_outside_object_profile", + "about": "a group under a profile that names none", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":\"000b73b044437005c34711736a91e1d99fb9513d4052378e43f947c75d3b5261\",\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"06ccf95397fe7593b2c7f63d2d4d94a174e8eefb157b252e61334e807517cd86782a7763b21141ebd78e3d94c46e2b7d68e32c0829dd62813dc2ca7f794a6006\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "figure_group_mismatch" + } + }, + { + "name": "measurement_object_without_group", + "about": "a delivered object named with no group: one content hash can be delivered in more than one", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":2,\"figure_profile\":\"moq-jwp:object\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"2c078bce70ff6aa1843bbfa1537744e972b4b320ace5066923dff2c4574dacbed67ba78c4928da3295d10b787f195f48515eaf487c3747bc115b7bd2584b2d07\",\"signer_id\":\"did:example:publisher\",\"signer_pubkey\":\"d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "figure_group_mismatch" + } + }, + { + "name": "measurement_legacy_class", + "about": "a report never restates the legacy HwShunt", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":null,\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"HwShunt\",\"energy_uncertainty\":null,\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"ec439d6edba6b19c08d2efea9f6e8175e7d1218d008c497268b8731d5e8ef67f78fd83cc6779b4e0994ded81905207ddea5760d479c3b4c70c219e268f06f409\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "energy_legacy_acquisition" + } + }, + { + "name": "measurement_class_without_evidence", + "about": "an OnChipCounter with no declared uncertainty", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":null,\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"68fc896b666b83f1e8fe934fbd391e9d9a383f300af95efe11a76f9e48ffc8bf114bba9a4dc5d7d78c9383987bbbf337fd9c7874534b379f87c281cc8255c90f\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "energy_acquisition_evidence" + } + }, + { + "name": "measurement_no_power_reader", + "about": "an OnChipCounter report that names no power reader: a hardware figure is a reading, and its report names what read it", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":null},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"4d21555d9a7d5a002bf040f49a9240367d03e8efc462ad3816f66ef3f5c0a24c46ba1b66b92fb1be9bb8994f812736fc4655b8e7e83786b3bbfc470f54e4bc07\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_no_power_reader" + } + }, + { + "name": "measurement_no_power_reader_no_bracket", + "about": "an OnChipCounter report with neither a bracket nor a reader: omitting the bracket does not excuse the reader", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":null,\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":null},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"98fa24fb9bbc347d7bd54bb7333848a14ff285dfba362e1ae1d3c08e412f280af22f04b8b27975ef9e2445ce6e7e1e0c6275c5fedacd994284512062b1033d07\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_no_power_reader" + } + }, + { + "name": "measurement_one_idle_sample", + "about": "an idle bracket of one sample has no standard deviation", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":1,\"longest_live_run\":1,\"mean_uw\":20000000,\"n\":1,\"sd_uw\":0},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"fb44d1074d23371bc7bb1053c4994ace5d311474048283a5de469286686f8e17c430e86707c0ca80947751bf6bfd23cfff6a3686250cccc6be79c67021961c08\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_too_few_samples" + } + }, + { + "name": "measurement_rail_not_live", + "about": "the busy bracket's rail advanced in 4 samples of 14: below five, and below four in five", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":4,\"longest_live_run\":2,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"382ebd7e849a2a0e6129b8e205ee708cee1f2b55236b656d8901fcfd28e73ba9da807aa0d9abcda151859b4dd3e6a755bdc2f21d6f09762644a4c09d5af81109\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_rail_not_live" + } + }, + { + "name": "measurement_flat_idle_frozen_rail", + "about": "a frozen rail refreshed once: an idle bracket of zeros and one busy sample holding the refresh. The flat idle gives an infinite ratio; the rail was not live", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":1,\"longest_live_run\":1,\"mean_uw\":10000000,\"n\":14,\"sd_uw\":37416574},\"completions\":60,\"idle\":{\"live_intervals\":0,\"longest_live_run\":0,\"mean_uw\":0,\"n\":10,\"sd_uw\":0},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"ca77f5458df0d8739a93f368a7636c382abb82fa7d64dd85dbf960430c7644b9100096276c0fc343301f9179bbac2622501b6a292d5275515f7742f2d829a803\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_rail_not_live" + } + }, + { + "name": "measurement_flat_idle_claimed_live", + "about": "an idle bracket whose samples are all equal (sd 0), its facts claiming the rail advanced in every sample: a repeated sample is not an advance, so a flat bracket is never a live rail, whatever facts it carries", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":0},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"c4261c0137db7cc1e06c7280d3397c4a241bae37eb4322c1d9a600e8fdb568481409895f901a52acd785a1f97ecf1c350b828fe95979e961a30e4dd68a9f380d\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_rail_not_live" + } + }, + { + "name": "measurement_rail_stuck_at_held_value", + "about": "a reader that holds its last value on a rail that is not refreshed: idle reads 5 W in all ten samples, busy 5 W three times and then 12 W, where one batch landed. Every sample is above zero and the flat idle gives an infinite ratio; the rail advanced once in the idle bracket and twice, never consecutively, in the busy one", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":2,\"longest_live_run\":1,\"mean_uw\":10500000,\"n\":14,\"sd_uw\":2980708},\"completions\":60,\"idle\":{\"live_intervals\":1,\"longest_live_run\":1,\"mean_uw\":5000000,\"n\":10,\"sd_uw\":0},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"dad5f99996ae8f27a8fb8967a4338bc8a784e5001c36f79c972b34b8a7fcd4768c097acda0a8e4690d85734c46964d6acf88fc4f762548ee6a0bf56a944be504\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_rail_not_live" + } + }, + { + "name": "measurement_rail_stuck_claimed_live", + "about": "the same held-value brackets, their facts claiming the rail advanced in every sample: the flat idle bracket still refuses them", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":10500000,\"n\":14,\"sd_uw\":2980708},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":5000000,\"n\":10,\"sd_uw\":0},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"a315963a4e7aa9664a0572be9217cb6ee418581f6d4b66b1abab4796796242e0f894820b1e04ef243c596a347cc43fb5a7ce877d7f06451f7f71a617da5d6b09\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_rail_not_live" + } + }, + { + "name": "measurement_two_step_refresh", + "about": "a refresh published in two steps in each bracket: two consecutive advances, and nothing else", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":2,\"longest_live_run\":2,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":2,\"longest_live_run\":2,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"579c7c4494d33c7371238bb6ab7a59fee749694ad6bb612357cb705f94107da7f298f59b61b12f416d9a4a4ede83684b4dfbee54d7372f1f37a95fdabcc26c05\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_rail_not_live" + } + }, + { + "name": "measurement_liveness_facts_inconsistent", + "about": "15 live intervals in a bracket of 14 samples", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":15,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"85b058ba193929dfb8a971829bc9336e73631d1e023875582b251dfd1eb73e47e1050cdfbfc8d2045e126c5543302a41dd8e9a57e270dc50a974abc41e1b7804\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_rail_not_live" + } + }, + { + "name": "measurement_no_completion", + "about": "no completion inside the window", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":0,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"e526b836e3679eedeba603a93f9f2d2a56a0f063e6a8398e7f4b71ad4bb8b5a5cbdb0b071e855f2e5eb385952a6c8b1b2694d642a4ad864c09a6c9283572810d\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_empty_burst" + } + }, + { + "name": "measurement_busy_not_above_idle", + "about": "the burst never rose above idle", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":20000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"846004dd2af10e1497f028d82613442fabc6d96561e5436439e09443b8cca5ae6885af31179c0d00e2e39e38921fc821ccae7b846d90877ed80dc7d84ead2b01\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_no_marginal" + } + }, + { + "name": "measurement_ratio_below_one", + "about": "a 10 W marginal against an idle standard deviation of 10.000001 W", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":10000001},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"e73e59c0c9a221d2c02c6e2254fe5813c4d6742428683fa8b1096737a7e58664170eb8a95c65a977ff90b37762c6e8a6ebb083ce9dd835ca71647eae9fda8f0d\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_below_attribution_floor" + } + }, + { + "name": "measurement_half_up_rounded_down", + "about": "the 2.5 uJ estimate sealed as 2: a half rounds up", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":21000000,\"n\":14,\"sd_uw\":1000},\"completions\":1000000,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":1000},\"in_flight\":1,\"opening\":\"running\",\"window_us\":2500000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":200000,\"window_us\":2500000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":2,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"b2136d7bcd4b4678a88b9e5735366d689adfae0e7f1dc5ffd1e3c3b968e9f025e424e4d5127e38da3895700431cf81d42c8c0cc539486f953118bbe749e9ca00\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_figure_mismatch" + } + }, + { + "name": "measurement_window_differs", + "about": "the declared window is one microsecond longer than the bracket's", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000001},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"6d0ab364a1c0491178fe7c77122c4d76961e6075fc88c97d4be44121f2823132d979fdd1109c114406f00ea381182e9828f0b159c8196035adae360ec257de06\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_window_mismatch" + } + }, + { + "name": "measurement_least_minus_one", + "about": "one ppm below the least the bracket supports", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44668,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"0ed955bcd8a2b8e9a4a4e8ecdc2badc5b2879ebd35a7f0f8f9a20b5cec4c1d08568ca46242e3a806b1693c84a688caac0f9bd83b3cf503187c8c2ea74b3eb702\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_declaration_below_bracket" + } + }, + { + "name": "measurement_sealed_from_samples", + "about": "the declaration a sealing path computes from the unrounded samples: one ppm below what the published integers support", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":29910000,\"n\":14,\"sd_uw\":176418},\"completions\":1301,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":105410},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":2007000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":6232,\"window_us\":2007000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":15229,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"3d7c50381569bf741031dabdafcad318c14c769d8c07bd190d233989671018a2cc27f53476681daa6a222e92fc1018c1ddffd81f712e41b83051d4a9e1a9d404\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_declaration_below_bracket" + } + }, + { + "name": "measurement_wide_least_minus_one", + "about": "one ppm below the least, at products past 200 bits", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":1000000000000,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":100000000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":288714,\"window_us\":100000000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":1,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"6e6e53a48fa5e4f25043e23d4d420e21d52cf753177f94d8e3392a9e8b8cce5f528b353bb05db10bcc9a39ebbd76e0e98e692b9d67a18e54159c6416fa61e50a\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_declaration_below_bracket" + } + }, + { + "name": "measurement_two_defects", + "about": "one idle sample and no completion: the earlier rule is reported", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":0,\"idle\":{\"live_intervals\":1,\"longest_live_run\":1,\"mean_uw\":20000000,\"n\":1,\"sd_uw\":0},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"58f22d79d66ec53da05ca463691d46d7cbd2a87512c6e1df1d9eb48554039fed44b6667ebd537027f822cc8bdf200c094b86a5f1ef915685080e865608365d01\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_too_few_samples" + } + }, + { + "name": "operating_point_empty_point", + "about": "a committed point with no member", + "procedure": "claim", + "receipt": "{\"body\":{\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"op_committed\":{},\"op_delivered\":{\"frame_rate_milli\":30000,\"height\":720},\"op_requested\":null,\"prediction\":null,\"vocabulary\":\"https://vocabulary.example.org/operating-points\"},\"class\":\"operating-point\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":2,\"sig\":\"a680a7b01760b54728f1d71c9b5fe8389ad2273352b7e4d6defe24b9bcac1040c0285bae29201774089f4820d150a09807933647e0eccb439121ca3a8d2b380b\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "operating_point_empty_point" + } + }, + { + "name": "operating_point_delivered_alone", + "about": "a delivered point with nothing committed to score it against", + "procedure": "claim", + "receipt": "{\"body\":{\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"op_committed\":null,\"op_delivered\":{\"frame_rate_milli\":30000,\"height\":720},\"op_requested\":null,\"prediction\":null,\"vocabulary\":\"https://vocabulary.example.org/operating-points\"},\"class\":\"operating-point\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":2,\"sig\":\"3fed075abd8ea46c7bfc87c3a646f84550bd0acf4972c3d09228a192af71e2c395b416fb2cc7f314bfad816d8afd4b871b42299e1dcdcb2f2e3b1e30cfe25900\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "operating_point_delivered_alone" + } + }, + { + "name": "operating_point_request_without_delivery", + "about": "a request and a commitment with no delivery", + "procedure": "claim", + "receipt": "{\"body\":{\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"op_committed\":{\"frame_rate_milli\":30000,\"height\":1080},\"op_delivered\":null,\"op_requested\":{\"frame_rate_milli\":30000,\"height\":1080},\"prediction\":{\"descriptor_hash\":\"1122437cacd17ba56225e487fcb4f5350858024b1fa7051cdd09dd526cba2f32\",\"predicted_joules_micro\":450000,\"predictor\":\"https://models.example.org/complexity-to-energy\"},\"vocabulary\":\"https://vocabulary.example.org/operating-points\"},\"class\":\"operating-point\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":2,\"sig\":\"d970c4dc1cd7535187aef63604620a0cd24edc159194109420e0ea541bd248f6d38b95787396ec944ca09180505f332de845765c46b4c3bfdcb50d543c84f603\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "operating_point_incomplete" + } + }, + { + "name": "operating_point_nothing", + "about": "no delivery and no prediction: nothing recorded", + "procedure": "claim", + "receipt": "{\"body\":{\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"op_committed\":null,\"op_delivered\":null,\"op_requested\":null,\"prediction\":null,\"vocabulary\":\"https://vocabulary.example.org/operating-points\"},\"class\":\"operating-point\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":2,\"sig\":\"fee14c732e57350f8ac9f619a02636051753cd828b91c1dde0200330ec859dbc33ac80d0cb3987127094bce32c22fa4437967c6b8f2c89dcf48b3cebb069b409\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "operating_point_empty" + } + }, + { + "name": "operating_point_against_hwshunt_figure", + "about": "an operating point recorded against a figure restated as legacy HwShunt", + "procedure": "claim", + "receipt": "{\"body\":{\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"HwShunt\",\"energy_uncertainty\":null,\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"op_committed\":{\"frame_rate_milli\":30000,\"height\":720},\"op_delivered\":{\"frame_rate_milli\":30000,\"height\":720},\"op_requested\":null,\"prediction\":null,\"vocabulary\":\"https://vocabulary.example.org/operating-points\"},\"class\":\"operating-point\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":2,\"sig\":\"902bc56695f8d2f6184d48448d5e80ea1e73a87661a4faedb9c44ffe225ca474ad9d06fea2bb1da99f9327f778c9daf793a51502bcccc836166d59b47276d103\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "energy_legacy_acquisition" + } + }, + { + "name": "measurement_identity_mismatch", + "about": "the receipt held is not the one the report names", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"344dac742dd2a315f00c020120f42e74d153e898d959b708621da09ec76628b8e27ca3ca07cb212c02dadb4a2a41c905ce0f79f99d4ce904b5fdb4047138ca07\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"4067f884c1e93c4c86ef0ef3c1a22b2c7bdd405c5e2e51681bb644ac749d7e15\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "claim": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"grade\":\"measured\",\"joules_micro\":461538,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"e31eb3b79ac562baf7d65c207ac1fbc39be6ae444f1fa2686e01356ccc68b9d462a3d2860b4303baa5f4dcd4933c0a9a64085f7ae349123bf872e5f88b66a307\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}" + } + }, + "expected": { + "outcome": "reject", + "step": 6, + "reason": "context_mismatch" + } + }, + { + "name": "measurement_index_out_of_range", + "about": "step 7 of a provenance receipt with three", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":null,\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"figure_index\":7,\"figure_profile\":\"wai:asset-receipt-id\",\"group\":null,\"joules_micro\":4200,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"09206a23ead41355e1bf69622b1be658f2e72da730a818d67f449081a198157d2fbd3cb47798d503885bb7d7b4af120f6804781e288162b8e2ecb617b430ce03\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"ac0a2d0072577e64b8350c275d7b0e4e57aab6550fb3958ad9959a58bc258fea\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "view": { + "profile": "wai:asset-receipt-id", + "identity": "ac0a2d0072577e64b8350c275d7b0e4e57aab6550fb3958ad9959a58bc258fea", + "group": null, + "figures": [ + { + "index": 0, + "joules_micro": 0 + }, + { + "index": 1, + "joules_micro": 4200, + "energy_provenance": "ModelBased" + }, + { + "index": 2, + "joules_micro": 90 + } + ], + "signer": "a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0" + } + } + }, + "expected": { + "outcome": "reject", + "step": 6, + "reason": "context_mismatch" + } + }, + { + "name": "measurement_object_other_group", + "about": "the same object delivered in another group: the report names this group, the view holds another", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":2,\"figure_profile\":\"moq-jwp:object\",\"group\":\"9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c\",\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"d93ac7627a7c2ff551ba50bb4820c122646789447b6825a8bf171a1e3122f38c032e1066f50460f9e1379042efd36162e9dd870c2eda6bf412bd499fb3ef0406\",\"signer_id\":\"did:example:publisher\",\"signer_pubkey\":\"d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}", + "context": { + "figure": { + "view": { + "profile": "moq-jwp:object", + "identity": "5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e", + "group": "000b73b044437005c34711736a91e1d99fb9513d4052378e43f947c75d3b5261", + "figures": [ + { + "index": 2, + "joules_micro": 461538, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 44669, + "window_us": 3000000 + }, + "energy_counter_filtering": "Unknown" + } + ], + "signer": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737" + } + } + }, + "expected": { + "outcome": "reject", + "step": 6, + "reason": "context_mismatch" + } + }, + { + "name": "measurement_of_tampered_energy_claim", + "about": "the energy claim the report names was altered after it was signed: it is no context", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"65096b098cd608f4b95e4291de03328c1b74f97d2b0eb082992658c2d9942db19fd86a5301f0c11b6b36e1d6f7e3413aaac2912ddb030f92248c9e47633d3c01\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"6d80143004ebf7b437eb6703e810b3c430a0048986bc8af9343a7de625e8b577\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "claim": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"grade\":\"measured\",\"joules_micro\":461538,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"e21eb3b79ac562baf7d65c207ac1fbc39be6ae444f1fa2686e01356ccc68b9d462a3d2860b4303baa5f4dcd4933c0a9a64085f7ae349123bf872e5f88b66a307\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}" + } + }, + "expected": { + "outcome": "reject", + "step": 6, + "reason": "context_invalid" + } + }, + { + "name": "measurement_figure_differs", + "about": "the energy claim states 5 uJ more than the report restates", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"fd2b163d70b75adf450f3510bf0598375c8fd0eec6614cde5b6967fbd7a8811f52f995be0acba7123d65b6a74b9134a965fba5b6f0d7fa295cbea56c4b435005\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"92715ea5763a9c0b53f390726744ae50b3e1a41f8b9b3278c827e56b554fce3a\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "claim": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"grade\":\"measured\",\"joules_micro\":461543,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"1395b52fd47bdf3a22f6021244e90f33514f12789af8e8d4fa403b9d827756ea6a02d1655feac7eb3fcb57752658a04fb701469bdeae4b535bb524b97337e60a\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}" + } + }, + "expected": { + "outcome": "reject", + "step": 6, + "reason": "figure_disagrees" + } + }, + { + "name": "measurement_of_unlabelled_figure", + "about": "a step whose figure carries no class: a report does not supply one", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":null,\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"figure_index\":1,\"figure_profile\":\"wai:asset-receipt-id\",\"group\":null,\"joules_micro\":4200,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"86364996bbe532596bc9b4971b5edac37da55a585bef2d502f147e27df4f079bb60b37d4915bcab5684b06a699f1068daf7af5f884d157e4e30a5eb9ee8e8109\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"ac0a2d0072577e64b8350c275d7b0e4e57aab6550fb3958ad9959a58bc258fea\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "view": { + "profile": "wai:asset-receipt-id", + "identity": "ac0a2d0072577e64b8350c275d7b0e4e57aab6550fb3958ad9959a58bc258fea", + "group": null, + "figures": [ + { + "index": 0, + "joules_micro": 0 + }, + { + "index": 1, + "joules_micro": 4200 + } + ], + "signer": "a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0" + } + } + }, + "expected": { + "outcome": "reject", + "step": 6, + "reason": "figure_unclassified" + } + }, + { + "name": "measurement_class_differs", + "about": "the energy claim's counter reads Off; the report restates Unknown", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"a6faf89b32932f553e938daefd2c17296402f5373111edd66cb46485fded48f16afd0412b6a092f2ad381ad3af139d1f32a6c8d2e259d57389e914572f56d30d\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"42302e2afd55e6a0babfbf7fccd5c16b178bd862b4769a6aa95deb9e1388fb70\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "claim": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Off\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"grade\":\"measured\",\"joules_micro\":461538,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"98013a5ebffcf258f62ae0bdd4f31ffc39985994c8ab8dcb7ff8b7775bfaeb988b1ae28a717569da065f5d0a489516386168b9ee3de4f87a432f952bd693ef08\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}" + } + }, + "expected": { + "outcome": "reject", + "step": 6, + "reason": "figure_class_disagrees" + } + }, + { + "name": "measurement_figure_not_held", + "about": "the operator's report on the meter's figure, checked without the receipt that carries the figure: it passes steps 1-5, but nothing shows its signer signed the figure, so it is not accepted as the figure signer's report", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"ab9a1c65a688ab4a935a10e6fc917158bf323d62500418981266c4d38f10d4ceff76df0ba14f17ab283b5556537f8c96b9cbe13574de7dbe6210a7538143e407\",\"signer_id\":\"did:example:operator\",\"signer_pubkey\":\"17cb79fb2b4120f2b1ec65e4198d6e08b28e813feb01e4a400839b85e18080ce\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 6, + "reason": "figure_not_held" + } + }, + { + "name": "measurement_own_figure_not_held", + "about": "the meter's own report, checked without the figure: the same outcome; a report is accepted only against its figure", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"1e3f79ad3571073e64b4ff97733c472c030d241377367a511e2a7be4f5975ea0fb34f2612fc78271941281fa185c3ba1252e8b8c96e3b3c61ee7854580e56e0f\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 6, + "reason": "figure_not_held" + } + }, + { + "name": "measurement_other_signer", + "about": "the operator reports on the meter's figure: only the key that signed a figure signs its report", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"ab9a1c65a688ab4a935a10e6fc917158bf323d62500418981266c4d38f10d4ceff76df0ba14f17ab283b5556537f8c96b9cbe13574de7dbe6210a7538143e407\",\"signer_id\":\"did:example:operator\",\"signer_pubkey\":\"17cb79fb2b4120f2b1ec65e4198d6e08b28e813feb01e4a400839b85e18080ce\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "claim": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"grade\":\"measured\",\"joules_micro\":461538,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"e31eb3b79ac562baf7d65c207ac1fbc39be6ae444f1fa2686e01356ccc68b9d462a3d2860b4303baa5f4dcd4933c0a9a64085f7ae349123bf872e5f88b66a307\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}" + } + }, + "expected": { + "outcome": "reject", + "step": 6, + "reason": "figure_signer_mismatch" + } + }, + { + "name": "measurement_of_binding", + "about": "a report on an energy binding's figure, which no Ed25519 key signs: nobody's report can be the figure signer's", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":{\"busy\":{\"live_intervals\":14,\"longest_live_run\":14,\"mean_uw\":30000000,\"n\":14,\"sd_uw\":115471},\"completions\":60,\"idle\":{\"live_intervals\":10,\"longest_live_run\":10,\"mean_uw\":20000000,\"n\":10,\"sd_uw\":115471},\"in_flight\":10,\"opening\":\"from-rest\",\"window_us\":3000000},\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"wai:energy-binding-id\",\"group\":null,\"joules_micro\":461538,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"1bb13bc6a7cd59f49b410397f73016e76fd6151f488f111aa0ace316aa5344d9648a1cae9ecfedf76c5aa5ce1149c7b06711339577072fac18c0c31790a6360b\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"1b03d02ea38bba751f7afbaac147f98bb493271a8e29494f5fee5e0e4ec6a940\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "view": { + "profile": "wai:energy-binding-id", + "identity": "1b03d02ea38bba751f7afbaac147f98bb493271a8e29494f5fee5e0e4ec6a940", + "group": null, + "figures": [ + { + "index": null, + "joules_micro": 461538, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 44669, + "window_us": 3000000 + }, + "energy_counter_filtering": "Unknown" + } + ], + "signer": null + } + } + }, + "expected": { + "outcome": "reject", + "step": 6, + "reason": "figure_signer_unknown" + } + }, + { + "name": "operating_point_against_unmetered_figure", + "about": "the figure the claim names carries the unmetered marker: a zero is not a figure to record an operating point against", + "procedure": "claim", + "receipt": "{\"body\":{\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":461538,\"op_committed\":{\"frame_rate_milli\":30000,\"height\":720},\"op_delivered\":{\"frame_rate_milli\":30000,\"height\":720},\"op_requested\":null,\"prediction\":null,\"vocabulary\":\"https://vocabulary.example.org/operating-points\"},\"class\":\"operating-point\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":2,\"sig\":\"8ea1a1fe049f31616ea4b02149e6f5d4b0494dd7c677adcf2ccc2a21133931217fc69617c740360de188b85bf81e9451453b58df4d5685f62d86f9f461b95502\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "view": { + "profile": "moq-jwp:claim-link", + "identity": "c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c", + "group": null, + "figures": [ + { + "index": null, + "joules_micro": 0 + } + ], + "signer": "a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0" + } + } + }, + "expected": { + "outcome": "reject", + "step": 6, + "reason": "figure_disagrees" + } + }, + { + "name": "operating_point_of_unlabelled_figure", + "about": "a step whose figure carries no class", + "procedure": "claim", + "receipt": "{\"body\":{\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"figure_index\":1,\"figure_profile\":\"wai:asset-receipt-id\",\"group\":null,\"joules_micro\":4200,\"op_committed\":{\"frame_rate_milli\":30000,\"height\":720},\"op_delivered\":{\"frame_rate_milli\":30000,\"height\":720},\"op_requested\":null,\"prediction\":null,\"vocabulary\":\"https://vocabulary.example.org/operating-points\"},\"class\":\"operating-point\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":2,\"sig\":\"ef050f2252d58f0ec718e1455e18085d3d2beb2adb641b731cd0aa451d9ee49c6fb3e662d2b8d3269b0207c28a7c81ae01aa416babe17ea95d81b8d53d33b40e\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"ac0a2d0072577e64b8350c275d7b0e4e57aab6550fb3958ad9959a58bc258fea\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "view": { + "profile": "wai:asset-receipt-id", + "identity": "ac0a2d0072577e64b8350c275d7b0e4e57aab6550fb3958ad9959a58bc258fea", + "group": null, + "figures": [ + { + "index": 0, + "joules_micro": 0 + }, + { + "index": 1, + "joules_micro": 4200 + } + ], + "signer": "a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0" + } + } + }, + "expected": { + "outcome": "reject", + "step": 6, + "reason": "figure_unclassified" + } + }, + { + "name": "measurement_figure_given_to_an_energy_claim", + "about": "a figure context given to a claim that reports on no figure", + "procedure": "claim", + "receipt": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":20000,\"window_us\":1000000},\"grade\":\"measured\",\"joules_micro\":12000,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"b4b6fc41b35a63093bb6e7fb0ee9c426cfb96dcda04dd0e2ccd3ee221a2414a33edc038515da39efba721695f3480c1fa4a875c64d14b83269af355a895e3100\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}", + "context": { + "figure": { + "claim": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":\"Unknown\",\"energy_provenance\":\"OnChipCounter\",\"energy_uncertainty\":{\"relative_ppm\":44669,\"window_us\":3000000},\"grade\":\"measured\",\"joules_micro\":461538,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"e31eb3b79ac562baf7d65c207ac1fbc39be6ae444f1fa2686e01356ccc68b9d462a3d2860b4303baa5f4dcd4933c0a9a64085f7ae349123bf872e5f88b66a307\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}" + } + }, + "expected": { + "outcome": "reject", + "step": 6, + "reason": "context_mismatch" + } } ] } diff --git a/wai/extensions/energy-binding.md b/wai/extensions/energy-binding.md index c3a0fcf8e..0c7543901 100644 --- a/wai/extensions/energy-binding.md +++ b/wai/extensions/energy-binding.md @@ -74,12 +74,20 @@ under the conditions in [determinism-tiers](determinism-tiers.md) §5 - recompute `workUnits` from the descriptor rather than trusting it, - and re-derive the rate. -What remains is **exactly one** unverifiable term: the joules. That is the -point of the construction, and the honest limit of it. An 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 is the difference between a measurement and an -assertion. +What remains unverifiable is the measurement: the joules, and every field that +describes how they were obtained — `acquisition` and its evidence, `coverage`, +`method`, and the values of any report about the figure +([energy-measurement](energy-measurement.md) §6). No verifier can re-measure any +of them; each is its signer's statement. Some can be checked for consistency +without re-measuring anything: the declared uncertainty against the resolution +floor (§4), a calibration reference against the certificate it names +(energy-measurement §2.5), and a report's bracket against the figure and the +declared uncertainty it must give, and against the liveness rule +(energy-measurement §6.2). Consistency is not truth. That is the point of the +construction, and the honest limit of it. An 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 its measurement — and knowing precisely which +fields those are is the difference between a measurement and an assertion. An implementation **MUST NOT** describe an energy binding as proving the energy figure. It binds the figure to a reproducible outcome; it does not attest the @@ -124,6 +132,15 @@ measurement. joule figure, in the same assertion, with no per-field provenance, is how a model comes to be read as a measurement. - A `Partial` figure **MUST NOT** be presented as the reconstruction's total. +- A binding's identity is `BLAKE3("wai:energy-binding-id\x01" ‖ JCS(body))`, + where `body` is the companion assertion's body as a JSON value and JCS is + RFC 8785. A body holding an integer above 2⁵³ − 1 has no identity. A report + about the binding's figure ([jwp-receipts](jwp-receipts.md) §2.4.8–§2.4.9) + names it by this identity under the figure profile `wai:energy-binding-id` + ([energy-measurement](energy-measurement.md) §6.1). No member is added to + the body, so a body written before this rule has the same bytes and an + identity. No Ed25519 key signs a binding's figure, so an `operating-point` + claim may name it and a `measurement` claim may not (jwp-receipts §2.4.8). ## 5. Relationship to the standard assertion @@ -224,8 +241,12 @@ is scored against something rather than merely reported. ### 7.2 Request, commitment, delivery -Where one party requests an operating point and another acts on it, the receipt -**MUST** seal all three of: +Where one party requests an operating point and another acts on it, all three +**MUST** be sealed, as `op_requested`, `op_committed` and `op_delivered`, in an +`operating-point` claim about the figure spent at that operating point +([jwp-receipts](jwp-receipts.md) §2.4.9). The claim names that figure by its +figure profile ([energy-measurement](energy-measurement.md) §6.1) and leaves the +receipt that seals it unchanged: | field | meaning | |---|---| @@ -238,17 +259,31 @@ receiving device sends a DOR-Req, and the remote encoder answers with a DOR-Resp saying how it decided to answer. An operating point is a class-scoped descriptor — frame rate, spatial resolution, -a complexity target — and this extension does not define its vocabulary. What it +a complexity target — and this extension does not define its vocabulary. The +claim names the vocabulary its parameters are drawn from by URI, and writes each +operating point as an object from parameter to unsigned integer, so whether two +operating points are equal is a comparison of canonical bytes. What this extension fixes is that the three are **distinct fields**, because the interesting cases are -exactly the ones where they differ. +exactly the ones where they differ, and that whether the commitment was met is +derived from them, never stated. -- A receipt **MUST NOT** carry `op_delivered` alone. A delivered figure with - nothing to score it against is the degradation-blind case §7.1 describes. +- A claim **MUST NOT** carry `op_delivered` without `op_committed` + (`operating_point_delivered_alone`). A delivered figure with nothing to score it + against is the degradation-blind case §7.1 describes. - `op_committed` **MUST** be recorded as what the acting party stated, even where it differs from the request. A commitment that silently becomes the request is not - a commitment. -- Where `op_delivered` differs from `op_committed`, the receipt **MUST** record - both and **MUST NOT** claim the commitment was met. + a commitment. No verifier can check what was stated; the claim is its signer's + account of it. +- Where `op_delivered` differs from `op_committed`, the claim records both and + states nothing about whether the commitment was met. A reader **MUST NOT** report + the commitment as met unless it derives that `op_delivered` equals + `op_committed` (jwp-receipts §2.4.9). +- An operating point **MUST NOT** be recorded against an unmetered figure or a + figure labelled with the legacy `HwShunt`. An `operating-point` claim restates a + figure with a refined or model class: a claim restating `HwShunt` is refused + (`energy_legacy_acquisition`), and one naming a figure that carries the unmetered + marker or no class is refused against it (`figure_disagrees`, + `figure_unclassified`). - A receipt over a payload that an encoder altered to save energy, whether at a receiver's request (such as a decoding-operation-reduction or attenuated-video request) or on its own account, covers that payload, and **MUST NOT** be @@ -263,19 +298,28 @@ against the first. ### 7.3 The residual When a declared complexity or operating-point descriptor and a measured energy -binding both cover the same reconstruction, the receipt **MUST** carry the -**residual** — the signed difference between the energy the descriptor implies -and the energy measured. +figure both cover the same reconstruction, the claim about that figure **MUST** +carry the prediction the descriptor gives: the `prediction` member of an +`operating-point` claim ([jwp-receipts](jwp-receipts.md) §2.4.9), holding the +energy the descriptor implies for that work, the model that computed it, and the +hash of the descriptor. The **residual** — the signed difference between the +energy measured and the energy the descriptor implies — is not written. Every +reader derives the same one, `joules_micro − predicted_joules_micro`, so it can be +neither stated inconsistently with the two figures nor dropped once the +prediction is sealed. -- The residual **MUST** be computed over the same work unit as the measurement - (`energy-measurement.md` §5), or it compares two different things and means - nothing. +- `predicted_joules_micro` **MUST** be stated for the same work as the figure + (`energy-measurement.md` §5), or the residual compares two different things and + means nothing. A verifier checks that the claim names the figure; it cannot + check what work the prediction was made for. - A residual **MUST NOT** be reported without the acquisition class of the - measured term. A residual against an `Estimator` figure is a difference between - two predictions. -- An implementation **MUST NOT** suppress a residual because it is large. The - large ones are the informative ones; suppression converts a falsifiable claim - into an unfalsifiable one. + measured term. An `operating-point` claim restates the figure's class and is + refused where the figure has none (`figure_unclassified`). A residual against an + `Estimator` figure is a difference between two predictions. +- An implementation **MUST NOT** omit a prediction because the residual is large. + The large ones are the informative ones; omission converts a falsifiable claim + into an unfalsifiable one. No verifier can tell an omitted prediction from one + never made, so this rule binds producers. The residual is what makes the predictive half checkable. A complexity descriptor is a prediction about energy, and until something records what actually happened @@ -284,9 +328,11 @@ rather than in a report. ### 7.4 Companion capability -Green-metadata semantics are registered the way -[`wai.meta.hdr10plus`](../research/media-codec-registry.md) and `wai.meta.dovi` -are: the capability names the descriptor class — complexity metrics, quality -recovery, display adaptation — and the sink supplies the decoder. **No carriage -is adopted**: ISOBMFF and DASH carriage of green metadata is somebody else's -layer, and WAI naming a descriptor does not make WAI responsible for moving it. +Green-metadata semantics are not yet registered as a capability. When they are, +they are registered the way [`wai.meta.hdr10plus`](../research/media-codec-registry.md) +and `wai.meta.dovi` are: the capability names the descriptor class — complexity +metrics, quality recovery, display adaptation — and the sink supplies the decoder. +**No carriage is adopted**: ISOBMFF and DASH carriage of green metadata belongs to +another layer, and WAI naming a descriptor does not make WAI responsible for moving +it. Until then, an `operating-point` claim names the vocabulary of its operating +points and the model behind its prediction by URI (jwp-receipts §2.4.9). diff --git a/wai/extensions/energy-measurement.md b/wai/extensions/energy-measurement.md index b72af1c51..34dbd295b 100644 --- a/wai/extensions/energy-measurement.md +++ b/wai/extensions/energy-measurement.md @@ -1,10 +1,13 @@ # 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. +> `joules_micro` field the receipt profiles carry means the same thing in two +> independent implementations. It defines an acquisition class and its signed +> encoding (§2, §2.7–§2.8), an acquisition protocol with a liveness rule for the +> power reader (§3, §3.1), a quality rule (§4), and a reporting contract (§6) +> whose values are published in a signed report that names the receipt it +> describes and leaves that receipt's bytes unchanged. A receipt that carries +> none of these additions keeps exactly the bytes it had. > Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174. ## 1. Why an acquisition protocol @@ -49,57 +52,54 @@ 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: +Two receipts WAI defines carry a figure that the group signature does not +cover in its legacy form: 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`, which the group signature covers only through the +object's Merkle leaf (jwp-receipts §2.2). Each meets §2 as follows: -- 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. +- **An object's figure** carries its class as an optional label (§2.8), bound + into its Merkle leaf with the figure and the object's `origin`. Absent, the + leaf and every signed byte are the legacy ones and the figure is unlabelled. + Present, the leaf covers the figure, its class, the class's evidence and the + origin, and the group signature, which covers the Merkle root, covers them + too. +- **A group total** may carry a class of its own, signed under the labelled + group payload (jwp-receipts §2.3) together with its coverage and the parent + link. The class is the one the objects' classes compose to (jwp-receipts + §2.4.1), so a holder of the group receipt alone holds a signed class for the + total, and a holder of every object checks it (jwp-receipts §3, *The group + total*). A group in the legacy payload carries none; a verifier holding every + object reads its total from the objects' labels as §2.8 reads a total, and + otherwise reads it as unlabelled. +- **A `WAI_RECEIPT` property** ([moq-streaming-format](moq-streaming-format.md) + §5) has no signature of its own. Its class is inside a signature only where + it equals a signed statement of the same object's figure and class: the label + in the object's leaf, or an `energy` claim about the object. A sink reads it + as a class only after checking that it does (moq-streaming-format §5). -An `energy` claim ([jwp-receipts](jwp-receipts.md) §2.4.5) gives an -object's figure a signed place for its class without changing the object -receipt. It is a receipt of its own, signed by whoever acquired the -figure, that restates the object's `joules_micro` beside its class and the -class's evidence in §2.7's JSON fields, and a verifier holding both -rejects the pair if the two figures differ. The claim meets §2: its figure, -class and evidence are inside its signature. The object receipt still has -no member for the class, and on its own still falls short. Nothing like the -claim exists for a group total. +An `energy` claim ([jwp-receipts](jwp-receipts.md) §2.4.5) is a receipt of its +own, signed by whoever acquired the figure. It restates an object's +`joules_micro` beside its class and the class's evidence in §2.7's JSON +fields, and meets §2. A verifier holding the claim and the object receipt +rejects the pair if the two figures differ, or, where the object's leaf is +labelled, if the two classes or their evidence differ. It is the way for a +party other than the publisher to sign a class for an object's figure. 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 the group total a -signed place for its class, and giving either receipt a member of its own for -it, remains open: it changes the jwp-receipts wire format and what its -producers seal. +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, and nothing above changes it. ## 2. The acquisition class (REQUIRED) 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. +figure before the class existed, the JWP object receipt and the JWP group +receipt's legacy payload among them. A `WAI_RECEIPT` property's class counts +only where §1 says it does. | class | meaning | requires | |---|---|---| @@ -132,6 +132,18 @@ applies", it means this order. Normative: +- **An unmetered figure is not a figure of zero joules.** Where a field encodes + unmetered as `0` — `joules_micro` in every receipt profile WAI defines, and + the legacy fields that predate this section — that `0` is the **unmetered + marker**: a reader **MUST** present it as unmetered and **MUST NOT** present, + total, average or compare it as 0 J. A total over parts that include + unmetered parts covers only the measured parts (`Partial`, energy-binding + §4) and **MUST** say so. A field or form defined after this revision marks + unmetered explicitly — class tag `0` beside a zero figure, as the labelled + JWP leaf and group do (jwp-receipts §2.2, §2.3) — and never by a bare `0` + alone. A meter that cannot measure produces no reading at all, never a + reading of zero, and a model whose output is zero is not a measurement of + zero either. - 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. @@ -170,8 +182,12 @@ reference implementation seals carries its figure in one of two ways: 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. +`wai-rs` reads and verifies JWP group and object receipts in both forms of +each, computes an object's leaf in both forms of jwp-receipts §2.2, reads a +group total from its objects, and reads a `WAI_RECEIPT` property against a +signed statement of its object (`jwp_delivery`). Neither crate seals a JWP +group receipt for delivery — `wai-rs` builds them only for its conformance +corpus — or writes a `WAI_RECEIPT` property. ### 2.1 Relationship to network-layer energy identities @@ -391,7 +407,8 @@ rounds down declares it relative to the smaller number. At `E` = 0.563 µJ with 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 + which opening it used; a measurement report states it in its bracket's + `opening` (§6). 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 @@ -473,8 +490,9 @@ Receipts sealed before this revision carry `HwShunt`. Its meaning is fixed as 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, an optional label (§2.8) and an `energy` claim - ([jwp-receipts](jwp-receipts.md) §2.4.5) refuse it, and the `wai-rs` + energy binding, an optional label (§2.8), the label of a JWP object's leaf or + group, and an `energy`, `measurement` or `operating-point` claim + ([jwp-receipts](jwp-receipts.md) §2.4.5, §2.4.8–§2.4.9) 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 @@ -494,7 +512,7 @@ by the class's evidence: | tag | class | followed by | |---|---|---| -| `0` | unmetered (no class) | — | +| `0` | no class: the unmetered marker beside a zero figure; in a receipt family of §2.8, the slot of an unlabelled legacy figure beside a non-zero one | — | | `1` | `HwShunt` (legacy) | — | | `2` | `ModelBased` | — | | `3` | `Estimator` | — | @@ -586,10 +604,30 @@ Where a receipt carries several figures, each is labelled where it sits: 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). +- A JWP group receipt labels each object in that object's Merkle leaf + ([jwp-receipts](jwp-receipts.md) §2.2). A labelled object's leaf uses the + `0x02` leaf domain, with the figure, its class and the object's origin after + the content hash; an unlabelled object keeps its legacy leaf. Its group may + sign the total's own class under the `0x02` group-signing domain (§1). + +A provenance or quantum bill total carries no class of its own, and neither +does a JWP group total in the legacy payload. A sum over figures of different +classes is only as strong as its weakest part, and an unmetered part is not a +zero. A reader that holds every part reads such a total as follows: + +- if every part's figure is the unmetered marker, the total is unmetered; +- otherwise, if any non-zero figure is unlabelled, the total is unlabelled; +- otherwise the total reads at the weakest class among the non-zero figures, in + §2's order, as a class name only. A total read this way carries no declared + uncertainty and **MUST NOT** be presented as carrying one. It covers the + whole of the work when no part is unmetered, and only the measured parts + otherwise ([energy-binding](energy-binding.md) §4's `Whole` and `Partial`). + +A reader that does not hold every part reads such a total as unlabelled. A JWP +group total in the labelled payload carries its own class instead, composed by +jwp-receipts §2.4.1's rule, which adds a declared uncertainty for a hardware +class: the parts' figure-weighted declarations, rounded up, over the longest +window — linear in the parts, so it assumes their errors fully correlated. In the reference implementation, each `wai-rs` family has an `energy_provenance` field and `with_energy_class()`, which re-signs with the @@ -612,8 +650,9 @@ 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. + `wai_quantum_meter`, `wai_prov`) label every non-zero figure they seal, + write a measurement report (§6) for it by default, and seal a figure they + could not measure with the unmetered marker, 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 @@ -696,7 +735,48 @@ 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. +is computed from; the figure alone is not auditable. Where the figure is sealed +in a receipt, they are published as §6 says. + +### 3.1 A live power reader + +A power reader can return values for a rail that is not being refreshed. A +frozen rail reads as zero in every sample, or as the last value the reader +holds; a rail refreshed in batches reads the same value except where one batch +lands, and one refresh can be published in two steps in adjacent samples. +Bracketed, either gives an idle bracket whose samples are all equal and a busy +bracket with a lump or a step in it: a flat idle, a positive marginal, and a +ratio (§4) that reads as clean — from no measurement of the work at all. + +So a figure is sealed under a hardware class only from brackets in which the +reader's rail was **live**. A rail is live in a bracket only if: + +- it advanced in two consecutive intervals; +- it advanced in at least five intervals, and in at least four of every five of + the bracket's intervals; and +- the bracket spans at least 300 ms. + +An interval is one sample of a power reader, in which the rail advanced if the +sample is strictly above zero and differs from the sample before it (the first +sample of a bracket has none before it, and advanced if it is above zero); or +one pair of successive reads of an energy counter, in which it advanced if the +second read exceeds the first, across the counter's wrap. A reader that holds +its last value repeats it, and a repeated value is not an advance, whatever the +value. Both brackets must be live. One advance is not a live rail, and neither +is one refresh published in two steps. An implementation reading a counter at +the ends of a span alone has one interval, which is never enough: it reads the +counter during the span as well. + +A figure from a bracket that is not live **MUST NOT** be sealed under a hardware +class. The producer seals it with the unmetered marker, or under a weaker class +it can justify. A measurement report carries each bracket's liveness facts — +its live intervals and its longest run of them (§6.2) — so a verifier checks the +first two conditions from the report; the span is the producer's to check. + +A bracket whose samples are all equal is not evidence of a quiet machine, and +never of a live rail: its samples advanced once at most. Its standard deviation +is zero, and under a hardware class a reader refuses a bracket whose `sd_uw` is +0 whatever liveness facts it carries. ## 4. Attribution quality (REQUIRED) @@ -719,7 +799,9 @@ 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: +standard deviation and report it. A measurement report reports it by carrying +the values it follows from, and every reader derives the same verdict from them +(§6.2): | ratio | verdict | |---|---| @@ -743,12 +825,16 @@ 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 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. + (`CalibratedInstrument`, `OnChipCounter`) when the reader's rail was not live + (§3.1), when the ratio is below 1, or when the per-unit figure rounds to zero. + None is a measurement of low energy; each is 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. +- A reader **MUST NOT** present a figure as publishable unless the verdict its + measurement report gives is clean (§6.2). A reader holding no report for a + figure cannot tell whether the figure met this bar, and **MUST NOT** say that + it did. 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 @@ -767,31 +853,163 @@ higher. 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. + the build profile alongside any published figure; a measurement report + states it in `build` (§6). - **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. +a class's reconstruction is byte-exact and proven across architectures, a +verifier holding the artifact re-derives the output and the work unit. It cannot +re-measure the energy, and it cannot re-measure anything that describes how the +energy was obtained: the acquisition class and its evidence (§2.2–§2.5), the +bracket values of §3 and their liveness facts (§3.1), the ratio they give (§4), +the build profile, and the coverage and method a receipt states beside the +figure. Those are the measurer's statements, as the figure is, and a signature +makes them attributable, not true. + +Some of them can still be checked without re-measuring anything: a declaration +against the resolution floor (§2.3), which needs nothing but the receipt; a +calibration reference against the certificate it names (§2.5); and a +measurement report's values against the figure and the declared uncertainty +they must give, and against the liveness rule (§6.2). Values that pass those +checks are consistent, not correct: a measurer who invents every value +consistently passes every one of them. Pairing a meter with a reproducible +outcome narrows what rests on the measurer to the measurement and its +description. 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), 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. +calibration reference (§2.5); the build profile (§5); the recomputable work unit +the rate is expressed against; and, for a figure obtained under the protocol of +§3, the idle mean and standard deviation, the busy mean and standard deviation, +the sample counts, each bracket's liveness facts (§3.1), the window, the +completion count, the thread count and how the window opened. The +attribution-quality ratio (§4) follows from those values (§6.2). + +Where a figure is sealed in a receipt with a class, its producer **MUST** +publish these, except the work unit, which the receipt carries, as a +`measurement` claim about the figure ([jwp-receipts](jwp-receipts.md) §2.4.8). +The claim names the figure by its figure profile (§6.1), restates it with its +class, and leaves the receipt that seals it unchanged. Only the key that signed +the figure may sign the claim, so a figure that no Ed25519 key signs — an energy +binding's (§6.1) — has no `measurement` claim; carrying a binding's report in +its C2PA manifest is outside this revision. A report printed beside a receipt +but not signed does not meet this section. No verifier can tell a figure whose +report was withheld from one that never had one, so this rule binds producers, +and what a verifier checks is a report it holds (§6.2). + +Producers that fall short of this section, recorded rather than exempted: +the generators that recreate a committed corpus with its unlabelled legacy +figures (§2.8); `world_model::frame_receipt`, which is handed its caller's +figures without their class or their bracket; and the in-browser demos of +`wai-web`, which seal illustrative figures. A figure sealed with no class +cannot meet this section, because a `measurement` claim restates a class. A figure published without these is not wrong so much as unusable: nothing about it can be checked, reproduced, or compared. +### 6.1 Figure profiles + +A report about a figure ([jwp-receipts](jwp-receipts.md) §2.4.8–§2.4.9) names +it by a **figure profile**, an index, and the identity of the receipt that +carries the figure. A profile is named by the domain its receipt's identity is +computed under, without the domain's final version byte. It fixes the claim's +`subject_type`, how the identity is computed, which figure an index selects, +where the figure's class is, and which key signed it. + +| figure profile | receipt | `subject_type` and identity | figure; its class | index | signed by | +|---|---|---|---|---|---| +| `moq-jwp:object` | JWP object receipt (jwp-receipts §2.4.2), accepted under its group (§3) | `object`: its §2.1 content hash; the claim's `group` is the group receipt's link hash | `joules_micro`; the label in its leaf (jwp-receipts §2.2) | its leaf index | the group receipt's `signer_pubkey` | +| `moq-jwp:claim-link` | `energy` claim (jwp-receipts §2.4.5) | `receipt`: its `link_hash` | `joules_micro`; its class | `null` | its `signer_pubkey` | +| `wai:energy-binding-id` | energy binding ([energy-binding](energy-binding.md) §2) | `receipt`: `BLAKE3("wai:energy-binding-id\x01" ‖ JCS(body))` (energy-binding §4) | `microjoules`; `acquisition` | `null` | no Ed25519 key | +| `wai:video-receipt-id`, `wai:video-manifest-receipt-id`, `wai:video-ssai-receipt-id`, `wai:video-keys-receipt-id`, `wai:video-package-receipt-id`, `wai:video-channel-receipt-id` | the `wai.video.int_motion` receipt and the `wai.video.*` OTT receipts | `receipt`: its receipt hash | `joules_micro`; its class | `null` | its `signer_pubkey` | +| `wai:world-receipt-id`, `wai:film-receipt-id`, `wai:haptic-receipt-id`, `wai:score-receipt-id`, `wai:splat4d-receipt-id`, `wai:audio-receipt-id`, `wai:ternary-receipt-id` | the world-session, film and reel, haptic, score, 4D-splat, audio-scene and ternary-reconstruction receipts | `receipt`: its receipt hash, over the payload in the form it was signed (§2.8) | `joules_micro`; its optional label | `null` | its `signer_pubkey` | +| `wai:asset-receipt-id` | provenance receipt | `receipt`: its receipt hash | step `i`'s `joules_micro`; that step's optional label | step, from 0 | its `signer_pubkey` | +| `wai:quantum-receipt-id`, `wai:quantum-calibration-id`, `wai:quantum-job-id`, `wai:quantum-mitigate-id`, `wai:quantum-decode-id`, `wai:quantum-compile-id`, `wai:quantum-rearrange-id`, `wai:phasor-receipt-id` | the quantum receipts | `receipt`: its receipt hash in the form it was sealed, labelled or not | `joules_micro`; its optional label | `null` | its `signer_pubkey` | +| `wai:quantum-qbom-id` | quantum bill of materials | `receipt`: its receipt hash | entry `i`'s `joules_micro`; that entry's optional label | entry, from 0 | its `signer_pubkey` | + +A receipt hash here is `BLAKE3(identity domain ‖ signing payload ‖ signature)`, +as each family defines it. One content hash can be delivered in more than one +group, so a `moq-jwp:object` report also names the group, and the same object in +another group is another figure. A claim naming a profile not in this table is +refused (jwp-receipts §3.1, `figure_unknown_profile`). A receipt family that +seals a figure after this revision adds its row. + +### 6.2 The measurement report + +A measurement report ([jwp-receipts](jwp-receipts.md) §2.4.8) carries a figure's +§3 values as integers: + +- each bracket's sample count `n`; its mean, in microwatts, rounded to the + nearest with a half rounding up; its sample standard deviation (divisor + `n − 1`, as §2.3 uses), in microwatts, rounded up, so a sealed dispersion never + states less than was evaluated; and its liveness facts (§3.1): the samples in + which the reader's rail advanced, and the longest run of consecutive ones; +- the window `T`, the busy bracket's, in microseconds rounded to the nearest: + the `window_us` of the figure's declared uncertainty (§2.3); +- the completion count `C`, the thread count `N`, and how the window opened. + +A producer **MUST** compute the figure it seals, and the least uncertainty it +declares, from those integers as below and not from the samples they summarise, +so that every verifier reproduces both exactly. + +Let `M` be the busy mean less the idle mean, and let the work the window paid +for (§2.3) be `Wn / Wd`: `Wn = 2C + N` and `Wd = 2` for a window opened from +rest, `Wn = C` and `Wd = 1` on running workers. The per-unit estimate is +`E = M·T·Wd / (10⁶·Wn)` µJ, and the figure is `E` rounded to the nearest +microjoule, a half rounding up: + +``` +F = ⌊(2·M·T·Wd + 10⁶·Wn) / (2·10⁶·Wn)⌋ +``` + +With `n_i`, `s_i` the idle bracket's count and standard deviation and `n_b`, +`s_b` the busy bracket's, §2.3's `u_rel` over those integers, multiplied out, is +the least integer `p` for which: + +``` +p² · K ≥ P, where +K = 12 · n_i · n_b · Wn⁴ · F² +P = 12 · Wn² · T² · Wd² · (s_i² · n_b + s_b² · n_i) + + 4 · n_i · n_b · M² · T² · Wd² · N² + + 10¹² · n_i · n_b · Wn⁴ +``` + +The three terms of `P` are `u_power`, `u_count` and the `0.5/√3` µJ of rounding, +each carried across in microjoules (`u_count` is `N / (√3 · Wn)` from rest and +on running workers alike). The least `p` always meets the resolution floor of +§2.3, `F · p ≥ 288 676`. Both computations are exact integer arithmetic. With +every value at most 2⁵³ − 1 (jwp-receipts §2.4.3), no product exceeds 2⁵¹² and +no floating-point operation is involved, so every verifier reaches the same `F` +and `p`. + +A figure sealed under `OnChipCounter` or `CalibratedInstrument` **MUST** come +from brackets whose rail was live (§3.1), and **MUST** declare a `relative_ppm` +of at least `p` over `window_us = T`. It declares more wherever its declaration +covers more than the bracket does: an instrument's calibration (§2.3), the noise +a filtered counter's vendor states (§2.4), or a clock resolution that is not +negligible (§2.3). A figure whose `p` exceeds 2³² − 1 cannot be declared (§2.7) +and **MUST NOT** be sealed under a hardware class. + +The attribution-quality ratio (§4) is `M / s_i`. Every reader derives the same +verdict from the report's values: clean when `M ≥ 10 · s_i`, usable for relative +comparison when `M ≥ 3 · s_i`, noisy when `M ≥ s_i`, and unusable otherwise. The +verdict is about the bracket's arithmetic; under a hardware class a report is +accepted only where the liveness facts also show the rail live and neither +bracket's `sd_uw` is zero, so a flat idle bracket from a frozen rail, or from a +reader holding a stale value, is refused, not read as clean. + +What a verifier checks with a report is arithmetic: that the figure is the one +its values give, that the declared uncertainty is at least what they support, +and, under a hardware class, that the rail was live and the ratio is at least 1 +(jwp-receipts §2.4.8). It does not check the values, which cannot be re-measured +(§5). + ## 7. Worked example `wai.video.int_motion`, real-footage vector, 8 frames of 96×96 RGB, release @@ -820,7 +1038,10 @@ microjoules adds. It also opens its window from rest and divides by 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. +the invention this section refuses. The reference meter now also publishes +these values, with the build profile, in a measurement report (§6). The run +above printed them to its console, and its report cannot be written after the +fact. **This example is deliberately a failing one.** It is a complete, honestly reported bracket that this extension nonetheless forbids publishing as an @@ -833,7 +1054,10 @@ 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. +the codestream hash, the frame hashes and the work figure from the codestream +alone. The energy, its class and the class's evidence rest on the signer (§5); a +report about the figure lets a verifier check that they are consistent with the +bracket, and no more (§6.2). ## 8. Reproducing a figure @@ -843,4 +1067,7 @@ 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. +prevent. A measurement report (§6) publishes, signed, the build profile, the +toolchain and target, the power reader and the bracket with its liveness facts, +in cleartext: the measuring machine's power levels and build environment are +visible to whoever holds the report. diff --git a/wai/extensions/interactive-worlds.md b/wai/extensions/interactive-worlds.md index dd1ff8dec..131e0d7cd 100644 --- a/wai/extensions/interactive-worlds.md +++ b/wai/extensions/interactive-worlds.md @@ -507,7 +507,9 @@ as `SessionReceipt::parent_receipt_hash` + `receipt_hash()` + `wai_world_meter` brackets a replay burst against an idle baseline and reports the marginal measured CPU energy. A browser, lacking those counters, supplies a wall-clock estimate against a **declared** power - coefficient and labels it an estimate; an unmetered sink signs `0`. + coefficient and labels it an estimate; an unmetered sink signs the + unmetered marker `0`, which a reader presents as unmetered and never as a + figure of zero joules ([energy-measurement](energy-measurement.md) §2). Joules are derived *from* measurement or a stated model, never asserted in place of one. The figure's acquisition class rides beside it as an optional signed label diff --git a/wai/extensions/jwp-receipts.md b/wai/extensions/jwp-receipts.md index 285f73c3f..0fc9f47d6 100644 --- a/wai/extensions/jwp-receipts.md +++ b/wai/extensions/jwp-receipts.md @@ -31,9 +31,15 @@ In scope: - **Typed receipt classes** (§2.4): every receipt belongs to exactly one class. `group` and `object` receipts are the **delivery** class; a `claim` receipt (§2.4.3) carries one of the others — `energy`, - `lawful-basis`, `log-inclusion` (§2.4.5–§2.4.7) — on one envelope, one - canonical form and one signature rule, so a statement other than - delivery does not need a field set of its own. + `lawful-basis`, `log-inclusion`, `measurement`, `operating-point` + (§2.4.5–§2.4.9) — on one envelope, one canonical form and one signature + rule, so a statement other than delivery does not need a field set of + its own. +- An optional acquisition-class label on the object receipt, bound with the + object's figure and origin into its Merkle leaf (§2.2, §2.4.2); an + optional class for the group total, signed under a labelled group payload + with its coverage and the parent link (§2.3, §2.4.1); and the group total + read from its objects (§3). - **A second binding class** (§2.6): a soft binding, declared by the publisher with a registry-named algorithm and a resolution endpoint, for objects whose bytes changed in normal distribution — and the @@ -100,9 +106,25 @@ internal nodes use distinct domain separators: ``` leaf(content_hash) = BLAKE3("moq-jwp:merkle-leaf\x01" || content_hash) +leaf(content_hash, joules_micro, class, origin) + = BLAKE3("moq-jwp:merkle-leaf\x02" || content_hash + || u64_be(joules_micro) || class + || u32_be(len(origin)) || origin_utf8) node(left, right) = BLAKE3("moq-jwp:merkle-node\x01" || left || right) ``` +An object's leaf takes the first form when its receipt carries no class +label (§2.4.2) and the second when it does. `class` is the label's signed +encoding: the tag byte and the evidence that follows it +([energy-measurement](energy-measurement.md) §2.7), tag `0` marking an +object explicitly unmetered. The encoding is self-delimiting and `origin` is +length-prefixed, so no two inputs share a leaf. The version byte keeps the two +forms disjoint, so a label, or the origin, can be neither added, stripped nor +altered on a delivered object without changing the root the group signature +covers. A group may mix the forms. A verifier that predates the labelled form +computes the first form for every object: it refuses a labelled object at §3 +step 5 and verifies an unlabelled one as before. + When the leaf count at any level is odd, the publisher MUST duplicate the final leaf to obtain an even pair. The root is the single hash at the top of the tree. The empty-group root is the 32-byte all-zero @@ -131,6 +153,23 @@ signing_payload = The signature is exactly 64 bytes. Implementations MUST reject any group receipt whose `signature` length is not 64. +A group whose total carries a class of its own (§2.4.1) is signed over the +**labelled** payload instead: the fields above, in that order, under the +domain `"moq-jwp:group-sign\x02"`, followed by + +``` + || class // energy-measurement §2.7: tag, evidence + || u8(coverage) // 0 Whole, 1 Partial + || u8(has_parent) || parent[32] // 1 and parent_receipt_hash, or 0 and 32 zero bytes +``` + +`class` is tag `0` exactly when `total_joules_micro` is zero, and then +`coverage` is `0`. The labelled payload signs `parent_receipt_hash`, which the +legacy payload does not (§6.1). The two domains keep the payloads disjoint. A +verifier or relay that predates the labelled payload computes the legacy one +and refuses a labelled group at §3 step 2; every group signed over the legacy +payload verifies as before. + ### 2.4. Receipt envelope (typed) Three receipt kinds are defined: `group` (one per group), `object` @@ -154,7 +193,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. No member carries its acquisition class, which [energy-measurement](energy-measurement.md) §2 requires beside a non-zero figure, and no claim class carries one for a group total; §1 there records this as open. | +| `joules_total` | u64 | Sum of microjoules across the group's objects; `0` is the unmetered marker ([energy-measurement](energy-measurement.md) §2). Its class, where it carries one, is in the labelled members below; a verifier holding every object checks it, or, for a group without one, reads the total from the objects' labels (§3, *The group total*). | | `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. | @@ -162,6 +201,38 @@ Required fields: | `sig` | 64 B hex | Ed25519 signature over §2.3 payload. | | `parent_receipt_hash` | 32 B hex or `null` | BLAKE3 of the previous group receipt's canonical bytes under this publisher key; `null` for the first group. | +**The total's class (labelled form).** A group whose total carries a class of +its own adds these members and is signed over the labelled payload (§2.3): + +| field | type | meaning | +|---|---|---| +| `sign_version` | integer | `2`. Absent (or `null`), the group is in the legacy form; any other value is refused (`group_label_invalid`). | +| `energy_provenance`, `energy_uncertainty`, `energy_counter_filtering`, `energy_calibration_ref` | as energy-measurement §2.7 | The total's class and its evidence; all absent for the unmetered marker beside a zero total. | +| `energy_coverage` | string | `Whole` or `Partial`; absent with the unmetered marker. | + +A legacy group carrying any of these members, a `sign_version` other than `2`, +or a labelled group whose class cannot be read, is refused +(`group_label_invalid`), never read as the legacy form. The class **MUST** be the one the group's objects compose to, by this +rule over the objects whose figure `F_i` is not zero, in integers: + +- if any such object is unlabelled, the group **MUST NOT** carry a class (it is + signed in the legacy form); +- if there is none, the class is the unmetered marker and the coverage `Whole`; +- otherwise the class is the weakest of the objects' classes, in + energy-measurement §2's order, and the coverage is `Partial` exactly when some + object's figure is zero; +- for a hardware class, `relative_ppm` is at least `⌈Σ F_i·p_i / Σ F_i⌉` and + `window_us` is the longest `window_us_i`. The bound is linear in the parts, so + it assumes their errors fully correlated, and it is conservative; +- an `OnChipCounter` total's filtering state is the objects' common one, or + `Unknown`; a `CalibratedInstrument` total needs one calibration reference + shared by every object, or the group carries no class. + +The resolution floor then holds for the total. With the group alone a verifier +checks that the class reads, is not `HwShunt`, is the unmetered marker exactly +beside a zero total, and reaches the resolution floor (§3 step 2); with every +object it checks the composition (§3, *The group total*). + #### 2.4.2. `kind: "object"` Required fields: @@ -173,10 +244,29 @@ 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. 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. This receipt has no member for the class; an `energy` claim (§2.4.5) restates the figure with its class under a signature. | +| `joules_micro` | u64 | Microjoules consumed producing this object; `0` is the unmetered marker. 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. Its class rides as an optional label, below, bound into the object's leaf with the figure and the origin (§2.2). | | `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. | +**Optional class label.** An object receipt **MAY** carry its figure's +acquisition class as an optional label +([energy-measurement](energy-measurement.md) §2.8): `"leaf_version": 2`, and +the class in energy-measurement §2.7's JSON fields — `energy_provenance`, and +for a refined hardware class `energy_uncertainty` with +`energy_counter_filtering` or `energy_calibration_ref`. With +`"leaf_version": 2` and no class the object is explicitly unmetered (tag `0`), +which is valid only beside a zero figure. Absent, the receipt is exactly as +above and its leaf takes the legacy form of §2.2. Present, its leaf takes the +labelled form, and the group signature covers the figure, the class, the +class's evidence and the origin through the root. A label **MUST NOT** be the +legacy `HwShunt` and **MUST NOT** sit beside a zero figure; a refined class +**MUST** carry its evidence and reach the resolution floor (energy-measurement +§2.3); class members without `"leaf_version": 2`, evidence without a class, or +a class that cannot be read, are refused rather than dropped +(energy-measurement §2.7). A verifier refuses an object that breaks any of +these at §3 step 5 (`object_label_invalid`). An `energy` claim (§2.4.5) remains +the way for a party other than the publisher to sign a class for the figure. + #### 2.4.3. `kind: "claim"` `group` and `object` answer one question — *what was delivered, by @@ -197,10 +287,10 @@ is present and `null`, so one claim has one canonical form. | `kind` | string | MUST equal `"claim"`. | | `class` | string | A class of §2.4.4 other than `delivery`. | | `subject_type` | string | `"object"` or `"receipt"`; which a class admits is fixed by §2.4.4. | -| `subject` | 32 B hex | For `object`: the §2.1 content hash of the object the claim is about. For `receipt`: the link hash of a receipt — §3 step 3's for a group receipt, `link_hash` below for a claim. | +| `subject` | 32 B hex | For `object`: the §2.1 content hash of the object the claim is about. For `receipt`: the link hash of a receipt — §3 step 3's for a group receipt, `link_hash` below for a claim — or, for a class that names a figure profile (§2.4.8–§2.4.9), the identity that profile defines ([energy-measurement](energy-measurement.md) §6.1). | | `seq` | integer | Strictly increasing across the claims of one `signer_pubkey`. | | `issued_at` | string | A date-time (below) at which the signer issued the claim. Self-asserted, like `group_end`. | -| `body` | object | Class-scoped; its members are fixed by `class` and by nothing else (§2.4.5–§2.4.7, §2.6). | +| `body` | object | Class-scoped; its members are fixed by `class` and by nothing else (§2.4.5–§2.4.9, §2.6). | | `signer_pubkey` | 32 B hex | Ed25519 verifying key per RFC 8032. | | `signer_id` | string | Bound identity string. | | `parent_receipt_hash` | 32 B hex or `null` | `link_hash` of this signer's previous claim; `null` for its first. | @@ -267,6 +357,8 @@ key. | `log-inclusion` | `claim` | `receipt` | §2.4.7 | whoever attaches the log's evidence | | `soft-binding` | `claim` | `object` | §2.6.2 | the object's publisher | | `binding-recovery` | `claim` | `object` (the ancestor) | §2.6.3 | the party that performed the recovery | +| `measurement` | `claim` | `object` or `receipt`, as its figure profile fixes | §2.4.8 | the key that signed the figure: the group's publisher for `moq-jwp:object`, the receipt's signer for every other profile | +| `operating-point` | `claim` | `object` or `receipt`, as its figure profile fixes | §2.4.9 | the party that records the request, the commitment and the delivery | Rules: @@ -288,9 +380,17 @@ Rules: not evidence about delivery, and a delivery signature is not evidence about a claim. - A `claim` that repeats a figure a delivery receipt carries for the - same subject — the `energy` class does, deliberately, to bind its - labels to the figure — **MUST** agree with it. Where they disagree a - verifier **MUST** reject rather than choose. + same subject **MUST** agree with it; the `energy` class does this + deliberately, to bind its labels to the figure. Where the delivery + receipt labels its figure (§2.4.2), the claim **MUST** also agree with + that label's class and evidence. Where they disagree a verifier **MUST** + reject rather than choose. +- A class that is about one figure — `measurement` or `operating-point` — + names it by a figure profile ([energy-measurement](energy-measurement.md) + §6.1) and the identity that profile defines, restates it with its class, + and is checked against the receipt that carries it at §3.1 step 6. It never + changes that receipt, and never supplies a class that receipt does not + carry. #### 2.4.5. `class: "energy"` @@ -368,8 +468,12 @@ for a subject **MUST NOT** report that subject's `joules_micro` as the whole cost of producing it. **Against the delivery receipt** (§3.1 step 6): the claim's -`joules_micro` **MUST** equal the `object` receipt's for the same -subject. +`joules_micro` **MUST** equal the `object` receipt's for the same subject +(`energy_disagrees_with_delivery`). Where that receipt carries a class label +(§2.4.2), the claim's class and its evidence **MUST** also equal the label's +(`energy_class_disagrees_with_delivery`). Each is read as energy-measurement +§2.7 reads it, so an `OnChipCounter` label with no filtering state reads as +`Unknown`. **Against the OER receipt it names** (§3.1 step 6): a verifier holding that receipt checks, in this order. The receipt is read as oer-receipts @@ -496,6 +600,159 @@ the subject as logged. This claim's signature never stands in for the log's, and [transparency](transparency.md) §6 applies: registration is a witness, not a verification. +#### 2.4.8. `class: "measurement"` + +[energy-measurement](energy-measurement.md) §6 requires a published figure +to be accompanied by how it was measured: for a figure obtained under that +extension's §3, the values its bracket gave, and for any figure, the build +profile. A `measurement` claim carries them for one figure. It restates the +figure and its acquisition class, so that it can be checked on its own, and +leaves the receipt that seals the figure unchanged. It names that receipt by a +figure profile (energy-measurement §6.1) and the receipt's identity under it. +Its `subject_type` and `subject` are the ones the profile gives: `object` and +the object's §2.1 content hash for `moq-jwp:object`, and `receipt` and the +receipt's identity for every other profile. + +| member | type | meaning | +|---|---|---| +| `figure_profile` | term | A figure profile of energy-measurement §6.1. | +| `figure_index` | integer or `null` | Which figure, from 0, in a profile that carries several — for `moq-jwp:object`, the object's leaf index; `null` in one that carries one. | +| `group` | 32 B hex or `null` | For `moq-jwp:object`, the link hash of the group receipt the object was delivered under (§3 step 3); `null` for every other profile. | +| `joules_micro` | integer | The figure. | +| `energy_provenance` | string | Its acquisition class, as §2.4.5 writes it. | +| `energy_uncertainty` | object or `null` | As §2.4.5. | +| `energy_counter_filtering` | string or `null` | As §2.4.5. | +| `energy_calibration_ref` | string or `null` | As §2.4.5. | +| `bracket` | object or `null` | For a figure obtained under energy-measurement §3: `{ "idle": summary, "busy": summary, "window_us": integer, "completions": integer, "in_flight": integer, "opening": "from-rest" or "running" }`, a summary being `{ "n": integer, "mean_uw": integer, "sd_uw": integer, "live_intervals": integer, "longest_live_run": integer }` as energy-measurement §6.2 defines them. `null` otherwise. | +| `build` | object | `{ "profile": "release" or "debug", "toolchain": string or null, "target": term or null }`: the build of the code measured (energy-measurement §5, §8). `release` is a build optimised as shipped; any other build is `debug`. A finer vocabulary is a new registration. | +| `power_reader` | string or `null` | The reader of the power interface (energy-measurement §8). Required under a hardware class (rule 4). | + +A report carries none of what follows from its values: neither the §4 ratio nor +its verdict, which every reader derives (energy-measurement §6.2). A bracket +carrying either has a member its table does not define (`unknown_member`). + +A `null` bracket says the figure was not obtained under energy-measurement §3 — +under a hardware class, for example, a counter read through the span of the +work. Such a report carries no values to check, so the figure's liveness +(energy-measurement §3.1) rests on its producer's statement alone, and no reader +derives a §4 verdict for it. + +Rules, checked at §3.1 step 4 in this order: +1. `joules_micro` **MUST NOT** be zero (`energy_zero_figure`): a zero is the + unmetered marker, or a failed measurement, and gets no report. +2. `figure_profile` **MUST** name a profile of energy-measurement §6.1 whose + subject type is the claim's `subject_type` (`figure_unknown_profile`); + `figure_index` **MUST** be an integer exactly when that profile carries + several figures (`figure_index_mismatch`); and `group` **MUST** be a hash + exactly when the profile is `moq-jwp:object` (`figure_group_mismatch`). +3. The acquisition class, as §2.4.5's rule 2 (`energy_unknown_acquisition`, + `energy_legacy_acquisition`, `energy_acquisition_evidence`). +4. Under `OnChipCounter` or `CalibratedInstrument`, `power_reader` **MUST NOT** + be `null` (`measurement_no_power_reader`): a hardware figure is a reading, and + its report names what read it, whether or not it carries a bracket. +5. Where `bracket` is not `null`, in this order, with `M` its busy mean less its + idle mean: + - each `n` **MUST** be at least 2 (`measurement_too_few_samples`); + - under `OnChipCounter` or `CalibratedInstrument`, each bracket's rail + **MUST** have been live (energy-measurement §3.1): `sd_uw` not zero (a + bracket whose samples are all equal advanced once at most), + `longest_live_run` at least 2, `live_intervals` at least 5 and at least + four fifths of `n` (`live_intervals · 5 ≥ n · 4`), and + `longest_live_run ≤ live_intervals ≤ n` (`measurement_rail_not_live`); + - `window_us`, `completions` and `in_flight` **MUST NOT** be zero + (`measurement_empty_burst`); + - `M` **MUST** be positive (`measurement_no_marginal`); + - under `OnChipCounter` or `CalibratedInstrument`, `M` **MUST** be at least + the idle `sd_uw`. A ratio below 1 is a failure to measure + (energy-measurement §4) (`measurement_below_attribution_floor`); + - `joules_micro` **MUST** be the figure `F` the bracket gives + (energy-measurement §6.2) (`measurement_figure_mismatch`); + - under `OnChipCounter` or `CalibratedInstrument`, the declared `window_us` + **MUST** be the bracket's (`measurement_window_mismatch`), and the declared + `relative_ppm` **MUST** be at least the least `p` the bracket supports + (energy-measurement §6.2) (`measurement_declaration_below_bracket`). + +**Against the figure** (§3.1 step 6). The context is the receipt the claim +names, verified under its own profile's rules; for `moq-jwp:object`, that is +the object receipt accepted under §3 against the group receipt `group` names. +In this order: +1. the receipt verifies (`context_invalid`); it is of `figure_profile`, its + identity under that profile is `subject`, it is within the group `group` + names, and it carries a figure at `figure_index` (`context_mismatch`); +2. that figure is `joules_micro` (`figure_disagrees`); +3. that figure carries an acquisition class (`figure_unclassified`). An + unlabelled figure has none, and neither has the unmetered marker; a claim does + not supply one; +4. that class and its evidence are the claim's, each read as energy-measurement + §2.7 reads it (`figure_class_disagrees`); +5. an Ed25519 key signs the figure (`figure_signer_unknown`) — every profile but + `wai:energy-binding-id` has one — and it is `signer_pubkey` + (`figure_signer_mismatch`). For `moq-jwp:object` it is the group receipt's + `signer_pubkey`. Only the key that signed a figure signs its report. + +Only check 5 shows that a `measurement` claim's signer is the figure's, so a +`measurement` claim is accepted only once step 6 has run against the receipt +it names. A verifier that does not hold that receipt reports +`figure_not_held` at step 6 — recoverable by fetching the receipt — and +**MUST NOT** present the claim as the figure signer's report, nor derive a §4 +verdict for the figure from its values: without the context, any key can sign +any values about any figure. + +An accepted `measurement` claim establishes that the figure's signer reported +these values for this figure, and that the figure and its declared uncertainty +are what the values give. It does not establish that the values were measured: +they cannot be re-measured (energy-measurement §5). A reader derives the §4 +verdict from an accepted claim's bracket (energy-measurement §6.2). + +#### 2.4.9. `class: "operating-point"` + +[energy-binding](energy-binding.md) §7 makes the operating point a figure was +spent at part of the claim about it: what was requested, what was committed, +what was delivered, and what a declared descriptor predicted. An +`operating-point` claim records these for one figure, named and restated as +§2.4.8 names and restates it, without changing the receipt that seals the +figure. It is signed by the party that records them. + +| member | type | meaning | +|---|---|---| +| `figure_profile`, `figure_index`, `group`, `joules_micro`, `energy_provenance`, `energy_uncertainty`, `energy_counter_filtering`, `energy_calibration_ref` | | As §2.4.8. | +| `vocabulary` | absolute URI | The vocabulary the operating points' parameters, and the prediction's descriptor, are drawn from. WAI defines none. | +| `op_requested` | point or `null` | What the requesting party asked for; `null` when nothing was requested. | +| `op_committed` | point or `null` | What the acting party said it would use. | +| `op_delivered` | point or `null` | What was used. | +| `prediction` | object or `null` | `{ "predictor": absolute URI, "descriptor_hash": 32 B hex, "predicted_joules_micro": integer }`: the model that turned the declared descriptor into energy, `BLAKE3("wai:energy-descriptor\x01" ‖ descriptor)` over the descriptor as `vocabulary` serialises it, and the energy it implies for the work the figure covers. | + +A **point** is an object whose member names are terms and whose values are +integers of §2.4.3. JCS orders its members and §3.1 step 1 refuses a repeated +one, so two points are equal exactly when their canonical forms are. + +Rules, checked at §3.1 step 4 in this order: +1. §2.4.8's rules 1–3. A claim restating `HwShunt` is refused there, so no + operating point is recorded against a figure labelled with the legacy class. +2. No point that is present **MUST** be empty (`operating_point_empty_point`). +3. `op_delivered` **MUST NOT** be present without `op_committed` + (`operating_point_delivered_alone`). +4. `op_requested` and `op_committed` **MUST NOT** be present without + `op_delivered` (`operating_point_incomplete`). +5. A claim **MUST** carry `op_delivered` or `prediction` (`operating_point_empty`). + +**Against the figure** (§3.1 step 6): §2.4.8's checks 1–4. The claim may be +signed by a party other than the figure's signer, so check 5 does not apply, +and it may name a figure no Ed25519 key signs. An operating point recorded +against a figure that carries the unmetered marker or no class is refused here +(`figure_disagrees`, `figure_unclassified`). + +From an accepted claim a reader derives, and no claim states: +- whether the commitment was met: it was when `op_delivered` equals + `op_committed`, and there was none when `op_committed` is `null`; +- the **residual**, `joules_micro − predicted_joules_micro`, a signed integer, + whenever `prediction` is present. + +A reader **MUST NOT** report the commitment as met unless it derives that it +was. An accepted claim establishes what its signer recorded about the request, +the commitment, the delivery and the prediction. An `op_requested` in a claim +the acting party signed is that party's account of the request. + ### 2.5. Worked example Manifest JSON (group receipt, formatted for readability — wire form @@ -579,6 +836,71 @@ nothing; the figure is invented: Its `link_hash` is `797a6d60712bb66270be3683546ed9d92c7134d3a3cfa34f304e6fdad886046f`. +A `measurement` claim (corpus vector `measurement_accept`, §5.6) reporting +on an `energy` claim's figure, by the key that signed it: ten idle and +fourteen busy samples, every one live, ten workers released from rest and +sixty completions over 3 s. Its figure and its declared uncertainty are the +least its bracket's integers give (energy-measurement §6.2). Formatted for +readability; the signature is real, under the corpus's published test seed, +and the values are invented: + +```json +{ + "body": { + "bracket": { + "busy": { + "live_intervals": 14, + "longest_live_run": 14, + "mean_uw": 30000000, + "n": 14, + "sd_uw": 115471 + }, + "completions": 60, + "idle": { + "live_intervals": 10, + "longest_live_run": 10, + "mean_uw": 20000000, + "n": 10, + "sd_uw": 115471 + }, + "in_flight": 10, + "opening": "from-rest", + "window_us": 3000000 + }, + "build": { + "profile": "release", + "target": "x86_64-unknown-linux-gnu", + "toolchain": "rustc 1.98.0 (example)" + }, + "energy_calibration_ref": null, + "energy_counter_filtering": "Unknown", + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 44669, + "window_us": 3000000 + }, + "figure_index": null, + "figure_profile": "moq-jwp:claim-link", + "group": null, + "joules_micro": 461538, + "power_reader": "reader:example" + }, + "class": "measurement", + "issued_at": "2026-09-27T12:00:00Z", + "kind": "claim", + "parent_receipt_hash": null, + "seq": 1, + "sig": "1e3f79ad3571073e64b4ff97733c472c030d241377367a511e2a7be4f5975ea0fb34f2612fc78271941281fa185c3ba1252e8b8c96e3b3c61ee7854580e56e0f", + "signer_id": "did:example:meter", + "signer_pubkey": "a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0", + "subject": "c857bdf859edf4041541d2507611c4205435be54f47ec38fc6ab4350feaf6b4c", + "subject_type": "receipt" +} +``` + +Its `link_hash` is +`31cfac4ec8c104a7db59c4156cd7968f539c690c7b5df0c69e34cf2c48529db9`. + ### 2.6. Binding classes A receipt binds to an object in one of two ways. @@ -699,52 +1021,108 @@ for every group receipt and every object receipt it accepts. Steps match the algorithm style of WAI SPEC.md §4 (numbered, terminating). 1. **Parse and shape-check.** Read the group receipt. If `kind != - "group"`, reject. If `sig` is not 64 bytes, reject. If - `signer_pubkey` is not a valid 32-byte Ed25519 verifying key - (RFC 8032), reject. If any required field from §2.4.1 is absent, - reject. + "group"`, reject (`wrong_kind`). If `sig` is not 64 bytes, if any + required field from §2.4.1 is absent, or if a member §2.4.1 does not + define is present, reject (`malformed`). Labelled members on a group + without `"sign_version": 2`, a `sign_version` other than `2`, or a class + that cannot be read, reject (`group_label_invalid`). If `signer_pubkey` does not decode under + RFC 8032 §5.1.3 or is of small order — §3.1 step 3's key rule — reject + (`bad_signer_key`). -2. **Verify the Ed25519 group signature.** Construct the canonical - signing payload per §2.3 from the receipt's fields. Verify `sig` - against `signer_pubkey` per RFC 8032 Pure Ed25519. If verification - fails, reject. After this step, the verifier holds a **verified - root**: the receipt's `root_hash` field, now bound to - `signer_pubkey` under signature. +2. **Verify the Ed25519 group signature.** Construct the signing payload + per §2.3 from the receipt's fields: the labelled payload for a group + with `"sign_version": 2`, the legacy payload otherwise. Verify `sig` + against `signer_pubkey` under §3.1 step 3's rule — `S` < L, `R` not of + small order, the cofactorless equation — the same rule every claim + signature is held to, so that every conforming verifier accepts the same + group signatures. If verification fails, reject + (`signature_verify_failed`). For a labelled group, check its class with + the group alone (§2.4.1) and reject one that breaks it + (`group_label_invalid`). After this step, the verifier holds a + **verified root**: the receipt's `root_hash` field, now bound to + `signer_pubkey` under signature, and for a labelled group the total's + class. 3. **Check parent linkage.** If the verifier maintains receipt-chain state for `signer_pubkey`, compute `BLAKE3("moq-jwp:receipt-link\x01" || canonical_bytes(previous_group_receipt))` and compare to - `parent_receipt_hash`. If they differ, reject (chain break). If + `parent_receipt_hash`. If they differ, reject (`chain_break`). If no previous receipt is known and `parent_receipt_hash` is `null`, accept as the chain head. A non-null `parent_receipt_hash` without a corresponding previously-verified receipt is a recoverable - condition: the verifier MAY request the missing receipt before - accepting; it MUST NOT silently accept. + condition (`parent_not_held`): the verifier MAY request the missing + receipt before accepting; it MUST NOT silently accept. + `canonical_bytes(G)` is G's §2.3 signing payload, in the form G was + signed in, followed by its 64-byte `sig`. The resulting hash is G's + **link hash**: the identity a `claim` names G by (§2.4.3), and a + `WAI_RECEIPT` property's `group_receipt` + ([moq-streaming-format](moq-streaming-format.md) §5). For a group in + the legacy form it does not cover `parent_receipt_hash`, which that + payload does not sign either; the labelled payload signs it. 4. **Verify the object's content hash.** For each accompanying `kind: "object"` receipt: recompute `content_hash` per §2.1 over the received WAI envelope bytes and compare to the receipt's - `content_hash` field. If they differ, reject the object (the - envelope was mutated in transit). + `content_hash` field. If they differ, reject the object + (`content_hash_mismatch`: the envelope was mutated in transit). An + object receipt missing a member of §2.4.2, or carrying one neither + §2.4.2 nor its label defines, is `malformed`. -5. **Verify the Merkle inclusion proof.** Compute the leaf as - `BLAKE3("moq-jwp:merkle-leaf\x01" || content_hash)`. Walk the - `merkle_proof.siblings` list from level 0 upward; at each level - `i`, combine the current value with the sibling using the - `direction_bits` bit `i` (1 = current is right child, 0 = current - is left child) and the `moq-jwp:merkle-node\x01` domain. After - walking all siblings, compare the resulting hash to the verified - root from step 2. If they differ, reject. +5. **Verify the Merkle inclusion proof.** In this order: + - If the object receipt carries a class label (§2.4.2), check it as + §2.4.2 requires and reject the object if it fails + (`object_label_invalid`). + - Check the object's position: `leaf_index` is below the group's + `leaf_count`; `merkle_proof.siblings` holds exactly + ⌈log₂ `leaf_count`⌉ hashes (none for one leaf); `direction_bits` + equals `leaf_index`, so it holds the index's bits and no higher + ones; `object_id` equals `leaf_index`; and `track_alias` and + `group_id` are the group's. Otherwise reject + (`proof_position_mismatch`). Without this, the last leaf of an odd + level, duplicated, walks two positions, and an object can be + presented at another object's index. + - Compute the object's leaf in the labelled form of §2.2 where it + carries a label, and in the legacy form otherwise. Walk the + `merkle_proof.siblings` list from level 0 upward; at each level `i`, + combine the current value with the sibling using the + `direction_bits` bit `i` (1 = current is right child, 0 = current is + left child) and the `moq-jwp:merkle-node\x01` domain. After walking + all siblings, compare the resulting hash to the verified root from + step 2. If they differ, reject (`inclusion_proof_failed`). 6. **Accept.** The object's bytes are bound to `signer_pubkey` at - `(track_alias, group_id, object_id)`, with the publisher's - declared `joules_micro` and `origin`. The verifier MAY now persist - the group receipt as the new chain head for `signer_pubkey` and - dispatch the WAI envelope per WAI SPEC.md §4. + `(track_alias, group_id, object_id)`. Where the object's leaf is + labelled, its `joules_micro`, its class, the class's evidence and its + `origin` are bound with them. Where it is not, `joules_micro` and + `origin` are the publisher's unsigned statements. The verifier MAY now + persist the group receipt as the new chain head for `signer_pubkey` + and dispatch the WAI envelope per WAI SPEC.md §4. A verifier MUST treat any rejection in steps 1–5 as a hard failure for the object in question. A verifier MUST NOT accept a partial -verification (e.g. valid Merkle proof against an unverified root). +verification (e.g. valid Merkle proof against an unverified root): a +reading of an object's figure, a group total and a `WAI_RECEIPT` +property are taken only from objects accepted under steps 1–6. + +**The group total.** A verifier that holds, for every `leaf_index` below +the group's `leaf_count`, exactly one object receipt it has accepted under +steps 1–6 **MUST** check that their `joules_micro` sum to `joules_total`; +if they do not, it reports `group_total_mismatch`, and the objects' own +verdicts stand. If they do: + +- for a group in the legacy form, it reads the total from the objects' + labels as [energy-measurement](energy-measurement.md) §2.8 reads a + total: unmetered, unlabelled, or the weakest class as a class name only; +- for a labelled group, it checks that the group's class is the one the + objects compose to (§2.4.1) — the class and its evidence other than the + declared uncertainty (`group_class_disagrees`), the coverage + (`group_coverage_disagrees`), and a `relative_ppm` at least the + composed one over the composed window (`group_uncertainty_below_parts`) + — and reads the total at the group's signed class. + +A verifier that does not hold exactly one accepted object for every index +reads a legacy group's total as unlabelled, and a labelled group's total +at its own signed class, which it has not checked against the parts. ### 3.1. Claim receipts @@ -783,10 +1161,12 @@ order listed. a wrong JSON type; an integer that is not one of §2.4.3; a byte string that is not lowercase hex, two digits a byte, or a hash that is not 64 of them; a value outside an enumeration this extension - defines here (`record`, `binding_type`); a URI that is not absolute; + defines here (`record`, `binding_type`, a bracket's `opening`, a build's + `profile`); a URI that is not absolute; a term that is not one of §2.4.3 (empty, longer than 2048 bytes, or holding a White_Space or control character); or a malformed - `oer_receipt`. A member's type and encoding are checked together, + `oer_receipt`; or an operating point whose member names are not terms + or whose values are not integers of §2.4.3. A member's type and encoding are checked together, before the next member is read. An object a class fixes the members of — a lawful-basis purpose, an `energy_uncertainty` — is checked the same way, its members and then their types, where the table @@ -799,8 +1179,14 @@ order listed. zero; an empty `purposes`; an empty `basis`, which is why its type is `term or ""`; an empty `log_receipt` or `value`; an `alg` that is a term but not in registry form; a `resolution_endpoint` that is an - absolute URI but not an `https` URI with a host and no userinfo; and - an `attests` other than `"ancestor"`. + absolute URI but not an `https` URI with a host and no userinfo; an + `attests` other than `"ancestor"`; a figure profile, an index or a + group that does not fit the profile; a hardware figure's report that + names no power reader; a bracket with fewer than two + samples, liveness facts that do not show a live rail, a zero window, + completion or thread count, or a busy mean not above its idle mean; + the figure and least uncertainty a bracket gives; an empty point; and + which points and predictions are present together. 3. **Signature.** Ed25519 (RFC 8032). This step restates RFC 8032's own decoding and range rules, and fixes the two choices on which RFC 8032 verifiers differ — whether a key or `R` of small order is @@ -824,7 +1210,7 @@ order listed. for a signature made without any private key — under the identity, `R` = [s]B with `S` = s verifies for every message — so such a key is refused before its signature is examined. -4. **Class rules**, per §2.4.5–§2.4.7 and §2.6, each with its own +4. **Class rules**, per §2.4.5–§2.4.9 and §2.6, each with its own reason (below). They run after the signature because a rule about what a signer said only means something once it is established who said it. @@ -840,9 +1226,14 @@ order listed. a `null` parent while a previous claim is held, is `chain_break`. 6. **Context**: checks against other receipts the verifier holds, each verified first — an `energy` claim against the `object` receipt - for its subject, verified under §3, and against the OER receipt it - names, verified under oer-receipts as §2.4.5's check 2 - (`energy_oer_invalid`); a decision against its declaration (§2.4.6); + for its subject, verified under §3, including the class label its leaf + carries (§2.4.5), and against the OER receipt it names, verified under + oer-receipts as §2.4.5's check 2 (`energy_oer_invalid`); a + `measurement` or `operating-point` claim against the receipt that + carries its figure, verified under that receipt's own profile (for + `moq-jwp:object`, the object receipt accepted under §3 against the + group receipt `group` names) (§2.4.8); a decision against its + declaration (§2.4.6); a recovery record against its declaration together with the publisher key of a verified delivery receipt for the declaration's subject (§2.6.3), and never against the declaration alone. A @@ -864,12 +1255,14 @@ order listed. publisher is then `recovery_signer_mismatch`, in §2.6.3's order. A claim that passes every step it was checked at is **accepted**: its -signer made this statement about this subject. §2.4.4's rules bound +signer made this statement about this subject. A `measurement` claim is +checked at step 6 always: without the receipt carrying its figure it is +`figure_not_held`, not accepted (§2.4.8). §2.4.4's rules bound what that means, and §2.4.7 bounds it further for `log-inclusion`. | step | reason | raised when | |---|---|---| -| 4 | `energy_zero_figure` | `joules_micro` is 0 | +| 4 | `energy_zero_figure` | `joules_micro` is 0 in an `energy`, `measurement` or `operating-point` claim | | 4 | `energy_unknown_acquisition` | `energy_provenance` names no class of energy-measurement §2 | | 4 | `energy_legacy_acquisition` | `energy_provenance` is the legacy `HwShunt` | | 4 | `energy_acquisition_evidence` | the class's evidence is missing, carried by a class that has none, or below the resolution floor (§2.4.5 rule 2) | @@ -886,10 +1279,33 @@ what that means, and §2.4.7 bounds it further for `log-inclusion`. | 4 | `soft_binding_endpoint_not_https` | `resolution_endpoint` is not an `https` URI with a host and no userinfo | | 4 | `recovery_not_ancestor` | `attests` is not `"ancestor"` | | 4 | `recovery_of_hard_bound_object` | `observed` equals `subject` | +| 4 | `figure_unknown_profile` | `figure_profile` names no profile of energy-measurement §6.1 for the claim's `subject_type` | +| 4 | `figure_index_mismatch` | `figure_index` is an integer under a profile that carries one figure, or `null` under one that carries several | +| 4 | `figure_group_mismatch` | `group` is a hash under a profile other than `moq-jwp:object`, or `null` under it | +| 4 | `measurement_no_power_reader` | under a hardware class, `power_reader` is `null` | +| 4 | `measurement_too_few_samples` | a bracket's `n` is below 2 | +| 4 | `measurement_rail_not_live` | under a hardware class, a bracket's liveness facts do not show a live rail, or its samples are all equal | +| 4 | `measurement_empty_burst` | a bracket's `window_us`, `completions` or `in_flight` is 0 | +| 4 | `measurement_no_marginal` | a bracket's busy mean is not above its idle mean | +| 4 | `measurement_below_attribution_floor` | under a hardware class, the marginal draw is below the idle standard deviation | +| 4 | `measurement_figure_mismatch` | `joules_micro` is not the figure the bracket gives | +| 4 | `measurement_window_mismatch` | under a hardware class, the declared window is not the bracket's | +| 4 | `measurement_declaration_below_bracket` | under a hardware class, `relative_ppm` is below the least the bracket supports | +| 4 | `operating_point_empty_point` | a point is present with no member | +| 4 | `operating_point_delivered_alone` | `op_delivered` is present without `op_committed` | +| 4 | `operating_point_incomplete` | `op_requested` or `op_committed` is present without `op_delivered` | +| 4 | `operating_point_empty` | neither `op_delivered` nor `prediction` is present | | 6 | `energy_disagrees_with_delivery` | the figure differs from the `object` receipt's | +| 6 | `energy_class_disagrees_with_delivery` | the object's leaf is labelled, and the `energy` claim's class or evidence differs from the label | +| 6 | `figure_disagrees` | the receipt a `measurement` or `operating-point` claim names carries another figure at `figure_index` | +| 6 | `figure_unclassified` | that figure carries no acquisition class | +| 6 | `figure_class_disagrees` | its class or evidence differs from the claim's | +| 6 | `figure_signer_unknown` | a `measurement` claim names a figure no Ed25519 key signs | +| 6 | `figure_signer_mismatch` | a `measurement` claim's figure is signed by an Ed25519 key other than `signer_pubkey` | +| 6 | `figure_not_held` | a `measurement` claim is checked without the receipt that carries its figure (§2.4.8) | | 6 | `energy_oer_invalid` | the named OER receipt reports any reason under oer-receipts | | 6 | `energy_disagrees_with_oer` | the named OER receipt states another grade or figure | -| 6 | `context_invalid` | a declaration a decision or a recovery record is checked against does not pass steps 1–4 | +| 6 | `context_invalid` | a receipt a claim is checked against — a declaration a decision or a recovery record names, or the receipt that carries a figure — does not verify | | 6 | `decision_declaration_mismatch` | a decision does not match the declaration it names | | 6 | `decision_purpose_not_declared` | a decision is about a purpose its declaration does not list | | 6 | `recovery_declaration_mismatch` | a recovery record does not match the declaration it names | @@ -955,6 +1371,26 @@ The reference implementation (`wai-rs`, feature `receipt_claims`, ancestor; there is no path from it to a statement about the object in hand. +### 3.3. Delivery reasons + +| check | reason | raised when | +|---|---|---| +| §3 step 1 | `wrong_kind` | `kind` is not the receipt's kind | +| §3 step 1, 4 | `malformed` | a group receipt (step 1) or object receipt (step 4) lacks a member, carries one its kind does not define, or holds one of the wrong type or encoding | +| §3 step 1 | `bad_signer_key` | `signer_pubkey` does not decode under RFC 8032 §5.1.3, or is of small order | +| §3 step 1, 2 | `group_label_invalid` | a group's labelled members appear without `"sign_version": 2`, its `sign_version` is not `2`, or its labelled members cannot be read (step 1), or its class breaks §2.4.1 with the group alone (step 2) | +| §3 step 2 | `signature_verify_failed` | the group signature fails §3.1 step 3's rule over the payload of the group's form | +| §3 step 3 | `chain_break`, `parent_not_held` | as §3.1 step 5, for groups | +| §3 step 4 | `content_hash_mismatch` | the envelope does not hash to `content_hash` | +| §3 step 5 | `object_label_invalid` | an object receipt's class label cannot be read or breaks §2.4.2: legacy `HwShunt`, a class beside a zero figure, the unmetered marker beside a non-zero one, a refined class without its evidence or below the resolution floor, or evidence without a class | +| §3 step 5 | `proof_position_mismatch` | the object is not at the position its proof walks: `leaf_index` outside the group, a proof of the wrong length, direction bits other than the index's, an `object_id` other than the index, or another group's coordinates | +| §3 step 5 | `inclusion_proof_failed` | the object's leaf, walked up its proof, does not reach the verified root — among other causes, because the leaf was computed in the other form of §2.2 | +| the group total | `group_total_mismatch` | the accepted object receipts, one for every leaf, do not sum to `joules_total` | +| the group total | `group_class_disagrees` | a labelled group's class, or its evidence other than the declared uncertainty, is not the one its objects compose to, or its objects compose to none | +| the group total | `group_coverage_disagrees` | a labelled group's coverage is not the one its objects give | +| the group total | `group_uncertainty_below_parts` | a labelled group declares less than the composed `relative_ppm`, or another window | +| `WAI_RECEIPT` | `property_invalid`, `property_disagrees` | as [moq-streaming-format](moq-streaming-format.md) §5 | + --- ## 4. Relay preservation requirements @@ -972,7 +1408,10 @@ chain across hops, a conforming relay: - Verify the group receipt's Ed25519 signature per §3 step 2 before the first object of the group is fanned out. A relay that cannot verify the signature MUST drop the group and emit a structured - error to upstream operators. + error to upstream operators. A relay that predates the labelled + group payload (§2.3) cannot verify a labelled group and drops it, so + a publisher labels a group only once the relays on its path verify + both payloads. - Forward the `parent_receipt_hash` field as received. A relay that has previously forwarded the chain MAY additionally check the link per §3 step 3 and refuse to fan out a broken chain. @@ -1079,7 +1518,7 @@ exercises all five vectors in its own test suite. ### 5.6. Claim receipts and recovery (`claim-conformance/`) -The typed classes of §2.4.3–§2.4.7, the binding classes of §2.6 and +The typed classes of §2.4.3–§2.4.9, the binding classes of §2.6 and the procedures of §3.1–§3.2 are checked by the corpus in [`claim-conformance/`](../claim-conformance/), generated and verified by `wai_claim_vectors` (`wai-rs`, feature `receipt_claims`) and run by @@ -1108,7 +1547,18 @@ unpaired surrogate escape, deep nesting, numbers out of every range — and dates that do not exist; the boundary between a member's type (step 2) and what a class's rules add to it (step 4), from both sides, and the order step 2 reads a body in, where a body has two defects; -and a claim whose canonical form spans three BLAKE3 chunks. +and a claim whose canonical form spans three BLAKE3 chunks. The corpus +also covers the `measurement` and `operating-point` classes: every reason +they add, the arithmetic of energy-measurement §6.2 at its boundaries — a +declaration at the least value a bracket supports and one below it, a +figure at a half microjoule, the ratio thresholds, a flat idle bracket, and +brackets whose products exceed 128 bits — the liveness rule of +energy-measurement §3.1 (a frozen rail refreshed once, a refresh published +in two steps, facts that describe no sequence), and the declaration a +sealing path computes from unrounded samples, which the rule refuses. That +arithmetic is pinned against values `claim_verify.py` computes with +Python's integers. It covers an `energy` claim checked against an object +whose leaf is labelled. The generator and the verifier in `wai-rs` are one implementation, so two checks are made against code that shares nothing with it. A @@ -1131,13 +1581,34 @@ evidence, figures near a half-microjoule and at the edges of oer-receipts §4 and §6, OER receipts holding numbers §4 forbids in a body, files §4 does not read at all, context receipts that do not verify, bodies of every class with one defect and with two at -different positions — runs both verifiers' `verdicts` mode on them, -and reports every input on which their verdicts differ. Agreement -shows that the two read alike, not that either reads §3 as written; -only the vectors' declared outcomes, set from the text, speak to that, -and only for the inputs they hold. The fixture runs in CI; -the script and the differential need Python with an Ed25519 library -and are run by hand, not by CI. +different positions, measurement brackets and operating points around +every rule — runs both verifiers' `verdicts` mode on them, and reports +every input on which their verdicts differ. Agreement shows that the two +read alike, not that either reads §3 as written; only the vectors' +declared outcomes, set from the text, speak to that, and only for the +inputs they hold. The fixture and the bracket arithmetic run in CI on every +platform of the matrix; the script and the differential, which need Python +with an Ed25519 library, run in CI on the linux runner with a fixed seed. + +### 5.7. Labelled leaves, labelled groups and the group total (`jwp-conformance/`) + +`wai_jwp_vectors gen` in `wai-rs` writes `jwp-conformance/vectors.json`, +and `verify` requires a byte-identical regeneration and every declared +outcome. Every vector is synthetic: keys come from published fixed seeds +and attest nothing, and envelopes, figures and origins are invented. The +corpus holds leaf vectors in both forms of §2.2; a group in the legacy +payload whose objects mix the forms, and a group in the labelled payload +chained to it, each with its signing payload, signature and link hash; +each object's outcome (`accept`, or a rejection at its §3 step with its +§3.3 reason), including objects presented at another object's position; +group signatures the strict rule refuses; the group total's reading or its +reason; and `WAI_RECEIPT` properties with the reading +[moq-streaming-format](moq-streaming-format.md) §5 gives them. +`jwp-conformance/jwp_verify.py`, written from this text, reports the same +outcomes; CI runs it on the linux runner, and runs `wai_jwp_vectors +verify` on every platform of the conformance matrix. joule-code's +`joule-moq` pins leaves, roots, signatures and link hashes copied from the +corpus. --- @@ -1152,7 +1623,9 @@ later time. This extension mitigates replay with two mechanisms: - `parent_receipt_hash` (§2.4.1) links each group receipt to its predecessor under the same `signer_pubkey`, so a chain replay - requires forging every intermediate hash. + requires forging every intermediate hash. The labelled payload (§2.3) + signs it; the legacy payload does not, so in a legacy group the link + is the publisher's unsigned statement and a relay can rewrite it. - `group_id` is monotonic per publisher key. Verifiers SHOULD track the highest `group_id` seen per `signer_pubkey` and reject any receipt with a non-increasing `group_id`. This binds the chain @@ -1186,9 +1659,11 @@ to re-present a group receipt under a different `(track_alias, group_id)` tuple cannot do so without re-signing — the receipt's fields are signature-covered, and any field mutation breaks verification at §3 step 2. The Merkle proof is similarly bound: -`leaf_index` is verifier-checked during the proof walk, so an -object's proof cannot be transplanted to a different position within -the same group. +the verifier checks that the proof walks the position `leaf_index` +names (§3 step 5), so an object's proof cannot be transplanted to a +different position within the same group, and the duplicated last +leaf of an odd level cannot be presented at the position past the +group's end. ### 6.4. Envelope mutation in transit @@ -1226,7 +1701,9 @@ multi-signer container; the v1.0 wire format remains a strict subset. [Confidentiality extension](confidentiality.md) seals the object bytes a receipt attests, and this chain deliberately commits over that ciphertext so a relay signs delivery + energy without ever - reading the content. + reading the content. A `measurement` claim (§2.4.8) also carries the + measuring machine's power levels, thread count, build and power reader + in cleartext. - **Pricing semantics for joules.** `joules_micro` and `joules_total` are unit-of-energy fields, not unit-of-account fields. Conversion to currency, carbon, or grid-mix CO₂ is diff --git a/wai/extensions/moq-streaming-format.md b/wai/extensions/moq-streaming-format.md index 6d45d372a..8f1370591 100644 --- a/wai/extensions/moq-streaming-format.md +++ b/wai/extensions/moq-streaming-format.md @@ -101,14 +101,20 @@ commitment: | field | type | meaning | |---|---|---| | `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. | +| `joules_micro` | u64 | Microjoules producing this object; `0` is the unmetered marker, never 0 J ([energy-measurement](energy-measurement.md) §2). | +| `acquisition` | string | Acquisition class per [energy-measurement](energy-measurement.md) §2; empty when `joules_micro` is `0`. | | `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. | +| `group_receipt` | 32 B | The link hash of the group receipt covering this object (`jwp-receipts` §3 step 3). | -- `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. +- A non-zero `joules_micro` **MUST** be accompanied by `acquisition`. 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. +- A zero `joules_micro` is the unmetered marker: `acquisition` **MUST** be + empty and `acquisition_evidence` empty, since a class beside a zero figure + claims a measurement that did not occur (`energy-measurement.md` §2). A sink + **MUST NOT** present the marker as a figure of zero joules, and **MUST** + discard a property that breaks this bullet or the one above + (`property_invalid`). - `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 @@ -117,6 +123,26 @@ commitment: 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. +- No signature covers this property. A sink **MUST NOT** report `acquisition` + or `acquisition_evidence` as signed unless they and `joules_micro` equal a + signed statement it holds of the same object, which is one of: + - the label in the object's Merkle leaf, once the object receipt has been + accepted under `jwp-receipts` §3 against the group receipt + `group_receipt` names — `group_receipt` must then name that group; or + - an `energy` claim about the object that it has accepted under + `jwp-receipts` §3.1. A claim names no group, so `group_receipt` is outside + what it states. + + Where such a statement gives another figure, class, evidence or group, the + sink **MUST** discard the property (`property_disagrees`). Where it holds + none, the class is outside every signature, and the sink reads the figure as + unlabelled. +- A class read as signed is the statement of the key that signed it: the group + receipt's `signer_pubkey` for a leaf, the claim's `signer_pubkey` for an + `energy` claim. A sink **MUST** report that key with the class, and **MUST + NOT** report a class a claim signs as the publisher's unless the claim's + signer is a key it holds as the publisher's. Anyone can sign an `energy` + claim, a relay that rewrites this property included. - 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/provenance.md b/wai/extensions/provenance.md index 052b465e1..54588ff33 100644 --- a/wai/extensions/provenance.md +++ b/wai/extensions/provenance.md @@ -43,7 +43,7 @@ A `ProvStep` is one transformation in the asset's lineage: | `input_hash` | content hash of the input (all-zero for an originating step) | | `output_hash` | content hash of the output | | `work` | exact deterministic work (pixels, samples) | -| `joules_micro` | measured/carried microjoules (0 = unmetered) | +| `joules_micro` | measured/carried microjoules; `0` is the unmetered marker, never 0 J ([energy-measurement](energy-measurement.md) §2) | Step content hash: `BLAKE3("wai:prov-step\x01" || op_len | op | input | output | work | joules)`. @@ -74,7 +74,8 @@ There is no tolerance parameter: the content hash matches or it does not. `joules_micro` per step is **carried** from a meter, exactly as the other 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. +carries the unmetered marker `0`, which a reader presents as unmetered and never +totals as zero joules. 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 @@ -82,8 +83,11 @@ 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. +measurement fails the §4 ratio or the power reader's liveness (§3.1) as +unmetered, and writes a measurement report for each step it measured +(energy-measurement §6). `total_joules_micro` carries no class of its own, and a +total over steps that include an unmetered one covers only the measured steps +(`Partial`). ## Appendix diff --git a/wai/extensions/quantum-sim.md b/wai/extensions/quantum-sim.md index 0a6ab3927..41095cf6a 100644 --- a/wai/extensions/quantum-sim.md +++ b/wai/extensions/quantum-sim.md @@ -196,12 +196,14 @@ is trusted the way it honestly can be.** | `statevector_hash` | the byte-exact reconstruction result | **re-checkable** (re-simulate) | | `measurement` | optional `{seed, shots, histogram_hash}` | re-checkable | | `work_amp_updates` | `n_ops · 2^n` — the exponential reconstruction cost | **verified by recomputation** | -| `joules_micro` | measured marginal CPU energy the sink spent | **attested by signature** | +| `joules_micro` | measured marginal CPU energy the sink spent; `0` is the unmetered marker | **attested by signature** | +| `energy_provenance` and its evidence (optional; [energy-measurement](energy-measurement.md) §2.8) | how `joules_micro` was acquired | **attested by signature**, not re-measurable | `work_amp_updates` is *portable-exact*: anyone recomputes it from `(n_ops, n_qubits)` and it grows exponentially in qubits, so a sink cannot inflate or deflate the stated cost. `joules_micro` is *measured-attested*: only the signer -can vouch for what its own silicon drew. One Ed25519 signature covers all of it +can vouch for what its own silicon drew, and the same holds for the class that +labels it and for the values of any report about it (energy-measurement §6). One Ed25519 signature covers all of it over a Merkle root of the leaf hashes (`circuit`, `statevector`, `[histogram]`) — the worlds / provenance / film receipt shape reused, `parent_receipt_hash` chaining a derivation exactly as they do. @@ -219,11 +221,15 @@ Two verification depths: `to_json()` / `from_json()` give the portable, sorted-key JSON an out-of-band verifier reads. `wai_quantum_meter [receipt.json]` fills the -energy honestly: on arm64 macOS it samples CPU power via `macmon` (IOReport, no -sudo), brackets a reconstruction burst against an idle baseline, and writes the +energy honestly: on a host that exposes its SoC's power interface to +unprivileged readers it samples CPU power through the open-source `macmon` +reader (no elevated privileges), 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 measured figure is sealed with its acquisition class as a +present it emits an UNMETERED receipt, whose `joules_micro` carries the +unmetered marker `0` ([energy-measurement](energy-measurement.md) §2), never a +figure of zero joules, with the exact work intact; a reader presents it as +unmetered, and `POST /verify` renders it as `"joules_micro": null`. Beside a +measured figure the meter writes a measurement report (energy-measurement §6). 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 diff --git a/wai/jwp-conformance/.gitattributes b/wai/jwp-conformance/.gitattributes new file mode 100644 index 000000000..165556bc9 --- /dev/null +++ b/wai/jwp-conformance/.gitattributes @@ -0,0 +1,3 @@ +# The pinned corpus is compared byte-for-byte with what the generator writes (LF), on +# every platform of the CI matrix. Keep a CRLF checkout from turning that into a failure. +* text eol=lf diff --git a/wai/jwp-conformance/README.md b/wai/jwp-conformance/README.md new file mode 100644 index 000000000..4491a6f59 --- /dev/null +++ b/wai/jwp-conformance/README.md @@ -0,0 +1,64 @@ +# jwp-conformance + +Conformance for the labelled forms of the [JWP delivery receipts](../extensions/jwp-receipts.md) +(§2.2–§2.4.2, §3, §3.3 and §5.7) and for the `WAI_RECEIPT` property's readings +([moq-streaming-format](../extensions/moq-streaming-format.md) §5): an object's figure, +class and origin bound into its Merkle leaf; a group total's class signed under the +labelled group payload; the position an object's inclusion proof must walk; the group +total read from its objects; and a property read as signed only where a signed statement +restates it. + +## Files + +| file | what it is | +|---|---| +| `vectors.json` | The corpus, generated by `wai_jwp_vectors gen` in `wai-rs` (feature `receipt_claims`). `wai_jwp_vectors verify` requires a byte-identical regeneration and every declared outcome. | +| `jwp_verify.py` | A second verifier, written from the extension text, that shares no code with `wai-rs`. It takes BLAKE3, jwp-receipts §3.1 step 3's Ed25519 rule and the claim verifier from `../claim-conformance/claim_verify.py`, itself independent of `wai-rs`. | + +## Procedures + +Each vector names one procedure and declares one outcome. + +| procedure | input | outcome | +|---|---|---| +| `leaf` | an object receipt | its leaf, in the form its label gives (§2.2) | +| `group` | a group receipt, and optionally the previous one held | `accept` with its link hash and signing payload (§2.3, §3 step 3), or a rejection at a §3 step | +| `object` | a group receipt, an object receipt and the envelope delivered | `accept` (§3 steps 1–5), or a rejection at a §3 step | +| `total` | a group receipt and the objects delivered with their envelopes | the total's reading — `unmetered`, `unlabelled`, `weakest` (a legacy group whose every non-zero part is labelled: a class name only), or `signed` (a labelled group's own class, `checked` against every object or `unchecked`) — or a rejection | +| `property` | a `WAI_RECEIPT` property, with the object, its group and its envelope, or with an `energy` claim | the property's reading — `unmetered`, `unsigned`, or `signed` with the class and the key that signed it (the group's for a leaf, the claim's for a claim) — or a rejection | + +A rejection names its reason (§3.3), and its §3 step where it has one; the group total +and a property are not steps of §3. + +## What the corpus holds + +Leaves in both forms, the labelled one with tags 0, 2, 4 and 5. A group in the legacy +payload whose objects mix the forms, and a group in the labelled payload chained to it, +with their signing payloads, signatures and link hashes. Every way a label, a figure or +an origin can be restated, stripped or added, and which of them the legacy leaf leaves +unbound. Labels that break their rules in correctly built trees. Objects presented at +another object's position — including the duplicated last leaf of an odd level, and a +duplicate that would hide a weaker class from the total. Group labels that break their +rules, correctly signed; a `sign_version` other than 2; and group signatures the strict +Ed25519 rule refuses (`S` ≥ L, a key of small order, a key whose `y` is not reduced). Totals read from complete and incomplete holdings, a total that +does not match its parts, and labelled totals whose class, coverage, declared uncertainty +or window disagree with what their parts compose to. Properties that agree with, and +disagree with, a labelled leaf or an `energy` claim, including a claim signed by a key +other than the publisher's, which reads as signed by that key. + +## Synthetic, and what it attests + +Keys are Ed25519 from fixed 32-byte seeds — 0x11 the publisher, 0x22 the meter, 0x33 a +relay — and attest nothing. Envelopes, figures, origins and calibration references are invented, and +calibration references use a reserved example domain. + +## Running + + cargo run --no-default-features --features receipt_claims --bin wai_jwp_vectors -- verify ../jwp-conformance + python3 jwp_verify.py vectors.json + +Both run in CI: the Rust verifier on every platform of the conformance matrix, the Python +one on the linux runner (`.github/workflows/wai-conformance.yml`). The Python verifier +needs the `cryptography` package. joule-code's `joule-moq` pins leaves, roots and +signatures from this corpus in its own tests, and its workflow re-runs when the corpus +changes. diff --git a/wai/jwp-conformance/jwp_verify.py b/wai/jwp-conformance/jwp_verify.py new file mode 100644 index 000000000..13633fef7 --- /dev/null +++ b/wai/jwp-conformance/jwp_verify.py @@ -0,0 +1,544 @@ +#!/usr/bin/env python3 +"""Second verifier for jwp-conformance/ — labelled leaves, labelled group receipts, the +group total and the WAI_RECEIPT property (extensions/jwp-receipts.md §2.2-§2.4.2, §3, +§3.3 and §5.7; moq-streaming-format.md §5). + +Written from the extension text. It shares no code with wai-rs (`crate::jwp_delivery`), +which generates the corpus. It takes from ../claim-conformance/claim_verify.py — itself +independent of wai-rs — BLAKE3 written from its specification, jwp-receipts §3.1 step 3's +Ed25519 rule (key decoding, small order, S < L, the cofactorless equation through the +`cryptography` package), and the claim verifier an energy claim is checked with. + + python3 jwp_verify.py [vectors.json] verify every vector against its declared outcome + python3 jwp_verify.py verdicts print `nameverdict` for every vector, + ignoring any declared outcome + +Exit status 0 only if every vector reports its declared outcome. +""" +import json, os, sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance")) +import claim_verify as CV # noqa: E402 + +LEAF1 = b"moq-jwp:merkle-leaf\x01" +LEAF2 = b"moq-jwp:merkle-leaf\x02" +NODE = b"moq-jwp:merkle-node\x01" +SIGN1 = b"moq-jwp:group-sign\x01" +SIGN2 = b"moq-jwp:group-sign\x02" +LINK = b"moq-jwp:receipt-link\x01" +OBJECT = b"moq-jwp:object\x01" +FLOOR = 288676 # energy-measurement §2.3: joules_micro x relative_ppm, at least +U32, U64 = 2 ** 32 - 1, 2 ** 64 - 1 +RANK = {"Estimator": 1, "ModelBased": 2, "HwShunt": 3, "OnChipCounter": 4, "CalibratedInstrument": 5} +TAG = {"HwShunt": 1, "ModelBased": 2, "Estimator": 3, "OnChipCounter": 4, "CalibratedInstrument": 5} +FILTERING = {"Unknown": 0, "Off": 1, "On": 2} +CLASS_KEYS = ["energy_provenance", "energy_uncertainty", "energy_counter_filtering", "energy_calibration_ref"] +GROUP = ["kind", "track_alias", "group_id", "root_hash", "leaf_count", "joules_total", "group_start", "group_end", + "signer_pubkey", "signer_id", "sig", "parent_receipt_hash"] +GROUP_LABEL = ["sign_version"] + CLASS_KEYS + ["energy_coverage"] +OBJ = ["kind", "track_alias", "group_id", "object_id", "content_hash", "joules_micro", "origin", "merkle_proof"] +OBJ_LABEL = ["leaf_version"] + CLASS_KEYS + + +class Refuse(Exception): + def __init__(self, step, reason): + super().__init__(reason) + self.step, self.reason = step, reason + + def label(self): + return f"reject reason={self.reason}" if self.step is None else f"reject step={self.step} reason={self.reason}" + + +class Unreadable(Exception): + """A class whose JSON members do not make one class (energy-measurement §2.7).""" + + +def blake3(b): + return CV.blake3(b) + + +def u64(v): + return isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= U64 + + +def hex_bytes(v, n=None): + if not CV.is_hex(v, n): + return None + return bytes.fromhex(v) + + +def present(m, k): + return m.get(k) is not None + + +# ------------------------------------------------------------ classes (§2.7) + +def read_class(m): + """The class a receipt's JSON members name, as energy-measurement §2.7 reads it, or + None for no class. Evidence without a class, an unknown class, or evidence a class + does not carry make no class: Unreadable.""" + label = m.get("energy_provenance") + u, f, r = m.get("energy_uncertainty"), m.get("energy_counter_filtering"), m.get("energy_calibration_ref") + if label is None: + if u is not None or f is not None or r is not None: + raise Unreadable() + return None + if not isinstance(label, str) or label not in TAG: + raise Unreadable() + if label in ("HwShunt", "ModelBased", "Estimator"): + if u is not None or f is not None or r is not None: + raise Unreadable() + return {"label": label} + if not isinstance(u, dict) or not u64(u.get("relative_ppm")) or not u64(u.get("window_us")): + raise Unreadable() + ppm, win = u["relative_ppm"], u["window_us"] + if ppm == 0 or ppm > U32 or win == 0: + raise Unreadable() + if label == "OnChipCounter": + if r is not None: + raise Unreadable() + if f is None: + f = "Unknown" # an unstated filtering state is Unknown, never Off (§2.4) + if f not in FILTERING: + raise Unreadable() + return {"label": label, "ppm": ppm, "window": win, "filtering": f} + if f is not None or not isinstance(r, str) or r.strip(CV.WHITE_SPACE) == "": + raise Unreadable() + return {"label": label, "ppm": ppm, "window": win, "ref": r} + + +def encode(c): + """The signed encoding (§2.7); None is tag 0, the unmetered marker.""" + if c is None: + return b"\x00" + b = bytes([TAG[c["label"]]]) + if c["label"] == "OnChipCounter": + b += c["ppm"].to_bytes(4, "big") + c["window"].to_bytes(8, "big") + bytes([FILTERING[c["filtering"]]]) + elif c["label"] == "CalibratedInstrument": + ref = c["ref"].encode() + b += c["ppm"].to_bytes(4, "big") + c["window"].to_bytes(8, "big") + len(ref).to_bytes(4, "big") + ref + return b + + +def label_ok(figure, c): + """A class as an optional label (energy-measurement §2.8): never HwShunt, never + beside a zero figure, at the resolution floor where it declares an uncertainty.""" + if c["label"] == "HwShunt" or figure == 0: + return False + return "ppm" not in c or figure * c["ppm"] >= FLOOR + + +# ------------------------------------------------------------ the tree (§2.2) + +def node(a, b): + return blake3(NODE + a + b) + + +def leaf(o): + if not o["labelled"]: + return blake3(LEAF1 + o["content_hash"]) + origin = o["origin"].encode() + return blake3(LEAF2 + o["content_hash"] + o["joules_micro"].to_bytes(8, "big") + encode(o["class"]) + + len(origin).to_bytes(4, "big") + origin) + + +def depth(n): + return 0 if n <= 1 else (n - 1).bit_length() + + +# ------------------------------------------------------------ group receipts (§2.4.1, §3 steps 1-3) + +def read_group(g): + """§3 step 1's reading of a group receipt.""" + if not isinstance(g, dict) or "kind" not in g: + raise Refuse(1, "malformed") + if g["kind"] != "group": + raise Refuse(1, "wrong_kind") + if any(k not in g for k in GROUP) or any(k not in GROUP and k not in GROUP_LABEL for k in g): + raise Refuse(1, "malformed") + ok = all(u64(g[k]) for k in ("track_alias", "group_id", "leaf_count", "joules_total")) \ + and all(isinstance(g[k], str) for k in ("group_start", "group_end", "signer_id")) \ + and CV.is_hex(g["root_hash"], 32) and CV.is_hex(g["signer_pubkey"], 32) and CV.is_hex(g["sig"], 64) \ + and (g["parent_receipt_hash"] is None or CV.is_hex(g["parent_receipt_hash"], 32)) + if not ok: + raise Refuse(1, "malformed") + out = dict(g, labelled=False) + sv = g.get("sign_version") + if sv is None: + if any(present(g, k) for k in GROUP_LABEL): + raise Refuse(1, "group_label_invalid") + return out + # sign_version is a labelled member (§2.4.1): any value but 2 is not the labelled + # form, and is group_label_invalid (§3.3), as leaf_version is object_label_invalid. + if not (isinstance(sv, int) and not isinstance(sv, bool) and sv == 2): + raise Refuse(1, "group_label_invalid") + try: + c = read_class(g) + except Unreadable: + raise Refuse(1, "group_label_invalid") + cov = g.get("energy_coverage") + if c is None: + if cov is not None: + raise Refuse(1, "group_label_invalid") + cov = "Whole" + elif cov not in ("Whole", "Partial"): + raise Refuse(1, "group_label_invalid") + out.update(labelled=True, cls=c, coverage=cov) + return out + + +def payload(g): + """§2.3: the legacy payload, or the labelled one — the same fields under the 0x02 + domain, then the class, the coverage byte and the parent link.""" + b = (SIGN2 if g["labelled"] else SIGN1) + g["track_alias"].to_bytes(8, "big") + g["group_id"].to_bytes(8, "big") + b += bytes.fromhex(g["root_hash"]) + g["leaf_count"].to_bytes(8, "big") + g["joules_total"].to_bytes(8, "big") + for s in (g["group_start"], g["group_end"]): + e = s.encode() + b += len(e).to_bytes(4, "big") + e + sid = g["signer_id"].encode() + b += bytes.fromhex(g["signer_pubkey"]) + len(sid).to_bytes(4, "big") + sid + if g["labelled"]: + b += encode(g["cls"]) + (b"\x01" if g["cls"] is not None and g["coverage"] == "Partial" else b"\x00") + parent = g["parent_receipt_hash"] + b += (b"\x01" + bytes.fromhex(parent)) if parent is not None else b"\x00" + bytes(32) + return b + + +def link(g): + return blake3(LINK + payload(g) + bytes.fromhex(g["sig"])) + + +def verify_group(g): + """§3 steps 1-2: the key, the signature under §3.1 step 3's rule, then a labelled + group's own checks.""" + try: + CV.check_signature(bytes.fromhex(g["signer_pubkey"]), bytes.fromhex(g["sig"]), payload(g)) + except CV.Reject as r: + raise Refuse(1 if r.reason == "bad_signer_key" else 2, r.reason) + if g["labelled"]: + c, total = g["cls"], g["joules_total"] + if c is None: + ok = total == 0 and g["coverage"] == "Whole" + else: + ok = label_ok(total, c) + if not ok: + raise Refuse(2, "group_label_invalid") + + +def check_parent(g, previous): + """§3 step 3. A previous group that does not verify is not held.""" + if previous is not None: + try: + verify_group(previous) + except Refuse: + previous = None + parent = g["parent_receipt_hash"] + if previous is None: + if parent is not None: + raise Refuse(3, "parent_not_held") + return + if parent is None or previous["signer_pubkey"] != g["signer_pubkey"] or bytes.fromhex(parent) != link(previous): + raise Refuse(3, "chain_break") + + +# ------------------------------------------------------------ object receipts (§2.4.2, §3 steps 4-5) + +def read_object(o): + """The object receipt's members. Shape failures are `malformed` at step 4; a label + that cannot be read is kept, and refused at step 5.""" + if not isinstance(o, dict) or "kind" not in o: + raise Refuse(4, "malformed") + if o["kind"] != "object": + raise Refuse(4, "wrong_kind") + if any(k not in o for k in OBJ) or any(k not in OBJ and k not in OBJ_LABEL for k in o): + raise Refuse(4, "malformed") + p = o["merkle_proof"] + ok = all(u64(o[k]) for k in ("track_alias", "group_id", "object_id", "joules_micro")) \ + and CV.is_hex(o["content_hash"], 32) and isinstance(o["origin"], str) \ + and isinstance(p, dict) and sorted(p) == ["direction_bits", "leaf_index", "siblings"] \ + and u64(p["direction_bits"]) and u64(p["leaf_index"]) and isinstance(p["siblings"], list) \ + and all(CV.is_hex(s, 32) for s in p["siblings"]) + if not ok: + raise Refuse(4, "malformed") + r = {"track_alias": o["track_alias"], "group_id": o["group_id"], "object_id": o["object_id"], + "content_hash": bytes.fromhex(o["content_hash"]), "joules_micro": o["joules_micro"], "origin": o["origin"], + "siblings": [bytes.fromhex(s) for s in p["siblings"]], "bits": p["direction_bits"], "index": p["leaf_index"], + "labelled": False, "class": None, "unreadable": False} + lv = o.get("leaf_version") + if lv is None: + r["unreadable"] = any(present(o, k) for k in OBJ_LABEL) + elif isinstance(lv, int) and not isinstance(lv, bool) and lv == 2: + try: + r.update(labelled=True, **{"class": read_class(o)}) + except Unreadable: + r["unreadable"] = True + else: + r["unreadable"] = True + return r + + +def inclusion(o, g): + """§3 step 5: the label, the position, the walk.""" + if o["unreadable"]: + raise Refuse(5, "object_label_invalid") + if o["labelled"]: + c = o["class"] + if (c is None and o["joules_micro"] != 0) or (c is not None and not label_ok(o["joules_micro"], c)): + raise Refuse(5, "object_label_invalid") + n = g["leaf_count"] + if (o["track_alias"] != g["track_alias"] or o["group_id"] != g["group_id"] or o["object_id"] != o["index"] + or not o["index"] < n or len(o["siblings"]) != depth(n) or o["bits"] != o["index"]): + raise Refuse(5, "proof_position_mismatch") + cur = leaf(o) + for i, s in enumerate(o["siblings"]): + cur = node(s, cur) if (o["bits"] >> i) & 1 else node(cur, s) + if cur != bytes.fromhex(g["root_hash"]): + raise Refuse(5, "inclusion_proof_failed") + + +def accept(o, g, envelope): + """§3 steps 1-5.""" + verify_group(g) + if blake3(OBJECT + envelope) != o["content_hash"]: + raise Refuse(4, "content_hash_mismatch") + inclusion(o, g) + + +# ------------------------------------------------------------ the group total (§3) + +def compose(parts): + """The composition rule over (figure, labelled, class) parts. None: no class may be + carried; otherwise (class or None for the unmetered marker, coverage).""" + any_zero = any(f == 0 for f, _, _ in parts) + live = [(f, c) for f, lab, c in parts if f != 0] + if any(c is None for _, c in live): + return None + if not live: + return (None, "Whole") + cov = "Partial" if any_zero else "Whole" + weakest = min(RANK[c["label"]] for _, c in live) + if weakest <= 2: + return ({"label": "ModelBased" if weakest == 2 else "Estimator"}, cov) + if any("ppm" not in c for _, c in live): + return None + num = sum(f * c["ppm"] for f, c in live) + den = sum(f for f, _ in live) + ppm = -(-num // den) + win = max(c["window"] for _, c in live) + if weakest == 4: + fs = {c["filtering"] for _, c in live if c["label"] == "OnChipCounter"} + return ({"label": "OnChipCounter", "ppm": ppm, "window": win, "filtering": fs.pop() if len(fs) == 1 else "Unknown"}, cov) + refs = {c["ref"] for _, c in live} + if len(refs) != 1: + return None + return ({"label": "CalibratedInstrument", "ppm": ppm, "window": win, "ref": refs.pop()}, cov) + + +def same_but_uncertainty(a, b): + if a["label"] != b["label"]: + return False + if a["label"] == "OnChipCounter": + return a["filtering"] == b["filtering"] + if a["label"] == "CalibratedInstrument": + return a["ref"] == b["ref"] + return True + + +def signed_label(c, cov, checked): + u = f" relative_ppm={c['ppm']} window_us={c['window']}" if "ppm" in c else "" + return f"read signed class={c['label']}{u} coverage={cov} parts={'checked' if checked else 'unchecked'}" + + +def total(inp): + g = read_group(inp["group"]) + verify_group(g) + held = {} + for d in inp["delivered"]: + try: + o = read_object(d["object"]) + accept(o, g, bytes.fromhex(d["envelope"])) + except Refuse: + continue + held.setdefault(o["index"], []).append(o) + complete = g["leaf_count"] > 0 and len(held) == g["leaf_count"] and all(len(v) == 1 for v in held.values()) + + def own(checked): + if not g["labelled"]: + return "read unlabelled" + if g["cls"] is None: + return "read unmetered" + return signed_label(g["cls"], g["coverage"], checked) + if not complete: + return own(False) + parts = [held[i][0] for i in sorted(held)] + if sum(o["joules_micro"] for o in parts) != g["joules_total"]: + raise Refuse(None, "group_total_mismatch") + comp = compose([(o["joules_micro"], o["labelled"], o["class"]) for o in parts]) + if not g["labelled"]: + if all(o["joules_micro"] == 0 for o in parts): + return "read unmetered" + if comp is None or comp[0] is None: + return "read unlabelled" + return f"read weakest class={comp[0]['label']} coverage={comp[1]}" + if comp is None: + raise Refuse(None, "group_class_disagrees") + got, want = g["cls"], comp[0] + if (got is None) != (want is None) or (got is not None and not same_but_uncertainty(got, want)): + raise Refuse(None, "group_class_disagrees") + if g["coverage"] != comp[1]: + raise Refuse(None, "group_coverage_disagrees") + if got is not None and "ppm" in got and (got["ppm"] < want["ppm"] or got["window"] != want["window"]): + raise Refuse(None, "group_uncertainty_below_parts") + return own(True) + + +# ------------------------------------------------------------ the WAI_RECEIPT property + +def property_class(p): + """moq-streaming-format §5's rules on the property alone (`property_invalid`); the + class it names, or None for a zero figure.""" + bad = Refuse(None, "property_invalid") + acq, ev = p["acquisition"], bytes.fromhex(p["acquisition_evidence"]) + if p["joules_micro"] == 0: + if acq != "" or ev: + raise bad + return None + if acq in ("HwShunt", "ModelBased", "Estimator"): + if ev: + raise bad + return {"label": acq} + if acq == "OnChipCounter": + if len(ev) != 13 or ev[12] > 2: + raise bad + c = {"label": acq, "ppm": int.from_bytes(ev[0:4], "big"), "window": int.from_bytes(ev[4:12], "big"), + "filtering": {0: "Unknown", 1: "Off", 2: "On"}[ev[12]]} + elif acq == "CalibratedInstrument": + if len(ev) < 16 or int.from_bytes(ev[12:16], "big") != len(ev) - 16: + raise bad + try: + ref = ev[16:].decode() + except UnicodeDecodeError: + raise bad + if ref.strip(CV.WHITE_SPACE) == "": + raise bad + c = {"label": acq, "ppm": int.from_bytes(ev[0:4], "big"), "window": int.from_bytes(ev[4:12], "big"), "ref": ref} + else: + raise bad + if c["ppm"] == 0 or c["window"] == 0 or p["joules_micro"] * c["ppm"] < FLOOR: + raise bad + return c + + +def claim_class(b): + """An energy claim's class in this file's form.""" + label = b["energy_provenance"] + if label in ("OnChipCounter", "CalibratedInstrument"): + u = b["energy_uncertainty"] + c = {"label": label, "ppm": u["relative_ppm"], "window": u["window_us"]} + if label == "OnChipCounter": + c["filtering"] = b["energy_counter_filtering"] + else: + c["ref"] = b["energy_calibration_ref"] + return c + return {"label": label} + + +def prop(inp): + p = inp["property"] + c = property_class(p) + if "claim" in inp: + try: + claim = CV.verify_claim(inp["claim"]) + except CV.Reject: + return "read unsigned" if c is not None else "read unmetered" + if claim["class"] != "energy" or claim["subject"] != p["content_hash"]: + return "read unsigned" if c is not None else "read unmetered" + b = claim["body"] + if c is not None and claim_class(b) == c and b["joules_micro"] == p["joules_micro"]: + # Signed by the claim's signer, whoever that is: never read as the publisher's. + return f"read signed class={c['label']} by={claim['signer_pubkey']}" + raise Refuse(None, "property_disagrees") + g = read_group(inp["group"]) + o = read_object(inp["object"]) + if bytes.fromhex(p["content_hash"]) != o["content_hash"] or bytes.fromhex(p["group_receipt"]) != link(g): + raise Refuse(None, "property_disagrees") + accept(o, g, bytes.fromhex(inp["envelope"])) + if not o["labelled"]: + return "read unmetered" if c is None else "read unsigned" + if o["class"] is None and c is None: + return "read unmetered" + if o["class"] is not None and c is not None and o["class"] == c and o["joules_micro"] == p["joules_micro"]: + return f"read signed class={c['label']} by={g['signer_pubkey']}" + raise Refuse(None, "property_disagrees") + + +# ------------------------------------------------------------ vectors + +def verdict(v): + inp = v["input"] + try: + if v["procedure"] == "leaf": + return "leaf=" + leaf(read_object(inp["object"])).hex() + if v["procedure"] == "group": + g = read_group(inp["group"]) + verify_group(g) + prev = None + if "previous" in inp: + try: + prev = read_group(inp["previous"]) + except Refuse: + prev = None + check_parent(g, prev) + return f"accept link_hash={link(g).hex()} signing_payload={payload(g).hex()}" + if v["procedure"] == "object": + g = read_group(inp["group"]) + o = read_object(inp["object"]) + accept(o, g, bytes.fromhex(inp["envelope"])) + return "accept" + if v["procedure"] == "total": + return total(inp) + if v["procedure"] == "property": + return prop(inp) + except Refuse as r: + return r.label() + raise ValueError("unknown procedure " + str(v["procedure"])) + + +def expected_of(e): + if "leaf" in e: + return "leaf=" + e["leaf"] + if e["outcome"] == "accept": + return f"accept link_hash={e['link_hash']} signing_payload={e['signing_payload']}" if "link_hash" in e else "accept" + if e["outcome"] == "read": + return "read " + e["reading"] + return f"reject step={e['step']} reason={e['reason']}" if "step" in e else f"reject reason={e['reason']}" + + +def main(argv): + if argv[1:2] == ["verdicts"] and len(argv) == 3: + doc = json.load(open(argv[2], encoding="utf-8")) + for v in doc["vectors"]: + try: + got = verdict(v) + except (ValueError, KeyError, TypeError, AttributeError): + got = "error" + print(f"{v['name']}\t{got}") + return 0 + path = argv[1] if len(argv) > 1 else os.path.join(HERE, "vectors.json") + doc = json.load(open(path, encoding="utf-8")) + fail = 0 + b3 = blake3(b"abc").hex() == "6437b3ac38465133ffb63b75273a8db548c558465d79db03fd359c6cd5bd9d85" + print(("PASS" if b3 else "FAIL") + " BLAKE3 reproduces its published value") + fail += not b3 + for v in doc["vectors"]: + want, got = expected_of(v["expected"]), verdict(v) + ok = want == got + fail += not ok + print(f"{'PASS' if ok else 'FAIL'} {v['name']} expected={want} got={got}") + print(f"{len(doc['vectors'])} vectors, {fail} failure(s)") + return 1 if fail else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/wai/jwp-conformance/vectors.json b/wai/jwp-conformance/vectors.json new file mode 100644 index 000000000..ce30a847f --- /dev/null +++ b/wai/jwp-conformance/vectors.json @@ -0,0 +1,4554 @@ +{ + "about": "jwp-receipts sections 2.2-2.4.2, 3 and 3.3, and moq-streaming-format section 5: labelled leaves, labelled group receipts, the group total and the WAI_RECEIPT property. Every vector is synthetic: keys are Ed25519 from fixed 32-byte seeds (0x11 publisher, 0x22 meter, 0x33 relay) and attest nothing; envelopes, figures, origins and calibration references are invented. Expected outcomes are declared per vector in the generator, not copied from a verifier. Regenerate with `wai_jwp_vectors gen`.", + "vectors": [ + { + "name": "leaf_legacy", + "about": "an object with no label: the legacy leaf over the content hash alone", + "procedure": "leaf", + "input": { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 2, + "content_hash": "3cb85c360e2af8f1a586389d13f0415ca90ea8ab6fa5fcdf1b3204a2dd1a44c4", + "joules_micro": 500, + "origin": "dc-2.example", + "merkle_proof": { + "siblings": [ + "339ea521d795afe4a4ec26042a2827fc9e8052b581fdbb1636b2315ee9953e93", + "81b35ae562bccadd9651fe74f2669b85dd2cfe5c60fc050119ceb3277483af12", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 2, + "leaf_index": 2 + } + } + }, + "expected": { + "leaf": "65dbe4a732e01bd69de3104e03d9822107318551714d055c8a7acc45313796e8" + } + }, + { + "name": "leaf_labelled_unmetered", + "about": "tag 0 beside a zero figure: the object is explicitly unmetered and its origin is bound", + "procedure": "leaf", + "input": { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 3, + "content_hash": "6e90ee47565591796c76e57de2b092695ba42d4e5dae585eb1503589e2b703d7", + "joules_micro": 0, + "origin": "dc-3.example", + "merkle_proof": { + "siblings": [ + "65dbe4a732e01bd69de3104e03d9822107318551714d055c8a7acc45313796e8", + "81b35ae562bccadd9651fe74f2669b85dd2cfe5c60fc050119ceb3277483af12", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 3, + "leaf_index": 3 + }, + "leaf_version": 2 + } + }, + "expected": { + "leaf": "339ea521d795afe4a4ec26042a2827fc9e8052b581fdbb1636b2315ee9953e93" + } + }, + { + "name": "leaf_labelled_on_chip_counter", + "about": "tag 4 with its declared uncertainty and filtering state", + "procedure": "leaf", + "input": { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 0, + "content_hash": "16d34babd9a027ad12b61e4d0aef877f560d87e8b4884a3a2b2d59cb54a4850d", + "joules_micro": 1200, + "origin": "dc-0.example", + "merkle_proof": { + "siblings": [ + "281de97b5d3b5f872298b30812bf65f6a14d2cb223c135d17034e19e20988c9f", + "59057bc02626a58b09500bf398bc79d53501ce15bb752e14be51fc3f56de8480", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + } + }, + "expected": { + "leaf": "eeb5d0cf357a3bca9437eff04cf82570a6338652c842d9a20a7670f409a80760" + } + }, + { + "name": "leaf_labelled_calibrated_instrument", + "about": "tag 5 with its declared uncertainty and length-prefixed calibration reference", + "procedure": "leaf", + "input": { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 1, + "content_hash": "a900d4803cc1c6cf8384e4238c3745bd6a1c3838a917b4c0a99fb77bd9ba7c1e", + "joules_micro": 800, + "origin": "dc-1.example", + "merkle_proof": { + "siblings": [ + "eeb5d0cf357a3bca9437eff04cf82570a6338652c842d9a20a7670f409a80760", + "59057bc02626a58b09500bf398bc79d53501ce15bb752e14be51fc3f56de8480", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 1, + "leaf_index": 1 + }, + "leaf_version": 2, + "energy_provenance": "CalibratedInstrument", + "energy_uncertainty": { + "relative_ppm": 5000, + "window_us": 2000000 + }, + "energy_calibration_ref": "https://calibration.example.org/certificates/0001" + } + }, + "expected": { + "leaf": "281de97b5d3b5f872298b30812bf65f6a14d2cb223c135d17034e19e20988c9f" + } + }, + { + "name": "leaf_labelled_model_based", + "about": "tag 2, followed by nothing", + "procedure": "leaf", + "input": { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 4, + "content_hash": "d328f60c60ffa2d36830838218ee22a5e31cc674b816e63526ebd74c1fe66cdc", + "joules_micro": 300, + "origin": "dc-4.example", + "merkle_proof": { + "siblings": [ + "3049a2823be995b9ef123c82675c47e2df96a77b50be6a71d676246325d46a76", + "c2638ebd069f911759c78d18685c71ef0791acbde44f604fe298e40448cda8c2", + "5880f5698f5a1b6b230f65c481aa126e56f86e3a26e1a29d2edb79eae011a818" + ], + "direction_bits": 4, + "leaf_index": 4 + }, + "leaf_version": 2, + "energy_provenance": "ModelBased" + } + }, + "expected": { + "leaf": "3049a2823be995b9ef123c82675c47e2df96a77b50be6a71d676246325d46a76" + } + }, + { + "name": "group_legacy_accept", + "about": "the mixed group in the legacy payload: it signs the total and no class", + "procedure": "group", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + } + }, + "expected": { + "outcome": "accept", + "link_hash": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5", + "signing_payload": "6d6f712d6a77703a67726f75702d7369676e010000000000000007000000000000000362ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d00000000000000050000000000000af000000014323032362d30392d33305431323a30303a30305a00000014323032362d30392d33305431323a30303a30315ad04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737000000156469643a6578616d706c653a7075626c6973686572" + } + }, + { + "name": "group_labelled_accept", + "about": "the labelled payload: the total's class (OnChipCounter, the weaker of a counter and an instrument, 14,000 ppm over 2 s), coverage Partial (one part is zero) and the parent link, chained to the mixed group", + "procedure": "group", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 4, + "root_hash": "5a8870c49a7ce232b1a4cd4bced3b0ee70b33535bcb8f9f563f617279ae9ce29", + "leaf_count": 3, + "joules_total": 2000, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "cd89ea4722e276b4458604faaf7d92a2fb1286c69a62dde021dfaaed9d6fc2975150865e71dffa91526ca52e2844a74197dc87102a5f489dcf32dc3394fd6509", + "parent_receipt_hash": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5", + "sign_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 14000, + "window_us": 2000000 + }, + "energy_counter_filtering": "Unknown", + "energy_coverage": "Partial" + }, + "previous": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + } + }, + "expected": { + "outcome": "accept", + "link_hash": "9c49baff6e40a5d24222e49679dad204f036e356b402449a4047e9bafcdaa7b7", + "signing_payload": "6d6f712d6a77703a67726f75702d7369676e02000000000000000700000000000000045a8870c49a7ce232b1a4cd4bced3b0ee70b33535bcb8f9f563f617279ae9ce29000000000000000300000000000007d000000014323032362d30392d33305431323a30303a30305a00000014323032362d30392d33305431323a30303a30315ad04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737000000156469643a6578616d706c653a7075626c697368657204000036b000000000001e84800001012e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5" + } + }, + { + "name": "group_bad_signature", + "about": "one bit flipped in sig", + "procedure": "group", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "82a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + } + }, + "expected": { + "outcome": "reject", + "step": 2, + "reason": "signature_verify_failed" + } + }, + { + "name": "group_signature_s_not_reduced", + "about": "S replaced by S + L: the verification equation still holds, and the strict rule of jwp-receipts 3.1 step 3 refuses S >= L", + "procedure": "group", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877b86d5e3e148e78a89a9431b2d2ece60f39f3fe91cb6f3c25621991914f7e341a", + "parent_receipt_hash": null + } + }, + "expected": { + "outcome": "reject", + "step": 2, + "reason": "signature_verify_failed" + } + }, + { + "name": "group_signer_key_identity", + "about": "the identity point as signer_pubkey", + "procedure": "group", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "0100000000000000000000000000000000000000000000000000000000000000", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + } + }, + "expected": { + "outcome": "reject", + "step": 1, + "reason": "bad_signer_key" + } + }, + { + "name": "group_missing_parent_field", + "about": "parent_receipt_hash absent rather than null", + "procedure": "group", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a" + } + }, + "expected": { + "outcome": "reject", + "step": 1, + "reason": "malformed" + } + }, + { + "name": "group_signer_key_non_canonical", + "about": "signer_pubkey encodes y = p + 3, a point not of small order, in a form RFC 8032 section 5.1.3 refuses (y >= p): every point has one encoding", + "procedure": "group", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "f0ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + } + }, + "expected": { + "outcome": "reject", + "step": 1, + "reason": "bad_signer_key" + } + }, + { + "name": "group_sign_version_unknown", + "about": "a labelled group whose sign_version is 3: a version this extension does not define is not the labelled form", + "procedure": "group", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 4, + "root_hash": "5a8870c49a7ce232b1a4cd4bced3b0ee70b33535bcb8f9f563f617279ae9ce29", + "leaf_count": 3, + "joules_total": 2000, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "cd89ea4722e276b4458604faaf7d92a2fb1286c69a62dde021dfaaed9d6fc2975150865e71dffa91526ca52e2844a74197dc87102a5f489dcf32dc3394fd6509", + "parent_receipt_hash": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5", + "sign_version": 3, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 14000, + "window_us": 2000000 + }, + "energy_counter_filtering": "Unknown", + "energy_coverage": "Partial" + } + }, + "expected": { + "outcome": "reject", + "step": 1, + "reason": "group_label_invalid" + } + }, + { + "name": "group_sign_version_legacy", + "about": "a legacy group carrying sign_version 1 and no class: a sign_version other than 2 is refused, never read as the legacy form", + "procedure": "group", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null, + "sign_version": 1 + } + }, + "expected": { + "outcome": "reject", + "step": 1, + "reason": "group_label_invalid" + } + }, + { + "name": "group_sign_version_string", + "about": "sign_version written as the string \"2\"", + "procedure": "group", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 4, + "root_hash": "5a8870c49a7ce232b1a4cd4bced3b0ee70b33535bcb8f9f563f617279ae9ce29", + "leaf_count": 3, + "joules_total": 2000, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "cd89ea4722e276b4458604faaf7d92a2fb1286c69a62dde021dfaaed9d6fc2975150865e71dffa91526ca52e2844a74197dc87102a5f489dcf32dc3394fd6509", + "parent_receipt_hash": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5", + "sign_version": "2", + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 14000, + "window_us": 2000000 + }, + "energy_counter_filtering": "Unknown", + "energy_coverage": "Partial" + } + }, + "expected": { + "outcome": "reject", + "step": 1, + "reason": "group_label_invalid" + } + }, + { + "name": "group_legacy_with_class_members", + "about": "a legacy group carrying energy_provenance: refused, never read as the legacy form", + "procedure": "group", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null, + "energy_provenance": "ModelBased" + } + }, + "expected": { + "outcome": "reject", + "step": 1, + "reason": "group_label_invalid" + } + }, + { + "name": "group_labelled_class_unknown", + "about": "a labelled group naming a class energy-measurement section 2 does not define", + "procedure": "group", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 4, + "root_hash": "5a8870c49a7ce232b1a4cd4bced3b0ee70b33535bcb8f9f563f617279ae9ce29", + "leaf_count": 3, + "joules_total": 2000, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "cd89ea4722e276b4458604faaf7d92a2fb1286c69a62dde021dfaaed9d6fc2975150865e71dffa91526ca52e2844a74197dc87102a5f489dcf32dc3394fd6509", + "parent_receipt_hash": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5", + "sign_version": 2, + "energy_provenance": "Measured", + "energy_uncertainty": { + "relative_ppm": 14000, + "window_us": 2000000 + }, + "energy_counter_filtering": "Unknown", + "energy_coverage": "Partial" + } + }, + "expected": { + "outcome": "reject", + "step": 1, + "reason": "group_label_invalid" + } + }, + { + "name": "group_labelled_class_tampered", + "about": "the total's declared uncertainty rewritten smaller after signing", + "procedure": "group", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 4, + "root_hash": "5a8870c49a7ce232b1a4cd4bced3b0ee70b33535bcb8f9f563f617279ae9ce29", + "leaf_count": 3, + "joules_total": 2000, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "cd89ea4722e276b4458604faaf7d92a2fb1286c69a62dde021dfaaed9d6fc2975150865e71dffa91526ca52e2844a74197dc87102a5f489dcf32dc3394fd6509", + "parent_receipt_hash": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5", + "sign_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 1000, + "window_us": 2000000 + }, + "energy_counter_filtering": "Unknown", + "energy_coverage": "Partial" + } + }, + "expected": { + "outcome": "reject", + "step": 2, + "reason": "signature_verify_failed" + } + }, + { + "name": "group_labelled_coverage_tampered", + "about": "coverage rewritten from Partial to Whole after signing", + "procedure": "group", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 4, + "root_hash": "5a8870c49a7ce232b1a4cd4bced3b0ee70b33535bcb8f9f563f617279ae9ce29", + "leaf_count": 3, + "joules_total": 2000, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "cd89ea4722e276b4458604faaf7d92a2fb1286c69a62dde021dfaaed9d6fc2975150865e71dffa91526ca52e2844a74197dc87102a5f489dcf32dc3394fd6509", + "parent_receipt_hash": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5", + "sign_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 14000, + "window_us": 2000000 + }, + "energy_counter_filtering": "Unknown", + "energy_coverage": "Whole" + } + }, + "expected": { + "outcome": "reject", + "step": 2, + "reason": "signature_verify_failed" + } + }, + { + "name": "group_labelled_parent_tampered", + "about": "the parent link rewritten after signing: the labelled payload signs it", + "procedure": "group", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 4, + "root_hash": "5a8870c49a7ce232b1a4cd4bced3b0ee70b33535bcb8f9f563f617279ae9ce29", + "leaf_count": 3, + "joules_total": 2000, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "cd89ea4722e276b4458604faaf7d92a2fb1286c69a62dde021dfaaed9d6fc2975150865e71dffa91526ca52e2844a74197dc87102a5f489dcf32dc3394fd6509", + "parent_receipt_hash": "abababababababababababababababababababababababababababababababab", + "sign_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 14000, + "window_us": 2000000 + }, + "energy_counter_filtering": "Unknown", + "energy_coverage": "Partial" + } + }, + "expected": { + "outcome": "reject", + "step": 2, + "reason": "signature_verify_failed" + } + }, + { + "name": "group_labelled_hwshunt", + "about": "a labelled group whose total is classed legacy HwShunt, correctly signed", + "procedure": "group", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 4, + "root_hash": "5a8870c49a7ce232b1a4cd4bced3b0ee70b33535bcb8f9f563f617279ae9ce29", + "leaf_count": 3, + "joules_total": 2000, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "d0105eeb8ff38f3a6e7cea7df72f56b2dd0759a8eb81af16137a3ff376816c77ccb350a530351c2d078b457068fdc00ccdf841a3b4ba8d4ba3fafd10ff25db06", + "parent_receipt_hash": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5", + "sign_version": 2, + "energy_provenance": "HwShunt", + "energy_coverage": "Partial" + } + }, + "expected": { + "outcome": "reject", + "step": 2, + "reason": "group_label_invalid" + } + }, + { + "name": "group_labelled_class_beside_zero_total", + "about": "a class beside a zero total, correctly signed", + "procedure": "group", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 4, + "root_hash": "5a8870c49a7ce232b1a4cd4bced3b0ee70b33535bcb8f9f563f617279ae9ce29", + "leaf_count": 3, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "330fc4b15dcc7e2f4e5e78dd8f2c078326368338de10635e90691febcd1f6255e3a99ce75673791dd21710c6ec40571eeeedbc67283e51c2649c8c4b11b8150b", + "parent_receipt_hash": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5", + "sign_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 14000, + "window_us": 2000000 + }, + "energy_counter_filtering": "Unknown", + "energy_coverage": "Partial" + } + }, + "expected": { + "outcome": "reject", + "step": 2, + "reason": "group_label_invalid" + } + }, + { + "name": "group_labelled_unmetered_beside_figure", + "about": "the unmetered marker beside a non-zero total, correctly signed", + "procedure": "group", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 4, + "root_hash": "5a8870c49a7ce232b1a4cd4bced3b0ee70b33535bcb8f9f563f617279ae9ce29", + "leaf_count": 3, + "joules_total": 2000, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "1701d1d0e0de50a5547d64109f2345982a4ac1245d96ba1847450c863e1124e90965307897fb8a67afed74147d265e903152172ef02f885d7e898393da6ab50e", + "parent_receipt_hash": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5", + "sign_version": 2 + } + }, + "expected": { + "outcome": "reject", + "step": 2, + "reason": "group_label_invalid" + } + }, + { + "name": "group_labelled_below_resolution", + "about": "a 2,000 uJ total declared at 144 ppm: below the resolution floor, correctly signed", + "procedure": "group", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 4, + "root_hash": "5a8870c49a7ce232b1a4cd4bced3b0ee70b33535bcb8f9f563f617279ae9ce29", + "leaf_count": 3, + "joules_total": 2000, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "25118a4d5c09142f8dba83eaff7274150066cb2c47a23b9deb530e7ebd17d6669099f62f3cc45fd92a5e1259e652a81efda1366d665628b5df6f45d94b184405", + "parent_receipt_hash": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5", + "sign_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 144, + "window_us": 2000000 + }, + "energy_counter_filtering": "Unknown", + "energy_coverage": "Partial" + } + }, + "expected": { + "outcome": "reject", + "step": 2, + "reason": "group_label_invalid" + } + }, + { + "name": "group_chain_break", + "about": "the parent named is not the previous group held", + "procedure": "group", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 4, + "root_hash": "5a8870c49a7ce232b1a4cd4bced3b0ee70b33535bcb8f9f563f617279ae9ce29", + "leaf_count": 3, + "joules_total": 2000, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "cd89ea4722e276b4458604faaf7d92a2fb1286c69a62dde021dfaaed9d6fc2975150865e71dffa91526ca52e2844a74197dc87102a5f489dcf32dc3394fd6509", + "parent_receipt_hash": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5", + "sign_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 14000, + "window_us": 2000000 + }, + "energy_counter_filtering": "Unknown", + "energy_coverage": "Partial" + }, + "previous": { + "kind": "group", + "track_alias": 7, + "group_id": 2, + "root_hash": "280ae8f73e196ab28c8252779d47b29ce73ddac14f59c579a34ba75fadedefdb", + "leaf_count": 1, + "joules_total": 1, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "a8ae8b8bec0013ee9ae6ddf30fe017173ab8fef18b40e011b73ae2669db8e21571de3748997abbb840240d415070062747fbba328c81c3560bcda749a3e79004", + "parent_receipt_hash": null + } + }, + "expected": { + "outcome": "reject", + "step": 3, + "reason": "chain_break" + } + }, + { + "name": "group_parent_not_held", + "about": "a parent the verifier does not hold is fetched, never accepted silently", + "procedure": "group", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 4, + "root_hash": "5a8870c49a7ce232b1a4cd4bced3b0ee70b33535bcb8f9f563f617279ae9ce29", + "leaf_count": 3, + "joules_total": 2000, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "cd89ea4722e276b4458604faaf7d92a2fb1286c69a62dde021dfaaed9d6fc2975150865e71dffa91526ca52e2844a74197dc87102a5f489dcf32dc3394fd6509", + "parent_receipt_hash": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5", + "sign_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 14000, + "window_us": 2000000 + }, + "energy_counter_filtering": "Unknown", + "energy_coverage": "Partial" + } + }, + "expected": { + "outcome": "reject", + "step": 3, + "reason": "parent_not_held" + } + }, + { + "name": "object_labelled_counter_accept", + "about": "a labelled leaf: the figure, the counter's class and evidence, and the origin are under the group signature", + "procedure": "object", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 0, + "content_hash": "16d34babd9a027ad12b61e4d0aef877f560d87e8b4884a3a2b2d59cb54a4850d", + "joules_micro": 1200, + "origin": "dc-0.example", + "merkle_proof": { + "siblings": [ + "281de97b5d3b5f872298b30812bf65f6a14d2cb223c135d17034e19e20988c9f", + "59057bc02626a58b09500bf398bc79d53501ce15bb752e14be51fc3f56de8480", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742030" + }, + "expected": { + "outcome": "accept" + } + }, + { + "name": "object_labelled_instrument_accept", + "about": "a labelled leaf carrying a calibrated instrument", + "procedure": "object", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 1, + "content_hash": "a900d4803cc1c6cf8384e4238c3745bd6a1c3838a917b4c0a99fb77bd9ba7c1e", + "joules_micro": 800, + "origin": "dc-1.example", + "merkle_proof": { + "siblings": [ + "eeb5d0cf357a3bca9437eff04cf82570a6338652c842d9a20a7670f409a80760", + "59057bc02626a58b09500bf398bc79d53501ce15bb752e14be51fc3f56de8480", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 1, + "leaf_index": 1 + }, + "leaf_version": 2, + "energy_provenance": "CalibratedInstrument", + "energy_uncertainty": { + "relative_ppm": 5000, + "window_us": 2000000 + }, + "energy_calibration_ref": "https://calibration.example.org/certificates/0001" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742031" + }, + "expected": { + "outcome": "accept" + } + }, + { + "name": "object_legacy_accept", + "about": "a legacy leaf beside labelled ones in one group", + "procedure": "object", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 2, + "content_hash": "3cb85c360e2af8f1a586389d13f0415ca90ea8ab6fa5fcdf1b3204a2dd1a44c4", + "joules_micro": 500, + "origin": "dc-2.example", + "merkle_proof": { + "siblings": [ + "339ea521d795afe4a4ec26042a2827fc9e8052b581fdbb1636b2315ee9953e93", + "81b35ae562bccadd9651fe74f2669b85dd2cfe5c60fc050119ceb3277483af12", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 2, + "leaf_index": 2 + } + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742032" + }, + "expected": { + "outcome": "accept" + } + }, + { + "name": "object_labelled_unmetered_accept", + "about": "tag 0 beside a zero figure", + "procedure": "object", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 3, + "content_hash": "6e90ee47565591796c76e57de2b092695ba42d4e5dae585eb1503589e2b703d7", + "joules_micro": 0, + "origin": "dc-3.example", + "merkle_proof": { + "siblings": [ + "65dbe4a732e01bd69de3104e03d9822107318551714d055c8a7acc45313796e8", + "81b35ae562bccadd9651fe74f2669b85dd2cfe5c60fc050119ceb3277483af12", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 3, + "leaf_index": 3 + }, + "leaf_version": 2 + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742033" + }, + "expected": { + "outcome": "accept" + } + }, + { + "name": "object_legacy_figure_unsigned_accept", + "about": "a legacy leaf binds neither the figure nor the origin: both rewritten, the object still verifies", + "procedure": "object", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 2, + "content_hash": "3cb85c360e2af8f1a586389d13f0415ca90ea8ab6fa5fcdf1b3204a2dd1a44c4", + "joules_micro": 9999, + "origin": "elsewhere.example", + "merkle_proof": { + "siblings": [ + "339ea521d795afe4a4ec26042a2827fc9e8052b581fdbb1636b2315ee9953e93", + "81b35ae562bccadd9651fe74f2669b85dd2cfe5c60fc050119ceb3277483af12", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 2, + "leaf_index": 2 + } + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742032" + }, + "expected": { + "outcome": "accept" + } + }, + { + "name": "object_in_labelled_group_accept", + "about": "an object of the labelled group", + "procedure": "object", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 4, + "root_hash": "5a8870c49a7ce232b1a4cd4bced3b0ee70b33535bcb8f9f563f617279ae9ce29", + "leaf_count": 3, + "joules_total": 2000, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "cd89ea4722e276b4458604faaf7d92a2fb1286c69a62dde021dfaaed9d6fc2975150865e71dffa91526ca52e2844a74197dc87102a5f489dcf32dc3394fd6509", + "parent_receipt_hash": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5", + "sign_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 14000, + "window_us": 2000000 + }, + "energy_counter_filtering": "Unknown", + "energy_coverage": "Partial" + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 4, + "object_id": 1, + "content_hash": "3e9410265f8b97cbc296544a1625a08bc7c414a87d7f5d9c5ba6ca1772f7aaec", + "joules_micro": 800, + "origin": "dc-11.example", + "merkle_proof": { + "siblings": [ + "0ca346e40f6aeefac6f4c22f1668ac477fe3a1b0565123f50ec60e15fb314cbd", + "d507a5e609507a082972720179f44a746dc3b040f20da0ebd8d757b6a46d51d2" + ], + "direction_bits": 1, + "leaf_index": 1 + }, + "leaf_version": 2, + "energy_provenance": "CalibratedInstrument", + "energy_uncertainty": { + "relative_ppm": 5000, + "window_us": 2000000 + }, + "energy_calibration_ref": "https://calibration.example.org/certificates/0001" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203131" + }, + "expected": { + "outcome": "accept" + } + }, + { + "name": "object_envelope_mutated", + "about": "the envelope does not hash to content_hash", + "procedure": "object", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 0, + "content_hash": "16d34babd9a027ad12b61e4d0aef877f560d87e8b4884a3a2b2d59cb54a4850d", + "joules_micro": 1200, + "origin": "dc-0.example", + "merkle_proof": { + "siblings": [ + "281de97b5d3b5f872298b30812bf65f6a14d2cb223c135d17034e19e20988c9f", + "59057bc02626a58b09500bf398bc79d53501ce15bb752e14be51fc3f56de8480", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + }, + "envelope": "57414931206d75746174656420696e207472616e736974" + }, + "expected": { + "outcome": "reject", + "step": 4, + "reason": "content_hash_mismatch" + } + }, + { + "name": "object_label_stripped", + "about": "the class members removed from a labelled object: its leaf is computed in the legacy form and misses the root", + "procedure": "object", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 0, + "content_hash": "16d34babd9a027ad12b61e4d0aef877f560d87e8b4884a3a2b2d59cb54a4850d", + "joules_micro": 1200, + "origin": "dc-0.example", + "merkle_proof": { + "siblings": [ + "281de97b5d3b5f872298b30812bf65f6a14d2cb223c135d17034e19e20988c9f", + "59057bc02626a58b09500bf398bc79d53501ce15bb752e14be51fc3f56de8480", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 0, + "leaf_index": 0 + } + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742030" + }, + "expected": { + "outcome": "reject", + "step": 5, + "reason": "inclusion_proof_failed" + } + }, + { + "name": "object_label_added", + "about": "a class added to a legacy object", + "procedure": "object", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 2, + "content_hash": "3cb85c360e2af8f1a586389d13f0415ca90ea8ab6fa5fcdf1b3204a2dd1a44c4", + "joules_micro": 500, + "origin": "dc-2.example", + "merkle_proof": { + "siblings": [ + "339ea521d795afe4a4ec26042a2827fc9e8052b581fdbb1636b2315ee9953e93", + "81b35ae562bccadd9651fe74f2669b85dd2cfe5c60fc050119ceb3277483af12", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 2, + "leaf_index": 2 + }, + "leaf_version": 2, + "energy_provenance": "ModelBased" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742032" + }, + "expected": { + "outcome": "reject", + "step": 5, + "reason": "inclusion_proof_failed" + } + }, + { + "name": "object_class_restated", + "about": "a labelled object's declared uncertainty rewritten smaller", + "procedure": "object", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 0, + "content_hash": "16d34babd9a027ad12b61e4d0aef877f560d87e8b4884a3a2b2d59cb54a4850d", + "joules_micro": 1200, + "origin": "dc-0.example", + "merkle_proof": { + "siblings": [ + "281de97b5d3b5f872298b30812bf65f6a14d2cb223c135d17034e19e20988c9f", + "59057bc02626a58b09500bf398bc79d53501ce15bb752e14be51fc3f56de8480", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 2000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742030" + }, + "expected": { + "outcome": "reject", + "step": 5, + "reason": "inclusion_proof_failed" + } + }, + { + "name": "object_class_promoted", + "about": "a labelled object's ModelBased class rewritten to Estimator", + "procedure": "object", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 4, + "content_hash": "d328f60c60ffa2d36830838218ee22a5e31cc674b816e63526ebd74c1fe66cdc", + "joules_micro": 300, + "origin": "dc-4.example", + "merkle_proof": { + "siblings": [ + "3049a2823be995b9ef123c82675c47e2df96a77b50be6a71d676246325d46a76", + "c2638ebd069f911759c78d18685c71ef0791acbde44f604fe298e40448cda8c2", + "5880f5698f5a1b6b230f65c481aa126e56f86e3a26e1a29d2edb79eae011a818" + ], + "direction_bits": 4, + "leaf_index": 4 + }, + "leaf_version": 2, + "energy_provenance": "Estimator" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742034" + }, + "expected": { + "outcome": "reject", + "step": 5, + "reason": "inclusion_proof_failed" + } + }, + { + "name": "object_labelled_figure_altered", + "about": "a labelled object's figure rewritten", + "procedure": "object", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 1, + "content_hash": "a900d4803cc1c6cf8384e4238c3745bd6a1c3838a917b4c0a99fb77bd9ba7c1e", + "joules_micro": 801, + "origin": "dc-1.example", + "merkle_proof": { + "siblings": [ + "eeb5d0cf357a3bca9437eff04cf82570a6338652c842d9a20a7670f409a80760", + "59057bc02626a58b09500bf398bc79d53501ce15bb752e14be51fc3f56de8480", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 1, + "leaf_index": 1 + }, + "leaf_version": 2, + "energy_provenance": "CalibratedInstrument", + "energy_uncertainty": { + "relative_ppm": 5000, + "window_us": 2000000 + }, + "energy_calibration_ref": "https://calibration.example.org/certificates/0001" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742031" + }, + "expected": { + "outcome": "reject", + "step": 5, + "reason": "inclusion_proof_failed" + } + }, + { + "name": "object_labelled_origin_altered", + "about": "a labelled object's origin rewritten: the labelled leaf binds it", + "procedure": "object", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 3, + "content_hash": "6e90ee47565591796c76e57de2b092695ba42d4e5dae585eb1503589e2b703d7", + "joules_micro": 0, + "origin": "elsewhere.example", + "merkle_proof": { + "siblings": [ + "65dbe4a732e01bd69de3104e03d9822107318551714d055c8a7acc45313796e8", + "81b35ae562bccadd9651fe74f2669b85dd2cfe5c60fc050119ceb3277483af12", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 3, + "leaf_index": 3 + }, + "leaf_version": 2 + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742033" + }, + "expected": { + "outcome": "reject", + "step": 5, + "reason": "inclusion_proof_failed" + } + }, + { + "name": "object_label_hwshunt", + "about": "a leaf labelled legacy HwShunt in a correctly built group", + "procedure": "object", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 9, + "root_hash": "47833e1e1230c5866245b7be2e34d42ebd71a664e79c2ad09e01604c90f76403", + "leaf_count": 1, + "joules_total": 100, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "d742de392302d506d432832a67737be95b80173f48367cb6dfb13ad610e8ce4422c8fe1e7a149c56fdcbea182892002541973a047c4ad4059fa7624038ac8101", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 9, + "object_id": 0, + "content_hash": "3682aa6fa34ed352628956084b932046781813b030dcc7cf4f65b576d8a25479", + "joules_micro": 100, + "origin": "dc-40.example", + "merkle_proof": { + "siblings": [], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "HwShunt" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203430" + }, + "expected": { + "outcome": "reject", + "step": 5, + "reason": "object_label_invalid" + } + }, + { + "name": "object_label_beside_zero", + "about": "a class beside a zero figure in a correctly built group", + "procedure": "object", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 9, + "root_hash": "8db6b1cc8ca1811d1744f001c31d42898b7008f7ebe9cbad4b2520664c37eba1", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "b18215496a534416305a39905b0d69b59644384ec457d23824dfbe89fc107ab6a400b12270189545e2622cc416cd573efcd81b1a34834a4fc7b020892b46d900", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 9, + "object_id": 0, + "content_hash": "f151bb3b9bccac787e44237d38993413a3f752010f4e1ab68d054e600a31f60a", + "joules_micro": 0, + "origin": "dc-41.example", + "merkle_proof": { + "siblings": [], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "ModelBased" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203431" + }, + "expected": { + "outcome": "reject", + "step": 5, + "reason": "object_label_invalid" + } + }, + { + "name": "object_label_unmetered_beside_figure", + "about": "tag 0 beside a non-zero figure in a correctly built group", + "procedure": "object", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 9, + "root_hash": "c4d92b7fa856e48a1f003414e188c2d93ee15bce57dcb0ef6ee261d7b5bf3343", + "leaf_count": 1, + "joules_total": 100, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "174b8238b1981527b4b5911fc4baeff31b7576d9e72ff7875a1ad9e1b45bcf153c09bb0aceddaf5b8acde42ac92589b36fbe6891b6d9eb382d5006f1bb417304", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 9, + "object_id": 0, + "content_hash": "faf10f31c03e74ea0522bb4abd60ff0c9d1f2276a17a5edcbaaa0765536855dd", + "joules_micro": 100, + "origin": "dc-42.example", + "merkle_proof": { + "siblings": [], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2 + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203432" + }, + "expected": { + "outcome": "reject", + "step": 5, + "reason": "object_label_invalid" + } + }, + { + "name": "object_label_below_resolution", + "about": "a 1 uJ figure declared at 288,675 ppm in a correctly built group", + "procedure": "object", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 9, + "root_hash": "de3bfe5c5df20f3a1f3f4cfca7b4fcb27577a8392bc89fcd8a1c6018bc28f699", + "leaf_count": 1, + "joules_total": 1, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "81a9198030c8fa8a757d8e16c9a0f7a26139a9d8b6e2905bfd1a8d730276e554d54018ca1ef995ecf5ad6c623196306ee142bdef84498d791d8716b72f79650b", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 9, + "object_id": 0, + "content_hash": "7551647b60b1df54a15e33b2febd2a26159320728df948ae106460f703260e19", + "joules_micro": 1, + "origin": "dc-43.example", + "merkle_proof": { + "siblings": [], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 288675, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203433" + }, + "expected": { + "outcome": "reject", + "step": 5, + "reason": "object_label_invalid" + } + }, + { + "name": "object_label_evidence_without_class", + "about": "a labelled object's class removed and its evidence kept: refused, never read as legacy", + "procedure": "object", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 0, + "content_hash": "16d34babd9a027ad12b61e4d0aef877f560d87e8b4884a3a2b2d59cb54a4850d", + "joules_micro": 1200, + "origin": "dc-0.example", + "merkle_proof": { + "siblings": [ + "281de97b5d3b5f872298b30812bf65f6a14d2cb223c135d17034e19e20988c9f", + "59057bc02626a58b09500bf398bc79d53501ce15bb752e14be51fc3f56de8480", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_counter_filtering": "Unknown", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + } + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742030" + }, + "expected": { + "outcome": "reject", + "step": 5, + "reason": "object_label_invalid" + } + }, + { + "name": "object_label_members_without_leaf_version", + "about": "class members on an object without leaf_version 2", + "procedure": "object", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 4, + "content_hash": "d328f60c60ffa2d36830838218ee22a5e31cc674b816e63526ebd74c1fe66cdc", + "joules_micro": 300, + "origin": "dc-4.example", + "merkle_proof": { + "siblings": [ + "3049a2823be995b9ef123c82675c47e2df96a77b50be6a71d676246325d46a76", + "c2638ebd069f911759c78d18685c71ef0791acbde44f604fe298e40448cda8c2", + "5880f5698f5a1b6b230f65c481aa126e56f86e3a26e1a29d2edb79eae011a818" + ], + "direction_bits": 4, + "leaf_index": 4 + }, + "energy_provenance": "ModelBased" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742034" + }, + "expected": { + "outcome": "reject", + "step": 5, + "reason": "object_label_invalid" + } + }, + { + "name": "object_at_another_index", + "about": "an object presented at its neighbour's index with its own proof, which still walks to the root", + "procedure": "object", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 1, + "content_hash": "16d34babd9a027ad12b61e4d0aef877f560d87e8b4884a3a2b2d59cb54a4850d", + "joules_micro": 1200, + "origin": "dc-0.example", + "merkle_proof": { + "siblings": [ + "281de97b5d3b5f872298b30812bf65f6a14d2cb223c135d17034e19e20988c9f", + "59057bc02626a58b09500bf398bc79d53501ce15bb752e14be51fc3f56de8480", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 0, + "leaf_index": 1 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742030" + }, + "expected": { + "outcome": "reject", + "step": 5, + "reason": "proof_position_mismatch" + } + }, + { + "name": "object_past_the_end", + "about": "the last of three objects at index 3: the duplicated leaf's proof walks that position too", + "procedure": "object", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 8, + "root_hash": "6f4275af51849b75868f80fdb5332a387f4b22a05e91b3ae1509a8fb99078d8b", + "leaf_count": 3, + "joules_total": 60, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "c931bde5e7895a44d5d993bdaa3551322b11dc03bddce30f83b137811af5a3767efc7c2df8c44c4ad5ddf54bd71a088a7880b4f0cf0a633df50bb3d3430c930e", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 8, + "object_id": 3, + "content_hash": "7ef012d887d192c988a1b85894ed54ebeae9885a250be7e93a9b7584189f5c77", + "joules_micro": 30, + "origin": "dc-32.example", + "merkle_proof": { + "siblings": [ + "412226eb14d389ee7cfd88e8a0d57ad3acb85c918d46d0be03c627a69ae4ba92", + "b99f24d41ef3c99a9c76eac34087f8ebf2f49084f94d58e7054a8aa6775b84ef" + ], + "direction_bits": 3, + "leaf_index": 3 + } + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203332" + }, + "expected": { + "outcome": "reject", + "step": 5, + "reason": "proof_position_mismatch" + } + }, + { + "name": "object_id_not_its_index", + "about": "object_id other than leaf_index", + "procedure": "object", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 0, + "content_hash": "3cb85c360e2af8f1a586389d13f0415ca90ea8ab6fa5fcdf1b3204a2dd1a44c4", + "joules_micro": 500, + "origin": "dc-2.example", + "merkle_proof": { + "siblings": [ + "339ea521d795afe4a4ec26042a2827fc9e8052b581fdbb1636b2315ee9953e93", + "81b35ae562bccadd9651fe74f2669b85dd2cfe5c60fc050119ceb3277483af12", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 2, + "leaf_index": 2 + } + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742032" + }, + "expected": { + "outcome": "reject", + "step": 5, + "reason": "proof_position_mismatch" + } + }, + { + "name": "object_proof_short", + "about": "a proof one sibling short of the group's depth", + "procedure": "object", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 2, + "content_hash": "3cb85c360e2af8f1a586389d13f0415ca90ea8ab6fa5fcdf1b3204a2dd1a44c4", + "joules_micro": 500, + "origin": "dc-2.example", + "merkle_proof": { + "siblings": [ + "339ea521d795afe4a4ec26042a2827fc9e8052b581fdbb1636b2315ee9953e93", + "81b35ae562bccadd9651fe74f2669b85dd2cfe5c60fc050119ceb3277483af12" + ], + "direction_bits": 2, + "leaf_index": 2 + } + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742032" + }, + "expected": { + "outcome": "reject", + "step": 5, + "reason": "proof_position_mismatch" + } + }, + { + "name": "object_other_group", + "about": "an object of another group_id presented under this group", + "procedure": "object", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 4, + "object_id": 2, + "content_hash": "3cb85c360e2af8f1a586389d13f0415ca90ea8ab6fa5fcdf1b3204a2dd1a44c4", + "joules_micro": 500, + "origin": "dc-2.example", + "merkle_proof": { + "siblings": [ + "339ea521d795afe4a4ec26042a2827fc9e8052b581fdbb1636b2315ee9953e93", + "81b35ae562bccadd9651fe74f2669b85dd2cfe5c60fc050119ceb3277483af12", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 2, + "leaf_index": 2 + } + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742032" + }, + "expected": { + "outcome": "reject", + "step": 5, + "reason": "proof_position_mismatch" + } + }, + { + "name": "object_under_unverified_group", + "about": "a valid proof against a group whose signature fails: no partial verification", + "procedure": "object", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "82a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 0, + "content_hash": "16d34babd9a027ad12b61e4d0aef877f560d87e8b4884a3a2b2d59cb54a4850d", + "joules_micro": 1200, + "origin": "dc-0.example", + "merkle_proof": { + "siblings": [ + "281de97b5d3b5f872298b30812bf65f6a14d2cb223c135d17034e19e20988c9f", + "59057bc02626a58b09500bf398bc79d53501ce15bb752e14be51fc3f56de8480", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742030" + }, + "expected": { + "outcome": "reject", + "step": 2, + "reason": "signature_verify_failed" + } + }, + { + "name": "object_unknown_member", + "about": "a member the object receipt does not define", + "procedure": "object", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 2, + "content_hash": "3cb85c360e2af8f1a586389d13f0415ca90ea8ab6fa5fcdf1b3204a2dd1a44c4", + "joules_micro": 500, + "origin": "dc-2.example", + "merkle_proof": { + "siblings": [ + "339ea521d795afe4a4ec26042a2827fc9e8052b581fdbb1636b2315ee9953e93", + "81b35ae562bccadd9651fe74f2669b85dd2cfe5c60fc050119ceb3277483af12", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 2, + "leaf_index": 2 + }, + "note": "x" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742032" + }, + "expected": { + "outcome": "reject", + "step": 4, + "reason": "malformed" + } + }, + { + "name": "total_legacy_unlabelled_part", + "about": "every object of the mixed group held; one non-zero figure is unlabelled, so the total reads as unlabelled", + "procedure": "total", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "delivered": [ + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 0, + "content_hash": "16d34babd9a027ad12b61e4d0aef877f560d87e8b4884a3a2b2d59cb54a4850d", + "joules_micro": 1200, + "origin": "dc-0.example", + "merkle_proof": { + "siblings": [ + "281de97b5d3b5f872298b30812bf65f6a14d2cb223c135d17034e19e20988c9f", + "59057bc02626a58b09500bf398bc79d53501ce15bb752e14be51fc3f56de8480", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742030" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 1, + "content_hash": "a900d4803cc1c6cf8384e4238c3745bd6a1c3838a917b4c0a99fb77bd9ba7c1e", + "joules_micro": 800, + "origin": "dc-1.example", + "merkle_proof": { + "siblings": [ + "eeb5d0cf357a3bca9437eff04cf82570a6338652c842d9a20a7670f409a80760", + "59057bc02626a58b09500bf398bc79d53501ce15bb752e14be51fc3f56de8480", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 1, + "leaf_index": 1 + }, + "leaf_version": 2, + "energy_provenance": "CalibratedInstrument", + "energy_uncertainty": { + "relative_ppm": 5000, + "window_us": 2000000 + }, + "energy_calibration_ref": "https://calibration.example.org/certificates/0001" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742031" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 2, + "content_hash": "3cb85c360e2af8f1a586389d13f0415ca90ea8ab6fa5fcdf1b3204a2dd1a44c4", + "joules_micro": 500, + "origin": "dc-2.example", + "merkle_proof": { + "siblings": [ + "339ea521d795afe4a4ec26042a2827fc9e8052b581fdbb1636b2315ee9953e93", + "81b35ae562bccadd9651fe74f2669b85dd2cfe5c60fc050119ceb3277483af12", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 2, + "leaf_index": 2 + } + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742032" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 3, + "content_hash": "6e90ee47565591796c76e57de2b092695ba42d4e5dae585eb1503589e2b703d7", + "joules_micro": 0, + "origin": "dc-3.example", + "merkle_proof": { + "siblings": [ + "65dbe4a732e01bd69de3104e03d9822107318551714d055c8a7acc45313796e8", + "81b35ae562bccadd9651fe74f2669b85dd2cfe5c60fc050119ceb3277483af12", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 3, + "leaf_index": 3 + }, + "leaf_version": 2 + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742033" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 4, + "content_hash": "d328f60c60ffa2d36830838218ee22a5e31cc674b816e63526ebd74c1fe66cdc", + "joules_micro": 300, + "origin": "dc-4.example", + "merkle_proof": { + "siblings": [ + "3049a2823be995b9ef123c82675c47e2df96a77b50be6a71d676246325d46a76", + "c2638ebd069f911759c78d18685c71ef0791acbde44f604fe298e40448cda8c2", + "5880f5698f5a1b6b230f65c481aa126e56f86e3a26e1a29d2edb79eae011a818" + ], + "direction_bits": 4, + "leaf_index": 4 + }, + "leaf_version": 2, + "energy_provenance": "ModelBased" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742034" + } + ] + }, + "expected": { + "outcome": "read", + "reading": "unlabelled" + } + }, + { + "name": "total_legacy_every_part_labelled", + "about": "a legacy group whose every non-zero part is labelled: the weakest class, as a name only", + "procedure": "total", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 5, + "root_hash": "5a8870c49a7ce232b1a4cd4bced3b0ee70b33535bcb8f9f563f617279ae9ce29", + "leaf_count": 3, + "joules_total": 2000, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "e2941e025b5118c7123e90045046934288dc8588f4f26aa725fc1f36dccf80844c4056fcb066b52352753a868a923957de2fe5dfbb5aad0b069bc20907290c0f", + "parent_receipt_hash": null + }, + "delivered": [ + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 5, + "object_id": 0, + "content_hash": "805138ef87f855ed95d3b7b1d6af233880735f7aff37bdbce6a04beb70a4969e", + "joules_micro": 1200, + "origin": "dc-10.example", + "merkle_proof": { + "siblings": [ + "04e8700113c4c7ef527c261a8982a555e8329745144326763b99ec062819b53a", + "d507a5e609507a082972720179f44a746dc3b040f20da0ebd8d757b6a46d51d2" + ], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203130" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 5, + "object_id": 1, + "content_hash": "3e9410265f8b97cbc296544a1625a08bc7c414a87d7f5d9c5ba6ca1772f7aaec", + "joules_micro": 800, + "origin": "dc-11.example", + "merkle_proof": { + "siblings": [ + "0ca346e40f6aeefac6f4c22f1668ac477fe3a1b0565123f50ec60e15fb314cbd", + "d507a5e609507a082972720179f44a746dc3b040f20da0ebd8d757b6a46d51d2" + ], + "direction_bits": 1, + "leaf_index": 1 + }, + "leaf_version": 2, + "energy_provenance": "CalibratedInstrument", + "energy_uncertainty": { + "relative_ppm": 5000, + "window_us": 2000000 + }, + "energy_calibration_ref": "https://calibration.example.org/certificates/0001" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203131" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 5, + "object_id": 2, + "content_hash": "8a70f1dbf741839ffecdf4da81833c9d138f5a424dfc3ce5d10688fb27343037", + "joules_micro": 0, + "origin": "dc-12.example", + "merkle_proof": { + "siblings": [ + "06f4ee1788b1df78dc2ef90c8c8b7e44b09a0a4f641c5ada33f7099dc1b730f0", + "e6ecd7356f041fa203ee887686493a43ac8673ac827f810b2470b4eeb7a61124" + ], + "direction_bits": 2, + "leaf_index": 2 + }, + "leaf_version": 2 + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203132" + } + ] + }, + "expected": { + "outcome": "read", + "reading": "weakest class=OnChipCounter coverage=Partial" + } + }, + { + "name": "total_legacy_all_zero", + "about": "every figure zero: unmetered, never zero joules", + "procedure": "total", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 6, + "root_hash": "7df5acfc544e5effec6b8b86ac64737a17c878d13eb6939d10a9050a246598af", + "leaf_count": 2, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "d83aa322b9ab2d448d4d5877a16510c090c19b175f7b4491a87afb54d4f84f908a8876629c3256b1cac96e523d8d74bca69e1a815c4398ec55de6257e8740d05", + "parent_receipt_hash": null + }, + "delivered": [ + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 6, + "object_id": 0, + "content_hash": "8d8357f0a07ac69eb96d9ceb376a011689316225f9b46f370f57073d3ec30c2f", + "joules_micro": 0, + "origin": "dc-50.example", + "merkle_proof": { + "siblings": [ + "74fc74301cf60db5362ab0d52ece299a0b32dc4c834e8e6f325829b515077daa" + ], + "direction_bits": 0, + "leaf_index": 0 + } + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203530" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 6, + "object_id": 1, + "content_hash": "01b4370a24aebd23a6f9424a669db17f3dacad26695e6eb198d1489c7e9954ce", + "joules_micro": 0, + "origin": "dc-51.example", + "merkle_proof": { + "siblings": [ + "972f079040dfe0ff3b45972f88dec15fe185ecccd3726f3e2fa9e3f485d75e24" + ], + "direction_bits": 1, + "leaf_index": 1 + }, + "leaf_version": 2 + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203531" + } + ] + }, + "expected": { + "outcome": "read", + "reading": "unmetered" + } + }, + { + "name": "total_legacy_object_missing", + "about": "one object not held: unlabelled", + "procedure": "total", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "delivered": [ + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 0, + "content_hash": "16d34babd9a027ad12b61e4d0aef877f560d87e8b4884a3a2b2d59cb54a4850d", + "joules_micro": 1200, + "origin": "dc-0.example", + "merkle_proof": { + "siblings": [ + "281de97b5d3b5f872298b30812bf65f6a14d2cb223c135d17034e19e20988c9f", + "59057bc02626a58b09500bf398bc79d53501ce15bb752e14be51fc3f56de8480", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742030" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 1, + "content_hash": "a900d4803cc1c6cf8384e4238c3745bd6a1c3838a917b4c0a99fb77bd9ba7c1e", + "joules_micro": 800, + "origin": "dc-1.example", + "merkle_proof": { + "siblings": [ + "eeb5d0cf357a3bca9437eff04cf82570a6338652c842d9a20a7670f409a80760", + "59057bc02626a58b09500bf398bc79d53501ce15bb752e14be51fc3f56de8480", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 1, + "leaf_index": 1 + }, + "leaf_version": 2, + "energy_provenance": "CalibratedInstrument", + "energy_uncertainty": { + "relative_ppm": 5000, + "window_us": 2000000 + }, + "energy_calibration_ref": "https://calibration.example.org/certificates/0001" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742031" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 2, + "content_hash": "3cb85c360e2af8f1a586389d13f0415ca90ea8ab6fa5fcdf1b3204a2dd1a44c4", + "joules_micro": 500, + "origin": "dc-2.example", + "merkle_proof": { + "siblings": [ + "339ea521d795afe4a4ec26042a2827fc9e8052b581fdbb1636b2315ee9953e93", + "81b35ae562bccadd9651fe74f2669b85dd2cfe5c60fc050119ceb3277483af12", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 2, + "leaf_index": 2 + } + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742032" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 3, + "content_hash": "6e90ee47565591796c76e57de2b092695ba42d4e5dae585eb1503589e2b703d7", + "joules_micro": 0, + "origin": "dc-3.example", + "merkle_proof": { + "siblings": [ + "65dbe4a732e01bd69de3104e03d9822107318551714d055c8a7acc45313796e8", + "81b35ae562bccadd9651fe74f2669b85dd2cfe5c60fc050119ceb3277483af12", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 3, + "leaf_index": 3 + }, + "leaf_version": 2 + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742033" + } + ] + }, + "expected": { + "outcome": "read", + "reading": "unlabelled" + } + }, + { + "name": "total_legacy_figure_altered", + "about": "a legacy object's unsigned figure rewritten: each object verifies, and the sum does not match the signed total", + "procedure": "total", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "delivered": [ + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 0, + "content_hash": "16d34babd9a027ad12b61e4d0aef877f560d87e8b4884a3a2b2d59cb54a4850d", + "joules_micro": 1200, + "origin": "dc-0.example", + "merkle_proof": { + "siblings": [ + "281de97b5d3b5f872298b30812bf65f6a14d2cb223c135d17034e19e20988c9f", + "59057bc02626a58b09500bf398bc79d53501ce15bb752e14be51fc3f56de8480", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742030" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 1, + "content_hash": "a900d4803cc1c6cf8384e4238c3745bd6a1c3838a917b4c0a99fb77bd9ba7c1e", + "joules_micro": 800, + "origin": "dc-1.example", + "merkle_proof": { + "siblings": [ + "eeb5d0cf357a3bca9437eff04cf82570a6338652c842d9a20a7670f409a80760", + "59057bc02626a58b09500bf398bc79d53501ce15bb752e14be51fc3f56de8480", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 1, + "leaf_index": 1 + }, + "leaf_version": 2, + "energy_provenance": "CalibratedInstrument", + "energy_uncertainty": { + "relative_ppm": 5000, + "window_us": 2000000 + }, + "energy_calibration_ref": "https://calibration.example.org/certificates/0001" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742031" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 2, + "content_hash": "3cb85c360e2af8f1a586389d13f0415ca90ea8ab6fa5fcdf1b3204a2dd1a44c4", + "joules_micro": 501, + "origin": "dc-2.example", + "merkle_proof": { + "siblings": [ + "339ea521d795afe4a4ec26042a2827fc9e8052b581fdbb1636b2315ee9953e93", + "81b35ae562bccadd9651fe74f2669b85dd2cfe5c60fc050119ceb3277483af12", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 2, + "leaf_index": 2 + } + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742032" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 3, + "content_hash": "6e90ee47565591796c76e57de2b092695ba42d4e5dae585eb1503589e2b703d7", + "joules_micro": 0, + "origin": "dc-3.example", + "merkle_proof": { + "siblings": [ + "65dbe4a732e01bd69de3104e03d9822107318551714d055c8a7acc45313796e8", + "81b35ae562bccadd9651fe74f2669b85dd2cfe5c60fc050119ceb3277483af12", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 3, + "leaf_index": 3 + }, + "leaf_version": 2 + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742033" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 4, + "content_hash": "d328f60c60ffa2d36830838218ee22a5e31cc674b816e63526ebd74c1fe66cdc", + "joules_micro": 300, + "origin": "dc-4.example", + "merkle_proof": { + "siblings": [ + "3049a2823be995b9ef123c82675c47e2df96a77b50be6a71d676246325d46a76", + "c2638ebd069f911759c78d18685c71ef0791acbde44f604fe298e40448cda8c2", + "5880f5698f5a1b6b230f65c481aa126e56f86e3a26e1a29d2edb79eae011a818" + ], + "direction_bits": 4, + "leaf_index": 4 + }, + "leaf_version": 2, + "energy_provenance": "ModelBased" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742034" + } + ] + }, + "expected": { + "outcome": "reject", + "reason": "group_total_mismatch" + } + }, + { + "name": "total_duplicate_cannot_hide_weaker_class", + "about": "the counter's object presented again at the model's index: refused at its position, so the model's index is not held and the total reads unlabelled rather than as a counter's", + "procedure": "total", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 7, + "root_hash": "8f6690b749a2282288561f90191b5ffe173a291cdabacab47e90b0ccf90670b1", + "leaf_count": 2, + "joules_total": 1000, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "b71b79144c59da0ee14b133c5de4be6945900f0c64f9b9a96ce475c1c8b1fef754664ef654495cb54f573f7fd9312d5d6faaf565c63f918fc8f2df6fc5c7e700", + "parent_receipt_hash": null + }, + "delivered": [ + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 7, + "object_id": 0, + "content_hash": "a20982ddf6fac7ca596c0cb44d14ffb74a2cb143d5937a9d2a46996296f2af20", + "joules_micro": 500, + "origin": "dc-60.example", + "merkle_proof": { + "siblings": [ + "555d9ed4c576f1b94a09780000a0055e7f07b0e1b79b6c3d1634f5a23bf3111b" + ], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203630" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 7, + "object_id": 1, + "content_hash": "a20982ddf6fac7ca596c0cb44d14ffb74a2cb143d5937a9d2a46996296f2af20", + "joules_micro": 500, + "origin": "dc-60.example", + "merkle_proof": { + "siblings": [ + "555d9ed4c576f1b94a09780000a0055e7f07b0e1b79b6c3d1634f5a23bf3111b" + ], + "direction_bits": 0, + "leaf_index": 1 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203630" + } + ] + }, + "expected": { + "outcome": "read", + "reading": "unlabelled" + } + }, + { + "name": "total_duplicate_both_held", + "about": "the same two objects held properly: the weaker class", + "procedure": "total", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 7, + "root_hash": "8f6690b749a2282288561f90191b5ffe173a291cdabacab47e90b0ccf90670b1", + "leaf_count": 2, + "joules_total": 1000, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "b71b79144c59da0ee14b133c5de4be6945900f0c64f9b9a96ce475c1c8b1fef754664ef654495cb54f573f7fd9312d5d6faaf565c63f918fc8f2df6fc5c7e700", + "parent_receipt_hash": null + }, + "delivered": [ + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 7, + "object_id": 0, + "content_hash": "a20982ddf6fac7ca596c0cb44d14ffb74a2cb143d5937a9d2a46996296f2af20", + "joules_micro": 500, + "origin": "dc-60.example", + "merkle_proof": { + "siblings": [ + "555d9ed4c576f1b94a09780000a0055e7f07b0e1b79b6c3d1634f5a23bf3111b" + ], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203630" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 7, + "object_id": 1, + "content_hash": "977f8380017633e746436852b1da3417bdc536c9359d4cffa912c47ada491292", + "joules_micro": 500, + "origin": "dc-61.example", + "merkle_proof": { + "siblings": [ + "0d4aff60416317eb13aa7159cb04d87aa563b85d21933d3f068075a96a65c43f" + ], + "direction_bits": 1, + "leaf_index": 1 + }, + "leaf_version": 2, + "energy_provenance": "ModelBased" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203631" + } + ] + }, + "expected": { + "outcome": "read", + "reading": "weakest class=ModelBased coverage=Whole" + } + }, + { + "name": "total_labelled_checked", + "about": "the labelled group with every object held: its signed class is what they compose to", + "procedure": "total", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 4, + "root_hash": "5a8870c49a7ce232b1a4cd4bced3b0ee70b33535bcb8f9f563f617279ae9ce29", + "leaf_count": 3, + "joules_total": 2000, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "cd89ea4722e276b4458604faaf7d92a2fb1286c69a62dde021dfaaed9d6fc2975150865e71dffa91526ca52e2844a74197dc87102a5f489dcf32dc3394fd6509", + "parent_receipt_hash": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5", + "sign_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 14000, + "window_us": 2000000 + }, + "energy_counter_filtering": "Unknown", + "energy_coverage": "Partial" + }, + "delivered": [ + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 4, + "object_id": 0, + "content_hash": "805138ef87f855ed95d3b7b1d6af233880735f7aff37bdbce6a04beb70a4969e", + "joules_micro": 1200, + "origin": "dc-10.example", + "merkle_proof": { + "siblings": [ + "04e8700113c4c7ef527c261a8982a555e8329745144326763b99ec062819b53a", + "d507a5e609507a082972720179f44a746dc3b040f20da0ebd8d757b6a46d51d2" + ], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203130" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 4, + "object_id": 1, + "content_hash": "3e9410265f8b97cbc296544a1625a08bc7c414a87d7f5d9c5ba6ca1772f7aaec", + "joules_micro": 800, + "origin": "dc-11.example", + "merkle_proof": { + "siblings": [ + "0ca346e40f6aeefac6f4c22f1668ac477fe3a1b0565123f50ec60e15fb314cbd", + "d507a5e609507a082972720179f44a746dc3b040f20da0ebd8d757b6a46d51d2" + ], + "direction_bits": 1, + "leaf_index": 1 + }, + "leaf_version": 2, + "energy_provenance": "CalibratedInstrument", + "energy_uncertainty": { + "relative_ppm": 5000, + "window_us": 2000000 + }, + "energy_calibration_ref": "https://calibration.example.org/certificates/0001" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203131" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 4, + "object_id": 2, + "content_hash": "8a70f1dbf741839ffecdf4da81833c9d138f5a424dfc3ce5d10688fb27343037", + "joules_micro": 0, + "origin": "dc-12.example", + "merkle_proof": { + "siblings": [ + "06f4ee1788b1df78dc2ef90c8c8b7e44b09a0a4f641c5ada33f7099dc1b730f0", + "e6ecd7356f041fa203ee887686493a43ac8673ac827f810b2470b4eeb7a61124" + ], + "direction_bits": 2, + "leaf_index": 2 + }, + "leaf_version": 2 + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203132" + } + ] + }, + "expected": { + "outcome": "read", + "reading": "signed class=OnChipCounter relative_ppm=14000 window_us=2000000 coverage=Partial parts=checked" + } + }, + { + "name": "total_labelled_part_held", + "about": "the labelled group with one object held: its signed class stands, unchecked", + "procedure": "total", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 4, + "root_hash": "5a8870c49a7ce232b1a4cd4bced3b0ee70b33535bcb8f9f563f617279ae9ce29", + "leaf_count": 3, + "joules_total": 2000, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "cd89ea4722e276b4458604faaf7d92a2fb1286c69a62dde021dfaaed9d6fc2975150865e71dffa91526ca52e2844a74197dc87102a5f489dcf32dc3394fd6509", + "parent_receipt_hash": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5", + "sign_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 14000, + "window_us": 2000000 + }, + "energy_counter_filtering": "Unknown", + "energy_coverage": "Partial" + }, + "delivered": [ + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 4, + "object_id": 0, + "content_hash": "805138ef87f855ed95d3b7b1d6af233880735f7aff37bdbce6a04beb70a4969e", + "joules_micro": 1200, + "origin": "dc-10.example", + "merkle_proof": { + "siblings": [ + "04e8700113c4c7ef527c261a8982a555e8329745144326763b99ec062819b53a", + "d507a5e609507a082972720179f44a746dc3b040f20da0ebd8d757b6a46d51d2" + ], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203130" + } + ] + }, + "expected": { + "outcome": "read", + "reading": "signed class=OnChipCounter relative_ppm=14000 window_us=2000000 coverage=Partial parts=unchecked" + } + }, + { + "name": "total_labelled_above_least", + "about": "a declaration above the least the parts compose to", + "procedure": "total", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 4, + "root_hash": "5a8870c49a7ce232b1a4cd4bced3b0ee70b33535bcb8f9f563f617279ae9ce29", + "leaf_count": 3, + "joules_total": 2000, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "a2ae6e3d881661d7d55e9e8695cb7ca95e92b1987deab551a125fa168b24954798576e2296f43b9794362a583e0bb6a4bdccd50b9be7d780c50bbb96b6923e03", + "parent_receipt_hash": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5", + "sign_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 14001, + "window_us": 2000000 + }, + "energy_counter_filtering": "Unknown", + "energy_coverage": "Partial" + }, + "delivered": [ + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 4, + "object_id": 0, + "content_hash": "805138ef87f855ed95d3b7b1d6af233880735f7aff37bdbce6a04beb70a4969e", + "joules_micro": 1200, + "origin": "dc-10.example", + "merkle_proof": { + "siblings": [ + "04e8700113c4c7ef527c261a8982a555e8329745144326763b99ec062819b53a", + "d507a5e609507a082972720179f44a746dc3b040f20da0ebd8d757b6a46d51d2" + ], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203130" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 4, + "object_id": 1, + "content_hash": "3e9410265f8b97cbc296544a1625a08bc7c414a87d7f5d9c5ba6ca1772f7aaec", + "joules_micro": 800, + "origin": "dc-11.example", + "merkle_proof": { + "siblings": [ + "0ca346e40f6aeefac6f4c22f1668ac477fe3a1b0565123f50ec60e15fb314cbd", + "d507a5e609507a082972720179f44a746dc3b040f20da0ebd8d757b6a46d51d2" + ], + "direction_bits": 1, + "leaf_index": 1 + }, + "leaf_version": 2, + "energy_provenance": "CalibratedInstrument", + "energy_uncertainty": { + "relative_ppm": 5000, + "window_us": 2000000 + }, + "energy_calibration_ref": "https://calibration.example.org/certificates/0001" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203131" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 4, + "object_id": 2, + "content_hash": "8a70f1dbf741839ffecdf4da81833c9d138f5a424dfc3ce5d10688fb27343037", + "joules_micro": 0, + "origin": "dc-12.example", + "merkle_proof": { + "siblings": [ + "06f4ee1788b1df78dc2ef90c8c8b7e44b09a0a4f641c5ada33f7099dc1b730f0", + "e6ecd7356f041fa203ee887686493a43ac8673ac827f810b2470b4eeb7a61124" + ], + "direction_bits": 2, + "leaf_index": 2 + }, + "leaf_version": 2 + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203132" + } + ] + }, + "expected": { + "outcome": "read", + "reading": "signed class=OnChipCounter relative_ppm=14001 window_us=2000000 coverage=Partial parts=checked" + } + }, + { + "name": "total_labelled_below_least", + "about": "one ppm below the least the parts compose to", + "procedure": "total", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 4, + "root_hash": "5a8870c49a7ce232b1a4cd4bced3b0ee70b33535bcb8f9f563f617279ae9ce29", + "leaf_count": 3, + "joules_total": 2000, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "d06c7577f2a414b137d1f6cd630c1259b484287007c45bdb4a27280a1199ab1fb84d1bddb3d764ab222f322e830a6d21441da5ff4a2da23948302540a3f3fd01", + "parent_receipt_hash": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5", + "sign_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 13999, + "window_us": 2000000 + }, + "energy_counter_filtering": "Unknown", + "energy_coverage": "Partial" + }, + "delivered": [ + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 4, + "object_id": 0, + "content_hash": "805138ef87f855ed95d3b7b1d6af233880735f7aff37bdbce6a04beb70a4969e", + "joules_micro": 1200, + "origin": "dc-10.example", + "merkle_proof": { + "siblings": [ + "04e8700113c4c7ef527c261a8982a555e8329745144326763b99ec062819b53a", + "d507a5e609507a082972720179f44a746dc3b040f20da0ebd8d757b6a46d51d2" + ], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203130" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 4, + "object_id": 1, + "content_hash": "3e9410265f8b97cbc296544a1625a08bc7c414a87d7f5d9c5ba6ca1772f7aaec", + "joules_micro": 800, + "origin": "dc-11.example", + "merkle_proof": { + "siblings": [ + "0ca346e40f6aeefac6f4c22f1668ac477fe3a1b0565123f50ec60e15fb314cbd", + "d507a5e609507a082972720179f44a746dc3b040f20da0ebd8d757b6a46d51d2" + ], + "direction_bits": 1, + "leaf_index": 1 + }, + "leaf_version": 2, + "energy_provenance": "CalibratedInstrument", + "energy_uncertainty": { + "relative_ppm": 5000, + "window_us": 2000000 + }, + "energy_calibration_ref": "https://calibration.example.org/certificates/0001" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203131" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 4, + "object_id": 2, + "content_hash": "8a70f1dbf741839ffecdf4da81833c9d138f5a424dfc3ce5d10688fb27343037", + "joules_micro": 0, + "origin": "dc-12.example", + "merkle_proof": { + "siblings": [ + "06f4ee1788b1df78dc2ef90c8c8b7e44b09a0a4f641c5ada33f7099dc1b730f0", + "e6ecd7356f041fa203ee887686493a43ac8673ac827f810b2470b4eeb7a61124" + ], + "direction_bits": 2, + "leaf_index": 2 + }, + "leaf_version": 2 + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203132" + } + ] + }, + "expected": { + "outcome": "reject", + "reason": "group_uncertainty_below_parts" + } + }, + { + "name": "total_labelled_window_not_the_longest", + "about": "the parts' longest window is 2 s; the group declares 1 s", + "procedure": "total", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 4, + "root_hash": "5a8870c49a7ce232b1a4cd4bced3b0ee70b33535bcb8f9f563f617279ae9ce29", + "leaf_count": 3, + "joules_total": 2000, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "72b1007ffda39cd30d1d8ad9842b497e98bdf2a545a61955ac8a5a6f1c35f71ee89d39966e0403e0a7eb878bca4f335e1874d4f2f468bf90d33815f2b1d4f20d", + "parent_receipt_hash": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5", + "sign_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 14000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown", + "energy_coverage": "Partial" + }, + "delivered": [ + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 4, + "object_id": 0, + "content_hash": "805138ef87f855ed95d3b7b1d6af233880735f7aff37bdbce6a04beb70a4969e", + "joules_micro": 1200, + "origin": "dc-10.example", + "merkle_proof": { + "siblings": [ + "04e8700113c4c7ef527c261a8982a555e8329745144326763b99ec062819b53a", + "d507a5e609507a082972720179f44a746dc3b040f20da0ebd8d757b6a46d51d2" + ], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203130" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 4, + "object_id": 1, + "content_hash": "3e9410265f8b97cbc296544a1625a08bc7c414a87d7f5d9c5ba6ca1772f7aaec", + "joules_micro": 800, + "origin": "dc-11.example", + "merkle_proof": { + "siblings": [ + "0ca346e40f6aeefac6f4c22f1668ac477fe3a1b0565123f50ec60e15fb314cbd", + "d507a5e609507a082972720179f44a746dc3b040f20da0ebd8d757b6a46d51d2" + ], + "direction_bits": 1, + "leaf_index": 1 + }, + "leaf_version": 2, + "energy_provenance": "CalibratedInstrument", + "energy_uncertainty": { + "relative_ppm": 5000, + "window_us": 2000000 + }, + "energy_calibration_ref": "https://calibration.example.org/certificates/0001" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203131" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 4, + "object_id": 2, + "content_hash": "8a70f1dbf741839ffecdf4da81833c9d138f5a424dfc3ce5d10688fb27343037", + "joules_micro": 0, + "origin": "dc-12.example", + "merkle_proof": { + "siblings": [ + "06f4ee1788b1df78dc2ef90c8c8b7e44b09a0a4f641c5ada33f7099dc1b730f0", + "e6ecd7356f041fa203ee887686493a43ac8673ac827f810b2470b4eeb7a61124" + ], + "direction_bits": 2, + "leaf_index": 2 + }, + "leaf_version": 2 + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203132" + } + ] + }, + "expected": { + "outcome": "reject", + "reason": "group_uncertainty_below_parts" + } + }, + { + "name": "total_labelled_class_stronger_than_parts", + "about": "the group classes its total as a calibrated instrument; one part is a counter", + "procedure": "total", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 4, + "root_hash": "5a8870c49a7ce232b1a4cd4bced3b0ee70b33535bcb8f9f563f617279ae9ce29", + "leaf_count": 3, + "joules_total": 2000, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "e644ab9f98db960d48403402eb722ca7306bb40e38d5592c5eda9886efe5a4df2e30c3304a215c36af7cb73113c9c2c8e569e174098acdd8be166d42867e1409", + "parent_receipt_hash": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5", + "sign_version": 2, + "energy_provenance": "CalibratedInstrument", + "energy_uncertainty": { + "relative_ppm": 14000, + "window_us": 2000000 + }, + "energy_calibration_ref": "https://calibration.example.org/certificates/0001", + "energy_coverage": "Partial" + }, + "delivered": [ + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 4, + "object_id": 0, + "content_hash": "805138ef87f855ed95d3b7b1d6af233880735f7aff37bdbce6a04beb70a4969e", + "joules_micro": 1200, + "origin": "dc-10.example", + "merkle_proof": { + "siblings": [ + "04e8700113c4c7ef527c261a8982a555e8329745144326763b99ec062819b53a", + "d507a5e609507a082972720179f44a746dc3b040f20da0ebd8d757b6a46d51d2" + ], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203130" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 4, + "object_id": 1, + "content_hash": "3e9410265f8b97cbc296544a1625a08bc7c414a87d7f5d9c5ba6ca1772f7aaec", + "joules_micro": 800, + "origin": "dc-11.example", + "merkle_proof": { + "siblings": [ + "0ca346e40f6aeefac6f4c22f1668ac477fe3a1b0565123f50ec60e15fb314cbd", + "d507a5e609507a082972720179f44a746dc3b040f20da0ebd8d757b6a46d51d2" + ], + "direction_bits": 1, + "leaf_index": 1 + }, + "leaf_version": 2, + "energy_provenance": "CalibratedInstrument", + "energy_uncertainty": { + "relative_ppm": 5000, + "window_us": 2000000 + }, + "energy_calibration_ref": "https://calibration.example.org/certificates/0001" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203131" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 4, + "object_id": 2, + "content_hash": "8a70f1dbf741839ffecdf4da81833c9d138f5a424dfc3ce5d10688fb27343037", + "joules_micro": 0, + "origin": "dc-12.example", + "merkle_proof": { + "siblings": [ + "06f4ee1788b1df78dc2ef90c8c8b7e44b09a0a4f641c5ada33f7099dc1b730f0", + "e6ecd7356f041fa203ee887686493a43ac8673ac827f810b2470b4eeb7a61124" + ], + "direction_bits": 2, + "leaf_index": 2 + }, + "leaf_version": 2 + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203132" + } + ] + }, + "expected": { + "outcome": "reject", + "reason": "group_class_disagrees" + } + }, + { + "name": "total_labelled_coverage_whole", + "about": "the group says Whole; one part is zero", + "procedure": "total", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 4, + "root_hash": "5a8870c49a7ce232b1a4cd4bced3b0ee70b33535bcb8f9f563f617279ae9ce29", + "leaf_count": 3, + "joules_total": 2000, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "f98b6324a04062fef7a583b8b2d46955e9e57ed75811ff4273a08d8fd0ced0a28da3eadd0abf2958e73724315c1823267f2c1eb9c21ffa165a2bafa28b6fb807", + "parent_receipt_hash": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5", + "sign_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 14000, + "window_us": 2000000 + }, + "energy_counter_filtering": "Unknown", + "energy_coverage": "Whole" + }, + "delivered": [ + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 4, + "object_id": 0, + "content_hash": "805138ef87f855ed95d3b7b1d6af233880735f7aff37bdbce6a04beb70a4969e", + "joules_micro": 1200, + "origin": "dc-10.example", + "merkle_proof": { + "siblings": [ + "04e8700113c4c7ef527c261a8982a555e8329745144326763b99ec062819b53a", + "d507a5e609507a082972720179f44a746dc3b040f20da0ebd8d757b6a46d51d2" + ], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203130" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 4, + "object_id": 1, + "content_hash": "3e9410265f8b97cbc296544a1625a08bc7c414a87d7f5d9c5ba6ca1772f7aaec", + "joules_micro": 800, + "origin": "dc-11.example", + "merkle_proof": { + "siblings": [ + "0ca346e40f6aeefac6f4c22f1668ac477fe3a1b0565123f50ec60e15fb314cbd", + "d507a5e609507a082972720179f44a746dc3b040f20da0ebd8d757b6a46d51d2" + ], + "direction_bits": 1, + "leaf_index": 1 + }, + "leaf_version": 2, + "energy_provenance": "CalibratedInstrument", + "energy_uncertainty": { + "relative_ppm": 5000, + "window_us": 2000000 + }, + "energy_calibration_ref": "https://calibration.example.org/certificates/0001" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203131" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 4, + "object_id": 2, + "content_hash": "8a70f1dbf741839ffecdf4da81833c9d138f5a424dfc3ce5d10688fb27343037", + "joules_micro": 0, + "origin": "dc-12.example", + "merkle_proof": { + "siblings": [ + "06f4ee1788b1df78dc2ef90c8c8b7e44b09a0a4f641c5ada33f7099dc1b730f0", + "e6ecd7356f041fa203ee887686493a43ac8673ac827f810b2470b4eeb7a61124" + ], + "direction_bits": 2, + "leaf_index": 2 + }, + "leaf_version": 2 + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203132" + } + ] + }, + "expected": { + "outcome": "reject", + "reason": "group_coverage_disagrees" + } + }, + { + "name": "total_labelled_over_unlabelled_part", + "about": "a labelled group over parts one of which is unlabelled: it may carry no class", + "procedure": "total", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "0f3d71f81f70d5ee41d4b9bd65b15d15a1cfb5e2bb9d423e2b5651f28d56d0c698be09e203bf4895b6282d2d64d4cb6ebeb45c5ac03b075a51ef35ae4c366f03", + "parent_receipt_hash": null, + "sign_version": 2, + "energy_provenance": "ModelBased", + "energy_coverage": "Partial" + }, + "delivered": [ + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 0, + "content_hash": "16d34babd9a027ad12b61e4d0aef877f560d87e8b4884a3a2b2d59cb54a4850d", + "joules_micro": 1200, + "origin": "dc-0.example", + "merkle_proof": { + "siblings": [ + "281de97b5d3b5f872298b30812bf65f6a14d2cb223c135d17034e19e20988c9f", + "59057bc02626a58b09500bf398bc79d53501ce15bb752e14be51fc3f56de8480", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742030" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 1, + "content_hash": "a900d4803cc1c6cf8384e4238c3745bd6a1c3838a917b4c0a99fb77bd9ba7c1e", + "joules_micro": 800, + "origin": "dc-1.example", + "merkle_proof": { + "siblings": [ + "eeb5d0cf357a3bca9437eff04cf82570a6338652c842d9a20a7670f409a80760", + "59057bc02626a58b09500bf398bc79d53501ce15bb752e14be51fc3f56de8480", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 1, + "leaf_index": 1 + }, + "leaf_version": 2, + "energy_provenance": "CalibratedInstrument", + "energy_uncertainty": { + "relative_ppm": 5000, + "window_us": 2000000 + }, + "energy_calibration_ref": "https://calibration.example.org/certificates/0001" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742031" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 2, + "content_hash": "3cb85c360e2af8f1a586389d13f0415ca90ea8ab6fa5fcdf1b3204a2dd1a44c4", + "joules_micro": 500, + "origin": "dc-2.example", + "merkle_proof": { + "siblings": [ + "339ea521d795afe4a4ec26042a2827fc9e8052b581fdbb1636b2315ee9953e93", + "81b35ae562bccadd9651fe74f2669b85dd2cfe5c60fc050119ceb3277483af12", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 2, + "leaf_index": 2 + } + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742032" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 3, + "content_hash": "6e90ee47565591796c76e57de2b092695ba42d4e5dae585eb1503589e2b703d7", + "joules_micro": 0, + "origin": "dc-3.example", + "merkle_proof": { + "siblings": [ + "65dbe4a732e01bd69de3104e03d9822107318551714d055c8a7acc45313796e8", + "81b35ae562bccadd9651fe74f2669b85dd2cfe5c60fc050119ceb3277483af12", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 3, + "leaf_index": 3 + }, + "leaf_version": 2 + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742033" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 4, + "content_hash": "d328f60c60ffa2d36830838218ee22a5e31cc674b816e63526ebd74c1fe66cdc", + "joules_micro": 300, + "origin": "dc-4.example", + "merkle_proof": { + "siblings": [ + "3049a2823be995b9ef123c82675c47e2df96a77b50be6a71d676246325d46a76", + "c2638ebd069f911759c78d18685c71ef0791acbde44f604fe298e40448cda8c2", + "5880f5698f5a1b6b230f65c481aa126e56f86e3a26e1a29d2edb79eae011a818" + ], + "direction_bits": 4, + "leaf_index": 4 + }, + "leaf_version": 2, + "energy_provenance": "ModelBased" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742034" + } + ] + }, + "expected": { + "outcome": "reject", + "reason": "group_class_disagrees" + } + }, + { + "name": "total_labelled_two_certificates", + "about": "two instruments under two certificates: a calibrated-instrument total needs one", + "procedure": "total", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 8, + "root_hash": "5053281251b86aaea8347a93cd1d1b849098db941748928ba15cdf90227330d2", + "leaf_count": 2, + "joules_total": 2000, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "1fe44f495bf7193fe3d92cc8b93c66c605e4c3b28fb4b03ef488a440418fb5a29c1b40b919fd12ffc30a5a446fa67581d81658f18d84cea1066911cf80c2120d", + "parent_receipt_hash": null, + "sign_version": 2, + "energy_provenance": "CalibratedInstrument", + "energy_uncertainty": { + "relative_ppm": 300, + "window_us": 1000000 + }, + "energy_calibration_ref": "https://calibration.example.org/certificates/a", + "energy_coverage": "Whole" + }, + "delivered": [ + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 8, + "object_id": 0, + "content_hash": "987e7495f9e0e3558ee23446144b639e2114754480adc0e346f3494bf84d0476", + "joules_micro": 1000, + "origin": "dc-70.example", + "merkle_proof": { + "siblings": [ + "32e4569cb617c4fa87be251f1c6f320728a4c69f4187e33d57a9392c26daa7de" + ], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "CalibratedInstrument", + "energy_uncertainty": { + "relative_ppm": 300, + "window_us": 1000000 + }, + "energy_calibration_ref": "https://calibration.example.org/certificates/a" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203730" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 8, + "object_id": 1, + "content_hash": "61a454e3fc7d36563f637a01b2f94a68161cf45d159acf5b481b0aded29511d6", + "joules_micro": 1000, + "origin": "dc-71.example", + "merkle_proof": { + "siblings": [ + "4990a4bf0801f29c12bc3d6f05b33230056baccbc3dc2056d367c6a5c182b18b" + ], + "direction_bits": 1, + "leaf_index": 1 + }, + "leaf_version": 2, + "energy_provenance": "CalibratedInstrument", + "energy_uncertainty": { + "relative_ppm": 300, + "window_us": 1000000 + }, + "energy_calibration_ref": "https://calibration.example.org/certificates/b" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203731" + } + ] + }, + "expected": { + "outcome": "reject", + "reason": "group_class_disagrees" + } + }, + { + "name": "total_labelled_unmetered", + "about": "a labelled group of zero figures: the unmetered marker", + "procedure": "total", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 6, + "root_hash": "7df5acfc544e5effec6b8b86ac64737a17c878d13eb6939d10a9050a246598af", + "leaf_count": 2, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "557d3223b187de0818a1fb265ab75fcff03835301ce98cadcdc1d8c50b0b3ac2058fea33c10277a11837c59a71e7d57b3eab4a16ff40c5caf4cdde199242f20c", + "parent_receipt_hash": null, + "sign_version": 2 + }, + "delivered": [ + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 6, + "object_id": 0, + "content_hash": "8d8357f0a07ac69eb96d9ceb376a011689316225f9b46f370f57073d3ec30c2f", + "joules_micro": 0, + "origin": "dc-50.example", + "merkle_proof": { + "siblings": [ + "74fc74301cf60db5362ab0d52ece299a0b32dc4c834e8e6f325829b515077daa" + ], + "direction_bits": 0, + "leaf_index": 0 + } + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203530" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 6, + "object_id": 1, + "content_hash": "01b4370a24aebd23a6f9424a669db17f3dacad26695e6eb198d1489c7e9954ce", + "joules_micro": 0, + "origin": "dc-51.example", + "merkle_proof": { + "siblings": [ + "972f079040dfe0ff3b45972f88dec15fe185ecccd3726f3e2fa9e3f485d75e24" + ], + "direction_bits": 1, + "leaf_index": 1 + }, + "leaf_version": 2 + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a656374203531" + } + ] + }, + "expected": { + "outcome": "read", + "reading": "unmetered" + } + }, + { + "name": "total_group_does_not_verify", + "about": "the group's signature fails: no total is read", + "procedure": "total", + "input": { + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "82a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "delivered": [ + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 0, + "content_hash": "16d34babd9a027ad12b61e4d0aef877f560d87e8b4884a3a2b2d59cb54a4850d", + "joules_micro": 1200, + "origin": "dc-0.example", + "merkle_proof": { + "siblings": [ + "281de97b5d3b5f872298b30812bf65f6a14d2cb223c135d17034e19e20988c9f", + "59057bc02626a58b09500bf398bc79d53501ce15bb752e14be51fc3f56de8480", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742030" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 1, + "content_hash": "a900d4803cc1c6cf8384e4238c3745bd6a1c3838a917b4c0a99fb77bd9ba7c1e", + "joules_micro": 800, + "origin": "dc-1.example", + "merkle_proof": { + "siblings": [ + "eeb5d0cf357a3bca9437eff04cf82570a6338652c842d9a20a7670f409a80760", + "59057bc02626a58b09500bf398bc79d53501ce15bb752e14be51fc3f56de8480", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 1, + "leaf_index": 1 + }, + "leaf_version": 2, + "energy_provenance": "CalibratedInstrument", + "energy_uncertainty": { + "relative_ppm": 5000, + "window_us": 2000000 + }, + "energy_calibration_ref": "https://calibration.example.org/certificates/0001" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742031" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 2, + "content_hash": "3cb85c360e2af8f1a586389d13f0415ca90ea8ab6fa5fcdf1b3204a2dd1a44c4", + "joules_micro": 500, + "origin": "dc-2.example", + "merkle_proof": { + "siblings": [ + "339ea521d795afe4a4ec26042a2827fc9e8052b581fdbb1636b2315ee9953e93", + "81b35ae562bccadd9651fe74f2669b85dd2cfe5c60fc050119ceb3277483af12", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 2, + "leaf_index": 2 + } + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742032" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 3, + "content_hash": "6e90ee47565591796c76e57de2b092695ba42d4e5dae585eb1503589e2b703d7", + "joules_micro": 0, + "origin": "dc-3.example", + "merkle_proof": { + "siblings": [ + "65dbe4a732e01bd69de3104e03d9822107318551714d055c8a7acc45313796e8", + "81b35ae562bccadd9651fe74f2669b85dd2cfe5c60fc050119ceb3277483af12", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 3, + "leaf_index": 3 + }, + "leaf_version": 2 + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742033" + }, + { + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 4, + "content_hash": "d328f60c60ffa2d36830838218ee22a5e31cc674b816e63526ebd74c1fe66cdc", + "joules_micro": 300, + "origin": "dc-4.example", + "merkle_proof": { + "siblings": [ + "3049a2823be995b9ef123c82675c47e2df96a77b50be6a71d676246325d46a76", + "c2638ebd069f911759c78d18685c71ef0791acbde44f604fe298e40448cda8c2", + "5880f5698f5a1b6b230f65c481aa126e56f86e3a26e1a29d2edb79eae011a818" + ], + "direction_bits": 4, + "leaf_index": 4 + }, + "leaf_version": 2, + "energy_provenance": "ModelBased" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742034" + } + ] + }, + "expected": { + "outcome": "reject", + "step": 2, + "reason": "signature_verify_failed" + } + }, + { + "name": "property_signed_by_leaf", + "about": "the property restates the labelled leaf: its class is signed", + "procedure": "property", + "input": { + "property": { + "content_hash": "16d34babd9a027ad12b61e4d0aef877f560d87e8b4884a3a2b2d59cb54a4850d", + "joules_micro": 1200, + "acquisition": "OnChipCounter", + "acquisition_evidence": "00004e2000000000000f424000", + "group_receipt": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5" + }, + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 0, + "content_hash": "16d34babd9a027ad12b61e4d0aef877f560d87e8b4884a3a2b2d59cb54a4850d", + "joules_micro": 1200, + "origin": "dc-0.example", + "merkle_proof": { + "siblings": [ + "281de97b5d3b5f872298b30812bf65f6a14d2cb223c135d17034e19e20988c9f", + "59057bc02626a58b09500bf398bc79d53501ce15bb752e14be51fc3f56de8480", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742030" + }, + "expected": { + "outcome": "read", + "reading": "signed class=OnChipCounter by=d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737" + } + }, + { + "name": "property_evidence_differs", + "about": "the property's declared uncertainty differs from the leaf's", + "procedure": "property", + "input": { + "property": { + "content_hash": "16d34babd9a027ad12b61e4d0aef877f560d87e8b4884a3a2b2d59cb54a4850d", + "joules_micro": 1200, + "acquisition": "OnChipCounter", + "acquisition_evidence": "0000271000000000000f424000", + "group_receipt": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5" + }, + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 0, + "content_hash": "16d34babd9a027ad12b61e4d0aef877f560d87e8b4884a3a2b2d59cb54a4850d", + "joules_micro": 1200, + "origin": "dc-0.example", + "merkle_proof": { + "siblings": [ + "281de97b5d3b5f872298b30812bf65f6a14d2cb223c135d17034e19e20988c9f", + "59057bc02626a58b09500bf398bc79d53501ce15bb752e14be51fc3f56de8480", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742030" + }, + "expected": { + "outcome": "reject", + "reason": "property_disagrees" + } + }, + { + "name": "property_figure_differs", + "about": "the property's figure differs from the leaf's", + "procedure": "property", + "input": { + "property": { + "content_hash": "16d34babd9a027ad12b61e4d0aef877f560d87e8b4884a3a2b2d59cb54a4850d", + "joules_micro": 1201, + "acquisition": "OnChipCounter", + "acquisition_evidence": "00004e2000000000000f424000", + "group_receipt": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5" + }, + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 0, + "content_hash": "16d34babd9a027ad12b61e4d0aef877f560d87e8b4884a3a2b2d59cb54a4850d", + "joules_micro": 1200, + "origin": "dc-0.example", + "merkle_proof": { + "siblings": [ + "281de97b5d3b5f872298b30812bf65f6a14d2cb223c135d17034e19e20988c9f", + "59057bc02626a58b09500bf398bc79d53501ce15bb752e14be51fc3f56de8480", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742030" + }, + "expected": { + "outcome": "reject", + "reason": "property_disagrees" + } + }, + { + "name": "property_names_another_group", + "about": "the property names another group receipt", + "procedure": "property", + "input": { + "property": { + "content_hash": "16d34babd9a027ad12b61e4d0aef877f560d87e8b4884a3a2b2d59cb54a4850d", + "joules_micro": 1200, + "acquisition": "OnChipCounter", + "acquisition_evidence": "00004e2000000000000f424000", + "group_receipt": "9c49baff6e40a5d24222e49679dad204f036e356b402449a4047e9bafcdaa7b7" + }, + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 0, + "content_hash": "16d34babd9a027ad12b61e4d0aef877f560d87e8b4884a3a2b2d59cb54a4850d", + "joules_micro": 1200, + "origin": "dc-0.example", + "merkle_proof": { + "siblings": [ + "281de97b5d3b5f872298b30812bf65f6a14d2cb223c135d17034e19e20988c9f", + "59057bc02626a58b09500bf398bc79d53501ce15bb752e14be51fc3f56de8480", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742030" + }, + "expected": { + "outcome": "reject", + "reason": "property_disagrees" + } + }, + { + "name": "property_class_beside_zero", + "about": "a class beside a zero figure", + "procedure": "property", + "input": { + "property": { + "content_hash": "6e90ee47565591796c76e57de2b092695ba42d4e5dae585eb1503589e2b703d7", + "joules_micro": 0, + "acquisition": "ModelBased", + "acquisition_evidence": "", + "group_receipt": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5" + }, + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 3, + "content_hash": "6e90ee47565591796c76e57de2b092695ba42d4e5dae585eb1503589e2b703d7", + "joules_micro": 0, + "origin": "dc-3.example", + "merkle_proof": { + "siblings": [ + "65dbe4a732e01bd69de3104e03d9822107318551714d055c8a7acc45313796e8", + "81b35ae562bccadd9651fe74f2669b85dd2cfe5c60fc050119ceb3277483af12", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 3, + "leaf_index": 3 + }, + "leaf_version": 2 + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742033" + }, + "expected": { + "outcome": "reject", + "reason": "property_invalid" + } + }, + { + "name": "property_figure_without_class", + "about": "a non-zero figure with no class", + "procedure": "property", + "input": { + "property": { + "content_hash": "3cb85c360e2af8f1a586389d13f0415ca90ea8ab6fa5fcdf1b3204a2dd1a44c4", + "joules_micro": 500, + "acquisition": "", + "acquisition_evidence": "", + "group_receipt": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5" + }, + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 2, + "content_hash": "3cb85c360e2af8f1a586389d13f0415ca90ea8ab6fa5fcdf1b3204a2dd1a44c4", + "joules_micro": 500, + "origin": "dc-2.example", + "merkle_proof": { + "siblings": [ + "339ea521d795afe4a4ec26042a2827fc9e8052b581fdbb1636b2315ee9953e93", + "81b35ae562bccadd9651fe74f2669b85dd2cfe5c60fc050119ceb3277483af12", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 2, + "leaf_index": 2 + } + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742032" + }, + "expected": { + "outcome": "reject", + "reason": "property_invalid" + } + }, + { + "name": "property_legacy_leaf", + "about": "the object's leaf is legacy: the property's class is outside every signature, and the figure reads as unlabelled", + "procedure": "property", + "input": { + "property": { + "content_hash": "3cb85c360e2af8f1a586389d13f0415ca90ea8ab6fa5fcdf1b3204a2dd1a44c4", + "joules_micro": 500, + "acquisition": "ModelBased", + "acquisition_evidence": "", + "group_receipt": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5" + }, + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 2, + "content_hash": "3cb85c360e2af8f1a586389d13f0415ca90ea8ab6fa5fcdf1b3204a2dd1a44c4", + "joules_micro": 500, + "origin": "dc-2.example", + "merkle_proof": { + "siblings": [ + "339ea521d795afe4a4ec26042a2827fc9e8052b581fdbb1636b2315ee9953e93", + "81b35ae562bccadd9651fe74f2669b85dd2cfe5c60fc050119ceb3277483af12", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 2, + "leaf_index": 2 + } + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742032" + }, + "expected": { + "outcome": "read", + "reading": "unsigned" + } + }, + { + "name": "property_unmetered", + "about": "a zero figure and no class beside an object explicitly unmetered", + "procedure": "property", + "input": { + "property": { + "content_hash": "6e90ee47565591796c76e57de2b092695ba42d4e5dae585eb1503589e2b703d7", + "joules_micro": 0, + "acquisition": "", + "acquisition_evidence": "", + "group_receipt": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5" + }, + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 3, + "content_hash": "6e90ee47565591796c76e57de2b092695ba42d4e5dae585eb1503589e2b703d7", + "joules_micro": 0, + "origin": "dc-3.example", + "merkle_proof": { + "siblings": [ + "65dbe4a732e01bd69de3104e03d9822107318551714d055c8a7acc45313796e8", + "81b35ae562bccadd9651fe74f2669b85dd2cfe5c60fc050119ceb3277483af12", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 3, + "leaf_index": 3 + }, + "leaf_version": 2 + }, + "envelope": "57414931206a77702d636f6e666f726d616e6365206f626a6563742033" + }, + "expected": { + "outcome": "read", + "reading": "unmetered" + } + }, + { + "name": "property_object_does_not_verify", + "about": "the envelope does not hash to the object's content hash: nothing is read", + "procedure": "property", + "input": { + "property": { + "content_hash": "16d34babd9a027ad12b61e4d0aef877f560d87e8b4884a3a2b2d59cb54a4850d", + "joules_micro": 1200, + "acquisition": "OnChipCounter", + "acquisition_evidence": "00004e2000000000000f424000", + "group_receipt": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5" + }, + "group": { + "kind": "group", + "track_alias": 7, + "group_id": 3, + "root_hash": "62ceef6eff690aa495e0744b8f17dc530e69ed587f3de45fc3107992ee531f8d", + "leaf_count": 5, + "joules_total": 2800, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "83a54aac08476e4af1ccdf8b87db5f1685fe20e428a1ab008e596749d08bf877cb9968e1f92a6650c4f7390ff4f207fb38f3fe91cb6f3c25621991914f7e340a", + "parent_receipt_hash": null + }, + "object": { + "kind": "object", + "track_alias": 7, + "group_id": 3, + "object_id": 0, + "content_hash": "16d34babd9a027ad12b61e4d0aef877f560d87e8b4884a3a2b2d59cb54a4850d", + "joules_micro": 1200, + "origin": "dc-0.example", + "merkle_proof": { + "siblings": [ + "281de97b5d3b5f872298b30812bf65f6a14d2cb223c135d17034e19e20988c9f", + "59057bc02626a58b09500bf398bc79d53501ce15bb752e14be51fc3f56de8480", + "9d64da32bb5321c05b43826384e2006364e5b144e765f5e7fd2da262f46d295d" + ], + "direction_bits": 0, + "leaf_index": 0 + }, + "leaf_version": 2, + "energy_provenance": "OnChipCounter", + "energy_uncertainty": { + "relative_ppm": 20000, + "window_us": 1000000 + }, + "energy_counter_filtering": "Unknown" + }, + "envelope": "57414931206d757461746564" + }, + "expected": { + "outcome": "reject", + "step": 4, + "reason": "content_hash_mismatch" + } + }, + { + "name": "property_signed_by_energy_claim", + "about": "an energy claim about the legacy object restates the property's figure and class", + "procedure": "property", + "input": { + "property": { + "content_hash": "3cb85c360e2af8f1a586389d13f0415ca90ea8ab6fa5fcdf1b3204a2dd1a44c4", + "joules_micro": 500, + "acquisition": "ModelBased", + "acquisition_evidence": "", + "group_receipt": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5" + }, + "claim": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"grade\":\"derived\",\"joules_micro\":500,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"object\",\"work_units\":1},\"class\":\"energy\",\"issued_at\":\"2026-09-30T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"e1b90fc2ceb247ca2e5fcf52c8763421a77b468e451d5dc9bd27c4f71be5f78596426d63f3f1a176ffae2d65b2806da872c145360f05bcf0d7c9c46d755f7200\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"3cb85c360e2af8f1a586389d13f0415ca90ea8ab6fa5fcdf1b3204a2dd1a44c4\",\"subject_type\":\"object\"}" + }, + "expected": { + "outcome": "read", + "reading": "signed class=ModelBased by=a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0" + } + }, + { + "name": "property_signed_by_third_party_claim", + "about": "an energy claim signed by a relay's own key restates the property: the class reads as signed by that key, not by the publisher", + "procedure": "property", + "input": { + "property": { + "content_hash": "3cb85c360e2af8f1a586389d13f0415ca90ea8ab6fa5fcdf1b3204a2dd1a44c4", + "joules_micro": 500, + "acquisition": "ModelBased", + "acquisition_evidence": "", + "group_receipt": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5" + }, + "claim": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"grade\":\"derived\",\"joules_micro\":500,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"object\",\"work_units\":1},\"class\":\"energy\",\"issued_at\":\"2026-09-30T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"b07414055b7bf57c16593e11e49b0b0d8422844d134c5c7db7f1bcc0057b57d3e9c394c78cb19214e9ef4e8b320154394d9bc2f1b0323448a850dd7cecdbe501\",\"signer_id\":\"did:example:relay\",\"signer_pubkey\":\"17cb79fb2b4120f2b1ec65e4198d6e08b28e813feb01e4a400839b85e18080ce\",\"subject\":\"3cb85c360e2af8f1a586389d13f0415ca90ea8ab6fa5fcdf1b3204a2dd1a44c4\",\"subject_type\":\"object\"}" + }, + "expected": { + "outcome": "read", + "reading": "signed class=ModelBased by=17cb79fb2b4120f2b1ec65e4198d6e08b28e813feb01e4a400839b85e18080ce" + } + }, + { + "name": "property_energy_claim_disagrees", + "about": "the energy claim states another figure", + "procedure": "property", + "input": { + "property": { + "content_hash": "3cb85c360e2af8f1a586389d13f0415ca90ea8ab6fa5fcdf1b3204a2dd1a44c4", + "joules_micro": 500, + "acquisition": "ModelBased", + "acquisition_evidence": "", + "group_receipt": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5" + }, + "claim": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"grade\":\"derived\",\"joules_micro\":499,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"object\",\"work_units\":1},\"class\":\"energy\",\"issued_at\":\"2026-09-30T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"882d3954eb578a1eab413c195bf760f9d7c43d7892cfdebb2eedbe9800adf42fb2913fba89618819d6b2a3e4c101801f826d85e56f289e2a0199b8dfab67b108\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"3cb85c360e2af8f1a586389d13f0415ca90ea8ab6fa5fcdf1b3204a2dd1a44c4\",\"subject_type\":\"object\"}" + }, + "expected": { + "outcome": "reject", + "reason": "property_disagrees" + } + }, + { + "name": "property_energy_claim_altered", + "about": "an energy claim altered after signing is no signed statement: the property reads as unsigned", + "procedure": "property", + "input": { + "property": { + "content_hash": "3cb85c360e2af8f1a586389d13f0415ca90ea8ab6fa5fcdf1b3204a2dd1a44c4", + "joules_micro": 500, + "acquisition": "ModelBased", + "acquisition_evidence": "", + "group_receipt": "2e58ff648d74e0ad023c4efe65f69fba6103655f0be027c8cba5f4767548fea5" + }, + "claim": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"grade\":\"derived\",\"joules_micro\":501,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"object\",\"work_units\":1},\"class\":\"energy\",\"issued_at\":\"2026-09-30T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"e1b90fc2ceb247ca2e5fcf52c8763421a77b468e451d5dc9bd27c4f71be5f78596426d63f3f1a176ffae2d65b2806da872c145360f05bcf0d7c9c46d755f7200\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"3cb85c360e2af8f1a586389d13f0415ca90ea8ab6fa5fcdf1b3204a2dd1a44c4\",\"subject_type\":\"object\"}" + }, + "expected": { + "outcome": "read", + "reading": "unsigned" + } + } + ] +} diff --git a/wai/research/2026-09-global-sweep.md b/wai/research/2026-09-global-sweep.md index 5591bda55..2f39bdf60 100644 --- a/wai/research/2026-09-global-sweep.md +++ b/wai/research/2026-09-global-sweep.md @@ -689,7 +689,7 @@ review. | G3 | **SyfAI's AI update-framework thread, opened at MPEG 155 jointly with WG 7 (S ⚠), covers parameter formats, manifests, repositories, integrity checking and bit exactness for NN decoders. The artifact a WAI `Prior` names has no normative wire form.** `Prior { id, sha256, determinism }` pins identity: `sha256` hashes the decoder artifact the sink runs. No wire format for that artifact is specified (2030 §6.4). | `wai-rs/src/container.rs` `struct Prior`; 2030 landscape §6.4; no SyfAI or Exploration 52 mention in the tree (grep) | **Addressed at link 05 (PR #106), with limits.** [`prior-carriage.md`](../extensions/prior-carriage.md) §2: a `Prior` MAY declare `form`; when it does, the digest covers the coded bitstream, and a sink that cannot read the form MUST refuse the prior. §3 registers `wai.prior.nnc` (ISO/IEC 15938-17:2024) as the one form, optional and not recommended, because its royalty status and cross-decoder exactness are not established. `Prior::is_cross_hardware_exact` requires a declared form to be established exact, none is, so a `replicate` resting on a declared form is refused (§5). The reference sink reads no form (`READABLE_PRIOR_FORMS` is empty), and `wai-rs` has no NNC reader or writer. SPEC's opening section now says a model's identity MAY be pinned by digest (a `Prior`). [`syfai-input.md`](syfai-input.md), committed with this record, maps WAI onto the update framework; its G1 records what SPEC still does not define | | G4 | **WAI's `HwShunt` takes in IOReport, a reading the monorepo classes five ways; and neither WAI nor the family's receipt ladder separates platform-relayed counters from independently rooted instruments. Only `sandbox-tee` does.** Three layers. **(a) The family's receipt ladder.** Every standard that lists interfaces under `HwShunt` puts on-die counters and dedicated shunts in it together (JCR-1 §2.3, JCP §5.1, Sandbox §6.3, `mesh-schema`, `proof-schema`), and IOReport is classed inconsistently (§5.3). **(b) `sandbox-tee`'s `CounterClass`** reserves `HwShunt` for a dedicated shunt and gives RAPL, NVML, IOReport and Redfish classes of their own; its `AttestTrust` ladder is derived from evidence, and `efficiency/1` commits to the attestation (§5.1). **(c) WAI** has `HwShunt` cover "RAPL, NVML, SMC, IOReport, or an external shunt", and `wai_video_meter` stamps IOReport readings `HwShunt`. That places WAI with Joule Code's text and the Sandbox meter, and against JCR-1 §2.3. WAI's texts present the ladder as the family's own without noting the split: `video_receipt.rs` says it is "reused verbatim from `mesh-schema` / `proof-schema`", which list no IOReport; `energy_binding.rs` calls it "the family's ladder"; `energy-measurement.md` §2 says WAI reuses "the same three names the receipt families elsewhere in the ecosystem already use". At every layer but (b), a verifier cannot tell a platform-relayed counter, which may be deliberately filtered (§5.1), from an independently rooted instrument. | §5.3 (the per-standard survey); `wai/extensions/energy-measurement.md` §2; `wai-rs/src/energy_binding.rs`:30, :47–51 (`Acquisition`); `wai-rs/src/video_receipt.rs` `Provenance` doc comment; `wai-rs/src/bin/wai_video_meter.rs`:184–190; `jouleclaw/jouleclaw-rs/crates/jouleclaw-jcr1/docs/JCR-1-spec.md` §2.3; `jcp/SPEC.md` §5.1; `sandbox/SPEC.md` §6.3–§6.4; `mesh/mesh-rs/crates/mesh-schema/src/lib.rs` and `proof/proof-rs/crates/proof-schema/src/lib.rs` (`Provenance`); `sandbox/sandbox-rs/crates/sandbox-tee/src/energy.rs` (`AttestTrust`, `CounterClass`, `is_independently_rooted`); `jcp/jcp-rs/crates/jcp-efficiency/MAPPING.md` §4 and `src/training.rs` (`energy_attestation`). No mention of `sandbox-tee`, `CounterClass`, `AttestTrust`, `energy_attestation`, `PhysicallyRooted` or `IntegrityAttested` under `wai/` (grep) | **WAI's side closed at link 08 (PR #109); the family's side open.** energy-measurement §2 separates `CalibratedInstrument` (an external instrument with a current calibration) from `OnChipCounter` (a counter the measured silicon keeps, or any instrument whose uncertainty is not traceable). Each carries a declared uncertainty (§2.3); the counter carries its filtering state (§2.4), the instrument a calibration reference (§2.5). `HwShunt` is legacy, accepted and never emitted (§2.6), and IOReport is `OnChipCounter` (§2.9). `energy_class.rs` is the one definition, and the reference meters seal `OnChipCounter`. The "reused verbatim" sentence is gone; `video_receipt.rs` and `energy_binding.rs` still call the ladder the family's, and §2 says the three-name vocabulary is extended, not replaced, with both refinements mapping to `HwShunt`. §2.9's list of differing documents is partial (§5.3). No WAI text references `sandbox-tee`'s ladder (grep). The family's IOReport split and whether its receipt-level ladder should separate the two kinds of hardware evidence are unassigned (below) | | G5 | **The codec registry has no AV2, VVC, HEVC, LCEVC, MPEG-H Audio, AVS3 or Audio Vivid capability.** Brazil mandates three of these (§3.3). Two are Chinese standards: AVS3 (a group standard, with its national standard pending) and Audio Vivid (GB/T 46271-2025). 2030 §6.1 (*Honest scope*) records that WAI registers no VSEI-family codec. **The tree's codec survey is a second surface to reconcile.** `wai/glossary/codecs.json`, which `wai/README.md` describes as the "Reference catalog of media codecs surveyed for capability registration", has entries for HEVC / H.265 and VVC / H.266 (both `niche`), LCEVC (`niche`) and ECM (`academic`, JVET's exploration model beyond VVC), and none for AV2, MPEG-H Audio, AVS3 or Audio Vivid. `glossary/SCHEMA.md` defines `success` as "carries non-trivial production traffic today" and `niche` as "alive in specific contexts". §4 reports HEVC in production use at 65% in a vendor survey (**S** ⚠), §3.3 reports VVC as a national broadcast system's main codec (**R** ⚠), and the HEVC entry's own description calls it universal in broadcast UHD. The grades are descriptive and the contrary evidence is unverified, so this is a reconciliation item, not a correction. | capability strings in `wai-rs/src/codecs/*.rs`: none for these codecs. Other mentions, from a grep over `*.rs`, `*.md`, `*.json`, `*.toml`, `*.yml`, `*.yaml`, `*.html`, `*.ts` and `*.js` under `wai/`: the `glossary/codecs.json` entries above; the H.264/H.265 royalty sentence in `SPEC.md` and `CHANGELOG.md`; the BD-rate note that names VVC in `wai-rs/src/codecs/mod.rs` and `wai-rs/src/neural/mlicpp.rs`; `HEVC` as an example name in `glossary/SCHEMA.md`; the 2030 landscape. Royalty class per codec **not established by this sweep**: only AV2's royalty-free patent policy was seen at the issuing body (the AOMedia press release, §2.4) | **Closed in the registry at link 04 (PR #105); the glossary is not reconciled.** SPEC §5 *Broadcast and streaming codecs* registers `wai.video.{av2,vvc,hevc,avc,vp9,evc,evc.baseline,avs3,lcevc}` and `wai.audio.{mpegh,avs3,aac}`, each with one payload framing and a licence class whose basis is recorded in [`media-codec-registry.md`](media-codec-registry.md). `wai.audio.avs3` names AVS3 audio / Audio Vivid (GB/T 46271-2025). All are sink-supplied, none is a default, and `wai.video.lcevc` is companion-only. SPEC §7 classes AVC, HEVC, VVC and VP9 `StandardDefined` on the conformance clauses read, AV2 `StandardTolerance`, and EVC, AVS3, LCEVC, MPEG-H 3D Audio and AAC `StandardUnclassified`. The licence classes rest on the registry's recorded bases, not on this sweep. `glossary/codecs.json` is unchanged, so its grades still stand against §3.3 and §4 | -| G6 | **No WAI receipt says who can check the energy figure, and the base JWP receipt does not carry even the acquisition class that `energy-measurement.md` requires.** The precedents in §5.1 differ exactly on who can check: instrument-signed (OCMF), peer-reviewed but unsigned, log-registered (SCITT), TEE-attested with a derived trust level (the family's `sandbox-tee` `AttestTrust`), or producer-asserted. **The base JWP receipt** has no acquisition-class field; its group signature covers `total_joules_micro` and no class, and the per-object `joules_micro` is in neither the Merkle leaf nor the signature. `energy-measurement.md` §2 requires a class inside the signature for any non-zero figure, while its header says it adds "no new wire format". **The video receipt** signs its class. **The energy binding** carries its class in a C2PA companion assertion signed as part of whatever manifest carries it. **The OER receipt** carries an evidential grade and a ledger identity, and no class. 2030 §8 item 4 queues typed receipt classes. | `extensions/energy-measurement.md` header, §1, §2 and §5; `extensions/jwp-receipts.md` §2.1–§2.4; `joule-code/joule-code-rs/crates/joule-moq/src/receipt.rs` (`ObjectReceipt`, `GroupReceipt::signing_payload`: no class); `wai-rs/src/energy_binding.rs`, `wai-rs/src/video_receipt.rs`; `extensions/oer-receipts.md` §2–§3, `wai-rs/src/oer.rs`; no reference to the family ladder under `wai/` (grep, as in G4) | **Partly closed at link 10 (PR #112); the rest open.** Every receipt within energy-measurement's scope that `wai-rs` and `wai-quantum` seal can carry the class (link 08, PR #109). jwp-receipts §2.4.3–§2.4.7 add a `claim` kind with typed classes. An `energy` claim (§2.4.5) restates a JWP object's figure beside its class, the class's evidence, a coverage statement and a grade; a verifier holding the object receipt rejects the pair if the figures differ, and one holding the OER receipt the claim names checks that grade and figure agree. `claim_receipt.rs` (feature `receipt_claims`) implements it, with vectors in `claim-conformance/`. Open, as energy-measurement §1 records: the JWP object receipt has no member of its own for the class; the group total has none, and nothing carries one for it; the `WAI_RECEIPT` property's class and evidence sit outside any signature; an OER/2 receipt has no member for the class. On who can check: a claim carries its signer's authority and no more (§2.4.4); key discovery and an authority class for who may sign which class are not done (CHANGELOG, v0.4.0 entry for typed receipt classes); no WAI receipt references `sandbox-tee`'s ladder or `efficiency/1`'s attestation (grep) | +| G6 | **No WAI receipt says who can check the energy figure, and the base JWP receipt does not carry even the acquisition class that `energy-measurement.md` requires.** The precedents in §5.1 differ exactly on who can check: instrument-signed (OCMF), peer-reviewed but unsigned, log-registered (SCITT), TEE-attested with a derived trust level (the family's `sandbox-tee` `AttestTrust`), or producer-asserted. **The base JWP receipt** has no acquisition-class field; its group signature covers `total_joules_micro` and no class, and the per-object `joules_micro` is in neither the Merkle leaf nor the signature. `energy-measurement.md` §2 requires a class inside the signature for any non-zero figure, while its header says it adds "no new wire format". **The video receipt** signs its class. **The energy binding** carries its class in a C2PA companion assertion signed as part of whatever manifest carries it. **The OER receipt** carries an evidential grade and a ledger identity, and no class. 2030 §8 item 4 queues typed receipt classes. | `extensions/energy-measurement.md` header, §1, §2 and §5; `extensions/jwp-receipts.md` §2.1–§2.4; `joule-code/joule-code-rs/crates/joule-moq/src/receipt.rs` (`ObjectReceipt`, `GroupReceipt::signing_payload`: no class); `wai-rs/src/energy_binding.rs`, `wai-rs/src/video_receipt.rs`; `extensions/oer-receipts.md` §2–§3, `wai-rs/src/oer.rs`; no reference to the family ladder under `wai/` (grep, as in G4) | **Partly closed at link 10 (PR #112); the rest open.** Every receipt within energy-measurement's scope that `wai-rs` and `wai-quantum` seal can carry the class (link 08, PR #109). jwp-receipts §2.4.3–§2.4.7 add a `claim` kind with typed classes. An `energy` claim (§2.4.5) restates a JWP object's figure beside its class, the class's evidence, a coverage statement and a grade; a verifier holding the object receipt rejects the pair if the figures differ, and one holding the OER receipt the claim names checks that grade and figure agree. `claim_receipt.rs` (feature `receipt_claims`) implements it, with vectors in `claim-conformance/`. Open, as energy-measurement §1 records: the JWP object receipt has no member of its own for the class; the group total has none, and nothing carries one for it; the `WAI_RECEIPT` property's class and evidence sit outside any signature; an OER/2 receipt has no member for the class. On who can check: a claim carries its signer's authority and no more (§2.4.4); key discovery and an authority class for who may sign which class are not done (CHANGELOG, v0.4.0 entry for typed receipt classes); no WAI receipt references `sandbox-tee`'s ladder or `efficiency/1`'s attestation (grep) *Status after the v0.4.0 change "Energy reports, labelled object leaves, and what the signatures cover":* the object's class rides in its labelled leaf, a group total can carry a signed class of its own, and a `WAI_RECEIPT` property's class counts only where a signed statement restates it; joule-moq verifies both labelled forms. Who can check remains open. | | G7 | **Explicit parameter transmission appears in `determinism-tiers.md` only as a generic route. Its first published cross-NPU instance (arXiv 2606.28027) and its cost are not recorded.** Also unrecorded: the mixed-precision entropy-synchronisation line (arXiv 2608.00483: FP16/FP32 across GPUs, no bit-exactness claim); the quantised learned-coding line (arXiv 2007.04684, 2111.09348); and IEEE 1857.11's use of *reproducibility cross-checks* rather than bit-exact decode (**R** ⚠), a reproducibility notion the tier vocabulary does not name. Both 2606.28027 and 2608.00483 target a synchronised entropy stage, so at most `entropy-consistency`, and 2608.00483 only over the GPUs it tested. On `main`, whether such a decode may back `replicate` depends on which of G10's conditions is read. | `extensions/determinism-tiers.md` §1–§3; `SPEC.md` §7 (read) | **Partly closed at link 02 (PR #103).** determinism-tiers §1 names arXiv 2606.28027 as the explicit-parameter instance and says the side information costs bitrate (no magnitude), and names a quantised decoder, arXiv 2312.11209, as the route to a reproducible reconstruction. Such a decode reaches `entropy-consistency` and does not back `replicate` (G10). Not in the tree at link 10 (grep): arXiv 2608.00483, the quantised line 2007.04684 / 2111.09348, and IEEE 1857.11's reproducibility cross-checks; the last is described in [`syfai-input.md`](syfai-input.md) §5, committed with this record | | G8 | **Standards inputs not tracked in the tree:** SyfAI; 3GPP TR 26.942; DVB S100 and CM-EE; the beyond-VVC CfP; Gaussian Splat Coding Exploration 45; IEEE 1857.11 / P1857.11.1; UWA 042; India D2M; the JPEG AI core experiments on energy consumption and bit-exact reconstruction. | a case-insensitive, word-bounded `git grep -P` over tracked `*.md`, `*.rs`, `*.json`, `*.toml`, `*.yml`, `*.yaml`, `*.html`, `*.ts` and `*.js` files finds none of these designations outside this record. The one other hit for `uwa` is the Hausa word in `aac/aac-rs/crates/aac-core/src/translations_data.json` (`"ha":"uwa"`), which is not the organisation. MPEG's splat work *is* tracked, though not Exploration 45 by name: `media-codec-registry.md` and `codecs/mod.rs` register `wai.splat.sog` against "MPEG WG4" work in progress, `3d-neural-coding-landscape.md` records splat coding under standardisation, and 2030 §6.4 records an NNC amendment covering Gaussian splats (G11) | **Partly closed by the two notes committed with this record.** [`syfai-input.md`](syfai-input.md) tracks SyfAI, IEEE 1857.11 and UWA 042; [`energy-exposure-mapping.md`](energy-exposure-mapping.md) tracks TR 26.942, DVB S100 and CM-EE. The same grep over the tree at link 10 finds the beyond-VVC CfP, Exploration 45, India D2M and the JPEG AI core experiments in no document but this record. Those four stay open | | G9 | **WAI's texts and code class `wai.image.jpegai` in ways that contradict one another, and SPEC contradicts itself on it.** SPEC §5, the capability's registration (`SPEC.md`:197), describes the decode as "integer entropy front end (mANS) + integer dequant/value path + fixed-order synthesis and post-filters" and declares "**decode-equivalence**: one pinned reconstruction … reproducible on every machine (a portable decoded-pixel hash)". `reconstruction-binding.md` §1 (:30–31) says the decode to RGB is "byte-identical on every machine", and §2 (:72) sets the binding's criterion at `decode-equivalence`; `determinism-tiers.md` §2–§3 list the capability there. SPEC §7 lets a neural capability back `replicate` only "with no float operation anywhere" (:445–448). `capability_replicate_class` returns `StandardDefined` for it through the default arm, and SPEC §7 defines that class as "reconstruction is fixed by the named codec/standard … the neural float hazard does not apply"; neither half holds for JPEG AI (§2.2; its decode runs the model). The strict `replicate` check reads only the envelope's prior (`neural/mod.rs`:282–292). **Loose ends:** (i) `jpegai_synthesis.rs` and `jpegai_mans.rs` say they back `wai.neural.jpeg_ai`, a string registered nowhere; (ii) the decode has f32 steps between its integer stages — its dequantisation among them, although SPEC §5 calls it integer; (iii) the byte-for-byte claim is made normatively, in SPEC §5 and `reconstruction-binding.md` §1, and rests on an `exp()` from the platform maths library and on no measurement on a second platform; (iv) the reference sink's dispatch (`neural/mod.rs`:205–216) uses the exact decoder only when the integer weight bundle is present, and otherwise falls back to a float ONNX Runtime decode. | `SPEC.md` §5 (:197) and §7 (:445–448, the `StandardDefined` bullet at :476–478, :492–495); `extensions/reconstruction-binding.md` §1 (:21–35) and §2 (:72); `extensions/determinism-tiers.md` §2–§3; `wai-rs/src/codecs/mod.rs` (the default arm and the test asserting `StandardDefined`); `wai-rs/src/jpegai_decode.rs`, `jpegai_synthesis.rs`, `jpegai_mans.rs`, `jpegai_pipeline.rs`; `wai-rs/src/neural/mod.rs`:205–216, :282–292; `git grep -n decode-equivalence` over `wai/` | **Closed at link 02 (PR #103), with the maths library removed at link 03 (PR #104).** SPEC §5's row registers `entropy-consistency` (registry class `NeuralEntropyExact`), describes dequantisation, latent refinement, post-filters and colour conversion as IEEE-754 float with a fixed evaluation order, and says "**Downgraded in v0.4.0:** this row previously claimed decode-equivalence". `reconstruction-binding.md` §1–§2 make the pinned reconstruction a property of the exact decoder, reproducible under that decoder's build and floating-point conditions. SPEC §7 states the new class and the reason. Loose ends: (i) fixed — the module docs name `wai.image.jpegai`, and `wai.neural.jpeg_ai` appears nowhere else in the tree (grep); (ii) the float steps remain and are now stated as float; (iii) link 03 builds `exp` and rounding from IEEE-754 basic operations (`float_repro`), guards the decode path with clippy's `disallowed_methods` and a linked-symbol check (`tests/jpegai_decode_symbols.rs`), and pins nine hashes for one codestream; determinism-tiers §6 records them identical on macOS arm64, wasm32-wasip2, Linux arm64 and Linux x86_64 under user-mode emulation, and not measured on Windows, macOS x86_64 or x86_64 hardware; (iv) the float fallback remains, and SPEC §5, determinism-tiers §5 and reconstruction-binding §1 now state it, and that the result does not record which path ran. Still true: a `norm.json` with a missing or mistyped key panics in the loaders instead of falling back, as the `try_jpegai_exact` doc comment says | @@ -730,6 +730,14 @@ review. - **The open parts of G6.** A signed place for a JWP group total's class, a member of its own for the JWP object receipt, a signature over `WAI_RECEIPT`, and key discovery for claim signers. + *Status after the v0.4.0 change "Energy reports, labelled object leaves, and + what the signatures cover":* the JWP object receipt carries its class in its + Merkle leaf with its figure and origin (jwp-receipts §2.2, §2.4.2); a group + total can carry its own class under a labelled group payload that also signs + the parent link (§2.3, §2.4.1); a `WAI_RECEIPT` property's class reads as + signed only where it equals the leaf's label or an `energy` claim + (moq-streaming-format §5). Open: key discovery for claim signers, an OER/2 + receipt's class, and who, other than the signer, stands behind a figure. - **G7's other lines** (arXiv 2608.00483, the quantised learned-coding line, IEEE 1857.11's reproducibility cross-checks) and **G8's other inputs** (the beyond-VVC CfP, Exploration 45, India D2M, the JPEG AI core experiments) are diff --git a/wai/research/energy-exposure-mapping.md b/wai/research/energy-exposure-mapping.md index 9d9ada74b..3fa57ffa3 100644 --- a/wai/research/energy-exposure-mapping.md +++ b/wai/research/energy-exposure-mapping.md @@ -465,6 +465,12 @@ term") read as if the joules were the only such term. That is true only if the measurement fields are counted as part of the joule figure. An input should use the wider wording, and those two sections should be reworded to match. +*Status after the v0.4.0 change "Energy reports, labelled object leaves, and what the signatures cover":* energy-measurement §5 and energy-binding §3 +now use the wider wording: what stays unverifiable is the measurement — the joules +and every field that describes how they were obtained — and a report's values can +be checked for consistency with the figure, which is not truth. The code docs that +called `joules_micro` the only attested field say the same. + **L9: The reporting contract has no receipt fields.** energy-measurement §6 requires a published figure to be accompanied by the idle mean and SD, busy mean and SD, sample counts, window, completion count, thread count, how the @@ -477,6 +483,17 @@ meters print the idle and busy figures, the marginal-to-idle ratio and the resolution term to their console. So the record DVB use case 1 would need (§2) is a requirement in the text, not yet a format any receipt carries. +*Status after the v0.4.0 change "Energy reports, labelled object leaves, and what the signatures cover":* closed for figures sealed with a class. A +`measurement` claim (jwp-receipts §2.4.8) carries the bracket's values as integers, +each bracket's liveness facts, the build (profile, toolchain, target) and the power +reader, names the receipt that seals the figure by a figure profile +(energy-measurement §6.1), and is signed by the figure's own key. Verifiers check +that the figure and the declared uncertainty are what the integers give +(energy-measurement §6.2); the reference meters write one by default. Figures +sealed with no class, `world_model::frame_receipt` and the `wai-web` demos are +recorded as falling short (energy-measurement §6), and the energy binding's report +carriage is not specified. + **L10: The operating-point fields exist only as text.** energy-binding §7.2 and §7.3 specify `op_requested`, `op_committed`, `op_delivered` and the residual between a declared complexity descriptor and the measured energy. No receipt or @@ -485,6 +502,12 @@ carries the reconstruction hash, the microjoules, the acquisition class and its evidence, coverage, work units and `method`. §2 use case 3 and §4 describe what the text adds, not what a receipt carries today. +*Status after the v0.4.0 change "Energy reports, labelled object leaves, and what the signatures cover":* an `operating-point` claim (jwp-receipts +§2.4.9) carries `op_requested`, `op_committed`, `op_delivered` and a prediction for +one figure, from a vocabulary named by URI. Whether the commitment was met and the +residual are derived by every reader, never stated. No operating point is recorded +against an unmetered or legacy `HwShunt` figure (energy-binding §7.2). + **Unverified near-prior-art.** Search snippets seen during this work pointed to public repositories describing signed receipts for AI inference with NVML-measured joules. Their bodies were not read, so they are neither named nor diff --git a/wai/wai-quantum-serve/src/lib.rs b/wai/wai-quantum-serve/src/lib.rs index 9775f0476..74dfa3bdd 100644 --- a/wai/wai-quantum-serve/src/lib.rs +++ b/wai/wai-quantum-serve/src/lib.rs @@ -43,12 +43,21 @@ struct Component; /// (`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. +/// +/// A receipt's `joules_micro` of `0` is its unmetered marker, never a figure of +/// zero joules (energy-measurement §2), so the verdict renders it as +/// `"joules_micro":null` with `"metered":false`: a client totalling or comparing +/// the field cannot read an unmetered run as free work. 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 (joules, metered) = match r.joules_micro { + 0 => ("null".to_string(), false), + j => (j.to_string(), true), + }; 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(","), @@ -57,14 +66,13 @@ fn verify_response(src: &str) -> (u16, String) { if ok { 200 } else { 409 }, format!( "{{\"valid\":{},\"circuit_hash\":\"{}\",\"statevector_hash\":\"{}\",\ - \"n_qubits\":{},\"n_ops\":{},\"work_amp_updates\":{},\"joules_micro\":{},\"signer_id\":\"{}\",{class}}}", + \"n_qubits\":{},\"n_ops\":{},\"work_amp_updates\":{},\"joules_micro\":{joules},\"metered\":{metered},\"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) ), ) @@ -348,4 +356,18 @@ mod tests { assert_eq!(verify_response(&j.replace("288830", "288831")).0, 409); assert_eq!(verify_response("not a receipt").0, 400); } + + /// An unmetered receipt's `0` is the unmetered marker: the verdict never + /// presents it as a figure of zero joules. + #[test] + fn an_unmetered_receipt_is_never_reported_as_zero_joules() { + let key = SigningKey::from_bytes(&[0x71u8; 32]); + let r0 = QuantumReceipt::seal(&key, "did:key:wai-quantum-cli", &ghz3(), None, 0, None).unwrap(); + let (_, body) = verify_response(&r0.to_json()); + assert!(body.contains("\"joules_micro\":null,\"metered\":false"), "{body}"); + assert!(!body.contains("\"joules_micro\":0"), "{body}"); + let r1 = QuantumReceipt::seal(&key, "did:web:meter", &ghz3(), None, 7, None).unwrap(); + let (_, body) = verify_response(&r1.to_json()); + assert!(body.contains("\"joules_micro\":7,\"metered\":true"), "{body}"); + } } diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index b5925553b..4115bba12 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -474,7 +474,7 @@ required-features = ["world"] [[bin]] name = "wai_world_meter" path = "src/bin/wai_world_meter.rs" -required-features = ["world"] +required-features = ["world", "receipt_claims"] [[bin]] name = "wai_audio_vectors" @@ -520,6 +520,11 @@ name = "wai_claim_vectors" path = "src/bin/wai_claim_vectors.rs" required-features = ["receipt_claims"] +[[bin]] +name = "wai_jwp_vectors" +path = "src/bin/wai_jwp_vectors.rs" +required-features = ["receipt_claims"] + [[bin]] name = "wai_quantum_vectors" path = "src/bin/wai_quantum_vectors.rs" @@ -533,22 +538,22 @@ required-features = ["quantum_ops"] [[bin]] name = "wai_quantum_meter" path = "src/bin/wai_quantum_meter.rs" -required-features = ["quantum_receipt"] +required-features = ["quantum_receipt", "receipt_claims"] [[bin]] name = "wai_video_meter" path = "src/bin/wai_video_meter.rs" -required-features = ["video_receipt"] +required-features = ["video_receipt", "receipt_claims"] [[bin]] name = "wai_meter" path = "src/bin/wai_meter.rs" -required-features = ["haptic", "splat4d", "score", "film"] +required-features = ["haptic", "splat4d", "score", "film", "receipt_claims"] [[bin]] name = "wai_prov" path = "src/bin/wai_prov.rs" -required-features = ["provenance", "codecs"] +required-features = ["provenance", "codecs", "receipt_claims"] [[bin]] name = "wai_bench" diff --git a/wai/wai-rs/build.rs b/wai/wai-rs/build.rs index 2ea5f8896..7c4b6fe51 100644 --- a/wai/wai-rs/build.rs +++ b/wai/wai-rs/build.rs @@ -10,8 +10,12 @@ //! //! This replaces the macOS-hardcoded `.cargo/config.toml` rustflags. +#[path = "src/build_info.rs"] +mod build_info; + fn main() { generate_c_header(); + record_build(); // Only the `codecs` feature compiles flac-bound — and pkg-config's // probe() emits a `cargo:rustc-link-lib=flac` of its own, so running // it unconditionally makes every consumer (e.g. the wasm32 world @@ -21,6 +25,33 @@ fn main() { } } +/// Record the build for a measurement report (`extensions/energy-measurement.md` +/// §5, §8): the profile, from cargo's `PROFILE` and `OPT_LEVEL` +/// (`src/build_info.rs`), the target triple, and the compiler's version line. +/// A part that cannot be determined is left unset and reads as `null`. +fn record_build() { + println!("cargo:rerun-if-env-changed=PROFILE"); + println!("cargo:rerun-if-env-changed=OPT_LEVEL"); + println!("cargo:rerun-if-env-changed=TARGET"); + println!("cargo:rerun-if-env-changed=RUSTC"); + let var = |k: &str| std::env::var(k).unwrap_or_default(); + println!( + "cargo:rustc-env=WAI_BUILD_PROFILE={}", + build_info::profile_label(&var("PROFILE"), &var("OPT_LEVEL")) + ); + let target = var("TARGET"); + if !target.is_empty() { + println!("cargo:rustc-env=WAI_BUILD_TARGET={target}"); + } + let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()); + if let Ok(out) = std::process::Command::new(rustc).arg("-V").output() { + let line = String::from_utf8_lossy(&out.stdout).trim().to_owned(); + if out.status.success() && !line.is_empty() && !line.contains('\n') { + println!("cargo:rustc-env=WAI_BUILD_TOOLCHAIN={line}"); + } + } +} + /// Regenerate `include/wai.h` from the FFI surface so SDK consumers can /// always grab a typed header that matches the shipped binary. Only /// regenerates when source files actually change (cargo handles this diff --git a/wai/wai-rs/src/bin/wai_claim_vectors.rs b/wai/wai-rs/src/bin/wai_claim_vectors.rs index 56b4fb078..4e91ea06c 100644 --- a/wai/wai-rs/src/bin/wai_claim_vectors.rs +++ b/wai/wai-rs/src/bin/wai_claim_vectors.rs @@ -1,5 +1,5 @@ //! Conformance corpus for typed receipt classes and the soft binding class -//! (`claim-conformance/`, extensions/jwp-receipts.md §2.4.3–§2.4.7, §2.6, §3.1–§3.2). +//! (`claim-conformance/`, extensions/jwp-receipts.md §2.4.3–§2.4.9, §2.6, §3.1–§3.2). //! //! Every vector declares one outcome: `accept` (with the claim's link hash, which pins //! the canonical form; or, for a recovery, the ancestor it attests), or `reject` at a @@ -16,11 +16,13 @@ //! `claim-conformance/differential.py` //! //! The generator and this verifier are one implementation, so the corpus alone cannot -//! show that this one reads the specification correctly. Three things are checked +//! show that this one reads the specification correctly. Four things are checked //! against code that shares nothing with it: the canonical fixture (bytes, signature and -//! link hash computed by `claim-conformance/claim_verify.py`), and — run by hand, not in -//! CI — that script's own verification of every vector, and `differential.py`, which -//! compares the two verifiers' verdicts on generated inputs the corpus does not hold. +//! link hash computed by `claim-conformance/claim_verify.py`); the bracket arithmetic of +//! energy-measurement §6.2 (figures and least declarations that script computes with +//! Python's integers); and — in CI, on the linux runner — that script's own verification +//! of every vector, and `differential.py`, which compares the two verifiers' verdicts on +//! generated inputs the corpus does not hold. use std::path::Path; use wai::claim_receipt::{check_canonical_form, conformance_vectors, corpus_verdicts, corpus_vector_count, run_corpus}; @@ -80,6 +82,14 @@ fn verify(dir: &Path) -> i32 { ); fail += (!cf.is_empty()) as usize; + let ba = wai::energy_report::check_bracket_arithmetic(); + println!( + "{} bracket arithmetic pinned to its independently computed values got={}", + if ba.is_empty() { "PASS" } else { "FAIL" }, + ba.join(",") + ); + fail += (!ba.is_empty()) as usize; + let path = dir.join("vectors.json"); // `.gitattributes` pins LF; a checkout that ignores it (a CRLF working tree) changes // only the pretty-print line breaks, never a receipt, so it is undone rather than diff --git a/wai/wai-rs/src/bin/wai_jwp_vectors.rs b/wai/wai-rs/src/bin/wai_jwp_vectors.rs new file mode 100644 index 000000000..92dc40e10 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_jwp_vectors.rs @@ -0,0 +1,98 @@ +//! Conformance corpus for labelled leaves, labelled group receipts, the group +//! total and the `WAI_RECEIPT` property (`jwp-conformance/`, extensions/jwp-receipts.md +//! §2.2–§2.4.2, §3, §3.3 and §5.7, moq-streaming-format §5). +//! +//! Every vector declares one outcome: a leaf hash; `accept` (for a group, with its +//! link hash and signing payload); a reading of a total or a property; or a +//! rejection with its reason, and its §3 step where it has one. +//! +//! wai_jwp_vectors gen write vectors.json +//! wai_jwp_vectors verify the pinned file regenerates byte-identically, +//! and every vector gets its declared outcome +//! wai_jwp_vectors verdicts one `nameverdict` line per vector, +//! any declared outcome ignored +//! +//! `jwp-conformance/jwp_verify.py`, written from the extension text and sharing no +//! code with this crate, reports the same outcomes; CI runs it. + +use std::path::Path; +use wai::jwp_delivery::corpus::{generate, run, vector_count, verdict}; + +fn main() { + let a: Vec = std::env::args().collect(); + match (a.get(1).map(String::as_str), a.get(2)) { + (Some("gen"), Some(d)) => { + let p = Path::new(d).join("vectors.json"); + if let Err(e) = std::fs::create_dir_all(d).and_then(|_| std::fs::write(&p, generate())) { + eprintln!("write {}: {e}", p.display()); + std::process::exit(1); + } + println!("wrote {}", p.display()); + } + (Some("verify"), Some(d)) => std::process::exit(verify(Path::new(d))), + (Some("verdicts"), Some(f)) => std::process::exit(verdicts(Path::new(f))), + _ => { + eprintln!("usage: wai_jwp_vectors gen|verify | verdicts "); + std::process::exit(2); + } + } +} + +fn verdicts(file: &Path) -> i32 { + let doc = match std::fs::read_to_string(file) { + Ok(s) => s, + Err(e) => { + eprintln!("read {}: {e}", file.display()); + return 1; + } + }; + let Ok(d) = serde_json::from_str::(&doc) else { + eprintln!("{}: not JSON", file.display()); + return 1; + }; + for v in d.get("vectors").and_then(|v| v.as_array()).into_iter().flatten() { + let name = v.get("name").and_then(|n| n.as_str()).unwrap_or("?"); + println!("{name}\t{}", verdict(v).unwrap_or_else(|_| "error".into())); + } + 0 +} + +fn verify(dir: &Path) -> i32 { + let mut fail = 0usize; + let path = dir.join("vectors.json"); + // `.gitattributes` pins LF; a CRLF working tree changes only line breaks. + let pinned = match std::fs::read_to_string(&path) { + Ok(s) => s.replace("\r\n", "\n"), + Err(e) => { + println!("FAIL {}: {e}", path.display()); + return 1; + } + }; + let same = pinned == generate(); + println!("{} pinned corpus regenerates byte-identically", if same { "PASS" } else { "FAIL" }); + fail += (!same) as usize; + match run(&pinned) { + Ok(results) => { + if results.len() != vector_count() { + println!("FAIL {}: {} vectors, expected {}", path.display(), results.len(), vector_count()); + fail += 1; + } + for (name, want, got) in results { + let ok = want == got; + println!("{} {name} expected={want} got={got}", if ok { "PASS" } else { "FAIL" }); + fail += (!ok) as usize; + } + } + Err(e) => { + println!("FAIL {}: {e}", path.display()); + fail += 1; + } + } + if fail == 0 { + println!("all vectors pass"); + 0 + } else { + println!("{fail} failure(s)"); + 1 + } +} diff --git a/wai/wai-rs/src/bin/wai_meter.rs b/wai/wai-rs/src/bin/wai_meter.rs index f4955166c..6db350269 100644 --- a/wai/wai-rs/src/bin/wai_meter.rs +++ b/wai/wai-rs/src/bin/wai_meter.rs @@ -6,7 +6,8 @@ //! idle baseline, and emits a signed receipt carrying that **measured** //! energy — a portable receipt with real joules, end to end. //! -//! wai_meter [receipt_out.json] +//! wai_meter +//! [receipt_out.json [report_out.json]] [--no-report] [--previous-report ] //! //! World classes (worlds / state-feeds / avatars) are metered by //! `wai_world_meter`. A linear film embeds a sealed world but reconstructs a @@ -17,12 +18,22 @@ //! `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). +//! counter); without `macmon`, or when the reader's rail was not live (§3.1), +//! or the §4 ratio or the §2.3 uncertainty cannot support a figure, the receipt +//! is sealed UNMETERED: `joules_micro` carries the unmetered marker `0`, with no +//! class, never a figure of zero joules. +//! +//! Beside a measured figure it writes a measurement report (energy-measurement +//! §6): a `measurement` claim, signed with the receipt's key, carrying the +//! bracket's values, the build and the power reader — beside the receipt +//! (`.report.json`), at the path given, or to standard output when no +//! receipt path is given. `--no-report` turns it off, and says so. use ed25519_dalek::SigningKey; -use wai::energy_burst::measure_with_macmon; +use wai::energy_burst::{measure_with_macmon, power_reader}; use wai::energy_class::{CounterFiltering, EnergyProvenance}; +use wai::energy_figure::{meter_report, EnergyFigures}; +use wai::energy_report::BracketValues; use wai::film::{Film, Reel}; use wai::haptic::{HapticReceipt, HapticSignal}; use wai::score::{Score, ScoreReceipt}; @@ -32,11 +43,16 @@ const SIGNER_SEED: u8 = 0x6d; // 'm' for meter const SIGNER_ID: &str = "did:web:wai.transaction.science#wai_meter"; fn main() { - let args: Vec = std::env::args().collect(); + let report_args = meter_report::parse_args_or_exit(&std::env::args().collect::>()); + let args = &report_args.positional; let Some(path) = args.get(1) else { - eprintln!("usage: wai_meter [receipt_out.json]"); + eprintln!( + "usage: wai_meter \ + [receipt_out.json [report_out.json]] [--no-report] [--previous-report ]" + ); std::process::exit(2); }; + let out = Out { args: &report_args }; let bytes = std::fs::read(path).expect("read file"); let signer = SigningKey::from_bytes(&[SIGNER_SEED; 32]); @@ -44,85 +60,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 (uj, class) = meter("reconstruct", &sig, work, "samples×ch", |s| { + let (uj, class, values) = meter("reconstruct", &sig, work, "samples×ch", |s| { for c in 0..s.channels.len() { std::hint::black_box(s.reconstruct_channel(c)); } }); - if let Some(out) = args.get(2) { + { 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"); - emit(out, &r.to_json(), r.energy_provenance.is_some(), work); + out.emit(&r, &r.to_json(), r.energy_provenance.is_some(), work, values, &signer); } } 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 (uj, class) = meter("reconstruct", &clip, work, "frames×gaussians", |c| { + let (uj, class, values) = meter("reconstruct", &clip, work, "frames×gaussians", |c| { for f in 0..c.contract.n_frames { std::hint::black_box(c.frame_hash(f)); } }); - if let Some(out) = args.get(2) { + { 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"); - emit(out, &r.to_json(), r.energy_provenance.is_some(), work); + out.emit(&r, &r.to_json(), r.energy_provenance.is_some(), work, values, &signer); } } 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 (uj, class) = meter("reconstruct", &score, work, "samples×notes", |s| { + let (uj, class, values) = meter("reconstruct", &score, work, "samples×notes", |s| { std::hint::black_box(s.mixdown()); }); - if let Some(out) = args.get(2) { + { 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"); - emit(out, &r.to_json(), r.energy_provenance.is_some(), work); + out.emit(&r, &r.to_json(), r.energy_provenance.is_some(), work, values, &signer); } } 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 (uj, class) = meter("reconstruct", &film, work, "frames", |f| { + let (uj, class, values) = meter("reconstruct", &film, work, "frames", |f| { std::hint::black_box(f.frame_hashes().expect("frame hashes")); }); - if let Some(out) = args.get(2) { + { 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"); - emit(out, &r.to_json(), r.energy_provenance.is_some(), work); + out.emit(&r, &r.to_json(), r.energy_provenance.is_some(), work, values, &signer); } } 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 (uj, class) = meter("reconstruct", &reel, work, "frames", |r| { + let (uj, class, values) = meter("reconstruct", &reel, work, "frames", |r| { std::hint::black_box(r.frame_hashes().expect("frame hashes")); }); - if let Some(out) = args.get(2) { + { 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"); - emit(out, &r.to_json(), r.energy_provenance.is_some(), work); + out.emit(&r, &r.to_json(), r.energy_provenance.is_some(), work, values, &signer); } } else { eprintln!("unrecognized file (want .whap, .w4ds, .wscr, .wflm, or .wrel); world classes → wai_world_meter"); @@ -132,19 +148,21 @@ fn main() { /// 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) +/// for a usable on-chip figure, `(0, None)` — the unmetered marker — otherwise; +/// and the bracket's values for the measurement report. +fn meter(verb: &str, item: &T, work: u64, unit: &str, one: W) -> (u64, Option, Option) where T: Clone + Send + 'static, W: Fn(&T) + Send + Sync + 'static, { let Some(b) = measure_with_macmon(verb, item, one) else { eprintln!( - "macmon unavailable or too few samples; sealing UNMETERED (joules_micro = 0). \ + "macmon unavailable or too few samples; sealing UNMETERED (joules_micro carries the unmetered marker 0). \ Install `brew install macmon` (the non-sudo IOReport reader) for a measured figure." ); - return (0, None); + return (0, None, None); }; + let values = b.values(); println!("== wai_meter — measured CPU energy (IOReport via macmon, no sudo) =="); print!("{}", b.report(verb)); if let Some(per) = b.per_unit_uj() { @@ -154,7 +172,7 @@ where match b.on_chip_figure(CounterFiltering::Unknown) { Some((uj, class)) => { println!("joules_micro (receipt, MEASURED): {uj}"); - (uj, Some(class)) + (uj, Some(class), values) } None => { eprintln!( @@ -163,13 +181,35 @@ where b.ratio(), b.unsealed_reason().unwrap_or("refused") ); - (0, None) + (0, None, values) } } } -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})"); +/// Where a sealed receipt and its report go. +struct Out<'a> { + args: &'a meter_report::ReportArgs, +} + +impl Out<'_> { + /// Write the receipt when a path was given, and its report by default. + fn emit(&self, r: &R, json: &str, measured: bool, work: u64, values: Option, signer: &SigningKey) { + let receipt_out = self.args.positional.get(2).map(String::as_str); + if let Some(out) = receipt_out { + 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})"); + } + meter_report::write( + self.args, + r, + None, + values, + power_reader(), + signer, + SIGNER_ID, + receipt_out, + self.args.positional.get(3).map(String::as_str), + ); + } } diff --git a/wai/wai-rs/src/bin/wai_prov.rs b/wai/wai-rs/src/bin/wai_prov.rs index 04948c795..83e99d05e 100644 --- a/wai/wai-rs/src/bin/wai_prov.rs +++ b/wai/wai-rs/src/bin/wai_prov.rs @@ -4,13 +4,20 @@ //! 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), 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). +//! declared uncertainty. An encode whose measurement the reader's liveness +//! (§3.1), the §4 ratio or the §2.3 uncertainty cannot support is recorded +//! unmetered: the step's `joules_micro` carries the unmetered marker `0`, with +//! no label, never a figure of zero joules. //! -//! wai_prov +//! wai_prov [--no-report] [--previous-report ] //! -//! Writes `/captured.jpeg.wai` (the content-addressed asset) and -//! `/captured.receipt.json` (the signed provenance). The asset is +//! Writes `/captured.jpeg.wai` (the content-addressed asset), +//! `/captured.receipt.json` (the signed provenance), and for each +//! measured step `/captured.step.report.json`: its measurement +//! report (energy-measurement §6), a `measurement` claim signed with the +//! receipt's key naming the step by its index, the first chained to +//! `--previous-report` when it is given and the second to the first. +//! `--no-report` turns the reports off, and says so. The asset is //! synthesized deterministically here — WAI is asset-agnostic; the lineage //! mechanism is identical whether the origin is a camera or a synthesizer, //! and that is stated plainly. The point is provenance for bytes WAI @@ -19,8 +26,10 @@ use ed25519_dalek::SigningKey; use std::path::Path; use wai::codecs::image::{jpeg_encode, png_encode}; -use wai::energy_burst::measure_with_macmon; +use wai::energy_burst::{measure_with_macmon, power_reader}; use wai::energy_class::{CounterFiltering, EnergyProvenance}; +use wai::energy_figure::meter_report; +use wai::energy_report::BracketValues; use wai::provenance::{AssetReceipt, ProvStep}; use wai::world::asset_content_hash; use wai::{Conditioning, Manifest, ModelRequirement, Wai}; @@ -29,7 +38,8 @@ const W: u32 = 512; const H: u32 = 512; fn main() { - let args: Vec = std::env::args().collect(); + let report_args = meter_report::parse_args_or_exit(&std::env::args().collect::>()); + let args = &report_args.positional; let dir = Path::new(args.get(1).map(String::as_str).unwrap_or(".")); std::fs::create_dir_all(dir).expect("mkdir"); @@ -40,14 +50,14 @@ fn main() { let raw_hash = blake3::hash(&rgb).into(); // step 1: lossless PNG master — measured. - let png_m = measure("encode.png", &rgb, |r| { + let (png_m, png_v) = 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_m = measure("encode.jpeg", &rgb, |r| { + let (jpeg_m, jpeg_v) = 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"); @@ -74,9 +84,10 @@ fn main() { ]; let signer = SigningKey::from_bytes(&[0x70u8; 32]); // 'p' for prov + let signer_id = "did:web:wai.transaction.science#wai_prov"; let receipt = AssetReceipt::build( &signer, - "did:web:wai.transaction.science#wai_prov", + signer_id, asset_hash, "image", "wai.image.jpeg", @@ -91,12 +102,28 @@ 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, receipt {}…", receipt.steps.len(), receipt.total_joules_micro, &hex(&receipt.receipt_hash())[..16]); + // The origin step is never metered, so the total covers the measured steps + // only, and an all-unmetered lineage is UNMETERED, never 0 µJ. + println!("captured.receipt.json : {} steps, total {}, receipt {}…", receipt.steps.len(), receipt.total_label(), &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)); + + // One report per measured step, by its index: the first chained to the + // previous run's report when `--previous-report` names one, each later one + // to the report before it; a step recorded unmetered gets none, and says why. + let mut chain = report_args.clone(); + for (index, values) in [(1u64, png_v), (2, jpeg_v)] { + let path = dir.join(format!("captured.step{index}.report.json")); + let path_s = path.to_string_lossy().into_owned(); + if meter_report::write(&chain, &receipt, Some(index), values, power_reader(), &signer, signer_id, None, Some(&path_s)) + .is_some() + { + chain.previous_report = Some(path_s); + } + } } /// Deterministic 512×512 RGB: radial rings over a diagonal gradient. @@ -121,14 +148,15 @@ fn synth_rgb() -> Vec { /// 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)> +/// figure, `None` — the unmetered marker — otherwise; and the bracket's values +/// for the step's measurement report. +fn measure(label: &str, rgb: &[u8], op: F) -> (Option<(u64, EnergyProvenance)>, Option) 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; + return (None, None); }; eprint!("{}", b.report(label)); b.warn_if_unpublishable(); @@ -140,7 +168,7 @@ where b.unsealed_reason().unwrap_or("refused") ); } - m + (m, b.values()) } fn hex(b: &[u8]) -> String { diff --git a/wai/wai-rs/src/bin/wai_quantum_meter.rs b/wai/wai-rs/src/bin/wai_quantum_meter.rs index 415b20a72..38f26b93c 100644 --- a/wai/wai-rs/src/bin/wai_quantum_meter.rs +++ b/wai/wai-rs/src/bin/wai_quantum_meter.rs @@ -9,7 +9,8 @@ //! idle baseline, and reports the **marginal** CPU energy attributable to the //! simulation. //! -//! wai_quantum_meter [receipt_out.json] +//! wai_quantum_meter [receipt_out.json [report_out.json]] +//! [--no-report] [--previous-report ] //! //! Output: measured microjoules per reconstruction — the honest figure that //! fills a receipt's `joules_micro`. With a second argument the meter emits a @@ -25,23 +26,30 @@ //! 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 +//! when the reader's rail was not live (§3.1), or the §4 ratio or the §2.3 +//! uncertainty cannot support a figure, the receipt is sealed UNMETERED: +//! `joules_micro` carries the unmetered marker `0`, unlabelled, never a figure +//! of zero joules. Beside a measured figure it writes a measurement report +//! (energy-measurement §6), a `measurement` claim signed with the receipt's key — +//! beside the receipt (`.report.json`), at the path given, or to +//! standard output; `--no-report` turns it off, and says so. 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 wai::energy_burst::measure_with_macmon; +use wai::energy_burst::{measure_with_macmon, power_reader}; +use wai::energy_figure::meter_report; use wai::energy_class::{to_quantum_class, CounterFiltering, EnergyProvenance}; use wai::quantum::from_wqc; -use wai::quantum_energy::Labelled; +use wai::quantum_energy::{Labelled, MaybeLabelled}; use wai::quantum_receipt::{work_amp_updates, QuantumReceipt}; use wai_quantum::ed25519_dalek::SigningKey; fn main() { - let args: Vec = std::env::args().collect(); + let report_args = meter_report::parse_args_or_exit(&std::env::args().collect::>()); + let args = &report_args.positional; let Some(path) = args.get(1) else { - eprintln!("usage: wai_quantum_meter [receipt_out.json]"); + eprintln!("usage: wai_quantum_meter [receipt_out.json [report_out.json]] [--no-report] [--previous-report ]"); std::process::exit(2); }; @@ -61,18 +69,20 @@ fn main() { // 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 mut values = None; 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 \ + (joules_micro carries the unmetered marker 0). Install `brew install macmon` (the non-sudo \ IOReport reader) for a measured figure." ); None } Some(b) => { + values = b.values(); println!("== wai_quantum_meter — measured CPU energy (IOReport via macmon, no sudo) =="); print!("{}", b.report("reconstruction")); if let Some(per) = b.per_unit_uj() { @@ -95,27 +105,29 @@ fn main() { }; 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) { + // The receipt carrying the measured (or unmetered) energy, written when a + // path is given; its report is written whether or not it is. + { // A native sink signs with its own identity key; this metering tool // uses a fixed, clearly-labeled demo signer. let signer = SigningKey::from_bytes(&[0x71u8; 32]); // 'q' for quantum-meter + let signer_id = "did:web:wai.transaction.science#wai_quantum_meter"; let receipt = QuantumReceipt::seal( &signer, - "did:web:wai.transaction.science#wai_quantum_meter", + signer_id, &circuit, measure, joules_micro, None, ) .expect("seal receipt"); - let (json, tag) = match &measured { + let (sealed, 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") + (MaybeLabelled::Labelled(l), "MEASURED, OnChipCounter") } None => { assert!(receipt.verify(), "emitted receipt must verify"); @@ -123,10 +135,23 @@ fn main() { receipt.verify_reconstruction(&circuit), "emitted receipt must re-simulate to the same statevector" ); - (receipt.to_json(), "UNMETERED") + (MaybeLabelled::Unlabelled(receipt), "UNMETERED") } }; - std::fs::write(out, json).expect("write receipt"); - println!("receipt : {out} (verified, joules_micro {tag}, work {work})"); + if let Some(out) = args.get(2) { + std::fs::write(out, sealed.to_json()).expect("write receipt"); + println!("receipt : {out} (verified, joules_micro {tag}, work {work})"); + } + meter_report::write( + &report_args, + &sealed, + None, + values, + power_reader(), + &signer, + signer_id, + args.get(2).map(String::as_str), + args.get(3).map(String::as_str), + ); } } diff --git a/wai/wai-rs/src/bin/wai_video_meter.rs b/wai/wai-rs/src/bin/wai_video_meter.rs index 14f36e618..3b2c6c059 100644 --- a/wai/wai-rs/src/bin/wai_video_meter.rs +++ b/wai/wai-rs/src/bin/wai_video_meter.rs @@ -8,37 +8,48 @@ //! decode burst against an idle baseline, and reports the **marginal** CPU //! energy attributable to the decode. //! -//! wai_video_meter [receipt_out.json] +//! wai_video_meter [receipt_out.json [report_out.json]] +//! [--no-report] [--previous-report ] //! //! 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. +//! matrix. So of everything the emitted receipt says, only the measurement — +//! `joules_micro` and the record of how it was obtained — rests on trusting this +//! machine; the result and the work can be re-derived from the codestream by +//! anyone, anywhere. +//! +//! Beside a measured figure it writes a measurement report +//! (energy-measurement §6): a `measurement` claim, signed with the receipt's key, +//! carrying the bracket's values, the build and the power reader. It goes beside +//! the receipt (`.report.json`), to the path given, or to standard output +//! when no receipt path is given; `--no-report` turns it off, and says so. //! //! 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 × 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". +//! number; it never fabricates one. Without `macmon`, or when the reader's rail +//! was not live (§3.1), or the §4 ratio or the §2.3 uncertainty cannot support a +//! figure, it emits an UNMETERED receipt — `joules_micro` carries the unmetered +//! marker `0`, never a figure of zero joules — 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_burst::{measure_with_macmon, power_reader}; +use wai::energy_figure::meter_report; use wai::energy_class::CounterFiltering; use wai::int_video::decode; use wai::video_receipt::{work_pixel_ops, Provenance, VideoReceipt}; fn main() { - let args: Vec = std::env::args().collect(); + let report_args = meter_report::parse_args_or_exit(&std::env::args().collect::>()); + let args = &report_args.positional; let Some(path) = args.get(1) else { - eprintln!("usage: wai_video_meter [receipt_out.json]"); + eprintln!("usage: wai_video_meter [receipt_out.json [report_out.json]] [--no-report] [--previous-report ]"); std::process::exit(2); }; @@ -65,6 +76,7 @@ fn main() { // 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 mut values = None; let measured: Option<(u64, Provenance)> = match measure_with_macmon("decode", &bs, |b| { let _ = decode(b); @@ -72,12 +84,13 @@ fn main() { None => { eprintln!( "macmon unavailable or too few samples; emitting an UNMETERED receipt \ - (joules_micro = 0). Install `brew install macmon` (the non-sudo \ + (joules_micro carries the unmetered marker 0). Install `brew install macmon` (the non-sudo \ IOReport reader) for a measured figure." ); None } Some(b) => { + values = b.values(); println!("== wai_video_meter — measured CPU energy (IOReport via macmon, no sudo) =="); print!("{}", b.report("decode")); match b.per_unit_uj() { @@ -116,11 +129,13 @@ fn main() { } }; - // Optional: emit a receipt carrying the measured (or unmetered) energy. - if let Some(out) = args.get(2) { + // The receipt carrying the measured (or unmetered) energy, written when a + // path is given; its report is written whether or not it is. + { // 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 signer_id = "did:web:wai.transaction.science#wai_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 @@ -130,7 +145,7 @@ fn main() { }; let receipt = VideoReceipt::seal_decode( &signer, - "did:web:wai.transaction.science#wai_video_meter", + signer_id, &bytes, &bs, uj, @@ -143,8 +158,21 @@ fn main() { 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})"); + if let Some(out) = args.get(2) { + std::fs::write(out, receipt.to_json()).expect("write receipt"); + println!("receipt : {out} (verified, joules_micro {tag}, work {work})"); + } + meter_report::write( + &report_args, + &receipt, + None, + values, + power_reader(), + &signer, + signer_id, + args.get(2).map(String::as_str), + args.get(3).map(String::as_str), + ); } } diff --git a/wai/wai-rs/src/bin/wai_world_meter.rs b/wai/wai-rs/src/bin/wai_world_meter.rs index 44a574720..ffe7561f6 100644 --- a/wai/wai-rs/src/bin/wai_world_meter.rs +++ b/wai/wai-rs/src/bin/wai_world_meter.rs @@ -8,7 +8,8 @@ //! tight replay burst against an idle baseline, and reports the //! **marginal** CPU energy attributable to the replay. //! -//! wai_world_meter [receipt_out.json] +//! wai_world_meter [receipt_out.json [report_out.json]] +//! [--no-report] [--previous-report ] //! //! Output: measured microjoules per replay (and per tick) — the honest //! figure that fills a receipt's `joules_micro`, replacing a browser's @@ -23,11 +24,20 @@ //! 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). +//! when the reader's rail was not live (§3.1), or the §4 ratio or the §2.3 +//! uncertainty cannot support a figure, the receipt is sealed UNMETERED: +//! `joules_micro` carries the unmetered marker `0`, with no class, never a +//! figure of zero joules. +//! +//! Beside a measured figure it writes a measurement report (energy-measurement +//! §6): a `measurement` claim, signed with the receipt's key, carrying the +//! bracket's values, the build and the power reader — beside the receipt +//! (`.report.json`), at the path given, or to standard output when no +//! receipt path is given. `--no-report` turns it off, and says so. use ed25519_dalek::SigningKey; -use wai::energy_burst::measure_with_macmon; +use wai::energy_burst::{measure_with_macmon, power_reader}; +use wai::energy_figure::meter_report; use wai::energy_class::CounterFiltering; use wai::world::feed::CAP_SIM_FEED0; use wai::world::sim::{CAP_SIM_BALLISTIC0, CAP_SIM_KINEMATIC0}; @@ -39,9 +49,10 @@ use wai::world::{ }; fn main() { - let args: Vec = std::env::args().collect(); + let report_args = meter_report::parse_args_or_exit(&std::env::args().collect::>()); + let args = &report_args.positional; let Some(path) = args.get(1) else { - eprintln!("usage: wai_world_meter [receipt_out.json]"); + eprintln!("usage: wai_world_meter [receipt_out.json [report_out.json]] [--no-report] [--previous-report ]"); std::process::exit(2); }; @@ -60,17 +71,19 @@ fn main() { // 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 mut values = None; 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). \ + "macmon unavailable or too few samples; sealing UNMETERED (joules_micro carries the unmetered marker 0). \ Install `brew install macmon` (the non-sudo IOReport reader this meter samples)." ); (0, None) } Some(b) => { + values = b.values(); 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() { @@ -96,16 +109,18 @@ fn main() { } }; - // Optional: emit a §8 receipt carrying the measured energy. - if let Some(out) = args.get(2) { + // The §8 receipt carrying the measured energy, written when a path is + // given; its report is written whether or not it is. + { let (checkpoints, work_steps) = checkpoints_and_work(&container, final_tick); // A native sink signs with its own identity key; this metering // tool uses a fixed, clearly-labeled demo signer. let signer = SigningKey::from_bytes(&[0x6du8; 32]); // 'm' for meter let wh = world_hash(&container.contract_bytes, &container.graph_bytes); + let signer_id = "did:web:wai.transaction.science#wai_world_meter"; let receipt = SessionReceipt::build( &signer, - "did:web:wai.transaction.science#wai_world_meter", + signer_id, wh, 0, &container.ops, &checkpoints, joules_micro, work_steps, None, ); let receipt = match class { @@ -113,9 +128,22 @@ fn main() { None => receipt, }; assert!(receipt.verify(), "emitted receipt must verify"); - std::fs::write(out, receipt.to_json()).expect("write receipt"); - let tag = if receipt.energy_provenance.is_some() { "MEASURED, OnChipCounter" } else { "UNMETERED" }; - println!("receipt : {out} (verified, joules_micro {tag}, work_steps {work_steps})"); + if let Some(out) = args.get(2) { + std::fs::write(out, receipt.to_json()).expect("write receipt"); + let tag = if receipt.energy_provenance.is_some() { "MEASURED, OnChipCounter" } else { "UNMETERED" }; + println!("receipt : {out} (verified, joules_micro {tag}, work_steps {work_steps})"); + } + meter_report::write( + &report_args, + &receipt, + None, + values, + power_reader(), + &signer, + signer_id, + args.get(2).map(String::as_str), + args.get(3).map(String::as_str), + ); } } diff --git a/wai/wai-rs/src/build_info.rs b/wai/wai-rs/src/build_info.rs new file mode 100644 index 000000000..6b607fa10 --- /dev/null +++ b/wai/wai-rs/src/build_info.rs @@ -0,0 +1,36 @@ +//! What `build.rs` records about the build for a measurement report +//! (`extensions/energy-measurement.md` §5, §8). This file is compiled twice: +//! into the crate, and into `build.rs` through `#[path]`, so the mapping the +//! build applies is the one the crate's tests check. It uses nothing but +//! `core`. + +/// The build-profile label for cargo's `PROFILE` and `OPT_LEVEL`: `release` for +/// a build optimised as shipped — cargo's `release` profile at `opt-level` 2 +/// or 3 — and `debug` for any other, including a `release` profile turned down +/// to a lower level and the size levels `s` and `z`, which are not how this +/// crate ships. The label is a statement about optimisation, not about debug +/// assertions: a release build with assertions on is still `release`, and a +/// dev build with them off is still `debug`. +pub fn profile_label(profile: &str, opt_level: &str) -> &'static str { + match (profile, opt_level) { + ("release", "2" | "3") => "release", + _ => "debug", + } +} + +#[cfg(test)] +mod tests { + use super::profile_label; + + #[test] + fn release_means_the_release_profile_optimised() { + assert_eq!(profile_label("release", "3"), "release"); + assert_eq!(profile_label("release", "2"), "release"); + assert_eq!(profile_label("release", "1"), "debug"); + assert_eq!(profile_label("release", "0"), "debug"); + assert_eq!(profile_label("release", "s"), "debug"); + assert_eq!(profile_label("debug", "3"), "debug", "a dev profile turned up is not the shipped build"); + assert_eq!(profile_label("debug", "0"), "debug"); + assert_eq!(profile_label("", ""), "debug"); + } +} diff --git a/wai/wai-rs/src/c2pa_emit.rs b/wai/wai-rs/src/c2pa_emit.rs index 5e15e3077..f75e1cd21 100644 --- a/wai/wai-rs/src/c2pa_emit.rs +++ b/wai/wai-rs/src/c2pa_emit.rs @@ -303,7 +303,9 @@ impl RecoveredBindings { /// 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 - /// canonical RGB8 here, and everything in the claim is confirmed except the joules. + /// canonical RGB8 here, and everything in the claim is confirmed except the + /// measurement: the joules and the fields that describe how they were obtained + /// (energy-binding §3). pub fn describes(&self, rgb: &[u8]) -> bool { use sha2::{Digest, Sha256}; let mut hex = String::with_capacity(64); diff --git a/wai/wai-rs/src/claim_receipt.rs b/wai/wai-rs/src/claim_receipt.rs index fc08656e1..881c39093 100644 --- a/wai/wai-rs/src/claim_receipt.rs +++ b/wai/wai-rs/src/claim_receipt.rs @@ -22,7 +22,19 @@ //! log's own evidence verbatim; //! - `soft-binding` — the publisher's statement that an object carries a soft binding, //! and where a receipt for it may be recovered (the second binding class); -//! - `binding-recovery` — the record that a soft binding was recovered. +//! - `binding-recovery` — the record that a soft binding was recovered; +//! - `measurement` — how one energy figure was measured: its bracket's values, the +//! build and the power reader (energy-measurement §6), checked against the figure +//! they give and the least declaration they support; +//! - `operating-point` — the operating point one energy figure was spent at: what was +//! requested, committed and delivered, and what a declared descriptor predicted +//! (energy-binding §7). +//! +//! A `measurement` or `operating-point` claim names the receipt that seals its figure +//! by a figure profile ([`crate::energy_figure`], energy-measurement §6.1) and that +//! receipt's identity, restates the figure with its class, and leaves the receipt +//! unchanged. Whether a commitment was met, a prediction's residual, and the §4 +//! verdict are derived by every reader from what the claim carries, never stated. //! //! # Rules enforced here rather than only documented //! @@ -48,9 +60,11 @@ //! key with no private key; the corpus carries such claims. //! - The per-class rules run after the signature, because a rule over what a signer said //! only means something once it is established who said it. -//! - Two signed figures for one subject that disagree are rejected, never reconciled by -//! choosing one — against the delivery receipt, and against a named OER receipt, which -//! must also be about the claim's subject. +//! - A claim's figure that disagrees with another statement of the same figure is +//! rejected, never reconciled by choosing one — against the delivery receipt, whose +//! figure the group signature covers only where the object's leaf is labelled +//! (jwp-receipts §2.2), against a named OER receipt, which must also be about the +//! claim's subject, and against the receipt a report names. //! - A recovery through a soft binding yields [`Recovered`], which attests the //! **ancestor** the object in hand was derived from. There is no path from it to a //! statement about the object in hand, and its statement says the transformation's @@ -85,7 +99,12 @@ //! generates cannot, on its own, catch a canonicalisation defect this module shares. use crate::energy_binding::Coverage; -use crate::energy_class::{check_resolution, AcquisitionError, CounterFiltering, EnergyProvenance, Uncertainty}; +use crate::energy_class::{ + check_resolution, AcquisitionError, CounterFiltering, EnergyProvenance, Uncertainty, WindowOpening, +}; +use crate::energy_figure::FigureView; +use crate::energy_report::{Attribution, BracketSummary, BracketValues, BuildProfile, BuildRecord, ReportDefect}; +use std::collections::BTreeMap; use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; use serde_json::{Map, Value}; use std::fmt::Write as _; @@ -137,7 +156,8 @@ pub enum Reason { BadSignerKey, /// The Ed25519 signature does not verify. SignatureVerifyFailed, - /// An `energy` claim with a zero figure: a failed measurement, not free work. + /// An `energy`, `measurement` or `operating-point` claim with a zero figure: a zero + /// is the unmetered marker or a failed measurement, never a figure to report on. EnergyZeroFigure, /// An `energy` claim naming no acquisition class of energy-measurement.md §2. EnergyUnknownAcquisition, @@ -182,9 +202,9 @@ pub enum Reason { ParentNotHeld, /// A receipt supplied as context is not about this claim's class, subject or reference. ContextMismatch, - /// A claim supplied as context — the declaration a decision or a recovery record is - /// checked against — does not itself pass §3.1 steps 1–4. What it says was never - /// established, so nothing can be checked against it. + /// A receipt supplied as context — the declaration a decision or a recovery record is + /// checked against, or the receipt that carries a reported figure — does not verify. + /// What it says was never established, so nothing can be checked against it. ContextInvalid, /// An `energy` claim and a delivery receipt for one subject state different figures. EnergyDisagreesWithDelivery, @@ -205,6 +225,57 @@ pub enum Reason { DecisionDeclarationMismatch, /// A decision about a purpose the declaration does not list. DecisionPurposeNotDeclared, + /// A report naming no figure profile of energy-measurement §6.1 for its subject type. + FigureUnknownProfile, + /// `figure_index` an integer under a profile with one figure, or `null` under one + /// with several. + FigureIndexMismatch, + /// `group` a hash under a profile that names no group, or `null` under one that does. + FigureGroupMismatch, + /// Under a hardware class, a report that names no power reader. + MeasurementNoPowerReader, + /// A bracket with fewer than two samples. + MeasurementTooFewSamples, + /// Under a hardware class, a bracket whose power reader's rail was not live. + MeasurementRailNotLive, + /// A bracket with a zero window, completion count or thread count. + MeasurementEmptyBurst, + /// A bracket whose busy mean is not above its idle mean. + MeasurementNoMarginal, + /// Under a hardware class, a marginal below the idle standard deviation. + MeasurementBelowAttributionFloor, + /// A figure other than the one the bracket gives. + MeasurementFigureMismatch, + /// Under a hardware class, a declared window other than the bracket's. + MeasurementWindowMismatch, + /// Under a hardware class, a declared uncertainty below the least the bracket supports. + MeasurementDeclarationBelowBracket, + /// An operating point present with no member. + OperatingPointEmptyPoint, + /// `op_delivered` without `op_committed`. + OperatingPointDeliveredAlone, + /// `op_requested` or `op_committed` without `op_delivered`. + OperatingPointIncomplete, + /// Neither `op_delivered` nor `prediction`. + OperatingPointEmpty, + /// The receipt a report names carries another figure at `figure_index`. + FigureDisagrees, + /// The figure a report names carries no acquisition class. + FigureUnclassified, + /// The figure's class or evidence differs from the report's. + FigureClassDisagrees, + /// A `measurement` claim not signed by the key that signed its figure. + FigureSignerMismatch, + /// A `measurement` claim about a figure no Ed25519 key signs: nobody's report can be + /// checked to be the figure signer's. + FigureSignerUnknown, + /// A `measurement` claim checked without the receipt that carries its figure: nothing + /// shows its signer signed the figure, so it is not accepted as a report. Recoverable + /// by fetching that receipt. + FigureNotHeld, + /// An `energy` claim whose class or evidence differs from the label in the object's + /// leaf. + EnergyClassDisagreesWithDelivery, } impl Reason { @@ -251,6 +322,29 @@ impl Reason { Reason::RecoveryValueMismatch => "recovery_value_mismatch", Reason::DecisionDeclarationMismatch => "decision_declaration_mismatch", Reason::DecisionPurposeNotDeclared => "decision_purpose_not_declared", + Reason::FigureUnknownProfile => "figure_unknown_profile", + Reason::FigureIndexMismatch => "figure_index_mismatch", + Reason::FigureGroupMismatch => "figure_group_mismatch", + Reason::MeasurementNoPowerReader => "measurement_no_power_reader", + Reason::MeasurementTooFewSamples => "measurement_too_few_samples", + Reason::MeasurementRailNotLive => "measurement_rail_not_live", + Reason::MeasurementEmptyBurst => "measurement_empty_burst", + Reason::MeasurementNoMarginal => "measurement_no_marginal", + Reason::MeasurementBelowAttributionFloor => "measurement_below_attribution_floor", + Reason::MeasurementFigureMismatch => "measurement_figure_mismatch", + Reason::MeasurementWindowMismatch => "measurement_window_mismatch", + Reason::MeasurementDeclarationBelowBracket => "measurement_declaration_below_bracket", + Reason::OperatingPointEmptyPoint => "operating_point_empty_point", + Reason::OperatingPointDeliveredAlone => "operating_point_delivered_alone", + Reason::OperatingPointIncomplete => "operating_point_incomplete", + Reason::OperatingPointEmpty => "operating_point_empty", + Reason::FigureDisagrees => "figure_disagrees", + Reason::FigureUnclassified => "figure_unclassified", + Reason::FigureClassDisagrees => "figure_class_disagrees", + Reason::FigureSignerMismatch => "figure_signer_mismatch", + Reason::FigureSignerUnknown => "figure_signer_unknown", + Reason::FigureNotHeld => "figure_not_held", + Reason::EnergyClassDisagreesWithDelivery => "energy_class_disagrees_with_delivery", } } } @@ -316,6 +410,8 @@ pub enum ClaimClass { LogInclusion, SoftBinding, BindingRecovery, + Measurement, + OperatingPoint, } /// The class of `group` and `object` receipts. A claim naming it is rejected. @@ -329,6 +425,8 @@ impl ClaimClass { ClaimClass::LogInclusion => "log-inclusion", ClaimClass::SoftBinding => "soft-binding", ClaimClass::BindingRecovery => "binding-recovery", + ClaimClass::Measurement => "measurement", + ClaimClass::OperatingPoint => "operating-point", } } @@ -339,17 +437,21 @@ impl ClaimClass { "log-inclusion" => Some(ClaimClass::LogInclusion), "soft-binding" => Some(ClaimClass::SoftBinding), "binding-recovery" => Some(ClaimClass::BindingRecovery), + "measurement" => Some(ClaimClass::Measurement), + "operating-point" => Some(ClaimClass::OperatingPoint), _ => None, } } /// Whether this class admits a subject of type `t`. A log registers receipts, not /// objects (transparency.md §3); a lawful-basis declaration may cover one object or - /// everything a group receipt covers; every other class is about one object. + /// everything a group receipt covers; a report about a figure takes the subject type + /// its figure profile fixes (checked at step 4); every other class is about one + /// object. pub fn admits(self, t: SubjectType) -> bool { match self { ClaimClass::LogInclusion => t == SubjectType::Receipt, - ClaimClass::LawfulBasis => true, + ClaimClass::LawfulBasis | ClaimClass::Measurement | ClaimClass::OperatingPoint => true, ClaimClass::Energy | ClaimClass::SoftBinding | ClaimClass::BindingRecovery => { t == SubjectType::Object } @@ -514,34 +616,65 @@ impl EnergyClaim { /// state its filtering state, since `null` beside `Unknown` would give one class /// two spellings. Any failure is `energy_acquisition_evidence`. pub fn acquisition_class(&self) -> Result { - let label = self.energy_provenance.as_str(); - // With no evidence, `from_parts` answers `UnknownLabel` for a label it does not - // know and something else — a class, or the evidence a refined class lacks — for - // one it does. - match EnergyProvenance::from_parts(label, None, None, None) { - Err(AcquisitionError::UnknownLabel) => return Err(Reason::EnergyUnknownAcquisition), - Ok(p) if p.is_legacy() => return Err(Reason::EnergyLegacyAcquisition), - _ => {} + admit_class( + self.joules_micro, + &self.energy_provenance, + self.energy_uncertainty, + self.energy_counter_filtering.as_deref(), + self.energy_calibration_ref.as_deref(), + ) + } +} + +/// §2.4.5 rule 2, the one admission of a class a claim restates (`energy`, `measurement` +/// and `operating-point` alike), in its order: an unknown label, legacy `HwShunt`, then +/// the class's evidence and the resolution floor. +fn admit_class( + joules_micro: u64, + label: &str, + uncertainty: Option, + filtering: Option<&str>, + calibration_ref: Option<&str>, +) -> Result { + // With no evidence, `from_parts` answers `UnknownLabel` for a label it does not + // know and something else — a class, or the evidence a refined class lacks — for + // one it does. + match EnergyProvenance::from_parts(label, None, None, None) { + Err(AcquisitionError::UnknownLabel) => return Err(Reason::EnergyUnknownAcquisition), + Ok(p) if p.is_legacy() => return Err(Reason::EnergyLegacyAcquisition), + _ => {} + } + let evidence = Reason::EnergyAcquisitionEvidence; + let uncertainty = match uncertainty { + None => None, + Some(u) => { + let ppm = u32::try_from(u.relative_ppm).map_err(|_| evidence)?; + Some(Uncertainty::new(ppm, u.window_us).map_err(|_| evidence)?) } - let evidence = Reason::EnergyAcquisitionEvidence; - let uncertainty = match self.energy_uncertainty { - None => None, - Some(u) => { - let ppm = u32::try_from(u.relative_ppm).map_err(|_| evidence)?; - Some(Uncertainty::new(ppm, u.window_us).map_err(|_| evidence)?) - } - }; - let filtering = match &self.energy_counter_filtering { - None => None, - Some(f) => Some(CounterFiltering::from_label(f).ok_or(evidence)?), - }; - let class = EnergyProvenance::from_parts(label, uncertainty, filtering, self.energy_calibration_ref.as_deref()) - .map_err(|_| evidence)?; - if matches!(class, EnergyProvenance::OnChipCounter { .. }) && filtering.is_none() { - return Err(evidence); + }; + let filtering = match filtering { + None => None, + Some(f) => Some(CounterFiltering::from_label(f).ok_or(evidence)?), + }; + let class = EnergyProvenance::from_parts(label, uncertainty, filtering, calibration_ref).map_err(|_| evidence)?; + if matches!(class, EnergyProvenance::OnChipCounter { .. }) && filtering.is_none() { + return Err(evidence); + } + check_resolution(joules_micro, &class).map_err(|_| evidence)?; + Ok(class) +} + +/// A class's evidence as a claim carries it: `(uncertainty, filtering, calibration +/// reference)`. +fn evidence_of(class: &EnergyProvenance) -> (Option, Option, Option) { + match class { + EnergyProvenance::OnChipCounter { uncertainty, filtering } => { + (Some(declared(uncertainty)), Some(filtering.label().to_owned()), None) } - check_resolution(self.joules_micro, &class).map_err(|_| evidence)?; - Ok(class) + EnergyProvenance::CalibratedInstrument { uncertainty, calibration_ref } => { + (Some(declared(uncertainty)), None, Some(calibration_ref.clone())) + } + _ => (None, None, None), } } @@ -549,6 +682,207 @@ fn declared(u: &Uncertainty) -> DeclaredUncertainty { DeclaredUncertainty { relative_ppm: u64::from(u.relative_ppm), window_us: u.window_us } } +/// The figure a `measurement` or `operating-point` claim is about (jwp-receipts +/// §2.4.8): named by a figure profile, an index and — for `moq-jwp:object` — a group, +/// and restated with its class as §2.4.5 writes one. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FigureHeader { + /// A figure profile of energy-measurement §6.1. + pub figure_profile: String, + /// Which figure, from 0, in a profile that carries several; `None` in one that + /// carries one. + pub figure_index: Option, + /// For `moq-jwp:object`, the link hash of the group the object was delivered in + /// (jwp-receipts §3 step 3); `None` for every other profile. + pub group: Option<[u8; 32]>, + pub joules_micro: u64, + pub energy_provenance: String, + pub energy_uncertainty: Option, + pub energy_counter_filtering: Option, + pub energy_calibration_ref: Option, +} + +impl FigureHeader { + /// A header naming the figure `joules_micro` of class `class`. + pub fn new(profile: &str, index: Option, group: Option<[u8; 32]>, joules_micro: u64, class: &EnergyProvenance) -> FigureHeader { + let (energy_uncertainty, energy_counter_filtering, energy_calibration_ref) = evidence_of(class); + FigureHeader { + figure_profile: profile.to_owned(), + figure_index: index, + group, + joules_micro, + energy_provenance: class.label().to_owned(), + energy_uncertainty, + energy_counter_filtering, + energy_calibration_ref, + } + } + + /// The class the header restates, admitted as §2.4.5 rule 2 admits one. + pub fn acquisition_class(&self) -> Result { + admit_class( + self.joules_micro, + &self.energy_provenance, + self.energy_uncertainty, + self.energy_counter_filtering.as_deref(), + self.energy_calibration_ref.as_deref(), + ) + } + + /// §2.4.8 rules 1–3: a non-zero figure; a profile for the claim's subject type, with + /// an index exactly when it carries several figures and a group exactly when it names + /// one; and the class. + fn check_rules(&self, subject_type: SubjectType) -> Result { + if self.joules_micro == 0 { + return Err(Reason::EnergyZeroFigure); + } + let profile = crate::energy_figure::profile(&self.figure_profile) + .filter(|p| p.subject_type == subject_type) + .ok_or(Reason::FigureUnknownProfile)?; + if self.figure_index.is_some() != profile.indexed { + return Err(Reason::FigureIndexMismatch); + } + if self.group.is_some() != profile.grouped { + return Err(Reason::FigureGroupMismatch); + } + self.acquisition_class() + } + + fn insert_into(&self, m: &mut Map) { + m.insert("figure_profile".into(), self.figure_profile.clone().into()); + m.insert("figure_index".into(), self.figure_index.map_or(Value::Null, Value::from)); + m.insert("group".into(), opt_hex(&self.group)); + m.insert("joules_micro".into(), self.joules_micro.into()); + m.insert("energy_provenance".into(), self.energy_provenance.clone().into()); + m.insert("energy_uncertainty".into(), uncertainty_value(self.energy_uncertainty)); + m.insert( + "energy_counter_filtering".into(), + self.energy_counter_filtering.clone().map(Value::String).unwrap_or(Value::Null), + ); + m.insert( + "energy_calibration_ref".into(), + self.energy_calibration_ref.clone().map(Value::String).unwrap_or(Value::Null), + ); + } + + /// Step 2: the header's members in the table's order, `figure_profile`'s encoding + /// checked as it is read, since nested objects follow it. + fn read(m: &Map) -> Result { + let figure_profile = get_str(m, "figure_profile")?; + term_ok(&figure_profile)?; + Ok(FigureHeader { + figure_profile, + figure_index: get_opt_int(m, "figure_index")?, + group: get_opt_hash(m, "group")?, + joules_micro: get_int(m, "joules_micro")?, + energy_provenance: get_str(m, "energy_provenance")?, + energy_uncertainty: get_uncertainty(m)?, + energy_counter_filtering: get_opt_str(m, "energy_counter_filtering")?, + energy_calibration_ref: get_opt_str(m, "energy_calibration_ref")?, + }) + } + + fn check_values(&self) -> Result<(), Reason> { + term_ok(&self.figure_profile)?; + int_ok(self.joules_micro)?; + if let Some(i) = self.figure_index { + int_ok(i)?; + } + if let Some(u) = self.energy_uncertainty { + int_ok(u.relative_ppm)?; + int_ok(u.window_us)?; + } + Ok(()) + } +} + +/// `measurement` (§2.4.8): how one figure was measured. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MeasurementClaim { + pub figure: FigureHeader, + /// The bracket's values (energy-measurement §6.2) for a figure obtained under §3; + /// `None` otherwise. + pub bracket: Option, + /// The build of the code measured. + pub build: BuildRecord, + /// The reader of the power interface. + pub power_reader: Option, +} + +impl MeasurementClaim { + /// The arithmetic of §4 over the bracket's values. It is a verdict on a figure only + /// once the claim carrying these values has been accepted against that figure + /// ([`ClaimReceipt::attribution`]); on a body alone it is a statement about numbers + /// whose signer nothing has tied to the figure. + pub(crate) fn attribution(&self) -> Option { + self.bracket?.attribution() + } +} + +/// An operating point (energy-binding §7.2): parameters drawn from a vocabulary the +/// claim names, each an unsigned integer. Ordered, so equal points have equal +/// canonical forms. +pub type OperatingPoint = BTreeMap; + +/// What a declared descriptor predicted for the figure's work (energy-binding §7.3). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Prediction { + /// The model that turned the descriptor into energy (absolute URI). + pub predictor: String, + /// `BLAKE3("wai:energy-descriptor\x01" ‖ descriptor)`. + pub descriptor_hash: [u8; 32], + pub predicted_joules_micro: u64, +} + +/// `operating-point` (§2.4.9): the operating point one figure was spent at. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OperatingPointClaim { + pub figure: FigureHeader, + /// The vocabulary the points' parameters are drawn from (absolute URI). + pub vocabulary: String, + pub op_requested: Option, + pub op_committed: Option, + pub op_delivered: Option, + pub prediction: Option, +} + +impl OperatingPointClaim { + /// Whether the commitment was met, derived (§2.4.9): `None` with no commitment, + /// otherwise whether what was delivered is what was committed. + pub fn commitment_met(&self) -> Option { + let committed = self.op_committed.as_ref()?; + Some(self.op_delivered.as_ref() == Some(committed)) + } + + /// The residual, derived (§2.4.9): the figure less the prediction, signed. + pub fn residual_uj(&self) -> Option { + self.prediction.as_ref().map(|p| self.figure.joules_micro as i128 - p.predicted_joules_micro as i128) + } +} + +fn uncertainty_value(u: Option) -> Value { + u.map_or(Value::Null, |u| { + let mut um = Map::new(); + um.insert("relative_ppm".into(), u.relative_ppm.into()); + um.insert("window_us".into(), u.window_us.into()); + Value::Object(um) + }) +} + +/// What a report's bracket rule broke, as its reason. +fn report_reason(d: ReportDefect) -> Reason { + match d { + ReportDefect::TooFewSamples => Reason::MeasurementTooFewSamples, + ReportDefect::RailNotLive => Reason::MeasurementRailNotLive, + ReportDefect::EmptyBurst => Reason::MeasurementEmptyBurst, + ReportDefect::NoMarginal => Reason::MeasurementNoMarginal, + ReportDefect::BelowAttributionFloor => Reason::MeasurementBelowAttributionFloor, + ReportDefect::FigureMismatch => Reason::MeasurementFigureMismatch, + ReportDefect::WindowMismatch => Reason::MeasurementWindowMismatch, + ReportDefect::DeclarationBelowBracket => Reason::MeasurementDeclarationBelowBracket, + } +} + /// One declared purpose and the basis it is processed on. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Purpose { @@ -639,6 +973,8 @@ pub enum ClaimBody { LogInclusion(LogInclusionClaim), SoftBinding(SoftBindingClaim), BindingRecovery(BindingRecoveryClaim), + Measurement(MeasurementClaim), + OperatingPoint(OperatingPointClaim), } /// The energy body's members, in the order step 2 reads them. @@ -664,6 +1000,40 @@ const LOG_MEMBERS: &[&str] = &["log", "profile", "entry", "log_receipt"]; const SOFT_MEMBERS: &[&str] = &["alg", "registry", "binding_type", "value", "resolution_endpoint"]; const RECOVERY_MEMBERS: &[&str] = &["alg", "binding_type", "value", "declaration", "resolution_endpoint", "observed", "attests"]; +/// A measurement body's members (§2.4.8), in the order step 2 reads them. +const MEASUREMENT_MEMBERS: &[&str] = &[ + "figure_profile", + "figure_index", + "group", + "joules_micro", + "energy_provenance", + "energy_uncertainty", + "energy_counter_filtering", + "energy_calibration_ref", + "bracket", + "build", + "power_reader", +]; +/// An operating-point body's members (§2.4.9), in the order step 2 reads them. +const OPERATING_POINT_MEMBERS: &[&str] = &[ + "figure_profile", + "figure_index", + "group", + "joules_micro", + "energy_provenance", + "energy_uncertainty", + "energy_counter_filtering", + "energy_calibration_ref", + "vocabulary", + "op_requested", + "op_committed", + "op_delivered", + "prediction", +]; +const BRACKET_MEMBERS: &[&str] = &["idle", "busy", "window_us", "completions", "in_flight", "opening"]; +const SUMMARY_MEMBERS: &[&str] = &["n", "mean_uw", "sd_uw", "live_intervals", "longest_live_run"]; +const BUILD_MEMBERS: &[&str] = &["profile", "toolchain", "target"]; +const PREDICTION_MEMBERS: &[&str] = &["predictor", "descriptor_hash", "predicted_joules_micro"]; impl ClaimBody { pub fn class(&self) -> ClaimClass { @@ -673,6 +1043,8 @@ impl ClaimBody { ClaimBody::LogInclusion(_) => ClaimClass::LogInclusion, ClaimBody::SoftBinding(_) => ClaimClass::SoftBinding, ClaimBody::BindingRecovery(_) => ClaimClass::BindingRecovery, + ClaimBody::Measurement(_) => ClaimClass::Measurement, + ClaimBody::OperatingPoint(_) => ClaimClass::OperatingPoint, } } @@ -757,6 +1129,38 @@ impl ClaimBody { m.insert("observed".into(), opt_hex(&r.observed)); m.insert("attests".into(), r.attests.clone().into()); } + ClaimBody::Measurement(c) => { + c.figure.insert_into(&mut m); + m.insert("bracket".into(), c.bracket.as_ref().map_or(Value::Null, bracket_value)); + let mut b = Map::new(); + b.insert("profile".into(), c.build.profile.label().into()); + b.insert("toolchain".into(), c.build.toolchain.clone().map(Value::String).unwrap_or(Value::Null)); + b.insert("target".into(), c.build.target.clone().map(Value::String).unwrap_or(Value::Null)); + m.insert("build".into(), Value::Object(b)); + m.insert("power_reader".into(), c.power_reader.clone().map(Value::String).unwrap_or(Value::Null)); + } + ClaimBody::OperatingPoint(c) => { + c.figure.insert_into(&mut m); + m.insert("vocabulary".into(), c.vocabulary.clone().into()); + for (k, p) in [("op_requested", &c.op_requested), ("op_committed", &c.op_committed), ("op_delivered", &c.op_delivered)] { + m.insert( + k.into(), + p.as_ref().map_or(Value::Null, |p| { + Value::Object(p.iter().map(|(k, v)| (k.clone(), Value::from(*v))).collect()) + }), + ); + } + m.insert( + "prediction".into(), + c.prediction.as_ref().map_or(Value::Null, |p| { + let mut pm = Map::new(); + pm.insert("predictor".into(), p.predictor.clone().into()); + pm.insert("descriptor_hash".into(), hex(&p.descriptor_hash).into()); + pm.insert("predicted_joules_micro".into(), p.predicted_joules_micro.into()); + Value::Object(pm) + }), + ); + } } Value::Object(m) } @@ -862,6 +1266,52 @@ impl ClaimBody { resolution_endpoint: get_str(m, "resolution_endpoint")?, }) } + ClaimClass::Measurement => { + members(m, MEASUREMENT_MEMBERS)?; + let figure = FigureHeader::read(m)?; + let bracket = match &m["bracket"] { + Value::Null => None, + v => Some(get_bracket(v)?), + }; + let bm = m["build"].as_object().ok_or(Reason::FieldType)?; + members(bm, BUILD_MEMBERS)?; + let profile = BuildProfile::from_label(&get_str(bm, "profile")?).ok_or(Reason::FieldType)?; + let toolchain = get_opt_str(bm, "toolchain")?; + let target = get_opt_str(bm, "target")?; + if let Some(t) = &target { + term_ok(t)?; + } + ClaimBody::Measurement(MeasurementClaim { + figure, + bracket, + build: BuildRecord { profile, toolchain, target }, + power_reader: get_opt_str(m, "power_reader")?, + }) + } + ClaimClass::OperatingPoint => { + members(m, OPERATING_POINT_MEMBERS)?; + let figure = FigureHeader::read(m)?; + let vocabulary = get_str(m, "vocabulary")?; + uri_ok(&vocabulary)?; + let op_requested = get_point(m, "op_requested")?; + let op_committed = get_point(m, "op_committed")?; + let op_delivered = get_point(m, "op_delivered")?; + let prediction = match &m["prediction"] { + Value::Null => None, + Value::Object(pm) => { + members(pm, PREDICTION_MEMBERS)?; + let predictor = get_str(pm, "predictor")?; + uri_ok(&predictor)?; + Some(Prediction { + predictor, + descriptor_hash: get_hash(pm, "descriptor_hash")?, + predicted_joules_micro: get_int(pm, "predicted_joules_micro")?, + }) + } + _ => return Err(Reason::FieldType), + }; + ClaimBody::OperatingPoint(OperatingPointClaim { figure, vocabulary, op_requested, op_committed, op_delivered, prediction }) + } ClaimClass::BindingRecovery => { members(m, RECOVERY_MEMBERS)?; ClaimBody::BindingRecovery(BindingRecoveryClaim { @@ -929,12 +1379,73 @@ impl ClaimBody { token_ok(&r.alg)?; uri_ok(&r.resolution_endpoint) } + ClaimBody::Measurement(c) => { + c.figure.check_values()?; + if let Some(b) = &c.bracket { + for s in [&b.idle, &b.busy] { + for v in [s.n, s.mean_uw, s.sd_uw, s.live_intervals, s.longest_live_run] { + int_ok(v)?; + } + } + for v in [b.window_us, b.completions, b.in_flight] { + int_ok(v)?; + } + } + if let Some(t) = &c.build.target { + term_ok(t)?; + } + Ok(()) + } + ClaimBody::OperatingPoint(c) => { + c.figure.check_values()?; + uri_ok(&c.vocabulary)?; + for p in [&c.op_requested, &c.op_committed, &c.op_delivered].into_iter().flatten() { + for (k, v) in p { + term_ok(k)?; + int_ok(*v)?; + } + } + if let Some(p) = &c.prediction { + uri_ok(&p.predictor)?; + int_ok(p.predicted_joules_micro)?; + } + Ok(()) + } } } /// The class rules (step 4), in the order §3.1 lists them. - fn check_class_rules(&self, subject: &[u8; 32]) -> Result<(), Reason> { + fn check_class_rules(&self, subject_type: SubjectType, subject: &[u8; 32]) -> Result<(), Reason> { match self { + ClaimBody::Measurement(c) => { + let class = c.figure.check_rules(subject_type)?; + // Rule 4: a hardware figure is a reading, and a reading names its reader. + if class.uncertainty().is_some() && c.power_reader.is_none() { + return Err(Reason::MeasurementNoPowerReader); + } + if let Some(b) = &c.bracket { + let hardware = class.uncertainty().map(|u| (u.relative_ppm, u.window_us)); + b.check(c.figure.joules_micro, hardware).map_err(report_reason)?; + } + Ok(()) + } + ClaimBody::OperatingPoint(c) => { + c.figure.check_rules(subject_type)?; + let points = [&c.op_requested, &c.op_committed, &c.op_delivered]; + if points.iter().any(|p| p.as_ref().is_some_and(|p| p.is_empty())) { + return Err(Reason::OperatingPointEmptyPoint); + } + if c.op_delivered.is_some() && c.op_committed.is_none() { + return Err(Reason::OperatingPointDeliveredAlone); + } + if (c.op_requested.is_some() || c.op_committed.is_some()) && c.op_delivered.is_none() { + return Err(Reason::OperatingPointIncomplete); + } + if c.op_delivered.is_none() && c.prediction.is_none() { + return Err(Reason::OperatingPointEmpty); + } + Ok(()) + } ClaimBody::Energy(e) => { // energy-measurement §2: a class with a zero figure claims a measurement // that did not occur. An unmetered subject gets no energy claim at all. @@ -1145,7 +1656,7 @@ impl ClaimReceipt { sig: [0u8; 64], }; r.check_shape()?; - r.body.check_class_rules(&r.subject).map_err(|e| Rejection::at(4, e))?; + r.body.check_class_rules(r.subject_type, &r.subject).map_err(|e| Rejection::at(4, e))?; r.sig = signer.sign(&r.signing_input()?).to_bytes(); Ok(r) } @@ -1232,22 +1743,10 @@ impl ClaimReceipt { /// Ed25519 rule, then the class rules. pub fn verify(&self) -> Result<(), Rejection> { self.check_shape()?; - let key = signer_key(&self.signer_pubkey).map_err(|r| Rejection::at(3, r))?; - // S < L (RFC 8032 §5.1.7), checked here as well as inside `verify_strict`: the - // library's own range check shrinks to a top-three-bits test when any crate in a - // build turns on its `legacy_compatibility` feature, and S + L passes that test. - let s: [u8; 32] = self.sig[32..].try_into().expect("a signature is 64 bytes"); - if bool::from(curve25519_dalek::Scalar::from_canonical_bytes(s).is_none()) { - return Err(Rejection::at(3, Reason::SignatureVerifyFailed)); - } - // `verify_strict` does the rest of §3.1 step 3's signature rule: R not of small - // order, and the cofactorless equation checked by comparing the encoding of - // [S]B − [k]A with R byte for byte (so a non-canonical R never verifies). Its own - // small-order test on the key cannot fire here; `signer_key` refused those first. - key.verify_strict(&self.signing_input()?, &Signature::from_bytes(&self.sig)) - .map_err(|_| Rejection::at(3, Reason::SignatureVerifyFailed))?; + verify_ed25519_strict(&self.signer_pubkey, &self.signing_input()?, &self.sig) + .map_err(|r| Rejection::at(3, r))?; self.body - .check_class_rules(&self.subject) + .check_class_rules(self.subject_type, &self.subject) .map_err(|r| Rejection::at(4, r)) } @@ -1278,12 +1777,29 @@ impl ClaimReceipt { } /// Step 6: an `energy` claim against the delivery (`object`) receipt's figure for the - /// same subject. Two signed figures for one subject that disagree are rejected, never - /// reconciled by choosing one. + /// same subject. That figure is the publisher's signed statement only where the + /// object's leaf is labelled (jwp-receipts §2.2); either way two statements of one + /// figure that disagree are rejected, never reconciled by choosing one. For an object + /// whose leaf is labelled, use [`ClaimReceipt::check_energy_against_labelled_object`]. pub fn check_energy_against_object( &self, object_content_hash: &[u8; 32], object_joules_micro: u64, + ) -> Result<(), Rejection> { + self.check_energy_against_labelled_object(object_content_hash, object_joules_micro, None) + } + + /// Step 6 (§2.4.5): an `energy` claim against an object receipt, with the class its + /// leaf carries where the leaf is labelled. In order: the claim is an energy claim + /// about the object (`context_mismatch`); the figures are equal + /// (`energy_disagrees_with_delivery`); where the leaf is labelled, the class and its + /// evidence are equal, each read as energy-measurement §2.7 reads it + /// (`energy_class_disagrees_with_delivery`). + pub fn check_energy_against_labelled_object( + &self, + object_content_hash: &[u8; 32], + object_joules_micro: u64, + object_class: Option<&EnergyProvenance>, ) -> Result<(), Rejection> { let ClaimBody::Energy(e) = &self.body else { return Err(Rejection::at(6, Reason::ContextMismatch)); @@ -1294,9 +1810,79 @@ impl ClaimReceipt { if e.joules_micro != object_joules_micro { return Err(Rejection::at(6, Reason::EnergyDisagreesWithDelivery)); } + if let Some(label) = object_class { + if e.acquisition_class().ok().as_ref() != Some(label) { + return Err(Rejection::at(6, Reason::EnergyClassDisagreesWithDelivery)); + } + } Ok(()) } + /// Step 6 (§2.4.8–§2.4.9): a `measurement` or `operating-point` claim against the + /// receipt that carries its figure, given as that receipt's [`FigureView`] — `None` + /// where the receipt does not verify (`context_invalid`). In order: the view is of the + /// claim's profile, its identity is the claim's subject, its group the claim's + /// `group`, and it carries a figure at `figure_index` (`context_mismatch`); that figure + /// is the claim's (`figure_disagrees`); it carries a class (`figure_unclassified`), + /// which is the claim's with the same evidence (`figure_class_disagrees`). For a + /// `measurement` claim only: an Ed25519 key signs the figure + /// (`figure_signer_unknown`), and it is the claim's signer (`figure_signer_mismatch`). + pub fn check_figure_against(&self, view: Option<&FigureView>) -> Result<(), Rejection> { + let r6 = |r| Rejection::at(6, r); + let (h, signer_rule) = match &self.body { + ClaimBody::Measurement(c) => (&c.figure, true), + ClaimBody::OperatingPoint(c) => (&c.figure, false), + _ => return Err(r6(Reason::ContextMismatch)), + }; + let v = view.ok_or(r6(Reason::ContextInvalid))?; + if v.profile != h.figure_profile || v.identity != self.subject || v.group != h.group { + return Err(r6(Reason::ContextMismatch)); + } + let f = v.figure(h.figure_index).ok_or(r6(Reason::ContextMismatch))?; + if f.joules_micro != h.joules_micro { + return Err(r6(Reason::FigureDisagrees)); + } + let class = f.class.as_ref().ok_or(r6(Reason::FigureUnclassified))?; + if h.acquisition_class().ok().as_ref() != Some(class) { + return Err(r6(Reason::FigureClassDisagrees)); + } + if signer_rule { + let signs = crate::energy_figure::profile(&h.figure_profile).is_some_and(|p| p.ed25519_signer); + if !signs { + return Err(r6(Reason::FigureSignerUnknown)); + } + if v.signer != Some(self.signer_pubkey) { + return Err(r6(Reason::FigureSignerMismatch)); + } + } + Ok(()) + } + + /// §4's verdict on the figure a `measurement` claim names, derived from the claim's + /// bracket (energy-measurement §6.2) — given only for a claim accepted against that + /// figure. `figure` is the view of the receipt carrying it, or `None` where the + /// verifier does not hold that receipt: the claim then fails at step 6 + /// (`figure_not_held`), because nothing shows its signer is the figure's (§2.4.8 + /// check 5), and a verdict from its values would be a verdict on anyone's numbers. + /// `Ok(None)` is an accepted claim with no bracket; any other class is + /// `context_mismatch`. + pub fn attribution(&self, figure: Option<&FigureView>) -> Result, Rejection> { + self.verify()?; + let ClaimBody::Measurement(c) = &self.body else { + return Err(Rejection::at(6, Reason::ContextMismatch)); + }; + let view = figure.ok_or(Rejection::at(6, Reason::FigureNotHeld))?; + self.check_figure_against(Some(view))?; + Ok(c.attribution()) + } + + /// Whether this claim is accepted only against the receipt carrying its figure: a + /// `measurement` claim, whose standing as the figure signer's report rests on §3.1 + /// step 6 (§2.4.8 check 5). Checked at steps 1–5 alone it is `figure_not_held`. + pub fn needs_figure(&self) -> bool { + matches!(self.body, ClaimBody::Measurement(_)) + } + /// Step 6: an `energy` claim against the OER receipt it names (oer-receipts.md). /// /// `oer_receipt` is the receipt as oer-receipts §4 reads it: a value taken from a @@ -1461,6 +2047,44 @@ fn signer_key(bytes: &[u8; 32]) -> Result { Ok(key) } +/// §3.1 step 3's Ed25519 rule: the key checks of [`signer_key`] (`bad_signer_key`), then +/// `S` < L, `R` not of small order, and the cofactorless equation with `R` compared byte +/// for byte (`signature_verify_failed`). One routine, so a JWP group signature +/// (jwp-receipts §3 step 2, `crate::jwp_delivery`) and a claim's are held to the same +/// rule and every conforming verifier accepts the same signatures. +pub(crate) fn verify_ed25519_strict(pubkey: &[u8; 32], msg: &[u8], sig: &[u8; 64]) -> Result<(), Reason> { + let key = signer_key(pubkey)?; + // S < L (RFC 8032 §5.1.7), checked here as well as inside `verify_strict`: the + // library's own range check shrinks to a top-three-bits test when any crate in a + // build turns on its `legacy_compatibility` feature, and S + L passes that test. + let s: [u8; 32] = sig[32..].try_into().expect("a signature is 64 bytes"); + if bool::from(curve25519_dalek::Scalar::from_canonical_bytes(s).is_none()) { + return Err(Reason::SignatureVerifyFailed); + } + // `verify_strict` does the rest of the rule: R not of small order, and the + // cofactorless equation checked by comparing the encoding of [S]B − [k]A with R byte + // for byte (so a non-canonical R never verifies). Its own small-order test on the key + // cannot fire here; `signer_key` refused those first. + key.verify_strict(msg, &Signature::from_bytes(sig)).map_err(|_| Reason::SignatureVerifyFailed) +} + +/// An RFC 3339 `date-time` in UTC, to the second, for `seconds` since the Unix epoch: +/// what a producer writes in `issued_at`. Integer only (the civil-from-days algorithm). +pub fn rfc3339_utc(seconds: u64) -> String { + let days = (seconds / 86_400) as i64; + let secs = seconds % 86_400; + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = yoe + era * 400 + i64::from(month <= 2); + format!("{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", secs / 3_600, secs % 3_600 / 60, secs % 60) +} + /// Steps 1–4 on the wire form. pub fn verify_claim_json(wire: &str) -> Result { let r = ClaimReceipt::from_json(wire)?; @@ -1793,6 +2417,86 @@ fn get_uncertainty(m: &Map) -> Result } } +fn get_opt_int(m: &Map, k: &str) -> Result, Reason> { + if m[k].is_null() { Ok(None) } else { get_int(m, k).map(Some) } +} + +/// A bracket (§2.4.8): its members, then in their order each summary's members and +/// types, the three integers, and `opening` from its enumeration. +fn get_bracket(v: &Value) -> Result { + let b = v.as_object().ok_or(Reason::FieldType)?; + members(b, BRACKET_MEMBERS)?; + let summary = |k: &str| -> Result { + let s = b[k].as_object().ok_or(Reason::FieldType)?; + members(s, SUMMARY_MEMBERS)?; + Ok(BracketSummary { + n: get_int(s, "n")?, + mean_uw: get_int(s, "mean_uw")?, + sd_uw: get_int(s, "sd_uw")?, + live_intervals: get_int(s, "live_intervals")?, + longest_live_run: get_int(s, "longest_live_run")?, + }) + }; + Ok(BracketValues { + idle: summary("idle")?, + busy: summary("busy")?, + window_us: get_int(b, "window_us")?, + completions: get_int(b, "completions")?, + in_flight: get_int(b, "in_flight")?, + opening: match get_str(b, "opening")?.as_str() { + "from-rest" => WindowOpening::FromRest, + "running" => WindowOpening::Running, + _ => return Err(Reason::FieldType), + }, + }) +} + +fn bracket_value(b: &BracketValues) -> Value { + let summary = |s: &BracketSummary| { + let mut m = Map::new(); + m.insert("n".into(), s.n.into()); + m.insert("mean_uw".into(), s.mean_uw.into()); + m.insert("sd_uw".into(), s.sd_uw.into()); + m.insert("live_intervals".into(), s.live_intervals.into()); + m.insert("longest_live_run".into(), s.longest_live_run.into()); + Value::Object(m) + }; + let mut m = Map::new(); + m.insert("idle".into(), summary(&b.idle)); + m.insert("busy".into(), summary(&b.busy)); + m.insert("window_us".into(), b.window_us.into()); + m.insert("completions".into(), b.completions.into()); + m.insert("in_flight".into(), b.in_flight.into()); + m.insert( + "opening".into(), + match b.opening { + WindowOpening::FromRest => "from-rest", + WindowOpening::Running => "running", + } + .into(), + ); + Value::Object(m) +} + +/// An operating point, or `null`: an object whose member names are terms and whose +/// values are integers, each checked in turn. +fn get_point(m: &Map, k: &str) -> Result, Reason> { + match &m[k] { + Value::Null => Ok(None), + Value::Object(p) => { + let mut out = OperatingPoint::new(); + for (name, v) in p { + term_ok(name)?; + let n = v.as_u64().ok_or(Reason::FieldType)?; + int_ok(n)?; + out.insert(name.clone(), n); + } + Ok(Some(out)) + } + _ => Err(Reason::FieldType), + } +} + fn get_binding_type(m: &Map) -> Result { BindingType::from_label(&get_str(m, "binding_type")?).ok_or(Reason::FieldType) } @@ -1973,7 +2677,7 @@ fn rfc3339(s: &str) -> bool { /// fails instead of passing on nothing. (A function rather than a public constant so /// cbindgen does not export it into the C header.) pub fn corpus_vector_count() -> usize { - 154 + 237 } /// The conformance corpus (`claim-conformance/vectors.json`), regenerated. Ed25519 @@ -3205,6 +3909,397 @@ mod corpus { reject("energy_work_unit_unicode_white_space", "a work unit holding U+3000, a White_Space character outside ASCII: not a term", meter_bad(unit_with_ideographic_space), 2, Reason::FieldType), ]); + v.extend(report_vectors()); + v + } + + // ───────── measurement, operating-point, and the labelled delivery leaf ───────── + + const READER: &str = "reader:example"; + const VOCABULARY: &str = "https://vocabulary.example.org/operating-points"; + const PREDICTOR: &str = "https://models.example.org/complexity-to-energy"; + + /// A bracket summary whose reader's rail advanced in every sample. + fn live(n: u64, mean_uw: u64, sd_uw: u64) -> BracketSummary { + BracketSummary { n, mean_uw, sd_uw, live_intervals: n, longest_live_run: n } + } + + fn bracket(idle: BracketSummary, busy: BracketSummary, window_us: u64, completions: u64, in_flight: u64, opening: WindowOpening) -> BracketValues { + BracketValues { idle, busy, window_us, completions, in_flight, opening } + } + + /// Ten workers from rest, sixty completions over 3 s, a 10 W marginal: ten idle + /// samples and fourteen busy ones, every one live. + fn unit_bracket() -> BracketValues { + bracket(live(10, 20_000_000, 115_471), live(14, 30_000_000, 115_471), 3_000_000, 60, 10, WindowOpening::FromRest) + } + + /// `(F, least p)` for a bracket, from `crate::energy_report`. + fn fp(b: &BracketValues) -> (u64, u32) { + let f = b.figure().expect("a bracket the corpus seals gives a figure"); + (f, b.least_relative_ppm(f).expect("and a declaration")) + } + + fn chip_class(ppm: u32, window_us: u64) -> EnergyProvenance { + EnergyProvenance::OnChipCounter { uncertainty: Uncertainty { relative_ppm: ppm, window_us }, filtering: CounterFiltering::Unknown } + } + + fn release() -> BuildRecord { + BuildRecord { + profile: BuildProfile::Release, + toolchain: Some("rustc 1.98.0 (example)".into()), + target: Some("x86_64-unknown-linux-gnu".into()), + } + } + + fn grade_of(c: &EnergyProvenance) -> &'static str { + match c.kind() { + crate::energy_class::AcquisitionKind::ModelBased => "derived", + crate::energy_class::AcquisitionKind::Estimator => "projected", + _ => "measured", + } + } + + /// The meter's `energy` claim about the ancestor, of `figure` and `class`: the + /// receipt a report names under `moq-jwp:claim-link`. + fn named(figure: u64, class: &EnergyProvenance) -> ClaimReceipt { + seal( + &meter(), + "did:example:meter", + SubjectType::Object, + ancestor(), + 0, + ClaimBody::Energy(EnergyClaim::new(figure, class, Coverage::Whole, grade_of(class), METHOD, 49_152, "subpixel", None)), + None, + ) + } + + fn claim_link(figure: u64, class: &EnergyProvenance) -> FigureHeader { + FigureHeader::new("moq-jwp:claim-link", None, None, figure, class) + } + + fn measurement_body(h: FigureHeader, b: Option, build: BuildRecord) -> ClaimBody { + ClaimBody::Measurement(MeasurementClaim { figure: h, bracket: b, build, power_reader: Some(READER.into()) }) + } + + fn point(pairs: &[(&str, u64)]) -> Option { + Some(pairs.iter().map(|(k, v)| ((*k).to_owned(), *v)).collect()) + } + + fn prediction(predicted: u64) -> Option { + let mut h = blake3::Hasher::new(); + h.update(b"wai:energy-descriptor\x01"); + h.update(b"claim-conformance: a declared complexity descriptor"); + Some(Prediction { predictor: PREDICTOR.into(), descriptor_hash: *h.finalize().as_bytes(), predicted_joules_micro: predicted }) + } + + fn op_body( + h: FigureHeader, + requested: Option, + committed: Option, + delivered: Option, + prediction: Option, + ) -> ClaimBody { + ClaimBody::OperatingPoint(OperatingPointClaim { + figure: h, + vocabulary: VOCABULARY.into(), + op_requested: requested, + op_committed: committed, + op_delivered: delivered, + prediction, + }) + } + + /// A `view` context: a receipt the verifier has verified, given as its figures. + fn view(profile: &str, identity: [u8; 32], group: Option<[u8; 32]>, figures: &[(Option, u64, Option<&EnergyProvenance>)], signer: Option<&SigningKey>) -> Value { + let figures: Vec = figures + .iter() + .map(|(i, j, c)| { + let mut m = Map::new(); + m.insert("index".into(), i.map_or(Value::Null, Value::from)); + m.insert("joules_micro".into(), (*j).into()); + if let Some(c) = c { + for (k, val) in crate::energy_class::json_fields(c) { + m.insert(k.into(), val); + } + } + Value::Object(m) + }) + .collect(); + json!({ "view": { + "profile": profile, + "identity": hex(&identity), + "group": opt_hex(&group), + "figures": figures, + "signer": signer.map_or(Value::Null, |k| pk(k).into()), + }}) + } + + fn claim_ctx(e: &ClaimReceipt) -> Value { + json!({ "claim": e.to_json() }) + } + + fn report_vectors() -> Vec { + let meter_id = "did:example:meter"; + let mut v = Vec::new(); + let unit = unit_bracket(); + let (f_unit, p_unit) = fp(&unit); + let c_unit = chip_class(p_unit, unit.window_us); + let e_unit = named(f_unit, &c_unit); + let unit_report = |class: &EnergyProvenance, b: BracketValues| measurement_body(claim_link(f_unit, class), Some(b), release()); + // A report about the meter's energy claim of `class`, by the meter, accepted. + let about = |name: &'static str, text: &'static str, b: BracketValues, class: EnergyProvenance| -> V { + let f = b.figure().expect("a figure"); + let e = named(f, &class); + let r = seal(&meter(), meter_id, SubjectType::Receipt, e.link_hash(), 1, measurement_body(claim_link(f, &class), Some(b), release()), None); + with(claim(name, text, r.to_json(), Ok(())), "figure", claim_ctx(&e)) + }; + // A report the generator signs whatever it says, about the energy claim `e`. + let bad_about = |e: &ClaimReceipt, body: ClaimBody| seal_bad(&meter(), meter_id, SubjectType::Receipt, e.link_hash(), 1, body); + let m_accept = seal(&meter(), meter_id, SubjectType::Receipt, e_unit.link_hash(), 1, unit_report(&c_unit, unit), None); + let m_wire = m_accept.to_json(); + + // ── accepted ── + let running = BracketValues { opening: WindowOpening::Running, ..unit }; + let one_uj = bracket(live(10, 20_000_000, 200_000), live(14, 30_000_000, 500_000), 2_815_000, 50_000_000, 10, WindowOpening::FromRest); + let half = bracket(live(10, 20_000_000, 1_000), live(14, 21_000_000, 1_000), 2_500_000, 1_000_000, 1, WindowOpening::Running); + let wide = bracket(live(10, 20_000_000, 115_471), live(14, 30_000_000, 115_471), 100_000_000_000, 1_000_000_000_000, 10, WindowOpening::FromRest); + let short = bracket(live(4, 20_000_000, 115_471), live(4, 30_000_000, 115_471), 3_000_000, 60, 10, WindowOpening::FromRest); + let dead = BracketValues { + idle: BracketSummary { live_intervals: 0, longest_live_run: 0, ..unit.idle }, + busy: BracketSummary { live_intervals: 1, longest_live_run: 1, ..unit.busy }, + ..unit + }; + let cal = |ppm| EnergyProvenance::CalibratedInstrument { + uncertainty: Uncertainty { relative_ppm: ppm, window_us: unit.window_us }, + calibration_ref: CALIBRATION_REF.into(), + }; + v.extend([ + with(claim("measurement_accept", "a report on the meter's own energy claim: ten workers from rest, sixty completions over 3 s, a 10 W marginal; the figure and the declaration are the least the bracket's integers give, every sample live", m_wire.clone(), Ok(())), "figure", claim_ctx(&e_unit)), + about("measurement_running_accept", "the same bracket opened on running workers: the work is C, not C + N/2", running, chip_class(fp(&running).1, running.window_us)), + about("measurement_one_microjoule_accept", "a 0.563 uJ estimate sealed as 1 uJ: its declaration is dominated by the rounding, and its products exceed 128 bits", one_uj, chip_class(fp(&one_uj).1, one_uj.window_us)), + about("measurement_half_up_accept", "an estimate of exactly 2.5 uJ: a half rounds up, to 3", half, chip_class(fp(&half).1, half.window_us)), + about("measurement_wide_accept", "a window of 10^11 us and 10^12 completions: products past 200 bits", wide, chip_class(fp(&wide).1, wide.window_us)), + about("measurement_declared_above_least_accept", "a calibrated instrument declaring more than the bracket's least: its calibration covers more than the bracket does", unit, cal(p_unit + 1_000)), + about("measurement_model_based_bracket_accept", "a ModelBased figure with a bracket of four samples each: no declaration to check, and liveness binds the hardware classes only", short, EnergyProvenance::ModelBased), + about("measurement_model_based_dead_rail_accept", "a ModelBased figure whose bracket shows a rail that was not live: a model's figure is not a reading, so liveness is not its rule", dead, EnergyProvenance::ModelBased), + ]); + let est = EnergyProvenance::Estimator; + let e_est = named(9_000, &est); + let debug = BuildRecord { profile: BuildProfile::Debug, toolchain: None, target: None }; + let no_bracket = ClaimBody::Measurement(MeasurementClaim { figure: claim_link(9_000, &est), bracket: None, build: debug, power_reader: None }); + v.push(with(claim("measurement_no_bracket_accept", "an Estimator figure obtained under no bracket: the report states the build alone, a debug build, its toolchain, target and reader unknown", + seal(&meter(), meter_id, SubjectType::Receipt, e_est.link_hash(), 1, no_bracket, None).to_json(), Ok(())), "figure", claim_ctx(&e_est))); + let counter = chip_class(20_000, 1_000_000); + let e_counter = named(9_000, &counter); + let counter_report = measurement_body(claim_link(9_000, &counter), None, release()); + v.push(with(claim("measurement_counter_no_bracket_accept", "an OnChipCounter figure not obtained under a bracket (a counter read through its span): the report names the reader and carries no bracket, so the rail's liveness rests on its producer, and no verdict is derived", + seal(&meter(), meter_id, SubjectType::Receipt, e_counter.link_hash(), 1, counter_report, None).to_json(), Ok(())), "figure", claim_ctx(&e_counter))); + let asset = object_content_hash(b"claim-conformance: a provenance receipt's identity"); + let model = EnergyProvenance::ModelBased; + let step_report = |idx: u64, f: u64| measurement_body(FigureHeader::new("wai:asset-receipt-id", Some(idx), None, f, &model), None, release()); + let asset_view = |k: &SigningKey| view("wai:asset-receipt-id", asset, None, &[(Some(0), 0, None), (Some(1), 4_200, Some(&model)), (Some(2), 90, None)], Some(k)); + v.push(with(claim("measurement_step_accept", "a report on step 1 of a provenance receipt, given as the view of a receipt the verifier has verified", + seal(&meter(), meter_id, SubjectType::Receipt, asset, 1, step_report(1, 4_200), None).to_json(), Ok(())), "figure", asset_view(&meter()))); + let group = group_link(); + let obj_report = |grp: [u8; 32]| measurement_body(FigureHeader::new("moq-jwp:object", Some(2), Some(grp), f_unit, &c_unit), Some(unit), release()); + let obj_view = view("moq-jwp:object", ancestor(), Some(group), &[(Some(2), f_unit, Some(&c_unit))], Some(&publisher())); + v.push(with(claim("measurement_object_accept", "a report on a delivered object: its subject is the object's content hash, its index the object's leaf, its group the group receipt's link hash, and the publisher who signed the leaf signs it", + seal(&publisher(), "did:example:publisher", SubjectType::Object, ancestor(), 1, obj_report(group), None).to_json(), Ok(())), "figure", obj_view.clone())); + + // ── operating points ── + let p1080 = || point(&[("frame_rate_milli", 30_000), ("height", 1_080)]); + let p720 = || point(&[("frame_rate_milli", 30_000), ("height", 720)]); + let op = |k: &SigningKey, id: &str, body: ClaimBody| seal(k, id, SubjectType::Receipt, e_unit.link_hash(), 2, body, None).to_json(); + let op_bad = |body: ClaimBody| seal_bad(&meter(), meter_id, SubjectType::Receipt, e_unit.link_hash(), 2, body); + let hdr = || claim_link(f_unit, &c_unit); + let fig = claim_ctx(&e_unit); + v.extend([ + with(claim("operating_point_accept", "requested, committed and delivered the same point, and the energy a declared descriptor predicted: the commitment was met, which no claim states", + op(&meter(), meter_id, op_body(hdr(), p1080(), p1080(), p1080(), prediction(450_000))), Ok(())), "figure", fig.clone()), + with(claim("operating_point_not_met_accept", "committed 1080 lines, delivered 720: both are recorded, and a reader derives that the commitment was not met", + op(&meter(), meter_id, op_body(hdr(), p1080(), p1080(), p720(), None)), Ok(())), "figure", fig.clone()), + with(claim("operating_point_own_account_accept", "no request: the acting party's own commitment and what it delivered", + op(&meter(), meter_id, op_body(hdr(), None, p720(), p720(), None)), Ok(())), "figure", fig.clone()), + with(claim("operating_point_prediction_only_accept", "a prediction alone, above the figure: the residual a reader derives is negative", + op(&meter(), meter_id, op_body(hdr(), None, None, None, prediction(500_000))), Ok(())), "figure", fig.clone()), + with(claim("operating_point_acting_party_accept", "recorded by the operator, not by the meter that signed the figure: an operating-point claim may be", + op(&operator(), "did:example:operator", op_body(hdr(), p1080(), p1080(), p1080(), None)), Ok(())), "figure", fig.clone()), + ]); + let binding_id = object_content_hash(b"claim-conformance: an energy binding's identity"); + let binding_view = view("wai:energy-binding-id", binding_id, None, &[(None, f_unit, Some(&c_unit))], None); + v.push(with(claim("operating_point_on_binding_accept", "an operating point recorded against an energy binding's figure, which no Ed25519 key signs", + seal(&operator(), "did:example:operator", SubjectType::Receipt, binding_id, 2, + op_body(FigureHeader::new("wai:energy-binding-id", None, None, f_unit, &c_unit), None, p720(), p720(), None), None).to_json(), + Ok(())), "figure", binding_view.clone())); + + // ── the labelled delivery leaf, against an energy claim ── + let e_counter = seal(&meter(), meter_id, SubjectType::Object, ancestor(), 0, energy(12_000, "OnChipCounter", "Whole", "measured", None), None); + let ecw = e_counter.to_json(); + let labelled = |j: u64, extra: Value| { + let mut o = json!({ "content_hash": hex(&ancestor()), "joules_micro": j }); + for (k, val) in extra.as_object().expect("an object") { + o[k] = val.clone(); + } + o + }; + let counter_label = json!({ "energy_provenance": "OnChipCounter", "energy_uncertainty": { "relative_ppm": 20_000, "window_us": 1_000_000 }, "energy_counter_filtering": "Unknown" }); + v.extend([ + with(claim("energy_matches_labelled_delivery_accept", "the object's leaf is labelled, and the claim restates the leaf's figure, class and evidence", + ecw.clone(), Ok(())), "object", labelled(12_000, counter_label.clone())), + with(claim("energy_filtering_absent_reads_unknown_accept", "the leaf's OnChipCounter label states no filtering state, which reads as Unknown, as the claim writes it", + ecw.clone(), Ok(())), "object", labelled(12_000, json!({ "energy_provenance": "OnChipCounter", "energy_uncertainty": { "relative_ppm": 20_000, "window_us": 1_000_000 } }))), + with(reject("energy_class_disagrees_with_labelled_delivery", "the leaf labels the figure ModelBased; the claim says OnChipCounter", + ecw.clone(), 6, Reason::EnergyClassDisagreesWithDelivery), "object", labelled(12_000, json!({ "energy_provenance": "ModelBased" }))), + with(reject("energy_evidence_disagrees_with_labelled_delivery", "the leaf's declared uncertainty is 20,001 ppm; the claim's is 20,000", + ecw.clone(), 6, Reason::EnergyClassDisagreesWithDelivery), "object", + labelled(12_000, json!({ "energy_provenance": "OnChipCounter", "energy_uncertainty": { "relative_ppm": 20_001, "window_us": 1_000_000 }, "energy_counter_filtering": "Unknown" }))), + with(reject("energy_labelled_delivery_figure_disagrees", "the labelled leaf's figure differs: the figure is compared before the class", + ecw.clone(), 6, Reason::EnergyDisagreesWithDelivery), "object", labelled(11_000, json!({ "energy_provenance": "ModelBased" }))), + ]); + + // ── step 2: what the member tables type ── + let edit = |f: &dyn Fn(&mut Map)| resigned(&m_accept, &meter(), |m| f(body_of(m))); + let bracket_of = |m: &mut Map| -> Map { m["bracket"].as_object().cloned().expect("a bracket") }; + let op_wire = seal(&meter(), meter_id, SubjectType::Receipt, e_unit.link_hash(), 2, op_body(hdr(), p1080(), p1080(), p1080(), prediction(450_000)), None); + let op_edit = |f: &dyn Fn(&mut Map)| resigned(&op_wire, &meter(), |m| f(body_of(m))); + v.extend([ + reject("measurement_missing_member", "a report without power_reader: a nullable member is present and null", edit(&|b| { b.remove("power_reader"); }), 2, Reason::MissingRequiredField), + reject("measurement_bracket_unknown_member", "a bracket carrying its ratio: derived quantities are never carried", edit(&|b| { let mut br = bracket_of(b); br.insert("ratio_milli".into(), 86_602.into()); b.insert("bracket".into(), Value::Object(br)); }), 2, Reason::UnknownMember), + reject("measurement_summary_missing_liveness", "an idle summary without live_intervals", edit(&|b| { let mut br = bracket_of(b); br["idle"].as_object_mut().expect("idle").remove("live_intervals"); b.insert("bracket".into(), Value::Object(br)); }), 2, Reason::MissingRequiredField), + reject("measurement_opening_outside_enumeration", "opening is from-rest or running", edit(&|b| { let mut br = bracket_of(b); br.insert("opening".into(), "rest".into()); b.insert("bracket".into(), Value::Object(br)); }), 2, Reason::FieldType), + reject("measurement_build_profile_outside_enumeration", "a build profile is release or debug, in that case", edit(&|b| { b["build"]["profile"] = "Release".into(); }), 2, Reason::FieldType), + reject("measurement_mean_fractional", "a mean in microwatts written as a string: a bracket carries integers", edit(&|b| { let mut br = bracket_of(b); br["busy"]["mean_uw"] = "30000000.5".into(); b.insert("bracket".into(), Value::Object(br)); }), 2, Reason::FieldType), + reject("measurement_profile_not_a_term", "a figure profile holding a space", edit(&|b| { b.insert("figure_profile".into(), "moq-jwp: claim-link".into()); }), 2, Reason::FieldType), + reject("measurement_target_not_a_term", "a target holding a space", edit(&|b| { b["build"]["target"] = "x86 64".into(); }), 2, Reason::FieldType), + reject("operating_point_key_not_a_term", "a parameter name holding a space", op_edit(&|b| { b["op_delivered"] = json!({ "frame rate": 30_000 }); }), 2, Reason::FieldType), + reject("operating_point_value_not_integer", "a parameter value written as a string", op_edit(&|b| { b["op_delivered"] = json!({ "height": "1080" }); }), 2, Reason::FieldType), + reject("operating_point_descriptor_hash_not_hex", "a descriptor hash in uppercase hex", op_edit(&|b| { let h = b["prediction"]["descriptor_hash"].as_str().expect("hex").to_uppercase(); b["prediction"]["descriptor_hash"] = h.into(); }), 2, Reason::FieldType), + reject("operating_point_vocabulary_not_absolute", "a vocabulary with no scheme", op_edit(&|b| { b.insert("vocabulary".into(), "vocabulary.example.org/operating-points".into()); }), 2, Reason::FieldType), + reject("measurement_values_rewritten", "the busy mean rewritten after signing: every value is inside the signature", tamper(&m_wire, "\"mean_uw\":30000000", "\"mean_uw\":31000000"), 3, Reason::SignatureVerifyFailed), + ]); + + // ── step 4 ── + let hw_report = |b: BracketValues, class: &EnergyProvenance, f: u64| bad_about(&e_unit, measurement_body(claim_link(f, class), Some(b), release())); + let at = |idle: BracketSummary, busy: BracketSummary| BracketValues { idle, busy, ..unit }; + let facts = |s: BracketSummary, live_intervals: u64, longest_live_run: u64| BracketSummary { live_intervals, longest_live_run, ..s }; + let sealed = bracket(live(10, 20_000_000, 105_410), live(14, 29_910_000, 176_418), 2_007_000, 1_301, 10, WindowOpening::FromRest); + let (f_sealed, p_sealed) = fp(&sealed); + let (f_wide, p_wide) = fp(&wide); + #[allow(deprecated)] + let hw = EnergyProvenance::HwShunt; + v.extend([ + reject("measurement_zero_figure", "a zero figure is the unmetered marker or a failed measurement; it gets no report", + bad_about(&e_unit, measurement_body(claim_link(0, &model), None, release())), 4, Reason::EnergyZeroFigure), + reject("measurement_unknown_profile", "a figure profile energy-measurement section 6.1 does not define", + bad_about(&e_unit, measurement_body(FigureHeader::new("wai:nonexistent-id", None, None, f_unit, &c_unit), Some(unit), release())), 4, Reason::FigureUnknownProfile), + reject("measurement_object_profile_as_receipt", "moq-jwp:object names an object; a report naming it about a receipt names no profile", + bad_about(&e_unit, measurement_body(FigureHeader::new("moq-jwp:object", Some(0), Some(group), f_unit, &c_unit), Some(unit), release())), 4, Reason::FigureUnknownProfile), + reject("measurement_index_for_single_figure", "an index under a profile that carries one figure", + bad_about(&e_unit, measurement_body(FigureHeader::new("moq-jwp:claim-link", Some(0), None, f_unit, &c_unit), Some(unit), release())), 4, Reason::FigureIndexMismatch), + reject("measurement_no_index_for_steps", "no index under a profile that carries a figure per step", + seal_bad(&meter(), meter_id, SubjectType::Receipt, asset, 1, measurement_body(FigureHeader::new("wai:asset-receipt-id", None, None, 4_200, &model), None, release())), 4, Reason::FigureIndexMismatch), + reject("measurement_group_outside_object_profile", "a group under a profile that names none", + bad_about(&e_unit, measurement_body(FigureHeader::new("moq-jwp:claim-link", None, Some(group), f_unit, &c_unit), Some(unit), release())), 4, Reason::FigureGroupMismatch), + reject("measurement_object_without_group", "a delivered object named with no group: one content hash can be delivered in more than one", + seal_bad(&publisher(), "did:example:publisher", SubjectType::Object, ancestor(), 1, measurement_body(FigureHeader::new("moq-jwp:object", Some(2), None, f_unit, &c_unit), Some(unit), release())), 4, Reason::FigureGroupMismatch), + reject("measurement_legacy_class", "a report never restates the legacy HwShunt", + bad_about(&e_unit, measurement_body(claim_link(f_unit, &hw), None, release())), 4, Reason::EnergyLegacyAcquisition), + reject("measurement_class_without_evidence", "an OnChipCounter with no declared uncertainty", + bad_about(&e_unit, ClaimBody::Measurement(MeasurementClaim { + figure: FigureHeader { energy_uncertainty: None, ..claim_link(f_unit, &c_unit) }, + bracket: Some(unit), build: release(), power_reader: Some(READER.into()), + })), 4, Reason::EnergyAcquisitionEvidence), + reject("measurement_no_power_reader", "an OnChipCounter report that names no power reader: a hardware figure is a reading, and its report names what read it", + bad_about(&e_unit, ClaimBody::Measurement(MeasurementClaim { figure: claim_link(f_unit, &c_unit), bracket: Some(unit), build: release(), power_reader: None })), 4, Reason::MeasurementNoPowerReader), + reject("measurement_no_power_reader_no_bracket", "an OnChipCounter report with neither a bracket nor a reader: omitting the bracket does not excuse the reader", + bad_about(&e_unit, ClaimBody::Measurement(MeasurementClaim { figure: claim_link(f_unit, &c_unit), bracket: None, build: release(), power_reader: None })), 4, Reason::MeasurementNoPowerReader), + reject("measurement_one_idle_sample", "an idle bracket of one sample has no standard deviation", + hw_report(at(live(1, 20_000_000, 0), unit.busy), &c_unit, f_unit), 4, Reason::MeasurementTooFewSamples), + reject("measurement_rail_not_live", "the busy bracket's rail advanced in 4 samples of 14: below five, and below four in five", + hw_report(at(unit.idle, facts(unit.busy, 4, 2)), &c_unit, f_unit), 4, Reason::MeasurementRailNotLive), + reject("measurement_flat_idle_frozen_rail", "a frozen rail refreshed once: an idle bracket of zeros and one busy sample holding the refresh. The flat idle gives an infinite ratio; the rail was not live", + hw_report(at(BracketSummary { n: 10, mean_uw: 0, sd_uw: 0, live_intervals: 0, longest_live_run: 0 }, BracketSummary { n: 14, mean_uw: 10_000_000, sd_uw: 37_416_574, live_intervals: 1, longest_live_run: 1 }), &c_unit, f_unit), 4, Reason::MeasurementRailNotLive), + reject("measurement_flat_idle_claimed_live", "an idle bracket whose samples are all equal (sd 0), its facts claiming the rail advanced in every sample: a repeated sample is not an advance, so a flat bracket is never a live rail, whatever facts it carries", + hw_report(at(live(10, 20_000_000, 0), unit.busy), &c_unit, f_unit), 4, Reason::MeasurementRailNotLive), + reject("measurement_rail_stuck_at_held_value", "a reader that holds its last value on a rail that is not refreshed: idle reads 5 W in all ten samples, busy 5 W three times and then 12 W, where one batch landed. Every sample is above zero and the flat idle gives an infinite ratio; the rail advanced once in the idle bracket and twice, never consecutively, in the busy one", + hw_report(at(BracketSummary { n: 10, mean_uw: 5_000_000, sd_uw: 0, live_intervals: 1, longest_live_run: 1 }, BracketSummary { n: 14, mean_uw: 10_500_000, sd_uw: 2_980_708, live_intervals: 2, longest_live_run: 1 }), &c_unit, f_unit), 4, Reason::MeasurementRailNotLive), + reject("measurement_rail_stuck_claimed_live", "the same held-value brackets, their facts claiming the rail advanced in every sample: the flat idle bracket still refuses them", + hw_report(at(live(10, 5_000_000, 0), live(14, 10_500_000, 2_980_708)), &c_unit, f_unit), 4, Reason::MeasurementRailNotLive), + reject("measurement_two_step_refresh", "a refresh published in two steps in each bracket: two consecutive advances, and nothing else", + hw_report(at(facts(unit.idle, 2, 2), facts(unit.busy, 2, 2)), &c_unit, f_unit), 4, Reason::MeasurementRailNotLive), + reject("measurement_liveness_facts_inconsistent", "15 live intervals in a bracket of 14 samples", + hw_report(at(unit.idle, facts(unit.busy, 15, 14)), &c_unit, f_unit), 4, Reason::MeasurementRailNotLive), + reject("measurement_no_completion", "no completion inside the window", + hw_report(BracketValues { completions: 0, ..unit }, &c_unit, f_unit), 4, Reason::MeasurementEmptyBurst), + reject("measurement_busy_not_above_idle", "the burst never rose above idle", + hw_report(at(unit.idle, live(14, 20_000_000, 115_471)), &c_unit, f_unit), 4, Reason::MeasurementNoMarginal), + reject("measurement_ratio_below_one", "a 10 W marginal against an idle standard deviation of 10.000001 W", + hw_report(at(live(10, 20_000_000, 10_000_001), unit.busy), &c_unit, f_unit), 4, Reason::MeasurementBelowAttributionFloor), + reject("measurement_half_up_rounded_down", "the 2.5 uJ estimate sealed as 2: a half rounds up", + bad_about(&e_unit, measurement_body(claim_link(2, &chip_class(200_000, half.window_us)), Some(half), release())), 4, Reason::MeasurementFigureMismatch), + reject("measurement_window_differs", "the declared window is one microsecond longer than the bracket's", + hw_report(unit, &chip_class(p_unit, unit.window_us + 1), f_unit), 4, Reason::MeasurementWindowMismatch), + reject("measurement_least_minus_one", "one ppm below the least the bracket supports", + hw_report(unit, &chip_class(p_unit - 1, unit.window_us), f_unit), 4, Reason::MeasurementDeclarationBelowBracket), + reject("measurement_sealed_from_samples", "the declaration a sealing path computes from the unrounded samples: one ppm below what the published integers support", + bad_about(&e_unit, measurement_body(claim_link(f_sealed, &chip_class(p_sealed - 1, sealed.window_us)), Some(sealed), release())), 4, Reason::MeasurementDeclarationBelowBracket), + reject("measurement_wide_least_minus_one", "one ppm below the least, at products past 200 bits", + bad_about(&e_unit, measurement_body(claim_link(f_wide, &chip_class(p_wide - 1, wide.window_us)), Some(wide), release())), 4, Reason::MeasurementDeclarationBelowBracket), + reject("measurement_two_defects", "one idle sample and no completion: the earlier rule is reported", + hw_report(BracketValues { idle: live(1, 20_000_000, 0), completions: 0, ..unit }, &c_unit, f_unit), 4, Reason::MeasurementTooFewSamples), + reject("operating_point_empty_point", "a committed point with no member", + op_bad(op_body(hdr(), None, point(&[]), p720(), None)), 4, Reason::OperatingPointEmptyPoint), + reject("operating_point_delivered_alone", "a delivered point with nothing committed to score it against", + op_bad(op_body(hdr(), None, None, p720(), None)), 4, Reason::OperatingPointDeliveredAlone), + reject("operating_point_request_without_delivery", "a request and a commitment with no delivery", + op_bad(op_body(hdr(), p1080(), p1080(), None, prediction(450_000))), 4, Reason::OperatingPointIncomplete), + reject("operating_point_nothing", "no delivery and no prediction: nothing recorded", + op_bad(op_body(hdr(), None, None, None, None)), 4, Reason::OperatingPointEmpty), + reject("operating_point_against_hwshunt_figure", "an operating point recorded against a figure restated as legacy HwShunt", + op_bad(op_body(claim_link(f_unit, &hw), None, p720(), p720(), None)), 4, Reason::EnergyLegacyAcquisition), + ]); + + // ── step 6: against the figure ── + let e_other = named(f_unit + 5, &c_unit); + let e_off = named(f_unit, &EnergyProvenance::OnChipCounter { uncertainty: Uncertainty { relative_ppm: p_unit, window_us: unit.window_us }, filtering: CounterFiltering::Off }); + let mut e_tampered = e_unit.clone(); + e_tampered.sig[0] ^= 1; + let about_e = |e: &ClaimReceipt, k: &SigningKey, id: &str| seal(k, id, SubjectType::Receipt, e.link_hash(), 1, unit_report(&c_unit, unit), None).to_json(); + let unlabelled_asset = view("wai:asset-receipt-id", asset, None, &[(Some(0), 0, None), (Some(1), 4_200, None)], Some(&meter())); + let unmetered_view = view("moq-jwp:claim-link", e_unit.link_hash(), None, &[(None, 0, None)], Some(&meter())); + v.extend([ + with(reject("measurement_identity_mismatch", "the receipt held is not the one the report names", + seal(&meter(), meter_id, SubjectType::Receipt, transformed(), 1, unit_report(&c_unit, unit), None).to_json(), 6, Reason::ContextMismatch), "figure", claim_ctx(&e_unit)), + with(reject("measurement_index_out_of_range", "step 7 of a provenance receipt with three", + seal(&meter(), meter_id, SubjectType::Receipt, asset, 1, step_report(7, 4_200), None).to_json(), 6, Reason::ContextMismatch), "figure", asset_view(&meter())), + with(reject("measurement_object_other_group", "the same object delivered in another group: the report names this group, the view holds another", + seal(&publisher(), "did:example:publisher", SubjectType::Object, ancestor(), 1, obj_report([0x9c; 32]), None).to_json(), 6, Reason::ContextMismatch), "figure", obj_view.clone()), + with(reject("measurement_of_tampered_energy_claim", "the energy claim the report names was altered after it was signed: it is no context", + about_e(&e_tampered, &meter(), meter_id), 6, Reason::ContextInvalid), "figure", claim_ctx(&e_tampered)), + with(reject("measurement_figure_differs", "the energy claim states 5 uJ more than the report restates", + about_e(&e_other, &meter(), meter_id), 6, Reason::FigureDisagrees), "figure", claim_ctx(&e_other)), + with(reject("measurement_of_unlabelled_figure", "a step whose figure carries no class: a report does not supply one", + seal(&meter(), meter_id, SubjectType::Receipt, asset, 1, step_report(1, 4_200), None).to_json(), 6, Reason::FigureUnclassified), "figure", unlabelled_asset.clone()), + with(reject("measurement_class_differs", "the energy claim's counter reads Off; the report restates Unknown", + about_e(&e_off, &meter(), meter_id), 6, Reason::FigureClassDisagrees), "figure", claim_ctx(&e_off)), + reject("measurement_figure_not_held", "the operator's report on the meter's figure, checked without the receipt that carries the figure: it passes steps 1-5, but nothing shows its signer signed the figure, so it is not accepted as the figure signer's report", + about_e(&e_unit, &operator(), "did:example:operator"), 6, Reason::FigureNotHeld), + reject("measurement_own_figure_not_held", "the meter's own report, checked without the figure: the same outcome; a report is accepted only against its figure", + m_wire.clone(), 6, Reason::FigureNotHeld), + with(reject("measurement_other_signer", "the operator reports on the meter's figure: only the key that signed a figure signs its report", + about_e(&e_unit, &operator(), "did:example:operator"), 6, Reason::FigureSignerMismatch), "figure", claim_ctx(&e_unit)), + with(reject("measurement_of_binding", "a report on an energy binding's figure, which no Ed25519 key signs: nobody's report can be the figure signer's", + seal(&meter(), meter_id, SubjectType::Receipt, binding_id, 1, measurement_body(FigureHeader::new("wai:energy-binding-id", None, None, f_unit, &c_unit), Some(unit), release()), None).to_json(), + 6, Reason::FigureSignerUnknown), "figure", binding_view.clone()), + with(reject("operating_point_against_unmetered_figure", "the figure the claim names carries the unmetered marker: a zero is not a figure to record an operating point against", + op(&meter(), meter_id, op_body(hdr(), None, p720(), p720(), None)), 6, Reason::FigureDisagrees), "figure", unmetered_view), + with(reject("operating_point_of_unlabelled_figure", "a step whose figure carries no class", + seal(&meter(), meter_id, SubjectType::Receipt, asset, 2, op_body(FigureHeader::new("wai:asset-receipt-id", Some(1), None, 4_200, &model), None, p720(), p720(), None), None).to_json(), + 6, Reason::FigureUnclassified), "figure", unlabelled_asset), + with(reject("measurement_figure_given_to_an_energy_claim", "a figure context given to a claim that reports on no figure", + ecw.clone(), 6, Reason::ContextMismatch), "figure", claim_ctx(&e_unit)), + ]); v } @@ -3248,7 +4343,7 @@ mod corpus { let mut doc = Map::new(); doc.insert( "about".into(), - "jwp-receipts section 2.4.3-2.4.7, 2.6 and 3.1-3.2: typed claim receipts and the soft \ + "jwp-receipts section 2.4.3-2.4.9, 2.6 and 3.1-3.2: typed claim receipts and the soft \ binding class. Every vector is synthetic: keys are Ed25519 from fixed 32-byte seeds \ (0x11 publisher, 0x22 meter, 0x33 operator, 0x44 sink, 0x55 resolver) and attest \ nothing; figures are invented; algorithm names, endpoints, logs and registries use \ @@ -3333,7 +4428,12 @@ mod corpus { if let Some(o) = c.get("object") { let h = hash_of(o.get("content_hash"), "object.content_hash")?; let j = o.get("joules_micro").and_then(Value::as_u64).ok_or("bad object.joules_micro")?; - if let Err(e) = r.check_energy_against_object(&h, j) { + // The class the object's leaf carries, where its leaf is labelled: read + // as energy-measurement §2.7 reads a class, so a malformed one is a + // malformed corpus, not a verdict. + let om = o.as_object().ok_or("object context must be an object")?; + let label = crate::energy_class::label_from_json(om).map_err(|e| format!("bad object label: {e}"))?; + if let Err(e) = r.check_energy_against_labelled_object(&h, j, label.as_ref()) { return Ok(Err(e)); } } @@ -3344,6 +4444,17 @@ mod corpus { return Ok(Err(e)); } } + match c.get("figure") { + Some(f) => { + if let Err(e) = r.check_figure_against(figure_view_of(f)?.as_ref()) { + return Ok(Err(e)); + } + } + // A report is its figure signer's only once step 6 has run against the + // figure (§2.4.8 check 5); checked without it, it is not accepted. + None if r.needs_figure() => return Ok(Err(Rejection::at(6, Reason::FigureNotHeld))), + None => {} + } if let Some(w) = ctx_wire("declaration")? { // §2.6.3: the declaration is context only together with the publisher // key; a corpus that supplies one without the other is malformed, not @@ -3373,6 +4484,50 @@ mod corpus { } } + /// A `figure` context: the receipt that carries the figure a report names — an + /// `energy` claim's wire form (`claim`), viewed as `crate::energy_figure` views it, or + /// a receipt the verifier has verified, given as its view (`view`): `{profile, + /// identity, group, figures: [{index, joules_micro, energy_* members}], signer}`. + /// `Ok(None)` is a receipt that does not verify. + fn figure_view_of(f: &Value) -> Result, String> { + use crate::energy_figure::{EnergyFigures, Figure}; + if let Some(w) = f.get("claim") { + let w = w.as_str().ok_or("a figure claim is given as its wire form")?; + return Ok(ClaimReceipt::from_json(w).ok().and_then(|c| c.figure_view())); + } + let v = f.get("view").and_then(Value::as_object).ok_or("figure context has neither claim nor view")?; + let opt_hash = |k: &str| -> Result, String> { + match v.get(k) { + Some(Value::Null) => Ok(None), + h => hash_of(h, k).map(Some), + } + }; + let figures = v + .get("figures") + .and_then(Value::as_array) + .ok_or("view.figures")? + .iter() + .map(|f| { + let fm = f.as_object().ok_or("view figure")?; + let index = match fm.get("index") { + Some(Value::Null) => None, + Some(i) => Some(i.as_u64().ok_or("view figure index")?), + None => return Err("view figure index".to_string()), + }; + let joules_micro = fm.get("joules_micro").and_then(Value::as_u64).ok_or("view figure joules_micro")?; + let class = crate::energy_class::label_from_json(fm).map_err(|e| format!("view figure class: {e}"))?; + Ok(Figure { index, joules_micro, class }) + }) + .collect::, String>>()?; + Ok(Some(FigureView { + profile: v.get("profile").and_then(Value::as_str).ok_or("view.profile")?.to_owned(), + identity: hash_of(v.get("identity"), "view.identity")?, + group: opt_hash("group")?, + figures, + signer: opt_hash("signer")?, + })) + } + pub(super) fn verdicts(doc: &str) -> Result, String> { // A corpus file holds OER receipts, so it is read as oer-receipts §4 reads a vector // document; one that reading refuses has no verdicts. @@ -3536,6 +4691,118 @@ mod tests { assert_eq!(e, Err(Rejection::at(4, Reason::EnergyGradeExceedsAcquisition))); } + /// A `measurement` claim is its figure signer's report only against the figure: with + /// no figure held it is `figure_not_held`, whoever signed it, and no verdict is given; + /// against the figure, another signer is `figure_signer_mismatch`. A hardware report + /// that names no reader is refused at step 4. + #[test] + fn a_report_is_the_figure_signers_only_against_the_figure() { + use crate::energy_figure::EnergyFigures; + let s = |n, mean_uw| BracketSummary { n, mean_uw, sd_uw: 115_471, live_intervals: n, longest_live_run: n }; + let b = BracketValues { idle: s(10, 20_000_000), busy: s(14, 30_000_000), window_us: 3_000_000, completions: 60, in_flight: 10, opening: WindowOpening::FromRest }; + let f = b.figure().unwrap(); + let class = EnergyProvenance::on_chip_counter(Uncertainty::new(b.least_relative_ppm(f).unwrap(), b.window_us).unwrap(), CounterFiltering::Unknown).unwrap(); + let (meter, other) = (k(2), k(3)); + let e = ClaimReceipt::seal(&meter, "m", SubjectType::Object, [9; 32], 0, T, + ClaimBody::Energy(EnergyClaim::new(f, &class, Coverage::Whole, "measured", "https://example.org/m", 1, "unit", None)), None).unwrap(); + let view = e.figure_view().unwrap(); + let report = |key: &SigningKey, reader: Option<&str>| { + let body = ClaimBody::Measurement(MeasurementClaim { + figure: FigureHeader::new("moq-jwp:claim-link", None, None, f, &class), + bracket: Some(b), + build: BuildRecord::of_this_build(), + power_reader: reader.map(str::to_owned), + }); + ClaimReceipt::seal_unchecked(key, "r", SubjectType::Receipt, e.link_hash(), 1, T, body, None).unwrap() + }; + let own = report(&meter, Some("reader:example")); + let forged = report(&other, Some("reader:example")); + assert!(own.verify().is_ok() && forged.verify().is_ok(), "both pass steps 1-4 on their own"); + assert_eq!(own.attribution(Some(&view)), Ok(Some(Attribution::Clean))); + assert_eq!(forged.attribution(None), Err(Rejection::at(6, Reason::FigureNotHeld)), "no verdict from anyone's numbers"); + assert_eq!(own.attribution(None), Err(Rejection::at(6, Reason::FigureNotHeld))); + assert_eq!(forged.attribution(Some(&view)), Err(Rejection::at(6, Reason::FigureSignerMismatch))); + assert_eq!(report(&meter, None).verify(), Err(Rejection::at(4, Reason::MeasurementNoPowerReader))); + assert_eq!(e.attribution(Some(&view)), Err(Rejection::at(6, Reason::ContextMismatch)), "an energy claim is no report"); + } + + /// The builder refuses what a verifier must reject in the report classes too, at the + /// step a verifier rejects it. + #[test] + fn seal_refuses_a_report_a_verifier_must_reject() { + let class = EnergyProvenance::ModelBased; + let h = |profile: &str, index: Option, j: u64| FigureHeader::new(profile, index, None, j, &class); + let m = |h: FigureHeader, b: Option| { + ClaimBody::Measurement(MeasurementClaim { figure: h, bracket: b, build: BuildRecord::of_this_build(), power_reader: None }) + }; + let seal = |body: ClaimBody| ClaimReceipt::seal(&k(1), "m", SubjectType::Receipt, [1; 32], 0, T, body, None); + assert!(seal(m(h("moq-jwp:claim-link", None, 5), None)).is_ok()); + assert_eq!(seal(m(h("moq-jwp:claim-link", None, 0), None)), Err(Rejection::at(4, Reason::EnergyZeroFigure))); + assert_eq!(seal(m(h("wai:nothing-id", None, 5), None)), Err(Rejection::at(4, Reason::FigureUnknownProfile))); + assert_eq!(seal(m(h("moq-jwp:claim-link", Some(0), 5), None)), Err(Rejection::at(4, Reason::FigureIndexMismatch))); + assert_eq!(seal(m(h("moq-jwp claim-link", None, 5), None)), Err(Rejection::at(2, Reason::FieldType))); + let b = BracketValues { + idle: BracketSummary { n: 1, mean_uw: 1, sd_uw: 0, live_intervals: 1, longest_live_run: 1 }, + busy: BracketSummary { n: 5, mean_uw: 9, sd_uw: 1, live_intervals: 5, longest_live_run: 5 }, + window_us: 1, + completions: 1, + in_flight: 1, + opening: WindowOpening::Running, + }; + assert_eq!(seal(m(h("moq-jwp:claim-link", None, 5), Some(b))), Err(Rejection::at(4, Reason::MeasurementTooFewSamples))); + let op = |d: Option, c: Option| { + ClaimBody::OperatingPoint(OperatingPointClaim { + figure: h("moq-jwp:claim-link", None, 5), + vocabulary: "https://vocabulary.example.org/op".into(), + op_requested: None, + op_committed: c, + op_delivered: d, + prediction: None, + }) + }; + assert_eq!(seal(op(None, None)), Err(Rejection::at(4, Reason::OperatingPointEmpty))); + let p = |v: u64| Some(OperatingPoint::from([("height".to_owned(), v)])); + assert_eq!(seal(op(p(720), None)), Err(Rejection::at(4, Reason::OperatingPointDeliveredAlone))); + let bad_key = OperatingPoint::from([("frame rate".to_owned(), 1)]); + assert_eq!(seal(op(Some(bad_key), p(1))), Err(Rejection::at(2, Reason::FieldType))); + assert!(seal(op(p(720), p(720))).is_ok()); + } + + /// Whether a commitment was met, and a prediction's residual, are derived from what + /// the claim carries; and §4's verdict from a report's bracket. + #[test] + fn readers_derive_what_no_claim_states() { + let p = |v: u64| Some(OperatingPoint::from([("height".to_owned(), v)])); + let c = |committed, delivered, predicted: Option| OperatingPointClaim { + figure: FigureHeader::new("moq-jwp:claim-link", None, None, 1_000, &EnergyProvenance::ModelBased), + vocabulary: "https://vocabulary.example.org/op".into(), + op_requested: None, + op_committed: committed, + op_delivered: delivered, + prediction: predicted.map(|x| Prediction { predictor: "https://example.org/p".into(), descriptor_hash: [0; 32], predicted_joules_micro: x }), + }; + assert_eq!(c(p(1080), p(1080), None).commitment_met(), Some(true)); + assert_eq!(c(p(1080), p(720), None).commitment_met(), Some(false)); + assert_eq!(c(None, None, Some(5)).commitment_met(), None, "no commitment, nothing to meet"); + assert_eq!(c(None, None, Some(1_200)).residual_uj(), Some(-200)); + assert_eq!(c(None, None, Some(800)).residual_uj(), Some(200)); + assert_eq!(c(p(1), p(1), None).residual_uj(), None); + } + + #[test] + fn rfc3339_utc_is_civil_time() { + for (secs, want) in [ + (0, "1970-01-01T00:00:00Z"), + (1_790_510_400, "2026-09-27T12:00:00Z"), + (1_835_395_200, "2028-02-29T00:00:00Z"), + (951_782_400, "2000-02-29T00:00:00Z"), + (4_102_444_799, "2099-12-31T23:59:59Z"), + ] { + assert_eq!(rfc3339_utc(secs), want); + assert!(rfc3339(&rfc3339_utc(secs))); + } + } + /// oer-receipts §2's correspondence, as a table: each acquisition class admits every /// grade up to the one it supports, and none above it. Legacy HwShunt admits none: a /// claim never carries it, whatever its grade. @@ -3950,6 +5217,21 @@ mod tests { assert_eq!(unhex("abc"), None); } + /// The vectors the corpus held before the `measurement` and `operating-point` + /// classes were added, pinned as one BLAKE3 over their compact JSON as the generator + /// writes them. New vectors are appended after them; this fails if appending, or any + /// change to the shared code, disturbs one of them — its receipt, its context, its + /// declared outcome or its link hash. The digest was computed before the classes + /// were added and must never be edited to make the test pass. + #[test] + fn the_existing_vectors_are_unchanged() { + let doc: Value = crate::oer::load(conformance_vectors().as_bytes()).unwrap(); + let vs = doc["vectors"].as_array().unwrap(); + let first = Value::Array(vs[..154].to_vec()); + let digest = blake3::hash(serde_json::to_string(&first).unwrap().as_bytes()); + assert_eq!(hex(digest.as_bytes()), "7e1a56e5df8af7e1dbf9ecef0883971e45d0c9277c29b611f9705a7644fb7f05"); + } + /// The pinned corpus is exactly what the generator produces today (canonical form, /// signatures and link hashes unchanged), and every vector gets its declared outcome. #[test] diff --git a/wai/wai-rs/src/energy_binding.rs b/wai/wai-rs/src/energy_binding.rs index 3138cff2c..b152f4877 100644 --- a/wai/wai-rs/src/energy_binding.rs +++ b/wai/wai-rs/src/energy_binding.rs @@ -23,10 +23,11 @@ //! [`crate::reconstruction_binding::ReconstructionBinding`] commits to. This is the //! whole point: because a WAI reconstruction is byte-exact and reproducible on every //! architecture, a verifier can re-decode, confirm it holds the same pixels the figure -//! is about, and be left with **exactly one** unverifiable term — the joules. An +//! is about, and be left with the measurement as the unverifiable part — the joules +//! and the fields that describe how they were obtained (energy-binding §3). An //! 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. +//! energy claim about an output anyone can reproduce is checkable except for its +//! measurement, and knowing precisely which fields those are is the difference. //! - **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 @@ -568,6 +569,11 @@ mod tests { 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}"); + // The whole bodies, pinned as the refined classes first emitted them, so a + // member that moves, or a byte of evidence that changes, fails here and not + // only a member that disappears. Never edit these to make a test pass. + assert_eq!(j, "{\"reconstructionHash\":\"4dbdc2b2b62cb00749785bc84202236dbc3777d74660611b8e58812f0cfde6c3\",\"alg\":\"sha256\",\"microjoules\":10565,\"acquisition\":\"OnChipCounter\",\"uncertainty\":{\"relativePpm\":30000,\"windowUs\":2000000},\"counterFiltering\":\"Unknown\",\"coverage\":\"Whole\",\"workUnits\":48,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\"}"); + assert_eq!(j2, "{\"reconstructionHash\":\"4dbdc2b2b62cb00749785bc84202236dbc3777d74660611b8e58812f0cfde6c3\",\"alg\":\"sha256\",\"microjoules\":10565,\"acquisition\":\"CalibratedInstrument\",\"uncertainty\":{\"relativePpm\":5000,\"windowUs\":60000000},\"calibrationRef\":\"https://lab.example/certificates/0412\",\"coverage\":\"Partial\",\"workUnits\":48,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\"}"); for b in [chip_b, cal_b] { let v: serde_json::Value = serde_json::from_str(&b.to_json()).expect("valid JSON"); diff --git a/wai/wai-rs/src/energy_burst.rs b/wai/wai-rs/src/energy_burst.rs index a8e33de3c..5dd397ff2 100644 --- a/wai/wai-rs/src/energy_burst.rs +++ b/wai/wai-rs/src/energy_burst.rs @@ -35,16 +35,40 @@ //! 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 +//! (§2.3, §6.2). 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. //! +//! # Sealed from the integers a report carries +//! +//! The figure and its declaration are computed from the bracket's values as a +//! measurement report carries them (§6.2) — sample counts, means rounded to +//! the nearest microwatt, standard deviations rounded up to the next, the +//! window in microseconds — by the exact integer arithmetic of +//! [`crate::energy_report`], not from the unrounded samples. A verifier holding +//! the report then reproduces both numbers exactly. Computed from the samples, +//! a declaration can come out one ppm below what the published integers +//! support. +//! +//! # A live rail +//! +//! A power reader can report a rail that is not being refreshed: a frozen rail +//! reads as a flat zero or as its last value held, and a rail refreshed in +//! batches shows one lump — sometimes published in two steps — in a bracket +//! that otherwise does not move. Either gives a flat idle bracket and a +//! positive marginal, a ratio the §4 rule reads as clean, from no measurement +//! of the work. So both brackets must show the rail live (§3.1, +//! [`crate::energy_report::Liveness`]): advancing — a sample above zero that +//! differs from the one before it — in two consecutive samples, in at least +//! five, in at least four of every five, over at least 300 ms. +//! //! # 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 +//! A figure is sealed under a hardware class only when §3.1, §4 and §2.3 +//! allow it: the reader's rail was live in both brackets, 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). @@ -54,9 +78,10 @@ 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, + bracket_relative_uncertainty, check_resolution, per_unit_relative_uncertainty, CompletionCount, CounterFiltering, + EnergyProvenance, Uncertainty, WindowOpening, RESOLUTION_UNCERTAINTY_UJ, }; +use crate::energy_report::{BracketSummary, BracketValues, BuildRecord, Liveness}; /// Idle-bracket sample count the reference meters use. const IDLE_SAMPLES: u32 = 10; @@ -75,6 +100,9 @@ pub struct Bracket { pub busy_w: Vec, /// The timed window the busy bracket spans, in seconds. pub window_s: f64, + /// How long the idle bracket took to sample, in seconds — what §3.1's span + /// rule is checked against for the idle bracket. + pub idle_span_s: f64, /// Completions, workers, and how the window opened. pub count: CompletionCount, } @@ -118,10 +146,14 @@ impl Bracket { 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". + /// The §4 attribution-quality ratio, for display: 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". An infinite ratio is not a clean measurement either: + /// a flat idle is what a frozen rail gives, and whether the rail was live + /// is [`Bracket::rail_live`]'s question. What a receipt seals follows the + /// report's integers ([`Bracket::values`]), not this. pub fn ratio(&self) -> f64 { let m = self.marginal_w(); match self.idle_sd() { @@ -170,10 +202,58 @@ impl Bracket { } /// The figure a receipt seals: the per-unit estimate rounded to whole - /// microjoules. `None` when it rounds to zero. + /// microjoules, computed from the report's integers ([`Bracket::values`], + /// §6.2). `None` when it rounds to zero or there is nothing to compute it + /// from. 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) + self.values()?.figure() + } + + /// The bracket as a measurement report carries it (§6.2): each bracket's + /// sample count, its mean in microwatts rounded to the nearest (a half up), + /// its sample standard deviation in microwatts rounded up, and the samples + /// in which the reader's rail advanced (§3.1); the window in microseconds + /// rounded to the nearest; the completion and thread counts and how the + /// window opened. `None` with fewer than two samples in a bracket, a + /// sample or window that is negative or not finite, or a value above + /// 2⁵³ − 1, which no report can carry. + pub fn values(&self) -> Option { + const MAX: f64 = 9_007_199_254_740_991.0; + let to_int = |x: f64| (x.is_finite() && (0.0..=MAX).contains(&x)).then_some(x as u64); + let summary = |v: &[f64]| -> Option { + if v.iter().any(|x| !x.is_finite() || *x < 0.0) { + return None; + } + let live = Liveness::of_power_samples(v); + Some(BracketSummary { + n: v.len() as u64, + mean_uw: to_int((mean(v) * 1e6).round())?, + sd_uw: to_int((sample_sd(v)? * 1e6).ceil())?, + live_intervals: live.live_intervals, + longest_live_run: live.longest_live_run, + }) + }; + let (completions, in_flight) = (self.count.completions, self.count.in_flight); + if completions as f64 > MAX || in_flight as f64 > MAX { + return None; + } + Some(BracketValues { + idle: summary(&self.idle_w)?, + busy: summary(&self.busy_w)?, + window_us: to_int((self.window_s * 1e6).round())?, + completions, + in_flight, + opening: self.count.opening, + }) + } + + /// Whether the power reader's rail was live in both brackets (§3.1): + /// advancing in two consecutive samples, in at least five and in four of + /// every five, over at least 300 ms. + pub fn rail_live(&self) -> bool { + let span_us = |s: f64| if s.is_finite() && s > 0.0 { (s * 1e6) as u64 } else { 0 }; + Liveness::of_power_samples(&self.idle_w).is_live_over(span_us(self.idle_span_s)) + && Liveness::of_power_samples(&self.busy_w).is_live_over(span_us(self.window_s)) } /// `u_res`: the rounding to [`Bracket::sealed_uj`], relative to it @@ -182,15 +262,14 @@ impl Bracket { 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`]). + /// The sealed figure and the least declaration its report's integers + /// support, computed together from those integers so a verifier + /// reproduces both (§6.2). 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()?)) + let v = self.values()?; + let f = v.figure()?; + let p = v.least_relative_ppm(f)?; + Some((f, Uncertainty::new(p, v.window_us).ok()?)) } /// The declared uncertainty a receipt carries: the measurement's own @@ -207,7 +286,9 @@ impl Bracket { 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 { + } else if !self.rail_live() { + Some("the power reader's rail did not advance in enough intervals (§3.1): not a live reading") + } else if !self.values().is_some_and(|v| v.meets_attribution_floor()) { 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") @@ -222,12 +303,13 @@ impl Bracket { /// 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. + /// when §3.1, §4 or §2.3 forbid sealing a hardware class — a rail that was + /// not live, a 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. The figure and declaration are the ones + /// the report's integers give ([`Bracket::values`]). pub fn on_chip_figure(&self, filtering: CounterFiltering) -> Option<(u64, EnergyProvenance)> { - if self.ratio() < 1.0 { + if !self.rail_live() || !self.values()?.meets_attribution_floor() { return None; } let (uj, uncertainty) = self.sealed_declaration()?; @@ -270,6 +352,19 @@ impl Bracket { ); line("marginal (busy-idle)".into(), format!("{:.2} W", self.marginal_w())); line("marginal / idle sd".into(), format!("{:.1}x ({})", self.ratio(), self.verdict())); + let live = |v: &[f64]| { + let l = Liveness::of_power_samples(v); + format!("{} of {} samples, longest run {}", l.live_intervals, l.intervals, l.longest_live_run) + }; + line( + "rail live (§3.1)".into(), + format!( + "{} — idle {}; busy {}", + if self.rail_live() { "yes" } else { "NO" }, + live(&self.idle_w), + live(&self.busy_w) + ), + ); line("u_power (brackets)".into(), pct(self.u_power())); line(format!("u_count ({n} in flight)"), pct(self.u_count())); line( @@ -290,6 +385,31 @@ impl Bracket { None => format!("none — {}", self.unsealed_reason().unwrap_or("not evaluable from this run")), }, ); + line( + "sealed values (§6.2)".into(), + match self.values() { + Some(v) => format!( + "idle n={} mean={} uW sd={} uW; busy n={} mean={} uW sd={} uW; T={} us; C={}; N={}", + v.idle.n, v.idle.mean_uw, v.idle.sd_uw, v.busy.n, v.busy.mean_uw, v.busy.sd_uw, v.window_us, + v.completions, v.in_flight + ), + None => "none".into(), + }, + ); + line( + "derived verdict (§4)".into(), + self.values().and_then(|v| v.attribution()).map_or("none".into(), |a| a.label().to_string()), + ); + let b = BuildRecord::of_this_build(); + line( + "build".into(), + format!( + "{} ({}, {})", + b.profile.label(), + b.toolchain.as_deref().unwrap_or("toolchain unknown"), + b.target.as_deref().unwrap_or("target unknown") + ), + ); o } @@ -371,16 +491,41 @@ pub fn measure_with_macmon(label: &str, item: &T, work: W) -> Option(label: &str, item: &T, work: W, mut reader: R) -> Option +where + T: Clone + Send + 'static, + W: Fn(&T) + Send + Sync + 'static, + R: FnMut(u32) -> Vec, { eprintln!(" {label}: idle baseline …"); - let idle_w = macmon_cpu_power(IDLE_SAMPLES); + let t0 = Instant::now(); + let idle_w = reader(IDLE_SAMPLES); + let idle_span_s = t0.elapsed().as_secs_f64(); 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 }) + let (busy_w, window_s, count) = burst_from_rest(workers, item, work, || reader(BUSY_SAMPLES)); + Some(Bracket { idle_w, busy_w, window_s, idle_span_s, count }) +} + +/// The power reader the reference meters name in a measurement report: the +/// open-source reader and the reading it takes. +pub fn power_reader() -> &'static str { + "macmon cpu_power" } /// One sample burst from `macmon pipe` (the non-privileged IOReport reader), @@ -406,31 +551,71 @@ pub fn cpu_power_w(line: &str) -> Option { mod tests { use super::*; + /// A bracket whose window opened from rest, with the idle bracket sampled + /// over two seconds. 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, + idle_span_s: 2.0, count: CompletionCount { in_flight: n, completions: c, opening: WindowOpening::FromRest }, } } + /// `pair` repeated to `n` samples: a tight, live bracket. + fn rep(pair: [f64; 2], n: usize) -> Vec { + (0..n).map(|i| pair[i % 2]).collect() + } + #[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 b = bracket(&rep([19.9, 20.1], 10), &rep([29.9, 30.1], 14), 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(); + // The declaration is the least the report's integers support, and it + // is within a ppm of what the unrounded samples give. + let v = b.values().unwrap(); + assert_eq!(Some(u.relative_ppm), v.least_relative_ppm(uj)); 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!((u.relative_ppm as f64 - want * 1e6).abs() <= 1.5, "{} vs {}", u.relative_ppm, want * 1e6); assert_eq!(u.window_us, 3_000_000); } + /// The failing-before case: sealed from the unrounded samples, this + /// bracket's declaration is 6,232 ppm, one below the 6,233 ppm the + /// integers its report publishes support — so the report would refuse the + /// receipt it describes. Sealed from the integers, it is 6,233. + #[test] + fn a_sealed_declaration_is_what_its_bracket_supports() { + let b = bracket(&rep([19.9, 20.1], 10), &rep([29.74, 30.08], 14), 2.007, 10, 1301); + let v = b.values().unwrap(); + assert_eq!( + (v.idle.n, v.idle.mean_uw, v.idle.sd_uw, v.busy.n, v.busy.mean_uw, v.busy.sd_uw, v.window_us), + (10, 20_000_000, 105_410, 14, 29_910_000, 176_418, 2_007_000) + ); + let (uj, class) = b.on_chip_figure(CounterFiltering::Unknown).unwrap(); + assert_eq!(uj, 15_229); + assert_eq!( + class, + EnergyProvenance::OnChipCounter { + uncertainty: Uncertainty { relative_ppm: 6_233, window_us: 2_007_000 }, + filtering: CounterFiltering::Unknown, + } + ); + assert_eq!(v.check(uj, Some((6_233, 2_007_000))), Ok(())); + assert_eq!( + v.check(uj, Some((6_232, 2_007_000))), + Err(crate::energy_report::ReportDefect::DeclarationBelowBracket) + ); + } + /// 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 { @@ -464,9 +649,26 @@ mod tests { 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); + // The 1 µJ figure is dominated by its resolution: 288,994 ppm, the + // value the independent script computes for these integers. + assert_eq!(small(0.563).declared().unwrap().relative_ppm, 288_994); + } + + /// Every bracket these tests seal: the figure and declaration are what the + /// report's integers give, so a verifier holding the report reproduces both. + #[test] + fn every_sealed_figure_is_reproduced_by_its_values() { + let mut brackets: Vec = [0.563, 10.35, 1.49, 42.6, 461_538.46, 0.51].into_iter().map(small).collect(); + brackets.push(bracket(&rep([19.9, 20.1], 10), &rep([29.9, 30.1], 14), 3.0, 10, 60)); + brackets.push(bracket(&rep([19.9, 20.1], 10), &rep([29.74, 30.08], 14), 2.007, 10, 1301)); + for b in brackets { + let (uj, class) = b.on_chip_figure(CounterFiltering::Unknown).expect("each of these seals"); + let v = b.values().unwrap(); + let u = class.uncertainty().unwrap(); + assert_eq!(v.figure(), Some(uj)); + assert_eq!(v.least_relative_ppm(uj), Some(u.relative_ppm)); + assert_eq!(v.check(uj, Some((u.relative_ppm, u.window_us))), Ok(())); + } } #[test] @@ -478,6 +680,27 @@ mod tests { assert_eq!(small(0.51).unsealed_reason(), None); } + #[test] + fn unsealed_reasons_follow_the_integer_rules() { + // A marginal that clears the idle noise only before the noise is + // rounded up: the idle sd is 541,487.34 µW and the marginal 0.1 µW + // above it, a ratio just over 1 from the samples; the report carries + // the sd rounded up to 541,488 µW and the marginal rounded to + // 541,487 µW, a ratio below 1, so nothing is sealed. + let live = rep([20.0 - 0.5137, 20.0 + 0.5137], 10); + let sd_idle = sample_sd(&live).unwrap(); + let busy = 20.0 + sd_idle + 1e-7; + let at_one = bracket(&live, &rep([busy - 1e-6, busy + 1e-6], 14), 3.0, 10, 5000); + assert!(at_one.ratio() > 1.0, "{}", at_one.ratio()); + let v = at_one.values().unwrap(); + assert_eq!((v.idle.sd_uw, v.marginal_uw()), (541_488, Some(541_487)), "{v:?}"); + assert!(at_one.on_chip_figure(CounterFiltering::Unknown).is_none()); + assert_eq!(at_one.unsealed_reason(), Some("the marginal draw did not rise above the idle noise (ratio below 1)")); + // No completion is reported before anything about the reader. + let none = bracket(&[0.0; 10], &[0.0; 14], 3.0, 10, 0); + assert_eq!(none.unsealed_reason(), Some("no completion inside the window")); + } + #[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); @@ -494,17 +717,166 @@ mod tests { 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); + let noisy = bracket(&rep([18.0, 22.0], 10), &rep([20.1, 20.3], 14), 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); + let none = bracket(&rep([19.9, 20.1], 10), &rep([29.9, 30.1], 14), 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()); } + // ── fault injection: a power reader whose rail is not live (§3.1) ── + + /// The burst these tests run: nothing, as fast as it goes. What is under + /// test is the reader in front of the protocol. + fn measure(reader: impl FnMut(u32) -> Vec) -> Bracket { + measure_with("fault", &(), |_| std::thread::sleep(std::time::Duration::from_millis(1)), reader) + .expect("the reader returns samples") + } + + /// A reader that returns `n` samples of `f(i)`, after sleeping long enough + /// that each bracket spans more than 300 ms. + fn reader(f: impl Fn(u32) -> f64) -> impl FnMut(u32) -> Vec { + move |n| { + std::thread::sleep(std::time::Duration::from_millis(320)); + (0..n).map(&f).collect() + } + } + + /// A frozen rail: the reader returns zeros in both brackets. + #[test] + fn a_frozen_rail_is_refused() { + let b = measure(reader(|_| 0.0)); + assert!(!b.rail_live()); + assert!(b.on_chip_figure(CounterFiltering::Unknown).is_none()); + assert!(b.unsealed_reason().is_some()); + } + + /// A rail refreshed in batches: the idle bracket is flat zero and one busy + /// sample carries the whole refresh. The flat idle makes the ratio + /// infinite and the arithmetic reads it as clean; before the liveness rule + /// this sealed an `OnChipCounter` figure. + #[test] + fn a_single_refresh_lump_is_refused() { + let mut calls = 0; + let b = measure(move |n| { + calls += 1; + std::thread::sleep(std::time::Duration::from_millis(320)); + let busy = calls == 2; + (0..n).map(|i| if busy && i == 6 { 140.0 } else { 0.0 }).collect() + }); + assert_eq!(b.ratio(), f64::INFINITY, "the display ratio is infinite"); + assert!(b.values().unwrap().attribution().is_some(), "the arithmetic gives a verdict"); + assert!(!b.rail_live()); + assert!(b.on_chip_figure(CounterFiltering::Unknown).is_none(), "one lump is not a live rail"); + assert_eq!( + b.unsealed_reason(), + Some("the power reader's rail did not advance in enough intervals (§3.1): not a live reading") + ); + } + + /// A rail refreshed in batches, one lump in each bracket: a larger one + /// while the burst runs. The idle bracket is no longer flat, the ratio is + /// above 1, and a rule that counted one advance as a live rail would seal + /// it. + #[test] + fn a_rail_refreshed_in_batches_is_refused() { + let mut calls = 0; + let b = measure(move |n| { + calls += 1; + std::thread::sleep(std::time::Duration::from_millis(320)); + let (at, lump) = if calls == 1 { (3, 20.0) } else { (6, 140.0) }; + (0..n).map(|i| if i == at { lump } else { 0.0 }).collect() + }); + assert!(b.ratio() >= 1.0, "{}", b.ratio()); + assert!(!b.rail_live()); + assert!(b.on_chip_figure(CounterFiltering::Unknown).is_none()); + } + + /// The same, with each refresh published in two steps in adjacent + /// samples. Every bracket shows two consecutive advances, so a rule of two + /// consecutive advances alone would seal it; the rest of §3.1 refuses it. + #[test] + fn a_two_step_refresh_is_refused() { + let mut calls = 0; + let b = measure(move |n| { + calls += 1; + std::thread::sleep(std::time::Duration::from_millis(320)); + let (at, step) = if calls == 1 { (3, 10.0) } else { (6, 70.0) }; + (0..n) + .map(|i| if i == at { step } else if i == at + 1 { step * 1.25 } else { 0.0 }) + .collect() + }); + for side in [&b.idle_w, &b.busy_w] { + let l = Liveness::of_power_samples(side); + assert_eq!((l.live_intervals, l.longest_live_run), (2, 2), "two consecutive advances"); + } + assert!(b.ratio() >= 1.0, "{}", b.ratio()); + assert!(!b.rail_live()); + assert!(b.on_chip_figure(CounterFiltering::Unknown).is_none()); + } + + /// A reader that holds its last value between refreshes, on a rail that + /// is not being refreshed: it reads a stale 5 W through the idle bracket + /// and steps once to 12 W, where one batch lands, in the busy bracket. + /// Every sample is above zero, the idle bracket is flat (sd 0), the + /// marginal is positive and the ratio infinite: a rule that counted a + /// non-zero sample as an advance sealed this as `OnChipCounter`. + #[test] + fn a_rail_stuck_at_a_held_value_is_refused() { + let mut calls = 0; + let b = measure(move |n| { + calls += 1; + std::thread::sleep(std::time::Duration::from_millis(320)); + let busy = calls == 2; + (0..n).map(|i| if busy && i >= 3 { 12.0 } else { 5.0 }).collect() + }); + assert!(b.idle_w.iter().chain(&b.busy_w).all(|w| *w > 0.0), "no sample reads zero"); + let v = b.values().unwrap(); + assert_eq!(v.idle.sd_uw, 0, "a flat idle bracket"); + assert_eq!(v.attribution(), Some(crate::energy_report::Attribution::Clean), "the arithmetic reads it as clean"); + assert!(!b.rail_live()); + assert!(b.on_chip_figure(CounterFiltering::Unknown).is_none(), "a held value is not a live rail"); + assert_eq!( + b.unsealed_reason(), + Some("the power reader's rail did not advance in enough intervals (§3.1): not a live reading") + ); + // And a reader of the report refuses it under a hardware class, whatever + // liveness facts the report carries. + let claimed = BracketValues { + idle: BracketSummary { live_intervals: v.idle.n, longest_live_run: v.idle.n, ..v.idle }, + busy: BracketSummary { live_intervals: v.busy.n, longest_live_run: v.busy.n, ..v.busy }, + ..v + }; + let f = claimed.figure().unwrap(); + let p = claimed.least_relative_ppm(f).unwrap(); + assert_eq!( + claimed.check(f, Some((p, claimed.window_us))), + Err(crate::energy_report::ReportDefect::RailNotLive) + ); + } + + /// A live reader seals. The busy samples are set high so that the burst's + /// own effect on the machine does not matter: the reader is scripted. + #[test] + fn a_live_rail_seals() { + let mut calls = 0; + let b = measure(move |n| { + calls += 1; + std::thread::sleep(std::time::Duration::from_millis(320)); + let level = if calls == 1 { 20.0 } else { 30.0 }; + (0..n).map(|i| level + if i % 2 == 0 { 0.1 } else { -0.1 }).collect() + }); + assert!(b.rail_live()); + assert!(b.on_chip_figure(CounterFiltering::Unknown).is_some()); + // The same samples over a bracket shorter than 300 ms are not live. + let short = Bracket { idle_span_s: 0.2, ..b.clone() }; + assert!(!short.rail_live() && short.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 diff --git a/wai/wai-rs/src/energy_class.rs b/wai/wai-rs/src/energy_class.rs index f177822d1..e8a160930 100644 --- a/wai/wai-rs/src/energy_class.rs +++ b/wai/wai-rs/src/energy_class.rs @@ -30,6 +30,15 @@ //! never reads it as any class — it is not upgraded to one — and checks a //! present label with [`check_label`]. //! +//! # Reports about a figure +//! +//! A figure obtained under the bracketed protocol is published with the values its +//! bracket gave, the build and the power reader, in a `measurement` claim about it +//! (`extensions/energy-measurement.md` §6, jwp-receipts §2.4.8), which names the +//! receipt sealing the figure and leaves it unchanged; an `operating-point` claim +//! records the operating point it was spent at (energy-binding §7). The class here is +//! what such a claim restates. +//! //! # Why hardware evidence is two classes, not one //! //! The first version of the ladder had a single hardware rung, `HwShunt`, for @@ -583,7 +592,8 @@ pub fn write_optional(p: Option<&EnergyProvenance>, o: &mut Vec) { /// enforce in `verify()` (the `wai.video.int_motion` and `wai.video.*` OTT /// receipts): /// -/// - unmetered (`0`, no class) is valid; +/// - the unmetered marker (`0`, no class) is valid — it marks a figure nobody +/// measured, never a figure of zero joules (energy-measurement §2); /// - 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`]); @@ -810,6 +820,31 @@ pub fn to_quantum_class( Ok(c) } +/// The same class in this crate's vocabulary, from `wai-quantum`'s +/// (`wai_quantum::quantum_energy::EnergyClass`): the inverse of +/// [`to_quantum_class`], with the same signed encoding. Every quantum class has a +/// counterpart here. +#[cfg(any(feature = "quantum_receipt", feature = "quantum_ops", feature = "quantum_phasor_meter"))] +pub fn from_quantum_class(c: &wai_quantum::quantum_energy::EnergyClass) -> EnergyProvenance { + use wai_quantum::quantum_energy as q; + let u = |u: &q::Uncertainty| Uncertainty { relative_ppm: u.relative_ppm, window_us: u.window_us }; + match c { + q::EnergyClass::ModelBased => EnergyProvenance::ModelBased, + q::EnergyClass::Estimator => EnergyProvenance::Estimator, + q::EnergyClass::OnChipCounter { uncertainty, filtering } => EnergyProvenance::OnChipCounter { + uncertainty: u(uncertainty), + filtering: match filtering { + q::CounterFiltering::Unknown => CounterFiltering::Unknown, + q::CounterFiltering::Off => CounterFiltering::Off, + q::CounterFiltering::On => CounterFiltering::On, + }, + }, + q::EnergyClass::CalibratedInstrument { uncertainty, calibration_ref } => { + EnergyProvenance::CalibratedInstrument { uncertainty: u(uncertainty), calibration_ref: calibration_ref.clone() } + } + } +} + /// The statistical (Type A) relative standard uncertainty of a bracketed /// marginal power draw — `u_power` in `extensions/energy-measurement.md` §2.3: /// @@ -1390,6 +1425,7 @@ mod tests { 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!(from_quantum_class(&q), p, "and the two conversions are inverse"); } assert_eq!(to_quantum_class(&EnergyProvenance::HwShunt), Err(AcquisitionError::LegacyLabel)); } diff --git a/wai/wai-rs/src/energy_figure.rs b/wai/wai-rs/src/energy_figure.rs new file mode 100644 index 000000000..439edb6a0 --- /dev/null +++ b/wai/wai-rs/src/energy_figure.rs @@ -0,0 +1,473 @@ +//! Figure profiles (`extensions/energy-measurement.md` §6.1): how a report about one +//! energy figure — a `measurement` or `operating-point` claim (jwp-receipts §2.4.8–§2.4.9) +//! — names the receipt that seals the figure, and what it reads there. +//! +//! A profile is named by the domain its receipt's identity is computed under, without +//! the domain's final version byte, so the registry cannot drift from the code: a +//! receipt family's identity domain *is* its profile's name. A profile fixes the +//! claim's subject type, how the identity is computed, which figure an index selects, +//! where the figure's class is, and which key signed it. +//! +//! [`EnergyFigures::figure_view`] gives, for a receipt this crate has verified, what a +//! report is checked against at jwp-receipts §3.1 step 6: the profile, the identity, +//! the figures with their classes, and the Ed25519 key that signed them. It is `None` +//! for a receipt that does not verify. No receipt family is changed by being viewed. +//! +//! The quantum receipts are viewed through `wai-quantum`'s `MaybeLabelled` and +//! `LabelledQbom`. Their profile names are read from the receipt type's identity +//! domain through the crate's labelling trait, whose methods `wai-quantum` documents +//! as plumbing rather than a public API; a test pins each type's profile name, so a +//! change there fails loudly here. + +use crate::claim_receipt::{jcs, ClaimBody, ClaimReceipt, SubjectType}; +use crate::energy_class::EnergyProvenance; + +/// One figure profile of energy-measurement §6.1. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Profile { + pub name: &'static str, + /// The `subject_type` a claim naming this profile carries. + pub subject_type: SubjectType, + /// Whether the receipt carries several figures, selected by `figure_index`. + pub indexed: bool, + /// Whether an Ed25519 key signs the figure. Only that key may sign a + /// `measurement` claim about it. + pub ed25519_signer: bool, + /// Whether the figure is named within a group: the `moq-jwp:object` profile, whose + /// subject is a content hash that more than one group can deliver. + pub grouped: bool, +} + +const fn p(name: &'static str, subject_type: SubjectType, indexed: bool, ed25519_signer: bool, grouped: bool) -> Profile { + Profile { name, subject_type, indexed, ed25519_signer, grouped } +} + +const PROFILES: &[Profile] = &[ + p("moq-jwp:object", SubjectType::Object, true, true, true), + p("moq-jwp:claim-link", SubjectType::Receipt, false, true, false), + p("wai:energy-binding-id", SubjectType::Receipt, false, false, false), + p("wai:video-receipt-id", SubjectType::Receipt, false, true, false), + p("wai:video-manifest-receipt-id", SubjectType::Receipt, false, true, false), + p("wai:video-ssai-receipt-id", SubjectType::Receipt, false, true, false), + p("wai:video-keys-receipt-id", SubjectType::Receipt, false, true, false), + p("wai:video-package-receipt-id", SubjectType::Receipt, false, true, false), + p("wai:video-channel-receipt-id", SubjectType::Receipt, false, true, false), + p("wai:world-receipt-id", SubjectType::Receipt, false, true, false), + p("wai:film-receipt-id", SubjectType::Receipt, false, true, false), + p("wai:haptic-receipt-id", SubjectType::Receipt, false, true, false), + p("wai:score-receipt-id", SubjectType::Receipt, false, true, false), + p("wai:splat4d-receipt-id", SubjectType::Receipt, false, true, false), + p("wai:audio-receipt-id", SubjectType::Receipt, false, true, false), + p("wai:ternary-receipt-id", SubjectType::Receipt, false, true, false), + p("wai:asset-receipt-id", SubjectType::Receipt, true, true, false), + p("wai:quantum-receipt-id", SubjectType::Receipt, false, true, false), + p("wai:quantum-calibration-id", SubjectType::Receipt, false, true, false), + p("wai:quantum-job-id", SubjectType::Receipt, false, true, false), + p("wai:quantum-mitigate-id", SubjectType::Receipt, false, true, false), + p("wai:quantum-decode-id", SubjectType::Receipt, false, true, false), + p("wai:quantum-compile-id", SubjectType::Receipt, false, true, false), + p("wai:quantum-rearrange-id", SubjectType::Receipt, false, true, false), + p("wai:phasor-receipt-id", SubjectType::Receipt, false, true, false), + p("wai:quantum-qbom-id", SubjectType::Receipt, true, true, false), +]; + +/// The §6.1 table. A function rather than a public constant, so the C header does +/// not gain it. +pub fn profiles() -> &'static [Profile] { + PROFILES +} + +/// The profile named `name`, if §6.1 has one. +pub fn profile(name: &str) -> Option<&'static Profile> { + PROFILES.iter().find(|p| p.name == name) +} + +/// The profile name of an identity domain: the domain without its final version byte. +pub fn profile_name_of_domain(domain: &[u8]) -> Option<&'static str> { + let (_, name) = domain.split_last()?; + let name = std::str::from_utf8(name).ok()?; + profile(name).map(|p| p.name) +} + +/// One figure a receipt carries. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Figure { + /// Its index, in a profile that carries several; `None` in one that carries one. + pub index: Option, + pub joules_micro: u64, + /// Its class; `None` for an unlabelled or unmetered figure. + pub class: Option, +} + +/// What a report about a figure is checked against (jwp-receipts §2.4.8, step 6): a +/// receipt that has verified, by profile and identity. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FigureView { + pub profile: String, + pub identity: [u8; 32], + /// For `moq-jwp:object`, the link hash of the group the object was accepted under. + pub group: Option<[u8; 32]>, + pub figures: Vec
, + /// The Ed25519 key that signed the figures, where one does. + pub signer: Option<[u8; 32]>, +} + +impl FigureView { + fn single(profile: &str, identity: [u8; 32], joules_micro: u64, class: Option, signer: [u8; 32]) -> FigureView { + FigureView { + profile: profile.into(), + identity, + group: None, + figures: vec![Figure { index: None, joules_micro, class }], + signer: Some(signer), + } + } + + /// The figure at `index`. + pub fn figure(&self, index: Option) -> Option<&Figure> { + self.figures.iter().find(|f| f.index == index) + } +} + +/// A receipt whose figures a report can name. +pub trait EnergyFigures { + /// The receipt's figures under its profile, or `None` when the receipt does not + /// verify. + fn figure_view(&self) -> Option; +} + +/// An `energy` claim (`moq-jwp:claim-link`): its figure and class, identified by its +/// link hash. A claim of another class carries no figure. +impl EnergyFigures for ClaimReceipt { + fn figure_view(&self) -> Option { + self.verify().ok()?; + let ClaimBody::Energy(e) = &self.body else { + return Some(FigureView { + profile: "moq-jwp:claim-link".into(), + identity: self.link_hash(), + group: None, + figures: Vec::new(), + signer: Some(self.signer_pubkey), + }); + }; + let class = e.acquisition_class().ok()?; + Some(FigureView::single("moq-jwp:claim-link", self.link_hash(), e.joules_micro, Some(class), self.signer_pubkey)) + } +} + +/// An energy binding's identity: `BLAKE3("wai:energy-binding-id\x01" ‖ JCS(body))` +/// (energy-binding §4). `None` for a body holding a value outside JCS's subset here, +/// such as an integer above 2⁵³ − 1. +pub fn binding_identity(body: &serde_json::Value) -> Option<[u8; 32]> { + let canonical = jcs(body).ok()?; + let mut h = blake3::Hasher::new(); + h.update(b"wai:energy-binding-id\x01"); + h.update(canonical.as_bytes()); + Some(*h.finalize().as_bytes()) +} + +/// An energy binding's body (energy-binding §2), viewed by its identity. No Ed25519 +/// key signs it. +pub struct BindingBody<'a>(pub &'a serde_json::Value); + +impl EnergyFigures for BindingBody<'_> { + fn figure_view(&self) -> Option { + let b = crate::energy_binding::EnergyBinding::from_json(self.0).ok()?; + Some(FigureView { + profile: "wai:energy-binding-id".into(), + identity: binding_identity(self.0)?, + group: None, + figures: vec![Figure { index: None, joules_micro: b.microjoules, class: Some(b.acquisition) }], + signer: None, + }) + } +} + +/// A JWP object (`moq-jwp:object`), accepted under its group with the envelope it was +/// delivered with: its figure at its leaf index, labelled where its leaf is, within +/// the group named by its link hash. +pub struct DeliveredObject<'a> { + pub object: &'a crate::jwp_delivery::ObjectReceipt, + pub group: &'a crate::jwp_delivery::GroupReceipt, + pub envelope: &'a [u8], +} + +impl EnergyFigures for DeliveredObject<'_> { + fn figure_view(&self) -> Option { + self.object.accept(self.group, self.envelope).ok()?; + let class = self.object.label.as_ref().and_then(|l| l.class().cloned()); + Some(FigureView { + profile: "moq-jwp:object".into(), + identity: self.object.content_hash, + group: Some(self.group.link_hash()), + figures: vec![Figure { + index: Some(self.object.merkle_proof.leaf_index), + joules_micro: self.object.joules_micro, + class, + }], + signer: Some(self.group.signer_pubkey), + }) + } +} + +/// The receipt families whose figure sits in `joules_micro` beside an optional or +/// always-present `energy_provenance`, signed by `signer_pubkey`, identified by +/// `receipt_hash()`. +macro_rules! single_figure { + ($feature:literal, $t:ty, $profile:literal) => { + #[cfg(feature = $feature)] + impl EnergyFigures for $t { + fn figure_view(&self) -> Option { + if !self.verify() { + return None; + } + Some(FigureView::single($profile, self.receipt_hash(), self.joules_micro, self.energy_provenance.clone(), self.signer_pubkey)) + } + } + }; +} + +single_figure!("video_receipt", crate::video_receipt::VideoReceipt, "wai:video-receipt-id"); +single_figure!("video_manifest", crate::wai_video_manifest::ManifestReceipt, "wai:video-manifest-receipt-id"); +single_figure!("video_ssai", crate::wai_video_ssai::SsaiReceipt, "wai:video-ssai-receipt-id"); +single_figure!("video_keys", crate::wai_video_keys::KeyReleaseReceipt, "wai:video-keys-receipt-id"); +single_figure!("video_package", crate::wai_video_package::PackageReceipt, "wai:video-package-receipt-id"); +single_figure!("video_channel", crate::wai_video_channel::ChannelReceipt, "wai:video-channel-receipt-id"); +single_figure!("world", crate::world::receipt::SessionReceipt, "wai:world-receipt-id"); +single_figure!("film", crate::film::FilmReceipt, "wai:film-receipt-id"); +single_figure!("haptic", crate::haptic::HapticReceipt, "wai:haptic-receipt-id"); +single_figure!("score", crate::score::ScoreReceipt, "wai:score-receipt-id"); +single_figure!("splat4d", crate::splat4d::Splat4dReceipt, "wai:splat4d-receipt-id"); +single_figure!("audio_scene", crate::audio_scene::SceneReceipt, "wai:audio-receipt-id"); +single_figure!("ternary_receipt", crate::ternary_receipt::TernaryReceipt, "wai:ternary-receipt-id"); + +/// A provenance receipt (`wai:asset-receipt-id`): one figure per step, indexed from 0. +impl EnergyFigures for crate::provenance::AssetReceipt { + fn figure_view(&self) -> Option { + if !self.verify() { + return None; + } + Some(FigureView { + profile: "wai:asset-receipt-id".into(), + identity: self.receipt_hash(), + group: None, + figures: self + .steps + .iter() + .enumerate() + .map(|(i, s)| Figure { index: Some(i as u64), joules_micro: s.joules_micro, class: s.energy_provenance.clone() }) + .collect(), + signer: Some(self.signer_pubkey), + }) + } +} + +/// A quantum receipt of `wai-quantum`, in either form. +#[cfg(any(feature = "quantum_receipt", feature = "quantum_ops", feature = "quantum_phasor_meter"))] +impl EnergyFigures for wai_quantum::quantum_energy::MaybeLabelled { + fn figure_view(&self) -> Option { + if !self.verify() { + return None; + } + let r = self.receipt(); + let profile = profile_name_of_domain(r.id_domain())?; + let class = self.energy_class().map(crate::energy_class::from_quantum_class); + Some(FigureView::single(profile, self.receipt_hash(), r.figure(), class, r.signer_key())) + } +} + +/// A quantum bill of materials, unlabelled: one unlabelled figure per entry. +#[cfg(feature = "quantum_qbom")] +impl EnergyFigures for wai_quantum::quantum_qbom::Qbom { + fn figure_view(&self) -> Option { + if !self.verify() { + return None; + } + Some(FigureView { + profile: "wai:quantum-qbom-id".into(), + identity: self.receipt_hash(), + group: None, + figures: self + .entries + .iter() + .enumerate() + .map(|(i, e)| Figure { index: Some(i as u64), joules_micro: e.joules_micro, class: None }) + .collect(), + signer: Some(self.signer_pubkey), + }) + } +} + +/// A quantum bill of materials labelled per entry. +#[cfg(feature = "quantum_qbom")] +impl EnergyFigures for wai_quantum::quantum_energy::LabelledQbom { + fn figure_view(&self) -> Option { + if !self.verify() { + return None; + } + Some(FigureView { + profile: "wai:quantum-qbom-id".into(), + identity: self.receipt_hash(), + group: None, + figures: self + .qbom + .entries + .iter() + .zip(&self.classes) + .enumerate() + .map(|(i, (e, c))| Figure { + index: Some(i as u64), + joules_micro: e.joules_micro, + class: c.as_ref().map(crate::energy_class::from_quantum_class), + }) + .collect(), + signer: Some(self.qbom.signer_pubkey), + }) + } +} + +/// What a reference meter writes beside the receipt it seals (energy-measurement §6): a +/// `measurement` claim about the receipt's figure, signed with the receipt's key and +/// checked against the receipt before it is written. Kept here, not in the meters, so +/// the whole path is tested in the library. +pub mod meter_report { + use super::{EnergyFigures, FigureView}; + use crate::claim_receipt::{rfc3339_utc, ClaimBody, ClaimReceipt, FigureHeader, MeasurementClaim}; + use crate::energy_report::{BracketValues, BuildRecord}; + use ed25519_dalek::SigningKey; + use std::path::PathBuf; + + /// A meter's command line with the report options taken out: `--no-report`, and + /// `--previous-report `, the meter's previous report, which the new one chains + /// to (jwp-receipts §3.1 step 5). + #[derive(Clone, Debug, Default, PartialEq, Eq)] + pub struct ReportArgs { + pub positional: Vec, + pub no_report: bool, + pub previous_report: Option, + } + + /// The report options taken out of `args`. `--previous-report` with no file + /// after it — at the end, or followed by another option — is an error, never a + /// silent first report. + pub fn parse_args(args: &[String]) -> Result { + let mut out = ReportArgs::default(); + let mut it = args.iter(); + while let Some(a) = it.next() { + match a.as_str() { + "--no-report" => out.no_report = true, + "--previous-report" => match it.next() { + Some(p) if !p.starts_with("--") => out.previous_report = Some(p.clone()), + _ => return Err("--previous-report needs the previous report's file".into()), + }, + _ => out.positional.push(a.clone()), + } + } + Ok(out) + } + + /// [`parse_args`] for a meter's `main`: a usage error exits with status 2. + pub fn parse_args_or_exit(args: &[String]) -> ReportArgs { + parse_args(args).unwrap_or_else(|e| { + eprintln!("usage error: {e}"); + std::process::exit(2) + }) + } + + /// Where the report goes by default: beside the receipt, its `.json` extension + /// replaced by `.report.json` (or `.report.json` appended), and to standard output + /// (`None`) when the receipt is not written to a file. + pub fn default_path(receipt_out: Option<&str>) -> Option { + let r = receipt_out?; + Some(PathBuf::from(match r.strip_suffix(".json") { + Some(stem) => format!("{stem}.report.json"), + None => format!("{r}.report.json"), + })) + } + + /// The report on the figure `view` holds at `index`: the figure restated with its + /// class, the bracket's values, the build and the reader, signed by `signer` — the + /// key that signed the figure — and checked against `view` (jwp-receipts §3.1 steps + /// 1–4 and 6). A figure with no class gets no report: `Err` says why. + #[allow(clippy::too_many_arguments)] + pub fn emit( + view: &FigureView, + index: Option, + bracket: Option, + build: BuildRecord, + power_reader: &str, + signer: &SigningKey, + signer_id: &str, + previous: Option<&ClaimReceipt>, + issued_at: &str, + ) -> Result { + let profile = super::profile(&view.profile).ok_or("the receipt's profile is not in energy-measurement section 6.1")?; + let f = view.figure(index).ok_or("the receipt carries no figure at that index")?; + let class = f.class.as_ref().ok_or("the figure carries no class (unmetered or unlabelled): it gets no report")?; + let body = ClaimBody::Measurement(MeasurementClaim { + figure: FigureHeader::new(profile.name, index, view.group, f.joules_micro, class), + bracket, + build, + power_reader: Some(power_reader.to_owned()), + }); + let (seq, parent) = match previous { + Some(p) => (p.seq + 1, Some(p.link_hash())), + None => (0, None), + }; + let r = ClaimReceipt::seal(signer, signer_id, profile.subject_type, view.identity, seq, issued_at, body, parent) + .map_err(|e| format!("the report is refused ({e})"))?; + r.check_figure_against(Some(view)).map_err(|e| format!("the report does not check against the receipt ({e})"))?; + r.check_parent(previous).map_err(|e| format!("the report does not chain to the previous one ({e})"))?; + Ok(r) + } + + /// Everything a meter does with its report, after sealing `receipt`: unless + /// `--no-report` was given, build the report on the figure at `index` and write it to + /// `explicit` or the default path beside `receipt_out`, or print it; say where it + /// went, or why there is none. Returns the report written. + #[allow(clippy::too_many_arguments)] + pub fn write( + args: &ReportArgs, + receipt: &R, + index: Option, + bracket: Option, + power_reader: &str, + signer: &SigningKey, + signer_id: &str, + receipt_out: Option<&str>, + explicit: Option<&str>, + ) -> Option { + if args.no_report { + println!("report : not written (--no-report); energy-measurement section 6 requires one beside a published figure"); + return None; + } + let Some(view) = receipt.figure_view() else { + eprintln!("report : none — the receipt does not verify"); + return None; + }; + let previous = args.previous_report.as_deref().map(|p| { + let wire = std::fs::read_to_string(p).unwrap_or_else(|e| panic!("read {p}: {e}")); + crate::claim_receipt::verify_claim_json(&wire).unwrap_or_else(|e| panic!("{p}: {e}")) + }); + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + match emit(&view, index, bracket, BuildRecord::of_this_build(), power_reader, signer, signer_id, previous.as_ref(), &rfc3339_utc(now)) { + Err(why) => { + println!("report : none — {why}"); + None + } + Ok(r) => { + match explicit.map(PathBuf::from).or_else(|| default_path(receipt_out)) { + Some(path) => { + std::fs::write(&path, r.to_json()).unwrap_or_else(|e| panic!("write {}: {e}", path.display())); + println!("report : {} (measurement claim, checked against the receipt)", path.display()); + } + None => println!("report : {}", r.to_json()), + } + Some(r) + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/wai/wai-rs/src/energy_figure/tests.rs b/wai/wai-rs/src/energy_figure/tests.rs new file mode 100644 index 000000000..0c2f2b2a2 --- /dev/null +++ b/wai/wai-rs/src/energy_figure/tests.rs @@ -0,0 +1,336 @@ +use super::*; +use crate::energy_class::{CounterFiltering, Uncertainty}; +use ed25519_dalek::SigningKey; + +fn key(s: u8) -> SigningKey { + SigningKey::from_bytes(&[s; 32]) +} + +fn chip(ppm: u32) -> EnergyProvenance { + EnergyProvenance::OnChipCounter { uncertainty: Uncertainty { relative_ppm: ppm, window_us: 2_977_104 }, filtering: CounterFiltering::Unknown } +} + +/// The registry is the set of identity domains the code hashes under, less their +/// version byte: one name each, no name twice. +#[test] +fn the_table_is_the_identity_domains_the_code_hashes_under() { + let domains: &[&[u8]] = &[ + b"wai:video-receipt-id\x01", + b"wai:video-manifest-receipt-id\x01", + b"wai:video-ssai-receipt-id\x01", + b"wai:video-keys-receipt-id\x01", + b"wai:video-package-receipt-id\x01", + b"wai:video-channel-receipt-id\x01", + b"wai:world-receipt-id\x01", + b"wai:film-receipt-id\x01", + b"wai:haptic-receipt-id\x01", + b"wai:score-receipt-id\x01", + b"wai:splat4d-receipt-id\x01", + b"wai:audio-receipt-id\x01", + b"wai:ternary-receipt-id\x01", + b"wai:asset-receipt-id\x01", + b"wai:energy-binding-id\x01", + b"moq-jwp:claim-link\x01", + b"moq-jwp:object\x01", + ]; + for d in domains { + assert!(profile_name_of_domain(d).is_some(), "{}", String::from_utf8_lossy(d)); + } + let mut names: Vec<&str> = profiles().iter().map(|p| p.name).collect(); + names.sort(); + names.dedup(); + assert_eq!(names.len(), profiles().len()); + assert!(profile_name_of_domain(b"wai:phasor-compute-id\x01").is_none(), "a domain that identifies no figure"); + // Exactly one profile names a group, and only the energy binding has no signer. + assert_eq!(profiles().iter().filter(|p| p.grouped).map(|p| p.name).collect::>(), ["moq-jwp:object"]); + assert_eq!(profiles().iter().filter(|p| !p.ed25519_signer).map(|p| p.name).collect::>(), ["wai:energy-binding-id"]); +} + +#[test] +fn an_energy_claim_is_viewed_by_its_link_hash() { + use crate::claim_receipt::{EnergyClaim, SubjectType}; + use crate::energy_binding::Coverage; + let c = ClaimReceipt::seal( + &key(0x22), + "m", + SubjectType::Object, + [1; 32], + 0, + "2026-09-30T12:00:00Z", + ClaimBody::Energy(EnergyClaim::new(500, &chip(20_000), Coverage::Whole, "measured", "https://example.org/m", 1, "u", None)), + None, + ) + .unwrap(); + let v = c.figure_view().unwrap(); + assert_eq!(v.profile, "moq-jwp:claim-link"); + assert_eq!(v.identity, c.link_hash()); + assert_eq!(v.figures, vec![Figure { index: None, joules_micro: 500, class: Some(chip(20_000)) }]); + assert_eq!(v.signer, Some(key(0x22).verifying_key().to_bytes())); + let mut bad = c.clone(); + bad.sig[0] ^= 1; + assert!(bad.figure_view().is_none(), "a claim that does not verify shows no figure"); +} + +#[test] +fn a_binding_is_viewed_by_its_identity_and_has_no_signer() { + let body: serde_json::Value = serde_json::from_str( + r#"{"reconstructionHash":"4dbdc2b2b62cb00749785bc84202236dbc3777d74660611b8e58812f0cfde6c3","alg":"sha256","microjoules":10565,"acquisition":"ModelBased","coverage":"Whole","workUnits":48,"method":"https://transaction.science/wai/extensions/energy-measurement"}"#, + ) + .unwrap(); + let v = BindingBody(&body).figure_view().unwrap(); + assert_eq!(v.profile, "wai:energy-binding-id"); + let mut h = blake3::Hasher::new(); + h.update(b"wai:energy-binding-id\x01"); + h.update(crate::claim_receipt::jcs(&body).unwrap().as_bytes()); + assert_eq!(v.identity, *h.finalize().as_bytes()); + assert_eq!(v.signer, None); + assert_eq!(v.figures[0].class, Some(EnergyProvenance::ModelBased)); + // A body holding an integer no canonical form holds has no identity. + let mut big = body.clone(); + big["workUnits"] = serde_json::Value::from(1u64 << 53); + assert!(binding_identity(&big).is_none()); +} + +#[test] +fn a_delivered_object_is_viewed_at_its_index_within_its_group() { + use crate::jwp_delivery::{GroupBuilder, ObjectLabel}; + let env = |i: u8| vec![i; 9]; + let mut b = GroupBuilder::default(); + b.add(crate::claim_receipt::object_content_hash(&env(0)), 300, "o", Some(ObjectLabel::Class(chip(20_000)))); + b.add(crate::claim_receipt::object_content_hash(&env(1)), 200, "o", None); + let (g, objs) = b.finish(&key(0x11), "p", 1, 2, "2026-09-30T12:00:00Z", "2026-09-30T12:00:01Z", None, false).unwrap(); + let v = DeliveredObject { object: &objs[1], group: &g, envelope: &env(1) }.figure_view().unwrap(); + assert_eq!(v.profile, "moq-jwp:object"); + assert_eq!(v.identity, objs[1].content_hash); + assert_eq!(v.group, Some(g.link_hash())); + assert_eq!(v.figures, vec![Figure { index: Some(1), joules_micro: 200, class: None }]); + let v = DeliveredObject { object: &objs[0], group: &g, envelope: &env(0) }.figure_view().unwrap(); + assert_eq!(v.figures[0].class, Some(chip(20_000))); + assert!(DeliveredObject { object: &objs[0], group: &g, envelope: &env(9) }.figure_view().is_none()); +} + +#[cfg(feature = "world")] +#[test] +fn a_world_session_is_viewed_by_its_receipt_hash() { + use crate::world::{receipt::SessionReceipt, Checkpoint}; + let cps = vec![Checkpoint { tick: 100, hash: [0x11; 32] }]; + let r = SessionReceipt::build(&key(9), "did:key:pin", [0xab; 32], 3, &[], &cps, 12_345, 999, None); + let v = r.figure_view().unwrap(); + assert_eq!((v.profile.as_str(), v.identity), ("wai:world-receipt-id", r.receipt_hash())); + assert_eq!(v.figures[0], Figure { index: None, joules_micro: 12_345, class: None }); + let l = r.with_energy_class(&key(9), chip(15_403)).unwrap(); + let v = l.figure_view().unwrap(); + assert_eq!((v.identity, v.figures[0].class.clone()), (l.receipt_hash(), Some(chip(15_403)))); + let mut bad = l.clone(); + bad.sig[0] ^= 1; + assert!(bad.figure_view().is_none()); +} + +#[test] +fn a_provenance_receipt_is_viewed_step_by_step() { + use crate::provenance::{AssetReceipt, ProvStep}; + let steps = vec![ + ProvStep::new("capture", [0; 32], [0x10; 32], 1, 0), + ProvStep::new("encode.png", [0x10; 32], [0x20; 32], 1, 4_200).with_energy_class(chip(15_403)).unwrap(), + ]; + let r = AssetReceipt::build(&key(9), "did:web:pin#prov", [0x20; 32], "image", "wai.image.png", 0, steps, None); + let v = r.figure_view().unwrap(); + assert_eq!((v.profile.as_str(), v.identity), ("wai:asset-receipt-id", r.receipt_hash())); + assert_eq!(v.figure(Some(0)).unwrap().class, None); + assert_eq!(v.figure(Some(1)), Some(&Figure { index: Some(1), joules_micro: 4_200, class: Some(chip(15_403)) })); + assert_eq!(v.figure(Some(2)), None); +} + +#[cfg(feature = "video_receipt")] +#[test] +fn the_video_receipt_is_viewed_by_its_receipt_hash() { + let p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/vectors/int_video/int_video_bitstream.json"); + let bytes = std::fs::read(p).unwrap(); + let bs: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let r = crate::video_receipt::VideoReceipt::seal_decode(&key(7), "m", &bytes, &bs, 4_200, Some(chip(30_000)), None).unwrap(); + let v = r.figure_view().unwrap(); + assert_eq!((v.profile.as_str(), v.identity), ("wai:video-receipt-id", r.receipt_hash())); + assert_eq!(v.figures[0].class, Some(chip(30_000))); +} + +#[cfg(feature = "video_manifest")] +#[test] +fn the_ott_receipts_are_viewed_under_their_profiles() { + use crate::wai_video_manifest::{ManifestReceipt, Segment, SegmentKind, SessionClass}; + let class = SessionClass { + content_id: "asset".into(), + entitlement: "tier".into(), + geo: "US".into(), + blackouts: vec![], + ad_pod_id: "pod".into(), + window: 7, + }; + let segments = vec![Segment { index: 0, kind: SegmentKind::Content, uri: "seg0.ts".into(), duration_ms: 6000, ad_id: None }]; + let grant = wai_quantum::quantum_ops::GrantRef { + grant_hash: [1; 32], + capability: "video.manifest".into(), + joule_ceiling_micro: 1_000_000, + funds_ceiling: None, + }; + let r = ManifestReceipt::seal(&key(3), "o", class, segments, 300, 9_000, Some(EnergyProvenance::ModelBased), grant, None); + let v = r.figure_view().unwrap(); + assert_eq!((v.profile.as_str(), v.identity), ("wai:video-manifest-receipt-id", r.receipt_hash())); + assert_eq!(v.figures[0].class, Some(EnergyProvenance::ModelBased)); + // The other OTT families' profiles are the receipt-id domains they hash under. + for (name, domain) in [ + ("wai:video-ssai-receipt-id", &b"wai:video-ssai-receipt-id\x01"[..]), + ("wai:video-keys-receipt-id", b"wai:video-keys-receipt-id\x01"), + ("wai:video-package-receipt-id", b"wai:video-package-receipt-id\x01"), + ("wai:video-channel-receipt-id", b"wai:video-channel-receipt-id\x01"), + ] { + assert_eq!(profile_name_of_domain(domain), Some(name)); + } +} + +#[cfg(feature = "quantum_receipt")] +#[test] +fn a_quantum_receipt_is_viewed_in_either_form() { + use wai_quantum::quantum::{Circuit, Measure}; + use wai_quantum::quantum_energy::{Labelled, MaybeLabelled}; + use wai_quantum::quantum_receipt::QuantumReceipt; + let mut c = Circuit::new(3); + c.h(0).cx(0, 1); + let r = QuantumReceipt::seal(&key(1), "did:key:pin", &c, Some(Measure { seed: 7, shots: 1024 }), 117_000, None).unwrap(); + let u = MaybeLabelled::Unlabelled(r.clone()); + let v = u.figure_view().unwrap(); + // The profile name is pinned by literal: it is read through the quantum crate's + // labelling trait, so a change there must fail here. + assert_eq!(v.profile, "wai:quantum-receipt-id"); + assert_eq!((v.identity, v.figures[0].class.clone()), (r.receipt_hash(), None)); + let q = crate::energy_class::to_quantum_class(&chip(15_403)).unwrap(); + let l = MaybeLabelled::Labelled(Labelled::seal(r, q, &key(1)).unwrap()); + let v = l.figure_view().unwrap(); + assert_eq!((v.identity, v.figures[0].class.clone()), (l.receipt_hash(), Some(chip(15_403)))); +} + +#[cfg(feature = "quantum_ops")] +#[test] +fn the_quantum_ops_receipts_name_their_profiles() { + use wai_quantum::quantum_energy::MaybeLabelled; + use wai_quantum::quantum_ops::*; + let grant = |cap: &str| GrantRef { grant_hash: content_hash(b"g"), capability: cap.into(), joule_ceiling_micro: 5_000_000, funds_ceiling: Some(100) }; + fn view(r: R) -> String { + MaybeLabelled::Unlabelled(r).figure_view().expect("verifies").profile + } + let k = key(1); + let job = JobReceipt::seal(&k, "p", "wai.quantum.circuit", content_hash(b"c"), content_hash(b"r"), None, 1000, 250, grant("quantum.job.run"), None); + let mit = MitigationReceipt::seal(&k, "p", "wai.quantum.circuit", "zne.richardson", "ZZ", content_hash(b"raw"), None, 744_178, 912_680, 20_971, 48_000, 900_000, grant("quantum.mitigate"), None); + let dec = DecodeReceipt::seal(&k, "p", "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); + let cmp = CompileReceipt::seal(&k, "p", "clifford-tableau", content_hash(b"s"), content_hash(b"t"), content_hash(b"cpl"), true, 3, 4096, 300_000, grant("quantum.compile"), None); + let rea = RearrangeReceipt::seal(&k, "p", "hungarian-lsap", content_hash(b"i"), content_hash(b"t"), content_hash(b"p"), true, 41, 318, 450_000, grant("quantum.rearrange"), None); + let cal = CalibrationReceipt::seal(&k, "p", "lab:rig", "q3", content_hash(b"cfg"), vec![], 1_000, grant("quantum.calibrate"), None); + assert_eq!(view(job), "wai:quantum-job-id"); + assert_eq!(view(mit), "wai:quantum-mitigate-id"); + assert_eq!(view(dec), "wai:quantum-decode-id"); + assert_eq!(view(cmp), "wai:quantum-compile-id"); + assert_eq!(view(rea), "wai:quantum-rearrange-id"); + assert_eq!(view(cal), "wai:quantum-calibration-id"); +} + +#[cfg(feature = "quantum_phasor_meter")] +#[test] +fn a_phasor_receipt_names_its_profile() { + use wai_quantum::quantum_energy::MaybeLabelled; + use wai_quantum::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), "p", "wai.quantum.kernel", computation_id("wai.quantum.kernel", &[0.5]), result_fingerprint(&[0.1]), work, 117_000, None); + assert_eq!(MaybeLabelled::Unlabelled(r).figure_view().unwrap().profile, "wai:phasor-receipt-id"); +} + +#[cfg(feature = "quantum_qbom")] +#[test] +fn a_bill_of_materials_is_viewed_entry_by_entry() { + use wai_quantum::quantum_energy::LabelledQbom; + use wai_quantum::quantum_qbom::QbomBuilder; + let q = QbomBuilder::new("job", [0x51; 32], "sim").stage("compile", [0x52; 32], 120_000).stage("execute", [0x53; 32], 90_000).seal(&key(1), "p"); + let v = q.figure_view().unwrap(); + assert_eq!((v.profile.as_str(), v.identity), ("wai:quantum-qbom-id", q.receipt_hash())); + assert_eq!(v.figure(Some(1)), Some(&Figure { index: Some(1), joules_micro: 90_000, class: None })); + let c = crate::energy_class::to_quantum_class(&chip(15_403)).unwrap(); + let l = LabelledQbom::seal(q, vec![Some(c), None], &key(1)).unwrap(); + let v = l.figure_view().unwrap(); + assert_eq!(v.identity, l.receipt_hash()); + assert_eq!(v.figure(Some(0)).unwrap().class, Some(chip(15_403))); + assert_eq!(v.figure(Some(1)).unwrap().class, None); +} + +mod meter_reports { + use super::*; + use crate::energy_figure::meter_report::{default_path, emit, parse_args, ReportArgs}; + use crate::energy_report::{BracketSummary, BracketValues, BuildRecord}; + + fn live(n: u64, mean_uw: u64, sd_uw: u64) -> BracketSummary { + BracketSummary { n, mean_uw, sd_uw, live_intervals: n, longest_live_run: n } + } + + fn bracket() -> BracketValues { + BracketValues { + idle: live(10, 20_000_000, 115_471), + busy: live(14, 30_000_000, 115_471), + window_us: 3_000_000, + completions: 60, + in_flight: 10, + opening: crate::energy_class::WindowOpening::FromRest, + } + } + + #[test] + fn a_report_is_written_by_default_beside_the_receipt() { + assert_eq!(default_path(Some("out/r.json")), Some("out/r.report.json".into())); + assert_eq!(default_path(Some("receipt")), Some("receipt.report.json".into())); + assert_eq!(default_path(None), None, "no receipt file: the report goes to standard output"); + let a = parse_args(&["x.wscr".into(), "--no-report".into(), "r.json".into(), "--previous-report".into(), "p.json".into()]); + assert_eq!(a, Ok(ReportArgs { positional: vec!["x.wscr".into(), "r.json".into()], no_report: true, previous_report: Some("p.json".into()) })); + assert!(!parse_args(&["x".into()]).unwrap().no_report, "on unless opted out"); + // A missing value is an error, not a report with no parent. + assert!(parse_args(&["x".into(), "--previous-report".into()]).is_err(), "at the end"); + assert!(parse_args(&["x".into(), "--previous-report".into(), "--no-report".into()]).is_err(), "followed by an option"); + } + + /// A report on a world session, sealed from a bracket: it checks against the + /// receipt, chains to the previous report, and leaves the receipt's bytes as they were. + #[cfg(feature = "world")] + #[test] + fn a_meter_report_checks_against_its_receipt_and_changes_nothing() { + use crate::world::{receipt::SessionReceipt, Checkpoint}; + let b = bracket(); + let f = b.figure().unwrap(); + let class = EnergyProvenance::OnChipCounter { + uncertainty: Uncertainty { relative_ppm: b.least_relative_ppm(f).unwrap(), window_us: b.window_us }, + filtering: CounterFiltering::Unknown, + }; + let cps = vec![Checkpoint { tick: 1, hash: [1; 32] }]; + let r = SessionReceipt::build(&key(9), "s", [2; 32], 0, &[], &cps, f, 1, None).with_energy_class(&key(9), class).unwrap(); + let (hash, json) = (r.receipt_hash(), r.to_json()); + let view = r.figure_view().unwrap(); + let first = emit(&view, None, Some(b), BuildRecord::of_this_build(), "reader:example", &key(9), "s", None, "2026-09-30T12:00:00Z").unwrap(); + first.verify().unwrap(); + assert_eq!(first.check_figure_against(Some(&view)), Ok(())); + let second = emit(&view, None, Some(b), BuildRecord::of_this_build(), "reader:example", &key(9), "s", Some(&first), "2026-09-30T12:00:01Z").unwrap(); + assert_eq!(second.check_parent(Some(&first)), Ok(())); + assert_eq!((r.receipt_hash(), r.to_json()), (hash, json), "the receipt is unchanged by a report about it"); + // Another key's report is refused before it is written. + assert!(emit(&view, None, Some(b), BuildRecord::of_this_build(), "reader:example", &key(8), "s", None, "2026-09-30T12:00:00Z").is_err()); + // An unmetered receipt gets none. + let u = SessionReceipt::build(&key(9), "s", [2; 32], 0, &[], &cps, 0, 1, None); + assert!(emit(&u.figure_view().unwrap(), None, None, BuildRecord::of_this_build(), "reader:example", &key(9), "s", None, "2026-09-30T12:00:00Z").is_err()); + } + + /// One report per labelled provenance step, by index. + #[test] + fn a_provenance_step_gets_its_own_report() { + use crate::provenance::{AssetReceipt, ProvStep}; + let model = EnergyProvenance::ModelBased; + let steps = vec![ProvStep::new("capture", [0; 32], [1; 32], 1, 0), ProvStep::new("encode", [1; 32], [2; 32], 1, 77).with_energy_class(model.clone()).unwrap()]; + let r = AssetReceipt::build(&key(9), "s", [2; 32], "image", "wai.image.png", 0, steps, None); + let view = r.figure_view().unwrap(); + let rep = emit(&view, Some(1), None, BuildRecord::of_this_build(), "reader:example", &key(9), "s", None, "2026-09-30T12:00:00Z").unwrap(); + assert_eq!(rep.check_figure_against(Some(&view)), Ok(())); + assert!(emit(&view, Some(0), None, BuildRecord::of_this_build(), "reader:example", &key(9), "s", None, "2026-09-30T12:00:00Z").is_err(), "an unmetered step gets none"); + } +} diff --git a/wai/wai-rs/src/energy_meter.rs b/wai/wai-rs/src/energy_meter.rs index 426e6ce96..e5d7aa3d0 100644 --- a/wai/wai-rs/src/energy_meter.rs +++ b/wai/wai-rs/src/energy_meter.rs @@ -18,13 +18,27 @@ //! 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. -//! - When a meter cannot measure — no hardware, no counter, no span started — -//! [`Meter::stop`] returns `None`. It never falls back to a fabricated figure, -//! because a plausible number with the wrong label is worse than no number: -//! the caller then either meters with a model and says so, or seals unmetered -//! (`0` / `None`), which the receipts already accept. +//! can only ever yield [`EnergyProvenance::ModelBased`]: its figure is a +//! model's output, labelled as one, and never presented as a measurement. +//! - When a meter cannot measure — no hardware, no counter, no span started, a +//! counter whose rail was not live, a model that gives nothing — +//! [`Meter::stop`] returns `None`. It never falls back to a fabricated +//! figure, because a plausible number with the wrong label is worse than no +//! number: the caller then either meters another way and says so, or seals +//! the receipt with the unmetered marker (`0` with no class, +//! `extensions/energy-measurement.md` §2). The marker is not a figure of zero +//! joules, and no meter here returns a reading of zero. +//! +//! # A live counter +//! +//! A counter that is not being refreshed, or is refreshed in batches, can show +//! a delta between two reads that is no measurement of the span: nothing at +//! all, or one batch's worth. So a counter meter reads the counter during the +//! span as well as at its ends ([`CounterMeter::sample`]), and refuses the span +//! unless the counter advanced over it as a live rail does +//! (energy-measurement §3.1, [`crate::energy_report::Liveness`]): in two +//! consecutive intervals, in at least five, in at least four of every five, +//! over at least 300 ms. Two reads alone are one interval, and never enough. //! //! Both meters take their source injected — a counter reader, a clock — so the //! delta and wraparound arithmetic is testable without hardware, and so the @@ -49,9 +63,12 @@ use core::num::NonZeroU32; use crate::energy_class::{ AcquisitionError, AcquisitionKind, CounterFiltering, Uncertainty, REGISTER_DELTA_UNCERTAINTY_UJ, }; +use crate::energy_report::Liveness; use crate::wai_video_manifest::EnergyProvenance; -/// A measured span: the energy, and the class of the measurement. +/// A reading over a span: the energy, and its class — measured or modelled, as +/// the class states. Never zero: a span with no figure is `None` at +/// [`Meter::stop`], and the receipt carries the unmetered marker instead. #[derive(Clone, Debug, PartialEq, Eq)] pub struct EnergyReading { pub joules_micro: u64, @@ -65,7 +82,8 @@ impl EnergyReading { } /// What to seal when there was no measurement at all — `(0, None)`, the - /// only unmetered form the receipts accept. + /// unmetered marker (energy-measurement §2): a `0` that means "not + /// metered", never a figure of zero joules. pub fn unmetered() -> (u64, Option) { (0, None) } @@ -101,8 +119,12 @@ pub struct CounterMeter { wrap_at: u64, relative_ppm: NonZeroU32, source: CounterSource, - /// `(counter, clock)` at the start of the span. - start: Option<(u64, u64)>, + /// `(counter, clock)` at each read of the span: its start, every + /// [`CounterMeter::sample`], and nothing yet of its stop. Empty with no span + /// started. + reads: Vec<(u64, u64)>, + /// A read during the span failed, so the span cannot be measured. + broken: bool, } impl CounterMeter { @@ -132,10 +154,46 @@ impl CounterMeter { wrap_at, relative_ppm, source: CounterSource::OnChip { filtering: CounterFiltering::Unknown }, - start: None, + reads: Vec::new(), + broken: false, } } + /// Read the counter during a span, as one interval's boundary. A span + /// with too few of these is refused at [`Meter::stop`]: two reads are one + /// interval, and the liveness rule needs at least five advancing ones + /// (energy-measurement §3.1). Does nothing with no span started. + pub fn sample(&mut self) { + if self.reads.is_empty() || self.broken { + return; + } + match (self.read)() { + Some(c) => { + let t = (self.clock_micros)(); + self.reads.push((c, t)); + } + None => self.broken = true, + } + } + + /// Probe whether the counter is live before trusting it: a span of + /// `intervals` intervals, `pause` between reads, that must pass the + /// liveness rule. The probe's reading is discarded. + pub fn probe(&mut self, intervals: u32, mut pause: impl FnMut()) -> bool { + self.start(); + for _ in 1..intervals { + pause(); + self.sample(); + } + pause(); + self.stop().is_some() + } + + /// The counter delta between two reads, across one wrap at `wrap_at`. + fn delta(&self, from: u64, to: u64) -> u64 { + if to >= from { to - from } else { self.wrap_at.saturating_sub(from).saturating_add(to) } + } + /// 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); @@ -182,8 +240,25 @@ impl CounterMeter { /// Where filtering may be active, the advisory's guidance 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. + /// + /// The counter is probed before the meter is returned: six intervals of + /// 60 ms, which must pass the liveness rule (energy-measurement §3.1). A + /// counter that is present but frozen, or refreshed in batches, gives no + /// meter either. #[cfg(not(target_arch = "wasm32"))] pub fn rapl(domain: &str, relative_ppm: NonZeroU32) -> Option { + CounterMeter::rapl_with_probe(domain, relative_ppm, |m| { + m.probe(6, || std::thread::sleep(std::time::Duration::from_millis(60))) + }) + } + + /// [`CounterMeter::rapl`] with the liveness probe supplied by the caller. + #[cfg(not(target_arch = "wasm32"))] + pub fn rapl_with_probe( + domain: &str, + relative_ppm: NonZeroU32, + probe: impl FnOnce(&mut CounterMeter) -> bool, + ) -> Option { let base = format!("/sys/class/powercap/{domain}"); let energy = format!("{base}/energy_uj"); let range = format!("{base}/max_energy_range_uj"); @@ -191,12 +266,13 @@ impl CounterMeter { let wrap_at = read_u64_file(&range)?.saturating_add(1); read_u64_file(&energy)?; let origin = std::time::Instant::now(); - Some(CounterMeter::new( + let mut m = CounterMeter::new( move || read_u64_file(&energy), move || origin.elapsed().as_micros() as u64, wrap_at, relative_ppm, - )) + ); + probe(&mut m).then_some(m) } } @@ -207,18 +283,31 @@ fn read_u64_file(path: &str) -> Option { impl Meter for CounterMeter { fn start(&mut self) { + self.broken = false; let counter = (self.read)(); let t = (self.clock_micros)(); - self.start = counter.map(|c| (c, t)); + self.reads = counter.map(|c| vec![(c, t)]).unwrap_or_default(); } fn stop(&mut self) -> Option { - let (started, t0) = self.start.take()?; + let mut reads = std::mem::take(&mut self.reads); + if reads.is_empty() || std::mem::take(&mut self.broken) { + return None; + } 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 }; + let t = (self.clock_micros)(); + reads.push((now, t)); + let window_us = t.saturating_sub(reads[0].1); + // Energy counters are monotonic until they wrap at their range; each + // interval is read across at most one wrap. + let deltas: Vec = reads.windows(2).map(|w| self.delta(w[0].0, w[1].0)).collect(); + // The counter must have advanced as a live rail does over the span + // (energy-measurement §3.1). One interval — a read at each end — never + // passes, nor does a counter that is frozen or moves in one batch. + if !Liveness::from_advances(deltas.iter().map(|d| *d > 0)).is_live_over(window_us) { + return None; + } + let delta = deltas.iter().try_fold(0u64, |a, d| a.checked_add(*d))?; // 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. @@ -254,9 +343,13 @@ impl Meter for CounterMeter { } } -/// Elapsed time multiplied by a calibrated power figure. A real number, and -/// honestly labelled as derived — it cannot claim a hardware class however it -/// is configured. +/// Elapsed time multiplied by a calibrated power figure: a modelled figure, +/// labelled `ModelBased`, never a measurement. It cannot claim a hardware class +/// however it is configured, and it never yields a figure of zero: a model +/// that gives nothing — a zero power, a span too short to reach a microjoule — +/// or a product too large to represent gives `None`, never a labelled zero +/// (which the receipts refuse) or a saturated maximum (which is not the +/// model's output). pub struct ModelMeter { clock_micros: Box u64>, milliwatts: u64, @@ -287,7 +380,10 @@ impl Meter for ModelMeter { let t0 = self.t0.take()?; let dt_micros = (self.clock_micros)().saturating_sub(t0); // uJ = mW * us / 1000 (mW*us = nJ; /1000 -> uJ) - let joules_micro = self.milliwatts.saturating_mul(dt_micros) / 1000; + let joules_micro = self.milliwatts.checked_mul(dt_micros)? / 1000; + if joules_micro == 0 { + return None; + } Some(EnergyReading { joules_micro, provenance: EnergyProvenance::ModelBased }) } @@ -317,16 +413,37 @@ mod tests { NonZeroU32::new(n).unwrap() } + /// A span over `n_reads` reads: the first at start, the last at stop, the + /// rest as samples. + fn span(m: &mut CounterMeter, n_reads: usize) -> Option { + m.start(); + for _ in 2..n_reads { + m.sample(); + } + m.stop() + } + + /// A meter over the counter values `counter`, read every `step_us`. + fn meter(counter: &[u64], step_us: u64, wrap_at: u64, p: u32) -> CounterMeter { + let clock: Vec = (0..counter.len() as u64).map(|i| 10 + i * step_us).collect(); + CounterMeter::new(scripted(counter.iter().copied().map(Some).collect()), scripted_clock(clock), wrap_at, ppm(p)) + } + + /// A span over `counter` read every `step_us`. + fn measured(counter: &[u64], step_us: u64, wrap_at: u64, p: u32) -> Option { + span(&mut meter(counter, step_us, wrap_at, p), counter.len()) + } + + /// Six reads 200 ms apart, advancing by `per` µJ each: five live intervals + /// over a second. + fn live(start: u64, per: u64) -> Vec { + (0..6).map(|i| start + i * per).collect() + } + #[test] 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(); + let mut m = meter(&live(1_000, 700), 200_000, 1 << 32, 20_000); + let r = span(&mut m, 6).unwrap(); assert_eq!(r.joules_micro, 3_500); // 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. @@ -344,13 +461,11 @@ mod tests { #[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 { + let mut m = meter(&live(0, 2), 80_000, 1 << 32, 150_000).with_filtering(CounterFiltering::On); + match span(&mut m, 6).unwrap().provenance { EnergyProvenance::OnChipCounter { filtering, uncertainty } => { assert_eq!(filtering, CounterFiltering::On); - assert_eq!(uncertainty.window_us, 100_000); + assert_eq!(uncertainty.window_us, 400_000); } other => panic!("unexpected class {other:?}"), } @@ -362,9 +477,11 @@ mod tests { CounterMeter::calibrated_instrument(scripted(vec![]), scripted_clock(vec![]), 1 << 32, ppm(5_000), " "), Err(AcquisitionError::NoCalibrationRef) )); + let reads = live(100, 120); + let clock: Vec = (0..6).map(|i| i * 12_000_000).collect(); let mut m = CounterMeter::calibrated_instrument( - scripted(vec![Some(100), Some(700)]), - scripted_clock(vec![0, 60_000_000]), + scripted(reads.iter().copied().map(Some).collect()), + scripted_clock(clock), 1 << 32, ppm(5_000), "lab-cert-0412", @@ -372,8 +489,7 @@ mod tests { .unwrap() .with_filtering(CounterFiltering::On); // no effect on an instrument assert_eq!(m.kind(), AcquisitionKind::CalibratedInstrument); - m.start(); - let r = m.stop().unwrap(); + let r = span(&mut m, 6).unwrap(); assert_eq!( r.provenance, EnergyProvenance::CalibratedInstrument { @@ -386,32 +502,28 @@ mod tests { #[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); + // A 5 µJ delta, the least a live span can show (one µJ in each of five + // intervals): the operator's 2 % is 0.1 µJ, below the 1/√6 µJ the two + // whole-microjoule end readings leave. Declaring 2 % alone would state + // less than the integer can carry, and the receipts refuse that. + let r = measured(&live(10, 1), 100_000, 1 << 32, 20_000).unwrap(); + assert_eq!(r.joules_micro, 5); let u = r.provenance.uncertainty().unwrap(); - assert_eq!(u.relative_ppm, 137_545); + assert_eq!(u.relative_ppm, 84_064); 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}"); + for per in [1u64, 2, 5, 17, 288, 289, 1_000, 288_676, 10_000_000] { + let r = measured(&live(0, per), 100_000, u64::MAX, 1).unwrap(); + assert_eq!(crate::energy_class::check_resolution(r.joules_micro, &r.provenance), Ok(()), "per {per}"); } } #[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)]), scripted_clock(vec![0, 5]), 10_000, ppm(1)); - m.start(); - assert_eq!(m.stop().unwrap().joules_micro, 400); + // Range 10_000 (wrap_at 10_000): 9_800 -> 200 crosses the wrap in the + // third interval; every interval advances by 100 µJ. + assert_eq!(measured(&[9_700, 9_800, 9_900, 0, 100, 200], 100_000, 10_000, 1).unwrap().joules_micro, 500); } #[test] @@ -424,16 +536,66 @@ mod tests { // And a stop with no start is likewise nothing. let mut m2 = CounterMeter::new(scripted(vec![Some(5)]), scripted_clock(vec![0]), 1 << 32, ppm(1)); assert!(m2.stop().is_none()); + // A read that fails during the span breaks the span. + let mut m3 = CounterMeter::new( + scripted(vec![Some(0), Some(1), None, Some(3), Some(4), Some(5)]), + scripted_clock((0..6).map(|i| i * 100_000).collect()), + 1 << 32, + ppm(1), + ); + assert!(span(&mut m3, 6).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)); + assert!(measured(&[7; 6], 200_000, 1 << 32, 1).is_none(), "no energy counted is a failure to measure"); + assert!(measured(&live(7, 2), 0, 1 << 32, 1).is_none(), "no window is not an integration"); + } + + // ── fault injection: a counter whose rail is not live (§3.1) ── + + /// Two reads are one interval: the delta a counter shows between them is + /// no evidence that it was refreshed in between. Before the liveness rule + /// this sealed a 3,500 µJ OnChipCounter reading. + #[test] + fn two_reads_are_never_a_live_span() { + 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(); - 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"); + assert!(m.stop().is_none()); + } + + #[test] + fn a_frozen_counter_is_refused() { + assert!(measured(&[5_000; 11], 100_000, 1 << 32, 20_000).is_none()); + } + + #[test] + fn a_single_refresh_lump_is_refused() { + assert!(measured(&[5_000, 5_000, 5_000, 9_000, 9_000, 9_000, 9_000, 9_000, 9_000, 9_000, 9_000], 100_000, 1 << 32, 20_000).is_none()); + } + + #[test] + fn a_two_step_refresh_is_refused() { + // Two consecutive advances, and nothing else in ten intervals. + assert!(measured(&[5_000, 5_000, 5_000, 7_000, 9_000, 9_000, 9_000, 9_000, 9_000, 9_000, 9_000], 100_000, 1 << 32, 20_000).is_none()); + } + + #[test] + fn a_live_counter_over_too_short_a_span_is_refused() { + assert!(measured(&live(0, 100), 59_000, 1 << 32, 20_000).is_none(), "five live intervals over 295 ms"); + assert!(measured(&live(0, 100), 60_000, 1 << 32, 20_000).is_some(), "over 300 ms"); + } + + #[test] + fn the_probe_refuses_a_counter_that_is_not_live() { + let mut frozen = meter(&[1; 6], 60_000, 1 << 32, 20_000); + assert!(!frozen.probe(5, || {}), "five intervals: six reads"); + let mut ok = meter(&live(0, 3), 60_000, 1 << 32, 20_000); + assert!(ok.probe(5, || {})); + // The probe's span is discarded: a stop after it measures nothing. + assert!(ok.stop().is_none()); + // An absent interface gives no meter, whatever the probe would say. + assert!(CounterMeter::rapl_with_probe("no-such-domain", ppm(20_000), |_| true).is_none()); } #[test] @@ -448,12 +610,46 @@ mod tests { assert_eq!(m.kind(), AcquisitionKind::ModelBased); } + #[test] + fn model_meter_never_yields_a_labelled_zero() { + // A span too short to reach a microjoule, a zero power, a zero span: + // a model that gives nothing is None, never a ModelBased zero. + for (clock, mw) in [(vec![0, 400], 2), (vec![0, 1_000_000], 0), (vec![5, 5], 2_000)] { + let mut m = ModelMeter::new(scripted_clock(clock), mw); + m.start(); + assert_eq!(m.stop(), None); + } + } + + #[test] + fn model_meter_refuses_overflow() { + let mut m = ModelMeter::new(scripted_clock(vec![0, u64::MAX]), 2); + m.start(); + assert_eq!(m.stop(), None, "a product past u64 is refused, not saturated to u64::MAX / 1000"); + let mut m = ModelMeter::new(scripted_clock(vec![0, u64::MAX / 2]), 2); + m.start(); + assert_eq!(m.stop().unwrap().joules_micro, (u64::MAX / 2 * 2) / 1000); + } + + #[test] + fn model_meter_is_never_hardware() { + let meters = [ModelMeter::new(scripted_clock(vec![0, 1_000_000]), 1_000), ModelMeter::wall_clock(1_000)]; + for mut m in meters { + assert_eq!(m.kind(), AcquisitionKind::ModelBased); + m.start(); + if let Some(r) = m.stop() { + assert_eq!(r.provenance, EnergyProvenance::ModelBased); + assert!(!r.provenance.is_hardware()); + } + } + } + #[test] fn seal_args_round_trip_to_the_receipt_contract() { 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. + // The unmetered marker the receipts accept. assert_eq!(EnergyReading::unmetered(), (0, None)); } diff --git a/wai/wai-rs/src/energy_report.rs b/wai/wai-rs/src/energy_report.rs new file mode 100644 index 000000000..19c3f218b --- /dev/null +++ b/wai/wai-rs/src/energy_report.rs @@ -0,0 +1,808 @@ +//! The integers a measurement report carries, and the arithmetic every reader +//! applies to them (`extensions/energy-measurement.md` §3.1 and §6.2). +//! +//! A figure obtained under the bracketed protocol of §3 is published with the +//! values its bracket gave: each bracket's sample count, mean and standard +//! deviation, the window, the completion count, the thread count and how the +//! window opened, and the facts that show the power reader's rail was live +//! (§3.1). This module holds those values as integers and computes from them, +//! exactly, the figure they give and the least uncertainty they support. A +//! producer seals the figure and the declaration this module computes, so a +//! verifier holding the same integers reaches the same two numbers: no +//! floating-point operation is involved, and the products are evaluated at +//! whatever width they need. +//! +//! The values describe a measurement and are the measurer's statements. What +//! the arithmetic checks is that the figure and the declaration are what the +//! values give. It cannot check that the values were measured (§5). +//! +//! # Liveness +//! +//! A power reader can return a figure for a rail that is not being refreshed: +//! a frozen rail reads as a flat zero, or holds its last value, and a rail +//! refreshed in batches publishes one lump, sometimes in two steps, inside a +//! bracket that does not move. A marginal computed from such a bracket is not +//! a measurement of the work. A power sample counts as an advance only when it +//! is above zero and differs from the sample before it, so a held value never +//! advances. A rail is **live** over a bracket only if it advanced in two +//! consecutive intervals, in at least five intervals, and in at least four +//! intervals of every five (§3.1). [`Liveness`] computes those facts and the +//! rule; the report carries them per bracket, and a figure sealed under a +//! hardware class needs both brackets live. A bracket whose standard deviation +//! is zero has samples that are all equal, so at most one of them advanced; +//! its facts cannot show a live rail, and a reader refuses it whatever facts +//! it carries ([`BracketSummary::is_live`]). + +#![deny(clippy::float_arithmetic)] + +use crate::energy_class::WindowOpening; + +/// The largest integer a report carries: I-JSON's exact-integer bound, as a +/// claim's (`jwp-receipts.md` §2.4.3). Crate-private so the C header does not +/// gain a generic name. +const MAX_INTEGER: u64 = (1 << 53) - 1; + +/// The least number of intervals in which a rail must advance (§3.1). +const MIN_LIVE_INTERVALS: u64 = 5; +/// The least run of consecutive advancing intervals (§3.1). +const MIN_LIVE_RUN: u64 = 2; +/// The least span, in microseconds, of a bracket whose rail is called live +/// (§3.1). Checked by the producer, which holds the bracket's timing; a +/// report's idle bracket carries no span. +const MIN_LIVE_SPAN_US: u64 = 300_000; + +/// Whether a rail advanced, interval by interval, and what that adds up to. +/// +/// An interval is one sample of a power reader, or one pair of successive +/// reads of an energy counter. For a power reader a sample "advanced" when it +/// is strictly above zero and differs from the sample before it (the first +/// sample of a bracket has none before it, and advanced when it is above zero); +/// for a counter, when the second read exceeds the first (modulo the counter's +/// wrap). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Liveness { + /// The intervals the bracket spans. + pub intervals: u64, + /// The intervals in which the rail advanced. + pub live_intervals: u64, + /// The longest run of consecutive intervals in which it advanced. + pub longest_live_run: u64, +} + +impl Liveness { + /// The facts of a sequence of intervals, each `true` where the rail + /// advanced. + pub fn from_advances>(advances: I) -> Liveness { + let mut l = Liveness::default(); + let mut run = 0u64; + for a in advances { + l.intervals += 1; + if a { + l.live_intervals += 1; + run += 1; + l.longest_live_run = l.longest_live_run.max(run); + } else { + run = 0; + } + } + l + } + + /// The facts of a bracket of power samples: an interval advanced where its + /// sample is strictly above zero and differs from the one before it. A + /// reader that holds its last value between refreshes repeats it, and a + /// repeated value is not an advance; nor is a sample that is not a number. + pub fn of_power_samples(samples_w: &[f64]) -> Liveness { + let mut previous: Option = None; + Liveness::from_advances(samples_w.iter().map(|w| { + let advanced = *w > 0.0 && previous.is_none_or(|p| p != *w); + previous = Some(*w); + advanced + })) + } + + /// §3.1's rule over the facts alone: at least two consecutive advances, at + /// least five advances, and advances in at least four intervals of every + /// five. One advance is not a live rail, and neither is one refresh + /// published in two steps inside a bracket of zeros. Facts that cannot + /// describe one sequence — more live intervals than intervals, or a run + /// longer than the live intervals — are not live either. + pub fn is_live(&self) -> bool { + let (n, live, run) = (self.intervals as u128, self.live_intervals as u128, self.longest_live_run as u128); + run <= live + && live <= n + && run >= MIN_LIVE_RUN as u128 + && live >= MIN_LIVE_INTERVALS as u128 + && live * 5 >= n * 4 + } + + /// [`Liveness::is_live`] together with the producer's span rule: the + /// bracket lasted at least 300 ms (§3.1). + pub fn is_live_over(&self, span_us: u64) -> bool { + self.is_live() && span_us >= MIN_LIVE_SPAN_US + } +} + +/// One bracket of a report, as integers (§6.2). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BracketSummary { + /// The sample count. + pub n: u64, + /// The mean, in microwatts, rounded to the nearest with a half rounding up. + pub mean_uw: u64, + /// The sample standard deviation (divisor `n − 1`), in microwatts, rounded + /// up. + pub sd_uw: u64, + /// The samples in which the reader's rail advanced (§3.1). + pub live_intervals: u64, + /// The longest run of consecutive samples in which it advanced (§3.1). + pub longest_live_run: u64, +} + +impl BracketSummary { + /// This bracket's liveness facts, over `n` intervals. + pub fn liveness(&self) -> Liveness { + Liveness { intervals: self.n, live_intervals: self.live_intervals, longest_live_run: self.longest_live_run } + } + + /// Whether these values show a live rail (§3.1): the facts meet + /// [`Liveness::is_live`], and the samples are not all equal. A standard + /// deviation of zero means every sample repeated the first, and a repeated + /// sample is not an advance, so facts that claim a live rail beside it + /// contradict the bracket they describe. + pub fn is_live(&self) -> bool { + self.sd_uw > 0 && self.liveness().is_live() + } +} + +/// The values a report carries for a figure obtained under §3. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BracketValues { + pub idle: BracketSummary, + pub busy: BracketSummary, + /// `T`: the busy bracket's window, in microseconds rounded to the nearest. + pub window_us: u64, + /// `C`: completions counted inside the window. + pub completions: u64, + /// `N`: the thread count. + pub in_flight: u64, + pub opening: WindowOpening, +} + +/// The first rule of energy-measurement §6.2 (jwp-receipts §2.4.8 rule 5) that +/// a report's bracket breaks, in the order the rules are checked. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReportDefect { + /// A bracket with fewer than two samples. + TooFewSamples, + /// Under a hardware class, a bracket whose rail was not live (§3.1). + RailNotLive, + /// A zero window, completion count or thread count. + EmptyBurst, + /// A busy mean not above the idle mean. + NoMarginal, + /// Under a hardware class, a marginal below the idle standard deviation: + /// a ratio below 1, which §4 makes a failure to measure. + BelowAttributionFloor, + /// The figure is not the one the bracket gives. + FigureMismatch, + /// Under a hardware class, a declared window other than the bracket's. + WindowMismatch, + /// Under a hardware class, a declared uncertainty below the least the + /// bracket supports. + DeclarationBelowBracket, +} + +impl ReportDefect { + /// The conformance reason (jwp-receipts §3.1). + pub fn reason(self) -> &'static str { + match self { + ReportDefect::TooFewSamples => "measurement_too_few_samples", + ReportDefect::RailNotLive => "measurement_rail_not_live", + ReportDefect::EmptyBurst => "measurement_empty_burst", + ReportDefect::NoMarginal => "measurement_no_marginal", + ReportDefect::BelowAttributionFloor => "measurement_below_attribution_floor", + ReportDefect::FigureMismatch => "measurement_figure_mismatch", + ReportDefect::WindowMismatch => "measurement_window_mismatch", + ReportDefect::DeclarationBelowBracket => "measurement_declaration_below_bracket", + } + } +} + +/// §4's verdict, derived from a report's values (§6.2). No report states it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Attribution { + /// `M ≥ 10 · s_idle`: publishable as an absolute figure. + Clean, + /// `M ≥ 3 · s_idle`: sound for comparing two workloads measured back to + /// back, not as an absolute figure. + RelativeOnly, + /// `M ≥ s_idle`: not publishable. + Noisy, + /// Below that: a failure to measure. + Unusable, +} + +impl Attribution { + pub fn label(self) -> &'static str { + match self { + Attribution::Clean => "clean — publishable", + Attribution::RelativeOnly => "usable for relative comparison — NOT publishable", + Attribution::Noisy => "NOISY — do not publish", + Attribution::Unusable => "UNUSABLE — the box was not idle, or the reader saw no draw", + } + } +} + +impl BracketValues { + fn in_range(&self) -> bool { + [ + self.idle.n, + self.idle.mean_uw, + self.idle.sd_uw, + self.idle.live_intervals, + self.idle.longest_live_run, + self.busy.n, + self.busy.mean_uw, + self.busy.sd_uw, + self.busy.live_intervals, + self.busy.longest_live_run, + self.window_us, + self.completions, + self.in_flight, + ] + .iter() + .all(|v| *v <= MAX_INTEGER) + } + + /// `M`: the busy mean less the idle mean, in microwatts, when it is + /// positive. + pub fn marginal_uw(&self) -> Option { + self.busy.mean_uw.checked_sub(self.idle.mean_uw).filter(|m| *m > 0) + } + + /// `(Wn, Wd)`: the work the window paid for is `Wn / Wd` (§2.3). + fn work(&self) -> (u128, u128) { + let (c, n) = (self.completions as u128, self.in_flight as u128); + match self.opening { + WindowOpening::FromRest => (2 * c + n, 2), + WindowOpening::Running => (c, 1), + } + } + + /// Whether the marginal is at least the idle standard deviation: §4's + /// ratio of at least 1. + pub fn meets_attribution_floor(&self) -> bool { + self.marginal_uw().is_some_and(|m| m >= self.idle.sd_uw) + } + + /// §4's verdict from these values, or `None` with no positive marginal. + /// A flat idle bracket (`s_idle = 0`) under a positive marginal is + /// `Clean` by the arithmetic; whether the reader's rail was live is a + /// separate rule ([`BracketValues::check`]), and a flat bracket never + /// passes it ([`BracketSummary::is_live`]). + pub fn attribution(&self) -> Option { + let m = self.marginal_uw()? as u128; + let s = self.idle.sd_uw as u128; + Some(if m >= 10 * s { + Attribution::Clean + } else if m >= 3 * s { + Attribution::RelativeOnly + } else if m >= s { + Attribution::Noisy + } else { + Attribution::Unusable + }) + } + + /// `F`: the per-unit estimate `M·T·Wd / (10⁶·Wn)` µJ rounded to the nearest + /// microjoule, a half rounding up (§6.2). `None` when the bracket has no + /// positive marginal, no window, no completion or no thread, a value above + /// 2⁵³ − 1, or a figure of zero or above 2⁵³ − 1. + pub fn figure(&self) -> Option { + if !self.in_range() || self.window_us == 0 || self.completions == 0 || self.in_flight == 0 { + return None; + } + let m = self.marginal_uw()? as u128; + let (wn, wd) = self.work(); + let t = self.window_us as u128; + let num = 2 * m * t * wd + 1_000_000 * wn; + let den = 2_000_000 * wn; + let f = num / den; + (f > 0 && f <= MAX_INTEGER as u128).then_some(f as u64) + } + + /// `K` and `P` of §6.2 for the figure `figure`: a declaration of `p` ppm is + /// supported exactly when `p²·K ≥ P`. + fn k_and_p(&self, figure: u64) -> Option<(Nat, Nat)> { + if !self.in_range() || figure == 0 { + return None; + } + let m = Nat::from(self.marginal_uw()? as u128); + let (wn, wd) = self.work(); + let (wn, wd) = (Nat::from(wn), Nat::from(wd)); + let t = Nat::from(self.window_us as u128); + let n = Nat::from(self.in_flight as u128); + let (ni, nb) = (Nat::from(self.idle.n as u128), Nat::from(self.busy.n as u128)); + let (si, sb) = (Nat::from(self.idle.sd_uw as u128), Nat::from(self.busy.sd_uw as u128)); + let f = Nat::from(figure as u128); + let ninb = ni.mul(&nb); + let wn2 = wn.mul(&wn); + let wn4 = wn2.mul(&wn2); + let t2wd2 = t.mul(&t).mul(&wd).mul(&wd); + let k = Nat::from(12).mul(&ninb).mul(&wn4).mul(&f).mul(&f); + let spread = si.mul(&si).mul(&nb).add(&sb.mul(&sb).mul(&ni)); + let p_power = Nat::from(12).mul(&wn2).mul(&t2wd2).mul(&spread); + let p_count = Nat::from(4).mul(&ninb).mul(&m).mul(&m).mul(&t2wd2).mul(&n).mul(&n); + let p_res = Nat::from(1_000_000_000_000).mul(&ninb).mul(&wn4); + Some((k, p_power.add(&p_count).add(&p_res))) + } + + /// Whether a declaration of `relative_ppm` on `figure` covers the + /// uncertainty the values give: `p²·K ≥ P` (§6.2). `None` when the values + /// give no figure to declare for. + pub fn supports(&self, figure: u64, relative_ppm: u32) -> Option { + let (k, p) = self.k_and_p(figure)?; + let pp = Nat::from(relative_ppm as u128).mul(&Nat::from(relative_ppm as u128)); + Some(pp.mul(&k) >= p) + } + + /// The least `p` the values support for `figure`, or `None` where no + /// `u32` declaration covers it (§2.7) or there is no figure. The least `p` + /// always meets the resolution floor, `figure · p ≥ 288 676` (§2.3). + pub fn least_relative_ppm(&self, figure: u64) -> Option { + let (k, p) = self.k_and_p(figure)?; + let holds = |x: u32| Nat::from(x as u128).mul(&Nat::from(x as u128)).mul(&k) >= p; + if !holds(u32::MAX) { + return None; + } + let (mut lo, mut hi) = (1u32, u32::MAX); + while lo < hi { + let mid = lo + (hi - lo) / 2; + if holds(mid) { + hi = mid; + } else { + lo = mid + 1; + } + } + Some(lo) + } + + /// Every rule of jwp-receipts §2.4.8 rule 5, in its order, for a report of + /// `figure` declared under `hardware` — `Some((relative_ppm, window_us))` + /// for a hardware class, `None` for any other. The first rule broken is + /// returned. + pub fn check(&self, figure: u64, hardware: Option<(u32, u64)>) -> Result<(), ReportDefect> { + if self.idle.n < 2 || self.busy.n < 2 { + return Err(ReportDefect::TooFewSamples); + } + if hardware.is_some() && !(self.idle.is_live() && self.busy.is_live()) { + return Err(ReportDefect::RailNotLive); + } + if self.window_us == 0 || self.completions == 0 || self.in_flight == 0 { + return Err(ReportDefect::EmptyBurst); + } + if self.marginal_uw().is_none() { + return Err(ReportDefect::NoMarginal); + } + if hardware.is_some() && !self.meets_attribution_floor() { + return Err(ReportDefect::BelowAttributionFloor); + } + if self.figure() != Some(figure) { + return Err(ReportDefect::FigureMismatch); + } + if let Some((ppm, window_us)) = hardware { + if window_us != self.window_us { + return Err(ReportDefect::WindowMismatch); + } + if self.supports(figure, ppm) != Some(true) { + return Err(ReportDefect::DeclarationBelowBracket); + } + } + Ok(()) + } +} + +/// How the code a figure measured was built (energy-measurement §5). +/// +/// The vocabulary is `release` and `debug`; a finer one is a new registration +/// (jwp-receipts §2.4.4). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BuildProfile { + /// Optimised as shipped: cargo's `release` profile at `opt-level` 2 or 3. + Release, + /// Any other build. + Debug, +} + +impl BuildProfile { + pub fn label(self) -> &'static str { + match self { + BuildProfile::Release => "release", + BuildProfile::Debug => "debug", + } + } + + pub fn from_label(s: &str) -> Option { + match s { + "release" => Some(BuildProfile::Release), + "debug" => Some(BuildProfile::Debug), + _ => None, + } + } + + /// The profile this crate was built under, as `build.rs` recorded it from + /// cargo's `PROFILE` and `OPT_LEVEL` (`src/build_info.rs`). + pub fn of_this_build() -> BuildProfile { + BuildProfile::from_label(env!("WAI_BUILD_PROFILE")).unwrap_or(BuildProfile::Debug) + } +} + +/// The build a figure measured: its profile, and where known the compiler +/// and the target (energy-measurement §5, §8). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BuildRecord { + pub profile: BuildProfile, + /// The compiler's version line, e.g. `rustc 1.98.0 (…)`. + pub toolchain: Option, + /// The target triple. + pub target: Option, +} + +impl BuildRecord { + /// This crate's build, as `build.rs` recorded it. A part `build.rs` could + /// not determine is `None`. + pub fn of_this_build() -> BuildRecord { + let nonempty = |s: &str| (!s.is_empty()).then(|| s.to_owned()); + BuildRecord { + profile: BuildProfile::of_this_build(), + toolchain: option_env!("WAI_BUILD_TOOLCHAIN").and_then(nonempty), + target: option_env!("WAI_BUILD_TARGET").and_then(nonempty), + } + } +} + +/// An unsigned integer of any width, little-endian in 64-bit limbs with no +/// high zero limb — only what §6.2's products need: multiply, add, compare. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Nat(Vec); + +impl Nat { + fn from(v: u128) -> Nat { + let mut n = Nat(vec![v as u64, (v >> 64) as u64]); + n.trim(); + n + } + + fn trim(&mut self) { + while self.0.last() == Some(&0) { + self.0.pop(); + } + } + + fn mul(&self, o: &Nat) -> Nat { + if self.0.is_empty() || o.0.is_empty() { + return Nat(Vec::new()); + } + let mut out = vec![0u64; self.0.len() + o.0.len()]; + for (i, a) in self.0.iter().enumerate() { + let mut carry = 0u128; + for (j, b) in o.0.iter().enumerate() { + let t = (*a as u128) * (*b as u128) + out[i + j] as u128 + carry; + out[i + j] = t as u64; + carry = t >> 64; + } + let mut k = i + o.0.len(); + while carry > 0 { + let t = out[k] as u128 + carry; + out[k] = t as u64; + carry = t >> 64; + k += 1; + } + } + let mut n = Nat(out); + n.trim(); + n + } + + fn add(&self, o: &Nat) -> Nat { + let len = self.0.len().max(o.0.len()); + let mut out = Vec::with_capacity(len + 1); + let mut carry = 0u128; + for i in 0..len { + let t = *self.0.get(i).unwrap_or(&0) as u128 + *o.0.get(i).unwrap_or(&0) as u128 + carry; + out.push(t as u64); + carry = t >> 64; + } + if carry > 0 { + out.push(carry as u64); + } + let mut n = Nat(out); + n.trim(); + n + } + + #[cfg(test)] + fn bits(&self) -> u32 { + match self.0.last() { + None => 0, + Some(top) => (self.0.len() as u32 - 1) * 64 + (64 - top.leading_zeros()), + } + } +} + +impl PartialOrd for Nat { + fn partial_cmp(&self, o: &Nat) -> Option { + Some(self.cmp(o)) + } +} + +impl Ord for Nat { + fn cmp(&self, o: &Nat) -> core::cmp::Ordering { + self.0.len().cmp(&o.0.len()).then_with(|| self.0.iter().rev().cmp(o.0.iter().rev())) + } +} + +/// The §6.2 arithmetic, pinned against values `claim-conformance/claim_verify.py` +/// computes with Python's integers — code that shares nothing with this module. +/// Returns the checks that fail; empty means the two agree. +pub fn check_bracket_arithmetic() -> Vec { + let mut fails = Vec::new(); + for (name, v, f, p) in pinned_brackets() { + if v.figure() != Some(f) { + fails.push(format!("{name}: figure {:?}, pinned {f}", v.figure())); + } + if v.least_relative_ppm(f) != Some(p) { + fails.push(format!("{name}: least ppm {:?}, pinned {p}", v.least_relative_ppm(f))); + } + if v.supports(f, p) != Some(true) || v.supports(f, p - 1) != Some(false) { + fails.push(format!("{name}: {p} ppm is not the least supported")); + } + } + fails +} + +/// The brackets [`check_bracket_arithmetic`] pins: `(name, values, F, p)`, as +/// `claim_verify.py`'s `bracket_fixture()` prints them. +fn pinned_brackets() -> Vec<(&'static str, BracketValues, u64, u32)> { + let s = |n: u64, mean_uw: u64, sd_uw: u64| BracketSummary { n, mean_uw, sd_uw, live_intervals: n, longest_live_run: n }; + let v = |idle, busy, window_us, completions, in_flight, opening| BracketValues { + idle, + busy, + window_us, + completions, + in_flight, + opening, + }; + use WindowOpening::{FromRest, Running}; + let x = MAX_INTEGER; + vec![ + ("unit_from_rest", v(s(4, 20_000_000, 115_471), s(4, 30_000_000, 115_471), 3_000_000, 60, 10, FromRest), 461_538, 45_156), + ("unit_running", v(s(4, 20_000_000, 115_471), s(4, 30_000_000, 115_471), 3_000_000, 60, 10, Running), 500_000, 96_571), + ("sealed_from_samples", v(s(4, 20_000_000, 115_471), s(4, 29_930_000, 115_471), 2_007_000, 1_000, 10, FromRest), 19_830, 8_711), + ("one_microjoule", v(s(5, 20_000_000, 200_000), s(5, 30_000_000, 500_000), 2_815_000, 50_000_000, 10, FromRest), 1, 288_994), + ("half_up", v(s(4, 20_000_000, 1_000), s(4, 21_000_000, 1_000), 2_500_000, 1_000_000, 1, Running), 3, 96_227), + ("wide", v(s(10, 20_000_000, 115_471), s(14, 30_000_000, 115_471), 100_000_000_000, 1_000_000_000_000, 10, FromRest), 1, 288_715), + ("at_bounds", v(s(x, 0, x), s(x, x, x), x, x, x, FromRest), 6_004_799_503, 192_451), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn s(n: u64, mean_uw: u64, sd_uw: u64) -> BracketSummary { + BracketSummary { n, mean_uw, sd_uw, live_intervals: n, longest_live_run: n } + } + + fn unit(opening: WindowOpening) -> BracketValues { + BracketValues { + idle: s(10, 20_000_000, 115_471), + busy: s(14, 30_000_000, 115_471), + window_us: 3_000_000, + completions: 60, + in_flight: 10, + opening, + } + } + + /// The arithmetic of §6.2 against the literals the independent script + /// computes: the figure, the least declaration, and that one ppm less is + /// not supported — including products of 151, 211 and 429 bits. + #[test] + fn pinned_brackets() { + assert_eq!(check_bracket_arithmetic(), Vec::::new()); + // Each case's widest product, so a narrower implementation is known to + // be out of its depth here, not merely wrong. + let bits: Vec = super::pinned_brackets() + .iter() + .map(|(_, v, f, p)| { + let (k, pp) = v.k_and_p(*f).unwrap(); + Nat::from(*p as u128).mul(&Nat::from(*p as u128)).mul(&k).bits().max(pp.bits()) + }) + .collect(); + assert_eq!(bits, vec![105, 103, 107, 151, 124, 211, 429]); + } + + #[test] + fn half_rounds_up_and_from_rest_divides_by_the_midpoint() { + // E = 2.5 uJ exactly: a half rounds up, never to even. + let h = super::pinned_brackets().into_iter().find(|c| c.0 == "half_up").unwrap(); + assert_eq!(h.1.figure(), Some(3)); + // From rest the divisor is C + N/2 = 65, not C = 60. + assert_eq!(unit(WindowOpening::FromRest).figure(), Some(461_538)); + assert_eq!(unit(WindowOpening::Running).figure(), Some(500_000)); + } + + #[test] + fn least_meets_the_resolution_floor() { + for c in [1u64, 2, 3, 7, 50, 1_000, 60_000, 1_000_000, 50_000_000] { + for t in [1_000u64, 200_000, 2_815_000, 30_000_000] { + for opening in [WindowOpening::FromRest, WindowOpening::Running] { + let v = BracketValues { completions: c, window_us: t, opening, ..unit(opening) }; + let Some(f) = v.figure() else { continue }; + let p = v.least_relative_ppm(f).unwrap(); + assert!(f as u128 * p as u128 >= 288_676, "C={c} T={t}: F={f} p={p}"); + assert_eq!(v.supports(f, p), Some(true)); + if p > 1 { + assert_eq!(v.supports(f, p - 1), Some(false)); + } + } + } + } + } + + #[test] + fn defects_are_reported_in_order() { + let hw = Some((45_156u32, 3_000_000u64)); + let ok = unit(WindowOpening::FromRest); + let f = ok.figure().unwrap(); + let p = ok.least_relative_ppm(f).unwrap(); + assert_eq!(ok.check(f, Some((p, 3_000_000))), Ok(())); + // Two defects at once: the earlier rule is reported. + let two = BracketValues { idle: s(1, 20_000_000, 0), completions: 0, ..ok }; + assert_eq!(two.check(f, hw), Err(ReportDefect::TooFewSamples)); + let frozen = BracketValues { busy: BracketSummary { live_intervals: 1, longest_live_run: 1, ..ok.busy }, completions: 0, ..ok }; + assert_eq!(frozen.check(f, hw), Err(ReportDefect::RailNotLive)); + assert_eq!(frozen.check(f, None), Err(ReportDefect::EmptyBurst), "liveness binds the hardware classes only"); + let flat = BracketValues { busy: s(14, 20_000_000, 1), ..ok }; + assert_eq!(flat.check(f, None), Err(ReportDefect::NoMarginal)); + let noisy = BracketValues { idle: s(10, 20_000_000, 10_000_001), ..ok }; + assert_eq!(noisy.check(f, hw), Err(ReportDefect::BelowAttributionFloor)); + assert_eq!(ok.check(f + 1, Some((p, 3_000_000))), Err(ReportDefect::FigureMismatch)); + assert_eq!(ok.check(f, Some((p, 3_000_001))), Err(ReportDefect::WindowMismatch)); + assert_eq!(ok.check(f, Some((p - 1, 3_000_000))), Err(ReportDefect::DeclarationBelowBracket)); + assert_eq!(ok.check(f, None), Ok(()), "a non-hardware class declares nothing to check"); + } + + #[test] + fn attribution_thresholds() { + let at = |m: u64, sd: u64| { + BracketValues { idle: s(10, 1_000, sd), busy: s(14, 1_000 + m, 5), ..unit(WindowOpening::FromRest) }.attribution() + }; + assert_eq!(at(100, 10), Some(Attribution::Clean)); + assert_eq!(at(99, 10), Some(Attribution::RelativeOnly)); + assert_eq!(at(30, 10), Some(Attribution::RelativeOnly)); + assert_eq!(at(29, 10), Some(Attribution::Noisy)); + assert_eq!(at(10, 10), Some(Attribution::Noisy)); + assert_eq!(at(9, 10), Some(Attribution::Unusable)); + assert_eq!(at(1, 0), Some(Attribution::Clean), "a flat idle under a positive marginal, by the arithmetic"); + assert_eq!(at(0, 0), None, "no marginal, no verdict"); + } + + /// §3.1's liveness rule: necessary conditions each, and the cases a + /// refreshed-in-batches rail produces. + #[test] + fn a_rail_is_live_only_over_enough_consecutive_advances() { + let l = |s: &str| Liveness::from_advances(s.chars().map(|c| c == '1')); + assert!(l("1111111111").is_live()); + assert!(l("1111111110").is_live(), "9 of 10"); + assert!(l("1111011110").is_live(), "8 of 10, runs of 4"); + assert!(l("1110111011").is_live(), "8 of 10, longest run 3"); + assert!(!l("0000000000").is_live(), "a frozen rail"); + assert!(!l("0000100000").is_live(), "one refresh lump"); + assert!(!l("0000110000").is_live(), "one refresh published in two steps: two consecutive advances alone are not enough"); + assert!(!l("1010101010").is_live(), "no two consecutive advances"); + assert!(!l("1111").is_live(), "fewer than five advances"); + assert!(!l("1111100000").is_live(), "five of ten is below four in five"); + assert!(!Liveness { intervals: 10, live_intervals: 11, longest_live_run: 2 }.is_live(), "more live than intervals"); + assert!(!Liveness { intervals: 10, live_intervals: 8, longest_live_run: 9 }.is_live(), "a run longer than the live intervals"); + assert!(l("11111").is_live_over(300_000) && !l("11111").is_live_over(299_999)); + assert_eq!(Liveness::of_power_samples(&[0.0, 1.0, 2.0, f64::NAN, 3.0]), Liveness { intervals: 5, live_intervals: 3, longest_live_run: 2 }); + } + + /// A reader that holds its last non-zero value between refreshes: a rail + /// stuck at 5 W, refreshed once to 12 W while the burst runs. Every sample + /// is above zero, so a rule that counted a non-zero sample as an advance + /// would call both brackets live; a repeated value is not an advance. + #[test] + fn a_held_value_is_not_an_advance() { + let idle = [5.0; 10]; + let busy = [5.0, 5.0, 5.0, 12.0, 12.0, 12.0, 12.0, 12.0, 12.0, 12.0, 12.0, 12.0, 12.0, 12.0]; + assert_eq!(Liveness::of_power_samples(&idle), Liveness { intervals: 10, live_intervals: 1, longest_live_run: 1 }); + assert_eq!(Liveness::of_power_samples(&busy), Liveness { intervals: 14, live_intervals: 2, longest_live_run: 1 }); + assert!(!Liveness::of_power_samples(&idle).is_live() && !Liveness::of_power_samples(&busy).is_live()); + // A live reader whose readings move every sample is unaffected. + assert!(Liveness::of_power_samples(&[5.0, 5.1, 5.0, 5.2, 4.9, 5.0, 5.1]).is_live()); + // Two equal readings in a row break a run; one in five may repeat. + assert_eq!(Liveness::of_power_samples(&[5.0, 5.1, 5.1, 5.2, 5.3, 5.4]), Liveness { intervals: 6, live_intervals: 5, longest_live_run: 3 }); + } + + /// A reader of the report refuses a flat bracket under a hardware class, + /// whatever liveness facts it carries: equal samples mean at most one + /// advance, so facts that say otherwise are not the bracket's. + #[test] + fn a_flat_bracket_is_never_live() { + let hw = Some((45_156u32, 3_000_000u64)); + let ok = unit(WindowOpening::FromRest); + let f = ok.figure().unwrap(); + assert!(ok.idle.is_live() && ok.busy.is_live()); + // The stuck-rail report: a flat idle at 5 W with full liveness facts, a + // busy bracket with one refresh in it. The arithmetic calls it clean. + let stuck = BracketValues { idle: s(10, 5_000_000, 0), busy: s(14, 10_000_000, 3_631_365), ..ok }; + assert_eq!(stuck.attribution(), Some(Attribution::Clean)); + let fs = stuck.figure().unwrap(); + assert!(!stuck.idle.is_live(), "sd 0 is not a live rail"); + assert_eq!(stuck.check(fs, hw), Err(ReportDefect::RailNotLive)); + assert_eq!(stuck.check(fs, None), Ok(()), "liveness binds the hardware classes only"); + let flat_busy = BracketValues { busy: s(14, 30_000_000, 0), ..ok }; + assert_eq!(flat_busy.check(f, hw), Err(ReportDefect::RailNotLive)); + } + + #[test] + fn nat_products_agree_with_u128() { + let xs = [0u128, 1, 7, u64::MAX as u128, 1 << 64, (1 << 100) + 12345]; + for a in xs { + for b in xs { + if let Some(p) = a.checked_mul(b) { + assert_eq!(Nat::from(a).mul(&Nat::from(b)), Nat::from(p)); + } + if let Some(q) = a.checked_add(b) { + assert_eq!(Nat::from(a).add(&Nat::from(b)), Nat::from(q)); + } + assert_eq!(Nat::from(a).cmp(&Nat::from(b)), a.cmp(&b)); + } + } + } + + /// The products against an independent big-integer library, where one is + /// in the build. + #[cfg(feature = "oer")] + #[test] + fn wide_matches_biguint() { + use num_bigint::BigUint; + let to_big = |n: &Nat| { + n.0.iter().rev().fold(BigUint::from(0u8), |acc, limb| (acc << 64u32) + BigUint::from(*limb)) + }; + for (_, v, f, p) in super::pinned_brackets() { + let (k, pp) = v.k_and_p(f).unwrap(); + let ws = |x: u64| BigUint::from(x); + let (wn, wd) = v.work(); + let (wn, wd) = (BigUint::from(wn), BigUint::from(wd)); + let m = ws(v.marginal_uw().unwrap()); + let t = ws(v.window_us); + let (ni, nb, si, sb) = (ws(v.idle.n), ws(v.busy.n), ws(v.idle.sd_uw), ws(v.busy.sd_uw)); + let k_want = ws(12) * &ni * &nb * wn.pow(4) * ws(f) * ws(f); + let p_want = ws(12) * wn.pow(2) * t.pow(2) * wd.pow(2) * (&si * &si * &nb + &sb * &sb * &ni) + + ws(4) * &ni * &nb * m.pow(2) * t.pow(2) * wd.pow(2) * ws(v.in_flight).pow(2) + + ws(1_000_000_000_000) * &ni * &nb * wn.pow(4); + assert_eq!(to_big(&k), k_want); + assert_eq!(to_big(&pp), p_want); + assert!(ws(p as u64).pow(2) * &k_want >= p_want); + } + } + + #[test] + fn the_build_profile_vocabulary() { + assert_eq!(BuildProfile::from_label("release"), Some(BuildProfile::Release)); + assert_eq!(BuildProfile::from_label("debug"), Some(BuildProfile::Debug)); + assert_eq!(BuildProfile::from_label("Release"), None); + // This test binary is built under the dev profile at opt-level 0. + if cfg!(debug_assertions) && crate::build_info::profile_label("debug", "0") == "debug" { + assert_eq!(BuildProfile::of_this_build().label(), env!("WAI_BUILD_PROFILE")); + } + let r = BuildRecord::of_this_build(); + assert!(r.target.as_deref().is_some_and(|t| t.contains('-')), "{r:?}"); + assert!(r.toolchain.as_deref().is_some_and(|t| t.starts_with("rustc ")), "{r:?}"); + } +} diff --git a/wai/wai-rs/src/film/receipt.rs b/wai/wai-rs/src/film/receipt.rs index 4166012eb..2bc9b6832 100644 --- a/wai/wai-rs/src/film/receipt.rs +++ b/wai/wai-rs/src/film/receipt.rs @@ -214,7 +214,8 @@ mod tests { impl Film { /// Build a film receipt over this film's per-frame reconstruction blocks. - /// `joules_micro` is supplied by the meter (0 = unmetered). + /// `joules_micro` is supplied by the meter; `0` is the unmetered marker, + /// never a figure of zero joules (energy-measurement §2). pub fn receipt( &self, signer: &SigningKey, diff --git a/wai/wai-rs/src/jwp_delivery.rs b/wai/wai-rs/src/jwp_delivery.rs new file mode 100644 index 000000000..51ecd8064 --- /dev/null +++ b/wai/wai-rs/src/jwp_delivery.rs @@ -0,0 +1,1228 @@ +//! JWP delivery receipts — the `group` and `object` receipts of +//! `extensions/jwp-receipts.md` §2.2–§2.4.2 — read and verified as §3 and §3.3 +//! define them, with the two forms of each that carry an energy figure's class +//! under the publisher's signature. +//! +//! # What the signatures cover +//! +//! - **An object's leaf.** In the legacy form, `BLAKE3("moq-jwp:merkle-leaf\x01" ‖ +//! content_hash)`, the leaf covers the envelope only: the object's +//! `joules_micro` and `origin` ride beside it unsigned. In the labelled form, +//! `BLAKE3("moq-jwp:merkle-leaf\x02" ‖ content_hash ‖ u64_be(joules_micro) ‖ +//! class ‖ u32_be(len(origin)) ‖ origin)`, the leaf — and through the Merkle +//! root the group signature — covers the figure, its class (the signed +//! encoding of energy-measurement §2.7, tag `0` for an object explicitly +//! unmetered), the class's evidence and the origin. The version byte keeps +//! the two forms disjoint, so a label, or the origin, can be neither added, +//! stripped nor altered without changing the root. +//! - **A group's total.** The legacy payload (`moq-jwp:group-sign\x01`) signs +//! `joules_total` and no class. The labelled payload (`moq-jwp:group-sign\x02`) +//! signs the same fields followed by the total's class, its coverage, and the +//! parent link, so a holder of the group receipt alone holds a signed class +//! for the total. That class is composed from the objects' classes by the +//! rule [`compose_group_class`] implements; a holder of every object checks it. +//! +//! Every new form is opt-in: an issued receipt keeps its bytes and verifies as +//! before. A verifier that predates them refuses a labelled object at §3 step 5 +//! and a labelled group at §3 step 2. +//! +//! # Accepting an object +//! +//! [`ObjectReceipt::accept`] is §3 steps 1–5: the group verifies, the envelope +//! hashes to the object's content hash, and the object's leaf — at the position +//! its proof walks, which must be the object's own — reaches the verified root. +//! [`ObjectReceipt::verify_inclusion`] is step 5 alone, against a root nobody +//! has verified, and is not an acceptance: every reading this module gives +//! from an object ([`read_group_total`], [`WaiReceiptProperty`]) goes through +//! `accept`. +//! +//! Signatures are verified under jwp-receipts §3.1 step 3's strict Ed25519 +//! rule, the one claims are held to, so every conforming verifier accepts the +//! same group signatures. +//! +//! This crate seals group receipts only to generate its conformance corpus; +//! [`GroupBuilder`] is not a publishing path. + +use crate::claim_receipt::{object_content_hash, verify_ed25519_strict, ClaimBody, ClaimReceipt, Reason as ClaimReason}; +use crate::energy_binding::Coverage; +use crate::energy_class::{ + check_label, json_fields, label_from_json, AcquisitionError, AcquisitionKind, CounterFiltering, EnergyProvenance, + Uncertainty, JSON_CALIBRATION_REF, JSON_CLASS, JSON_FILTERING, JSON_UNCERTAINTY, +}; +use ed25519_dalek::{Signer, SigningKey}; +use serde_json::{Map, Value}; + +/// §2.2: the legacy leaf domain. +pub const DOMAIN_LEAF: &[u8] = b"moq-jwp:merkle-leaf\x01"; +/// §2.2: the labelled leaf domain. +pub const DOMAIN_LEAF_LABELLED: &[u8] = b"moq-jwp:merkle-leaf\x02"; +/// §2.2: the node domain. +pub const DOMAIN_NODE: &[u8] = b"moq-jwp:merkle-node\x01"; +/// §2.3: the legacy group signing domain. +pub const DOMAIN_GROUP_SIGN: &[u8] = b"moq-jwp:group-sign\x01"; +/// §2.3: the labelled group signing domain. +pub const DOMAIN_GROUP_SIGN_LABELLED: &[u8] = b"moq-jwp:group-sign\x02"; +/// §3 step 3: the domain of a group receipt's link hash. +pub const DOMAIN_RECEIPT_LINK: &[u8] = b"moq-jwp:receipt-link\x01"; + +// ───────────────────────────── reasons ───────────────────────────── + +/// Why a delivery receipt, a group total or a property was refused (§3.3). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Reason { + /// A receipt that is not a JSON object, lacks a member, carries a member its + /// kind does not define, or holds a member of the wrong type or encoding. + Malformed, + /// `kind` is not the receipt's kind. + WrongKind, + /// `signer_pubkey` does not decode under RFC 8032 §5.1.3, or is of small order. + BadSignerKey, + /// The group signature does not verify under jwp-receipts §3.1 step 3's rule. + SignatureVerifyFailed, + /// A group's own class breaks §2.4.1: unreadable, legacy `HwShunt`, a class + /// beside a zero total or none beside a non-zero one, a refined class without + /// its evidence or below the resolution floor, or class members in a legacy + /// group. + GroupLabelInvalid, + /// The parent link does not name the previous group receipt held. + ChainBreak, + /// A non-null parent with no previous group receipt held. + ParentNotHeld, + /// The envelope does not hash to the object's `content_hash` (§3 step 4). + ContentHashMismatch, + /// An object's label is unreadable or breaks §2.4.2. + ObjectLabelInvalid, + /// The object is not at the position its proof walks: `leaf_index` outside + /// the group, a proof of the wrong length, direction bits other than the + /// index's, an `object_id` other than the index, or another group's + /// coordinates. + ProofPositionMismatch, + /// The leaf, walked up its proof, does not reach the verified root. + InclusionProofFailed, + /// The accepted objects, one per leaf, do not sum to `joules_total`. + GroupTotalMismatch, + /// A labelled group's class is not the one its objects compose to. + GroupClassDisagrees, + /// A labelled group's coverage is not the one its objects give. + GroupCoverageDisagrees, + /// A labelled group declares less uncertainty than its objects compose to, + /// or another window. + GroupUncertaintyBelowParts, + /// A `WAI_RECEIPT` property breaks moq-streaming-format §5. + PropertyInvalid, + /// A property disagrees with a signed statement of the same object. + PropertyDisagrees, +} + +impl Reason { + pub fn label(self) -> &'static str { + match self { + Reason::Malformed => "malformed", + Reason::WrongKind => "wrong_kind", + Reason::BadSignerKey => "bad_signer_key", + Reason::SignatureVerifyFailed => "signature_verify_failed", + Reason::GroupLabelInvalid => "group_label_invalid", + Reason::ChainBreak => "chain_break", + Reason::ParentNotHeld => "parent_not_held", + Reason::ContentHashMismatch => "content_hash_mismatch", + Reason::ObjectLabelInvalid => "object_label_invalid", + Reason::ProofPositionMismatch => "proof_position_mismatch", + Reason::InclusionProofFailed => "inclusion_proof_failed", + Reason::GroupTotalMismatch => "group_total_mismatch", + Reason::GroupClassDisagrees => "group_class_disagrees", + Reason::GroupCoverageDisagrees => "group_coverage_disagrees", + Reason::GroupUncertaintyBelowParts => "group_uncertainty_below_parts", + Reason::PropertyInvalid => "property_invalid", + Reason::PropertyDisagrees => "property_disagrees", + } + } +} + +/// A refusal: the §3 step it happened at — `None` for the group total and for a +/// property, which are not steps of §3 — and why. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DeliveryError { + pub step: Option, + pub reason: Reason, +} + +impl DeliveryError { + fn at(step: u8, reason: Reason) -> DeliveryError { + DeliveryError { step: Some(step), reason } + } + fn of(reason: Reason) -> DeliveryError { + DeliveryError { step: None, reason } + } +} + +impl core::fmt::Display for DeliveryError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self.step { + Some(s) => write!(f, "rejected at step {s}: {}", self.reason.label()), + None => write!(f, "rejected: {}", self.reason.label()), + } + } +} + +impl std::error::Error for DeliveryError {} + +// ───────────────────────────── the tree ───────────────────────────── + +/// An object's label in the labelled leaf: a class, or the explicit unmetered +/// marker (tag `0`), which the labelled form allows only beside a zero figure. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ObjectLabel { + /// Tag `0`: explicitly unmetered, with the origin bound. + Unmetered, + /// A class of energy-measurement §2 with its evidence. + Class(EnergyProvenance), +} + +impl ObjectLabel { + /// The signed encoding (energy-measurement §2.7): the tag, then the evidence. + pub fn write_signed(&self, o: &mut Vec) { + match self { + ObjectLabel::Unmetered => o.push(0), + ObjectLabel::Class(p) => p.write_signed(o), + } + } + + pub fn class(&self) -> Option<&EnergyProvenance> { + match self { + ObjectLabel::Unmetered => None, + ObjectLabel::Class(p) => Some(p), + } + } +} + +/// §2.2's legacy leaf. +pub fn leaf_legacy(content_hash: &[u8; 32]) -> [u8; 32] { + let mut h = blake3::Hasher::new(); + h.update(DOMAIN_LEAF); + h.update(content_hash); + *h.finalize().as_bytes() +} + +/// §2.2's labelled leaf: the figure, its label and the origin after the content +/// hash. The label's encoding is self-delimiting and the origin is +/// length-prefixed, so no two inputs share a leaf. +pub fn leaf_labelled(content_hash: &[u8; 32], joules_micro: u64, label: &ObjectLabel, origin: &str) -> [u8; 32] { + let mut b = Vec::with_capacity(32 + 8 + 32 + 4 + origin.len()); + b.extend_from_slice(content_hash); + b.extend_from_slice(&joules_micro.to_be_bytes()); + label.write_signed(&mut b); + b.extend_from_slice(&(origin.len() as u32).to_be_bytes()); + b.extend_from_slice(origin.as_bytes()); + let mut h = blake3::Hasher::new(); + h.update(DOMAIN_LEAF_LABELLED); + h.update(&b); + *h.finalize().as_bytes() +} + +/// §2.2's node. +pub fn node(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { + let mut h = blake3::Hasher::new(); + h.update(DOMAIN_NODE); + h.update(left); + h.update(right); + *h.finalize().as_bytes() +} + +/// §2.2's root over leaves already hashed: the last of an odd level is +/// duplicated, and the empty group's root is all zero. +pub fn root(leaves: &[[u8; 32]]) -> [u8; 32] { + if leaves.is_empty() { + return [0u8; 32]; + } + let mut level = leaves.to_vec(); + while level.len() > 1 { + if level.len() % 2 == 1 { + level.push(*level.last().expect("non-empty")); + } + level = level.chunks_exact(2).map(|p| node(&p[0], &p[1])).collect(); + } + level[0] +} + +/// The number of siblings a proof in a group of `leaf_count` leaves holds: +/// ⌈log₂ leaf_count⌉, and 0 for a single leaf. +pub fn proof_len(leaf_count: u64) -> u32 { + if leaf_count <= 1 { 0 } else { 64 - (leaf_count - 1).leading_zeros() } +} + +/// A §2.4.2 inclusion proof. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MerkleProof { + pub siblings: Vec<[u8; 32]>, + /// Bit `i` is 1 where the current node at level `i` is the right child. + pub direction_bits: u64, + pub leaf_index: u64, +} + +impl MerkleProof { + /// The proof for leaf `index` of `leaves`, as the publisher builds it. + pub fn build(leaves: &[[u8; 32]], index: usize) -> MerkleProof { + let mut siblings = Vec::new(); + let mut bits = 0u64; + let mut level = leaves.to_vec(); + let mut i = index; + let mut depth = 0; + while level.len() > 1 { + if level.len() % 2 == 1 { + level.push(*level.last().expect("non-empty")); + } + siblings.push(level[i ^ 1]); + if i & 1 == 1 { + bits |= 1 << depth; + } + level = level.chunks_exact(2).map(|p| node(&p[0], &p[1])).collect(); + i /= 2; + depth += 1; + } + MerkleProof { siblings, direction_bits: bits, leaf_index: index as u64 } + } + + /// Walk `leaf` up the siblings (§3 step 5). Only [`ObjectReceipt::verify_inclusion`] + /// calls it, after [`MerkleProof::walks_its_position`]; a proof read from JSON can + /// hold any number of siblings, and a level past the 64 direction bits reads as a + /// left child rather than shifting out of range. + pub(crate) fn walk(&self, leaf: &[u8; 32]) -> [u8; 32] { + let mut cur = *leaf; + for (i, s) in self.siblings.iter().enumerate() { + let right = u32::try_from(i).ok().and_then(|i| self.direction_bits.checked_shr(i)).unwrap_or(0) & 1 == 1; + cur = if right { node(s, &cur) } else { node(&cur, s) }; + } + cur + } + + /// Whether the proof walks position `leaf_index` of a group of `leaf_count` + /// leaves (§3 step 5): the index is inside the group, the proof has the + /// group's depth, and the direction bits are the index's bits and no + /// others. Without this a leaf duplicated at the end of an odd level walks + /// two positions, and an object can be presented at another's index. + pub fn walks_its_position(&self, leaf_count: u64) -> bool { + self.leaf_index < leaf_count + && self.siblings.len() as u64 == proof_len(leaf_count) as u64 + && self.direction_bits == self.leaf_index + } +} + +// ───────────────────────────── the group ───────────────────────────── + +/// A labelled group's own class (§2.4.1): its class — `None` for the +/// unmetered marker, tag `0` beside a zero total — and its coverage. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GroupLabel { + pub class: Option, + pub coverage: Coverage, +} + +impl GroupLabel { + fn write_signed(&self, o: &mut Vec) { + match &self.class { + None => o.push(0), + Some(p) => p.write_signed(o), + } + o.push(match (&self.class, self.coverage) { + (None, _) | (Some(_), Coverage::Whole) => 0, + (Some(_), Coverage::Partial) => 1, + }); + } + + /// The group alone's checks (§2.4.1): the unmetered marker exactly when the + /// total is zero, and with it no coverage but `Whole`; otherwise a class as + /// an optional label must be (energy-measurement §2.8) — never `HwShunt`, + /// with its evidence, at the resolution floor. + fn check(&self, joules_total: u64) -> Result<(), AcquisitionError> { + match &self.class { + None if joules_total == 0 && self.coverage == Coverage::Whole => Ok(()), + None => Err(AcquisitionError::NoUncertainty), + Some(p) => check_label(joules_total, Some(p)), + } + } +} + +/// A §2.4.1 group receipt, in either payload form. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GroupReceipt { + pub track_alias: u64, + pub group_id: u64, + pub root_hash: [u8; 32], + pub leaf_count: u64, + pub joules_total: u64, + pub group_start: String, + pub group_end: String, + pub signer_pubkey: [u8; 32], + pub signer_id: String, + pub sig: [u8; 64], + pub parent_receipt_hash: Option<[u8; 32]>, + /// The total's own class: `Some` in the labelled form, signed under + /// `moq-jwp:group-sign\x02`; `None` in the legacy form. + pub label: Option, +} + +const GROUP_MEMBERS: &[&str] = &[ + "kind", + "track_alias", + "group_id", + "root_hash", + "leaf_count", + "joules_total", + "group_start", + "group_end", + "signer_pubkey", + "signer_id", + "sig", + "parent_receipt_hash", +]; +const GROUP_LABEL_MEMBERS: &[&str] = + &["sign_version", JSON_CLASS, JSON_UNCERTAINTY, JSON_FILTERING, JSON_CALIBRATION_REF, "energy_coverage"]; + +impl GroupReceipt { + /// §2.3's signing payload: the legacy form, or the labelled form — the same + /// fields under the `\x02` domain, then the class (§2.7, tag `0` for the + /// unmetered marker), the coverage (`0` Whole, `1` Partial), and the parent + /// link (`1` and the hash, or `0` and 32 zero bytes). + pub fn signing_payload(&self) -> Vec { + let mut b = Vec::with_capacity(256); + b.extend_from_slice(if self.label.is_some() { DOMAIN_GROUP_SIGN_LABELLED } else { DOMAIN_GROUP_SIGN }); + b.extend_from_slice(&self.track_alias.to_be_bytes()); + b.extend_from_slice(&self.group_id.to_be_bytes()); + b.extend_from_slice(&self.root_hash); + b.extend_from_slice(&self.leaf_count.to_be_bytes()); + b.extend_from_slice(&self.joules_total.to_be_bytes()); + for s in [&self.group_start, &self.group_end] { + b.extend_from_slice(&(s.len() as u32).to_be_bytes()); + b.extend_from_slice(s.as_bytes()); + } + b.extend_from_slice(&self.signer_pubkey); + b.extend_from_slice(&(self.signer_id.len() as u32).to_be_bytes()); + b.extend_from_slice(self.signer_id.as_bytes()); + if let Some(l) = &self.label { + l.write_signed(&mut b); + match self.parent_receipt_hash { + Some(h) => { + b.push(1); + b.extend_from_slice(&h); + } + None => { + b.push(0); + b.extend_from_slice(&[0u8; 32]); + } + } + } + b + } + + /// `canonical_bytes(G)` (§3 step 3): the signing payload followed by `sig`. + pub fn canonical_bytes(&self) -> Vec { + let mut b = self.signing_payload(); + b.extend_from_slice(&self.sig); + b + } + + /// The link hash (§3 step 3): `BLAKE3("moq-jwp:receipt-link\x01" ‖ + /// canonical_bytes(G))`. A claim names a group by it, and a `WAI_RECEIPT` + /// property's `group_receipt` holds it. + pub fn link_hash(&self) -> [u8; 32] { + let mut h = blake3::Hasher::new(); + h.update(DOMAIN_RECEIPT_LINK); + h.update(&self.canonical_bytes()); + *h.finalize().as_bytes() + } + + /// §3 steps 1–2: the key (`bad_signer_key`), the signature under §3.1 step + /// 3's strict rule (`signature_verify_failed`), then a labelled group's own + /// checks (`group_label_invalid`). + pub fn verify(&self) -> Result<(), DeliveryError> { + verify_ed25519_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).map_err(|r| match r { + ClaimReason::BadSignerKey => DeliveryError::at(1, Reason::BadSignerKey), + _ => DeliveryError::at(2, Reason::SignatureVerifyFailed), + })?; + if let Some(l) = &self.label { + l.check(self.joules_total).map_err(|_| DeliveryError::at(2, Reason::GroupLabelInvalid))?; + } + Ok(()) + } + + /// §3 step 3: the parent link against the previous group receipt the + /// verifier holds for this signer. A previous receipt that does not verify + /// is not held. + pub fn check_parent(&self, previous: Option<&GroupReceipt>) -> Result<(), DeliveryError> { + let previous = previous.filter(|p| p.verify().is_ok()); + match (previous, self.parent_receipt_hash) { + (None, None) => Ok(()), + (None, Some(_)) => Err(DeliveryError::at(3, Reason::ParentNotHeld)), + (Some(_), None) => Err(DeliveryError::at(3, Reason::ChainBreak)), + (Some(p), Some(h)) => { + if p.signer_pubkey != self.signer_pubkey || h != p.link_hash() { + Err(DeliveryError::at(3, Reason::ChainBreak)) + } else { + Ok(()) + } + } + } + } + + /// The §2.4.1 JSON form: the table's members in its order, then, for a + /// labelled group, `sign_version: 2`, the class's members (§2.7) and + /// `energy_coverage` — none of them for the unmetered marker. + pub fn to_json(&self) -> Value { + let mut m = Map::new(); + m.insert("kind".into(), "group".into()); + m.insert("track_alias".into(), self.track_alias.into()); + m.insert("group_id".into(), self.group_id.into()); + m.insert("root_hash".into(), hex(&self.root_hash).into()); + m.insert("leaf_count".into(), self.leaf_count.into()); + m.insert("joules_total".into(), self.joules_total.into()); + m.insert("group_start".into(), self.group_start.clone().into()); + m.insert("group_end".into(), self.group_end.clone().into()); + m.insert("signer_pubkey".into(), hex(&self.signer_pubkey).into()); + m.insert("signer_id".into(), self.signer_id.clone().into()); + m.insert("sig".into(), hex(&self.sig).into()); + m.insert("parent_receipt_hash".into(), self.parent_receipt_hash.map_or(Value::Null, |h| hex(&h).into())); + if let Some(l) = &self.label { + m.insert("sign_version".into(), 2.into()); + if let Some(p) = &l.class { + for (k, v) in json_fields(p) { + m.insert(k.into(), v); + } + m.insert("energy_coverage".into(), l.coverage.label().into()); + } + } + Value::Object(m) + } + + /// Read §2.4.1's JSON form (§3 step 1). A member the kind does not define + /// is `malformed`; class members in a legacy group, a `sign_version` other + /// than 2, or an unreadable class in a labelled one, are + /// `group_label_invalid`, never read as the legacy form. + pub fn from_json(v: &Value) -> Result { + let bad = DeliveryError::at(1, Reason::Malformed); + let m = v.as_object().ok_or(bad)?; + match m.get("kind").and_then(Value::as_str) { + None => return Err(bad), + Some("group") => {} + Some(_) => return Err(DeliveryError::at(1, Reason::WrongKind)), + } + if GROUP_MEMBERS.iter().any(|k| !m.contains_key(*k)) + || m.keys().any(|k| !GROUP_MEMBERS.contains(&k.as_str()) && !GROUP_LABEL_MEMBERS.contains(&k.as_str())) + { + return Err(bad); + } + let mut g = GroupReceipt { + track_alias: u64_of(m, "track_alias").ok_or(bad)?, + group_id: u64_of(m, "group_id").ok_or(bad)?, + root_hash: hash_of(m, "root_hash").ok_or(bad)?, + leaf_count: u64_of(m, "leaf_count").ok_or(bad)?, + joules_total: u64_of(m, "joules_total").ok_or(bad)?, + group_start: str_of(m, "group_start").ok_or(bad)?, + group_end: str_of(m, "group_end").ok_or(bad)?, + signer_pubkey: hash_of(m, "signer_pubkey").ok_or(bad)?, + signer_id: str_of(m, "signer_id").ok_or(bad)?, + sig: m.get("sig").and_then(Value::as_str).and_then(unhex).and_then(|b| b.try_into().ok()).ok_or(bad)?, + parent_receipt_hash: match m.get("parent_receipt_hash") { + Some(Value::Null) => None, + _ => Some(hash_of(m, "parent_receipt_hash").ok_or(bad)?), + }, + label: None, + }; + // §3 step 1 and §3.3: a group's labelled members — `sign_version` among them — + // that do not read as the labelled form are `group_label_invalid`, a + // `sign_version` other than 2 included, as `leaf_version` is on the object side. + let label_invalid = DeliveryError::at(1, Reason::GroupLabelInvalid); + let present = |k: &str| !matches!(m.get(k), None | Some(Value::Null)); + match m.get("sign_version") { + None | Some(Value::Null) => { + if GROUP_LABEL_MEMBERS.iter().any(|k| present(k)) { + return Err(label_invalid); + } + } + Some(v) if v.as_u64() == Some(2) => { + let class = label_from_json(m).map_err(|_| label_invalid)?; + let coverage = match (&class, m.get("energy_coverage")) { + (None, None | Some(Value::Null)) => Coverage::Whole, + (Some(_), Some(Value::String(c))) => coverage_of(c).ok_or(label_invalid)?, + _ => return Err(label_invalid), + }; + g.label = Some(GroupLabel { class, coverage }); + } + Some(_) => return Err(label_invalid), + } + Ok(g) + } +} + +/// A §2.3 legacy payload for a labelled group — what a verifier that predates +/// the labelled form computes — for tests that show such a verifier refuses +/// one. +#[cfg(test)] +fn legacy_payload(g: &GroupReceipt) -> Vec { + GroupReceipt { label: None, ..g.clone() }.signing_payload() +} + +// ───────────────────────────── the object ───────────────────────────── + +/// A §2.4.2 object receipt, in either leaf form. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ObjectReceipt { + pub track_alias: u64, + pub group_id: u64, + pub object_id: u64, + pub content_hash: [u8; 32], + pub joules_micro: u64, + pub origin: String, + pub merkle_proof: MerkleProof, + /// `Some` in the labelled leaf form, `None` in the legacy form. + pub label: Option, + /// The receipt was read with a label that cannot be read (§2.4.2); refused + /// at §3 step 5. + unreadable_label: bool, +} + +const OBJECT_MEMBERS: &[&str] = + &["kind", "track_alias", "group_id", "object_id", "content_hash", "joules_micro", "origin", "merkle_proof"]; +const OBJECT_LABEL_MEMBERS: &[&str] = &["leaf_version", JSON_CLASS, JSON_UNCERTAINTY, JSON_FILTERING, JSON_CALIBRATION_REF]; + +impl ObjectReceipt { + #[allow(clippy::too_many_arguments)] + pub fn new( + track_alias: u64, + group_id: u64, + object_id: u64, + content_hash: [u8; 32], + joules_micro: u64, + origin: impl Into, + merkle_proof: MerkleProof, + label: Option, + ) -> ObjectReceipt { + ObjectReceipt { + track_alias, + group_id, + object_id, + content_hash, + joules_micro, + origin: origin.into(), + merkle_proof, + label, + unreadable_label: false, + } + } + + /// The object's leaf, in the form its label gives (§2.2). + pub fn leaf(&self) -> [u8; 32] { + match &self.label { + None => leaf_legacy(&self.content_hash), + Some(l) => leaf_labelled(&self.content_hash, self.joules_micro, l, &self.origin), + } + } + + /// The label's checks (§2.4.2): tag `0` exactly beside a zero figure; a class + /// as an optional label must be — never `HwShunt`, never beside a zero + /// figure, with its evidence, at the resolution floor. + fn check_label(&self) -> Result<(), DeliveryError> { + let invalid = DeliveryError::at(5, Reason::ObjectLabelInvalid); + if self.unreadable_label { + return Err(invalid); + } + match &self.label { + None => Ok(()), + Some(ObjectLabel::Unmetered) if self.joules_micro == 0 => Ok(()), + Some(ObjectLabel::Unmetered) => Err(invalid), + Some(ObjectLabel::Class(p)) => check_label(self.joules_micro, Some(p)).map_err(|_| invalid), + } + } + + /// §3 step 5 alone, against `group`'s root as the group states it — which + /// nobody has verified here. Not an acceptance: a proof that reaches an + /// unverified root binds nothing. [`ObjectReceipt::accept`] is. + /// + /// In order: the label (`object_label_invalid`); the position — the object's + /// coordinates are the group's, `object_id` is `leaf_index`, and the proof + /// walks that index (`proof_position_mismatch`); the walk + /// (`inclusion_proof_failed`). + pub fn verify_inclusion(&self, group: &GroupReceipt) -> Result<(), DeliveryError> { + self.check_label()?; + if self.track_alias != group.track_alias + || self.group_id != group.group_id + || self.object_id != self.merkle_proof.leaf_index + || !self.merkle_proof.walks_its_position(group.leaf_count) + { + return Err(DeliveryError::at(5, Reason::ProofPositionMismatch)); + } + if self.merkle_proof.walk(&self.leaf()) != group.root_hash { + return Err(DeliveryError::at(5, Reason::InclusionProofFailed)); + } + Ok(()) + } + + /// §3 steps 1–5: `group` verifies, `envelope` hashes to `content_hash`, and + /// the object is included at its position under the verified root. What it + /// binds is §3 step 6's: the envelope, at its coordinates, to + /// `signer_pubkey`; and, where the leaf is labelled, the figure, its class, + /// the class's evidence and the origin. + pub fn accept(&self, group: &GroupReceipt, envelope: &[u8]) -> Result<(), DeliveryError> { + group.verify()?; + if object_content_hash(envelope) != self.content_hash { + return Err(DeliveryError::at(4, Reason::ContentHashMismatch)); + } + self.verify_inclusion(group) + } + + /// The §2.4.2 JSON form: the table's members in its order, then, for a + /// labelled leaf, `leaf_version: 2` and the class's members (§2.7) — none + /// for the unmetered marker. + pub fn to_json(&self) -> Value { + let mut m = Map::new(); + m.insert("kind".into(), "object".into()); + m.insert("track_alias".into(), self.track_alias.into()); + m.insert("group_id".into(), self.group_id.into()); + m.insert("object_id".into(), self.object_id.into()); + m.insert("content_hash".into(), hex(&self.content_hash).into()); + m.insert("joules_micro".into(), self.joules_micro.into()); + m.insert("origin".into(), self.origin.clone().into()); + let mut p = Map::new(); + p.insert( + "siblings".into(), + Value::Array(self.merkle_proof.siblings.iter().map(|s| Value::from(hex(s))).collect()), + ); + p.insert("direction_bits".into(), self.merkle_proof.direction_bits.into()); + p.insert("leaf_index".into(), self.merkle_proof.leaf_index.into()); + m.insert("merkle_proof".into(), Value::Object(p)); + if let Some(l) = &self.label { + m.insert("leaf_version".into(), 2.into()); + if let ObjectLabel::Class(c) = l { + for (k, v) in json_fields(c) { + m.insert(k.into(), v); + } + } + } + Value::Object(m) + } + + /// Read §2.4.2's JSON form. A missing or unknown member, or one of the + /// wrong type, is `malformed` at §3 step 4. A label that cannot be read — + /// class members without `leaf_version: 2`, evidence without a class, an + /// unknown class — is kept, and refused at §3 step 5 as + /// `object_label_invalid`, never read as the legacy form. + pub fn from_json(v: &Value) -> Result { + let bad = DeliveryError::at(4, Reason::Malformed); + let m = v.as_object().ok_or(bad)?; + match m.get("kind").and_then(Value::as_str) { + None => return Err(bad), + Some("object") => {} + Some(_) => return Err(DeliveryError::at(4, Reason::WrongKind)), + } + if OBJECT_MEMBERS.iter().any(|k| !m.contains_key(*k)) + || m.keys().any(|k| !OBJECT_MEMBERS.contains(&k.as_str()) && !OBJECT_LABEL_MEMBERS.contains(&k.as_str())) + { + return Err(bad); + } + let pm = m.get("merkle_proof").and_then(Value::as_object).ok_or(bad)?; + if pm.len() != 3 { + return Err(bad); + } + let siblings = pm + .get("siblings") + .and_then(Value::as_array) + .ok_or(bad)? + .iter() + .map(|s| s.as_str().and_then(unhex).and_then(|b| b.try_into().ok())) + .collect::>>() + .ok_or(bad)?; + let merkle_proof = MerkleProof { + siblings, + direction_bits: u64_of(pm, "direction_bits").ok_or(bad)?, + leaf_index: u64_of(pm, "leaf_index").ok_or(bad)?, + }; + let mut o = ObjectReceipt::new( + u64_of(m, "track_alias").ok_or(bad)?, + u64_of(m, "group_id").ok_or(bad)?, + u64_of(m, "object_id").ok_or(bad)?, + hash_of(m, "content_hash").ok_or(bad)?, + u64_of(m, "joules_micro").ok_or(bad)?, + str_of(m, "origin").ok_or(bad)?, + merkle_proof, + None, + ); + let present = |k: &str| !matches!(m.get(k), None | Some(Value::Null)); + match m.get("leaf_version") { + None | Some(Value::Null) => { + o.unreadable_label = OBJECT_LABEL_MEMBERS.iter().any(|k| present(k)); + } + Some(v) if v.as_u64() == Some(2) => match label_from_json(m) { + Ok(None) => o.label = Some(ObjectLabel::Unmetered), + Ok(Some(p)) => o.label = Some(ObjectLabel::Class(p)), + Err(_) => o.unreadable_label = true, + }, + Some(_) => o.unreadable_label = true, + } + Ok(o) + } +} + +// ───────────────────────────── the group total ───────────────────────────── + +/// The class a total's parts compose to (§3, *The group total*), or why it has +/// none. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Composition { + /// The total's class: `None` for the unmetered marker (every part zero). + pub class: Option, + pub coverage: Coverage, +} + +/// The composition rule (§2.4.1, energy-measurement §2.8), over the parts whose +/// figure is not zero — integers only: +/// +/// - any such part unlabelled: the total carries no class (`None`); +/// - no such part: the unmetered marker, `Whole`; +/// - otherwise the weakest class among them, in energy-measurement §2's order; +/// coverage `Partial` exactly when some part's figure is zero; and for a +/// hardware class the least declaration `⌈Σ F·p / Σ F⌉` ppm — linear in the +/// parts, so it assumes their errors fully correlated and is conservative — +/// over the longest window, with an on-chip counter's filtering state the +/// parts' common one or `Unknown`. A calibrated-instrument total needs one +/// calibration reference shared by every part, or it carries no class. +/// +/// Whatever it composes reaches the resolution floor: every part's `F·p` does. +pub fn compose_group_class(parts: &[(u64, Option<&ObjectLabel>)]) -> Option { + let any_zero = parts.iter().any(|(f, _)| *f == 0); + let mut classes = Vec::new(); + for (f, l) in parts { + if *f == 0 { + continue; + } + match l.and_then(|l| l.class()) { + Some(c) => classes.push((*f, c)), + None => return None, + } + } + if classes.is_empty() { + return Some(Composition { class: None, coverage: Coverage::Whole }); + } + let coverage = if any_zero { Coverage::Partial } else { Coverage::Whole }; + let weakest = classes.iter().map(|(_, c)| c.rank()).min().expect("non-empty"); + let class = match weakest { + r if r == EnergyProvenance::ModelBased.rank() => EnergyProvenance::ModelBased, + r if r == EnergyProvenance::Estimator.rank() => EnergyProvenance::Estimator, + _ => { + // Every part is a refined hardware class: legacy HwShunt is refused + // as a label, and anything weaker would be the weakest. + let (mut num, mut den, mut window) = (0u128, 0u128, 0u64); + for (f, c) in &classes { + let u = c.uncertainty()?; + num = num.checked_add((*f as u128).checked_mul(u.relative_ppm as u128)?)?; + den = den.checked_add(*f as u128)?; + window = window.max(u.window_us); + } + let ppm = u32::try_from(num.div_ceil(den)).ok()?; + let uncertainty = Uncertainty { relative_ppm: ppm, window_us: window }; + if weakest == 4 { + let mut filtering = classes.iter().filter_map(|(_, c)| match c { + EnergyProvenance::OnChipCounter { filtering, .. } => Some(*filtering), + _ => None, + }); + let first = filtering.next().expect("the weakest part is an on-chip counter"); + let common = if filtering.all(|f| f == first) { first } else { CounterFiltering::Unknown }; + EnergyProvenance::OnChipCounter { uncertainty, filtering: common } + } else { + let mut refs = classes.iter().map(|(_, c)| match c { + EnergyProvenance::CalibratedInstrument { calibration_ref, .. } => calibration_ref.as_str(), + _ => "", + }); + let first = refs.next().expect("non-empty"); + if !refs.all(|r| r == first) { + return None; + } + EnergyProvenance::CalibratedInstrument { uncertainty, calibration_ref: first.to_owned() } + } + } + }; + Some(Composition { class: Some(class), coverage }) +} + +/// How a group's total reads (§3, *The group total*). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TotalReading { + /// Every figure is zero: unmetered — never a total of zero joules. + Unmetered, + /// The total carries no class a reader may use: not every object is held, + /// or a non-zero figure is unlabelled. + Unlabelled, + /// A legacy group whose every non-zero part is labelled: the weakest class, + /// as a class name only — no declared uncertainty may be presented with it. + Weakest { kind: AcquisitionKind, coverage: Coverage }, + /// A labelled group's own class, under the publisher's signature; + /// `checked` where every object was held and the class is what they + /// compose to. + Signed { class: EnergyProvenance, coverage: Coverage, checked: bool }, +} + +/// An object and the envelope it was delivered with. +#[derive(Clone, Copy, Debug)] +pub struct Delivered<'a> { + pub object: &'a ObjectReceipt, + pub envelope: &'a [u8], +} + +/// §3, *The group total*: verify the group; accept each delivered object +/// ([`ObjectReceipt::accept`]) and keep those accepted; and, holding exactly one +/// accepted object for every index below `leaf_count`, check that their figures +/// sum to `joules_total` (`group_total_mismatch`). A legacy group's total is +/// then read from the objects' labels as energy-measurement §2.8 reads a total; +/// a labelled group's class must be what the objects compose to +/// (`group_class_disagrees`, `group_coverage_disagrees`, +/// `group_uncertainty_below_parts`). Without one object for every index — one +/// missing, or two at one index — a legacy total reads as unlabelled and a +/// labelled one reads as its own signed class, unchecked. +pub fn read_group_total(group: &GroupReceipt, delivered: &[Delivered]) -> Result { + group.verify()?; + // Keyed by index rather than sized by `leaf_count`, which the receipt states. + let mut held: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + for d in delivered { + if d.object.accept(group, d.envelope).is_ok() { + held.entry(d.object.merkle_proof.leaf_index).or_default().push(d.object); + } + } + // `accept` holds every index below `leaf_count`, so as many distinct + // indices as leaves is every index. + let complete = + group.leaf_count > 0 && held.len() as u64 == group.leaf_count && held.values().all(|h| h.len() == 1); + let own = |checked| match &group.label { + Some(GroupLabel { class: Some(c), coverage }) => TotalReading::Signed { class: c.clone(), coverage: *coverage, checked }, + Some(GroupLabel { class: None, .. }) => TotalReading::Unmetered, + None => TotalReading::Unlabelled, + }; + if !complete { + return Ok(own(false)); + } + let parts: Vec<&ObjectReceipt> = held.values().map(|h| h[0]).collect(); + let sum: u128 = parts.iter().map(|o| o.joules_micro as u128).sum(); + if sum != group.joules_total as u128 { + return Err(DeliveryError::of(Reason::GroupTotalMismatch)); + } + let labels: Vec<(u64, Option<&ObjectLabel>)> = parts.iter().map(|o| (o.joules_micro, o.label.as_ref())).collect(); + match &group.label { + None => { + if parts.iter().all(|o| o.joules_micro == 0) { + return Ok(TotalReading::Unmetered); + } + match compose_group_class(&labels) { + Some(Composition { class: Some(c), coverage }) => Ok(TotalReading::Weakest { kind: c.kind(), coverage }), + _ => Ok(TotalReading::Unlabelled), + } + } + Some(l) => { + let Some(want) = compose_group_class(&labels) else { + return Err(DeliveryError::of(Reason::GroupClassDisagrees)); + }; + match (&l.class, &want.class) { + (None, None) => {} + (Some(got), Some(want_c)) if same_class_ignoring_uncertainty(got, want_c) => {} + _ => return Err(DeliveryError::of(Reason::GroupClassDisagrees)), + } + if l.coverage != want.coverage { + return Err(DeliveryError::of(Reason::GroupCoverageDisagrees)); + } + if let (Some(got), Some(want_c)) = (&l.class, &want.class) { + if let (Some(g), Some(w)) = (got.uncertainty(), want_c.uncertainty()) { + if g.relative_ppm < w.relative_ppm || g.window_us != w.window_us { + return Err(DeliveryError::of(Reason::GroupUncertaintyBelowParts)); + } + } + } + Ok(own(true)) + } + } +} + +/// The same class with the same evidence other than the declared uncertainty. +fn same_class_ignoring_uncertainty(a: &EnergyProvenance, b: &EnergyProvenance) -> bool { + match (a, b) { + (EnergyProvenance::OnChipCounter { filtering: fa, .. }, EnergyProvenance::OnChipCounter { filtering: fb, .. }) => fa == fb, + ( + EnergyProvenance::CalibratedInstrument { calibration_ref: ra, .. }, + EnergyProvenance::CalibratedInstrument { calibration_ref: rb, .. }, + ) => ra == rb, + _ => a == b, + } +} + +// ───────────────────────────── the WAI_RECEIPT property ───────────────────────────── + +/// moq-streaming-format §5's per-object property. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WaiReceiptProperty { + pub content_hash: [u8; 32], + pub joules_micro: u64, + pub acquisition: String, + pub acquisition_evidence: Vec, + /// The link hash of the group receipt covering the object (§3 step 3). + pub group_receipt: [u8; 32], +} + +/// What a property's class is worth. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PropertyReading { + /// A zero figure: unmetered. + Unmetered, + /// A non-zero figure whose class no signed statement the sink holds + /// restates: outside every signature, so the figure reads as unlabelled. + Unsigned, + /// The class equals a signed statement of the same object, made by `by`: + /// the group's `signer_pubkey` where the statement is the object's leaf, + /// the claim's `signer_pubkey` where it is an `energy` claim. The class is + /// that key's statement and nobody else's; a claim's signer is the + /// publisher only where the sink holds it as the publisher's key. + Signed { class: EnergyProvenance, by: [u8; 32] }, +} + +/// The class `label` names with `evidence`, the bytes after the tag in +/// energy-measurement §2.7. `None` where the two do not make one class. +fn class_from_property(label: &str, evidence: &[u8]) -> Option { + let be32 = |b: &[u8]| u32::from_be_bytes(b.try_into().expect("4")); + let be64 = |b: &[u8]| u64::from_be_bytes(b.try_into().expect("8")); + match label { + "OnChipCounter" => { + if evidence.len() != 13 { + return None; + } + let filtering = match evidence[12] { + 0 => CounterFiltering::Unknown, + 1 => CounterFiltering::Off, + 2 => CounterFiltering::On, + _ => return None, + }; + let u = Uncertainty::new(be32(&evidence[0..4]), be64(&evidence[4..12])).ok()?; + EnergyProvenance::on_chip_counter(u, filtering).ok() + } + "CalibratedInstrument" => { + if evidence.len() < 16 || be32(&evidence[12..16]) as usize != evidence.len() - 16 { + return None; + } + let u = Uncertainty::new(be32(&evidence[0..4]), be64(&evidence[4..12])).ok()?; + let r = std::str::from_utf8(&evidence[16..]).ok()?; + EnergyProvenance::calibrated_instrument(u, r).ok() + } + other if evidence.is_empty() => EnergyProvenance::from_label(other).ok(), + _ => None, + } +} + +impl WaiReceiptProperty { + /// moq-streaming-format §5's rules on the property alone + /// (`property_invalid`): a non-zero figure with a class and that class's + /// evidence, at the resolution floor; a zero figure with neither. Returns + /// the class. + pub fn check(&self) -> Result, DeliveryError> { + let invalid = DeliveryError::of(Reason::PropertyInvalid); + if self.joules_micro == 0 { + return if self.acquisition.is_empty() && self.acquisition_evidence.is_empty() { Ok(None) } else { Err(invalid) }; + } + let class = class_from_property(&self.acquisition, &self.acquisition_evidence).ok_or(invalid)?; + crate::energy_class::check_resolution(self.joules_micro, &class).map_err(|_| invalid)?; + Ok(Some(class)) + } + + /// The property against the object receipt it describes: the property + /// names that object and that group (`property_disagrees`); the object is + /// accepted under `group` ([`ObjectReceipt::accept`]); and, where its leaf + /// is labelled, the figure and class equal the leaf's (`property_disagrees`). + pub fn check_against_object( + &self, + object: &ObjectReceipt, + group: &GroupReceipt, + envelope: &[u8], + ) -> Result { + let class = self.check()?; + let disagrees = DeliveryError::of(Reason::PropertyDisagrees); + if self.content_hash != object.content_hash || self.group_receipt != group.link_hash() { + return Err(disagrees); + } + object.accept(group, envelope)?; + match (&object.label, class) { + (None, None) => Ok(PropertyReading::Unmetered), + (None, Some(_)) => Ok(PropertyReading::Unsigned), + (Some(ObjectLabel::Unmetered), None) => Ok(PropertyReading::Unmetered), + (Some(ObjectLabel::Class(p)), Some(c)) if *p == c && object.joules_micro == self.joules_micro => { + Ok(PropertyReading::Signed { class: c, by: group.signer_pubkey }) + } + _ => Err(disagrees), + } + } + + /// The property against an `energy` claim about the object + /// (jwp-receipts §2.4.5). A claim that does not verify, or is not an energy + /// claim about this object, is no signed statement of it: the property + /// reads as unsigned. One that is, and states another figure or class, + /// is `property_disagrees`. An `energy` claim names no group, so + /// `group_receipt` is outside what it states; and anyone can sign one, so the + /// reading names the claim's signer, never the publisher + /// (moq-streaming-format §5). + pub fn check_against_claim(&self, claim: &ClaimReceipt) -> Result { + let class = self.check()?; + let unsigned = if class.is_some() { PropertyReading::Unsigned } else { PropertyReading::Unmetered }; + let ClaimBody::Energy(e) = &claim.body else { return Ok(unsigned) }; + if claim.verify().is_err() || claim.subject != self.content_hash { + return Ok(unsigned); + } + let claimed = e.acquisition_class().map_err(|_| DeliveryError::of(Reason::PropertyDisagrees))?; + match class { + Some(c) if c == claimed && e.joules_micro == self.joules_micro => { + Ok(PropertyReading::Signed { class: c, by: claim.signer_pubkey }) + } + _ => Err(DeliveryError::of(Reason::PropertyDisagrees)), + } + } + + /// The corpus's JSON form of a property. + pub fn to_json(&self) -> Value { + let mut m = Map::new(); + m.insert("content_hash".into(), hex(&self.content_hash).into()); + m.insert("joules_micro".into(), self.joules_micro.into()); + m.insert("acquisition".into(), self.acquisition.clone().into()); + m.insert("acquisition_evidence".into(), hex(&self.acquisition_evidence).into()); + m.insert("group_receipt".into(), hex(&self.group_receipt).into()); + Value::Object(m) + } + + pub fn from_json(v: &Value) -> Option { + let m = v.as_object()?; + if m.len() != 5 { + return None; + } + Some(WaiReceiptProperty { + content_hash: hash_of(m, "content_hash")?, + joules_micro: u64_of(m, "joules_micro")?, + acquisition: str_of(m, "acquisition")?, + acquisition_evidence: m.get("acquisition_evidence")?.as_str().and_then(unhex)?, + group_receipt: hash_of(m, "group_receipt")?, + }) + } +} + +/// The evidence bytes a property carries for `class`: the signed encoding +/// after its tag (energy-measurement §2.7). +pub fn property_evidence(class: &EnergyProvenance) -> Vec { + let mut o = Vec::new(); + class.write_signed(&mut o); + o.split_off(1) +} + +// ───────────────────────────── building a group ───────────────────────────── + +/// Builds a group and its objects — for this crate's conformance corpus and +/// tests. It is not a publishing path. +#[doc(hidden)] +#[derive(Default)] +pub struct GroupBuilder { + objects: Vec<([u8; 32], u64, String, Option)>, +} + +#[doc(hidden)] +impl GroupBuilder { + pub fn add(&mut self, content_hash: [u8; 32], joules_micro: u64, origin: &str, label: Option) -> &mut Self { + self.objects.push((content_hash, joules_micro, origin.to_owned(), label)); + self + } + + /// The group and its objects. With `labelled`, the group is sealed in the + /// labelled form with the class its objects compose to, or `None` where + /// they compose to none. + #[allow(clippy::too_many_arguments)] + pub fn finish( + &self, + signer: &SigningKey, + signer_id: &str, + track_alias: u64, + group_id: u64, + group_start: &str, + group_end: &str, + parent: Option<[u8; 32]>, + labelled: bool, + ) -> Option<(GroupReceipt, Vec)> { + let mut objects: Vec = self + .objects + .iter() + .enumerate() + .map(|(i, (h, j, o, l))| { + ObjectReceipt::new(track_alias, group_id, i as u64, *h, *j, o.clone(), MerkleProof::build(&[], 0), l.clone()) + }) + .collect(); + let leaves: Vec<[u8; 32]> = objects.iter().map(ObjectReceipt::leaf).collect(); + for (i, o) in objects.iter_mut().enumerate() { + o.merkle_proof = MerkleProof::build(&leaves, i); + } + let label = if labelled { + let parts: Vec<(u64, Option<&ObjectLabel>)> = objects.iter().map(|o| (o.joules_micro, o.label.as_ref())).collect(); + let c = compose_group_class(&parts)?; + Some(GroupLabel { class: c.class, coverage: c.coverage }) + } else { + None + }; + let g = GroupReceipt { + track_alias, + group_id, + root_hash: root(&leaves), + leaf_count: objects.len() as u64, + joules_total: objects.iter().map(|o| o.joules_micro).sum(), + group_start: group_start.into(), + group_end: group_end.into(), + signer_pubkey: signer.verifying_key().to_bytes(), + signer_id: signer_id.into(), + sig: [0u8; 64], + parent_receipt_hash: parent, + label, + }; + Some((resign(g, signer), objects)) + } +} + +/// `g` signed again by `signer` over whatever its fields now say — a valid +/// signature over a receipt that may break another rule. +#[doc(hidden)] +pub fn resign(mut g: GroupReceipt, signer: &SigningKey) -> GroupReceipt { + g.sig = signer.sign(&g.signing_payload()).to_bytes(); + g +} + +// ───────────────────────────── helpers ───────────────────────────── + +fn coverage_of(s: &str) -> Option { + [Coverage::Whole, Coverage::Partial].into_iter().find(|c| c.label() == s) +} + +fn u64_of(m: &Map, k: &str) -> Option { + m.get(k)?.as_u64() +} + +fn str_of(m: &Map, k: &str) -> Option { + m.get(k)?.as_str().map(str::to_owned) +} + +fn hash_of(m: &Map, k: &str) -> Option<[u8; 32]> { + m.get(k)?.as_str().and_then(unhex)?.try_into().ok() +} + +/// Lowercase hex only, so one byte string has one encoding. +fn unhex(s: &str) -> Option> { + if !s.len().is_multiple_of(2) || !s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) { + return None; + } + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect() +} + +fn hex(b: &[u8]) -> String { + use std::fmt::Write as _; + let mut s = String::with_capacity(b.len() * 2); + for x in b { + let _ = write!(s, "{x:02x}"); + } + s +} + +#[cfg(test)] +mod tests; + +pub mod corpus; diff --git a/wai/wai-rs/src/jwp_delivery/corpus.rs b/wai/wai-rs/src/jwp_delivery/corpus.rs new file mode 100644 index 000000000..915f401aa --- /dev/null +++ b/wai/wai-rs/src/jwp_delivery/corpus.rs @@ -0,0 +1,684 @@ +//! The conformance corpus for labelled leaves, labelled groups, the group total +//! and the `WAI_RECEIPT` property (`jwp-conformance/vectors.json`, +//! jwp-receipts §5.7), regenerated and run. +//! +//! Every vector is synthetic: keys come from fixed 32-byte seeds (0x11 +//! publisher, 0x22 meter, 0x33 relay) and attest nothing; envelopes, figures, origins and +//! calibration references are invented. Expected outcomes are declared here, +//! per vector, not copied from a verifier. + +use super::*; +use crate::claim_receipt::{ClaimBody, ClaimReceipt, EnergyClaim, SubjectType}; +use crate::energy_class::{CounterFiltering, Uncertainty}; +use serde_json::json; + +const T0: &str = "2026-09-30T12:00:00Z"; +const T1: &str = "2026-09-30T12:00:01Z"; +const PUBLISHER_ID: &str = "did:example:publisher"; +const CAL_REF: &str = "https://calibration.example.org/certificates/0001"; + +/// The encoding of `y = p + 3` with the sign bit clear: a point of the curve, not +/// of small order, written with a `y` coordinate RFC 8032 §5.1.3 refuses (≥ p). +/// Decoders that reduce `y` read it as the point with `y = 3`. +const NON_CANONICAL_KEY: [u8; 32] = { + let mut k = [0xff; 32]; + k[0] = 0xf0; + k[31] = 0x7f; + k +}; + +fn key(seed: u8) -> SigningKey { + SigningKey::from_bytes(&[seed; 32]) +} + +/// The envelope of object `n`: invented bytes standing in for a WAI envelope. +pub fn envelope(n: u8) -> Vec { + format!("WAI1 jwp-conformance object {n}").into_bytes() +} + +fn origin(n: u8) -> String { + format!("dc-{n}.example") +} + +fn chip(ppm: u32, window_us: u64) -> EnergyProvenance { + EnergyProvenance::OnChipCounter { uncertainty: Uncertainty { relative_ppm: ppm, window_us }, filtering: CounterFiltering::Unknown } +} + +fn cal(ppm: u32, window_us: u64, r: &str) -> EnergyProvenance { + EnergyProvenance::CalibratedInstrument { uncertainty: Uncertainty { relative_ppm: ppm, window_us }, calibration_ref: r.into() } +} + +fn lab(p: EnergyProvenance) -> Option { + Some(ObjectLabel::Class(p)) +} + +/// A group of `parts`, envelopes numbered from `first`. +fn build( + first: u8, + parts: &[(u64, Option)], + group_id: u64, + parent: Option<[u8; 32]>, + labelled: bool, +) -> (GroupReceipt, Vec) { + let mut b = GroupBuilder::default(); + for (i, (j, l)) in parts.iter().enumerate() { + let n = first + i as u8; + b.add(object_content_hash(&envelope(n)), *j, &origin(n), l.clone()); + } + b.finish(&key(0x11), PUBLISHER_ID, 7, group_id, T0, T1, parent, labelled).expect("the corpus's groups compose") +} + +/// The mixed group, legacy form: a counter, an instrument, a legacy object, an +/// object explicitly unmetered and a model — five, so the last leaf of the +/// first level is duplicated. Its objects use envelopes 0–4. +pub fn mixed() -> (GroupReceipt, Vec) { + build( + 0, + &[ + (1_200, lab(chip(20_000, 1_000_000))), + (800, lab(cal(5_000, 2_000_000, CAL_REF))), + (500, None), + (0, Some(ObjectLabel::Unmetered)), + (300, lab(EnergyProvenance::ModelBased)), + ], + 3, + None, + false, + ) +} + +/// The labelled group: a counter, an instrument and an object explicitly +/// unmetered, chained to the mixed group, with the class they compose to. +/// Envelopes 10–12. +pub fn labelled() -> (GroupReceipt, Vec) { + build( + 10, + &[(1_200, lab(chip(20_000, 1_000_000))), (800, lab(cal(5_000, 2_000_000, CAL_REF))), (0, Some(ObjectLabel::Unmetered))], + 4, + Some(mixed().0.link_hash()), + true, + ) +} + +fn hexs(b: &[u8]) -> String { + hex(b) +} + +struct V { + name: &'static str, + about: &'static str, + procedure: &'static str, + input: Value, + expected: Value, +} + +fn v(name: &'static str, about: &'static str, procedure: &'static str, input: Value, expected: Value) -> V { + V { name, about, procedure, input, expected } +} + +fn reject(step: Option, reason: Reason) -> Value { + match step { + Some(s) => json!({ "outcome": "reject", "step": s, "reason": reason.label() }), + None => json!({ "outcome": "reject", "reason": reason.label() }), + } +} + +fn read(reading: &str) -> Value { + json!({ "outcome": "read", "reading": reading }) +} + +fn delivered_json(objs: &[(ObjectReceipt, Vec)]) -> Value { + Value::Array(objs.iter().map(|(o, e)| json!({ "object": o.to_json(), "envelope": hexs(e) })).collect()) +} + +fn with_envelopes(objs: &[ObjectReceipt], first: u8) -> Vec<(ObjectReceipt, Vec)> { + objs.iter().enumerate().map(|(i, o)| (o.clone(), envelope(first + i as u8))).collect() +} + +fn group_accept(g: &GroupReceipt) -> Value { + json!({ "outcome": "accept", "link_hash": hexs(&g.link_hash()), "signing_payload": hexs(&g.signing_payload()) }) +} + +/// The reading's verdict string. +pub fn total_label(r: &TotalReading) -> String { + let kind = |k: AcquisitionKind| match k { + AcquisitionKind::HwShunt => "HwShunt", + AcquisitionKind::ModelBased => "ModelBased", + AcquisitionKind::Estimator => "Estimator", + AcquisitionKind::OnChipCounter => "OnChipCounter", + AcquisitionKind::CalibratedInstrument => "CalibratedInstrument", + }; + match r { + TotalReading::Unmetered => "unmetered".into(), + TotalReading::Unlabelled => "unlabelled".into(), + TotalReading::Weakest { kind: k, coverage } => format!("weakest class={} coverage={}", kind(*k), coverage.label()), + TotalReading::Signed { class, coverage, checked } => { + let u = class.uncertainty().map_or(String::new(), |u| format!(" relative_ppm={} window_us={}", u.relative_ppm, u.window_us)); + format!( + "signed class={}{u} coverage={} parts={}", + class.label(), + coverage.label(), + if *checked { "checked" } else { "unchecked" } + ) + } + } +} + +pub fn property_label(r: &PropertyReading) -> String { + match r { + PropertyReading::Unmetered => "unmetered".into(), + PropertyReading::Unsigned => "unsigned".into(), + PropertyReading::Signed { class, by } => format!("signed class={} by={}", class.label(), hexs(by)), + } +} + +fn vectors() -> Vec { + let (gm, om) = mixed(); + let (gl, ol) = labelled(); + let mut out = Vec::new(); + + // ── leaves (§2.2) ── + let leaf = |o: &ObjectReceipt| json!({ "leaf": hexs(&o.leaf()) }); + out.extend([ + v("leaf_legacy", "an object with no label: the legacy leaf over the content hash alone", "leaf", + json!({ "object": om[2].to_json() }), leaf(&om[2])), + v("leaf_labelled_unmetered", "tag 0 beside a zero figure: the object is explicitly unmetered and its origin is bound", "leaf", + json!({ "object": om[3].to_json() }), leaf(&om[3])), + v("leaf_labelled_on_chip_counter", "tag 4 with its declared uncertainty and filtering state", "leaf", + json!({ "object": om[0].to_json() }), leaf(&om[0])), + v("leaf_labelled_calibrated_instrument", "tag 5 with its declared uncertainty and length-prefixed calibration reference", "leaf", + json!({ "object": om[1].to_json() }), leaf(&om[1])), + v("leaf_labelled_model_based", "tag 2, followed by nothing", "leaf", + json!({ "object": om[4].to_json() }), leaf(&om[4])), + ]); + + // ── groups (§2.3, §3 steps 1-3) ── + let gm_json = gm.to_json(); + let gl_json = gl.to_json(); + let tampered = |g: &Value, k: &str, val: Value| { + let mut t = g.clone(); + t[k] = val; + t + }; + let sealed = |mut g: GroupReceipt, edit: &dyn Fn(&mut GroupReceipt)| { + edit(&mut g); + resign(g, &key(0x11)).to_json() + }; + let s_plus_l = { + const L: [u8; 32] = [ + 0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, 0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, 0x14, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0x10, + ]; + let mut g = gm.clone(); + let mut carry = 0u16; + for (s, l) in g.sig[32..].iter_mut().zip(L) { + let t = u16::from(*s) + u16::from(l) + carry; + *s = t as u8; + carry = t >> 8; + } + assert_eq!(carry, 0, "S + L < 2^254"); + g.to_json() + }; + let mut flipped = gm.clone(); + flipped.sig[0] ^= 1; + let identity = { + let mut k = [0u8; 32]; + k[0] = 1; + hexs(&k) + }; + #[allow(deprecated)] + let hw = EnergyProvenance::HwShunt; + let no_parent = { + let mut t = gm_json.clone(); + t.as_object_mut().expect("a group is an object").remove("parent_receipt_hash"); + t + }; + out.extend([ + v("group_legacy_accept", "the mixed group in the legacy payload: it signs the total and no class", "group", + json!({ "group": gm_json }), group_accept(&gm)), + v("group_labelled_accept", "the labelled payload: the total's class (OnChipCounter, the weaker of a counter and an instrument, 14,000 ppm over 2 s), coverage Partial (one part is zero) and the parent link, chained to the mixed group", "group", + json!({ "group": gl_json, "previous": gm_json }), group_accept(&gl)), + v("group_bad_signature", "one bit flipped in sig", "group", + json!({ "group": flipped.to_json() }), reject(Some(2), Reason::SignatureVerifyFailed)), + v("group_signature_s_not_reduced", "S replaced by S + L: the verification equation still holds, and the strict rule of jwp-receipts 3.1 step 3 refuses S >= L", "group", + json!({ "group": s_plus_l }), reject(Some(2), Reason::SignatureVerifyFailed)), + v("group_signer_key_identity", "the identity point as signer_pubkey", "group", + json!({ "group": tampered(&gm_json, "signer_pubkey", identity.into()) }), reject(Some(1), Reason::BadSignerKey)), + v("group_missing_parent_field", "parent_receipt_hash absent rather than null", "group", + json!({ "group": no_parent }), + reject(Some(1), Reason::Malformed)), + v("group_signer_key_non_canonical", "signer_pubkey encodes y = p + 3, a point not of small order, in a form RFC 8032 section 5.1.3 refuses (y >= p): every point has one encoding", "group", + json!({ "group": tampered(&gm_json, "signer_pubkey", hexs(&NON_CANONICAL_KEY).into()) }), reject(Some(1), Reason::BadSignerKey)), + v("group_sign_version_unknown", "a labelled group whose sign_version is 3: a version this extension does not define is not the labelled form", "group", + json!({ "group": tampered(&gl_json, "sign_version", 3.into()) }), reject(Some(1), Reason::GroupLabelInvalid)), + v("group_sign_version_legacy", "a legacy group carrying sign_version 1 and no class: a sign_version other than 2 is refused, never read as the legacy form", "group", + json!({ "group": tampered(&gm_json, "sign_version", 1.into()) }), reject(Some(1), Reason::GroupLabelInvalid)), + v("group_sign_version_string", "sign_version written as the string \"2\"", "group", + json!({ "group": tampered(&gl_json, "sign_version", "2".into()) }), reject(Some(1), Reason::GroupLabelInvalid)), + v("group_legacy_with_class_members", "a legacy group carrying energy_provenance: refused, never read as the legacy form", "group", + json!({ "group": tampered(&gm_json, "energy_provenance", "ModelBased".into()) }), reject(Some(1), Reason::GroupLabelInvalid)), + v("group_labelled_class_unknown", "a labelled group naming a class energy-measurement section 2 does not define", "group", + json!({ "group": tampered(&gl_json, "energy_provenance", "Measured".into()) }), reject(Some(1), Reason::GroupLabelInvalid)), + v("group_labelled_class_tampered", "the total's declared uncertainty rewritten smaller after signing", "group", + json!({ "group": tampered(&gl_json, "energy_uncertainty", json!({ "relative_ppm": 1_000, "window_us": 2_000_000 })) }), + reject(Some(2), Reason::SignatureVerifyFailed)), + v("group_labelled_coverage_tampered", "coverage rewritten from Partial to Whole after signing", "group", + json!({ "group": tampered(&gl_json, "energy_coverage", "Whole".into()) }), reject(Some(2), Reason::SignatureVerifyFailed)), + v("group_labelled_parent_tampered", "the parent link rewritten after signing: the labelled payload signs it", "group", + json!({ "group": tampered(&gl_json, "parent_receipt_hash", hexs(&[0xab; 32]).into()) }), reject(Some(2), Reason::SignatureVerifyFailed)), + v("group_labelled_hwshunt", "a labelled group whose total is classed legacy HwShunt, correctly signed", "group", + json!({ "group": sealed(gl.clone(), &|g| g.label.as_mut().unwrap().class = Some(hw.clone())) }), reject(Some(2), Reason::GroupLabelInvalid)), + v("group_labelled_class_beside_zero_total", "a class beside a zero total, correctly signed", "group", + json!({ "group": sealed(gl.clone(), &|g| g.joules_total = 0) }), reject(Some(2), Reason::GroupLabelInvalid)), + v("group_labelled_unmetered_beside_figure", "the unmetered marker beside a non-zero total, correctly signed", "group", + json!({ "group": sealed(gl.clone(), &|g| g.label = Some(GroupLabel { class: None, coverage: Coverage::Whole })) }), + reject(Some(2), Reason::GroupLabelInvalid)), + v("group_labelled_below_resolution", "a 2,000 uJ total declared at 144 ppm: below the resolution floor, correctly signed", "group", + json!({ "group": sealed(gl.clone(), &|g| g.label.as_mut().unwrap().class = Some(chip(144, 2_000_000))) }), + reject(Some(2), Reason::GroupLabelInvalid)), + v("group_chain_break", "the parent named is not the previous group held", "group", + json!({ "group": gl_json, "previous": labelled_other_previous() }), reject(Some(3), Reason::ChainBreak)), + v("group_parent_not_held", "a parent the verifier does not hold is fetched, never accepted silently", "group", + json!({ "group": gl_json }), reject(Some(3), Reason::ParentNotHeld)), + ]); + + // ── objects (§3 steps 4-5) ── + let obj = |g: &Value, o: Value, e: u8| json!({ "group": g, "object": o, "envelope": hexs(&envelope(e)) }); + let edit = |o: &ObjectReceipt, f: &dyn Fn(&mut Value)| { + let mut j = o.to_json(); + f(&mut j); + j + }; + let accept = json!({ "outcome": "accept" }); + // A group whose one object carries `label` beside `joules`, built correctly. + let lone = |n: u8, joules: u64, label: Option| { + let (g, o) = build(n, &[(joules, label)], 9, None, false); + obj(&g.to_json(), o[0].to_json(), n) + }; + // Three objects: the last leaf of the first level is duplicated. + let (g3, o3) = build(30, &[(10, None), (20, None), (30, None)], 8, None, false); + let g3j = g3.to_json(); + out.extend([ + v("object_labelled_counter_accept", "a labelled leaf: the figure, the counter's class and evidence, and the origin are under the group signature", "object", + obj(&gm_json, om[0].to_json(), 0), accept.clone()), + v("object_labelled_instrument_accept", "a labelled leaf carrying a calibrated instrument", "object", + obj(&gm_json, om[1].to_json(), 1), accept.clone()), + v("object_legacy_accept", "a legacy leaf beside labelled ones in one group", "object", + obj(&gm_json, om[2].to_json(), 2), accept.clone()), + v("object_labelled_unmetered_accept", "tag 0 beside a zero figure", "object", + obj(&gm_json, om[3].to_json(), 3), accept.clone()), + v("object_legacy_figure_unsigned_accept", "a legacy leaf binds neither the figure nor the origin: both rewritten, the object still verifies", "object", + obj(&gm_json, edit(&om[2], &|j| { j["joules_micro"] = 9_999.into(); j["origin"] = "elsewhere.example".into(); }), 2), accept.clone()), + v("object_in_labelled_group_accept", "an object of the labelled group", "object", + obj(&gl_json, ol[1].to_json(), 11), accept.clone()), + v("object_envelope_mutated", "the envelope does not hash to content_hash", "object", + json!({ "group": gm_json, "object": om[0].to_json(), "envelope": hexs(b"WAI1 mutated in transit") }), + reject(Some(4), Reason::ContentHashMismatch)), + v("object_label_stripped", "the class members removed from a labelled object: its leaf is computed in the legacy form and misses the root", "object", + obj(&gm_json, edit(&om[0], &|j| { let m = j.as_object_mut().unwrap(); for k in ["leaf_version", "energy_provenance", "energy_uncertainty", "energy_counter_filtering"] { m.remove(k); } }), 0), + reject(Some(5), Reason::InclusionProofFailed)), + v("object_label_added", "a class added to a legacy object", "object", + obj(&gm_json, edit(&om[2], &|j| { j["leaf_version"] = 2.into(); j["energy_provenance"] = "ModelBased".into(); }), 2), + reject(Some(5), Reason::InclusionProofFailed)), + v("object_class_restated", "a labelled object's declared uncertainty rewritten smaller", "object", + obj(&gm_json, edit(&om[0], &|j| j["energy_uncertainty"]["relative_ppm"] = 2_000.into()), 0), + reject(Some(5), Reason::InclusionProofFailed)), + v("object_class_promoted", "a labelled object's ModelBased class rewritten to Estimator", "object", + obj(&gm_json, edit(&om[4], &|j| j["energy_provenance"] = "Estimator".into()), 4), + reject(Some(5), Reason::InclusionProofFailed)), + v("object_labelled_figure_altered", "a labelled object's figure rewritten", "object", + obj(&gm_json, edit(&om[1], &|j| j["joules_micro"] = 801.into()), 1), reject(Some(5), Reason::InclusionProofFailed)), + v("object_labelled_origin_altered", "a labelled object's origin rewritten: the labelled leaf binds it", "object", + obj(&gm_json, edit(&om[3], &|j| j["origin"] = "elsewhere.example".into()), 3), reject(Some(5), Reason::InclusionProofFailed)), + v("object_label_hwshunt", "a leaf labelled legacy HwShunt in a correctly built group", "object", + lone(40, 100, lab(hw.clone())), reject(Some(5), Reason::ObjectLabelInvalid)), + v("object_label_beside_zero", "a class beside a zero figure in a correctly built group", "object", + lone(41, 0, lab(EnergyProvenance::ModelBased)), reject(Some(5), Reason::ObjectLabelInvalid)), + v("object_label_unmetered_beside_figure", "tag 0 beside a non-zero figure in a correctly built group", "object", + lone(42, 100, Some(ObjectLabel::Unmetered)), reject(Some(5), Reason::ObjectLabelInvalid)), + v("object_label_below_resolution", "a 1 uJ figure declared at 288,675 ppm in a correctly built group", "object", + lone(43, 1, lab(chip(288_675, 1_000_000))), reject(Some(5), Reason::ObjectLabelInvalid)), + v("object_label_evidence_without_class", "a labelled object's class removed and its evidence kept: refused, never read as legacy", "object", + obj(&gm_json, edit(&om[0], &|j| { j.as_object_mut().unwrap().remove("energy_provenance"); }), 0), + reject(Some(5), Reason::ObjectLabelInvalid)), + v("object_label_members_without_leaf_version", "class members on an object without leaf_version 2", "object", + obj(&gm_json, edit(&om[4], &|j| { j.as_object_mut().unwrap().remove("leaf_version"); }), 4), + reject(Some(5), Reason::ObjectLabelInvalid)), + v("object_at_another_index", "an object presented at its neighbour's index with its own proof, which still walks to the root", "object", + obj(&gm_json, edit(&om[0], &|j| { j["object_id"] = 1.into(); j["merkle_proof"]["leaf_index"] = 1.into(); }), 0), + reject(Some(5), Reason::ProofPositionMismatch)), + v("object_past_the_end", "the last of three objects at index 3: the duplicated leaf's proof walks that position too", "object", + obj(&g3j, edit(&o3[2], &|j| { j["object_id"] = 3.into(); j["merkle_proof"]["leaf_index"] = 3.into(); j["merkle_proof"]["direction_bits"] = 3.into(); }), 32), + reject(Some(5), Reason::ProofPositionMismatch)), + v("object_id_not_its_index", "object_id other than leaf_index", "object", + obj(&gm_json, edit(&om[2], &|j| j["object_id"] = 0.into()), 2), reject(Some(5), Reason::ProofPositionMismatch)), + v("object_proof_short", "a proof one sibling short of the group's depth", "object", + obj(&gm_json, edit(&om[2], &|j| { j["merkle_proof"]["siblings"].as_array_mut().unwrap().pop(); }), 2), + reject(Some(5), Reason::ProofPositionMismatch)), + v("object_other_group", "an object of another group_id presented under this group", "object", + obj(&gm_json, edit(&om[2], &|j| j["group_id"] = 4.into()), 2), reject(Some(5), Reason::ProofPositionMismatch)), + v("object_under_unverified_group", "a valid proof against a group whose signature fails: no partial verification", "object", + obj(&flipped.to_json(), om[0].to_json(), 0), reject(Some(2), Reason::SignatureVerifyFailed)), + v("object_unknown_member", "a member the object receipt does not define", "object", + obj(&gm_json, edit(&om[2], &|j| j["note"] = "x".into()), 2), reject(Some(4), Reason::Malformed)), + ]); + + // ── the group total (§3) ── + let all_m = with_envelopes(&om, 0); + let all_l = with_envelopes(&ol, 10); + let total = |g: &Value, d: &[(ObjectReceipt, Vec)]| json!({ "group": g, "delivered": delivered_json(d) }); + let (gv1l, ov1l) = build(10, &[(1_200, lab(chip(20_000, 1_000_000))), (800, lab(cal(5_000, 2_000_000, CAL_REF))), (0, Some(ObjectLabel::Unmetered))], 5, None, false); + let (gz, oz) = build(50, &[(0, None), (0, Some(ObjectLabel::Unmetered))], 6, None, false); + let (gzl, ozl) = build(50, &[(0, None), (0, Some(ObjectLabel::Unmetered))], 6, None, true); + let (gd, od) = build(60, &[(500, lab(chip(20_000, 1_000_000))), (500, lab(EnergyProvenance::ModelBased))], 7, None, false); + let (gc, oc) = build(70, &[(1_000, lab(cal(300, 1_000_000, "https://calibration.example.org/certificates/a"))), (1_000, lab(cal(300, 1_000_000, "https://calibration.example.org/certificates/b")))], 8, None, false); + let relabel = |g: &GroupReceipt, l: GroupLabel| { + let mut t = g.clone(); + t.label = Some(l); + resign(t, &key(0x11)).to_json() + }; + let lbl = gl.label.clone().unwrap(); + let with_class = |c: EnergyProvenance| GroupLabel { class: Some(c), coverage: lbl.coverage }; + let mut dup = with_envelopes(&od, 60); + let mut twin = od[0].clone(); + twin.object_id = 1; + twin.merkle_proof.leaf_index = 1; + dup[1] = (twin, envelope(60)); + let mut altered = all_m.clone(); + altered[2].0.joules_micro = 501; + out.extend([ + v("total_legacy_unlabelled_part", "every object of the mixed group held; one non-zero figure is unlabelled, so the total reads as unlabelled", "total", + total(&gm_json, &all_m), read("unlabelled")), + v("total_legacy_every_part_labelled", "a legacy group whose every non-zero part is labelled: the weakest class, as a name only", "total", + total(&gv1l.to_json(), &with_envelopes(&ov1l, 10)), read("weakest class=OnChipCounter coverage=Partial")), + v("total_legacy_all_zero", "every figure zero: unmetered, never zero joules", "total", + total(&gz.to_json(), &with_envelopes(&oz, 50)), read("unmetered")), + v("total_legacy_object_missing", "one object not held: unlabelled", "total", + total(&gm_json, &all_m[..4]), read("unlabelled")), + v("total_legacy_figure_altered", "a legacy object's unsigned figure rewritten: each object verifies, and the sum does not match the signed total", "total", + total(&gm_json, &altered), reject(None, Reason::GroupTotalMismatch)), + v("total_duplicate_cannot_hide_weaker_class", "the counter's object presented again at the model's index: refused at its position, so the model's index is not held and the total reads unlabelled rather than as a counter's", "total", + total(&gd.to_json(), &dup), read("unlabelled")), + v("total_duplicate_both_held", "the same two objects held properly: the weaker class", "total", + total(&gd.to_json(), &with_envelopes(&od, 60)), read("weakest class=ModelBased coverage=Whole")), + v("total_labelled_checked", "the labelled group with every object held: its signed class is what they compose to", "total", + total(&gl_json, &all_l), read("signed class=OnChipCounter relative_ppm=14000 window_us=2000000 coverage=Partial parts=checked")), + v("total_labelled_part_held", "the labelled group with one object held: its signed class stands, unchecked", "total", + total(&gl_json, &all_l[..1]), read("signed class=OnChipCounter relative_ppm=14000 window_us=2000000 coverage=Partial parts=unchecked")), + v("total_labelled_above_least", "a declaration above the least the parts compose to", "total", + total(&relabel(&gl, with_class(chip(14_001, 2_000_000))), &all_l), + read("signed class=OnChipCounter relative_ppm=14001 window_us=2000000 coverage=Partial parts=checked")), + v("total_labelled_below_least", "one ppm below the least the parts compose to", "total", + total(&relabel(&gl, with_class(chip(13_999, 2_000_000))), &all_l), reject(None, Reason::GroupUncertaintyBelowParts)), + v("total_labelled_window_not_the_longest", "the parts' longest window is 2 s; the group declares 1 s", "total", + total(&relabel(&gl, with_class(chip(14_000, 1_000_000))), &all_l), reject(None, Reason::GroupUncertaintyBelowParts)), + v("total_labelled_class_stronger_than_parts", "the group classes its total as a calibrated instrument; one part is a counter", "total", + total(&relabel(&gl, with_class(cal(14_000, 2_000_000, CAL_REF))), &all_l), reject(None, Reason::GroupClassDisagrees)), + v("total_labelled_coverage_whole", "the group says Whole; one part is zero", "total", + total(&relabel(&gl, GroupLabel { class: lbl.class.clone(), coverage: Coverage::Whole }), &all_l), reject(None, Reason::GroupCoverageDisagrees)), + v("total_labelled_over_unlabelled_part", "a labelled group over parts one of which is unlabelled: it may carry no class", "total", + total(&relabel(&gm, GroupLabel { class: Some(EnergyProvenance::ModelBased), coverage: Coverage::Partial }), &all_m), + reject(None, Reason::GroupClassDisagrees)), + v("total_labelled_two_certificates", "two instruments under two certificates: a calibrated-instrument total needs one", "total", + total(&relabel(&gc, GroupLabel { class: Some(cal(300, 1_000_000, "https://calibration.example.org/certificates/a")), coverage: Coverage::Whole }), &with_envelopes(&oc, 70)), + reject(None, Reason::GroupClassDisagrees)), + v("total_labelled_unmetered", "a labelled group of zero figures: the unmetered marker", "total", + total(&gzl.to_json(), &with_envelopes(&ozl, 50)), read("unmetered")), + v("total_group_does_not_verify", "the group's signature fails: no total is read", "total", + total(&flipped.to_json(), &all_m), reject(Some(2), Reason::SignatureVerifyFailed)), + ]); + + // ── the WAI_RECEIPT property (moq-streaming-format §5) ── + let prop = |o: &ObjectReceipt, j: u64, class: Option<&EnergyProvenance>, group: &GroupReceipt| WaiReceiptProperty { + content_hash: o.content_hash, + joules_micro: j, + acquisition: class.map_or(String::new(), |c| c.label().into()), + acquisition_evidence: class.map_or(Vec::new(), property_evidence), + group_receipt: group.link_hash(), + }; + let against = |p: WaiReceiptProperty, o: &ObjectReceipt, e: u8| { + json!({ "property": p.to_json(), "group": gm_json, "object": o.to_json(), "envelope": hexs(&envelope(e)) }) + }; + let c0 = chip(20_000, 1_000_000); + let model = EnergyProvenance::ModelBased; + let claim_by = |k: u8, id: &str, j: u64, class: &EnergyProvenance| { + ClaimReceipt::seal( + &key(k), + id, + SubjectType::Object, + om[2].content_hash, + 0, + T0, + ClaimBody::Energy(EnergyClaim::new( + j, + class, + Coverage::Whole, + "derived", + "https://transaction.science/wai/extensions/energy-measurement", + 1, + "object", + None, + )), + None, + ) + .expect("a valid claim") + .to_json() + }; + let claim = |j: u64, class: &EnergyProvenance| claim_by(0x22, "did:example:meter", j, class); + let signed_by = |class: &str, k: u8| read(&format!("signed class={class} by={}", hexs(key(k).verifying_key().as_bytes()))); + let by_claim = |p: WaiReceiptProperty, c: String| json!({ "property": p.to_json(), "claim": c }); + let mut other_group = prop(&om[0], 1_200, Some(&c0), &gm); + other_group.group_receipt = gl.link_hash(); + out.extend([ + v("property_signed_by_leaf", "the property restates the labelled leaf: its class is signed", "property", + against(prop(&om[0], 1_200, Some(&c0), &gm), &om[0], 0), signed_by("OnChipCounter", 0x11)), + v("property_evidence_differs", "the property's declared uncertainty differs from the leaf's", "property", + against(prop(&om[0], 1_200, Some(&chip(10_000, 1_000_000)), &gm), &om[0], 0), reject(None, Reason::PropertyDisagrees)), + v("property_figure_differs", "the property's figure differs from the leaf's", "property", + against(prop(&om[0], 1_201, Some(&c0), &gm), &om[0], 0), reject(None, Reason::PropertyDisagrees)), + v("property_names_another_group", "the property names another group receipt", "property", + against(other_group, &om[0], 0), reject(None, Reason::PropertyDisagrees)), + v("property_class_beside_zero", "a class beside a zero figure", "property", + against(prop(&om[3], 0, Some(&model), &gm), &om[3], 3), reject(None, Reason::PropertyInvalid)), + v("property_figure_without_class", "a non-zero figure with no class", "property", + against(prop(&om[2], 500, None, &gm), &om[2], 2), reject(None, Reason::PropertyInvalid)), + v("property_legacy_leaf", "the object's leaf is legacy: the property's class is outside every signature, and the figure reads as unlabelled", "property", + against(prop(&om[2], 500, Some(&model), &gm), &om[2], 2), read("unsigned")), + v("property_unmetered", "a zero figure and no class beside an object explicitly unmetered", "property", + against(prop(&om[3], 0, None, &gm), &om[3], 3), read("unmetered")), + v("property_object_does_not_verify", "the envelope does not hash to the object's content hash: nothing is read", "property", + json!({ "property": prop(&om[0], 1_200, Some(&c0), &gm).to_json(), "group": gm_json, "object": om[0].to_json(), "envelope": hexs(b"WAI1 mutated") }), + reject(Some(4), Reason::ContentHashMismatch)), + v("property_signed_by_energy_claim", "an energy claim about the legacy object restates the property's figure and class", "property", + by_claim(prop(&om[2], 500, Some(&model), &gm), claim(500, &model)), signed_by("ModelBased", 0x22)), + v("property_signed_by_third_party_claim", "an energy claim signed by a relay's own key restates the property: the class reads as signed by that key, not by the publisher", "property", + by_claim(prop(&om[2], 500, Some(&model), &gm), claim_by(0x33, "did:example:relay", 500, &model)), signed_by("ModelBased", 0x33)), + v("property_energy_claim_disagrees", "the energy claim states another figure", "property", + by_claim(prop(&om[2], 500, Some(&model), &gm), claim(499, &model)), reject(None, Reason::PropertyDisagrees)), + v("property_energy_claim_altered", "an energy claim altered after signing is no signed statement: the property reads as unsigned", "property", + by_claim(prop(&om[2], 500, Some(&model), &gm), claim(500, &model).replacen("\"joules_micro\":500", "\"joules_micro\":501", 1)), + read("unsigned")), + ]); + out +} + +/// A group receipt, valid, that is not the labelled group's parent. +fn labelled_other_previous() -> Value { + build(80, &[(1, None)], 2, None, false).0.to_json() +} + +/// The corpus, regenerated: byte-identical on every machine. +pub fn generate() -> String { + let vs: Vec = vectors() + .into_iter() + .map(|v| { + let mut m = Map::new(); + m.insert("name".into(), v.name.into()); + m.insert("about".into(), v.about.into()); + m.insert("procedure".into(), v.procedure.into()); + m.insert("input".into(), v.input); + m.insert("expected".into(), v.expected); + Value::Object(m) + }) + .collect(); + let mut doc = Map::new(); + doc.insert( + "about".into(), + "jwp-receipts sections 2.2-2.4.2, 3 and 3.3, and moq-streaming-format section 5: labelled \ + leaves, labelled group receipts, the group total and the WAI_RECEIPT property. Every \ + vector is synthetic: keys are Ed25519 from fixed 32-byte seeds (0x11 publisher, 0x22 \ + meter, 0x33 relay) and attest nothing; envelopes, figures, origins and calibration references are \ + invented. Expected outcomes are declared per vector in the generator, not copied from a \ + verifier. Regenerate with `wai_jwp_vectors gen`." + .into(), + ); + doc.insert("vectors".into(), Value::Array(vs)); + let mut s = serde_json::to_string_pretty(&Value::Object(doc)).expect("serialises"); + s.push('\n'); + s +} + +fn err_label(e: &DeliveryError) -> String { + match e.step { + Some(s) => format!("reject step={s} reason={}", e.reason.label()), + None => format!("reject reason={}", e.reason.label()), + } +} + +fn bytes_of(v: Option<&Value>) -> Result, String> { + v.and_then(Value::as_str).and_then(unhex).ok_or_else(|| "bad hex".into()) +} + +/// One vector's verdict under this verifier. +pub fn verdict(v: &Value) -> Result { + let i = v.get("input").ok_or("no input")?; + let group = |k: &str| -> Result, String> { + Ok(GroupReceipt::from_json(i.get(k).ok_or(format!("no {k}"))?)) + }; + match v.get("procedure").and_then(Value::as_str) { + Some("leaf") => { + let o = ObjectReceipt::from_json(i.get("object").ok_or("no object")?).map_err(|e| e.to_string())?; + Ok(format!("leaf={}", hex(&o.leaf()))) + } + Some("group") => { + let g = match group("group")? { + Ok(g) => g, + Err(e) => return Ok(err_label(&e)), + }; + if let Err(e) = g.verify() { + return Ok(err_label(&e)); + } + let previous = match i.get("previous") { + None => None, + Some(p) => GroupReceipt::from_json(p).ok(), + }; + Ok(match g.check_parent(previous.as_ref()) { + Ok(()) => format!("accept link_hash={} signing_payload={}", hex(&g.link_hash()), hex(&g.signing_payload())), + Err(e) => err_label(&e), + }) + } + Some("object") => { + let g = match group("group")? { + Ok(g) => g, + Err(e) => return Ok(err_label(&e)), + }; + let o = match ObjectReceipt::from_json(i.get("object").ok_or("no object")?) { + Ok(o) => o, + Err(e) => return Ok(err_label(&e)), + }; + let env = bytes_of(i.get("envelope"))?; + Ok(match o.accept(&g, &env) { + Ok(()) => "accept".into(), + Err(e) => err_label(&e), + }) + } + Some("total") => { + let g = match group("group")? { + Ok(g) => g, + Err(e) => return Ok(err_label(&e)), + }; + let mut held = Vec::new(); + for d in i.get("delivered").and_then(Value::as_array).ok_or("no delivered")? { + // An object that cannot be read is not held. + if let Ok(o) = ObjectReceipt::from_json(d.get("object").ok_or("no object")?) { + held.push((o, bytes_of(d.get("envelope"))?)); + } + } + let ds: Vec = held.iter().map(|(o, e)| Delivered { object: o, envelope: e }).collect(); + Ok(match read_group_total(&g, &ds) { + Ok(r) => format!("read {}", total_label(&r)), + Err(e) => err_label(&e), + }) + } + Some("property") => { + let p = WaiReceiptProperty::from_json(i.get("property").ok_or("no property")?).ok_or("bad property")?; + let r = if let Some(c) = i.get("claim") { + let wire = c.as_str().ok_or("a claim is given as its wire form")?; + match ClaimReceipt::from_json(wire) { + Ok(claim) => p.check_against_claim(&claim), + // A claim that cannot be read is no signed statement. + Err(_) => p.check().map(|c| if c.is_some() { PropertyReading::Unsigned } else { PropertyReading::Unmetered }), + } + } else { + let g = match group("group")? { + Ok(g) => g, + Err(e) => return Ok(err_label(&e)), + }; + let o = match ObjectReceipt::from_json(i.get("object").ok_or("no object")?) { + Ok(o) => o, + Err(e) => return Ok(err_label(&e)), + }; + p.check_against_object(&o, &g, &bytes_of(i.get("envelope"))?) + }; + Ok(match r { + Ok(r) => format!("read {}", property_label(&r)), + Err(e) => err_label(&e), + }) + } + other => Err(format!("unknown procedure {other:?}")), + } +} + +/// The verdict a vector declares. +pub fn expected(v: &Value) -> Result { + let e = v.get("expected").ok_or("no expected")?; + let s = |k: &str| e.get(k).and_then(Value::as_str).ok_or(format!("expected.{k}")); + if let Some(l) = e.get("leaf").and_then(Value::as_str) { + return Ok(format!("leaf={l}")); + } + match s("outcome")? { + "accept" => match (e.get("link_hash"), e.get("signing_payload")) { + (Some(l), Some(p)) => Ok(format!( + "accept link_hash={} signing_payload={}", + l.as_str().ok_or("link_hash")?, + p.as_str().ok_or("signing_payload")? + )), + _ => Ok("accept".into()), + }, + "read" => Ok(format!("read {}", s("reading")?)), + "reject" => Ok(match e.get("step").and_then(Value::as_u64) { + Some(st) => format!("reject step={st} reason={}", s("reason")?), + None => format!("reject reason={}", s("reason")?), + }), + o => Err(format!("bad outcome {o}")), + } +} + +/// Run every vector of a corpus document: `(name, expected, got)`. +pub fn run(doc: &str) -> Result, String> { + let d: Value = serde_json::from_str(doc).map_err(|e| e.to_string())?; + let vs = d.get("vectors").and_then(Value::as_array).ok_or("no vectors")?; + vs.iter() + .map(|v| { + let name = v.get("name").and_then(Value::as_str).unwrap_or("?").to_owned(); + let want = expected(v).map_err(|e| format!("{name}: {e}"))?; + let got = verdict(v).map_err(|e| format!("{name}: {e}"))?; + Ok((name, want, got)) + }) + .collect() +} + +/// The number of vectors the pinned corpus holds, so a truncated file fails. +pub fn vector_count() -> usize { + vectors().len() +} diff --git a/wai/wai-rs/src/jwp_delivery/tests.rs b/wai/wai-rs/src/jwp_delivery/tests.rs new file mode 100644 index 000000000..158b6bc6a --- /dev/null +++ b/wai/wai-rs/src/jwp_delivery/tests.rs @@ -0,0 +1,525 @@ +use super::*; +use crate::energy_class::{CounterFiltering, Uncertainty}; +use serde_json::json; + +fn key(s: u8) -> SigningKey { + SigningKey::from_bytes(&[s; 32]) +} + +fn chip(ppm: u32, window_us: u64) -> EnergyProvenance { + EnergyProvenance::OnChipCounter { uncertainty: Uncertainty { relative_ppm: ppm, window_us }, filtering: CounterFiltering::Unknown } +} + +fn cal(ppm: u32, r: &str) -> EnergyProvenance { + EnergyProvenance::CalibratedInstrument { + uncertainty: Uncertainty { relative_ppm: ppm, window_us: 2_000_000 }, + calibration_ref: r.into(), + } +} + +fn env(i: u8) -> Vec { + format!("WAI1 jwp-delivery test object {i}").into_bytes() +} + +const T0: &str = "2026-09-30T12:00:00Z"; +const T1: &str = "2026-09-30T12:00:01Z"; + +/// A group of `parts` (figure, label), sealed by key 0x11, legacy or labelled. +fn group(parts: &[(u64, Option)], labelled: bool) -> (GroupReceipt, Vec) { + let mut b = GroupBuilder::default(); + for (i, (j, l)) in parts.iter().enumerate() { + b.add(object_content_hash(&env(i as u8)), *j, "origin.example", l.clone()); + } + b.finish(&key(0x11), "did:example:publisher", 7, 3, T0, T1, None, labelled).expect("composes") +} + +fn delivered(objects: &[ObjectReceipt]) -> Vec<(ObjectReceipt, Vec)> { + objects.iter().enumerate().map(|(i, o)| (o.clone(), env(i as u8))).collect() +} + +fn total(g: &GroupReceipt, d: &[(ObjectReceipt, Vec)]) -> Result { + let ds: Vec = d.iter().map(|(o, e)| Delivered { object: o, envelope: e }).collect(); + read_group_total(g, &ds) +} + +fn lab(p: EnergyProvenance) -> Option { + Some(ObjectLabel::Class(p)) +} + +#[test] +fn the_legacy_leaf_and_tree_are_the_ones_every_profile_already_builds() { + let h = object_content_hash(b"one"); + assert_eq!(root(&[leaf_legacy(&h)]), crate::world::receipt::merkle_root(&[h])); + let hs: Vec<[u8; 32]> = (0..5u8).map(|i| object_content_hash(&[i])).collect(); + let leaves: Vec<[u8; 32]> = hs.iter().map(leaf_legacy).collect(); + assert_eq!(root(&leaves), crate::world::receipt::merkle_root(&hs)); + assert_eq!(root(&[]), [0u8; 32]); +} + +#[test] +fn the_labelled_leaf_binds_the_figure_the_class_and_the_origin() { + let h = object_content_hash(b"x"); + let base = leaf_labelled(&h, 1_000, &ObjectLabel::Class(chip(20_000, 1_000_000)), "o"); + assert_ne!(base, leaf_legacy(&h)); + for other in [ + leaf_labelled(&h, 1_001, &ObjectLabel::Class(chip(20_000, 1_000_000)), "o"), + leaf_labelled(&h, 999, &ObjectLabel::Class(chip(20_000, 1_000_000)), "o"), + leaf_labelled(&h, 1_000, &ObjectLabel::Class(chip(20_001, 1_000_000)), "o"), + leaf_labelled(&h, 1_000, &ObjectLabel::Class(chip(19_999, 1_000_000)), "o"), + leaf_labelled(&h, 1_000, &ObjectLabel::Class(chip(20_000, 1_000_001)), "o"), + leaf_labelled( + &h, + 1_000, + &ObjectLabel::Class(EnergyProvenance::OnChipCounter { + uncertainty: Uncertainty { relative_ppm: 20_000, window_us: 1_000_000 }, + filtering: CounterFiltering::Off, + }), + "o", + ), + leaf_labelled(&h, 1_000, &ObjectLabel::Class(cal(20_000, "c")), "o"), + leaf_labelled(&h, 1_000, &ObjectLabel::Class(EnergyProvenance::ModelBased), "o"), + leaf_labelled(&h, 1_000, &ObjectLabel::Class(chip(20_000, 1_000_000)), "p"), + leaf_labelled(&h, 1_000, &ObjectLabel::Class(chip(20_000, 1_000_000)), ""), + leaf_labelled(&h, 0, &ObjectLabel::Unmetered, "o"), + ] { + assert_ne!(other, base); + } + // The origin is length-prefixed: moving bytes between the class and the + // origin changes the leaf. + assert_ne!( + leaf_labelled(&h, 5, &ObjectLabel::Class(cal(60_000, "ab")), "c"), + leaf_labelled(&h, 5, &ObjectLabel::Class(cal(60_000, "a")), "bc"), + ); +} + +#[test] +fn a_mixed_group_verifies_and_a_label_can_be_neither_restated_stripped_nor_added() { + let parts = [ + (1_200, lab(chip(20_000, 1_000_000))), + (800, lab(cal(5_000, "https://calibration.example.org/c/1"))), + (500, None), + (0, Some(ObjectLabel::Unmetered)), + ]; + let (g, objs) = group(&parts, false); + g.verify().unwrap(); + for (i, o) in objs.iter().enumerate() { + o.accept(&g, &env(i as u8)).unwrap(); + } + let reject = |o: &ObjectReceipt, i: u8| o.accept(&g, &env(i)).unwrap_err(); + let failed = DeliveryError::at(5, Reason::InclusionProofFailed); + + let mut restated = objs[0].clone(); + restated.label = lab(chip(19_000, 1_000_000)); + assert_eq!(reject(&restated, 0), failed, "a class restated in transit"); + let mut stripped = objs[0].clone(); + stripped.label = None; + assert_eq!(reject(&stripped, 0), failed, "a label stripped"); + let mut added = objs[2].clone(); + added.label = lab(EnergyProvenance::ModelBased); + assert_eq!(reject(&added, 2), failed, "a label added to a legacy leaf"); + let mut origin = objs[0].clone(); + origin.origin = "elsewhere.example".into(); + assert_eq!(reject(&origin, 0), failed, "the origin altered"); + let mut figure = objs[1].clone(); + figure.joules_micro += 1; + assert_eq!(reject(&figure, 1), failed, "a labelled figure altered"); + // In the legacy form the figure and the origin are not bound. + let mut legacy = objs[2].clone(); + legacy.joules_micro = 9_999; + legacy.origin = "elsewhere.example".into(); + legacy.accept(&g, &env(2)).unwrap(); + // The envelope is. + assert_eq!(objs[0].accept(&g, b"mutated").unwrap_err(), DeliveryError::at(4, Reason::ContentHashMismatch)); +} + +#[test] +fn a_label_that_breaks_its_rules_is_refused_in_a_correctly_built_tree() { + #[allow(deprecated)] + let hw = EnergyProvenance::HwShunt; + let invalid = DeliveryError::at(5, Reason::ObjectLabelInvalid); + for (j, l) in [ + (100, lab(hw)), + (0, lab(chip(20_000, 1))), + (100, Some(ObjectLabel::Unmetered)), + (1, lab(chip(288_675, 1))), + (100, lab(EnergyProvenance::OnChipCounter { uncertainty: Uncertainty { relative_ppm: 0, window_us: 1 }, filtering: CounterFiltering::Off })), + ] { + let (g, objs) = group(&[(j, l.clone())], false); + assert_eq!(objs[0].accept(&g, &env(0)).unwrap_err(), invalid, "{l:?} beside {j}"); + } +} + +#[test] +fn an_object_must_be_at_the_position_its_proof_walks() { + let (g, objs) = group(&[(10, None), (20, None), (30, None)], false); + let mismatch = DeliveryError::at(5, Reason::ProofPositionMismatch); + // The last leaf of an odd level is duplicated, so its proof also walks the + // position past the end of the group. + let mut past_end = objs[2].clone(); + past_end.merkle_proof.leaf_index = 3; + past_end.merkle_proof.direction_bits = 3; + past_end.object_id = 3; + assert_eq!(past_end.merkle_proof.walk(&past_end.leaf()), g.root_hash, "the duplicate walks to the root"); + assert_eq!(past_end.accept(&g, &env(2)).unwrap_err(), mismatch); + // An object presented at another's index, with its own proof. + let mut moved = objs[0].clone(); + moved.merkle_proof.leaf_index = 1; + moved.object_id = 1; + assert_eq!(moved.merkle_proof.walk(&moved.leaf()), g.root_hash, "its proof still walks to the root"); + assert_eq!(moved.accept(&g, &env(0)).unwrap_err(), mismatch); + // object_id other than the index, a short proof, another group. + let mut id = objs[1].clone(); + id.object_id = 0; + assert_eq!(id.accept(&g, &env(1)).unwrap_err(), mismatch); + let mut short = objs[1].clone(); + short.merkle_proof.siblings.pop(); + assert_eq!(short.accept(&g, &env(1)).unwrap_err(), mismatch); + let mut other = objs[1].clone(); + other.group_id += 1; + assert_eq!(other.accept(&g, &env(1)).unwrap_err(), mismatch); + assert_eq!(proof_len(1), 0); + assert_eq!(proof_len(2), 1); + assert_eq!(proof_len(3), 2); + assert_eq!(proof_len(4), 2); + assert_eq!(proof_len(5), 3); +} + +/// Two objects of equal figure, one measured by a counter and one modelled. +/// Presented twice, the counter's object would hide the modelled one and the +/// total would read at the counter's class. +#[test] +fn a_duplicate_object_cannot_hide_a_weaker_class() { + let (g, objs) = group(&[(500, lab(chip(20_000, 1_000_000))), (500, lab(EnergyProvenance::ModelBased))], false); + let all = delivered(&objs); + assert_eq!(total(&g, &all), Ok(TotalReading::Weakest { kind: AcquisitionKind::ModelBased, coverage: Coverage::Whole })); + let mut twin = objs[0].clone(); + twin.merkle_proof.leaf_index = 1; + twin.object_id = 1; + let hidden = vec![(objs[0].clone(), env(0)), (twin, env(0))]; + assert_eq!(total(&g, &hidden), Ok(TotalReading::Unlabelled), "the second index is not held"); +} + +#[test] +fn a_legacy_total_reads_from_its_objects_labels() { + let both = |a: Option, ja: u64, b: Option, jb: u64| { + let (g, objs) = group(&[(ja, a), (jb, b)], false); + total(&g, &delivered(&objs)) + }; + let c = || lab(chip(20_000, 1_000_000)); + assert_eq!(both(c(), 100, lab(cal(5_000, "r")), 200), Ok(TotalReading::Weakest { kind: AcquisitionKind::OnChipCounter, coverage: Coverage::Whole })); + assert_eq!(both(c(), 100, None, 0), Ok(TotalReading::Weakest { kind: AcquisitionKind::OnChipCounter, coverage: Coverage::Partial })); + assert_eq!(both(c(), 100, None, 5), Ok(TotalReading::Unlabelled), "one unlabelled non-zero figure"); + assert_eq!(both(None, 0, Some(ObjectLabel::Unmetered), 0), Ok(TotalReading::Unmetered)); + let (g, objs) = group(&[(100, c()), (200, None)], false); + let mut d = delivered(&objs); + d.pop(); + assert_eq!(total(&g, &d), Ok(TotalReading::Unlabelled), "an object missing"); + // A legacy figure is not bound, so altering it is accepted per object and + // caught only by the sum. + let mut d = delivered(&objs); + d[1].0.joules_micro = 201; + assert_eq!(total(&g, &d), Err(DeliveryError::of(Reason::GroupTotalMismatch))); +} + +#[test] +fn the_link_hash_covers_the_payload_and_the_signature() { + let (g, _) = group(&[(1, None)], false); + let mut want = blake3::Hasher::new(); + want.update(b"moq-jwp:receipt-link\x01"); + want.update(&g.signing_payload()); + want.update(&g.sig); + assert_eq!(g.link_hash(), *want.finalize().as_bytes()); +} + +#[test] +fn a_labelled_group_signs_its_class_its_coverage_and_its_parent() { + let parts = [(1_200, lab(chip(20_000, 1_000_000))), (800, lab(cal(5_000, "r"))), (0, Some(ObjectLabel::Unmetered))]; + let (g, objs) = group(&parts, true); + g.verify().unwrap(); + let l = g.label.clone().unwrap(); + // OnChipCounter is the weaker of the two classes; one part is zero. + assert_eq!(l.coverage, Coverage::Partial); + // ⌈(1200·20000 + 800·5000) / 2000⌉ = 14000. + assert_eq!(l.class, Some(chip(14_000, 2_000_000))); + assert_eq!( + total(&g, &delivered(&objs)), + Ok(TotalReading::Signed { class: chip(14_000, 2_000_000), coverage: Coverage::Partial, checked: true }) + ); + let sig_failed = DeliveryError::at(2, Reason::SignatureVerifyFailed); + let mut t = g.clone(); + t.label.as_mut().unwrap().class = Some(cal(14_000, "r")); + assert_eq!(t.verify(), Err(sig_failed), "class"); + let mut t = g.clone(); + t.label.as_mut().unwrap().coverage = Coverage::Whole; + assert_eq!(t.verify(), Err(sig_failed), "coverage"); + let mut t = g.clone(); + t.parent_receipt_hash = Some([7; 32]); + assert_eq!(t.verify(), Err(sig_failed), "parent"); + // A verifier that predates the labelled form computes the legacy payload + // and refuses the signature. + let key = ed25519_dalek::VerifyingKey::from_bytes(&g.signer_pubkey).unwrap(); + assert!(key.verify_strict(&legacy_payload(&g), &ed25519_dalek::Signature::from_bytes(&g.sig)).is_err()); + // In the legacy form the parent is not signed. + let (v1, _) = group(&parts, false); + let mut t = v1.clone(); + t.parent_receipt_hash = Some([7; 32]); + t.verify().unwrap(); +} + +#[test] +fn the_composition_rule() { + let p = |f: u64, l: Option| (f, l); + let compose = |parts: Vec<(u64, Option)>| { + let v: Vec<(u64, Option<&ObjectLabel>)> = parts.iter().map(|(f, l)| (*f, l.as_ref())).collect(); + compose_group_class(&v) + }; + // Mixed classes: the weakest, as a class name and its evidence. + assert_eq!( + compose(vec![p(10, lab(chip(30_000, 1))), p(10, lab(EnergyProvenance::ModelBased))]), + Some(Composition { class: Some(EnergyProvenance::ModelBased), coverage: Coverage::Whole }) + ); + // The least declaration rounds up, and the window is the longest. + assert_eq!( + compose(vec![p(1, lab(chip(300_000, 5))), p(2, lab(chip(200_000, 9)))]).unwrap().class, + Some(chip(233_334, 9)) + ); + // Filtering: common or Unknown. + let off = |ppm| { + lab(EnergyProvenance::OnChipCounter { uncertainty: Uncertainty { relative_ppm: ppm, window_us: 1 }, filtering: CounterFiltering::Off }) + }; + assert!(matches!( + compose(vec![p(1, off(300_000)), p(1, off(300_000))]).unwrap().class, + Some(EnergyProvenance::OnChipCounter { filtering: CounterFiltering::Off, .. }) + )); + assert!(matches!( + compose(vec![p(1, off(300_000)), p(1, lab(chip(300_000, 1)))]).unwrap().class, + Some(EnergyProvenance::OnChipCounter { filtering: CounterFiltering::Unknown, .. }) + )); + // Calibrated: one reference, or no class. + assert!(compose(vec![p(1, lab(cal(300_000, "a"))), p(1, lab(cal(300_000, "a")))]).unwrap().class.is_some()); + assert_eq!(compose(vec![p(1, lab(cal(300_000, "a"))), p(1, lab(cal(300_000, "b")))]), None); + // An unlabelled non-zero part: no class; all zero: unmetered. + assert_eq!(compose(vec![p(1, lab(chip(300_000, 1))), p(1, None)]), None); + assert_eq!(compose(vec![p(0, None), p(0, Some(ObjectLabel::Unmetered))]), Some(Composition { class: None, coverage: Coverage::Whole })); +} + +#[test] +fn a_labelled_group_is_checked_against_its_parts() { + let parts = [(1_000, lab(chip(30_000, 1_000_000))), (3_000, lab(chip(10_000, 2_000_000)))]; + let (g, objs) = group(&parts, true); + let d = delivered(&objs); + // ⌈(1000·30000 + 3000·10000) / 4000⌉ = 15000, over the longer window. + assert_eq!(g.label.as_ref().unwrap().class, Some(chip(15_000, 2_000_000))); + let with = |class: Option, coverage: Coverage| { + let mut t = g.clone(); + t.label = Some(GroupLabel { class, coverage }); + total(&resign(t, &key(0x11)), &d) + }; + let signed = |c| Ok(TotalReading::Signed { class: c, coverage: Coverage::Whole, checked: true }); + assert_eq!(with(Some(chip(15_000, 2_000_000)), Coverage::Whole), signed(chip(15_000, 2_000_000))); + assert_eq!(with(Some(chip(15_001, 2_000_000)), Coverage::Whole), signed(chip(15_001, 2_000_000)), "more is allowed"); + let below = Err(DeliveryError::of(Reason::GroupUncertaintyBelowParts)); + assert_eq!(with(Some(chip(14_999, 2_000_000)), Coverage::Whole), below, "one ppm less is not"); + assert_eq!(with(Some(chip(15_000, 1_000_000)), Coverage::Whole), below, "another window"); + assert_eq!(with(Some(cal(15_000, "r")), Coverage::Whole), Err(DeliveryError::of(Reason::GroupClassDisagrees))); + assert_eq!(with(Some(EnergyProvenance::ModelBased), Coverage::Whole), Err(DeliveryError::of(Reason::GroupClassDisagrees))); + assert_eq!(with(Some(chip(15_000, 2_000_000)), Coverage::Partial), Err(DeliveryError::of(Reason::GroupCoverageDisagrees))); + // Holding part of the group, the signed class stands, unchecked. + assert_eq!( + total(&g, &d[..1]), + Ok(TotalReading::Signed { class: chip(15_000, 2_000_000), coverage: Coverage::Whole, checked: false }) + ); + // A group whose parts compose to no class may not carry one. + let (v1, objs) = group(&[(1, lab(cal(300_000, "a"))), (1, lab(cal(300_000, "b")))], false); + let mut t = v1.clone(); + t.label = Some(GroupLabel { class: Some(cal(300_000, "a")), coverage: Coverage::Whole }); + assert_eq!(total(&resign(t, &key(0x11)), &delivered(&objs)), Err(DeliveryError::of(Reason::GroupClassDisagrees))); +} + +#[test] +fn a_group_label_is_checked_with_the_group_alone() { + let (g, _) = group(&[(100, lab(chip(20_000, 1)))], true); + let invalid = Err(DeliveryError::at(2, Reason::GroupLabelInvalid)); + #[allow(deprecated)] + for (class, total_uj) in [ + (Some(EnergyProvenance::HwShunt), 100), + (Some(chip(20_000, 1)), 0), + (None, 100), + (Some(chip(2_000, 1)), 100), + ] { + let mut t = g.clone(); + t.joules_total = total_uj; + t.label = Some(GroupLabel { class, coverage: Coverage::Whole }); + assert_eq!(resign(t, &key(0x11)).verify(), invalid); + } + let mut t = g.clone(); + t.joules_total = 0; + t.label = Some(GroupLabel { class: None, coverage: Coverage::Partial }); + assert_eq!(resign(t, &key(0x11)).verify(), invalid, "the unmetered marker covers nothing partially"); +} + +#[test] +fn group_signatures_are_held_to_the_strict_rule() { + let (g, _) = group(&[(1, None)], false); + // S + L: the equation holds, the scalar is out of range. + let l: [u8; 32] = [ + 0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, 0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, 0x14, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0x10, + ]; + let mut t = g.clone(); + let mut carry = 0u16; + for (s, l) in t.sig[32..].iter_mut().zip(l) { + let v = u16::from(*s) + u16::from(l) + carry; + *s = v as u8; + carry = v >> 8; + } + assert_eq!(carry, 0); + assert_eq!(t.verify(), Err(DeliveryError::at(2, Reason::SignatureVerifyFailed))); + // The identity as a key. + let mut t = g.clone(); + t.signer_pubkey = [0; 32]; + t.signer_pubkey[0] = 1; + assert_eq!(t.verify(), Err(DeliveryError::at(1, Reason::BadSignerKey))); +} + +/// A proof read from JSON may hold more siblings than direction bits. Walking +/// it neither shifts out of range nor is accepted: the position check refuses +/// it first. +#[test] +fn a_proof_longer_than_its_direction_bits_is_walked_and_refused() { + let (g, objs) = group(&[(10, None), (20, None)], false); + let mut long = objs[1].clone(); + long.merkle_proof.siblings = vec![[7; 32]; 70]; + long.merkle_proof.direction_bits = u64::MAX; + let walked = long.merkle_proof.walk(&long.leaf()); + assert_ne!(walked, g.root_hash); + assert_eq!(long.accept(&g, &env(1)).unwrap_err(), DeliveryError::at(5, Reason::ProofPositionMismatch)); + let back = ObjectReceipt::from_json(&long.to_json()).expect("any number of siblings reads"); + assert_eq!(back.verify_inclusion(&g), Err(DeliveryError::at(5, Reason::ProofPositionMismatch))); +} + +/// `sign_version` is a labelled member: any value but 2 is +/// `group_label_invalid`, as `leaf_version` is `object_label_invalid`. +#[test] +fn a_sign_version_other_than_2_is_group_label_invalid() { + let (g, _) = group(&[(1, lab(EnergyProvenance::ModelBased))], true); + let (legacy, _) = group(&[(1, None)], false); + for (base, v) in [(&g, json!(3)), (&g, json!(1)), (&g, json!("2")), (&legacy, json!(1)), (&legacy, json!(0))] { + let mut j = base.to_json(); + j["sign_version"] = v.clone(); + assert_eq!(GroupReceipt::from_json(&j), Err(DeliveryError::at(1, Reason::GroupLabelInvalid)), "{v}"); + } + assert!(GroupReceipt::from_json(&g.to_json()).is_ok()); +} + +/// RFC 8032 §5.1.3 gives every point one encoding: `y = p + 3` encodes a point +/// of large order that a reducing decoder would accept, and it is refused as a +/// key, at step 1, before any signature is examined. +#[test] +fn a_non_canonical_key_encoding_is_refused() { + let (g, _) = group(&[(1, None)], false); + let mut t = g.clone(); + t.signer_pubkey = [0xff; 32]; + t.signer_pubkey[0] = 0xf0; + t.signer_pubkey[31] = 0x7f; + assert_eq!(t.verify(), Err(DeliveryError::at(1, Reason::BadSignerKey))); +} + +#[test] +fn the_parent_link() { + let (g0, _) = group(&[(1, lab(EnergyProvenance::ModelBased))], true); + let mut b = GroupBuilder::default(); + b.add(object_content_hash(&env(0)), 1, "o", lab(EnergyProvenance::ModelBased)); + let (g1, _) = b.finish(&key(0x11), "did:example:publisher", 7, 4, T0, T1, Some(g0.link_hash()), true).unwrap(); + assert_eq!(g1.check_parent(Some(&g0)), Ok(())); + assert_eq!(g1.check_parent(None), Err(DeliveryError::at(3, Reason::ParentNotHeld))); + assert_eq!(g0.check_parent(Some(&g1)), Err(DeliveryError::at(3, Reason::ChainBreak))); + let (other, _) = group(&[(2, lab(EnergyProvenance::ModelBased))], true); + assert_eq!(g1.check_parent(Some(&other)), Err(DeliveryError::at(3, Reason::ChainBreak))); +} + +#[test] +fn both_forms_round_trip_through_json_and_a_label_is_never_dropped() { + let parts = [(1_200, lab(chip(20_000, 1_000_000))), (500, None), (0, Some(ObjectLabel::Unmetered))]; + for labelled in [false, true] { + let parts: Vec<_> = if labelled { vec![parts[0].clone(), parts[2].clone()] } else { parts.to_vec() }; + let (g, objs) = group(&parts, labelled); + assert_eq!(GroupReceipt::from_json(&g.to_json()), Ok(g.clone())); + for o in &objs { + assert_eq!(ObjectReceipt::from_json(&o.to_json()).as_ref(), Ok(o)); + } + } + // Class members in a legacy group are refused, never read as legacy. + let (g, objs) = group(&parts, false); + let mut j = g.to_json(); + j["energy_provenance"] = "ModelBased".into(); + assert_eq!(GroupReceipt::from_json(&j), Err(DeliveryError::at(1, Reason::GroupLabelInvalid))); + // An object's unreadable label is refused at step 5. + let mut j = objs[1].to_json(); + j["energy_provenance"] = "ModelBased".into(); + let o = ObjectReceipt::from_json(&j).unwrap(); + assert_eq!(o.accept(&g, &env(1)), Err(DeliveryError::at(5, Reason::ObjectLabelInvalid))); + let mut j = objs[0].to_json(); + j.as_object_mut().unwrap().remove("energy_provenance"); + let o = ObjectReceipt::from_json(&j).unwrap(); + assert_eq!(o.accept(&g, &env(0)), Err(DeliveryError::at(5, Reason::ObjectLabelInvalid)), "evidence without a class"); + let mut j = objs[0].to_json(); + j["note"] = 1.into(); + assert_eq!(ObjectReceipt::from_json(&j), Err(DeliveryError::at(4, Reason::Malformed))); +} + +#[test] +fn a_property_reads_as_signed_only_where_a_signed_statement_restates_it() { + let parts = [(1_200, lab(chip(20_000, 1_000_000))), (500, None), (0, Some(ObjectLabel::Unmetered))]; + let (g, objs) = group(&parts, false); + let prop = |i: usize, j: u64, class: Option<&EnergyProvenance>| WaiReceiptProperty { + content_hash: objs[i].content_hash, + joules_micro: j, + acquisition: class.map_or(String::new(), |c| c.label().into()), + acquisition_evidence: class.map_or(Vec::new(), property_evidence), + group_receipt: g.link_hash(), + }; + let c = chip(20_000, 1_000_000); + assert_eq!(prop(0, 1_200, Some(&c)).check_against_object(&objs[0], &g, &env(0)), Ok(PropertyReading::Signed { class: c.clone(), by: g.signer_pubkey })); + let disagrees = Err(DeliveryError::of(Reason::PropertyDisagrees)); + assert_eq!(prop(0, 1_200, Some(&chip(20_001, 1_000_000))).check_against_object(&objs[0], &g, &env(0)), disagrees); + assert_eq!(prop(0, 1_201, Some(&c)).check_against_object(&objs[0], &g, &env(0)), disagrees); + let mut other_group = prop(0, 1_200, Some(&c)); + other_group.group_receipt = [9; 32]; + assert_eq!(other_group.check_against_object(&objs[0], &g, &env(0)), disagrees); + assert_eq!( + prop(1, 500, Some(&EnergyProvenance::ModelBased)).check_against_object(&objs[1], &g, &env(1)), + Ok(PropertyReading::Unsigned), + "a legacy leaf restates nothing" + ); + assert_eq!(prop(2, 0, None).check_against_object(&objs[2], &g, &env(2)), Ok(PropertyReading::Unmetered)); + let invalid = Err(DeliveryError::of(Reason::PropertyInvalid)); + assert_eq!(prop(2, 0, Some(&c)).check_against_object(&objs[2], &g, &env(2)), invalid, "a class beside zero"); + assert_eq!(prop(1, 500, None).check_against_object(&objs[1], &g, &env(1)), invalid, "a figure without a class"); + + // An energy claim about the object. + use crate::claim_receipt::{ClaimBody, ClaimReceipt, EnergyClaim, SubjectType}; + let claim = |j: u64, class: &EnergyProvenance| { + ClaimReceipt::seal( + &key(0x22), + "did:example:meter", + SubjectType::Object, + objs[1].content_hash, + 0, + T0, + ClaimBody::Energy(EnergyClaim::new(j, class, Coverage::Whole, "derived", "https://example.org/m", 1, "u", None)), + None, + ) + .unwrap() + }; + let m = EnergyProvenance::ModelBased; + assert_eq!( + prop(1, 500, Some(&m)).check_against_claim(&claim(500, &m)), + Ok(PropertyReading::Signed { class: m.clone(), by: key(0x22).verifying_key().to_bytes() }), + "signed by the claim's signer, not the group's" + ); + assert_eq!(prop(1, 500, Some(&m)).check_against_claim(&claim(501, &m)), disagrees); + let mut forged = claim(500, &m); + forged.sig[0] ^= 1; + assert_eq!(prop(1, 500, Some(&m)).check_against_claim(&forged), Ok(PropertyReading::Unsigned)); +} diff --git a/wai/wai-rs/src/lib.rs b/wai/wai-rs/src/lib.rs index 076338cc5..0afaf63c8 100644 --- a/wai/wai-rs/src/lib.rs +++ b/wai/wai-rs/src/lib.rs @@ -237,6 +237,18 @@ pub mod reconstruction_binding; // dependencies, so it is compiled unconditionally. pub mod energy_class; +// The integers a measurement report carries for a figure obtained under the +// bracketed protocol, the exact arithmetic every reader applies to them (the figure +// they give, the least declaration they support, the derived attribution verdict), +// the liveness rule a power reader's rail must meet, and the build record +// (energy-measurement §3.1, §6.2). Integer only, pure std, compiled unconditionally. +pub mod energy_report; + +// What build.rs records about the build, shared with build.rs through `#[path]`; +// compiled into the crate for its tests. +#[cfg(test)] +mod build_info; + // 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. @@ -280,6 +292,20 @@ pub mod claim_receipt; #[cfg(feature = "receipt_claims")] mod claim_text; +// JWP delivery receipts (extensions/jwp-receipts.md §2.2–§2.4.2, §3, §3.3): group and +// object receipts read and verified, in the legacy forms and the labelled ones — an +// object's figure, class and origin in its Merkle leaf, a group total's class under a +// versioned signing payload — with the position check, the group-total reading and the +// WAI_RECEIPT property's readings (moq-streaming-format §5). +#[cfg(feature = "receipt_claims")] +pub mod jwp_delivery; + +// Figure profiles (energy-measurement §6.1): how a measurement or operating-point claim +// names the receipt that seals its figure, by that receipt's identity domain, and the +// figures each receipt family shows a report — read without changing any family. +#[cfg(feature = "receipt_claims")] +pub mod energy_figure; + // wai.world.model — generative-world transport: a signed, replay-equivalent action // trace (the world CausalOp log) + per-frame provenance. The wire carries the // action trace + a declared model_id, not the pixels; the sink renders. The neural @@ -310,7 +336,8 @@ pub mod quantum_envelope; // The energy-accounted seal for a quantum reconstruction (extensions/quantum-sim // § Receipt): a signed receipt separating the portable-exact WORK (n_ops·2^n // amplitude updates, recomputable by anyone) from the measured-attested ENERGY -// (joules_micro from the sink's meter), both bound to a re-simulatable statevector +// (joules_micro from the sink's meter, and the class that labels it), both bound to a +// re-simulatable statevector // hash. A JWP profile — reuses the world Merkle + Ed25519. #[cfg(feature = "quantum_receipt")] pub use wai_quantum::quantum_receipt; @@ -335,7 +362,8 @@ pub mod ternary; // The energy-accounted seal for a ternary-MPS reconstruction (extensions/ternary-tt // § Receipt): a signed receipt separating portable-exact WORK (trit_macs) from -// measured-attested ENERGY (joules_micro), bound to the WQTT payload hash and a +// measured-attested ENERGY (joules_micro and the class that labels it), bound to the +// WQTT payload hash and a // re-decodable reconstruction hash. A JWP profile — reuses the world Merkle + Ed25519. #[cfg(feature = "ternary_receipt")] pub mod ternary_receipt; @@ -574,7 +602,8 @@ pub use wai_quantum::quantum_kernel; // Phasor energy meter (extensions/quantum-sim § Receipt, `wai.quantum.phasor`): the // energy-accounted seal for an interference computation — portable-exact phasor -// work (linear, recomputable) split from measured-attested joules, so qFHRR is an +// work (linear, recomputable) split from measured-attested joules and their class, so +// qFHRR is an // accountable substrate, not merely a fast one. Reuses the JWP Merkle + Ed25519. #[cfg(feature = "quantum_phasor_meter")] pub use wai_quantum::quantum_phasor_meter; diff --git a/wai/wai-rs/src/provenance.rs b/wai/wai-rs/src/provenance.rs index 67ec2b0c5..30b731f0e 100644 --- a/wai/wai-rs/src/provenance.rs +++ b/wai/wai-rs/src/provenance.rs @@ -30,7 +30,10 @@ //! 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. +//! different classes is only as strong as its weakest step. An unmetered step +//! adds its marker `0` to the sum, so a total over steps that include one covers +//! only the measured steps (`Partial`, energy-measurement §2) and a reader says +//! so; it is never the whole cost of the lineage. use crate::energy_class::{ check_label, json_with_label, label_coheres, label_from_json, labelled_domain, write_label, @@ -68,7 +71,8 @@ pub struct ProvStep { pub output_hash: [u8; 32], /// Exact, deterministic work (e.g. pixels or samples processed). pub work: u64, - /// Measured/carried microjoules for the step (0 = unmetered). + /// Measured/carried microjoules for the step; `0` is the unmetered marker, + /// never a figure of zero joules (energy-measurement §2). 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 @@ -131,7 +135,8 @@ pub struct AssetReceipt { pub group_id: u64, pub steps: Vec, pub merkle_root: [u8; 32], - /// Σ of the steps' carried joules. + /// Σ of the steps' carried joules. A step carrying the unmetered marker adds + /// nothing, so with one the total covers only the measured steps. pub total_joules_micro: u64, pub parent_receipt_hash: Option<[u8; 32]>, pub signer_pubkey: [u8; 32], @@ -249,6 +254,25 @@ impl AssetReceipt { /// Confirm the receipt vouches for exactly this asset envelope: /// recompute the content hash and compare. The tamper test — a /// 1-bit change to the envelope breaks it. + /// The total as a reader presents it (energy-measurement §2): `UNMETERED` + /// when every step carries the unmetered marker, never `0 µJ`; the figure + /// labelled `Whole` when every step is measured; otherwise the figure + /// labelled `Partial`, naming the unmetered steps the total does not cover. + pub fn total_label(&self) -> String { + let unmetered: Vec<&str> = self.steps.iter().filter(|s| s.joules_micro == 0).map(|s| s.op.as_str()).collect(); + if unmetered.len() == self.steps.len() { + "UNMETERED (no step measured)".to_owned() + } else if unmetered.is_empty() { + format!("{} µJ (Whole)", self.total_joules_micro) + } else { + format!( + "{} µJ over the measured steps only (Partial; unmetered: {})", + self.total_joules_micro, + unmetered.join(", ") + ) + } + } + pub fn matches_asset(&self, envelope_bytes: &[u8]) -> bool { crate::world::asset_content_hash(envelope_bytes) == self.asset_hash } @@ -399,6 +423,21 @@ mod tests { assert!(!wrong.verify()); } + /// An unmetered step is never presented as 0 J, and a total over steps that + /// include one says it covers only the measured steps. + #[test] + fn the_total_is_labelled_as_a_reader_presents_it() { + let (steps, asset) = chain(); + let build = |steps: Vec| AssetReceipt::build(&key(9), "did:web:wai#prov", asset, "image", "wai.image.jpeg", 0, steps, None); + let partial = build(steps.clone()); + assert_eq!(partial.total_label(), "4290 µJ over the measured steps only (Partial; unmetered: capture)"); + let none = build(steps.iter().cloned().map(|s| ProvStep { joules_micro: 0, ..s }).collect()); + assert_eq!(none.total_joules_micro, 0); + assert_eq!(none.total_label(), "UNMETERED (no step measured)", "never 0 µJ"); + let whole = build(steps.into_iter().map(|s| ProvStep { joules_micro: s.joules_micro.max(7), ..s }).collect()); + assert_eq!(whole.total_label(), "4297 µJ (Whole)"); + } + #[test] fn json_round_trips_and_verifies() { let (steps, asset) = chain(); diff --git a/wai/wai-rs/src/receipt_pins.rs b/wai/wai-rs/src/receipt_pins.rs index 514e5f377..5cf1b845d 100644 --- a/wai/wai-rs/src/receipt_pins.rs +++ b/wai/wai-rs/src/receipt_pins.rs @@ -423,3 +423,94 @@ mod labels { assert!(legacy_back.verify()); } } + +// --------------------------------------------------------------------------- +// Pins for the labelled forms (energy-measurement §2.8). +// +// The legacy pins above hold unlabelled receipts to their pre-label bytes. +// These hold one labelled receipt of every family to the bytes the label +// gave it when it was introduced, so a later change to a labelled encoding — +// a byte leaking into `write_signed` or `write_label`, or a changed domain — +// fails here instead of re-signing quietly. The hex was produced by the code +// as the labels were first introduced and must never be edited to make a +// test pass. +// --------------------------------------------------------------------------- + +mod labelled_pins { + use super::*; + use super::labels::chip; + + #[cfg(feature = "world")] + #[test] + fn world_labelled_bytes() { + let r = world().with_energy_class(&key(9), chip(15_403)).unwrap(); + assert!(r.verify()); + assert_eq!(hx(&r.receipt_hash()), "32b7ef840b7140c369ecdd5d04c0b9ed2f361980aacfa072c0342d3ad4eb1057"); + assert_eq!(json_hash(&r.to_json()), "6c8f53541d7f8a896a8a0e5e2873c076c5afab36c914a95d9e4bf3211b042855"); + } + + #[cfg(feature = "audio_scene")] + #[test] + fn audio_scene_labelled_bytes() { + let r = audio_scene().with_energy_class(&key(9), chip(15_403)).unwrap(); + assert!(r.verify()); + assert_eq!(hx(&r.receipt_hash()), "2fe5b5771dfd818a050c020fe365522f5ac5bfbbd133191e0663c25ab3b1533e"); + } + + #[cfg(feature = "film")] + #[test] + fn film_labelled_bytes() { + let r = film().with_energy_class(&key(9), chip(15_403)).unwrap(); + assert!(r.verify()); + assert_eq!(hx(&r.receipt_hash()), "9bd844ee24ea3903a273a79e509edaae3eef830509edb05a586482581d003e06"); + assert_eq!(json_hash(&r.to_json()), "d8644a2f82e407b14d1ff57a2f12b733f63e931c62c00b9526880d2af55e329e"); + } + + #[cfg(feature = "haptic")] + #[test] + fn haptic_labelled_bytes() { + let r = haptic().with_energy_class(&key(9), chip(15_403)).unwrap(); + assert!(r.verify()); + assert_eq!(hx(&r.receipt_hash()), "e17f760cd90364feb9ac47e121a783c85035253c29bf640406c66cfc8cf3e487"); + assert_eq!(json_hash(&r.to_json()), "1fb81b2976b6e49a568d63a0a60a872946c29b6751de88f30b12a3fa99dfaa85"); + } + + #[cfg(feature = "score")] + #[test] + fn score_labelled_bytes() { + let r = score().with_energy_class(&key(9), chip(15_403)).unwrap(); + assert!(r.verify()); + assert_eq!(hx(&r.receipt_hash()), "0e795647d7ef74bf400fee962e6bcf57292116e0a084da41a1c153de777a0bee"); + assert_eq!(json_hash(&r.to_json()), "856823c9874b10bb5d78c01b576318bc0cd44e1f2112fbcd56f72204db38b544"); + } + + #[cfg(feature = "splat4d")] + #[test] + fn splat4d_labelled_bytes() { + let r = splat4d().with_energy_class(&key(9), chip(15_403)).unwrap(); + assert!(r.verify()); + assert_eq!(hx(&r.receipt_hash()), "410e26fdf5c60b8bba50f39d318a6e6e8c122f8a3807bb65f90dcf2a92c13a76"); + assert_eq!(json_hash(&r.to_json()), "5efa236fa7b818685fda50ef7e52c9037c48c76b32eeb0fbfc092888a255156d"); + } + + #[cfg(feature = "ternary_receipt")] + #[test] + fn ternary_labelled_bytes() { + let r = ternary().with_energy_class(&key(1), chip(15_403)).unwrap(); + assert!(r.verify()); + assert_eq!(hx(&r.receipt_hash()), "3eaf95f6d3e7cbc2505f6bacfadac60f2cf7451041cf36c1c7307c1345120c3e"); + assert_eq!(json_hash(&r.to_json()), "321f0c9d739ce6fd6e4bf6e32d663ef3e14f25339ad75799fce9f17159052413"); + } + + #[cfg(feature = "provenance")] + #[test] + fn provenance_labelled_bytes() { + let mut steps = provenance_steps(); + steps[1] = steps[1].clone().with_energy_class(chip(15_403)).unwrap(); + let r = provenance(steps); + assert!(r.verify()); + assert_eq!(hx(&r.receipt_hash()), "db3979573fc3ff07a1c2d82256268c862d6a2d1737ea9636c876155ef9546ae1"); + assert_eq!(json_hash(&r.to_json()), "d1a52b06b30dece511e701a86b86eb176b5fa414ae2084520a35a1e004fdcee0"); + assert_eq!(hx(&r.steps[1].content_hash()), "8b94897f5b73b5b8e62b8328ee416368e8d087c65ee661fc188e4bcb5b97da42"); + } +} diff --git a/wai/wai-rs/src/ternary_receipt.rs b/wai/wai-rs/src/ternary_receipt.rs index b66d0439b..225e72eef 100644 --- a/wai/wai-rs/src/ternary_receipt.rs +++ b/wai/wai-rs/src/ternary_receipt.rs @@ -164,8 +164,8 @@ impl TernaryReceipt { /// confirm it hashes to this receipt's `payload_hash`, decodes to its /// `reconstruction_hash`, and matches its `trit_macs` — then that the /// receipt verifies. Rejects a receipt whose claimed reconstruction is not - /// what the payload actually decodes to, on any machine. (Measured - /// `joules_micro` stays attested-only.) + /// what the payload actually decodes to, on any machine. (The measurement — + /// `joules_micro` and the class that labels it — stays attested-only.) pub fn verify_reconstruction(&self, wqtt_payload: &[u8]) -> bool { if *blake3::hash(wqtt_payload).as_bytes() != self.payload_hash { return false; diff --git a/wai/wai-rs/src/video_receipt.rs b/wai/wai-rs/src/video_receipt.rs index 4ae13a7a9..0560e2d33 100644 --- a/wai/wai-rs/src/video_receipt.rs +++ b/wai/wai-rs/src/video_receipt.rs @@ -12,18 +12,19 @@ //! 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. +//! [`VideoReceipt::verify_reconstruction`] — and every field except the +//! measurement 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: +//! That leaves the measurement attested-not-verifiable — `joules_micro` and the +//! class and evidence that describe how it was obtained — 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. +//! - `joules_micro` and `energy_provenance` — **measured-attested**: the real +//! marginal CPU energy the metering sink spent, and how it was acquired, +//! 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 diff --git a/wai/wai-rs/src/wai_video_channel.rs b/wai/wai-rs/src/wai_video_channel.rs index 457111d21..cbb23822d 100644 --- a/wai/wai-rs/src/wai_video_channel.rs +++ b/wai/wai-rs/src/wai_video_channel.rs @@ -21,8 +21,9 @@ //! Windows chain via `parent_receipt_hash`, so a channel's history is a hash //! chain of program-as-run windows — the StreamOps audit trail. Each window's //! manifest is still a `wai.video.manifest`, so the per-class dedup carries -//! through: one generation per window per class, not per viewer. Only -//! `joules_micro` is measured-attested. Reuses the manifest floor. +//! through: one generation per window per class, not per viewer. Only the +//! measurement — `joules_micro`, its acquisition class and the class's evidence — +//! is measured-attested: signed, and not re-measurable. Reuses the manifest floor. use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; diff --git a/wai/wai-rs/src/wai_video_keys.rs b/wai/wai-rs/src/wai_video_keys.rs index 3e0f013a5..8b25db176 100644 --- a/wai/wai-rs/src/wai_video_keys.rs +++ b/wai/wai-rs/src/wai_video_keys.rs @@ -18,8 +18,9 @@ //! without the record ever carrying key material. A refusal carries no //! commitment, and the coupling is enforced, not merely documented. //! -//! Only `joules_micro` is measured-attested. Reuses the manifest module's -//! `EnergyProvenance` and the world Merkle floor's Ed25519 + `GrantRef`; links +//! Only the measurement — `joules_micro`, its acquisition class and the class's +//! evidence — is measured-attested: signed, and not re-measurable. Reuses the +//! manifest module's `EnergyProvenance` and the world Merkle floor's Ed25519 + `GrantRef`; links //! to a manifest via `parent_receipt_hash`. Reuse, not reinvention. use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; diff --git a/wai/wai-rs/src/wai_video_manifest.rs b/wai/wai-rs/src/wai_video_manifest.rs index 1d6f52f76..e151110c9 100644 --- a/wai/wai-rs/src/wai_video_manifest.rs +++ b/wai/wai-rs/src/wai_video_manifest.rs @@ -16,8 +16,9 @@ //! and confirms `manifest_hash == hash(render(segments))`. The composition, the //! stitched ads, the blackouts applied and the compose work all stop being //! claims and become checkable facts (see [`ManifestReceipt::verify_render`]). -//! - **Measured-attested** — `joules_micro`, the marginal energy the generation -//! sink spent, vouched for by the signature and nothing else. Same honesty +//! - **Measured-attested** — the measurement: `joules_micro`, the marginal +//! energy the generation sink spent, its acquisition class and the class's +//! evidence, vouched for by the signature and nothing else. Same honesty //! ladder as [`crate::video_receipt`]: a figure without its acquisition class //! is not comparable to another. //! diff --git a/wai/wai-rs/src/wai_video_package.rs b/wai/wai-rs/src/wai_video_package.rs index 43b02dec7..be70aaca2 100644 --- a/wai/wai-rs/src/wai_video_package.rs +++ b/wai/wai-rs/src/wai_video_package.rs @@ -17,8 +17,9 @@ //! overstated. `uj_per_unique_segment` is the encode-once energy, and it is //! honest precisely because the uniqueness is recomputable. //! -//! Only `joules_micro` is measured-attested. Reuses the manifest module's -//! `EnergyProvenance` and the world Merkle floor's Ed25519 + `GrantRef`. +//! Only the measurement — `joules_micro`, its acquisition class and the class's +//! evidence — is measured-attested: signed, and not re-measurable. Reuses the +//! manifest module's `EnergyProvenance` and the world Merkle floor's Ed25519 + `GrantRef`. use std::collections::BTreeSet; diff --git a/wai/wai-rs/src/wai_video_ssai.rs b/wai/wai-rs/src/wai_video_ssai.rs index 0b5b054b8..0ab3a9ffd 100644 --- a/wai/wai-rs/src/wai_video_ssai.rs +++ b/wai/wai-rs/src/wai_video_ssai.rs @@ -18,8 +18,9 @@ //! The decision itself is deterministic ([`decide`]): a pure function of the //! candidate pod, the avail and the funds remaining, so an auditor re-runs it //! and gets the same fill. The pod is bound by `pod_hash`, so the inputs to that -//! decision are fixed too. Only `joules_micro` is measured-attested — the same -//! honesty ladder as the rest of WAI. +//! decision are fixed too. Only the measurement — `joules_micro`, its acquisition +//! class and the class's evidence — is measured-attested: signed, and not +//! re-measurable, the same honesty ladder as the rest of WAI. //! //! Links to [`crate::wai_video_manifest`] by `manifest_hash` and chains from the //! manifest receipt via `parent_receipt_hash`: the ad break belongs to a diff --git a/wai/wai-rs/src/world/receipt.rs b/wai/wai-rs/src/world/receipt.rs index 2d56bdcb4..b7c229755 100644 --- a/wai/wai-rs/src/world/receipt.rs +++ b/wai/wai-rs/src/world/receipt.rs @@ -57,7 +57,8 @@ pub struct SessionReceipt { pub object_hashes: Vec<[u8; 32]>, /// Merkle root over `object_hashes` (JWP §2.2). pub merkle_root: [u8; 32], - /// Measured microjoules for the session; `0` = unmetered. Joules + /// Measured microjoules for the session; `0` is the unmetered marker, never a + /// figure of zero joules (energy-measurement §2). Joules /// are *carried*, never fabricated — a real meter (RAPL natively, a /// declared wall-clock estimate in a browser) supplies the figure; /// it is NOT derived inside this builder, and a sink that didn't diff --git a/wai/wai-rs/tests/video_receipt.rs b/wai/wai-rs/tests/video_receipt.rs index 31c6b1fb0..c8ce13d98 100644 --- a/wai/wai-rs/tests/video_receipt.rs +++ b/wai/wai-rs/tests/video_receipt.rs @@ -245,3 +245,35 @@ fn a_refined_class_carries_its_uncertainty_and_cannot_omit_it() { ); assert!(!t.verify(), "restated uncertainty"); } + +/// The refined classes' signed bytes, pinned as they were when the hardware +/// class was split. `legacy_labels_keep_their_signed_bytes` holds the legacy +/// encodings; this holds the refined ones, so a byte that leaks into the +/// evidence encoding (`write_signed`) fails here. The digests were captured +/// from the code as the refined classes were introduced and must never be +/// edited to make a test pass. +#[test] +fn refined_labels_keep_their_signed_bytes() { + let (chip_r, _, _) = seal("int_video_bitstream.json", 4_200); + let cal = Provenance::calibrated_instrument( + Uncertainty::new(5_000, 60_000_000).unwrap(), + "https://calibration.example.org/certificates/0001", + ) + .unwrap(); + let (cal_r, _, _) = seal_as("int_video_bitstream.json", 4_200, Some(cal)); + assert!(chip_r.verify() && cal_r.verify()); + assert_eq!( + [ + hex(&chip_r.receipt_hash()), + hex(blake3::hash(chip_r.to_json().as_bytes()).as_bytes()), + hex(&cal_r.receipt_hash()), + hex(blake3::hash(cal_r.to_json().as_bytes()).as_bytes()), + ], + [ + "23b6f548184a543fa324066427648152de6bb367f64aaddb9ff608d8af41543d", + "7ece767eb1428faff0d4dd035d1d85e285461bc4610c5af30223a5c0fc20dcc3", + "c6aad993760ff0b23c57a49e64a4a24c568db2af1c3f18b3c11e659c4ffb4151", + "2d5529def21fdbf30524a53c1638f06c6311bd3d0d50e125ba4d3a4b2fcf69bb", + ] + ); +} -- 2.54.0 (Apple Git-157) From 2904219c9cd0d86f8b83f67f5c184f867f02f2e8 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 06:48:31 -0400 Subject: [PATCH 2/3] wai: name jwp-conformance in the decoded-output sidecar exclusions every_decoded_output_states_a_sha256 enumerates every wai/*-conformance directory and requires a sha256.json sidecar or a named exclusion. The new JWP corpus holds delivery receipts, receipt content with no decoded output, so it is excluded for the reason the claim and OER corpora are. Without this, the fast-kernel steps of wai-conformance.yml fail on the assembled tree. Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/tests/int_kernels_vectors.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/wai/wai-rs/tests/int_kernels_vectors.rs b/wai/wai-rs/tests/int_kernels_vectors.rs index 6ff621abb..acee56fbc 100644 --- a/wai/wai-rs/tests/int_kernels_vectors.rs +++ b/wai/wai-rs/tests/int_kernels_vectors.rs @@ -217,6 +217,7 @@ fn every_decoded_output_states_a_sha256() { ("volumetric-conformance", NATIVE), ("world-conformance", NATIVE), ("quantum-conformance", NATIVE), ("quantum-ops-conformance", NATIVE), ("claim-conformance", "receipt claims: receipt content, no decoded output"), + ("jwp-conformance", "JWP delivery receipts: receipt content, no decoded output"), ("oer-conformance", "OER/2 receipts: receipt content, no decoded output"), ("pin-conformance", "parameter-set pins: every digest there is a SHA-256 of a parameter set"), ("byte-exact-conformance", "goldens over tests/vectors, whose sidecars state the SHA-256"), -- 2.54.0 (Apple Git-157) From bc0dffa96519b7aeaf0b2bf323b7ed64368ba9fe Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 09:05:14 -0400 Subject: [PATCH 3/3] wai: state what CI builds, the meters' new feature, and the /verify change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v0.4.0 energy-report entry said the conformance build builds the reference meters on every platform. It builds two of them, wai_meter and wai_world_meter. wai-energy-receipts.yml builds those two, wai_video_meter and wai_quantum_meter on linux, linux-arm64 and macos. No workflow builds wai_prov, which needs codecs. The entry now says so, and the conformance workflow's comment no longer names a Windows leg its matrix does not have. The entry now records two compatibility changes. Every reference meter bin requires receipt_claims, and Cargo skips a bin whose required features are off without an error. POST /verify in wai-quantum-serve renders an unmetered receipt's joules_micro as null and adds a metered member, so the crate moves to 0.4.0 and the Wire paragraph names the change. jwp-receipts §2.4.9 rule 2 reads "A point that is present MUST NOT be empty", in the form of the other rules. quantum-sim names the meter's other unmetered paths: a rail that is not live in both brackets (energy-measurement §3.1), and a §4 ratio or §2.3 uncertainty that cannot support a figure. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-conformance.yml | 2 +- wai/CHANGELOG.md | 19 ++++++++++++++++--- wai/extensions/jwp-receipts.md | 2 +- wai/extensions/quantum-sim.md | 4 +++- wai/wai-quantum-serve/Cargo.lock | 2 +- wai/wai-quantum-serve/Cargo.toml | 2 +- 6 files changed, 23 insertions(+), 8 deletions(-) diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index e4c01c8ea..ee42c862c 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -108,7 +108,7 @@ jobs: # and is checkable on this matrix with no ONNX Runtime present. receipt_claims # is in the set because the reference meters (wai_meter, wai_world_meter) write a # measurement report by default and require it: without it they would drop out - # of this build, on Windows too, without a word. + # of this build on every leg of this matrix, without a word. run: cargo build --no-default-features --features world,audio_scene,haptic,splat4d,score,provenance,neural_int,film,receipt_claims - name: Library unit tests (the fixed-point determinism floors) working-directory: wai/wai-rs diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 211cc1b75..f0aa7b109 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -359,8 +359,17 @@ wire format may change and such changes are recorded here. (`wai_jwp_vectors`) and `jwp_verify.py`, an independent verifier. CI now runs `claim_verify.py`, `differential.py` (fixed seed) and `jwp_verify.py` on the linux runner, and the JWP corpus on every platform of the conformance matrix. The - conformance build includes `receipt_claims`, so the reference meters are built on - every platform, and joule-code's CI runs `joule-moq` with its QUIC transport. + conformance build includes `receipt_claims`, so `wai_meter` and `wai_world_meter` + are built on every platform of the conformance matrix; `wai-energy-receipts.yml` + builds those two, `wai_video_meter` and `wai_quantum_meter` on linux, linux-arm64 + and macos; no workflow builds `wai_prov`, which needs `codecs`. joule-code's CI runs + `joule-moq` with its QUIC transport. +- **Build.** Every reference meter bin (`wai_meter`, `wai_world_meter`, + `wai_video_meter`, `wai_quantum_meter`, `wai_prov`) now also requires the + `receipt_claims` feature, because it writes a measurement report. Cargo skips a bin + whose required features are off without an error, so a build that names only the + older features (`--features video_receipt`, say) no longer produces that meter; add + `receipt_claims`. **Wire.** Every receipt issued in a legacy form keeps its bytes and verifies as before: v1 groups, `\x01` leaves, every claim and corpus vector already pinned, `wai-quantum`'s @@ -371,7 +380,11 @@ object at §3 step 5, and a claim verifier refuses `measurement` and `operating- **External JWP implementations must adopt the `\x02` leaf and the labelled group payload before verifying objects or groups that use them, and hold publisher keys to the canonical-encoding rule**; the in-tree `joule-moq` does, and its public API changes are -listed in joule-code's CHANGELOG. New meter receipts can declare one ppm more than before, because they +listed in joule-code's CHANGELOG. `wai-quantum-serve` (0.4.0) changes the `POST /verify` +response: an unmetered receipt renders as `"joules_micro": null, "metered": false` +where it rendered its marker as the integer `0`, and every other receipt adds +`"metered": true`; a client that reads `joules_micro` as an integer must accept +`null`. New meter receipts can declare one ppm more than before, because they seal from the published integers, and a meter now refuses a rail that is not live where it used to seal a figure. diff --git a/wai/extensions/jwp-receipts.md b/wai/extensions/jwp-receipts.md index 0fc9f47d6..2c42c799a 100644 --- a/wai/extensions/jwp-receipts.md +++ b/wai/extensions/jwp-receipts.md @@ -729,7 +729,7 @@ one, so two points are equal exactly when their canonical forms are. Rules, checked at §3.1 step 4 in this order: 1. §2.4.8's rules 1–3. A claim restating `HwShunt` is refused there, so no operating point is recorded against a figure labelled with the legacy class. -2. No point that is present **MUST** be empty (`operating_point_empty_point`). +2. A point that is present **MUST NOT** be empty (`operating_point_empty_point`). 3. `op_delivered` **MUST NOT** be present without `op_committed` (`operating_point_delivered_alone`). 4. `op_requested` and `op_committed` **MUST NOT** be present without diff --git a/wai/extensions/quantum-sim.md b/wai/extensions/quantum-sim.md index 41095cf6a..26cb750ac 100644 --- a/wai/extensions/quantum-sim.md +++ b/wai/extensions/quantum-sim.md @@ -225,7 +225,9 @@ energy honestly: on a host that exposes its SoC's power interface to unprivileged readers it samples CPU power through the open-source `macmon` reader (no elevated privileges), 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, whose `joules_micro` carries the +present, or the reader's rail is not live in both brackets +([energy-measurement](energy-measurement.md) §3.1), or the §4 ratio or the §2.3 +uncertainty cannot support a figure, it emits an UNMETERED receipt, whose `joules_micro` carries the unmetered marker `0` ([energy-measurement](energy-measurement.md) §2), never a figure of zero joules, with the exact work intact; a reader presents it as unmetered, and `POST /verify` renders it as `"joules_micro": null`. Beside a diff --git a/wai/wai-quantum-serve/Cargo.lock b/wai/wai-quantum-serve/Cargo.lock index 00aafc33e..8bc1dd68d 100644 --- a/wai/wai-quantum-serve/Cargo.lock +++ b/wai/wai-quantum-serve/Cargo.lock @@ -598,7 +598,7 @@ dependencies = [ [[package]] name = "wai-quantum-serve" -version = "0.3.0" +version = "0.4.0" dependencies = [ "ed25519-dalek", "wai", diff --git a/wai/wai-quantum-serve/Cargo.toml b/wai/wai-quantum-serve/Cargo.toml index b6751663e..f30447a9c 100644 --- a/wai/wai-quantum-serve/Cargo.toml +++ b/wai/wai-quantum-serve/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wai-quantum-serve" -version = "0.3.0" +version = "0.4.0" edition = "2021" license = "Apache-2.0" description = "The WAI quantum stack as a WASI-HTTP component — deterministic quantum computation as a serverless function. Runs on any wasi:http/proxy host (wasmtime serve, and other component-model runtimes); returns the same statevector hashes and the same signed receipt bytes as a native run." -- 2.54.0 (Apple Git-157)