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}
420
421/// Exact counts over reproduced generation evidence (spec 037, 3.16).
422/// No text or model scorer participates. `n` counts contributing cases for
423/// counts and field slots for the backstop rate.
424pub fn generation(runs: &[&RunRecord]) -> Vec<rustev_contract::eval_report::Metric> {
425    use rustev_contract::eval_report::{Metric, MetricValue};
426    use rustev_contract::generation_result::{TextCounts, TextOrigin, TextReason, TextSource};
427    use rustev_contract::run::CoreEvidence;
428    #[derive(Default)]
429    struct Counts {
430        fields: TextCounts,
431        cases: u64,
432        origins: [u64; 4],
433        cached: u64,
434    }
435    fn origin(c: &TextCounts) -> TextOrigin {
436        if c.generated + c.repaired + c.backstop == 0 {
437            TextOrigin::None
438        } else if c.backstop + c.absent == 0 {
439            TextOrigin::Generated
440        } else if c.generated + c.repaired == 0 {
441            TextOrigin::Backstop
442        } else {
443            TextOrigin::Partial
444        }
445    }
446    fn add(to: &mut TextCounts, from: &TextCounts) {
447        to.generated += from.generated;
448        to.repaired += from.repaired;
449        to.backstop += from.backstop;
450        to.absent += from.absent;
451        for (r, n) in &from.reasons {
452            *to.reasons.entry(*r).or_default() += n;
453        }
454    }
455    fn case(c: &mut Counts, fields: &TextCounts, cached: u64, origin: TextOrigin) {
456        add(&mut c.fields, fields);
457        c.cached += cached;
458        c.cases += 1;
459        c.origins[match origin {
460            TextOrigin::None => 0,
461            TextOrigin::Generated => 1,
462            TextOrigin::Backstop => 2,
463            TextOrigin::Partial => 3,
464        }] += 1;
465    }
466    let mut groups: BTreeMap<String, Counts> = BTreeMap::new();
467    for run in runs {
468        let CoreEvidence::Recorded(e) = &run.core else {
469            continue;
470        };
471        let Some(steps) = &e.generation else {
472            continue;
473        };
474        let mut total = TextCounts::default();
475        let mut hits = 0;
476        for s in steps {
477            let cached = s
478                .items
479                .iter()
480                .flat_map(|i| i.generated.values())
481                .filter(|f| matches!(f.source, TextSource::ServedFromCache { .. }))
482                .count() as u64;
483            case(
484                groups.entry(format!("text/step/{}", s.step)).or_default(),
485                &s.counts,
486                cached,
487                s.text_origin,
488            );
489            add(&mut total, &s.counts);
490            hits += cached;
491        }
492        case(
493            groups.entry("text".into()).or_default(),
494            &total,
495            hits,
496            origin(&total),
497        );
498    }
499    let mut metrics = vec![];
500    for (prefix, c) in groups {
501        let f = c.fields;
502        let slots = f.generated + f.repaired + f.backstop + f.absent;
503        let mut count = |name: &str, value: u64| {
504            metrics.push(Metric {
505                name: format!("{prefix}/{name}"),
506                value: MetricValue::Measured {
507                    value: value as f64,
508                    n: c.cases,
509                },
510            })
511        };
512        count("field_slots", slots);
513        count("origin/generated", f.generated);
514        count("origin/repaired", f.repaired);
515        count("origin/backstop", f.backstop);
516        count("origin/absent", f.absent);
517        count("served_from_cache", c.cached);
518        for (name, value) in ["none", "generated", "backstop", "partial"]
519            .into_iter()
520            .zip(c.origins)
521        {
522            count(&format!("text_origin/{name}"), value);
523        }
524        for reason in [
525            TextReason::ScreenBlocked,
526            TextReason::ScreenUnavailable,
527            TextReason::Deadline,
528            TextReason::RetriesExhausted,
529            TextReason::InvalidOutput,
530            TextReason::BackendUnavailable,
531            TextReason::BudgetExhausted,
532            TextReason::NotAttempted,
533        ] {
534            let name = serde_json::to_value(reason).expect("reason serializes");
535            count(
536                &format!("reason/{}", name.as_str().expect("reason name")),
537                f.reasons.get(&reason).copied().unwrap_or(0),
538            );
539        }
540        metrics.push(Metric {
541            name: format!("{prefix}/backstop_rate"),
542            value: if slots == 0 {
543                MetricValue::Unknown {
544                    reason: "zero field slots".into(),
545                }
546            } else {
547                MetricValue::Measured {
548                    value: (f.backstop + f.absent) as f64 / slots as f64,
549                    n: slots,
550                }
551            },
552        });
553    }
554    metrics
555}