0db9b8belisted three things here as not done. `CarbonAccount` dropped provenance, and `account_request` is a public way in. A report with zero correct answers carries `joules_per_correct == u64::MAX` as a sentinel, which `sci_from_eval_report` multiplied by `correct.max(1)` into a u64::MAX µJ energy term (18.4 TJ). And the test that pins the bridge's provenance sat behind the `eval-bridge` feature, which nothing in the workspace enables, so `cargo test --workspace` never compiled it, nor any mutation it catches. What holds now: - `CarbonAccount` gains `it_energy_source`, the input `JouleCost`'s provenance unchanged, and `facility_energy_source`. The facility joules are `it_joules` × PUE, so they are measured only when every part of them was: the IT joules were measured, and the PUE added no overhead. A PUE above 1 is a published or assumed facility ratio applied to the reading, energy no counter on the host read; a figure containing any of it is an estimate. A PUE of 1 adds nothing, so measured IT joules stay measured, as an estimated zero adds nothing to a live span in eoc-cascade. Grid intensity has its own provenance (the reading's provider and kind) and does not enter the tag. Both fields read back as `Estimated` when absent. Nothing outside this crate builds a `CarbonAccount`. - `SciScore::energy_source` is now the account's facility provenance, because the score's energy term is facility energy. Before, it was the input's provenance, so measured IT joules at the default PUE of 1.58 gave a "measured" term of which 37% no counter read. This changes what0db9b8beset: an SCI score at PUE > 1 is now estimated even from measured joules. The SCI numbers do not change. - The bridge's pure part is compiled in every build. `EvalEnergy` (micro-joules per correct answer, correct answers, provenance) and `sci_per_correct_answer` hold all the scoring, including the `JouleCost` construction 0db9b8be's mutation targeted. `sci_from_eval_report`, still behind `eval-bridge`, only copies the report's three fields across. - A run with no correct answer has no score per correct answer: `sci_per_correct_answer` and `sci_from_eval_report` return `None` for `correct == 0` and for the `u64::MAX` sentinel, whatever `correct` says beside it. The sentinel is never read as energy. `sci_from_eval_report` now returns `Option<SciScore>`; its only callers are this crate's tests. Tests in the default build: an account's (IT, facility) tags are (Measured, Measured) at PUE 1, (Measured, Estimated) at 1.2, (Estimated, Estimated) from an estimate at either, and (Measured, Measured) at a PUE of 0.5, which is clamped to 1; the figures are the same whichever tag. An account round-trips and an old one reads back estimated. A score is measured at PUE 1, estimated at 1.2 from measured joules, with the same SCI. An eval run's score keeps the run's provenance (2 × 1 J × 1.2 = 2.4 J over R = 2), and a run with 0 correct, or the sentinel with 3 correct, has none. `account_per_request` now asserts its tags. Under `eval-bridge`, a report's score equals its run's score for both provenances and both PUEs, and a 0-correct report has none. Falsified, each mutant restored byte-for-byte (sha256 checked); every one but the last runs in the default build: - 0db9b8be's mutant, `JouleCost::measured(total)` in the bridge, now fails `an_eval_runs_score_keeps_the_runs_provenance` in a plain `cargo test`; - an account dropping the IT provenance fails 1 test; - the facility tag ignoring the PUE fails 3, ignoring the IT provenance 3; - the score taking the input's provenance (0db9b8be's rule) fails 2; - removing the no-correct-answer guard fails 1, and so does checking `correct == 0` without the sentinel; - the `eval-bridge` wrapper tagging every report measured fails `an_eval_reports_score_is_its_runs_score` under `--features eval-bridge`. That wrapper's three-field copy is the only part a default build cannot see. `cargo test -p eoc-carbon` passes (42 + 8), and with `--features eval-bridge` (43 + 8). No clippy warning in this crate, before or after. Not done: `joules_per_correct` is an integer quotient, so the total reconstructed from it can fall short of the run's by less than one µJ per correct answer. Co-Authored-By: Claude Opus 5.5 <[email protected]>
313 lines
12 KiB
Rust
313 lines
12 KiB
Rust
//! Bridge to the Green Software Foundation **Software Carbon Intensity
|
||
//! (SCI)** specification, v1.
|
||
//!
|
||
//! SCI is defined as:
|
||
//!
|
||
//! ```text
|
||
//! SCI = ((E * I) + M) / R
|
||
//! ```
|
||
//!
|
||
//! where:
|
||
//! - `E` is energy consumed by the software (kWh),
|
||
//! - `I` is the location-based marginal grid intensity (gCO2e/kWh),
|
||
//! - `M` is embodied emissions amortized over the request (gCO2e),
|
||
//! - `R` is the functional unit (e.g. one request, one inference, one
|
||
//! correct answer for an eval).
|
||
//!
|
||
//! `eoc-eval` already references SCI; this module is the explicit
|
||
//! computation surface. [`sci_per_correct_answer`] scores an eval run's
|
||
//! energy with `R = number of correct cases`, and when the `eval-bridge`
|
||
//! feature is on, `sci_from_eval_report` feeds it an
|
||
//! [`eoc_eval::EvalReport`]. A run with no correct answer has no score per
|
||
//! correct answer: both return `None` for it.
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::account::{CarbonAccount, account_request};
|
||
use crate::intensity::CarbonIntensity;
|
||
use crate::pue::Pue;
|
||
use eoc_core::{JouleCost, JouleSource};
|
||
|
||
/// Inputs to one SCI computation.
|
||
#[derive(Debug, Clone)]
|
||
pub struct SciInputs<'a> {
|
||
/// Joules at the IT layer, with their provenance: measured by a live
|
||
/// counter (see [`eoc_meter`]) or estimated. The score carries it.
|
||
pub joule_cost: &'a JouleCost,
|
||
/// Grid intensity (marginal preferred, average acceptable).
|
||
pub intensity: &'a CarbonIntensity,
|
||
/// Facility PUE.
|
||
pub pue: &'a Pue,
|
||
/// Embodied emissions amortized to this request, in gCO2e. Set to
|
||
/// 0.0 if you don't track it yet.
|
||
pub embodied_g_co2e: f64,
|
||
/// Functional units served by this request (number of inferences,
|
||
/// number of correct answers, etc.). Must be > 0; values ≤ 0 are
|
||
/// clamped to 1 to keep the score finite.
|
||
pub functional_units: f64,
|
||
}
|
||
|
||
/// A computed SCI score.
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
pub struct SciScore {
|
||
/// Energy term (kWh).
|
||
pub energy_kwh: f64,
|
||
/// Operational gCO2e (energy × intensity × PUE).
|
||
pub operational_g_co2e: f64,
|
||
/// Embodied gCO2e passed in.
|
||
pub embodied_g_co2e: f64,
|
||
/// `R` denominator actually used.
|
||
pub functional_units: f64,
|
||
/// The SCI value (gCO2e per functional unit).
|
||
pub sci: f64,
|
||
/// Where the energy term came from. The term is facility energy (IT
|
||
/// joules × PUE), so this is the account's
|
||
/// [`facility_energy_source`](CarbonAccount::facility_energy_source):
|
||
/// `Measured` only when the input joules were measured and the PUE
|
||
/// added no overhead, which no counter read. An SCI value is never more
|
||
/// measured than the joules it was computed from. A score serialized
|
||
/// without this field reads back as `Estimated`.
|
||
#[serde(default = "unrecorded_energy_source")]
|
||
pub energy_source: JouleSource,
|
||
}
|
||
|
||
/// The provenance of a score that recorded none: an estimate, never a
|
||
/// measurement.
|
||
fn unrecorded_energy_source() -> JouleSource {
|
||
JouleSource::Estimated
|
||
}
|
||
|
||
impl SciScore {
|
||
/// Compute an SCI score from joules + intensity + PUE + embodied.
|
||
pub fn compute(inputs: SciInputs<'_>) -> Self {
|
||
let account: CarbonAccount = account_request(inputs.joule_cost, inputs.intensity, inputs.pue);
|
||
let energy_kwh = account.facility_joules / 3_600_000.0;
|
||
let operational_g_co2e = account.g_co2e;
|
||
let r = if inputs.functional_units > 0.0 {
|
||
inputs.functional_units
|
||
} else {
|
||
1.0
|
||
};
|
||
let sci = (operational_g_co2e + inputs.embodied_g_co2e) / r;
|
||
Self {
|
||
energy_kwh,
|
||
operational_g_co2e,
|
||
embodied_g_co2e: inputs.embodied_g_co2e,
|
||
functional_units: r,
|
||
sci,
|
||
energy_source: account.facility_energy_source,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// An eval run's energy, as `eoc_eval::EvalReport` carries it: micro-joules
|
||
/// per correct answer, the number of correct answers, and the provenance of
|
||
/// the joules. Kept free of `eoc-eval` so the scoring below is compiled, and
|
||
/// tested, in every build; the `eval-bridge` feature only fills it from a
|
||
/// report.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct EvalEnergy {
|
||
/// Micro-joules per correct answer. `u64::MAX` is the report's sentinel
|
||
/// for "no correct answer", not a figure.
|
||
pub joules_per_correct: u64,
|
||
/// Correct answers in the run.
|
||
pub correct: usize,
|
||
/// Where the joules came from: `Measured` only when every case's were.
|
||
pub joule_source: JouleSource,
|
||
}
|
||
|
||
/// The SCI score per correct answer of an eval run: total IT energy
|
||
/// `joules_per_correct × correct`, with the run's own provenance (never
|
||
/// upgraded; see [`SciScore::energy_source`]), and `R = correct`.
|
||
///
|
||
/// `None` when the run has no correct answer — `correct == 0`, or the
|
||
/// `u64::MAX` sentinel a report carries for that — since there is no figure
|
||
/// per correct answer to score. The sentinel is never read as energy.
|
||
pub fn sci_per_correct_answer(
|
||
energy: EvalEnergy,
|
||
intensity: &CarbonIntensity,
|
||
pue: &Pue,
|
||
embodied_g_co2e: f64,
|
||
) -> Option<SciScore> {
|
||
if energy.correct == 0 || energy.joules_per_correct == u64::MAX {
|
||
return None;
|
||
}
|
||
let total_microjoules = (energy.joules_per_correct as u128)
|
||
.saturating_mul(energy.correct as u128)
|
||
.min(u64::MAX as u128) as u64;
|
||
let cost = JouleCost {
|
||
microjoules: total_microjoules,
|
||
source: energy.joule_source,
|
||
};
|
||
Some(SciScore::compute(SciInputs {
|
||
joule_cost: &cost,
|
||
intensity,
|
||
pue,
|
||
embodied_g_co2e,
|
||
functional_units: energy.correct as f64,
|
||
}))
|
||
}
|
||
|
||
/// `eoc-eval` bridge — turns an `EvalReport` into an SCI score with
|
||
/// `R = correct`. Compiled only with the `eval-bridge` Cargo feature; it
|
||
/// only hands the report's energy to [`sci_per_correct_answer`], which is
|
||
/// compiled and tested in every build.
|
||
///
|
||
/// `EvalReport` carries `joules_per_correct` in micro-joules and a
|
||
/// `correct` count, so the total IT-layer energy is reconstructed as
|
||
/// `joules_per_correct * correct`. Its provenance is the report's own
|
||
/// `joule_source`: measured only when every case of the run was. `None`
|
||
/// for a run with no correct answer.
|
||
#[cfg(feature = "eval-bridge")]
|
||
pub fn sci_from_eval_report(
|
||
report: &eoc_eval::EvalReport,
|
||
intensity: &CarbonIntensity,
|
||
pue: &Pue,
|
||
embodied_g_co2e: f64,
|
||
) -> Option<SciScore> {
|
||
sci_per_correct_answer(
|
||
EvalEnergy {
|
||
joules_per_correct: report.joules_per_correct,
|
||
correct: report.correct,
|
||
joule_source: report.joule_source,
|
||
},
|
||
intensity,
|
||
pue,
|
||
embodied_g_co2e,
|
||
)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::intensity::{IntensityKind, ProviderKind};
|
||
use chrono::Utc;
|
||
|
||
#[test]
|
||
fn r_clamped_when_zero() {
|
||
let cost = JouleCost::measured(1_000_000); // 1 J
|
||
let ci = CarbonIntensity::new(
|
||
"US",
|
||
400.0,
|
||
IntensityKind::Average,
|
||
ProviderKind::Mock,
|
||
Utc::now(),
|
||
);
|
||
let s = SciScore::compute(SciInputs {
|
||
joule_cost: &cost,
|
||
intensity: &ci,
|
||
pue: &Pue::new(1.2),
|
||
embodied_g_co2e: 0.0,
|
||
functional_units: 0.0,
|
||
});
|
||
assert_eq!(s.functional_units, 1.0);
|
||
assert!(s.sci > 0.0);
|
||
}
|
||
|
||
fn intensity() -> CarbonIntensity {
|
||
CarbonIntensity::new("US", 400.0, IntensityKind::Average, ProviderKind::Mock, Utc::now())
|
||
}
|
||
|
||
fn score(cost: JouleCost, pue: f64) -> SciScore {
|
||
SciScore::compute(SciInputs {
|
||
joule_cost: &cost,
|
||
intensity: &intensity(),
|
||
pue: &Pue::new(pue),
|
||
embodied_g_co2e: 0.0,
|
||
functional_units: 1.0,
|
||
})
|
||
}
|
||
|
||
/// The score says where its energy term came from, and the same figure
|
||
/// yields the same numbers either way. The term is facility energy, so
|
||
/// it is measured only when the joules were and the PUE added nothing.
|
||
#[test]
|
||
fn the_score_carries_its_energy_provenance() {
|
||
assert_eq!(score(JouleCost::measured(1_000_000), 1.0).energy_source, JouleSource::Measured);
|
||
assert_eq!(score(JouleCost::estimated(1_000_000), 1.0).energy_source, JouleSource::Estimated);
|
||
assert_eq!(
|
||
score(JouleCost::measured(1_000_000), 1.2).energy_source,
|
||
JouleSource::Estimated,
|
||
"a PUE's overhead is not a reading"
|
||
);
|
||
assert_eq!(score(JouleCost::measured(1_000_000), 1.2).sci, score(JouleCost::estimated(1_000_000), 1.2).sci);
|
||
}
|
||
|
||
/// A score written before `energy_source` existed recorded no
|
||
/// provenance, so it reads back as an estimate.
|
||
#[test]
|
||
fn a_score_without_provenance_reads_back_estimated() {
|
||
let json = r#"{"energy_kwh":1.0,"operational_g_co2e":1.0,"embodied_g_co2e":0.0,
|
||
"functional_units":1.0,"sci":1.0}"#;
|
||
let back: SciScore = serde_json::from_str(json).unwrap();
|
||
assert_eq!(back.energy_source, JouleSource::Estimated);
|
||
}
|
||
|
||
fn run(joules_per_correct: u64, correct: usize, joule_source: JouleSource) -> EvalEnergy {
|
||
EvalEnergy { joules_per_correct, correct, joule_source }
|
||
}
|
||
|
||
/// An eval run's score is measured exactly when the run's joules were
|
||
/// (and the PUE adds nothing); the run's provenance is never upgraded.
|
||
/// Compiled in every build: the `eval-bridge` feature only fills the
|
||
/// run's energy from a report.
|
||
#[test]
|
||
fn an_eval_runs_score_keeps_the_runs_provenance() {
|
||
let ci = intensity();
|
||
let per_correct = |source, pue: f64| {
|
||
sci_per_correct_answer(run(1_000_000, 2, source), &ci, &Pue::new(pue), 0.0).expect("two correct answers")
|
||
};
|
||
assert_eq!(per_correct(JouleSource::Estimated, 1.0).energy_source, JouleSource::Estimated);
|
||
assert_eq!(per_correct(JouleSource::Measured, 1.0).energy_source, JouleSource::Measured);
|
||
assert_eq!(per_correct(JouleSource::Measured, 1.2).energy_source, JouleSource::Estimated);
|
||
// 2 correct × 1 J, × 1.2 PUE = 2.4 J either way, over R = 2.
|
||
let (e, m) = (per_correct(JouleSource::Estimated, 1.2), per_correct(JouleSource::Measured, 1.2));
|
||
assert!((e.energy_kwh - 2.4 / 3_600_000.0).abs() < 1e-15, "{}", e.energy_kwh);
|
||
assert_eq!(e.functional_units, 2.0);
|
||
assert_eq!(e.sci, m.sci);
|
||
}
|
||
|
||
/// A run with no correct answer has no score per correct answer. Its
|
||
/// `u64::MAX` sentinel is never read as a u64::MAX µJ energy term,
|
||
/// whatever `correct` says beside it.
|
||
#[test]
|
||
fn a_run_with_no_correct_answer_has_no_score() {
|
||
let ci = intensity();
|
||
let pue = Pue::new(1.2);
|
||
for source in [JouleSource::Measured, JouleSource::Estimated] {
|
||
assert_eq!(sci_per_correct_answer(run(u64::MAX, 0, source), &ci, &pue, 0.0), None);
|
||
assert_eq!(sci_per_correct_answer(run(5_000_000, 0, source), &ci, &pue, 0.0), None);
|
||
assert_eq!(sci_per_correct_answer(run(u64::MAX, 3, source), &ci, &pue, 0.0), None);
|
||
}
|
||
assert!(sci_per_correct_answer(run(1, 1, JouleSource::Measured), &ci, &pue, 0.0).is_some());
|
||
}
|
||
|
||
/// The bridge scores a report exactly as the run's energy is scored, and
|
||
/// gives none for a report with no correct answer.
|
||
#[cfg(feature = "eval-bridge")]
|
||
#[test]
|
||
fn an_eval_reports_score_is_its_runs_score() {
|
||
let report = |correct, joules_per_correct, joule_source| eoc_eval::EvalReport {
|
||
harness: "fixed".into(),
|
||
model: "test".into(),
|
||
total_cases: 4,
|
||
correct,
|
||
score: correct as f64 / 4.0,
|
||
metric: eoc_eval::Metric::Accuracy,
|
||
joules_per_correct,
|
||
joule_source,
|
||
latency_p50_ms: 1,
|
||
latency_p95_ms: 1,
|
||
};
|
||
let ci = intensity();
|
||
for pue in [Pue::new(1.0), Pue::new(1.2)] {
|
||
for source in [JouleSource::Estimated, JouleSource::Measured] {
|
||
let got = sci_from_eval_report(&report(2, 1_000_000, source), &ci, &pue, 0.1);
|
||
let want = sci_per_correct_answer(run(1_000_000, 2, source), &ci, &pue, 0.1);
|
||
assert_eq!(got, want);
|
||
assert!(got.is_some());
|
||
}
|
||
}
|
||
assert_eq!(sci_from_eval_report(&report(0, u64::MAX, JouleSource::Measured), &ci, &Pue::new(1.2), 0.0), None);
|
||
}
|
||
}
|