From c291fec716fdfd92abb58057aa01c1a617775985 Mon Sep 17 00:00:00 2001 From: David Charlot Date: Sat, 29 Aug 2026 12:07:02 -0400 Subject: [PATCH] wai: bind a decode's joules to the decode (C2PA 2.4 companion assertion) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C2PA 2.4 added `c2pa.environmental-sustainability`, carrying `energy_kwh` per ISO/IEC TR 20226:2025. The dominant signed-provenance container in media now has a slot for joules — and nothing behind it. Verified against the spec text: the figure is self-declared, `measurementMethod` is optional, and it is not bound to any reproducible output. A validator can check every signature in a manifest and still not know where the number came from or what it was measured over. This fills the slot rather than replacing it: the standard assertion is emitted for interoperability, with a companion carrying the three things it cannot express. The load-bearing one is the binding. `reconstructionHash` is the SHA-256 of the canonical decoded pixels — the same digest `reconstruction_binding` commits to, so two assertions in one manifest name the same decode by construction and a manifest whose digests disagree is self-refuting. Because a WAI reconstruction is byte-exact on every architecture, a verifier can re-decode, confirm the figure is about *those* bytes, recompute `workUnits` from the descriptor rather than trusting it, and re-derive the rate — leaving exactly one unverifiable term, the joules. That is the point and also the honest limit: the extension says plainly that a binding does NOT prove the figure. An energy claim about an output nobody can reproduce is not checkable at all; one about an output anyone can reproduce is checkable except for a single number, and knowing which number that is, is the whole difference. The other two: an acquisition class (`HwShunt`/`ModelBased`/`Estimator`) because a counter reading and a modelled constant are different claims occupying the same field in the standard assertion; and a coverage statement, because a partial reading is not wrong but presenting it as a total is. Deliberate refusals: - A zero measurement is rejected, not recorded. A decode that measured 0 uJ did not consume nothing; it failed to measure, and the honest artifact is the absence of an energy assertion. - `method` is required, where the standard leaves `measurementMethod` optional — which is exactly the field that stays empty in practice. - `carbon_kgco2e` and `water_litres` are NOT emitted. Deriving them from an energy figure and a grid-average factor, then placing them beside a measured joule figure in one assertion with no per-field provenance, is precisely how a model comes to be read as a measurement. Integer microjoules are authoritative and `energy_kwh` is derived: one microjoule is 2.8e-13 kWh, so an image decode lands near 1e-9 kWh, where the float's trailing digits are conversion artifacts. The first draft of the unit test asserted that magnitude from memory and was wrong by two orders; it now asserts against computed arithmetic, and the comment says why. Verified: 76 wai-rs tests pass under `--features provenance` (6 new), no clippy warnings in the new file, extension linked from SPEC.md beside its sibling. --- wai/SPEC.md | 4 + wai/extensions/energy-binding.md | 102 ++++++++ wai/wai-rs/src/energy_binding.rs | 385 +++++++++++++++++++++++++++++++ wai/wai-rs/src/lib.rs | 7 + 4 files changed, 498 insertions(+) create mode 100644 wai/extensions/energy-binding.md create mode 100644 wai/wai-rs/src/energy_binding.rs diff --git a/wai/SPEC.md b/wai/SPEC.md index 4810b7583..e44dc8b9b 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -251,6 +251,10 @@ private-channel sub-spec: MLS (RFC 9420) group keys + SFrame (RFC 9605) object encryption, the byte-level framing behind §6. See [`extensions/reconstruction-binding.md`](extensions/reconstruction-binding.md) for the reproducible-reconstruction binding: a C2PA / JPEG-Trust hard binding +See [`extensions/energy-binding.md`](extensions/energy-binding.md) for the +companion energy assertion: the joules a reconstruction cost, bound to that +reconstruction, with the acquisition class and coverage C2PA 2.4's +`c2pa.environmental-sustainability` has no field for. over the *deterministically decoded pixels* of a neural-coded asset (SHA-256 over the canonical RGB plus the descriptor that pins the reconstruction) — the reconstruct-side companion to `provenance`, applicable to `wai.image.jpegai` and diff --git a/wai/extensions/energy-binding.md b/wai/extensions/energy-binding.md new file mode 100644 index 000000000..db301d887 --- /dev/null +++ b/wai/extensions/energy-binding.md @@ -0,0 +1,102 @@ +# WAI Extension: Energy Binding + +> Status: Draft. Binds the **energy a reconstruction cost** to *that* +> reconstruction, as a companion to C2PA 2.4's +> `c2pa.environmental-sustainability` assertion — supplying the acquisition +> class, the coverage, and the outcome binding the standard assertion has no +> field for. Reference impl: the `energy_binding` module of `wai-rs` +> (feature `provenance`). Companion to +> [Reproducible-Reconstruction Binding](reconstruction-binding.md) and the +> acquisition protocol in [Energy Measurement](energy-measurement.md). +> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174. + +## 1. Why a companion assertion + +C2PA 2.4 defines `c2pa.environmental-sustainability`, carrying `energy_kwh` +(per ISO/IEC TR 20226:2025) alongside carbon and water figures. The dominant +signed-provenance container in media now has a place to put joules. + +Three properties it does not have, and this extension supplies: + +1. **It is self-declared.** `measurementMethod` is optional, and an + unattributed number cannot be compared with any other number. In practice + the optional field is the one that stays empty. +2. **It carries no acquisition class.** A hardware counter reading and a + modelled constant occupy the same field and are indistinguishable to a + reader, though they are different claims about the world. +3. **It is not bound to any reproducible output.** The assertion is about an + asset; a validator can check every signature in a manifest and still not + know what the figure was measured *over*. + +The third is the load-bearing one. A signature proves who said a number, never +that the number is about anything checkable. + +## 2. What the binding is + +An energy binding is the tuple + +``` +{ reconstructionHash, alg, microjoules, acquisition, coverage, workUnits, method } +``` + +carried as a third-party C2PA assertion under the reverse-domain label +`science.transaction.wai.energy.reconstruction`, **alongside** — never instead +of — the standard `c2pa.environmental-sustainability` assertion. + +`reconstructionHash` is the SHA-256 of the canonical decoded pixels, the same +digest [reconstruction-binding](reconstruction-binding.md) commits to. Both +assertions in one manifest therefore name the same decode by construction, and +a manifest whose two digests disagree is self-refuting. + +## 3. What this makes checkable, and what it does not + +Because a WAI reconstruction is byte-exact and reproducible on every +architecture, a verifier holding the codestream can: + +- re-decode under the pinned descriptor, +- hash the pixels and confirm the energy figure is about **those** bytes, +- 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. + +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 +measurement. + +## 4. Normative rules + +- The binding **MUST** carry an acquisition class (`HwShunt` / `ModelBased` / + `Estimator`, per [energy-measurement](energy-measurement.md) §2) and a + coverage statement (`Whole` / `Partial`, §5 there). +- `method` **MUST** be present and name the published protocol the figure was + obtained under. This is deliberately stricter than the standard assertion's + optional `measurementMethod`. +- A figure of zero **MUST NOT** be recorded. A reconstruction that measured + 0 µJ did not consume nothing; it failed to measure, and the honest artifact is + the absence of an energy assertion rather than a zero in one. +- `microjoules` is an integer and is authoritative. `energy_kwh` in the standard + assertion **MUST** be treated as a derived interop value: one microjoule is + 2.8 × 10⁻¹³ kWh, so a whole image decode lands near 10⁻⁹ kWh, where the + trailing digits of the float are artifacts of the conversion rather than of + the measurement. +- An implementation **MUST NOT** populate `carbon_kgco2e` or `water_litres` + from an energy figure and a grid-average factor while presenting the result + as measured. Those are modelled quantities; emitting them beside a measured + 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. + +## 5. Relationship to the standard assertion + +Interoperability is the reason both are emitted. A validator that understands +only C2PA 2.4 reads `energy_kwh` and a populated `measurementMethod`, which is +already better than the common case. A validator that understands this +extension additionally learns how the figure was acquired, how much of the work +it covers, and which reconstruction it describes — and can check the last of +those itself. diff --git a/wai/wai-rs/src/energy_binding.rs b/wai/wai-rs/src/energy_binding.rs new file mode 100644 index 000000000..bc482b4eb --- /dev/null +++ b/wai/wai-rs/src/energy_binding.rs @@ -0,0 +1,385 @@ +//! Energy binding — the joules a reconstruction cost, tied to *that* reconstruction. +//! +//! # Why this exists +//! +//! C2PA 2.4 added a standard `c2pa.environmental-sustainability` assertion carrying +//! `energy_kwh` (per ISO/IEC TR 20226:2025) alongside carbon and water figures. The +//! dominant signed-provenance container in media now has a slot for joules. +//! +//! What it does not have is anything behind that slot. The figure is **self-declared**, +//! its `measurementMethod` is **optional**, and — the part that matters most — it is not +//! bound to any reproducible output. A manifest can assert a number about an asset, and +//! a validator that checks every signature in the manifest still learns nothing about +//! where the number came from or what it was measured over. +//! +//! This module fills the slot rather than replacing it. It emits the standard assertion +//! for interoperability, and carries a companion assertion with the three things the +//! standard one omits. +//! +//! # What the companion assertion adds +//! +//! - **A binding to the reconstruction.** The figure carries the SHA-256 of the decoded +//! pixels it was measured over — the same digest +//! [`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 +//! energy claim about an output nobody can reproduce is not checkable at all; an +//! energy claim about an output anyone can reproduce is checkable except for one +//! number, and knowing precisely which number that is, is the difference. +//! - **An acquisition class.** `HwShunt` / `ModelBased` / `Estimator` — the family's +//! ladder, stated rather than implied. A counter reading and a modelled constant are +//! not the same claim, and the standard assertion has no way to tell them apart. +//! - **A coverage statement.** Whether the figure accounts for the whole reconstruction +//! or part of it. A partial reading is not wrong; presenting it as a total is. +//! +//! # On units +//! +//! The truth carried here is **integer microjoules**. The standard assertion wants +//! `energy_kwh`, and one microjoule is 2.8e-13 kWh, so a whole image decode lands +//! around 1e-9 kWh — eleven orders of magnitude below the unit's natural scale, where +//! the trailing digits are an artifact of the conversion rather than of the +//! measurement. So `energy_kwh` is emitted as a *derived* interop value and labelled as +//! such, while the integer stays authoritative. A verifier should read the integer. + +use sha2::{Digest, Sha256}; + +/// How the energy figure was acquired — the family's provenance ladder. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Acquisition { + /// Read from a hardware energy interface (RAPL / NVML / SMC / IOReport / shunt). + HwShunt, + /// Computed from a calibrated per-operation model. A real number, honestly derived. + ModelBased, + /// A coarse constant or load estimator. + Estimator, +} + +impl Acquisition { + /// The wire label. + pub fn label(self) -> &'static str { + match self { + Acquisition::HwShunt => "HwShunt", + Acquisition::ModelBased => "ModelBased", + Acquisition::Estimator => "Estimator", + } + } +} + +/// How much of the reconstruction the figure accounts for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Coverage { + /// The figure covers the whole reconstruction. + Whole, + /// It covers part of it; the remainder is unmeasured, which is not the same as zero. + Partial, +} + +impl Coverage { + /// The wire label. + pub fn label(self) -> &'static str { + match self { + Coverage::Whole => "Whole", + Coverage::Partial => "Partial", + } + } +} + +/// Errors from constructing a binding. +#[derive(Debug, PartialEq, Eq)] +pub enum EnergyBindingError { + /// A measured figure of zero is a failed measurement, not free work. + ZeroMeasurement, + /// The work unit must be recomputable and non-zero to express a rate. + ZeroWork, + /// A figure must name the protocol it was obtained under. + NoMethod, +} + +impl core::fmt::Display for EnergyBindingError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + EnergyBindingError::ZeroMeasurement => { + write!(f, "a measured figure of 0 uJ is a failed measurement, not free work") + } + EnergyBindingError::ZeroWork => write!(f, "work unit must be non-zero"), + EnergyBindingError::NoMethod => { + write!(f, "a figure must name the acquisition protocol it was obtained under") + } + } + } +} + +impl std::error::Error for EnergyBindingError {} + +/// The joules a reconstruction cost, bound to the reconstruction itself. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EnergyBinding { + /// SHA-256 of the canonical decoded pixels this figure was measured over — the same + /// digest the reconstruction binding commits to. + pub reconstruction_hash: [u8; 32], + /// The measured energy, integer microjoules. Authoritative; `energy_kwh` in the + /// standard assertion is derived from this. + pub microjoules: u64, + /// How the figure was acquired. + pub acquisition: Acquisition, + /// How much of the reconstruction it accounts for. + pub coverage: Coverage, + /// The recomputable work unit the rate is expressed against (for an image, decoded + /// subpixels: `width * height * 3`). A verifier derives this from the reconstruction + /// descriptor rather than trusting it. + pub work_units: u64, + /// The published protocol the figure was obtained under — for WAI decodes, + /// `extensions/energy-measurement.md`. Required: an unattributed number is not a + /// measurement, and the standard assertion's optional `measurementMethod` is exactly + /// the field everyone leaves empty. + pub method: String, +} + +impl EnergyBinding { + /// The companion assertion's label (reverse-domain, as C2PA requires for third-party + /// assertions). Carried *alongside* `c2pa.environmental-sustainability`, never + /// instead of it. + pub const C2PA_LABEL: &'static str = "science.transaction.wai.energy.reconstruction"; + + /// The standard C2PA 2.4 assertion this one accompanies. + pub const C2PA_STANDARD_LABEL: &'static str = "c2pa.environmental-sustainability"; + + /// Bind a measured figure to a reconstruction. + /// + /// Rejects a zero measurement rather than recording it: a decode that measured 0 µJ + /// did not consume nothing, it failed to measure, and the honest artifact in that + /// case is no energy assertion at all. + pub fn bind( + reconstruction_hash: [u8; 32], + microjoules: u64, + acquisition: Acquisition, + coverage: Coverage, + work_units: u64, + method: impl Into, + ) -> Result { + if microjoules == 0 { + return Err(EnergyBindingError::ZeroMeasurement); + } + if work_units == 0 { + return Err(EnergyBindingError::ZeroWork); + } + let method = method.into(); + if method.trim().is_empty() { + return Err(EnergyBindingError::NoMethod); + } + Ok(EnergyBinding { + reconstruction_hash, + microjoules, + acquisition, + coverage, + work_units, + method, + }) + } + + /// Bind directly to decoded pixels, hashing them the same way the reconstruction + /// binding does, so the two assertions in one manifest necessarily agree. + pub fn bind_pixels( + rgb: &[u8], + microjoules: u64, + acquisition: Acquisition, + coverage: Coverage, + method: impl Into, + ) -> Result { + let hash: [u8; 32] = Sha256::digest(rgb).into(); + Self::bind(hash, microjoules, acquisition, coverage, rgb.len() as u64, method) + } + + /// Whether this figure describes the pixels a verifier just decoded. + /// + /// This is the check that makes the assertion worth carrying: re-decode, hash, and + /// confirm the energy claim is about *these* bytes and not some other run. + pub fn describes(&self, rgb: &[u8]) -> bool { + <[u8; 32]>::from(Sha256::digest(rgb)) == self.reconstruction_hash + } + + /// Microjoules per thousand work units — the comparable rate, with its acquisition + /// class, because a rate without one cannot be compared to another. + pub fn uj_per_kilo_unit(&self) -> (f64, Acquisition) { + (self.microjoules as f64 * 1000.0 / self.work_units as f64, self.acquisition) + } + + /// The derived `energy_kwh` for the standard assertion. + /// + /// Lossy by construction — a microjoule-scale decode lands around 1e-13 kWh, where + /// the trailing digits come from the conversion rather than the measurement. Emitted + /// for interoperability; [`Self::microjoules`] remains authoritative. + pub fn energy_kwh_derived(&self) -> f64 { + self.microjoules as f64 / 3.6e12 + } + + /// Lowercase-hex of the reconstruction digest. + pub fn hash_hex(&self) -> String { + let mut s = String::with_capacity(64); + for b in self.reconstruction_hash { + use std::fmt::Write; + let _ = write!(s, "{b:02x}"); + } + s + } + + /// The standard `c2pa.environmental-sustainability` assertion body, for interop. + /// + /// `measurementMethod` is always populated — it is optional in the standard, and an + /// unattributed figure is the failure this module exists to avoid. Carbon and water + /// are deliberately absent: this measures energy, and inventing the other two from a + /// grid-average factor would be a modelled figure wearing a measured one's clothes. + pub fn to_standard_assertion_json(&self) -> String { + format!( + "{{\"energy_kwh\":{:e},\"measurementMethod\":{}}}", + self.energy_kwh_derived(), + json_str(&self.method) + ) + } + + /// The companion assertion body: everything the standard one cannot express. + pub fn to_json(&self) -> String { + format!( + "{{\"reconstructionHash\":\"{}\",\"alg\":\"sha256\",\"microjoules\":{},\ + \"acquisition\":\"{}\",\"coverage\":\"{}\",\"workUnits\":{},\"method\":{}}}", + self.hash_hex(), + self.microjoules, + self.acquisition.label(), + self.coverage.label(), + self.work_units, + json_str(&self.method) + ) + } +} + +fn json_str(s: &str) -> String { + let mut o = String::with_capacity(s.len() + 2); + o.push('"'); + for c in s.chars() { + match c { + '"' => o.push_str("\\\""), + '\\' => o.push_str("\\\\"), + '\n' => o.push_str("\\n"), + c => o.push(c), + } + } + o.push('"'); + o +} + +#[cfg(test)] +mod tests { + use super::*; + + const METHOD: &str = "https://transaction.science/wai/extensions/energy-measurement"; + + fn pixels() -> Vec { + (0..(4 * 4 * 3u32)).map(|i| (i % 251) as u8).collect() + } + + /// The point of the module: the figure is about pixels a verifier can reproduce. + #[test] + fn the_figure_is_bound_to_the_pixels_it_was_measured_over() { + let rgb = pixels(); + let b = EnergyBinding::bind_pixels(&rgb, 10_565, Acquisition::HwShunt, Coverage::Whole, METHOD) + .unwrap(); + + assert!(b.describes(&rgb), "must describe the pixels it was bound to"); + + // A different reconstruction is a different claim. + let mut other = rgb.clone(); + other[0] ^= 1; + assert!(!b.describes(&other), "must not vouch for pixels it never saw"); + + // And it agrees with the reconstruction binding's own digest, so two + // assertions in one manifest cannot disagree about which decode is meant. + use crate::reconstruction_binding::{ReconstructionBinding, ReconstructionDescriptor}; + let rb = ReconstructionBinding::bind( + &rgb, + ReconstructionDescriptor { + decoder_id: 0, + profile: "p".into(), + level: "l".into(), + width: 4, + height: 4, + format: "RGB8".into(), + color_space: "bt709".into(), + }, + ); + assert_eq!(rb.hash_hex(), b.hash_hex(), "both assertions must name the same decode"); + } + + /// A zero measurement is a failed measurement, not free work — so it cannot be + /// recorded as an energy claim at all. + #[test] + fn a_zero_measurement_is_refused() { + let rgb = pixels(); + assert_eq!( + EnergyBinding::bind_pixels(&rgb, 0, Acquisition::HwShunt, Coverage::Whole, METHOD), + Err(EnergyBindingError::ZeroMeasurement) + ); + } + + /// The standard assertion leaves `measurementMethod` optional, which is exactly the + /// field that ends up empty. Here it cannot be. + #[test] + fn a_figure_must_name_its_method() { + let rgb = pixels(); + assert_eq!( + EnergyBinding::bind_pixels(&rgb, 10, Acquisition::Estimator, Coverage::Whole, " "), + Err(EnergyBindingError::NoMethod) + ); + } + + /// Acquisition and coverage survive into the wire, because a bare number is not + /// comparable to another bare number. + #[test] + fn the_wire_carries_what_the_standard_assertion_cannot() { + let rgb = pixels(); + let b = EnergyBinding::bind_pixels(&rgb, 2_469, Acquisition::ModelBased, Coverage::Partial, METHOD) + .unwrap(); + let j = b.to_json(); + assert!(j.contains("\"acquisition\":\"ModelBased\"")); + assert!(j.contains("\"coverage\":\"Partial\"")); + assert!(j.contains("\"microjoules\":2469")); + assert!(j.contains("reconstructionHash")); + + // The standard assertion always names its method, and carries no carbon or + // water figure this module did not measure. + let s = b.to_standard_assertion_json(); + assert!(s.contains("measurementMethod")); + assert!(!s.contains("carbon"), "must not invent a carbon figure: {s}"); + assert!(!s.contains("water"), "must not invent a water figure: {s}"); + } + + /// kWh is a derived interop value; the integer is the truth. Round-tripping is + /// lossy at this scale and the docs say so — this pins that it really is. + #[test] + fn kwh_is_derived_and_lossy_while_the_integer_is_exact() { + let rgb = pixels(); + let b = EnergyBinding::bind_pixels(&rgb, 10_565, Acquisition::HwShunt, Coverage::Whole, METHOD) + .unwrap(); + assert_eq!(b.microjoules, 10_565, "the integer is carried exactly"); + + let kwh = b.energy_kwh_derived(); + let back = (kwh * 3.6e12).round() as u64; + assert_eq!(back, 10_565, "this magnitude survives the round trip"); + // 10_565 uJ is 2.93e-9 kWh. Asserted against computed arithmetic rather than + // an eyeballed exponent — the first draft of this test guessed 1e-11 and was + // wrong by two orders of magnitude, which is the entire hazard of the unit. + assert!((kwh - 10_565.0 / 3.6e12).abs() < 1e-20); + assert!(kwh < 1e-8 && kwh > 1e-10, "an image decode sits around 1e-9 kWh, got {kwh:e}"); + } + + /// The rate is only meaningful with its acquisition class attached. + #[test] + fn a_rate_carries_its_acquisition_class() { + let rgb = pixels(); // 48 bytes of work + let b = EnergyBinding::bind_pixels(&rgb, 4_800, Acquisition::HwShunt, Coverage::Whole, METHOD) + .unwrap(); + let (rate, acq) = b.uj_per_kilo_unit(); + assert_eq!(acq, Acquisition::HwShunt); + assert!((rate - 100_000.0).abs() < 1e-6, "4800 uJ over 48 units = 100 uJ/unit"); + } +} diff --git a/wai/wai-rs/src/lib.rs b/wai/wai-rs/src/lib.rs index bba1e36e2..429847fc3 100644 --- a/wai/wai-rs/src/lib.rs +++ b/wai/wai-rs/src/lib.rs @@ -197,6 +197,13 @@ pub mod provenance; #[cfg(feature = "provenance")] pub mod reconstruction_binding; +// The joules a reconstruction cost, bound to that reconstruction. Fills the slot +// C2PA 2.4's `c2pa.environmental-sustainability` opened but left self-declared: +// same digest as the reconstruction binding, plus the acquisition class and +// coverage the standard assertion cannot express (extensions/energy-binding.md). +#[cfg(feature = "provenance")] +pub mod energy_binding; + // 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 -- 2.54.0 (Apple Git-157)