From bd5c960f1381a3c98b12cd3ea7b3bf7dcb773ada Mon Sep 17 00:00:00 2001 From: David Charlot Date: Thu, 27 Aug 2026 10:35:53 -0400 Subject: [PATCH 1/3] eoc: divide a measured facility total among tenants without losing any of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `eoc-carbon` accounted one request bottom-up — `it_joules × PUE × intensity` — and had no notion of a facility, a tenant, a share, or a residual. `grep -rniE "tenant|allocat|conserv|residual"` across the crate returned zero. That shape is right for a single tenant and wrong for a facility, because nothing forces the tenants' figures to add up to what the building actually drew. Both failure modes are silent and opposite: idle capacity, cooling headroom, the control plane and failed work draw real power that belongs to no request and simply vanishes from the sum; while two tenants can each be charged a PUE multiplier over the same shared overhead and nothing notices. An allocation here is anchored top-down on a measured total and the arithmetic is required to close. **Integer microjoules, on purpose.** `CarbonAccount` is `f64` throughout, which is fine for a reported figure and unusable for an invariant: a conservation law that holds to within floating-point error is not a conservation law. Allocation uses `u64` µJ, matching `eoc_core::JouleCost`, so `Σ shares + residual == total` holds exactly and is checked rather than hoped for. Sums are taken in `u128` so an overflowing share set is reported as over-allocation instead of wrapping into a total that appears to conserve. **The residual is required, not optional.** An allocation that assigns 100% of a facility to its tenants asserts that every joule the building drew was attributable to a paying workload, which is essentially never true. Making the remainder a mandatory field forces an operator to state it. `proportional()` sends its integer-division loss there too, rather than spreading it by a rounding rule — the residual is the honest home for energy no tenant demonstrably caused. Rejects rather than clamps throughout: an over-allocation means the model is wrong, and trimming it silently would hide the only signal that says so. **The basis is a disclosed choice.** The same facility split by measured energy, by compute-seconds or by reserved capacity yields three different numbers for one tenant, all defensible and none comparable, so an allocation that does not say which was used cannot be checked against another. **A tenant can check its own figure without being shown its co-tenants.** Shares are committed to a Merkle **sum** tree — every node carries a hash and its subtree total. A tenant gets its own leaf plus the sibling `(hash, sum)` pairs along its path, recomputes the root, and confirms the root's sum plus the residual equals the signed facility total. Both halves are checked together on purpose: a share can be genuinely included in a tree that does not add up, so inclusion alone proves nothing. What that leaks is stated rather than glossed: the tenant learns the aggregate of each sibling subtree on its path — about `log2(n)` partial sums — and no individual co-tenant's share or identity. `aggregates_revealed()` reports the count so both sides know. Hiding the aggregates as well needs a zero-knowledge range proof, which this module does not attempt and does not claim. Scoped as multi-tenant plumbing, not "verifiable emissions reporting": nothing here signs anything or speaks to carbon. Verified: 30 eoc-carbon tests pass (9 new, covering exact conservation, the overflow path, rounding-to-residual, odd leaf counts 1..9, tampered shares, misstated totals and restated residuals), no clippy warnings in the new file. The eoc-rs workspace has a pre-existing build failure in `eoc-tools` (reqwest `query` behind `default-features = false`) that predates this change and is untouched by it. --- eoc/eoc-rs/Cargo.lock | 1 + eoc/eoc-rs/crates/eoc-carbon/Cargo.toml | 1 + .../crates/eoc-carbon/src/allocation.rs | 551 ++++++++++++++++++ eoc/eoc-rs/crates/eoc-carbon/src/lib.rs | 1 + 4 files changed, 554 insertions(+) create mode 100644 eoc/eoc-rs/crates/eoc-carbon/src/allocation.rs diff --git a/eoc/eoc-rs/Cargo.lock b/eoc/eoc-rs/Cargo.lock index 0e1e2fd8c..0e94a0cd7 100644 --- a/eoc/eoc-rs/Cargo.lock +++ b/eoc/eoc-rs/Cargo.lock @@ -766,6 +766,7 @@ name = "eoc-carbon" version = "0.1.0" dependencies = [ "async-trait", + "blake3", "chrono", "eoc-core", "eoc-eval", diff --git a/eoc/eoc-rs/crates/eoc-carbon/Cargo.toml b/eoc/eoc-rs/crates/eoc-carbon/Cargo.toml index a646119b2..3aabdd4e5 100644 --- a/eoc/eoc-rs/crates/eoc-carbon/Cargo.toml +++ b/eoc/eoc-rs/crates/eoc-carbon/Cargo.toml @@ -17,6 +17,7 @@ eval-bridge = ["dep:eoc-eval"] [dependencies] eoc-core.workspace = true +blake3.workspace = true eoc-meter.workspace = true serde = { workspace = true } serde_json = "1" diff --git a/eoc/eoc-rs/crates/eoc-carbon/src/allocation.rs b/eoc/eoc-rs/crates/eoc-carbon/src/allocation.rs new file mode 100644 index 000000000..7c4e79fc6 --- /dev/null +++ b/eoc/eoc-rs/crates/eoc-carbon/src/allocation.rs @@ -0,0 +1,551 @@ +//! Multi-tenant facility allocation — dividing a **measured facility total** +//! among the tenants that caused it, without losing any of it. +//! +//! # The gap this fills +//! +//! [`crate::account`] accounts one request bottom-up: `it_joules × PUE × +//! intensity`. That is the right shape for a single tenant and the wrong shape +//! for a facility. Nothing in a bottom-up model forces the tenants' figures to +//! add up to what the building actually drew, so the two failure modes are +//! silent and opposite: +//! +//! - **Under-reporting.** Idle capacity, cooling headroom, the control plane, +//! failed requests and unbilled internal work draw real power and belong to no +//! request, so they simply vanish from the sum. +//! - **Over-reporting.** Two tenants can each be charged a PUE multiplier over +//! the same shared overhead, and nothing notices. +//! +//! An allocation here is therefore anchored **top-down** on a measured total, +//! and the arithmetic is required to close. +//! +//! # Integer microjoules, on purpose +//! +//! [`crate::account::CarbonAccount`] is `f64` throughout, which is fine for a +//! reported figure and unusable for an invariant: a conservation law that holds +//! to within floating-point error is not a conservation law. Allocation uses +//! `u64` microjoules, matching `eoc_core::JouleCost`, so `Σ shares + residual == +//! total` holds **exactly** and is checked rather than hoped for. +//! +//! # The residual is named, never dropped +//! +//! [`Allocation`] requires an explicit `residual_uj`. An allocation that assigns +//! 100% of a facility to its tenants is asserting that every joule the building +//! drew was attributable to a paying workload, which is essentially never true. +//! Making the remainder a required field means an operator has to state it +//! rather than let it disappear into rounding. +//! +//! # What a tenant can check +//! +//! Tenant `T` wants to confirm its own figure was derived from the real total, +//! without being shown its co-tenants' consumption. The shares are committed to +//! a **Merkle sum tree**: every node carries a hash *and* the sum of its +//! subtree. `T` receives its own leaf and the sibling `(hash, sum)` pairs along +//! its path, recomputes the root, and checks that the root's sum plus the +//! residual equals the signed facility total. +//! +//! What that leaks is stated plainly rather than glossed: `T` learns the +//! **aggregate** of each sibling subtree along its path — with `n` tenants, +//! roughly `log2(n)` partial sums. It does not learn any individual co-tenant's +//! share or identity. Hiding the aggregates too needs a zero-knowledge range +//! proof, which this module does not attempt and does not pretend to. + +use blake3::Hasher; +use serde::{Deserialize, Serialize}; + +const DOMAIN_LEAF: &[u8] = b"eoc:alloc-leaf\x01"; +const DOMAIN_NODE: &[u8] = b"eoc:alloc-node\x01"; + +/// Errors an allocation can fail with. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum AllocationError { + /// Shares plus residual do not equal the facility total. + #[error( + "shares + residual = {got} µJ but the facility drew {total} µJ — an allocation that does not close is not an allocation" + )] + NotConserved { + /// What the parts actually summed to. + got: u128, + /// What the facility measured. + total: u64, + }, + /// Tenants were charged for more energy than the facility drew. + #[error("shares total {shares} µJ, more than the facility's {total} µJ")] + OverAllocated { + /// The summed tenant shares. + shares: u128, + /// What the facility measured. + total: u64, + }, + /// An allocation with no tenants allocates nothing. + #[error("no tenant shares supplied")] + NoShares, + /// The same tenant appears twice, which would charge it twice. + #[error("tenant {0:?} appears more than once")] + DuplicateTenant(String), + /// The accounting period runs backwards. + #[error("the period ends before it starts")] + BadPeriod, +} + +/// How the shares were derived. A disclosed choice, never a default: the same +/// facility split by measured energy, by core-hours, or by revenue produces +/// three different numbers for the same tenant, all defensible and none +/// comparable. An allocation that does not say which was used cannot be checked +/// against another. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Basis { + /// In proportion to each tenant's measured IT energy. + MeasuredEnergy, + /// In proportion to allocated compute time (core- or device-seconds). + ComputeSeconds, + /// In proportion to reserved capacity, whether or not it was used. + ReservedCapacity, + /// Something else, named by the operator. + Declared, +} + +impl Basis { + /// The wire label for this basis. + pub fn label(self) -> &'static str { + match self { + Basis::MeasuredEnergy => "measured_energy", + Basis::ComputeSeconds => "compute_seconds", + Basis::ReservedCapacity => "reserved_capacity", + Basis::Declared => "declared", + } + } +} + +/// The measured draw of a whole facility over one accounting period. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FacilityTotal { + /// Which facility this total is for. + pub facility_id: String, + /// Start of the accounting period (microseconds since the epoch). + pub period_start_us: u64, + /// End of the accounting period (microseconds since the epoch). + pub period_end_us: u64, + /// Measured facility-layer energy for the period, integer microjoules. + pub facility_uj: u64, +} + +/// One tenant's allocated share. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TenantShare { + /// The tenant this share is allocated to. + pub tenant: String, + /// The allocated energy, integer microjoules. + pub uj: u64, +} + +impl TenantShare { + fn leaf_hash(&self) -> [u8; 32] { + let mut h = Hasher::new(); + h.update(DOMAIN_LEAF); + h.update(&(self.tenant.len() as u64).to_be_bytes()); + h.update(self.tenant.as_bytes()); + h.update(&self.uj.to_be_bytes()); + *h.finalize().as_bytes() + } +} + +/// A node in the Merkle **sum** tree: a hash and the total of its subtree. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SumNode { + /// BLAKE3 over this subtree. + pub hash: [u8; 32], + /// Total microjoules committed beneath this node. + pub sum_uj: u64, +} + +fn combine(l: &SumNode, r: &SumNode) -> SumNode { + let mut h = Hasher::new(); + h.update(DOMAIN_NODE); + h.update(&l.hash); + h.update(&l.sum_uj.to_be_bytes()); + h.update(&r.hash); + h.update(&r.sum_uj.to_be_bytes()); + SumNode { + hash: *h.finalize().as_bytes(), + // Sums are bounded by the facility total, which the constructor has + // already checked, so this cannot overflow in a valid allocation. + sum_uj: l.sum_uj.saturating_add(r.sum_uj), + } +} + +/// A facility total divided among tenants, with the remainder named. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Allocation { + /// The measured facility draw being divided. + pub total: FacilityTotal, + /// How the shares were derived (§ disclosed choice). + pub basis: Basis, + shares: Vec, + /// Facility energy that no tenant caused: idle capacity, cooling headroom, + /// the control plane, failed work. REQUIRED — see the module docs. + pub residual_uj: u64, +} + +impl Allocation { + /// Build an allocation, enforcing conservation exactly. + /// + /// Rejects rather than clamps. An over-allocation means the model is wrong, + /// and silently trimming it would hide the only signal that says so. + pub fn new( + total: FacilityTotal, + basis: Basis, + shares: Vec, + residual_uj: u64, + ) -> Result { + if total.period_end_us < total.period_start_us { + return Err(AllocationError::BadPeriod); + } + if shares.is_empty() { + return Err(AllocationError::NoShares); + } + for (i, s) in shares.iter().enumerate() { + if shares[..i].iter().any(|o| o.tenant == s.tenant) { + return Err(AllocationError::DuplicateTenant(s.tenant.clone())); + } + } + // u128 so an overflowing set of shares is reported as over-allocation + // rather than wrapping into a total that appears to conserve. + let sum: u128 = shares.iter().map(|s| u128::from(s.uj)).sum(); + if sum > u128::from(total.facility_uj) { + return Err(AllocationError::OverAllocated { shares: sum, total: total.facility_uj }); + } + let got = sum + u128::from(residual_uj); + if got != u128::from(total.facility_uj) { + return Err(AllocationError::NotConserved { got, total: total.facility_uj }); + } + Ok(Allocation { total, basis, shares, residual_uj }) + } + + /// Allocate proportionally to a per-tenant weight, assigning every + /// microjoule the weights do not account for to the residual. + /// + /// Integer division loses microjoules; they go to the residual rather than + /// being spread by a rounding rule, because the residual is the honest home + /// for energy no tenant demonstrably caused. `allocatable_uj` is the portion + /// of the facility total the operator considers tenant-attributable at all — + /// the rest is residual by construction. + pub fn proportional( + total: FacilityTotal, + basis: Basis, + weights: &[(String, u64)], + allocatable_uj: u64, + ) -> Result { + if weights.is_empty() { + return Err(AllocationError::NoShares); + } + if allocatable_uj > total.facility_uj { + return Err(AllocationError::OverAllocated { + shares: u128::from(allocatable_uj), + total: total.facility_uj, + }); + } + let wsum: u128 = weights.iter().map(|(_, w)| u128::from(*w)).sum(); + let shares: Vec = if wsum == 0 { + // No weight anywhere: nothing is attributable, and saying so is + // better than inventing an even split. + weights.iter().map(|(t, _)| TenantShare { tenant: t.clone(), uj: 0 }).collect() + } else { + weights + .iter() + .map(|(t, w)| TenantShare { + tenant: t.clone(), + uj: ((u128::from(allocatable_uj) * u128::from(*w)) / wsum) as u64, + }) + .collect() + }; + let assigned: u128 = shares.iter().map(|s| u128::from(s.uj)).sum(); + let residual = u128::from(total.facility_uj) - assigned; + Allocation::new(total, basis, shares, residual as u64) + } + + /// The allocated shares, in the order supplied. + pub fn shares(&self) -> &[TenantShare] { + &self.shares + } + + /// The fraction of the facility nobody was charged for, in parts per + /// million. A high value is not a defect to be hidden — it is the operator + /// telling the truth about idle and overhead. + pub fn residual_ppm(&self) -> u64 { + if self.total.facility_uj == 0 { + return 0; + } + ((u128::from(self.residual_uj) * 1_000_000) / u128::from(self.total.facility_uj)) as u64 + } + + /// The Merkle sum tree's leaves, in the order shares were supplied. + fn leaves(&self) -> Vec { + self.shares + .iter() + .map(|s| SumNode { hash: s.leaf_hash(), sum_uj: s.uj }) + .collect() + } + + /// The root commitment over all shares. + pub fn root(&self) -> SumNode { + let mut level = self.leaves(); + while level.len() > 1 { + level = level + .chunks(2) + .map(|c| if c.len() == 2 { combine(&c[0], &c[1]) } else { c[0] }) + .collect(); + } + level[0] + } + + /// An inclusion proof for one tenant: their own share plus the sibling + /// `(hash, sum)` pairs along the path to the root. + pub fn proof_for(&self, tenant: &str) -> Option { + let idx = self.shares.iter().position(|s| s.tenant == tenant)?; + let mut level = self.leaves(); + let mut i = idx; + let mut path = Vec::new(); + while level.len() > 1 { + let sib = if i % 2 == 0 { i + 1 } else { i - 1 }; + if sib < level.len() { + path.push((i % 2 == 1, level[sib])); + } + level = level + .chunks(2) + .map(|c| if c.len() == 2 { combine(&c[0], &c[1]) } else { c[0] }) + .collect(); + i /= 2; + } + Some(ShareProof { + share: self.shares[idx].clone(), + path, + residual_uj: self.residual_uj, + facility_uj: self.total.facility_uj, + }) + } +} + +/// What one tenant is handed: enough to check their own figure closes against +/// the facility total, and no co-tenant's individual share. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ShareProof { + /// The tenant's own share. + pub share: TenantShare, + /// `(sibling_is_left, sibling)` from leaf to root. + pub path: Vec<(bool, SumNode)>, + /// The allocation's declared residual. + pub residual_uj: u64, + /// The facility total the whole allocation must close against. + pub facility_uj: u64, +} + +impl ShareProof { + /// Recompute the root from this tenant's own leaf, and check the whole + /// allocation closes. + /// + /// Passing establishes two things together: this share is committed in the + /// tree whose root the operator signed, **and** that tree's total plus the + /// residual is exactly the facility figure. Either alone is not enough — a + /// share can be genuinely included in a tree that does not add up. + pub fn verify(&self, signed_root: &SumNode) -> bool { + let mut node = SumNode { hash: self.share.leaf_hash(), sum_uj: self.share.uj }; + for (sibling_is_left, sib) in &self.path { + node = if *sibling_is_left { combine(sib, &node) } else { combine(&node, sib) }; + } + if node != *signed_root { + return false; + } + u128::from(node.sum_uj) + u128::from(self.residual_uj) == u128::from(self.facility_uj) + } + + /// How many co-tenant subtree aggregates this proof reveals. Stated so a + /// tenant knows what they learned, and an operator knows what they gave + /// away — roughly `log2(n)` partial sums, never an individual share. + pub fn aggregates_revealed(&self) -> usize { + self.path.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn total(uj: u64) -> FacilityTotal { + FacilityTotal { + facility_id: "dc-1".into(), + period_start_us: 0, + period_end_us: 3_600_000_000, + facility_uj: uj, + } + } + fn s(t: &str, uj: u64) -> TenantShare { + TenantShare { tenant: t.into(), uj } + } + + /// The invariant the whole module exists for, and the reason it is integer: + /// it holds exactly or the allocation does not exist. + #[test] + fn an_allocation_must_close_exactly() { + let a = Allocation::new( + total(1_000), + Basis::MeasuredEnergy, + vec![s("acme", 400), s("globex", 350)], + 250, + ) + .expect("400 + 350 + 250 = 1000"); + assert_eq!(a.residual_ppm(), 250_000); + + // One microjoule short: rejected, not rounded away. + assert!(matches!( + Allocation::new(total(1_000), Basis::MeasuredEnergy, vec![s("acme", 400)], 599), + Err(AllocationError::NotConserved { got: 999, total: 1_000 }) + )); + + // Charging tenants for more than the building drew is a broken model, + // and is reported as one rather than clamped. + assert!(matches!( + Allocation::new(total(1_000), Basis::MeasuredEnergy, vec![s("acme", 900), s("globex", 900)], 0), + Err(AllocationError::OverAllocated { .. }) + )); + } + + /// Overflowing shares must not wrap into a total that appears to conserve. + #[test] + fn overflowing_shares_are_over_allocation_not_a_wrap() { + let r = Allocation::new( + total(1_000), + Basis::MeasuredEnergy, + vec![s("a", u64::MAX), s("b", u64::MAX)], + 0, + ); + assert!(matches!(r, Err(AllocationError::OverAllocated { .. }))); + } + + /// Integer division loses microjoules. They go to the residual, because the + /// residual is the honest home for energy no tenant demonstrably caused — + /// and conservation still closes exactly. + #[test] + fn proportional_sends_its_rounding_loss_to_the_residual() { + // 1000 allocatable over weights 1:1:1 divides unevenly. + let a = Allocation::proportional( + total(1_000), + Basis::ComputeSeconds, + &[("a".into(), 1), ("b".into(), 1), ("c".into(), 1)], + 1_000, + ) + .unwrap(); + let sum: u64 = a.shares().iter().map(|x| x.uj).sum(); + assert_eq!(sum, 999, "333 each"); + assert_eq!(a.residual_uj, 1, "the lost microjoule is named, not spread"); + assert_eq!(sum + a.residual_uj, 1_000); + } + + /// Only part of a facility is tenant-attributable; the rest is residual by + /// construction rather than by omission. + #[test] + fn unallocatable_capacity_lands_in_the_residual() { + let a = Allocation::proportional( + total(10_000), + Basis::ReservedCapacity, + &[("a".into(), 3), ("b".into(), 1)], + 8_000, // 20% of the facility is not tenant-attributable at all + ) + .unwrap(); + assert_eq!(a.shares()[0].uj, 6_000); + assert_eq!(a.shares()[1].uj, 2_000); + assert_eq!(a.residual_uj, 2_000); + assert_eq!(a.residual_ppm(), 200_000); + } + + /// No weights anywhere means nothing is attributable. Saying so beats + /// inventing an even split that looks like a measurement. + #[test] + fn zero_weights_allocate_nothing() { + let a = Allocation::proportional( + total(500), + Basis::Declared, + &[("a".into(), 0), ("b".into(), 0)], + 500, + ) + .unwrap(); + assert!(a.shares().iter().all(|x| x.uj == 0)); + assert_eq!(a.residual_uj, 500); + } + + /// The point of the sum tree: a tenant checks its own figure closes against + /// the signed facility total, without being shown any co-tenant's share. + #[test] + fn a_tenant_verifies_its_own_share_without_seeing_the_others() { + let a = Allocation::new( + total(10_000), + Basis::MeasuredEnergy, + vec![s("acme", 4_000), s("globex", 3_000), s("initech", 2_000), s("umbrella", 500)], + 500, + ) + .unwrap(); + let root = a.root(); + assert_eq!(root.sum_uj, 9_500); + + for t in ["acme", "globex", "initech", "umbrella"] { + let p = a.proof_for(t).expect("tenant present"); + assert!(p.verify(&root), "{t} must verify"); + // 4 leaves -> 2 sibling aggregates on the path, never an individual + // co-tenant share. + assert_eq!(p.aggregates_revealed(), 2); + } + assert!(a.proof_for("nobody").is_none()); + } + + /// A share genuinely in the tree is still not enough: the tree must also add + /// up to the facility total. Both halves are checked together. + #[test] + fn inclusion_alone_does_not_satisfy_the_check() { + let a = Allocation::new( + total(1_000), + Basis::MeasuredEnergy, + vec![s("acme", 600), s("globex", 300)], + 100, + ) + .unwrap(); + let root = a.root(); + + // Real inclusion proof, but the operator misstates the facility total. + let mut p = a.proof_for("acme").unwrap(); + p.facility_uj = 2_000; + assert!(!p.verify(&root), "a tree that does not close must fail"); + + // Real total, but a tampered share no longer reconstructs the root. + let mut p = a.proof_for("acme").unwrap(); + p.share.uj = 900; + assert!(!p.verify(&root), "an altered share must not verify"); + + // Restating the residual breaks conservation too. + let mut p = a.proof_for("globex").unwrap(); + p.residual_uj = 0; + assert!(!p.verify(&root)); + } + + /// An odd tenant count must not break the tree. + #[test] + fn odd_leaf_counts_still_verify() { + for n in 1..=9u64 { + let shares: Vec = (0..n).map(|i| s(&format!("t{i}"), 100)).collect(); + let a = Allocation::new(total(100 * n + 7), Basis::MeasuredEnergy, shares, 7).unwrap(); + let root = a.root(); + assert_eq!(root.sum_uj, 100 * n); + for i in 0..n { + let p = a.proof_for(&format!("t{i}")).unwrap(); + assert!(p.verify(&root), "n={n} tenant {i}"); + } + } + } + + /// A tenant cannot be charged twice under two entries. + #[test] + fn duplicate_tenants_are_rejected() { + assert!(matches!( + Allocation::new(total(200), Basis::MeasuredEnergy, vec![s("acme", 100), s("acme", 100)], 0), + Err(AllocationError::DuplicateTenant(_)) + )); + } +} diff --git a/eoc/eoc-rs/crates/eoc-carbon/src/lib.rs b/eoc/eoc-rs/crates/eoc-carbon/src/lib.rs index 968783c9b..830995ed4 100644 --- a/eoc/eoc-rs/crates/eoc-carbon/src/lib.rs +++ b/eoc/eoc-rs/crates/eoc-carbon/src/lib.rs @@ -25,6 +25,7 @@ #![forbid(unsafe_code)] #![warn(missing_docs)] +pub mod allocation; pub mod account; pub mod co2signal; pub mod electricity_maps; -- 2.54.0 (Apple Git-157) From 44eae552a2926c5dd785f2baa7c9c5a27af1d0fa Mon Sep 17 00:00:00 2001 From: David Charlot Date: Thu, 27 Aug 2026 13:14:08 -0400 Subject: [PATCH 2/3] eoc: bind a published facility band to the allocation it came from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A band published on its own is unfalsifiable: a letter, a facility name, and nothing a reader can check. `FacilityRating` carries the commitment to the share set the rating aggregates, so the tenant proof from `Allocation::proof_for` verifies against **the same root the public rating cites**. That join is the point. A tenant can confirm its own consumption sits inside the very set that produced the published label, without seeing any co-tenant — which is the only thing that makes a facility label mean anything to the customers it was derived from. `admits()` checks the proof against the receipt's root *and* that the totals agree, so a proof that is internally valid against a different period is rejected rather than quietly accepted. **Assurance is derived, not stored.** The obvious design is a `verified: bool` or an assurance level written into the artifact, and that would repeat the failure the band itself had: a claim about evidence, asserted by the party under audit. So there is no assurance field. `assurance()` takes a `SignatureStatus` the caller must supply, and an attestation that nobody checked yields `AttestedUnverified` — never `Verified`. An unchecked signature is a string; it becomes evidence when someone checks it, and the type makes skipping that step impossible to do silently. A failed check reports `AttestationInvalid`, which is strictly worse than no attestation: someone tried to pass off a bad signature. This crate does no signature verification at all, and says so. There is no signing dependency in this workspace, and manufacturing a `Verified` state it cannot substantiate is exactly what the rest of the module refuses to do. `RatingRecord` is data, not a dependency on whichever crate produced the band: a receipt records *what* was rated, not *how*. It carries the scale id, metric, unit and measured value, so a reader re-derives the letter against the cited scale instead of trusting it — and a band whose scale is unstated is not comparable to any other band. The signed preimage covers every field that makes the rating a claim (band, value, scale, basis, residual, commitment), with length-prefixed fields so shifting a character across a boundary cannot produce the same bytes. Attaching an attestation does not change what it signs. Verified: 36 eoc-carbon tests pass (7 new), no clippy warnings in either new file. One test assertion was corrected rather than the code: with an odd leaf count a promoted node has no sibling at that level, so the aggregate count varies between tenants — the guarantee is the bound, not a fixed number. --- eoc/eoc-rs/crates/eoc-carbon/src/lib.rs | 1 + eoc/eoc-rs/crates/eoc-carbon/src/rating.rs | 414 +++++++++++++++++++++ 2 files changed, 415 insertions(+) create mode 100644 eoc/eoc-rs/crates/eoc-carbon/src/rating.rs diff --git a/eoc/eoc-rs/crates/eoc-carbon/src/lib.rs b/eoc/eoc-rs/crates/eoc-carbon/src/lib.rs index 830995ed4..ab1a31279 100644 --- a/eoc/eoc-rs/crates/eoc-carbon/src/lib.rs +++ b/eoc/eoc-rs/crates/eoc-carbon/src/lib.rs @@ -26,6 +26,7 @@ #![warn(missing_docs)] pub mod allocation; +pub mod rating; pub mod account; pub mod co2signal; pub mod electricity_maps; diff --git a/eoc/eoc-rs/crates/eoc-carbon/src/rating.rs b/eoc/eoc-rs/crates/eoc-carbon/src/rating.rs new file mode 100644 index 000000000..7f6d83f90 --- /dev/null +++ b/eoc/eoc-rs/crates/eoc-carbon/src/rating.rs @@ -0,0 +1,414 @@ +//! A facility rating receipt — a published band, bound to the allocation it +//! came from. +//! +//! # What this is for +//! +//! [`crate::allocation::Allocation`] divides a measured facility total among +//! tenants and commits the shares to a Merkle sum tree. A published rating — +//! the A–G band a facility advertises — is downstream of exactly that: it is a +//! claim about a number, derived from a set of workloads, over a period. +//! +//! Published on its own, a band is unfalsifiable. This receipt carries the +//! commitment to the share set the rating aggregates, so the tenant proof from +//! [`crate::allocation::Allocation::proof_for`] verifies against **the same +//! root the public rating cites**. A tenant can therefore confirm its own +//! consumption is inside the very set that produced the published label, +//! without seeing any co-tenant — which is the only thing that makes a facility +//! label mean anything to the customers it is derived from. +//! +//! # Assurance is not a field +//! +//! The temptation is a `verified: bool`, or an assurance level stored in the +//! artifact. That would be the same failure as the band itself: a claim about +//! evidence, asserted by the party under audit. +//! +//! So [`Assurance`] is **derived**, and derived from what the caller actually +//! checked. [`FacilityRating::assurance`] takes a [`SignatureStatus`] the +//! caller must supply, and the presence of an attestation on its own yields +//! [`Assurance::AttestedUnverified`] — never `Verified`. An unchecked signature +//! is a string; it becomes evidence only when someone checks it, and the type +//! makes that step impossible to skip silently. +//! +//! This crate deliberately performs no signature verification: there is no +//! signing dependency in this workspace, and inventing a `Verified` state it +//! cannot substantiate is precisely what the rest of this module refuses to do. + +use serde::{Deserialize, Serialize}; + +use crate::allocation::{Allocation, ShareProof, SumNode}; + +/// The rating itself, recorded as data. +/// +/// Deliberately not a dependency on whichever crate produced the band: a +/// receipt records *what was rated*, not *how*. Every field needed to re-derive +/// the letter travels with it, so a reader can check the band against the cited +/// scale instead of trusting the letter. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RatingRecord { + /// The scale that assigned the band, name and version. A band without this + /// is not comparable to any other band. + pub scale_id: String, + /// What was measured. + pub metric: String, + /// The unit of [`RatingRecord::value`]. + pub unit: String, + /// The measured value the band was derived from. + pub value: u64, + /// The band earned, as its wire label (`"A"`..`"G"`). + pub band: String, +} + +/// A verifier's attestation over a rating. Carried, never trusted on sight. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct VerifierAttestation { + /// Who attested. + pub verifier_id: String, + /// The signature algorithm, so a checker knows what it is checking. + pub alg: String, + /// The signature over [`FacilityRating::core_bytes`], hex. + pub sig_hex: String, +} + +/// What the caller actually did about the attestation's signature. +/// +/// Required rather than defaulted: every value here is a different claim, and +/// picking one silently on the caller's behalf is how "unchecked" becomes +/// "verified" in a report. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SignatureStatus { + /// Nobody checked it. + NotChecked, + /// Checked against the verifier's key, and it held. + Valid, + /// Checked, and it did not hold. + Invalid, +} + +/// How much weight a rating can bear. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Assurance { + /// No attestation at all — the operator's own word about its own facility. + SelfDeclared, + /// An attestation is present but nobody has checked the signature. This is + /// **not** a middle rung between self-declared and verified; it is + /// self-declared with a string attached, and is named separately only so a + /// reader can tell that a check is available and was not run. + AttestedUnverified { + /// Who claims to have attested. + verifier_id: String, + }, + /// An attestation was checked and held. + Verified { + /// Who attested. + verifier_id: String, + }, + /// An attestation was checked and failed. Strictly worse than none: someone + /// tried to pass off a bad signature. + AttestationInvalid { + /// Who the failed attestation named. + verifier_id: String, + }, +} + +/// A published facility rating, bound to the allocation behind it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FacilityRating { + /// Which facility. + pub facility_id: String, + /// Start of the rated period (microseconds since the epoch). + pub period_start_us: u64, + /// End of the rated period. + pub period_end_us: u64, + /// The measured facility draw the rating covers. + pub facility_uj: u64, + /// Energy attributed to no tenant. + pub residual_uj: u64, + /// How many tenants the allocation divided among. + pub tenant_count: u32, + /// The basis the shares were derived on, carried so the rating discloses it. + pub basis: String, + /// Commitment to the per-tenant share set — the same root a tenant's + /// [`ShareProof`] verifies against. + pub allocation_root: SumNode, + /// The band and everything needed to re-derive it. + pub rating: RatingRecord, + /// A verifier's attestation, if one was obtained. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attestation: Option, +} + +impl FacilityRating { + /// Build a rating receipt over an allocation. + /// + /// Every total is taken from the allocation rather than accepted from the + /// caller, so the published figures cannot drift from the set they commit + /// to. + pub fn from_allocation(alloc: &Allocation, rating: RatingRecord) -> Self { + FacilityRating { + facility_id: alloc.total.facility_id.clone(), + period_start_us: alloc.total.period_start_us, + period_end_us: alloc.total.period_end_us, + facility_uj: alloc.total.facility_uj, + residual_uj: alloc.residual_uj, + tenant_count: alloc.shares().len() as u32, + basis: alloc.basis.label().to_string(), + allocation_root: alloc.root(), + rating, + attestation: None, + } + } + + /// Attach a verifier's attestation. Attaching is not verifying — see + /// [`FacilityRating::assurance`]. + pub fn attested_by(mut self, attestation: VerifierAttestation) -> Self { + self.attestation = Some(attestation); + self + } + + /// The bytes a verifier signs: every field that makes the rating a claim. + /// + /// The attestation is excluded, since it signs this. Fields are + /// length-prefixed rather than concatenated, so two different receipts + /// cannot produce the same preimage by shifting bytes across a boundary. + pub fn core_bytes(&self) -> Vec { + let mut o = b"eoc:facility-rating\x01".to_vec(); + let field = |b: &[u8], out: &mut Vec| { + out.extend_from_slice(&(b.len() as u64).to_be_bytes()); + out.extend_from_slice(b); + }; + field(self.facility_id.as_bytes(), &mut o); + o.extend_from_slice(&self.period_start_us.to_be_bytes()); + o.extend_from_slice(&self.period_end_us.to_be_bytes()); + o.extend_from_slice(&self.facility_uj.to_be_bytes()); + o.extend_from_slice(&self.residual_uj.to_be_bytes()); + o.extend_from_slice(&self.tenant_count.to_be_bytes()); + field(self.basis.as_bytes(), &mut o); + o.extend_from_slice(&self.allocation_root.hash); + o.extend_from_slice(&self.allocation_root.sum_uj.to_be_bytes()); + field(self.rating.scale_id.as_bytes(), &mut o); + field(self.rating.metric.as_bytes(), &mut o); + field(self.rating.unit.as_bytes(), &mut o); + o.extend_from_slice(&self.rating.value.to_be_bytes()); + field(self.rating.band.as_bytes(), &mut o); + o + } + + /// Derive how much weight this rating can bear, from what was actually + /// checked. + /// + /// There is no `assurance` field to read: the level is a function of the + /// evidence present *and* the check the caller performed. A caller that has + /// not checked passes [`SignatureStatus::NotChecked`] and gets + /// [`Assurance::AttestedUnverified`], which is the truth about what it + /// knows. + pub fn assurance(&self, sig: SignatureStatus) -> Assurance { + match (&self.attestation, sig) { + (None, _) => Assurance::SelfDeclared, + (Some(a), SignatureStatus::NotChecked) => { + Assurance::AttestedUnverified { verifier_id: a.verifier_id.clone() } + } + (Some(a), SignatureStatus::Valid) => { + Assurance::Verified { verifier_id: a.verifier_id.clone() } + } + (Some(a), SignatureStatus::Invalid) => { + Assurance::AttestationInvalid { verifier_id: a.verifier_id.clone() } + } + } + } + + /// Re-check that the receipt's own figures close: committed shares plus + /// residual equal the facility total. + /// + /// The allocation enforced this at construction, but a receipt travels + /// separately and its fields are public, so a reader checks rather than + /// assumes. + pub fn conserves(&self) -> bool { + u128::from(self.allocation_root.sum_uj) + u128::from(self.residual_uj) + == u128::from(self.facility_uj) + } + + /// Whether a tenant's proof belongs to the set this rating was derived from. + /// + /// This is the join that makes a published band answerable to the customers + /// underneath it: the tenant checks its own share against the very root the + /// public rating cites. + pub fn admits(&self, proof: &ShareProof) -> bool { + proof.facility_uj == self.facility_uj + && proof.residual_uj == self.residual_uj + && proof.verify(&self.allocation_root) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::allocation::{Basis, FacilityTotal, TenantShare}; + + fn alloc() -> Allocation { + Allocation::new( + FacilityTotal { + facility_id: "dc-1".into(), + period_start_us: 0, + period_end_us: 3_600_000_000, + facility_uj: 10_000, + }, + Basis::MeasuredEnergy, + vec![ + TenantShare { tenant: "acme".into(), uj: 4_000 }, + TenantShare { tenant: "globex".into(), uj: 3_000 }, + TenantShare { tenant: "initech".into(), uj: 2_000 }, + ], + 1_000, + ) + .unwrap() + } + + fn record() -> RatingRecord { + RatingRecord { + scale_id: "example.pue/1".into(), + metric: "pue".into(), + unit: "milli_pue".into(), + value: 1_180, + band: "B".into(), + } + } + + fn att() -> VerifierAttestation { + VerifierAttestation { + verifier_id: "did:example:auditor".into(), + alg: "ed25519".into(), + sig_hex: "00".repeat(64), + } + } + + /// The join the whole module exists for: a tenant confirms its own share is + /// inside the very set that produced the published band, without seeing any + /// co-tenant. + #[test] + fn a_tenant_can_place_itself_inside_the_published_rating() { + let a = alloc(); + let r = FacilityRating::from_allocation(&a, record()); + assert!(r.conserves()); + + for t in ["acme", "globex", "initech"] { + let p = a.proof_for(t).unwrap(); + assert!(r.admits(&p), "{t} must be admitted by the published rating"); + // Only sibling aggregates are learned, never an individual share, + // and never more than the tree is deep. With an odd leaf count a + // promoted node has no sibling at that level, so the count varies + // between tenants — the guarantee is the bound, not a fixed number. + assert!( + p.aggregates_revealed() <= 2, + "{t} revealed {} aggregates, more than ceil(log2(3))", + p.aggregates_revealed() + ); + } + } + + /// A proof from a different facility period must not be admitted, even + /// though it is internally valid. + #[test] + fn a_valid_proof_from_another_allocation_is_not_admitted() { + let r = FacilityRating::from_allocation(&alloc(), record()); + + let other = Allocation::new( + FacilityTotal { + facility_id: "dc-1".into(), + period_start_us: 3_600_000_000, + period_end_us: 7_200_000_000, + facility_uj: 10_000, + }, + Basis::MeasuredEnergy, + vec![TenantShare { tenant: "acme".into(), uj: 9_000 }], + 1_000, + ) + .unwrap(); + let p = other.proof_for("acme").unwrap(); + assert!(p.verify(&other.root()), "the proof is valid on its own allocation"); + assert!(!r.admits(&p), "but it does not belong to this rating"); + } + + /// Assurance is derived from what was checked, never from a stored field. + /// Presence of a signature is not verification. + #[test] + fn assurance_reflects_the_check_that_was_actually_run() { + let plain = FacilityRating::from_allocation(&alloc(), record()); + // No attestation: the operator's own word, whatever the caller claims. + assert_eq!(plain.assurance(SignatureStatus::NotChecked), Assurance::SelfDeclared); + assert_eq!(plain.assurance(SignatureStatus::Valid), Assurance::SelfDeclared); + + let signed = FacilityRating::from_allocation(&alloc(), record()).attested_by(att()); + // The dangerous default, named honestly rather than promoted. + assert_eq!( + signed.assurance(SignatureStatus::NotChecked), + Assurance::AttestedUnverified { verifier_id: "did:example:auditor".into() } + ); + assert_eq!( + signed.assurance(SignatureStatus::Valid), + Assurance::Verified { verifier_id: "did:example:auditor".into() } + ); + // A failed check is strictly worse than no attestation at all. + assert_eq!( + signed.assurance(SignatureStatus::Invalid), + Assurance::AttestationInvalid { verifier_id: "did:example:auditor".into() } + ); + } + + /// Every field that makes the rating a claim is inside the signed preimage, + /// so a verifier's signature cannot be carried over to a different claim. + #[test] + fn the_signed_core_covers_every_load_bearing_field() { + let base = FacilityRating::from_allocation(&alloc(), record()); + let core = base.core_bytes(); + + let mut t = base.clone(); + t.rating.band = "A".into(); + assert_ne!(core, t.core_bytes(), "the band must be signed"); + + let mut t = base.clone(); + t.rating.value = 1_179; + assert_ne!(core, t.core_bytes(), "the measured value must be signed"); + + let mut t = base.clone(); + t.rating.scale_id = "example.pue/2".into(); + assert_ne!(core, t.core_bytes(), "the scale must be signed"); + + let mut t = base.clone(); + t.residual_uj += 1; + assert_ne!(core, t.core_bytes(), "the residual must be signed"); + + let mut t = base.clone(); + t.allocation_root.sum_uj += 1; + assert_ne!(core, t.core_bytes(), "the commitment must be signed"); + + let mut t = base.clone(); + t.basis = "reserved_capacity".into(); + assert_ne!(core, t.core_bytes(), "the basis must be signed"); + + // Attaching an attestation does not change what it signs. + assert_eq!(core, base.clone().attested_by(att()).core_bytes()); + } + + /// Length-prefixing makes the preimage injective: shifting a character + /// across a field boundary must not produce the same bytes. + #[test] + fn the_preimage_is_injective_across_field_boundaries() { + let mut a = FacilityRating::from_allocation(&alloc(), record()); + a.facility_id = "dc".into(); + a.basis = "1measured_energy".into(); + let mut b = FacilityRating::from_allocation(&alloc(), record()); + b.facility_id = "dc1".into(); + b.basis = "measured_energy".into(); + assert_ne!(a.core_bytes(), b.core_bytes()); + } + + /// A receipt travels separately from the allocation, so a reader re-checks + /// conservation rather than assuming it. + #[test] + fn a_receipt_that_does_not_close_is_detectable() { + let mut r = FacilityRating::from_allocation(&alloc(), record()); + assert!(r.conserves()); + r.residual_uj = 999; + assert!(!r.conserves(), "9_000 + 999 != 10_000"); + } +} -- 2.54.0 (Apple Git-157) From ad49173ff8bd1b37c8c6071d79cceb8cd32904b1 Mon Sep 17 00:00:00 2001 From: David Charlot Date: Thu, 27 Aug 2026 13:32:56 -0400 Subject: [PATCH 3/3] eoc: pin rand off 0.10.2, which does not compile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rand 0.10.2` fails to build: its own `src/rng.rs:168` contains `use rand::RngExt;` — a crate referring to itself by name rather than `crate::`. That is an upstream defect in a published release, not a configuration problem, and it reaches this workspace through `eoc-route-learned`, `eoc-spec-decode` and `rand_distr`. Pinned to 0.10.1, which builds. The lock is pinned rather than the manifests: the constraint is "not this exact release", not a new minimum, and a manifest pin would outlive the broken version and quietly hold the workspace back once upstream fixes it. Verified pre-existing rather than assumed: 0.10.2 is in the lock at `origin/main` and this branch's other commits touch zero `rand` lines. The `eoc-tools` reqwest `query` feature — the other half of what kept `cargo check --workspace` from passing — landed on main independently while this branch was in flight, with the same fix and the same reasoning. That commit is taken as-is here rather than duplicated. With both in place the entire eoc-rs workspace checks clean, which is what makes the allocation and rating work in the preceding commits verifiable in place rather than only crate-locally. --- eoc/eoc-rs/Cargo.lock | 14 +++++++------- eoc/eoc-rs/crates/eoc-tools/Cargo.toml | 5 ++++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/eoc/eoc-rs/Cargo.lock b/eoc/eoc-rs/Cargo.lock index 0e94a0cd7..5232af78d 100644 --- a/eoc/eoc-rs/Cargo.lock +++ b/eoc/eoc-rs/Cargo.lock @@ -1012,7 +1012,7 @@ dependencies = [ "eoc-kv", "eoc-neural", "nalgebra", - "rand 0.10.2", + "rand 0.10.1", "rand_distr", "serde", "serde_json", @@ -1044,7 +1044,7 @@ dependencies = [ "eoc-meter", "eoc-neural", "proptest", - "rand 0.10.2", + "rand 0.10.1", "serde", "serde_json", "thiserror 2.0.20", @@ -2665,7 +2665,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.2", + "rand 0.10.1", "rand_pcg", "ring", "rustc-hash 2.1.3", @@ -2725,9 +2725,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.2" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -2766,7 +2766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8" dependencies = [ "num-traits", - "rand 0.10.2", + "rand 0.10.1", ] [[package]] @@ -3460,7 +3460,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", diff --git a/eoc/eoc-rs/crates/eoc-tools/Cargo.toml b/eoc/eoc-rs/crates/eoc-tools/Cargo.toml index cc4f0a039..cea50f82b 100644 --- a/eoc/eoc-rs/crates/eoc-tools/Cargo.toml +++ b/eoc/eoc-rs/crates/eoc-tools/Cargo.toml @@ -20,7 +20,10 @@ jsonschema = { version = "0.46", default-features = false } tokio = { workspace = true, features = ["macros", "rt", "sync", "fs", "process", "io-util", "time"] } async-trait.workspace = true futures = "0.3" -# reqwest 0.13 moved RequestBuilder::query behind the "query" feature. +# `query` is a feature in reqwest 0.13 (it was unconditional in 0.12), and this +# crate builds four search adapters with `.query(..)`. With +# `default-features = false` the method is simply not compiled, so the crate has +# not built since the 0.13 bump. reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "query"] } thiserror.workspace = true tracing = "0.1" -- 2.54.0 (Apple Git-157)