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"
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
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)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod allocation;
|
||||
pub mod rating;
|
||||
pub mod account;
|
||||
pub mod co2signal;
|
||||
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"] }
|
||||
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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue