From 4ba3e618418a1854c4f347b73d586b407d39c605 Mon Sep 17 00:00:00 2001 From: David Charlot Date: Fri, 28 Aug 2026 17:39:29 -0400 Subject: [PATCH] jcp: an energy figure must say how much of the work it covers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Energy { measured_uj, estimated_uj, provenance }`. `provenance` answers *how* the number was acquired and nothing answers *how much of the task it accounts for* — two different questions, routinely conflated to the figure's advantage. The concrete case: on a host where the accelerator exposes an energy counter but the CPU does not, a caller reads the accelerator and `Energy::measured()` stamps `HwShunt` on a figure presented as the task's energy. The CPU's share is not zero; it is unmeasured and silently missing. The reading is honest about its sensor and dishonest about its scope, and nothing in the type or the receipt could tell the difference. `Coverage { Whole, Partial }` sits beside provenance rather than inside it, because the axes really are independent: a partial reading is still a genuine sensor reading, and `check_provenance` is untouched. `Energy::measured_partial` exists so the honest case is as easy to express as the flattering one — without it, a caller holding a partial reading reaches for `measured()` and the figure claims the whole task by default. The distinction is carried into the sealed receipt, not just the type. Had it stopped at the type, a partial reading would reach a verifier looking exactly like a whole one, which is the failure the field was added to prevent. `Whole` is the default and is omitted from the wire, so every receipt sealed before this field canonicalizes to the same bytes, and a legacy payload with no `coverage` key parses as `Whole` — both pinned by tests, and the published vectors are unchanged on disk. On blast radius: I first declined this change on a measurement of "85 literal sites across 8 workspaces", which was wrong — a grep for `Energy {` had swept up `mesh::Energy`, `proof::Energy`, `DeviceEnergy` and `AttestedEnergy`, which are different types. The compiler put the real figure at **one** site, a test inside `jcp-schema` itself. Every one of the eight path-depending workspaces compiles untouched, checked before and again after the receipt change, since that is the whole risk of editing this crate. Verified: 240 jcp-rs tests pass, conformance passes all classes, published vectors unchanged, and joule-code / joule-ui / jouleclaw / joulehook / jouledesk / sandbox / mesh all check clean with zero errors. --- jcp/jcp-rs/crates/jcp-receipt/src/lib.rs | 11 +++ jcp/jcp-rs/crates/jcp-schema/src/lib.rs | 120 ++++++++++++++++++++++- 2 files changed, 127 insertions(+), 4 deletions(-) diff --git a/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs b/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs index 9770d3125..12b2efc34 100644 --- a/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-receipt/src/lib.rs @@ -101,6 +101,15 @@ pub fn to_jcr1_wire(frame: &Frame, grant: &Grant, decision: &Decision, steward: } obj.insert("cost_uj".into(), json!(decision.cost.uj())); obj.insert("energy_provenance".into(), json!(decision.cost.provenance.jcr1_label())); + // Coverage rides beside provenance, but only when it is `Partial`: the + // whole-task case is the default and is omitted, so every receipt sealed + // before this field existed canonicalizes to the same bytes and still + // verifies. Without this the distinction would exist in the type and vanish + // in the artifact, which is the failure the field was added to prevent — a + // partial reading would reach a verifier looking exactly like a whole one. + if !decision.cost.coverage.is_whole() { + obj.insert("energy_coverage".into(), json!(decision.cost.coverage.label())); + } obj.insert("budget_remaining_uj".into(), json!(decision.budget_remaining_uj)); obj.insert("budget_uj".into(), json!(grant.budget_uj)); obj.insert("steward".into(), json!(steward)); @@ -707,6 +716,8 @@ mod tests { /// `conformance/v1/vectors.json` before `payload_hash` existed, sealed under /// `jcp/1`. They MUST still verify — version dispatch recomputes the legacy /// preimage — or the bump silently invalidated every receipt in the wild. + + #[test] fn published_v1_receipts_still_verify() { const V1_ALLOW: &str = "d284581fa201270458196469643a6578616d706c653a6a63702d7265666572656e6365a05901d2ae65747275737467636f6e74726f6c66616374696f6e64726561646669737375657278386469643a6b65793a7a364d6b6f6e334e656364364e6b6b79666f476f48786964327a6e476335394c55334b376d756261526346624c664c5867636f73745f756a181e677374657761726478196469643a6578616d706c653a6a63702d7265666572656e6365677375626a65637478386469643a6b65793a7a364d6b6f39685467674d776a535445614a615055664536747163793278765536426e4e713365336f38715642697948677665726469637465616c6c6f77686772616e745f6964784033623161356139636262373831353134636637353162613030376530373765383038333937376234663037623130346433366565393034643932383939386334687265736f75726365784030663766623535636565393361623937373738323936303865383531666133316536386531326462396466633061383032366164323938333134636633663932696275646765745f756a18646a6672616d655f6b696e6469746f6f6c2d63616c6c6b6a63705f72656365697074656a63702f3171656e657267795f70726f76656e616e63656748775368756e74736275646765745f72656d61696e696e675f756a184658405a7be56b33956c069d4e23127baa7b090e5028b9704dff6ca5ac46da546534e667151f80d7d7a605bcc31ea2bb33ec971e3e8b8794f7248a18b34c33bc438509"; diff --git a/jcp/jcp-rs/crates/jcp-schema/src/lib.rs b/jcp/jcp-rs/crates/jcp-schema/src/lib.rs index c218ada26..ba23423a4 100644 --- a/jcp/jcp-rs/crates/jcp-schema/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-schema/src/lib.rs @@ -125,6 +125,38 @@ impl Provenance { } } +/// How much of a unit of work an energy figure accounts for. +/// +/// Deliberately separate from [`Provenance`]: a reading can be hardware-rooted +/// and still cover only part of the work, and reporting the two as one number +/// overstates it. A partial figure is not wrong — it is incomplete, and saying +/// which is the whole point. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Coverage { + /// The figure accounts for the whole unit of work. + #[default] + Whole, + /// The figure accounts for only part of it. The remainder is **unmeasured**, + /// which is not the same as zero, and a `Partial` figure MUST NOT be + /// presented as a task total. + Partial, +} + +impl Coverage { + /// Whether this is the default, wire-omitted case. + pub fn is_whole(&self) -> bool { + matches!(self, Coverage::Whole) + } + + /// The wire label. + pub fn label(self) -> &'static str { + match self { + Coverage::Whole => "Whole", + Coverage::Partial => "Partial", + } + } +} + /// A two-part energy reading, integer microjoules (SPEC §5). #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Energy { @@ -132,18 +164,53 @@ pub struct Energy { pub measured_uj: Option, pub estimated_uj: u64, pub provenance: Provenance, + /// How much of the work this figure accounts for. + /// + /// [`Provenance`] answers *how* the number was acquired; it says nothing + /// about *how much of the task it covers*, and the two are routinely + /// conflated to the figure's advantage. On a host where the accelerator + /// exposes an energy counter but the CPU does not, a caller reads the + /// accelerator and stamps `HwShunt` on a figure presented as the task's + /// energy — the CPU's share is not zero, it is unmeasured and silently + /// missing. + /// + /// Defaults to [`Coverage::Whole`] and is omitted from the wire in that + /// case, so every previously sealed receipt and published vector is + /// unchanged. + #[serde(default, skip_serializing_if = "Coverage::is_whole")] + pub coverage: Coverage, } impl Energy { pub fn modeled(uj: u64) -> Self { - Energy { measured_uj: None, estimated_uj: uj, provenance: Provenance::ModelBased } + Energy { measured_uj: None, estimated_uj: uj, provenance: Provenance::ModelBased, coverage: Coverage::Whole } } pub fn measured(uj: u64) -> Self { - Energy { measured_uj: Some(uj), estimated_uj: uj, provenance: Provenance::HwShunt } + Energy { measured_uj: Some(uj), estimated_uj: uj, provenance: Provenance::HwShunt, coverage: Coverage::Whole } } pub fn estimator(uj: u64) -> Self { - Energy { measured_uj: None, estimated_uj: uj, provenance: Provenance::Estimator } + Energy { measured_uj: None, estimated_uj: uj, provenance: Provenance::Estimator, coverage: Coverage::Whole } } + /// A hardware reading that covers only part of the work — an accelerator + /// counter on a host whose CPU has none, say. + /// + /// Exists so the honest case is as easy to express as the flattering one: + /// without it, a caller with a partial reading reaches for + /// [`Energy::measured`] and the figure silently claims the whole task. + pub fn measured_partial(uj: u64) -> Self { + Energy { + measured_uj: Some(uj), + estimated_uj: uj, + provenance: Provenance::HwShunt, + coverage: Coverage::Partial, + } + } + + /// Whether this figure accounts for the whole unit of work. + pub fn is_whole(&self) -> bool { + self.coverage.is_whole() + } + /// The figure to debit a budget by: the hardware reading if present, else the estimate. pub fn uj(&self) -> u64 { self.measured_uj.unwrap_or(self.estimated_uj) @@ -609,10 +676,55 @@ mod tests { assert!(Energy::modeled(10).check_provenance().is_ok()); assert!(Energy::measured(10).check_provenance().is_ok()); assert!(Energy::estimator(10).check_provenance().is_ok()); - let bad = Energy { measured_uj: None, estimated_uj: 1, provenance: Provenance::HwShunt }; + let bad = Energy { + measured_uj: None, + estimated_uj: 1, + provenance: Provenance::HwShunt, + coverage: Coverage::Whole, + }; assert!(bad.check_provenance().is_err()); } + /// Provenance and coverage answer different questions, and a figure that + /// conflates them overstates itself. + #[test] + fn coverage_is_separate_from_provenance() { + // The flattering case the constructor made easy: a hardware reading + // that silently claims the whole task. + let whole = Energy::measured(10); + assert_eq!(whole.provenance, Provenance::HwShunt); + assert!(whole.is_whole()); + + // The honest case, now equally easy to express. Same provenance — the + // counter is just as real — but it accounts for part of the work. + let part = Energy::measured_partial(10); + assert_eq!(part.provenance, Provenance::HwShunt, "a partial reading is still a sensor reading"); + assert!(!part.is_whole()); + assert_eq!(part.coverage, Coverage::Partial); + + // Partial is orthogonal to the provenance coupling, so it must not + // disturb it. + part.check_provenance().expect("HwShunt + measured is still coherent"); + } + + /// Absence is the default and is omitted from the wire, so every receipt + /// and vector sealed before this field existed is unchanged. + #[test] + fn whole_coverage_is_omitted_from_the_wire() { + let whole = serde_json::to_string(&Energy::measured(10)).unwrap(); + assert!(!whole.contains("coverage"), "default coverage must not appear: {whole}"); + + let part = serde_json::to_string(&Energy::measured_partial(10)).unwrap(); + assert!(part.contains("Partial"), "a partial figure must say so: {part}"); + + // And a payload written before the field existed still parses, as Whole. + let legacy: Energy = + serde_json::from_str(r#"{"measured_uj":10,"estimated_uj":10,"provenance":"HwShunt"}"#) + .expect("legacy payload must still parse"); + assert!(legacy.is_whole()); + assert_eq!(legacy, Energy::measured(10)); + } + fn grant_without_funds() -> Grant { let r = ContentId::of_bytes(b"db"); Grant { -- 2.54.0 (Apple Git-157)