wai: bind a decode's joules to the decode (C2PA 2.4 companion assertion) #68

Merged
dcharlot merged 1 commit from wai/energy-binding into main 2026-09-03 15:10:02 -04:00
4 changed files with 498 additions and 0 deletions

View file

@ -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

View file

@ -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.

View file

@ -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<String>,
) -> Result<Self, EnergyBindingError> {
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<String>,
) -> Result<Self, EnergyBindingError> {
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<u8> {
(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");
}
}

View file

@ -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