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