Skip to main content

rustev_eval/
report.rs

1//! Evaluating a dataset split into a report (spec 004, 3.5).
2//!
3//! Every case of the split is reproduced from its bundle; with a candidate,
4//! each reproduced case is also compared. The existing `rustev.eval-report/1`
5//! envelope carries the metrics; a `rustev.eval-detail/1` document binds the
6//! report's digest, dataset, split and configuration to every numerator,
7//! denominator and exclusion and to each case's scope, outcome and reason.
8//! A standalone envelope never proves those denominators. All counts
9//! reconcile to the named cohort before a report is returned.
10
11use std::collections::{BTreeMap, BTreeSet};
12use std::fmt;
13
14use rustev_contract::calibration::CalibrationArtifact;
15use rustev_contract::eval_report::{
16    DatasetRef, EvalReport, Metric, MetricValue, ReportStatus, Split,
17};
18use rustev_contract::ids::{
19    ArtifactId, CalibrationId, ContentDigest, DatasetId, EvaluatorConfigId, PlanId,
20};
21use rustev_contract::judgment::OutValue;
22use rustev_contract::limits::REPLAY_V1;
23use rustev_contract::plan::{BoundCalibration, Plan, PlanExecution, PlanStepDetail};
24use rustev_contract::run::{Charge, RunRecord};
25use rustev_contract::scope::Scope;
26use rustev_contract::{Document, Identified, schema};
27use rustev_core::Compiled;
28use serde::{Deserialize, Serialize};
29
30use crate::bundle::BundleInput;
31use crate::compare::compare;
32use crate::config::{ConfigError, Direction, EvaluatorConfig, Gate};
33use crate::dataset::{DatasetCase, DatasetError, DatasetManifest, ModelRef};
34use crate::doc::eval_document;
35use crate::eval_cases::{self, CaseMeasure, CaseMeasures, EVAL_CASES, EvalCases};
36use crate::fit::CalibrationFit;
37use crate::metrics::{
38    self, CaseOutcome, Measure, Scored, TaskAdapter, brier, log_loss_term, mean_log_loss,
39    nearest_rank, probabilities, reliability, reliability_bin,
40};
41use crate::repeat_config::TopK;
42use crate::replay::{ReplayConfig, ReplayOutcome, RetainedValue, reproduce};
43use crate::resolve::Resolver;
44
45pub const EVAL_DETAIL: &str = "rustev.eval-detail/1";
46
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct EvalDetail {
50    pub schema: String,
51    /// The record digest of the report this detail explains.
52    pub report: ContentDigest,
53    pub dataset: DatasetId,
54    pub split: Split,
55    pub config: EvaluatorConfigId,
56    /// What the counts are over.
57    pub cohort: String,
58    pub counts: Vec<Count>,
59    pub cases: Vec<CaseDetail>,
60}
61
62eval_document!(EvalDetail, EVAL_DETAIL, REPLAY_V1);
63
64/// One metric's accounting: `numerator` of `denominator`, and every case
65/// the metric left out or broke down, by reason.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub struct Count {
69    pub metric: String,
70    pub numerator: u64,
71    pub denominator: u64,
72    pub breakdown: BTreeMap<String, u64>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct CaseDetail {
78    pub case: String,
79    /// The bundle's isolation scope, when there was a bundle.
80    pub scope: Option<Scope>,
81    /// `incomparable`, `diverged`, `proposal:<key>` or `abstention:<kind>`.
82    pub outcome: String,
83    pub reason: Option<String>,
84}
85
86/// Why no report was produced.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum EvalError {
89    Config(ConfigError),
90    Dataset(DatasetError),
91    /// The configuration names another adapter than the one supplied.
92    Adapter,
93    EmptySplit,
94    /// Reproduced cases come from different plans.
95    MixedPlans,
96    /// A metric the configuration does not declare.
97    UndeclaredMetric(String),
98    /// The counts do not add up to the cohort.
99    Reconcile(String),
100    NotCanonical,
101}
102
103impl fmt::Display for EvalError {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        write!(f, "{self:?}")
106    }
107}
108
109impl std::error::Error for EvalError {}
110
111/// What one evaluation takes.
112pub struct Inputs<'a> {
113    pub manifest: &'a DatasetManifest,
114    pub split: Split,
115    pub config: &'a EvaluatorConfig,
116    pub adapter: &'a dyn TaskAdapter,
117    /// By case id: a parsed bundle or the load failure the host observed
118    /// (spec 015). A case with no entry is `bundle-missing`.
119    pub bundles: &'a BTreeMap<String, BundleInput>,
120    pub resolver: &'a dyn Resolver,
121    pub replay: &'a ReplayConfig,
122    /// A candidate plan to compare; none for a baseline report.
123    pub candidate: Option<Candidate<'a>>,
124}
125
126/// A candidate plan with the calibration artifacts it binds.
127#[derive(Clone, Copy)]
128pub struct Candidate<'a> {
129    pub plan: &'a Compiled,
130    pub calibrations: &'a [CalibrationArtifact],
131}
132
133/// A report with the documents that explain it.
134#[derive(Debug, Clone)]
135pub struct Evaluated {
136    pub report: EvalReport,
137    pub detail: EvalDetail,
138    pub config: EvaluatorConfig,
139}
140
141enum Status {
142    Incomparable(String),
143    Diverged,
144    Comparable {
145        outcome: CaseOutcome,
146        historical: Option<CaseOutcome>,
147        probabilities: Result<Vec<(String, f64)>, String>,
148        run: Box<RunRecord>,
149        plan: Box<Plan>,
150    },
151}
152
153struct CaseResult<'a> {
154    case: &'a DatasetCase,
155    scope: Option<Scope>,
156    status: Status,
157}
158
159fn evaluate_case<'a>(inputs: &Inputs<'_>, case: &'a DatasetCase) -> CaseResult<'a> {
160    let incomparable = |scope, code: &str| CaseResult {
161        case,
162        scope,
163        status: Status::Incomparable(code.into()),
164    };
165    let bundle = match inputs.bundles.get(&case.id) {
166        None => return incomparable(None, "bundle-missing"),
167        // No scope: the bytes that would carry it are unusable.
168        Some(BundleInput::Failed(f)) => return incomparable(None, f.kind.code()),
169        Some(BundleInput::Loaded(b)) => b,
170    };
171    let scope = Some(bundle.scope.clone());
172    if bundle.snapshot_id != case.snapshot {
173        return incomparable(scope, "dataset-mismatch");
174    }
175    let r = match reproduce(bundle, inputs.resolver, inputs.replay) {
176        ReplayOutcome::Reproduced(r) => r,
177        ReplayOutcome::Diverged { .. } => {
178            return CaseResult {
179                case,
180                scope,
181                status: Status::Diverged,
182            };
183        }
184        ReplayOutcome::Incomparable(i) => return incomparable(scope, &i.code()),
185    };
186    let agreement = &inputs.config.agreement;
187    let historical = CaseOutcome::of(&r.judgment, agreement);
188    let step = inputs.config.probability_step.as_deref();
189    let status = match inputs.candidate {
190        None => {
191            let probabilities = match step {
192                None => Err("no-probability-step".into()),
193                Some(s) => r
194                    .supplies
195                    .iter()
196                    .find(|x| x.step == s && x.instance.is_empty())
197                    .ok_or_else(|| "no-output".to_string())
198                    .and_then(|x| match &x.value {
199                        RetainedValue::Output { output, .. } => {
200                            probabilities(&r.plan, &r.calibrations, s, output)
201                        }
202                        RetainedValue::Reason(_) => Err("runtime-failure".into()),
203                    }),
204            };
205            Status::Comparable {
206                outcome: historical,
207                historical: None,
208                probabilities,
209                run: Box::new(r.run.clone()),
210                plan: Box::new(r.plan.clone()),
211            }
212        }
213        Some(cand) => match compare(&r, cand.plan) {
214            Err(e) => return incomparable(scope, e.code()),
215            Ok(cmp) => {
216                let probabilities = match step {
217                    None => Err("no-probability-step".into()),
218                    Some(s) => cmp
219                        .supplied
220                        .iter()
221                        .find(|(st, inst, _)| st == s && inst.is_empty())
222                        .ok_or_else(|| "no-output".to_string())
223                        .and_then(|(_, _, raw)| {
224                            probabilities(&cand.plan.plan, cand.calibrations, s, raw)
225                        }),
226                };
227                Status::Comparable {
228                    outcome: CaseOutcome::of(&cmp.candidate, agreement),
229                    historical: Some(historical),
230                    probabilities,
231                    run: Box::new(r.run.clone()),
232                    plan: Box::new(cand.plan.plan.clone()),
233                }
234            }
235        },
236    };
237    CaseResult {
238        case,
239        scope,
240        status,
241    }
242}
243
244fn bump(m: &mut BTreeMap<String, u64>, k: impl Into<String>) {
245    *m.entry(k.into()).or_default() += 1;
246}
247
248fn plan_parts(plan: &Plan) -> (Vec<ArtifactId>, Vec<CalibrationId>) {
249    let mut artifacts = BTreeSet::new();
250    let mut calibrations = BTreeSet::new();
251    for s in &plan.steps {
252        if let PlanStepDetail::Semantic(b) = &s.detail {
253            artifacts.insert(b.artifact.clone());
254            if let BoundCalibration::Bound { id, .. } = &b.calibration {
255                calibrations.insert(id.clone());
256            }
257        }
258    }
259    if let PlanExecution::Declared(d) = &plan.execution {
260        artifacts.extend(d.fallbacks.iter().map(|f| f.artifact.clone()));
261    }
262    (
263        artifacts.into_iter().collect(),
264        calibrations.into_iter().collect(),
265    )
266}
267
268fn metric(name: impl Into<String>, m: Measure) -> Metric {
269    Metric {
270        name: name.into(),
271        value: match m {
272            Measure::Value { value, n } => MetricValue::Measured { value, n },
273            Measure::Unknown { reason } => MetricValue::Unknown { reason },
274        },
275    }
276}
277
278fn quantile_name(q: rustev_contract::decimal::Decimal) -> String {
279    format!("p{}", q.to_canonical_string())
280}
281
282/// Evaluate one split of a dataset.
283pub fn evaluate(inputs: &Inputs<'_>) -> Result<Evaluated, EvalError> {
284    evaluate_inner(inputs, None).map(|(e, _)| e)
285}
286
287/// Evaluate one split and write its per-case measures (spec 029, 3.2):
288/// the same report, detail and configuration as [`evaluate`], byte for
289/// byte, with a `rustev.eval-cases/1` document bound to the report and
290/// already reconciled with it.
291pub fn evaluate_with_cases(
292    inputs: &Inputs<'_>,
293    top_k: Option<&TopK>,
294) -> Result<(Evaluated, EvalCases), EvalError> {
295    let (evaluated, cases) = evaluate_inner(inputs, Some(top_k))?;
296    let cases = EvalCases {
297        schema: EVAL_CASES.into(),
298        report: evaluated.detail.report.clone(),
299        top_k: top_k.cloned(),
300        cases: cases.unwrap_or_default(),
301    };
302    eval_cases::reconcile(&evaluated, &cases).map_err(EvalError::Reconcile)?;
303    Ok((evaluated, cases))
304}
305
306/// The first `k` candidates of a proposal's ranking or shortlist parameter.
307fn top_k_of(outcome: &CaseOutcome, top_k: &TopK) -> Option<Vec<String>> {
308    let CaseOutcome::Proposal { params, .. } = outcome else {
309        return None;
310    };
311    let k = usize::try_from(top_k.k).unwrap_or(usize::MAX);
312    match params.get(&top_k.param)? {
313        OutValue::Ranking(r) => Some(
314            r.entries
315                .iter()
316                .take(k)
317                .map(|e| e.candidate.clone())
318                .collect(),
319        ),
320        OutValue::Shortlist(s) => Some(s.ids.iter().take(k).cloned().collect()),
321        _ => None,
322    }
323}
324
325fn count_measure(hit: bool) -> CaseMeasure {
326    CaseMeasure::Count {
327        num: u64::from(hit),
328        den: 1,
329    }
330}
331
332fn mean_measure(term: Result<f64, String>) -> CaseMeasure {
333    match term {
334        Ok(num) if num.is_finite() => CaseMeasure::Mean { num, den: 1 },
335        Ok(_) => CaseMeasure::Unknown {
336            reason: "non-finite value".into(),
337        },
338        Err(reason) => CaseMeasure::Unknown { reason },
339    }
340}
341
342/// `cases` is `Some` when per-case measures are wanted, with the declared
343/// top-k parameter.
344fn evaluate_inner(
345    inputs: &Inputs<'_>,
346    cases_for: Option<Option<&TopK>>,
347) -> Result<(Evaluated, Option<Vec<CaseMeasures>>), EvalError> {
348    let config = inputs.config;
349    config.check().map_err(EvalError::Config)?;
350    // Name and version always; in version 2 the rules too (spec 014, 3.2.2).
351    if config.adapter.reference() != inputs.adapter.adapter()
352        || config
353            .adapter
354            .rules
355            .as_ref()
356            .is_some_and(|r| *r != inputs.adapter.rules())
357    {
358        return Err(EvalError::Adapter);
359    }
360    let adapter = inputs.adapter;
361    inputs
362        .manifest
363        .check(&config.adapter.reference(), |l| adapter.check_label(l))
364        .map_err(EvalError::Dataset)?;
365    let cases: Vec<&DatasetCase> = inputs.manifest.split(inputs.split).collect();
366    if cases.is_empty() {
367        return Err(EvalError::EmptySplit);
368    }
369    let results: Vec<CaseResult<'_>> = cases.iter().map(|c| evaluate_case(inputs, c)).collect();
370
371    // Accounting.
372    let total = results.len() as u64;
373    let mut coverage_out = BTreeMap::new();
374    let mut abstentions = BTreeMap::new();
375    let (mut comparable, mut proposals, mut labeled, mut wrong, mut changed) = (0, 0, 0, 0, 0);
376    let mut unlabeled = BTreeMap::new();
377    let mut prob_out = BTreeMap::new();
378    let mut scored_rows: Vec<(&DatasetCase, Vec<(String, f64)>)> = vec![];
379    let mut runs: Vec<&RunRecord> = vec![];
380    let mut plans: BTreeSet<PlanId> = BTreeSet::new();
381    let mut plan_doc: Option<&Plan> = None;
382    let mut details = vec![];
383    let mut case_measures: Vec<CaseMeasures> = vec![];
384    for r in &results {
385        let mut measures = BTreeMap::new();
386        let mut run_digest = None;
387        let mut ranked = None;
388        let (outcome, reason) = match &r.status {
389            Status::Incomparable(code) => {
390                bump(&mut coverage_out, format!("incomparable:{code}"));
391                ("incomparable".to_string(), Some(code.clone()))
392            }
393            Status::Diverged => {
394                bump(&mut coverage_out, "diverged");
395                (
396                    "diverged".to_string(),
397                    Some("historical judgment diverged".into()),
398                )
399            }
400            Status::Comparable {
401                outcome,
402                historical,
403                probabilities,
404                run,
405                plan,
406            } => {
407                comparable += 1;
408                runs.push(run);
409                if cases_for.is_some() {
410                    run_digest = run.record_digest().ok();
411                    ranked = cases_for.flatten().and_then(|t| top_k_of(outcome, t));
412                    case_measures_of(
413                        &mut measures,
414                        inputs,
415                        r.case,
416                        outcome,
417                        historical.as_ref(),
418                        probabilities,
419                        run,
420                    );
421                }
422                if let Ok(id) = plan.id() {
423                    plans.insert(id);
424                }
425                plan_doc = Some(plan);
426                if let Some(h) = historical {
427                    // By outcome key: payloads outside the agreement do not count.
428                    if h.name() != outcome.name() {
429                        changed += 1;
430                    }
431                }
432                let label = r.case.label.value();
433                match outcome {
434                    CaseOutcome::Proposal { action, params, .. } => {
435                        proposals += 1;
436                        match label {
437                            Some(l) => {
438                                labeled += 1;
439                                if !adapter.correct(action, params, l) {
440                                    wrong += 1;
441                                }
442                            }
443                            None => bump(&mut unlabeled, "unlabeled"),
444                        }
445                    }
446                    CaseOutcome::Abstention { kind } => bump(&mut abstentions, kind.clone()),
447                }
448                match (label, probabilities) {
449                    (None, _) => bump(&mut prob_out, "unlabeled"),
450                    (Some(_), Err(e)) => bump(&mut prob_out, e.split(':').next().unwrap_or(e)),
451                    (Some(_), Ok(p)) => scored_rows.push((r.case, p.clone())),
452                }
453                (outcome.name(), None)
454            }
455        };
456        if cases_for.is_some() {
457            measures.insert(
458                "coverage".into(),
459                count_measure(matches!(r.status, Status::Comparable { .. })),
460            );
461            case_measures.push(CaseMeasures {
462                case: r.case.id.clone(),
463                run: run_digest,
464                outcome: outcome.clone(),
465                top_k: ranked,
466                measures,
467            });
468        }
469        details.push(CaseDetail {
470            case: r.case.id.clone(),
471            scope: r.scope.clone(),
472            outcome,
473            reason,
474        });
475    }
476    if plans.len() > 1 {
477        return Err(EvalError::MixedPlans);
478    }
479    let mut counts = vec![
480        Count {
481            metric: "coverage".into(),
482            numerator: comparable,
483            denominator: total,
484            breakdown: coverage_out,
485        },
486        Count {
487            metric: "acceptance_coverage".into(),
488            numerator: proposals,
489            denominator: comparable,
490            breakdown: abstentions,
491        },
492        Count {
493            metric: "labeled_proposal_coverage".into(),
494            numerator: labeled,
495            denominator: proposals,
496            breakdown: unlabeled,
497        },
498        Count {
499            metric: "error_among_accepted".into(),
500            numerator: wrong,
501            denominator: labeled,
502            breakdown: BTreeMap::new(),
503        },
504        Count {
505            metric: "probability_scored".into(),
506            numerator: scored_rows.len() as u64,
507            denominator: comparable,
508            breakdown: prob_out,
509        },
510    ];
511    if inputs.candidate.is_some() {
512        counts.push(Count {
513            metric: "candidate_outcome_change".into(),
514            numerator: changed,
515            denominator: comparable,
516            breakdown: BTreeMap::new(),
517        });
518    }
519
520    // Metrics, coverage first.
521    let mut ms = vec![];
522    for c in &counts {
523        if c.metric != "probability_scored" {
524            ms.push(metric(
525                &c.metric,
526                Measure::ratio(c.numerator, c.denominator),
527            ));
528        }
529    }
530    let scored: Vec<Scored<'_>> = scored_rows
531        .iter()
532        .map(|(c, p)| Scored {
533            case: &c.id,
534            probabilities: p,
535            label: c.label.value().unwrap_or_default(),
536        })
537        .collect();
538    ms.push(metric("mean_log_loss", mean_log_loss(&scored)));
539    ms.push(metric("brier", brier(&scored)));
540    let mut groups: Vec<(String, Vec<&Scored<'_>>)> = vec![("all".into(), scored.iter().collect())];
541    for g in &config.subgroups {
542        groups.push((
543            g.clone(),
544            scored
545                .iter()
546                .filter(|s| {
547                    scored_rows
548                        .iter()
549                        .any(|(c, _)| c.id == s.case && c.subgroups.contains(g))
550                })
551                .collect(),
552        ));
553    }
554    for (g, members) in &groups {
555        let owned: Vec<Scored<'_>> = members
556            .iter()
557            .map(|s| Scored {
558                case: s.case,
559                probabilities: s.probabilities,
560                label: s.label,
561            })
562            .collect();
563        for (i, b) in reliability(&owned, &config.reliability_bins)
564            .into_iter()
565            .enumerate()
566        {
567            ms.push(metric(
568                format!("reliability/{g}/{i}/confidence"),
569                b.mean_confidence,
570            ));
571            ms.push(metric(format!("reliability/{g}/{i}/accuracy"), b.accuracy));
572        }
573    }
574    let historical_only = |name: &str| {
575        metric(
576            name,
577            Measure::Unknown {
578                reason: "the candidate was not executed; latency and cost are the baseline's"
579                    .into(),
580            },
581        )
582    };
583    // A queue time beyond the elapsed time is not a measurement.
584    let service: Option<Vec<u64>> = runs
585        .iter()
586        .map(|r| r.timing.elapsed_ms.checked_sub(r.timing.queued_ms))
587        .collect();
588    let queued: Vec<u64> = runs.iter().map(|r| r.timing.queued_ms).collect();
589    for q in &config.latency_quantiles {
590        let (a, b) = (
591            format!("latency_ms/{}", quantile_name(*q)),
592            format!("queue_ms/{}", quantile_name(*q)),
593        );
594        if inputs.candidate.is_some() {
595            ms.push(historical_only(&a));
596            ms.push(historical_only(&b));
597        } else {
598            ms.push(metric(
599                a,
600                match &service {
601                    Some(v) => nearest_rank(v, *q),
602                    None => Measure::Unknown {
603                        reason: "a run records more queueing than elapsed time".into(),
604                    },
605                },
606            ));
607            ms.push(metric(b, nearest_rank(&queued, *q)));
608        }
609    }
610    let cost = metrics::cost(&runs, config.cost_units_comparable);
611    if inputs.candidate.is_some() {
612        for n in [
613            "cost_observed_units",
614            "cost_estimated_units",
615            "cost_unknown_attempts",
616            "cost_liability_units",
617        ] {
618            ms.push(historical_only(n));
619        }
620    } else {
621        ms.push(metric("cost_observed_units", cost.observed_units));
622        ms.push(metric("cost_estimated_units", cost.estimated_units));
623        let n = runs.len() as u64;
624        let count = |v: u64| {
625            if n == 0 {
626                Measure::Unknown {
627                    reason: "zero denominator".into(),
628                }
629            } else {
630                Measure::Value { value: v as f64, n }
631            }
632        };
633        ms.push(metric(
634            "cost_unknown_attempts",
635            count(cost.unknown_attempts),
636        ));
637        ms.push(metric("cost_liability_units", count(cost.liability_units)));
638    }
639    let declared: BTreeSet<&str> = config.metrics.iter().map(|m| m.name.as_str()).collect();
640    for m in &ms {
641        let base = m.name.split('/').next().unwrap_or(&m.name);
642        if !declared.contains(base) {
643            return Err(EvalError::UndeclaredMetric(base.into()));
644        }
645    }
646
647    // The envelope.
648    let plan_id = match (inputs.candidate, plans.iter().next()) {
649        (Some(c), _) => c.plan.id.clone(),
650        (None, Some(p)) => p.clone(),
651        (None, None) => inputs
652            .bundles
653            .values()
654            .find_map(|b| match b {
655                BundleInput::Loaded(b) => Some(b.plan_id.clone()),
656                BundleInput::Failed(_) => None,
657            })
658            .ok_or(EvalError::EmptySplit)?,
659    };
660    let (artifacts, calibrations) = match (inputs.candidate, plan_doc) {
661        (Some(c), _) => plan_parts(&c.plan.plan),
662        (None, Some(p)) => plan_parts(p),
663        (None, None) => (vec![], vec![]),
664    };
665    let unknown = ms
666        .iter()
667        .filter(|m| matches!(m.value, MetricValue::Unknown { .. }))
668        .count();
669    let status = if comparable == 0 {
670        ReportStatus::Incomparable {
671            reason: "no comparable case".into(),
672        }
673    } else if comparable < total || unknown > 0 {
674        ReportStatus::Incomplete {
675            reason: format!(
676                "{} of {total} cases not comparable; {unknown} metrics unknown",
677                total - comparable
678            ),
679        }
680    } else {
681        ReportStatus::Complete
682    };
683    let dataset = inputs.manifest.id().map_err(|_| EvalError::NotCanonical)?;
684    let config_id = config.id().map_err(|_| EvalError::NotCanonical)?;
685    let report = EvalReport {
686        schema: schema::EVAL_REPORT.into(),
687        plan: plan_id,
688        artifacts,
689        calibrations,
690        dataset: DatasetRef {
691            id: dataset.clone(),
692            split: inputs.split,
693            provenance: inputs.manifest.provenance.clone(),
694        },
695        evaluator_config: config_id.clone(),
696        status,
697        metrics: ms,
698    };
699    let detail = EvalDetail {
700        schema: EVAL_DETAIL.into(),
701        report: report
702            .record_digest()
703            .map_err(|_| EvalError::NotCanonical)?,
704        dataset,
705        split: inputs.split,
706        config: config_id,
707        cohort: format!(
708            "{} split of dataset {}: {total} cases{}",
709            split_name(inputs.split),
710            inputs.manifest.name,
711            if inputs.candidate.is_some() {
712                ", candidate"
713            } else {
714                ", baseline"
715            }
716        ),
717        counts,
718        cases: details,
719    };
720    detail.reconcile().map_err(EvalError::Reconcile)?;
721    Ok((
722        Evaluated {
723            report,
724            detail,
725            config: config.clone(),
726        },
727        cases_for.map(|_| case_measures),
728    ))
729}
730
731/// A comparable case's contributions to every case-decomposable metric of
732/// spec 004 (spec 029, 3.2.3), each computed by the term the report's own
733/// function adds for it.
734fn case_measures_of(
735    measures: &mut BTreeMap<String, CaseMeasure>,
736    inputs: &Inputs<'_>,
737    case: &DatasetCase,
738    outcome: &CaseOutcome,
739    historical: Option<&CaseOutcome>,
740    probabilities: &Result<Vec<(String, f64)>, String>,
741    run: &RunRecord,
742) {
743    let config = inputs.config;
744    let label = case.label.value();
745    let proposal = matches!(outcome, CaseOutcome::Proposal { .. });
746    measures.insert("acceptance_coverage".into(), count_measure(proposal));
747    if let CaseOutcome::Proposal { action, params, .. } = outcome {
748        measures.insert(
749            "labeled_proposal_coverage".into(),
750            count_measure(label.is_some()),
751        );
752        if let Some(l) = label {
753            measures.insert(
754                "error_among_accepted".into(),
755                count_measure(!inputs.adapter.correct(action, params, l)),
756            );
757        }
758    }
759    if let Some(h) = historical {
760        measures.insert(
761            "candidate_outcome_change".into(),
762            count_measure(h.name() != outcome.name()),
763        );
764    }
765    if let (Some(l), Ok(p)) = (label, probabilities) {
766        let scored = Scored {
767            case: &case.id,
768            probabilities: p,
769            label: l,
770        };
771        measures.insert("mean_log_loss".into(), mean_measure(log_loss_term(&scored)));
772        if let Some((i, conf, top)) = reliability_bin(p, &config.reliability_bins) {
773            let groups = std::iter::once("all").chain(
774                config
775                    .subgroups
776                    .iter()
777                    .map(String::as_str)
778                    .filter(|g| case.subgroups.iter().any(|s| s == g)),
779            );
780            for g in groups {
781                measures.insert(
782                    format!("reliability/{g}/{i}/confidence"),
783                    CaseMeasure::Mean { num: conf, den: 1 },
784                );
785                measures.insert(
786                    format!("reliability/{g}/{i}/accuracy"),
787                    count_measure(top == l),
788                );
789            }
790        }
791    }
792    // A candidate did not produce the run record: its latency is never
793    // measured here (spec 029, 3.11.2).
794    if inputs.candidate.is_none() {
795        for q in &config.latency_quantiles {
796            let service = match run.timing.elapsed_ms.checked_sub(run.timing.queued_ms) {
797                Some(v) => CaseMeasure::Sample { value: v as f64 },
798                None => CaseMeasure::Unknown {
799                    reason: "the run records more queueing than elapsed time".into(),
800                },
801            };
802            measures.insert(format!("latency_ms/{}", quantile_name(*q)), service);
803            measures.insert(
804                format!("queue_ms/{}", quantile_name(*q)),
805                CaseMeasure::Sample {
806                    value: run.timing.queued_ms as f64,
807                },
808            );
809        }
810        // Spec 029, 3.2.5: 1000 times the case's series cost, a mean per
811        // comparable case; unknown cost is never counted as zero. Still
812        // inside `inputs.candidate.is_none()`: a candidate's cases carry no
813        // cost measure, whatever the historical run recorded.
814        let not_comparable = || CaseMeasure::Unknown {
815            reason: "cost units are not declared comparable".into(),
816        };
817        let per_1000 = |units: u64| CaseMeasure::Mean {
818            num: 1000.0 * units as f64,
819            den: 1,
820        };
821        let unknown_charge = run.cost.liability > 0
822            || run
823                .requests
824                .iter()
825                .flat_map(|q| q.attempts.iter())
826                .any(|a| a.cost.charge == Charge::Unknown);
827        let observed = match (config.cost_units_comparable, unknown_charge) {
828            (false, _) => not_comparable(),
829            (true, true) => CaseMeasure::Unknown {
830                reason: "the case carries unknown cost liability".into(),
831            },
832            (true, false) => per_1000(run.cost.observed),
833        };
834        let estimated = if config.cost_units_comparable {
835            per_1000(run.cost.estimated)
836        } else {
837            not_comparable()
838        };
839        measures.insert(eval_cases::COST_PER_1000_OBSERVED.into(), observed);
840        measures.insert(eval_cases::COST_PER_1000_ESTIMATED.into(), estimated);
841    }
842}
843
844pub fn split_name(s: Split) -> &'static str {
845    match s {
846        Split::Training => "training",
847        Split::ModelSelection => "model_selection",
848        Split::Calibration => "calibration",
849        Split::FinalTest => "final_test",
850    }
851}
852
853impl EvalDetail {
854    pub fn count(&self, metric: &str) -> Option<&Count> {
855        self.counts.iter().find(|c| c.metric == metric)
856    }
857
858    /// Every count adds up to the named cohort: comparable plus excluded is
859    /// every case; proposals plus abstentions every comparable case;
860    /// labeled plus unlabeled every proposal; per-case outcomes agree.
861    pub fn reconcile(&self) -> Result<(), String> {
862        let get = |m: &str| self.count(m).ok_or(format!("no {m} count"));
863        let sum = |c: &Count| c.numerator + c.breakdown.values().sum::<u64>();
864        let coverage = get("coverage")?;
865        if coverage.denominator != self.cases.len() as u64 || sum(coverage) != coverage.denominator
866        {
867            return Err("coverage does not reconcile with the cases".into());
868        }
869        let accept = get("acceptance_coverage")?;
870        if accept.denominator != coverage.numerator || sum(accept) != accept.denominator {
871            return Err("acceptance does not reconcile with comparable cases".into());
872        }
873        let labeled = get("labeled_proposal_coverage")?;
874        if labeled.denominator != accept.numerator || sum(labeled) != labeled.denominator {
875            return Err("labels do not reconcile with proposals".into());
876        }
877        let error = get("error_among_accepted")?;
878        if error.denominator != labeled.numerator || error.numerator > error.denominator {
879            return Err("errors do not reconcile with labeled proposals".into());
880        }
881        let scored = get("probability_scored")?;
882        if scored.denominator != coverage.numerator || sum(scored) != scored.denominator {
883            return Err("probability scoring does not reconcile with comparable cases".into());
884        }
885        let per_case = |prefix: &str| {
886            self.cases
887                .iter()
888                .filter(|c| c.outcome.starts_with(prefix))
889                .count() as u64
890        };
891        if per_case("proposal:") != accept.numerator
892            || per_case("abstention:") != accept.denominator - accept.numerator
893            || per_case("incomparable") + per_case("diverged")
894                != coverage.denominator - coverage.numerator
895        {
896            return Err("per-case outcomes do not match the counts".into());
897        }
898        Ok(())
899    }
900}
901
902/// A gate's verdict. Unknown is never a pass.
903#[derive(Debug, Clone, PartialEq)]
904pub enum GateResult {
905    Pass,
906    Fail { reason: String },
907    Unknown { reason: String },
908}
909
910/// A gate over a baseline and a candidate evaluation. The cohorts compared
911/// are the ones the reports name; they must share dataset, split and
912/// configuration, and each detail must be bound to its report.
913pub fn evaluate_gate(gate: &Gate, baseline: &Evaluated, candidate: &Evaluated) -> GateResult {
914    let unknown = |r: &str| GateResult::Unknown { reason: r.into() };
915    for e in [baseline, candidate] {
916        if e.report.record_digest().ok().as_ref() != Some(&e.detail.report) {
917            return unknown("a detail is not bound to its report");
918        }
919        if e.config.id().ok().as_ref() != Some(&e.report.evaluator_config)
920            || e.detail.config != e.report.evaluator_config
921        {
922            return unknown("a configuration is not the report's");
923        }
924        if e.detail.dataset != e.report.dataset.id || e.detail.split != e.report.dataset.split {
925            return unknown("a detail names another dataset or split than its report");
926        }
927        if let Err(r) = e.detail.reconcile() {
928            return unknown(&format!("a detail does not reconcile: {r}"));
929        }
930        if !e.config.gates.contains(gate) {
931            return unknown("the configuration does not declare this gate");
932        }
933    }
934    // Roles: only a candidate evaluation counts outcome changes.
935    if baseline.detail.count("candidate_outcome_change").is_some()
936        || candidate.detail.count("candidate_outcome_change").is_none()
937    {
938        return unknown("the baseline and candidate roles do not hold");
939    }
940    let (b, c) = (&baseline.report, &candidate.report);
941    if b.dataset.id != c.dataset.id || b.dataset.split != c.dataset.split {
942        return unknown("the cohorts are different datasets or splits");
943    }
944    // Both sides' correctness rules bound and equal (spec 014, 3.3).
945    match (
946        baseline.config.bound_rules(),
947        candidate.config.bound_rules(),
948    ) {
949        (None, _) => return unknown("the baseline's task adapter rules are unbound"),
950        (_, None) => return unknown("the candidate's task adapter rules are unbound"),
951        (Some(x), Some(y)) if x != y => {
952            return unknown("the task adapter rules differ");
953        }
954        _ => {}
955    }
956    if b.evaluator_config != c.evaluator_config {
957        return unknown("the cohorts use different evaluator configurations");
958    }
959    // Both measurements must be over the same comparable cases: a candidate
960    // that turns hard cases incomparable is not compared on easier ones.
961    let comparable = |e: &Evaluated| -> BTreeSet<String> {
962        e.detail
963            .cases
964            .iter()
965            .filter(|c| c.outcome.starts_with("proposal:") || c.outcome.starts_with("abstention:"))
966            .map(|c| c.case.clone())
967            .collect()
968    };
969    if comparable(baseline) != comparable(candidate) {
970        return unknown("the cohorts compare different cases");
971    }
972    for e in [baseline, candidate] {
973        for (metric, min, what) in [
974            ("coverage", gate.min_comparable_coverage, "comparable"),
975            (
976                "labeled_proposal_coverage",
977                gate.min_labeled_coverage,
978                "labeled",
979            ),
980        ] {
981            let Some(count) = e.detail.count(metric).filter(|c| c.denominator > 0) else {
982                return unknown(&format!("{what} coverage has a zero denominator"));
983            };
984            // Exact: numerator / denominator >= min, in integer arithmetic.
985            let lhs = i128::from(count.numerator) * 1_000_000_000;
986            let rhs = min.units() * i128::from(count.denominator);
987            if lhs < rhs {
988                return GateResult::Fail {
989                    reason: format!("{what} coverage below the minimum"),
990                };
991            }
992        }
993    }
994    let value = |r: &EvalReport| {
995        r.metrics
996            .iter()
997            .find(|m| m.name == gate.metric)
998            .and_then(|m| match m.value {
999                MetricValue::Measured { value, .. } if value.is_finite() => Some(value),
1000                _ => None,
1001            })
1002    };
1003    let (Some(bv), Some(cv)) = (value(b), value(c)) else {
1004        return unknown("a measurement is missing, unknown or non-finite");
1005    };
1006    let tol = gate.tolerance.to_f64_nearest();
1007    let ok = match gate.direction {
1008        Direction::LowerIsBetter => cv <= bv + tol,
1009        Direction::HigherIsBetter => cv >= bv - tol,
1010    };
1011    if ok {
1012        GateResult::Pass
1013    } else {
1014        GateResult::Fail {
1015            reason: format!(
1016                "{}: candidate {cv} against baseline {bv} beyond {tol}",
1017                gate.metric
1018            ),
1019        }
1020    }
1021}
1022
1023// ---------------------------------------------------------------------------
1024// Qualification on admitted data (spec 023, 3.3 to 3.5).
1025//
1026// A qualification freezes, before any final-test case is evaluated, the
1027// evaluator configuration, bindings, baselines, calibrations, minimum sizes
1028// and analysis, and digests them. Measurement then reads spec 004 reports of
1029// the candidate and its baselines side by side, refusing any that is not of
1030// the frozen dataset, split, configuration and bindings. A claim is composed
1031// here from the frozen and admitted identities, so it cannot name another
1032// population, binding or split. Nothing here adds a metric to spec 004:
1033// figures are its counts and values, regrouped by role, slice and exposure.
1034// ---------------------------------------------------------------------------
1035
1036/// Schema of a qualification record's frozen part (spec 023, 3.3).
1037pub const QUALIFICATION: &str = "rustev.qualification/1";
1038
1039/// What a qualification fixes before the final test (spec 023, 3.3.1). Its
1040/// digest is the frozen identity. It holds no binary64 number.
1041#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1042#[serde(deny_unknown_fields)]
1043pub struct QualificationRecord {
1044    pub schema: String,
1045    /// The task qualified, as the host names it.
1046    pub task: String,
1047    pub dataset: DatasetId,
1048    /// The admission record's digest.
1049    pub admission: ContentDigest,
1050    /// The evaluator configuration: gates, thresholds and minimum coverage
1051    /// are its own (spec 004, 3.5.8).
1052    pub config: EvaluatorConfigId,
1053    pub candidate: CandidateBinding,
1054    /// The rules backend's binding (spec 005).
1055    pub rules_baseline: ArtifactId,
1056    pub exact_baseline: ExactBaseline,
1057    /// Calibration artifacts the calibrated candidate binds; empty when none.
1058    pub calibrations: Vec<CalibrationId>,
1059    /// The sample-size target: final-test cases, exposed ones excluded,
1060    /// below which every headline figure is unknown.
1061    pub min_final_test: u64,
1062    /// The minimum size of each declared slice (spec 023, 6.2).
1063    pub min_slice: BTreeMap<String, u64>,
1064    /// The metrics to be reported, each declared by the configuration.
1065    pub analysis: Vec<String>,
1066    /// The previous qualified result for the same task, for regression.
1067    pub previous: Option<ContentDigest>,
1068    /// The qualification this one replaces after a change (3.3.3).
1069    pub supersedes: Option<ContentDigest>,
1070    pub frozen_on: String,
1071}
1072
1073eval_document!(QualificationRecord, QUALIFICATION, REPLAY_V1, ContentDigest);
1074
1075/// The candidate's binding and the model it serves.
1076#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1077#[serde(deny_unknown_fields)]
1078pub struct CandidateBinding {
1079    pub artifact: ArtifactId,
1080    pub model: ModelRef,
1081}
1082
1083/// The exact baseline: the task's exact plan steps, or why the task allows
1084/// none (spec 023, 3.4.6).
1085#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1086#[serde(rename_all = "snake_case", deny_unknown_fields)]
1087pub enum ExactBaseline {
1088    Evaluated,
1089    NotApplicable { reason: String },
1090}
1091
1092/// A report's role in a qualification.
1093#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1094pub enum Role {
1095    Candidate,
1096    /// The candidate with the frozen calibration artifacts applied.
1097    CandidateCalibrated,
1098    RulesBaseline,
1099    ExactBaseline,
1100}
1101
1102impl Role {
1103    pub fn name(self) -> &'static str {
1104        match self {
1105            Role::Candidate => "candidate",
1106            Role::CandidateCalibrated => "candidate, calibrated",
1107            Role::RulesBaseline => "rules baseline",
1108            Role::ExactBaseline => "exact baseline",
1109        }
1110    }
1111}
1112
1113/// Why a qualification step refused.
1114#[derive(Debug, Clone, PartialEq, Eq)]
1115pub enum QualificationError {
1116    Schema,
1117    Incomplete(&'static str),
1118    /// No admission record for this dataset, or one for another dataset
1119    /// (spec 023, I-1): refused as qualification input.
1120    NotAdmitted,
1121    /// The configuration is not the one frozen, or cannot be frozen.
1122    Config(String),
1123    /// A frozen model labeled the data or shares a labeler's family.
1124    LabelsNotIndependent {
1125        producer: ModelRef,
1126    },
1127    /// The admission did not judge independence against this model.
1128    UndeclaredSystem,
1129    /// A calibration lacks lineage, or was fitted on or with final-test
1130    /// data: the qualification is void (spec 004, 3.6.4).
1131    CalibrationVoid {
1132        calibration: String,
1133        reason: String,
1134    },
1135    /// A slice without a frozen minimum, or a minimum for no slice.
1136    Slice(String),
1137    /// A replacement qualification reuses the old final test (3.3.3).
1138    Supersedes(String),
1139    /// A report under another configuration than the frozen one: a new
1140    /// qualification is required; this result is not the frozen one.
1141    ConfigChanged {
1142        frozen: EvaluatorConfigId,
1143        found: EvaluatorConfigId,
1144    },
1145    /// A report that is not of the frozen dataset, split, cases or
1146    /// bindings, a role missing or repeated, or a detail not bound to it.
1147    Report {
1148        role: &'static str,
1149        reason: String,
1150    },
1151}
1152
1153impl fmt::Display for QualificationError {
1154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1155        write!(f, "{self:?}")
1156    }
1157}
1158
1159impl std::error::Error for QualificationError {}
1160
1161/// A checked, digested freeze. Only [`freeze`] makes one.
1162#[derive(Debug, Clone)]
1163pub struct Frozen {
1164    record: QualificationRecord,
1165    id: ContentDigest,
1166}
1167
1168impl Frozen {
1169    pub fn record(&self) -> &QualificationRecord {
1170        &self.record
1171    }
1172    /// The frozen record's digest.
1173    pub fn id(&self) -> &ContentDigest {
1174        &self.id
1175    }
1176}
1177
1178/// What a freeze takes.
1179pub struct FreezeInputs<'a> {
1180    pub record: &'a QualificationRecord,
1181    pub manifest: &'a DatasetManifest,
1182    /// The dataset's checked admission; none when there is no record.
1183    pub admission: Option<&'a crate::dataset::Admitted>,
1184    pub config: &'a EvaluatorConfig,
1185    /// Every frozen calibration artifact with its fit record, if any.
1186    pub calibrations: &'a [(CalibrationArtifact, Option<CalibrationFit>)],
1187    /// The record and manifest of the qualification this one supersedes.
1188    pub superseded: Option<(&'a QualificationRecord, &'a DatasetManifest)>,
1189}
1190
1191fn admitted_for<'a>(
1192    admission: Option<&'a crate::dataset::Admitted>,
1193    manifest: &DatasetManifest,
1194) -> Result<&'a crate::dataset::Admitted, QualificationError> {
1195    let a = admission.ok_or(QualificationError::NotAdmitted)?;
1196    if manifest.id().ok().as_ref() != Some(a.dataset()) {
1197        return Err(QualificationError::NotAdmitted);
1198    }
1199    Ok(a)
1200}
1201
1202/// Freeze a qualification before any final-test case is evaluated (spec
1203/// 023, 3.3). Refuses unadmitted data, a configuration other than the
1204/// frozen one or with unbound rules, a candidate whose family labeled the
1205/// data, a calibration without lineage or touching the final test, slices
1206/// without minimums, and a replacement that reuses the old final test.
1207pub fn freeze(inputs: &FreezeInputs<'_>) -> Result<Frozen, QualificationError> {
1208    use QualificationError as E;
1209    let r = inputs.record;
1210    if r.schema != QUALIFICATION {
1211        return Err(E::Schema);
1212    }
1213    let blank = |s: &str| s.trim().is_empty();
1214    if blank(&r.task) || blank(&r.frozen_on) {
1215        return Err(E::Incomplete("task and freeze date"));
1216    }
1217    let admitted = admitted_for(inputs.admission, inputs.manifest)?;
1218    if &r.dataset != admitted.dataset() || &r.admission != admitted.id() {
1219        return Err(E::NotAdmitted);
1220    }
1221    let config = inputs.config;
1222    config.check().map_err(|e| E::Config(e.0))?;
1223    if config.id().ok().as_ref() != Some(&r.config) {
1224        return Err(E::Config("the configuration is not the frozen one".into()));
1225    }
1226    let mapping = &admitted.record().task_mapping;
1227    if config.bound_rules() != Some(&mapping.rules) {
1228        return Err(E::Config(
1229            "the configuration does not bind the admitted adapter rules".into(),
1230        ));
1231    }
1232    if config.adapter.reference() != mapping.adapter {
1233        return Err(E::Config("the configuration names another adapter".into()));
1234    }
1235    let declared: BTreeSet<&str> = config.metrics.iter().map(|m| m.name.as_str()).collect();
1236    if r.analysis.is_empty()
1237        || r.analysis
1238            .iter()
1239            .any(|m| !declared.contains(m.split('/').next().unwrap_or_default()))
1240    {
1241        return Err(E::Config(
1242            "the analysis names a metric the configuration does not declare".into(),
1243        ));
1244    }
1245    if let Some(producer) = admitted.related_labeler(&r.candidate.model) {
1246        return Err(E::LabelsNotIndependent {
1247            producer: producer.clone(),
1248        });
1249    }
1250    if !admitted.declares_system(&r.candidate.model) {
1251        return Err(E::UndeclaredSystem);
1252    }
1253    if let ExactBaseline::NotApplicable { reason } = &r.exact_baseline
1254        && blank(reason)
1255    {
1256        return Err(E::Incomplete("why no exact baseline"));
1257    }
1258    if r.min_final_test == 0 {
1259        return Err(E::Incomplete("the final-test sample-size target"));
1260    }
1261    let slices: BTreeSet<&str> = admitted
1262        .record()
1263        .population
1264        .slices
1265        .iter()
1266        .map(|s| s.name.as_str())
1267        .collect();
1268    let minimums: BTreeSet<&str> = r.min_slice.keys().map(String::as_str).collect();
1269    if slices != minimums || r.min_slice.values().any(|m| *m == 0) {
1270        return Err(E::Slice(
1271            "every declared slice, and only those, has a positive minimum".into(),
1272        ));
1273    }
1274    if let Some(s) = slices
1275        .iter()
1276        .find(|s| !config.subgroups.iter().any(|g| g == *s))
1277    {
1278        return Err(E::Slice(format!(
1279            "slice {s} is not a configuration subgroup"
1280        )));
1281    }
1282    // Calibrations: exactly the frozen ones, each with lineage, fitted off
1283    // the final test (spec 004, 3.6.4).
1284    let mut frozen_cals: Vec<&CalibrationId> = r.calibrations.iter().collect();
1285    frozen_cals.sort();
1286    let mut given = vec![];
1287    for (artifact, fit) in inputs.calibrations {
1288        let id = artifact
1289            .id()
1290            .map_err(|_| E::Incomplete("a calibration artifact's identity"))?;
1291        let void = |reason: String| E::CalibrationVoid {
1292            calibration: id.to_string(),
1293            reason,
1294        };
1295        match crate::fit::qualify(artifact, fit.as_ref(), inputs.manifest, Split::FinalTest) {
1296            crate::fit::Qualification::Qualified => {}
1297            crate::fit::Qualification::Unknown { reason }
1298            | crate::fit::Qualification::Refused { reason } => return Err(void(reason)),
1299        }
1300        if fit.as_ref().is_some_and(|f| f.split != Split::Calibration) {
1301            return Err(void("not fitted on the calibration split".into()));
1302        }
1303        given.push(id);
1304    }
1305    given.sort();
1306    if given.iter().collect::<Vec<_>>() != frozen_cals {
1307        return Err(E::Config(
1308            "the calibrations supplied are not exactly the frozen ones".into(),
1309        ));
1310    }
1311    match (&r.supersedes, inputs.superseded) {
1312        (None, None) => {}
1313        (Some(old_id), Some((old, old_manifest))) => {
1314            if old.id().ok().as_ref() != Some(old_id) {
1315                return Err(E::Supersedes(
1316                    "the superseded record's digest differs".into(),
1317                ));
1318            }
1319            if old_manifest.id().ok().as_ref() != Some(&old.dataset) {
1320                return Err(E::Supersedes(
1321                    "the superseded manifest is not its dataset".into(),
1322                ));
1323            }
1324            for c in inputs.manifest.split(Split::FinalTest) {
1325                if old_manifest
1326                    .split(Split::FinalTest)
1327                    .any(|o| o.source == c.source || o.snapshot == c.snapshot)
1328                {
1329                    return Err(E::Supersedes(format!(
1330                        "final-test case {} reuses the superseded final test",
1331                        c.id
1332                    )));
1333                }
1334            }
1335        }
1336        _ => {
1337            return Err(E::Supersedes(
1338                "a replacement names, and is given, what it supersedes".into(),
1339            ));
1340        }
1341    }
1342    let id = r.id().map_err(|_| E::Incomplete("canonical form"))?;
1343    Ok(Frozen {
1344        record: r.clone(),
1345        id,
1346    })
1347}
1348
1349/// One figure. A ratio carries its numerator and denominator; a value is a
1350/// spec 004 measurement with its count. `Unknown` carries the reason a
1351/// figure cannot be given (for example a zero denominator, a slice below its
1352/// minimum, or exposed final-test cases); it is never replaced by a number, and
1353/// `scope_claim` refuses to compose a claim from it.
1354#[derive(Debug, Clone, PartialEq)]
1355pub enum Figure {
1356    Ratio { numerator: u64, denominator: u64 },
1357    Value { value: f64, n: u64 },
1358    Unknown { reason: String },
1359}
1360
1361impl Figure {
1362    fn ratio(numerator: u64, denominator: u64) -> Figure {
1363        if denominator == 0 {
1364            Figure::Unknown {
1365                reason: "zero denominator".into(),
1366            }
1367        } else {
1368            Figure::Ratio {
1369                numerator,
1370                denominator,
1371            }
1372        }
1373    }
1374}
1375
1376#[derive(Debug, Clone, PartialEq)]
1377pub struct Row {
1378    pub name: String,
1379    pub figure: Figure,
1380}
1381
1382/// One slice's figures.
1383#[derive(Debug, Clone, PartialEq)]
1384pub struct SliceRows {
1385    pub slice: String,
1386    /// Final-test cases in the slice, exposed ones excluded.
1387    pub cases: u64,
1388    pub rows: Vec<Row>,
1389}
1390
1391/// One role's figures: coverage and abstention rows always come before
1392/// accuracy and probability rows (spec 023, R-1).
1393#[derive(Debug, Clone, PartialEq)]
1394pub struct RoleFigures {
1395    pub role: Role,
1396    /// The report's record digest.
1397    pub report: ContentDigest,
1398    pub headline: Vec<Row>,
1399    pub slices: Vec<SliceRows>,
1400    /// Exposed final-test cases, reported separately (spec 023, 3.2.1).
1401    pub exposed: Vec<Row>,
1402}
1403
1404/// The side-by-side measurement of one frozen qualification.
1405#[derive(Debug, Clone, PartialEq)]
1406pub struct Measurement {
1407    qualification: ContentDigest,
1408    pub roles: Vec<RoleFigures>,
1409}
1410
1411impl Measurement {
1412    /// The frozen qualification measured.
1413    pub fn qualification(&self) -> &ContentDigest {
1414        &self.qualification
1415    }
1416    pub fn role(&self, role: Role) -> Option<&RoleFigures> {
1417        self.roles.iter().find(|r| r.role == role)
1418    }
1419}
1420
1421/// Coverage, exclusions, acceptance, labels and abstentions over `cases`,
1422/// from the detail's per-case outcomes and the manifest's labels.
1423fn count_rows(detail: &EvalDetail, manifest: &DatasetManifest, cases: &BTreeSet<&str>) -> Vec<Row> {
1424    let mut excluded: BTreeMap<String, u64> = BTreeMap::new();
1425    let mut abstentions: BTreeMap<String, u64> = BTreeMap::new();
1426    let (mut comparable, mut proposals, mut labeled) = (0u64, 0u64, 0u64);
1427    for d in detail
1428        .cases
1429        .iter()
1430        .filter(|d| cases.contains(d.case.as_str()))
1431    {
1432        if let Some(kind) = d.outcome.strip_prefix("abstention:") {
1433            comparable += 1;
1434            bump(&mut abstentions, kind);
1435        } else if d.outcome.starts_with("proposal:") {
1436            comparable += 1;
1437            proposals += 1;
1438            let label = manifest.cases.iter().find(|c| c.id == d.case);
1439            if label.is_some_and(|c| c.label.value().is_some()) {
1440                labeled += 1;
1441            }
1442        } else {
1443            let reason = match (&d.outcome[..], &d.reason) {
1444                ("incomparable", Some(r)) => format!("incomparable:{r}"),
1445                (o, _) => o.to_string(),
1446            };
1447            bump(&mut excluded, reason);
1448        }
1449    }
1450    let n = cases.len() as u64;
1451    let mut rows = vec![Row {
1452        name: "coverage".into(),
1453        figure: Figure::ratio(comparable, n),
1454    }];
1455    for (reason, k) in excluded {
1456        rows.push(Row {
1457            name: format!("excluded/{reason}"),
1458            figure: Figure::ratio(k, n),
1459        });
1460    }
1461    rows.push(Row {
1462        name: "acceptance_coverage".into(),
1463        figure: Figure::ratio(proposals, comparable),
1464    });
1465    for (kind, k) in abstentions {
1466        rows.push(Row {
1467            name: format!("abstention/{kind}"),
1468            figure: Figure::ratio(k, comparable),
1469        });
1470    }
1471    rows.push(Row {
1472        name: "labeled_proposal_coverage".into(),
1473        figure: Figure::ratio(labeled, proposals),
1474    });
1475    rows
1476}
1477
1478/// The report's own values for `names` and series under `prefix`, or
1479/// unknown with `blocked` when they may not be shown.
1480fn value_rows(
1481    report: &EvalReport,
1482    names: &[&str],
1483    prefix: &str,
1484    blocked: Option<&str>,
1485) -> Vec<Row> {
1486    report
1487        .metrics
1488        .iter()
1489        .filter(|m| names.contains(&m.name.as_str()) || m.name.starts_with(prefix))
1490        .map(|m| Row {
1491            name: m.name.clone(),
1492            figure: match (blocked, &m.value) {
1493                (Some(r), _) => Figure::Unknown { reason: r.into() },
1494                (None, MetricValue::Measured { value, n }) if value.is_finite() => Figure::Value {
1495                    value: *value,
1496                    n: *n,
1497                },
1498                (None, MetricValue::Measured { .. }) => Figure::Unknown {
1499                    reason: "non-finite".into(),
1500                },
1501                (None, MetricValue::Unknown { reason }) => Figure::Unknown {
1502                    reason: reason.clone(),
1503                },
1504            },
1505        })
1506        .collect()
1507}
1508
1509fn all_unknown(rows: Vec<Row>, reason: &str) -> Vec<Row> {
1510    rows.into_iter()
1511        .map(|r| Row {
1512            name: r.name,
1513            figure: Figure::Unknown {
1514                reason: reason.into(),
1515            },
1516        })
1517        .collect()
1518}
1519
1520/// Measure a frozen qualification from spec 004 reports of its roles on the
1521/// final-test split (spec 023, 3.4). Every report must be of the admitted
1522/// dataset, the final-test split, every final-test case and the frozen
1523/// configuration and bindings; a report under another configuration is
1524/// [`QualificationError::ConfigChanged`]. Exposed cases are counted apart
1525/// and keep every accuracy and probability figure unknown, since a spec 004
1526/// report cannot leave them out. A slice below its frozen minimum, and a
1527/// headline below the sample-size target, is unknown, never a figure.
1528pub fn measure(
1529    frozen: &Frozen,
1530    admission: Option<&crate::dataset::Admitted>,
1531    manifest: &DatasetManifest,
1532    reports: &[(Role, &Evaluated)],
1533) -> Result<Measurement, QualificationError> {
1534    use QualificationError as E;
1535    let admitted = admitted_for(admission, manifest)?;
1536    let r = &frozen.record;
1537    if &r.dataset != admitted.dataset() || &r.admission != admitted.id() {
1538        return Err(E::NotAdmitted);
1539    }
1540    let mut expected = vec![Role::Candidate, Role::RulesBaseline];
1541    if !r.calibrations.is_empty() {
1542        expected.push(Role::CandidateCalibrated);
1543    }
1544    if r.exact_baseline == ExactBaseline::Evaluated {
1545        expected.push(Role::ExactBaseline);
1546    }
1547    expected.sort();
1548    let mut given: Vec<Role> = reports.iter().map(|(role, _)| *role).collect();
1549    given.sort();
1550    if given != expected {
1551        return Err(E::Report {
1552            role: "all",
1553            reason: format!("roles {given:?} are not the frozen {expected:?}"),
1554        });
1555    }
1556    let final_test: BTreeSet<&str> = manifest
1557        .split(Split::FinalTest)
1558        .map(|c| c.id.as_str())
1559        .collect();
1560    let exposed: BTreeSet<&str> = admitted
1561        .record()
1562        .exposure
1563        .exposed_final_test
1564        .iter()
1565        .map(String::as_str)
1566        .collect();
1567    let cohort: BTreeSet<&str> = final_test.difference(&exposed).copied().collect();
1568    let mut frozen_cals = r.calibrations.clone();
1569    frozen_cals.sort();
1570    let mut roles = vec![];
1571    for (role, e) in reports {
1572        let bad = |reason: &str| E::Report {
1573            role: role.name(),
1574            reason: reason.into(),
1575        };
1576        let digest = e
1577            .report
1578            .record_digest()
1579            .map_err(|_| bad("the report has no record form"))?;
1580        if digest != e.detail.report {
1581            return Err(bad("the detail is not bound to its report"));
1582        }
1583        if e.config.id().ok().as_ref() != Some(&e.report.evaluator_config)
1584            || e.detail.config != e.report.evaluator_config
1585        {
1586            return Err(bad("the configuration is not the report's"));
1587        }
1588        if e.report.evaluator_config != r.config {
1589            return Err(E::ConfigChanged {
1590                frozen: r.config.clone(),
1591                found: e.report.evaluator_config.clone(),
1592            });
1593        }
1594        if &e.report.dataset.id != admitted.dataset()
1595            || e.detail.dataset != e.report.dataset.id
1596            || e.report.dataset.split != Split::FinalTest
1597            || e.detail.split != Split::FinalTest
1598            || e.report.dataset.provenance != manifest.provenance
1599        {
1600            return Err(bad("not the admitted dataset's final-test split"));
1601        }
1602        e.detail.reconcile().map_err(|x| bad(&x))?;
1603        let cases: BTreeSet<&str> = e.detail.cases.iter().map(|c| c.case.as_str()).collect();
1604        if cases != final_test || cases.len() != e.detail.cases.len() {
1605            return Err(bad("the report is not over every final-test case once"));
1606        }
1607        let names = |a: &ArtifactId| e.report.artifacts.contains(a);
1608        let mut cals = e.report.calibrations.clone();
1609        cals.sort();
1610        let binding_ok = match role {
1611            Role::Candidate => names(&r.candidate.artifact) && cals.is_empty(),
1612            Role::CandidateCalibrated => names(&r.candidate.artifact) && cals == frozen_cals,
1613            Role::RulesBaseline => names(&r.rules_baseline),
1614            Role::ExactBaseline => e.report.artifacts.is_empty(),
1615        };
1616        if !binding_ok {
1617            return Err(bad("the report's bindings are not the frozen ones"));
1618        }
1619        let small = format!(
1620            "{} final-test cases, below the frozen target {}",
1621            cohort.len(),
1622            r.min_final_test
1623        );
1624        let below = (cohort.len() as u64) < r.min_final_test;
1625        let blocked = if !exposed.is_empty() {
1626            Some("the report's cohort includes exposed cases")
1627        } else {
1628            None
1629        };
1630        let mut headline = count_rows(&e.detail, manifest, &cohort);
1631        headline.extend(value_rows(
1632            &e.report,
1633            &["error_among_accepted", "mean_log_loss", "brier"],
1634            "reliability/all/",
1635            blocked,
1636        ));
1637        if below {
1638            headline = all_unknown(headline, &small);
1639        }
1640        let mut slices = vec![];
1641        for s in &admitted.record().population.slices {
1642            let members: BTreeSet<&str> = manifest
1643                .split(Split::FinalTest)
1644                .filter(|c| cohort.contains(c.id.as_str()) && c.subgroups.contains(&s.name))
1645                .map(|c| c.id.as_str())
1646                .collect();
1647            let min = r.min_slice.get(&s.name).copied().unwrap_or(u64::MAX);
1648            let mut rows = count_rows(&e.detail, manifest, &members);
1649            rows.push(Row {
1650                name: "error_among_accepted".into(),
1651                figure: Figure::Unknown {
1652                    reason: "a spec 004 report states no per-slice error".into(),
1653                },
1654            });
1655            rows.extend(value_rows(
1656                &e.report,
1657                &[],
1658                &format!("reliability/{}/", s.name),
1659                blocked,
1660            ));
1661            if (members.len() as u64) < min {
1662                rows = all_unknown(
1663                    rows,
1664                    &format!("{} cases, below the frozen minimum {min}", members.len()),
1665                );
1666            }
1667            slices.push(SliceRows {
1668                slice: s.name.clone(),
1669                cases: members.len() as u64,
1670                rows,
1671            });
1672        }
1673        let exposed_rows = if exposed.is_empty() {
1674            vec![]
1675        } else {
1676            count_rows(&e.detail, manifest, &exposed)
1677        };
1678        roles.push(RoleFigures {
1679            role: *role,
1680            report: digest,
1681            headline,
1682            slices,
1683            exposed: exposed_rows,
1684        });
1685    }
1686    roles.sort_by_key(|x| x.role);
1687    Ok(Measurement {
1688        qualification: frozen.id.clone(),
1689        roles,
1690    })
1691}
1692
1693/// Regression against the previous qualified result for the same task
1694/// (spec 023, 3.4.5): each frozen gate over the previous result's report
1695/// (baseline role) and this one's (candidate role), by spec 004's own
1696/// [`evaluate_gate`], which is unknown unless both share dataset, split and
1697/// configuration. Empty when the freeze names no previous result; unknown
1698/// when it names one that is not supplied.
1699pub fn regression(
1700    frozen: &Frozen,
1701    previous: Option<(&Frozen, &Evaluated)>,
1702    current: &Evaluated,
1703) -> Vec<(String, GateResult)> {
1704    let Some(want) = &frozen.record.previous else {
1705        return vec![];
1706    };
1707    let unknown = |reason: &str| {
1708        current
1709            .config
1710            .gates
1711            .iter()
1712            .map(|g| {
1713                (
1714                    g.name.clone(),
1715                    GateResult::Unknown {
1716                        reason: reason.into(),
1717                    },
1718                )
1719            })
1720            .collect()
1721    };
1722    let Some((old, baseline)) = previous else {
1723        return unknown("the previous qualified result was not supplied");
1724    };
1725    if old.id() != want || old.record.task != frozen.record.task {
1726        return unknown("the supplied result is not the frozen previous one");
1727    }
1728    current
1729        .config
1730        .gates
1731        .iter()
1732        .map(|g| (g.name.clone(), evaluate_gate(g, baseline, current)))
1733        .collect()
1734}
1735
1736/// The served model identity, as observed for the calls a claim covers
1737/// (spec 012, 3.6.3).
1738#[derive(Debug, Clone, PartialEq, Eq)]
1739pub enum Served {
1740    Observed { version: String },
1741    Unknown,
1742}
1743
1744/// What a claim asks to state. Its scope is not free text: dataset, split,
1745/// configuration and binding come from the freeze, and the population must
1746/// be the admitted one.
1747pub struct ClaimRequest<'a> {
1748    pub role: Role,
1749    /// A headline row name, or a slice row name with `slice`.
1750    pub figure: &'a str,
1751    pub slice: Option<&'a str>,
1752    pub population: &'a str,
1753    pub call_dates: &'a str,
1754    pub served: &'a Served,
1755}
1756
1757/// A claim sentence and the limits that bound it (spec 023, 3.5).
1758#[derive(Debug, Clone, PartialEq, Eq)]
1759pub struct ScopedClaim {
1760    pub sentence: String,
1761    pub limits: Vec<String>,
1762}
1763
1764/// Why a claim was rejected.
1765#[derive(Debug, Clone, PartialEq, Eq)]
1766pub enum ClaimError {
1767    /// The dataset has no admission record, or the freeze another one.
1768    NotAdmitted,
1769    /// The measurement is of another freeze.
1770    NotThisQualification,
1771    /// The claim names another population than the admitted one.
1772    PopulationOutside {
1773        claimed: String,
1774        admitted: String,
1775    },
1776    Incomplete(&'static str),
1777    NoSuchFigure,
1778    /// An unknown figure is never claimed as a figure.
1779    UnknownFigure {
1780        reason: String,
1781    },
1782}
1783
1784impl fmt::Display for ClaimError {
1785    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1786        write!(f, "{self:?}")
1787    }
1788}
1789
1790impl std::error::Error for ClaimError {}
1791
1792/// Compose a scoped claim (spec 023, 3.5, I-3). The sentence names the
1793/// dataset id, split, evaluator configuration id, binding identity, call
1794/// dates, served identity disclosure and population; the limits carry the
1795/// admission's stated limits. Anything beyond them is rejected.
1796pub fn scope_claim(
1797    admission: Option<&crate::dataset::Admitted>,
1798    frozen: &Frozen,
1799    measurement: &Measurement,
1800    request: &ClaimRequest<'_>,
1801) -> Result<ScopedClaim, ClaimError> {
1802    let admitted = admission.ok_or(ClaimError::NotAdmitted)?;
1803    let r = &frozen.record;
1804    if &r.dataset != admitted.dataset() || &r.admission != admitted.id() {
1805        return Err(ClaimError::NotAdmitted);
1806    }
1807    if measurement.qualification() != frozen.id() {
1808        return Err(ClaimError::NotThisQualification);
1809    }
1810    let a = admitted.record();
1811    if request.population != a.population.id {
1812        return Err(ClaimError::PopulationOutside {
1813            claimed: request.population.into(),
1814            admitted: a.population.id.clone(),
1815        });
1816    }
1817    if request.call_dates.trim().is_empty() {
1818        return Err(ClaimError::Incomplete("call dates"));
1819    }
1820    if let Served::Observed { version } = request.served
1821        && version.trim().is_empty()
1822    {
1823        return Err(ClaimError::Incomplete("served version"));
1824    }
1825    let figures = measurement
1826        .role(request.role)
1827        .ok_or(ClaimError::NoSuchFigure)?;
1828    let rows = match request.slice {
1829        None => &figures.headline,
1830        Some(s) => {
1831            &figures
1832                .slices
1833                .iter()
1834                .find(|x| x.slice == s)
1835                .ok_or(ClaimError::NoSuchFigure)?
1836                .rows
1837        }
1838    };
1839    let row = rows
1840        .iter()
1841        .find(|x| x.name == request.figure)
1842        .ok_or(ClaimError::NoSuchFigure)?;
1843    let value = match &row.figure {
1844        Figure::Unknown { reason } => {
1845            return Err(ClaimError::UnknownFigure {
1846                reason: reason.clone(),
1847            });
1848        }
1849        Figure::Ratio {
1850            numerator,
1851            denominator,
1852        } => format!("{numerator}/{denominator}"),
1853        Figure::Value { value, n } => format!("{value} (n = {n})"),
1854    };
1855    let binding = match request.role {
1856        Role::Candidate => format!("binding {}", r.candidate.artifact),
1857        Role::CandidateCalibrated => {
1858            let cals: Vec<String> = r.calibrations.iter().map(ToString::to_string).collect();
1859            format!(
1860                "binding {} with calibration {}",
1861                r.candidate.artifact,
1862                cals.join(", ")
1863            )
1864        }
1865        Role::RulesBaseline => format!("rules baseline binding {}", r.rules_baseline),
1866        Role::ExactBaseline => "the exact plan steps (no semantic binding)".into(),
1867    };
1868    let served = match request.served {
1869        Served::Observed { version } => format!("served identity {version}"),
1870        Served::Unknown => "served identity not observable".into(),
1871    };
1872    let slice = request
1873        .slice
1874        .map(|s| format!(" in slice {s}"))
1875        .unwrap_or_default();
1876    let sentence = format!(
1877        "On dataset {}, split final_test, evaluator configuration {}, {binding}, calls {}, \
1878         {served}, population {}: {} ({}){slice} is {value}.",
1879        r.dataset,
1880        r.config,
1881        request.call_dates,
1882        a.population.id,
1883        request.figure,
1884        request.role.name(),
1885    );
1886    let mut limits = vec![
1887        format!("population: {}", a.population.limitations),
1888        format!("near-duplicates: {}", a.splits.near_duplicates),
1889        "no claim about another population, another binding, real user data not represented, \
1890         or a served model version not observed"
1891            .to_string(),
1892        "point estimates without intervals (spec 023, 6.3)".to_string(),
1893    ];
1894    if !a.exposure.training_data.trim().is_empty() {
1895        limits.push(format!(
1896            "training-data exposure: {}",
1897            a.exposure.training_data
1898        ));
1899    }
1900    if !a.exposure.exposed_final_test.is_empty() {
1901        limits.push(format!(
1902            "{} exposed final-test cases are reported apart and excluded",
1903            a.exposure.exposed_final_test.len()
1904        ));
1905    }
1906    if let Some(c) = &a.exposure.ranking_caveat {
1907        limits.push(format!("ranking exposure: {c}"));
1908    }
1909    if *request.served == Served::Unknown {
1910        limits.push(
1911            "a change of served model version during the calls would not have been visible".into(),
1912        );
1913    }
1914    Ok(ScopedClaim { sentence, limits })
1915}