eoc: allocate a facility among tenants, then bind the published band to it #63
6 changed files with 980 additions and 8 deletions
15
eoc/eoc-rs/Cargo.lock
generated
15
eoc/eoc-rs/Cargo.lock
generated
|
|
@ -766,6 +766,7 @@ name = "eoc-carbon"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"blake3",
|
||||||
"chrono",
|
"chrono",
|
||||||
"eoc-core",
|
"eoc-core",
|
||||||
"eoc-eval",
|
"eoc-eval",
|
||||||
|
|
@ -1011,7 +1012,7 @@ dependencies = [
|
||||||
"eoc-kv",
|
"eoc-kv",
|
||||||
"eoc-neural",
|
"eoc-neural",
|
||||||
"nalgebra",
|
"nalgebra",
|
||||||
"rand 0.10.2",
|
"rand 0.10.1",
|
||||||
"rand_distr",
|
"rand_distr",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
@ -1043,7 +1044,7 @@ dependencies = [
|
||||||
"eoc-meter",
|
"eoc-meter",
|
||||||
"eoc-neural",
|
"eoc-neural",
|
||||||
"proptest",
|
"proptest",
|
||||||
"rand 0.10.2",
|
"rand 0.10.1",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
|
|
@ -2664,7 +2665,7 @@ dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"lru-slab",
|
"lru-slab",
|
||||||
"rand 0.10.2",
|
"rand 0.10.1",
|
||||||
"rand_pcg",
|
"rand_pcg",
|
||||||
"ring",
|
"ring",
|
||||||
"rustc-hash 2.1.3",
|
"rustc-hash 2.1.3",
|
||||||
|
|
@ -2724,9 +2725,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.10.2"
|
version = "0.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chacha20",
|
"chacha20",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
|
|
@ -2765,7 +2766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8"
|
checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rand 0.10.2",
|
"rand 0.10.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3459,7 +3460,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.3.4",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ eval-bridge = ["dep:eoc-eval"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
eoc-core.workspace = true
|
eoc-core.workspace = true
|
||||||
|
blake3.workspace = true
|
||||||
eoc-meter.workspace = true
|
eoc-meter.workspace = true
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
|
||||||
551
eoc/eoc-rs/crates/eoc-carbon/src/allocation.rs
Normal file
551
eoc/eoc-rs/crates/eoc-carbon/src/allocation.rs
Normal file
|
|
@ -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<TenantShare>,
|
||||||
|
/// 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<TenantShare>,
|
||||||
|
residual_uj: u64,
|
||||||
|
) -> Result<Self, AllocationError> {
|
||||||
|
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<Self, AllocationError> {
|
||||||
|
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<TenantShare> = 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<SumNode> {
|
||||||
|
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<ShareProof> {
|
||||||
|
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<TenantShare> = (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(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,8 @@
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
pub mod allocation;
|
||||||
|
pub mod rating;
|
||||||
pub mod account;
|
pub mod account;
|
||||||
pub mod co2signal;
|
pub mod co2signal;
|
||||||
pub mod electricity_maps;
|
pub mod electricity_maps;
|
||||||
|
|
|
||||||
414
eoc/eoc-rs/crates/eoc-carbon/src/rating.rs
Normal file
414
eoc/eoc-rs/crates/eoc-carbon/src/rating.rs
Normal file
|
|
@ -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<VerifierAttestation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<u8> {
|
||||||
|
let mut o = b"eoc:facility-rating\x01".to_vec();
|
||||||
|
let field = |b: &[u8], out: &mut Vec<u8>| {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,7 +20,10 @@ jsonschema = { version = "0.46", default-features = false }
|
||||||
tokio = { workspace = true, features = ["macros", "rt", "sync", "fs", "process", "io-util", "time"] }
|
tokio = { workspace = true, features = ["macros", "rt", "sync", "fs", "process", "io-util", "time"] }
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
futures = "0.3"
|
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"] }
|
reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "query"] }
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue