Skip to main content

rustev_eval/
metrics.rs

1//! Metric definitions (spec 004, 3.5.4 to 3.5.7). Every metric states its
2//! numerator, denominator and exclusions; a zero denominator, a non-finite
3//! value or an incompatible input is `unknown`, never a pass.
4
5use std::collections::BTreeMap;
6
7use rustev_contract::calibration::CalibrationArtifact;
8use rustev_contract::canonical::record_canonical_bytes;
9use rustev_contract::decimal::Decimal;
10use rustev_contract::definition::{RequiredKind, StepBody};
11use rustev_contract::judgment::{Judgment, OutValue, Outcome};
12use rustev_contract::output::RawOutput;
13use rustev_contract::plan::{BoundCalibration, Plan, PlanStepDetail};
14use rustev_contract::run::{Charge, RunRecord};
15use rustev_core::calibrate::{self, BindingCheck, CalibrationInput};
16use rustev_core::kinds::Distribution;
17
18use crate::config::{AdapterRules, Agreement};
19use crate::dataset::AdapterRef;
20
21/// How a task reads its judgments and labels.
22pub trait TaskAdapter {
23    fn adapter(&self) -> AdapterRef;
24    /// What determines this adapter's label check and correctness (spec
25    /// 014, 3.1). No default: every adapter states whether its rules are
26    /// bound to a document or opaque.
27    fn rules(&self) -> AdapterRules;
28    /// Refuse a label of the wrong shape.
29    fn check_label(&self, label: &str) -> Result<(), String>;
30    /// Whether a proposal is correct for a label. The adapter defines
31    /// correctness; an unlabeled proposal is never correct or wrong.
32    fn correct(&self, action: &str, params: &BTreeMap<String, OutValue>, label: &str) -> bool;
33}
34
35/// A comparable case's outcome category.
36#[derive(Debug, Clone, PartialEq)]
37pub enum CaseOutcome {
38    /// Keyed by the action and the agreement's named payloads.
39    Proposal {
40        key: String,
41        action: String,
42        params: BTreeMap<String, OutValue>,
43    },
44    /// Escalations, missing evidence and every unresolved outcome are
45    /// abstentions: counted in the acceptance denominator, never accepted.
46    Abstention { kind: String },
47}
48
49impl CaseOutcome {
50    pub fn of(j: &Judgment, agreement: &Agreement) -> CaseOutcome {
51        match &j.outcome {
52            Outcome::Propose { action, params } => {
53                let mut key = action.clone();
54                for name in &agreement.params {
55                    let value = params
56                        .get(name)
57                        .and_then(|v| record_canonical_bytes(v).ok())
58                        .map(|b| String::from_utf8_lossy(&b).into_owned())
59                        .unwrap_or_else(|| {
60                            if params.contains_key(name) {
61                                "unrepresentable".into()
62                            } else {
63                                "absent".into()
64                            }
65                        });
66                    key.push_str(&format!(";{name}={value}"));
67                }
68                CaseOutcome::Proposal {
69                    key,
70                    action: action.clone(),
71                    params: params.clone(),
72                }
73            }
74            Outcome::Escalate { .. } => CaseOutcome::Abstention {
75                kind: "escalation".into(),
76            },
77            Outcome::Unresolved(u) | Outcome::UnresolvedAction { reason: u, .. } => {
78                CaseOutcome::Abstention {
79                    kind: u.kind().name().into(),
80                }
81            }
82            // Judgment 2 outcomes (spec 033, 3.5.1) are abstentions by their
83            // own names; neither is a proposal.
84            Outcome::Refused { .. } => CaseOutcome::Abstention {
85                kind: "refused".into(),
86            },
87            Outcome::NeedsInput(_) => CaseOutcome::Abstention {
88                kind: "needs_input".into(),
89            },
90        }
91    }
92
93    pub fn name(&self) -> String {
94        match self {
95            CaseOutcome::Proposal { key, .. } => format!("proposal:{key}"),
96            CaseOutcome::Abstention { kind } => format!("abstention:{kind}"),
97        }
98    }
99}
100
101/// The probability vector a step's raw output yields, computed with the
102/// core's own validation, normalization and calibration. Scores, labels,
103/// ranks and unresolved values never become probabilities.
104pub fn probabilities(
105    plan: &Plan,
106    calibrations: &[CalibrationArtifact],
107    step: &str,
108    raw: &RawOutput,
109) -> Result<Vec<(String, f64)>, String> {
110    let decl = plan
111        .definition
112        .steps
113        .iter()
114        .find(|s| s.id == step)
115        .and_then(|s| match &s.body {
116            StepBody::Semantic(d) => Some(d),
117            _ => None,
118        })
119        .ok_or("wrong-kind")?;
120    let Some(PlanStepDetail::Semantic(b)) =
121        plan.steps.iter().find(|s| s.id == step).map(|s| &s.detail)
122    else {
123        return Err("wrong-kind".into());
124    };
125    let tol = plan.definition.limits.distribution_tolerance;
126    let invalid = |e: String| format!("invalid-output: {e}");
127    let (options, masses): (Vec<String>, Vec<f64>) = match (b.requires, raw) {
128        (
129            RequiredKind::CalibratedProbability,
130            RawOutput::Logits(m) | RawOutput::Distribution(m),
131        ) => {
132            let BoundCalibration::Bound { id, .. } = &b.calibration else {
133                return Err("wrong-kind".into());
134            };
135            let art = calibrations
136                .iter()
137                .find(|c| rustev_contract::Identified::id(*c).ok().as_ref() == Some(id))
138                .ok_or("missing-calibration")?;
139            let input = match raw {
140                RawOutput::Logits(m) => CalibrationInput::Logits(m),
141                _ => CalibrationInput::Distribution(m),
142            };
143            let check = BindingCheck {
144                artifact: &b.artifact,
145                task: &decl.task,
146                question: step,
147                options: &decl.options,
148            };
149            let p = calibrate::apply(art, id, &check, input, tol)
150                .map_err(|e| invalid(e.to_string()))?;
151            (p.options().to_vec(), p.masses().to_vec())
152        }
153        (RequiredKind::Distribution | RequiredKind::OrdinalDistribution, RawOutput::Logits(m)) => {
154            let d = Distribution::from_logits(&decl.options, m, tol).map_err(|e| invalid(e.0))?;
155            (d.options().to_vec(), d.masses().to_vec())
156        }
157        (
158            RequiredKind::Distribution | RequiredKind::OrdinalDistribution,
159            RawOutput::Distribution(m),
160        ) => {
161            let d = Distribution::new(&decl.options, m, tol).map_err(|e| invalid(e.0))?;
162            (d.options().to_vec(), d.masses().to_vec())
163        }
164        _ => return Err("wrong-kind".into()),
165    };
166    Ok(options.into_iter().zip(masses).collect())
167}
168
169/// A metric's value, or why there is none.
170#[derive(Debug, Clone, PartialEq)]
171pub enum Measure {
172    Value { value: f64, n: u64 },
173    Unknown { reason: String },
174}
175
176impl Measure {
177    pub fn ratio(num: u64, den: u64) -> Measure {
178        if den == 0 {
179            Measure::Unknown {
180                reason: "zero denominator".into(),
181            }
182        } else {
183            Measure::finite(num as f64 / den as f64, den)
184        }
185    }
186
187    pub fn finite(value: f64, n: u64) -> Measure {
188        if value.is_finite() {
189            Measure::Value { value, n }
190        } else {
191            Measure::Unknown {
192                reason: "non-finite value".into(),
193            }
194        }
195    }
196}
197
198/// Labeled probability vectors, scored.
199pub struct Scored<'a> {
200    pub case: &'a str,
201    pub probabilities: &'a [(String, f64)],
202    pub label: &'a str,
203}
204
205/// `-sum(ln(p[label])) / n`; a zero true-class probability is an explicit
206/// infinite-loss diagnostic and an unknown finite metric, never clipped.
207pub fn mean_log_loss(cases: &[Scored<'_>]) -> Measure {
208    if cases.is_empty() {
209        return Measure::Unknown {
210            reason: "zero denominator".into(),
211        };
212    }
213    let mut sum = 0.0;
214    for c in cases {
215        // `sum + -ln(p)` is `sum - ln(p)` bit for bit (IEEE 754 defines
216        // subtraction as addition of the negation).
217        match log_loss_term(c) {
218            Ok(term) => sum += term,
219            Err(reason) => return Measure::Unknown { reason },
220        }
221    }
222    Measure::finite(sum / cases.len() as f64, cases.len() as u64)
223}
224
225/// One case's log loss contribution, `-ln(p[label])` (spec 029, 3.2.3): the
226/// term `mean_log_loss` subtracts, or why it has none.
227pub fn log_loss_term(c: &Scored<'_>) -> Result<f64, String> {
228    match c
229        .probabilities
230        .iter()
231        .find(|(o, _)| o == c.label)
232        .map(|(_, p)| *p)
233    {
234        Some(p) if p > 0.0 => Ok(-rustev_core::math::ln(p)),
235        Some(_) => Err(format!(
236            "infinite loss: zero true-class probability at case {}",
237            c.case
238        )),
239        None => Err(format!("label is not an option at case {}", c.case)),
240    }
241}
242
243/// One case's Brier sum, `sum_class (p - y)^2` added left to right from
244/// zero, or why it has none. Summing these over cases does not in general
245/// reproduce [`brier`]'s bits, which add every term to one running sum, so
246/// spec 029 does not use it as a per-case measure yet.
247pub fn brier_term(c: &Scored<'_>) -> Result<f64, String> {
248    if !c.probabilities.iter().any(|(o, _)| o == c.label) {
249        return Err(format!("label is not an option at case {}", c.case));
250    }
251    let mut sum = 0.0;
252    for (o, p) in c.probabilities {
253        let y = if o == c.label { 1.0 } else { 0.0 };
254        sum += (p - y) * (p - y);
255    }
256    Ok(sum)
257}
258
259/// Multiclass Brier: `sum_case sum_class (p - y)^2 / n`.
260pub fn brier(cases: &[Scored<'_>]) -> Measure {
261    if cases.is_empty() {
262        return Measure::Unknown {
263            reason: "zero denominator".into(),
264        };
265    }
266    let mut sum = 0.0;
267    for c in cases {
268        if !c.probabilities.iter().any(|(o, _)| o == c.label) {
269            return Measure::Unknown {
270                reason: format!("label is not an option at case {}", c.case),
271            };
272        }
273        for (o, p) in c.probabilities {
274            let y = if o == c.label { 1.0 } else { 0.0 };
275            sum += (p - y) * (p - y);
276        }
277    }
278    Measure::finite(sum / cases.len() as f64, cases.len() as u64)
279}
280
281/// One reliability bin over top-label confidence.
282#[derive(Debug, Clone, PartialEq)]
283pub struct Bin {
284    pub lower: Decimal,
285    pub upper: Decimal,
286    pub count: u64,
287    /// Unknown for an empty bin.
288    pub mean_confidence: Measure,
289    pub accuracy: Measure,
290}
291
292/// A scored case's reliability bin: the bin index, the top-label
293/// confidence and the top label. The top label is the first option with
294/// the greatest mass; bins are half-open `[b_i, b_{i+1})`, the last closed
295/// at 1. None for a case with no option or no bin.
296pub fn reliability_bin<'a>(
297    probabilities: &'a [(String, f64)],
298    bounds: &[Decimal],
299) -> Option<(usize, f64, &'a str)> {
300    let n = bounds.len().saturating_sub(1);
301    let (top, conf) =
302        probabilities
303            .iter()
304            .fold(None::<(&str, f64)>, |best, (o, p)| match best {
305                Some((_, bp)) if bp >= *p => best,
306                _ => Some((o, *p)),
307            })?;
308    let i = (0..n).find(|&i| {
309        let lo = bounds[i].to_f64_nearest();
310        let hi = bounds[i + 1].to_f64_nearest();
311        // The last bin is closed and also takes a mass a tolerated
312        // normalization left just above 1: no scored case is dropped.
313        conf >= lo && (conf < hi || i + 1 == n)
314    })?;
315    Some((i, conf, top))
316}
317
318/// Reliability over half-open bins `[b_i, b_{i+1})`, the last closed at 1.
319/// The top label is the first option with the greatest mass.
320pub fn reliability(cases: &[Scored<'_>], bounds: &[Decimal]) -> Vec<Bin> {
321    let n = bounds.len().saturating_sub(1);
322    let mut acc: Vec<(u64, f64, u64)> = vec![(0, 0.0, 0); n];
323    for c in cases {
324        if let Some((i, conf, top)) = reliability_bin(c.probabilities, bounds) {
325            acc[i].0 += 1;
326            acc[i].1 += conf;
327            acc[i].2 += u64::from(top == c.label);
328        }
329    }
330    acc.into_iter()
331        .enumerate()
332        .map(|(i, (count, conf, right))| Bin {
333            lower: bounds[i],
334            upper: bounds[i + 1],
335            count,
336            mean_confidence: if count == 0 {
337                Measure::Unknown {
338                    reason: "empty bin".into(),
339                }
340            } else {
341                Measure::finite(conf / count as f64, count)
342            },
343            accuracy: Measure::ratio(right, count),
344        })
345        .collect()
346}
347
348/// Nearest-rank quantile: the `ceil(q * n)`th smallest value, one-based.
349pub fn nearest_rank(values: &[u64], q: Decimal) -> Measure {
350    if values.is_empty() {
351        return Measure::Unknown {
352            reason: "zero denominator".into(),
353        };
354    }
355    let mut v = values.to_vec();
356    v.sort_unstable();
357    let n = v.len() as i128;
358    // ceil(q * n) in exact decimal arithmetic: q has 9 fractional digits.
359    let scaled = q.units() * n;
360    let one = 1_000_000_000i128;
361    let rank = ((scaled + one - 1) / one).clamp(1, n) as usize;
362    Measure::Value {
363        value: v[rank - 1] as f64,
364        n: v.len() as u64,
365    }
366}
367
368/// Observed, estimated and unknown cost, never mixed into one total.
369#[derive(Debug, Clone, PartialEq)]
370pub struct CostSeries {
371    pub observed_units: Measure,
372    pub estimated_units: Measure,
373    /// Attempts whose cost is unknown, and units kept as liability.
374    pub unknown_attempts: u64,
375    pub liability_units: u64,
376    pub backends: Vec<String>,
377}
378
379pub fn cost(runs: &[&RunRecord], units_comparable: bool) -> CostSeries {
380    let mut backends: Vec<String> = runs
381        .iter()
382        .flat_map(|r| r.requests.iter())
383        .flat_map(|q| q.attempts.iter().map(|a| a.backend_id.clone()))
384        .collect();
385    backends.sort();
386    backends.dedup();
387    let unknown_attempts = runs
388        .iter()
389        .flat_map(|r| r.requests.iter())
390        .flat_map(|q| q.attempts.iter())
391        .filter(|a| a.cost.charge == Charge::Unknown)
392        .count() as u64;
393    let liability_units = runs.iter().map(|r| r.cost.liability).sum();
394    let n = runs.len() as u64;
395    let total = |f: fn(&RunRecord) -> u64| -> Measure {
396        if n == 0 {
397            return Measure::Unknown {
398                reason: "zero denominator".into(),
399            };
400        }
401        if backends.len() > 1 && !units_comparable {
402            return Measure::Unknown {
403                reason: "units of different backends are not declared comparable".into(),
404            };
405        }
406        let sum: u128 = runs.iter().map(|r| u128::from(f(r))).sum();
407        Measure::Value {
408            value: sum as f64,
409            n,
410        }
411    };
412    CostSeries {
413        observed_units: total(|r| r.cost.observed),
414        estimated_units: total(|r| r.cost.estimated),
415        unknown_attempts,
416        liability_units,
417        backends,
418    }
419}