compliance: derive the A-G band instead of accepting one #64

Merged
dcharlot merged 1 commit from compliance/banding-function into main 2026-08-29 03:09:24 -04:00
2 changed files with 318 additions and 0 deletions

View file

@ -0,0 +1,317 @@
//! Turning a measurement into a rating — and carrying the scale that did it.
//!
//! # The gap
//!
//! [`crate::passport::EnergyLabel`] is an A–G enum with an ordering and a
//! description, and nothing in this workspace ever *derives* one: every use
//! supplies a letter the caller had already decided. The label is a container
//! for someone else's judgement, which is exactly the shape a rating must not
//! have. Meanwhile the one threshold rule that does exist,
//! `EuComplianceReport::is_compliant`, is a hardcoded `pue < 1.5 &&
//! renewable_pct >= 50.0` — two magic floats with no stated provenance, no
//! version, and no way to band anything.
//!
//! # A letter is not a rating
//!
//! The central failure this module exists to prevent: **a band means nothing
//! without the scale that produced it.** Two facilities both rated "B" under
//! different threshold sets are not comparable, and nothing about the letter
//! says so. A rating is therefore always `(label, scale, measured value)`
//! together — [`Rating`] cannot be constructed without its scale — and
//! [`Rating::comparable_with`] is an explicit check rather than an assumption.
//!
//! # Scales are data
//!
//! [`BandingScale`] is a value, not a `match` arm. A new scale, or a revision of
//! an existing one, is a different `scale_id` rather than a recompile — and
//! because the id travels inside every rating, a revision cannot silently
//! re-band history. Thresholds are integers in a declared unit (milli-PUE
//! rather than `1.5`), matching the integer discipline of the receipt family:
//! floating-point boundaries make band edges depend on how a value was parsed.
use serde::{Deserialize, Serialize};
use crate::passport::EnergyLabel;
/// Which way "better" runs on a scale.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Direction {
/// Smaller values earn better bands — PUE, µJ per unit of work, WUE.
LowerIsBetter,
/// Larger values earn better bands — renewable share, utilisation.
HigherIsBetter,
}
/// Errors from defining or applying a scale.
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum BandingError {
/// Boundaries do not run monotonically in the declared direction.
#[error(
"threshold {index} ({value}) is not monotonic for {direction:?} — a scale whose bands overlap cannot rank anything"
)]
NonMonotonic {
/// Which boundary broke the ordering.
index: usize,
/// Its value.
value: u64,
/// The direction it was supposed to follow.
direction: Direction,
},
/// A scale with no identity cannot be cited by a rating.
#[error("a scale must carry a non-empty scale_id")]
UnnamedScale,
/// The unit is what makes the thresholds mean anything.
#[error("a scale must declare the unit its thresholds are in")]
UnitlessScale,
}
/// A banding scale: six boundaries dividing a measured quantity into A–G.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BandingScale {
/// Name and version of this scale. Travels inside every rating it produces,
/// so a later revision is a different id and cannot re-band earlier
/// ratings.
pub scale_id: String,
/// What is being banded (e.g. `"pue"`, `"uj_per_kilopixel"`).
pub metric: String,
/// The unit the thresholds are expressed in (e.g. `"milli_pue"`). Integer
/// units are deliberate: a float boundary makes the band depend on how the
/// value was parsed.
pub unit: String,
/// Which way better runs.
pub direction: Direction,
/// The six boundaries, in order: A|B, B|C, C|D, D|E, E|F, F|G.
///
/// A value exactly on a boundary earns the **better** band, so the
/// boundary is the last value that still qualifies for the band above it.
pub thresholds: [u64; 6],
}
const LADDER: [EnergyLabel; 7] = [
EnergyLabel::A,
EnergyLabel::B,
EnergyLabel::C,
EnergyLabel::D,
EnergyLabel::E,
EnergyLabel::F,
EnergyLabel::G,
];
impl BandingScale {
/// Check the scale can rank anything.
///
/// Overlapping or unordered boundaries are rejected rather than tolerated:
/// a scale whose bands are not monotonic will assign different letters to
/// the same value depending on evaluation order, which is worse than having
/// no scale at all.
pub fn validate(&self) -> Result<(), BandingError> {
if self.scale_id.trim().is_empty() {
return Err(BandingError::UnnamedScale);
}
if self.unit.trim().is_empty() {
return Err(BandingError::UnitlessScale);
}
for i in 1..self.thresholds.len() {
let ok = match self.direction {
Direction::LowerIsBetter => self.thresholds[i] > self.thresholds[i - 1],
Direction::HigherIsBetter => self.thresholds[i] < self.thresholds[i - 1],
};
if !ok {
return Err(BandingError::NonMonotonic {
index: i,
value: self.thresholds[i],
direction: self.direction,
});
}
}
Ok(())
}
/// Band a measured value, producing a rating that carries this scale.
///
/// Returns an error rather than a letter for an invalid scale: emitting a
/// band from a scale that cannot rank is the failure mode this module is
/// for.
pub fn rate(&self, value: u64) -> Result<Rating, BandingError> {
self.validate()?;
let idx = match self.direction {
Direction::LowerIsBetter => self.thresholds.iter().position(|t| value <= *t),
Direction::HigherIsBetter => self.thresholds.iter().position(|t| value >= *t),
};
// No boundary claimed it, so it falls past the last one into G.
let label = LADDER[idx.unwrap_or(6)];
Ok(Rating {
label,
scale_id: self.scale_id.clone(),
metric: self.metric.clone(),
unit: self.unit.clone(),
value,
})
}
}
/// A band, inseparable from the scale and measurement that produced it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Rating {
/// The band earned.
pub label: EnergyLabel,
/// The scale that assigned it.
pub scale_id: String,
/// What was measured.
pub metric: String,
/// The unit of [`Rating::value`].
pub unit: String,
/// The measured value the band came from, kept so a reader can re-derive
/// the letter instead of trusting it.
pub value: u64,
}
impl Rating {
/// Whether two ratings can be meaningfully compared.
///
/// Only ratings from the same scale, metric and unit rank against each
/// other. Two "B"s from different scales are two different claims that
/// happen to share a letter, and comparing them is the specific error a
/// bare A–G enum invites.
pub fn comparable_with(&self, other: &Rating) -> bool {
self.scale_id == other.scale_id && self.metric == other.metric && self.unit == other.unit
}
/// Rank against another rating on the same scale.
///
/// `None` when they are not comparable — deliberately not `Ordering::Equal`,
/// which would let incomparable ratings silently tie.
pub fn better_than(&self, other: &Rating) -> Option<bool> {
self.comparable_with(other).then(|| self.label < other.label)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// PUE in milli-units: 1.15 -> 1150. Integer boundaries, no float edges.
fn pue_scale() -> BandingScale {
BandingScale {
scale_id: "example.pue/1".into(),
metric: "pue".into(),
unit: "milli_pue".into(),
direction: Direction::LowerIsBetter,
thresholds: [1_150, 1_250, 1_350, 1_500, 1_700, 2_000],
}
}
fn renewable_scale() -> BandingScale {
BandingScale {
scale_id: "example.renewable/1".into(),
metric: "renewable_share".into(),
unit: "ppm".into(),
direction: Direction::HigherIsBetter,
thresholds: [950_000, 800_000, 600_000, 400_000, 200_000, 50_000],
}
}
/// The function that did not exist: measurement in, band out.
#[test]
fn a_measurement_becomes_a_band() {
let s = pue_scale();
for (milli_pue, want) in [
(1_050, EnergyLabel::A),
(1_150, EnergyLabel::A), // exactly on the boundary earns the better band
(1_151, EnergyLabel::B),
(1_400, EnergyLabel::D), // in (1_350, 1_500] — the D band
(1_500, EnergyLabel::D), // the boundary still earns D
(1_501, EnergyLabel::E),
(2_000, EnergyLabel::F),
(2_001, EnergyLabel::G), // past the last boundary
(9_999, EnergyLabel::G),
] {
assert_eq!(s.rate(milli_pue).unwrap().label, want, "milli_pue {milli_pue}");
}
}
/// Higher-is-better runs the other way, with the same boundary rule.
#[test]
fn direction_is_honoured() {
let s = renewable_scale();
assert_eq!(s.rate(1_000_000).unwrap().label, EnergyLabel::A);
assert_eq!(s.rate(950_000).unwrap().label, EnergyLabel::A);
assert_eq!(s.rate(949_999).unwrap().label, EnergyLabel::B);
assert_eq!(s.rate(0).unwrap().label, EnergyLabel::G);
}
/// The whole point: a letter without its scale is not a rating, and two
/// letters from different scales are two different claims that happen to
/// share a character.
#[test]
fn ratings_from_different_scales_do_not_compare() {
let a = pue_scale().rate(1_100).unwrap();
let b = renewable_scale().rate(1_000_000).unwrap();
assert_eq!(a.label, EnergyLabel::A);
assert_eq!(b.label, EnergyLabel::A);
assert!(!a.comparable_with(&b), "different metrics must not compare");
// Not `Some(false)` — refusing to rank is different from ranking equal.
assert_eq!(a.better_than(&b), None);
// Same scale: ranking works and follows A < G.
let worse = pue_scale().rate(1_600).unwrap();
assert!(a.comparable_with(&worse));
assert_eq!(a.better_than(&worse), Some(true));
assert_eq!(worse.better_than(&a), Some(false));
}
/// A revised scale is a different id, so it cannot silently re-band history.
#[test]
fn a_revised_scale_does_not_re_band_earlier_ratings() {
let old = pue_scale().rate(1_300).unwrap();
let mut v2 = pue_scale();
v2.scale_id = "example.pue/2".into();
v2.thresholds = [1_100, 1_200, 1_280, 1_400, 1_600, 1_900];
let new = v2.rate(1_300).unwrap();
assert_eq!(old.label, EnergyLabel::C);
assert_eq!(new.label, EnergyLabel::D, "the same value bands differently under v2");
assert!(!old.comparable_with(&new), "a revision is not the same scale");
}
/// A scale whose bands overlap would assign different letters to one value
/// depending on evaluation order. Rejected, not tolerated.
#[test]
fn a_non_monotonic_scale_cannot_rate() {
let mut s = pue_scale();
s.thresholds = [1_150, 1_250, 1_100, 1_500, 1_700, 2_000];
assert!(matches!(s.validate(), Err(BandingError::NonMonotonic { index: 2, .. })));
// and `rate` refuses rather than emitting a band from a broken scale
assert!(s.rate(1_200).is_err());
let mut d = renewable_scale();
d.direction = Direction::LowerIsBetter; // thresholds now descend the wrong way
assert!(d.validate().is_err());
}
/// A scale must be citable and its numbers must mean something.
#[test]
fn a_scale_needs_an_identity_and_a_unit() {
let mut s = pue_scale();
s.scale_id = " ".into();
assert_eq!(s.validate(), Err(BandingError::UnnamedScale));
let mut s = pue_scale();
s.unit = String::new();
assert_eq!(s.validate(), Err(BandingError::UnitlessScale));
}
/// The measured value rides along, so a reader re-derives the letter rather
/// than trusting it.
#[test]
fn a_rating_can_be_re_derived_from_what_it_carries() {
let s = pue_scale();
let r = s.rate(1_337).unwrap();
assert_eq!(r.value, 1_337);
assert_eq!(r.scale_id, "example.pue/1");
assert_eq!(r.unit, "milli_pue");
// Re-banding the carried value under the cited scale reproduces it.
assert_eq!(s.rate(r.value).unwrap().label, r.label);
}
}

View file

@ -25,6 +25,7 @@
#![warn(missing_docs)]
pub mod sci;
pub mod banding;
pub mod passport;
pub mod eu_compliance;