diff --git a/eoc/eoc-rs/Cargo.lock b/eoc/eoc-rs/Cargo.lock index 0e1e2fd8c..5232af78d 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", @@ -1011,7 +1012,7 @@ dependencies = [ "eoc-kv", "eoc-neural", "nalgebra", - "rand 0.10.2", + "rand 0.10.1", "rand_distr", "serde", "serde_json", @@ -1043,7 +1044,7 @@ dependencies = [ "eoc-meter", "eoc-neural", "proptest", - "rand 0.10.2", + "rand 0.10.1", "serde", "serde_json", "thiserror 2.0.20", @@ -2664,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", @@ -2724,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", @@ -2765,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]] @@ -3459,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-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..ab1a31279 100644 --- a/eoc/eoc-rs/crates/eoc-carbon/src/lib.rs +++ b/eoc/eoc-rs/crates/eoc-carbon/src/lib.rs @@ -25,6 +25,8 @@ #![forbid(unsafe_code)] #![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"); + } +} 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"