From 078aeb3c2a8f09b58d06b870de3d1961bbc14372 Mon Sep 17 00:00:00 2001 From: David Charlot Date: Thu, 27 Aug 2026 10:41:37 -0400 Subject: [PATCH] compliance: derive the A-G band instead of accepting one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `EnergyLabel` is an A–G enum with an ordering and a description, and nothing in this workspace ever *derived* one — every use supplies a letter the caller had already decided (`build_sample("svc-1", 0.35, EnergyLabel::A)`). The label was a container for someone else's judgement, which is the one shape a rating must not have. The only threshold rule that did exist, `EuComplianceReport::is_compliant`, is a hardcoded `pue < 1.5 && renewable_pct >= 50.0`: two magic floats with no provenance, no version, and no way to band anything. `BandingScale::rate` is the missing function. What matters is less the arithmetic than what it refuses to let a caller do. **A letter is not a rating.** Two facilities both rated "B" under different threshold sets are not comparable, and nothing about the letter says so. A `Rating` therefore cannot be constructed without its scale: it carries the `scale_id`, the metric, the unit, and the measured value, so a reader re-derives the letter rather than trusting it. `comparable_with` is an explicit check, and `better_than` returns `None` for incomparable ratings rather than `Ordering::Equal` — refusing to rank is a different answer from ranking equal, and collapsing them is exactly how a bare enum invites the mistake. **A revision is a different scale.** Because the `scale_id` travels inside every rating, re-tuning thresholds cannot silently re-band history — the test pins a value that is C under v1 and D under v2, and shows the two ratings do not compare. **Scales are data, not match arms.** A new scale is a value, not a recompile. **Integers, in a declared unit.** Thresholds are `u64` in a unit the scale names (milli-PUE rather than `1.5`), matching the receipt family's discipline: a float boundary makes the band depend on how the value was parsed, and band edges are precisely where that bites. **Boundaries earn the better band**, stated once and tested at every edge, so "on the line" is not left to the reader. **A non-monotonic scale cannot rate at all.** Overlapping boundaries would assign different letters to one value depending on evaluation order, so `validate` rejects them and `rate` refuses rather than emitting a band from a scale that cannot rank. A scale must also carry an id and a unit, since a rating that cannot cite its scale is back to being a bare letter. No scale is shipped as canonical. The examples in the tests are named `example.*` on purpose: which thresholds are correct for a given metric is a policy question this crate has no standing to settle, and baking one in would repeat the `pue < 1.5` mistake with more ceremony. Verified: 43 crate tests pass (7 new), no clippy warnings in the new file. --- .../jouleclaw-compliance/src/banding.rs | 317 ++++++++++++++++++ .../crates/jouleclaw-compliance/src/lib.rs | 1 + 2 files changed, 318 insertions(+) create mode 100644 jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/banding.rs diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/banding.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/banding.rs new file mode 100644 index 000000000..4d8f228ef --- /dev/null +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/banding.rs @@ -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 { + 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 { + 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); + } +} diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/lib.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/lib.rs index 1b687ec7c..02c25d0e7 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/lib.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/lib.rs @@ -25,6 +25,7 @@ #![warn(missing_docs)] pub mod sci; +pub mod banding; pub mod passport; pub mod eu_compliance; -- 2.54.0 (Apple Git-157)