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    scores_refusal(declared, scores, views, &digest).map_err(|c| EvalError::Scores(c.into()))
1149}
1150
1151/// [`check_scores`] over views whose record digest is `digest`, returning
1152/// the refusal code; spec 061 applies it to a host run's own views.
1153pub(crate) fn scores_refusal(
1154    declared: &Declarations<'_>,
1155    scores: &[Scores],
1156    views: &ScoreViews,
1157    digest: &ContentDigest,
1158) -> Result<(), &'static str> {
1159    let mut seen = BTreeSet::new();
1160    for d in scores {
1161        if !declared.scorers.contains(&d.scorer) {
1162            return Err("scorer-undeclared");
1163        }
1164        if !seen.insert(d.scorer.id.as_str()) {
1165            return Err("scores-duplicate");
1166        }
1167        let same_entries = d.entries.len() == views.cases.len()
1168            && d.entries
1169                .iter()
1170                .zip(&views.cases)
1171                .all(|(s, v)| s.case == v.case && s.subject == v.subject);
1172        if d.schema != SCORES || d.views != *digest || !same_entries {
1173            return Err("scores-mismatch");
1174        }
1175    }
1176    Ok(())
1177}
1178
1179/// A comparable case's contributions to every case-decomposable metric of
1180/// spec 004 (spec 029, 3.2.3), each computed by the term the report's own
1181/// function adds for it.
1182fn case_measures_of(
1183    measures: &mut BTreeMap<String, CaseMeasure>,
1184    inputs: &Evaluation<'_>,
1185    case: &DatasetCase,
1186    outcome: &CaseOutcome,
1187    historical: Option<&CaseOutcome>,
1188    probabilities: &Result<Vec<(String, f64)>, String>,
1189    run: &RunRecord,
1190) {
1191    let config = inputs.config;
1192    let label = case.label.value();
1193    let proposal = matches!(outcome, CaseOutcome::Proposal { .. });
1194    measures.insert("acceptance_coverage".into(), count_measure(proposal));
1195    if let CaseOutcome::Proposal { action, params, .. } = outcome {
1196        measures.insert(
1197            "labeled_proposal_coverage".into(),
1198            count_measure(label.is_some()),
1199        );
1200        if let Some(l) = label {
1201            measures.insert(
1202                "error_among_accepted".into(),
1203                count_measure(!inputs.adapter.correct(action, params, l)),
1204            );
1205        }
1206    }
1207    if let Some(h) = historical {
1208        measures.insert(
1209            "candidate_outcome_change".into(),
1210            count_measure(h.name() != outcome.name()),
1211        );
1212    }
1213    if let (Some(l), Ok(p)) = (label, probabilities) {
1214        let scored = Scored {
1215            case: &case.id,
1216            probabilities: p,
1217            label: l,
1218        };
1219        measures.insert("mean_log_loss".into(), mean_measure(log_loss_term(&scored)));
1220        if let Some((i, conf, top)) = reliability_bin(p, &config.reliability_bins) {
1221            let groups = std::iter::once("all").chain(
1222                config
1223                    .subgroups
1224                    .iter()
1225                    .map(String::as_str)
1226                    .filter(|g| case.subgroups.iter().any(|s| s == g)),
1227            );
1228            for g in groups {
1229                measures.insert(
1230                    format!("reliability/{g}/{i}/confidence"),
1231                    CaseMeasure::Mean { num: conf, den: 1 },
1232                );
1233                measures.insert(
1234                    format!("reliability/{g}/{i}/accuracy"),
1235                    count_measure(top == l),
1236                );
1237            }
1238        }
1239    }
1240    // A candidate did not produce the run record: its latency is never
1241    // measured here (spec 029, 3.11.2).
1242    if inputs.candidate.is_none() {
1243        for q in &config.latency_quantiles {
1244            let service = match run.timing.elapsed_ms.checked_sub(run.timing.queued_ms) {
1245                Some(v) => CaseMeasure::Sample { value: v as f64 },
1246                None => CaseMeasure::Unknown {
1247                    reason: "the run records more queueing than elapsed time".into(),
1248                },
1249            };
1250            measures.insert(format!("latency_ms/{}", quantile_name(*q)), service);
1251            measures.insert(
1252                format!("queue_ms/{}", quantile_name(*q)),
1253                CaseMeasure::Sample {
1254                    value: run.timing.queued_ms as f64,
1255                },
1256            );
1257        }
1258        // Spec 029, 3.2.5: 1000 times the case's series cost, a mean per
1259        // comparable case; unknown cost is never counted as zero. Still
1260        // inside `inputs.candidate.is_none()`: a candidate's cases carry no
1261        // cost measure, whatever the historical run recorded.
1262        let not_comparable = || CaseMeasure::Unknown {
1263            reason: "cost units are not declared comparable".into(),
1264        };
1265        let per_1000 = |units: u64| CaseMeasure::Mean {
1266            num: 1000.0 * units as f64,
1267            den: 1,
1268        };
1269        let unknown_charge = run.cost.liability > 0
1270            || run
1271                .requests
1272                .iter()
1273                .flat_map(|q| q.attempts.iter())
1274                .any(|a| a.cost.charge == Charge::Unknown);
1275        let observed = match (config.cost_units_comparable, unknown_charge) {
1276            (false, _) => not_comparable(),
1277            (true, true) => CaseMeasure::Unknown {
1278                reason: "the case carries unknown cost liability".into(),
1279            },
1280            (true, false) => per_1000(run.cost.observed),
1281        };
1282        let estimated = if config.cost_units_comparable {
1283            per_1000(run.cost.estimated)
1284        } else {
1285            not_comparable()
1286        };
1287        measures.insert(eval_cases::COST_PER_1000_OBSERVED.into(), observed);
1288        measures.insert(eval_cases::COST_PER_1000_ESTIMATED.into(), estimated);
1289    }
1290}
1291
1292pub fn split_name(s: Split) -> &'static str {
1293    match s {
1294        Split::Training => "training",
1295        Split::ModelSelection => "model_selection",
1296        Split::Calibration => "calibration",
1297        Split::FinalTest => "final_test",
1298    }
1299}
1300
1301impl EvalDetail {
1302    pub fn count(&self, metric: &str) -> Option<&Count> {
1303        self.counts.iter().find(|c| c.metric == metric)
1304    }
1305
1306    /// Every count adds up to the named cohort: comparable plus excluded is
1307    /// every case; proposals plus abstentions every comparable case;
1308    /// labeled plus unlabeled every proposal; per-case outcomes agree.
1309    pub fn reconcile(&self) -> Result<(), String> {
1310        let get = |m: &str| self.count(m).ok_or(format!("no {m} count"));
1311        let sum = |c: &Count| c.numerator + c.breakdown.values().sum::<u64>();
1312        let coverage = get("coverage")?;
1313        if coverage.denominator != self.cases.len() as u64 || sum(coverage) != coverage.denominator
1314        {
1315            return Err("coverage does not reconcile with the cases".into());
1316        }
1317        let accept = get("acceptance_coverage")?;
1318        if accept.denominator != coverage.numerator || sum(accept) != accept.denominator {
1319            return Err("acceptance does not reconcile with comparable cases".into());
1320        }
1321        let labeled = get("labeled_proposal_coverage")?;
1322        if labeled.denominator != accept.numerator || sum(labeled) != labeled.denominator {
1323            return Err("labels do not reconcile with proposals".into());
1324        }
1325        let error = get("error_among_accepted")?;
1326        if error.denominator != labeled.numerator || error.numerator > error.denominator {
1327            return Err("errors do not reconcile with labeled proposals".into());
1328        }
1329        let scored = get("probability_scored")?;
1330        if scored.denominator != coverage.numerator || sum(scored) != scored.denominator {
1331            return Err("probability scoring does not reconcile with comparable cases".into());
1332        }
1333        let per_case = |prefix: &str| {
1334            self.cases
1335                .iter()
1336                .filter(|c| c.outcome.starts_with(prefix))
1337                .count() as u64
1338        };
1339        if per_case("proposal:") != accept.numerator
1340            || per_case("abstention:") != accept.denominator - accept.numerator
1341            || per_case("incomparable") + per_case("diverged")
1342                != coverage.denominator - coverage.numerator
1343        {
1344            return Err("per-case outcomes do not match the counts".into());
1345        }
1346        Ok(())
1347    }
1348}
1349
1350/// A gate's verdict. Unknown is never a pass.
1351#[derive(Debug, Clone, PartialEq)]
1352pub enum GateResult {
1353    Pass,
1354    Fail { reason: String },
1355    Unknown { reason: String },
1356}
1357
1358/// A gate over a baseline and a candidate evaluation. The cohorts compared
1359/// are the ones the reports name; they must share dataset, split and
1360/// configuration, and each detail must be bound to its report.
1361pub fn evaluate_gate(gate: &Gate, baseline: &Evaluated, candidate: &Evaluated) -> GateResult {
1362    let unknown = |r: &str| GateResult::Unknown { reason: r.into() };
1363    for e in [baseline, candidate] {
1364        if e.report.record_digest().ok().as_ref() != Some(&e.detail.report) {
1365            return unknown("a detail is not bound to its report");
1366        }
1367        if e.config.id().ok().as_ref() != Some(&e.report.evaluator_config)
1368            || e.detail.config != e.report.evaluator_config
1369        {
1370            return unknown("a configuration is not the report's");
1371        }
1372        if e.detail.dataset != e.report.dataset.id || e.detail.split != e.report.dataset.split {
1373            return unknown("a detail names another dataset or split than its report");
1374        }
1375        if let Err(r) = e.detail.reconcile() {
1376            return unknown(&format!("a detail does not reconcile: {r}"));
1377        }
1378        if !e.config.gates.contains(gate) {
1379            return unknown("the configuration does not declare this gate");
1380        }
1381    }
1382    // Roles: only a candidate evaluation counts outcome changes.
1383    if baseline.detail.count("candidate_outcome_change").is_some()
1384        || candidate.detail.count("candidate_outcome_change").is_none()
1385    {
1386        return unknown("the baseline and candidate roles do not hold");
1387    }
1388    let (b, c) = (&baseline.report, &candidate.report);
1389    if b.dataset.id != c.dataset.id || b.dataset.split != c.dataset.split {
1390        return unknown("the cohorts are different datasets or splits");
1391    }
1392    // Both sides' correctness rules bound and equal (spec 014, 3.3).
1393    match (
1394        baseline.config.bound_rules(),
1395        candidate.config.bound_rules(),
1396    ) {
1397        (None, _) => return unknown("the baseline's task adapter rules are unbound"),
1398        (_, None) => return unknown("the candidate's task adapter rules are unbound"),
1399        (Some(x), Some(y)) if x != y => {
1400            return unknown("the task adapter rules differ");
1401        }
1402        _ => {}
1403    }
1404    // Spec 041, 3.12.1: version 3 preconditions, named before the
1405    // configuration identities they are implied by.
1406    match (
1407        baseline.config.adapter.measures.as_ref(),
1408        candidate.config.adapter.measures.as_ref(),
1409    ) {
1410        (Some(x), Some(y)) if x != y => {
1411            return unknown("the baseline's and candidate's adapter measures digests differ");
1412        }
1413        (Some(_), None) => return unknown("only the baseline binds adapter measures"),
1414        (None, Some(_)) => return unknown("only the candidate binds adapter measures"),
1415        _ => {}
1416    }
1417    for (side, e) in [("baseline", baseline), ("candidate", candidate)] {
1418        if e.config.declarations().is_some_and(|d| {
1419            d.metric_names()
1420                .get(&gate.metric)
1421                .is_some_and(|(_, opaque)| *opaque)
1422        }) {
1423            return unknown(&format!(
1424                "the {side}'s gated metric comes from an opaque scorer"
1425            ));
1426        }
1427    }
1428    if b.evaluator_config != c.evaluator_config {
1429        return unknown("the cohorts use different evaluator configurations");
1430    }
1431    // Both measurements must be over the same comparable cases: a candidate
1432    // that turns hard cases incomparable is not compared on easier ones.
1433    let comparable = |e: &Evaluated| -> BTreeSet<String> {
1434        e.detail
1435            .cases
1436            .iter()
1437            .filter(|c| c.outcome.starts_with("proposal:") || c.outcome.starts_with("abstention:"))
1438            .map(|c| c.case.clone())
1439            .collect()
1440    };
1441    if comparable(baseline) != comparable(candidate) {
1442        return unknown("the cohorts compare different cases");
1443    }
1444    for e in [baseline, candidate] {
1445        for (metric, min, what) in [
1446            ("coverage", gate.min_comparable_coverage, "comparable"),
1447            (
1448                "labeled_proposal_coverage",
1449                gate.min_labeled_coverage,
1450                "labeled",
1451            ),
1452        ] {
1453            let Some(count) = e.detail.count(metric).filter(|c| c.denominator > 0) else {
1454                return unknown(&format!("{what} coverage has a zero denominator"));
1455            };
1456            // Exact: numerator / denominator >= min, in integer arithmetic.
1457            let lhs = i128::from(count.numerator) * 1_000_000_000;
1458            let rhs = min.units() * i128::from(count.denominator);
1459            if lhs < rhs {
1460                return GateResult::Fail {
1461                    reason: format!("{what} coverage below the minimum"),
1462                };
1463            }
1464        }
1465    }
1466    let value = |r: &EvalReport| {
1467        r.metrics
1468            .iter()
1469            .find(|m| m.name == gate.metric)
1470            .and_then(|m| match m.value {
1471                MetricValue::Measured { value, .. } if value.is_finite() => Some(value),
1472                _ => None,
1473            })
1474    };
1475    let (Some(bv), Some(cv)) = (value(b), value(c)) else {
1476        return unknown("a measurement is missing, unknown or non-finite");
1477    };
1478    let tol = gate.tolerance.to_f64_nearest();
1479    let ok = match gate.direction {
1480        Direction::LowerIsBetter => cv <= bv + tol,
1481        Direction::HigherIsBetter => cv >= bv - tol,
1482    };
1483    if ok {
1484        GateResult::Pass
1485    } else {
1486        GateResult::Fail {
1487            reason: format!(
1488                "{}: candidate {cv} against baseline {bv} beyond {tol}",
1489                gate.metric
1490            ),
1491        }
1492    }
1493}
1494
1495// ---------------------------------------------------------------------------
1496// Qualification on admitted data (spec 023, 3.3 to 3.5).
1497//
1498// A qualification freezes, before any final-test case is evaluated, the
1499// evaluator configuration, bindings, baselines, calibrations, minimum sizes
1500// and analysis, and digests them. Measurement then reads spec 004 reports of
1501// the candidate and its baselines side by side, refusing any that is not of
1502// the frozen dataset, split, configuration and bindings. A claim is composed
1503// here from the frozen and admitted identities, so it cannot name another
1504// population, binding or split. Nothing here adds a metric to spec 004:
1505// figures are its counts and values, regrouped by role, slice and exposure.
1506// ---------------------------------------------------------------------------
1507
1508/// Schema of a qualification record's frozen part (spec 023, 3.3).
1509pub const QUALIFICATION: &str = "rustev.qualification/1";
1510
1511/// What a qualification fixes before the final test (spec 023, 3.3.1). Its
1512/// digest is the frozen identity. It holds no binary64 number.
1513#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1514#[serde(deny_unknown_fields)]
1515pub struct QualificationRecord {
1516    pub schema: String,
1517    /// The task qualified, as the host names it.
1518    pub task: String,
1519    pub dataset: DatasetId,
1520    /// The admission record's digest.
1521    pub admission: ContentDigest,
1522    /// The evaluator configuration: gates, thresholds and minimum coverage
1523    /// are its own (spec 004, 3.5.8).
1524    pub config: EvaluatorConfigId,
1525    pub candidate: CandidateBinding,
1526    /// The rules backend's binding (spec 005).
1527    pub rules_baseline: ArtifactId,
1528    pub exact_baseline: ExactBaseline,
1529    /// Calibration artifacts the calibrated candidate binds; empty when none.
1530    pub calibrations: Vec<CalibrationId>,
1531    /// The sample-size target: final-test cases, exposed ones excluded,
1532    /// below which every headline figure is unknown.
1533    pub min_final_test: u64,
1534    /// The minimum size of each declared slice (spec 023, 6.2).
1535    pub min_slice: BTreeMap<String, u64>,
1536    /// The metrics to be reported, each declared by the configuration.
1537    pub analysis: Vec<String>,
1538    /// The previous qualified result for the same task, for regression.
1539    pub previous: Option<ContentDigest>,
1540    /// The qualification this one replaces after a change (3.3.3).
1541    pub supersedes: Option<ContentDigest>,
1542    pub frozen_on: String,
1543}
1544
1545eval_document!(QualificationRecord, QUALIFICATION, REPLAY_V1, ContentDigest);
1546
1547/// The candidate's binding and the model it serves.
1548#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1549#[serde(deny_unknown_fields)]
1550pub struct CandidateBinding {
1551    pub artifact: ArtifactId,
1552    pub model: ModelRef,
1553}
1554
1555/// The exact baseline: the task's exact plan steps, or why the task allows
1556/// none (spec 023, 3.4.6).
1557#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1558#[serde(rename_all = "snake_case", deny_unknown_fields)]
1559pub enum ExactBaseline {
1560    Evaluated,
1561    NotApplicable { reason: String },
1562}
1563
1564/// A report's role in a qualification.
1565#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1566pub enum Role {
1567    Candidate,
1568    /// The candidate with the frozen calibration artifacts applied.
1569    CandidateCalibrated,
1570    RulesBaseline,
1571    ExactBaseline,
1572}
1573
1574impl Role {
1575    pub fn name(self) -> &'static str {
1576        match self {
1577            Role::Candidate => "candidate",
1578            Role::CandidateCalibrated => "candidate, calibrated",
1579            Role::RulesBaseline => "rules baseline",
1580            Role::ExactBaseline => "exact baseline",
1581        }
1582    }
1583}
1584
1585/// Why a qualification step refused.
1586#[derive(Debug, Clone, PartialEq, Eq)]
1587pub enum QualificationError {
1588    Schema,
1589    Incomplete(&'static str),
1590    /// No admission record for this dataset, or one for another dataset
1591    /// (spec 023, I-1): refused as qualification input.
1592    NotAdmitted,
1593    /// The configuration is not the one frozen, or cannot be frozen.
1594    Config(String),
1595    /// A frozen model labeled the data or shares a labeler's family.
1596    LabelsNotIndependent {
1597        producer: ModelRef,
1598    },
1599    /// The admission did not judge independence against this model.
1600    UndeclaredSystem,
1601    /// A calibration lacks lineage, or was fitted on or with final-test
1602    /// data: the qualification is void (spec 004, 3.6.4).
1603    CalibrationVoid {
1604        calibration: String,
1605        reason: String,
1606    },
1607    /// A slice without a frozen minimum, or a minimum for no slice.
1608    Slice(String),
1609    /// A replacement qualification reuses the old final test (3.3.3).
1610    Supersedes(String),
1611    /// A report under another configuration than the frozen one: a new
1612    /// qualification is required; this result is not the frozen one.
1613    ConfigChanged {
1614        frozen: EvaluatorConfigId,
1615        found: EvaluatorConfigId,
1616    },
1617    /// A report that is not of the frozen dataset, split, cases or
1618    /// bindings, a role missing or repeated, or a detail not bound to it.
1619    Report {
1620        role: &'static str,
1621        reason: String,
1622    },
1623}
1624
1625impl fmt::Display for QualificationError {
1626    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1627        write!(f, "{self:?}")
1628    }
1629}
1630
1631impl std::error::Error for QualificationError {}
1632
1633/// A checked, digested freeze. Only [`freeze`] makes one.
1634#[derive(Debug, Clone)]
1635pub struct Frozen {
1636    record: QualificationRecord,
1637    id: ContentDigest,
1638}
1639
1640impl Frozen {
1641    pub fn record(&self) -> &QualificationRecord {
1642        &self.record
1643    }
1644    /// The frozen record's digest.
1645    pub fn id(&self) -> &ContentDigest {
1646        &self.id
1647    }
1648}
1649
1650/// What a freeze takes.
1651pub struct FreezeInputs<'a> {
1652    pub record: &'a QualificationRecord,
1653    pub manifest: &'a DatasetManifest,
1654    /// The dataset's checked admission; none when there is no record.
1655    pub admission: Option<&'a crate::dataset::Admitted>,
1656    pub config: &'a EvaluatorConfig,
1657    /// Every frozen calibration artifact with its fit record, if any.
1658    pub calibrations: &'a [(CalibrationArtifact, Option<CalibrationFit>)],
1659    /// The record and manifest of the qualification this one supersedes.
1660    pub superseded: Option<(&'a QualificationRecord, &'a DatasetManifest)>,
1661}
1662
1663fn admitted_for<'a>(
1664    admission: Option<&'a crate::dataset::Admitted>,
1665    manifest: &DatasetManifest,
1666) -> Result<&'a crate::dataset::Admitted, QualificationError> {
1667    let a = admission.ok_or(QualificationError::NotAdmitted)?;
1668    if manifest.id().ok().as_ref() != Some(a.dataset()) {
1669        return Err(QualificationError::NotAdmitted);
1670    }
1671    Ok(a)
1672}
1673
1674/// Freeze a qualification before any final-test case is evaluated (spec
1675/// 023, 3.3). Refuses unadmitted data, a configuration other than the
1676/// frozen one or with unbound rules, a candidate whose family labeled the
1677/// data, a calibration without lineage or touching the final test, slices
1678/// without minimums, and a replacement that reuses the old final test.
1679pub fn freeze(inputs: &FreezeInputs<'_>) -> Result<Frozen, QualificationError> {
1680    use QualificationError as E;
1681    let r = inputs.record;
1682    if r.schema != QUALIFICATION {
1683        return Err(E::Schema);
1684    }
1685    let blank = |s: &str| s.trim().is_empty();
1686    if blank(&r.task) || blank(&r.frozen_on) {
1687        return Err(E::Incomplete("task and freeze date"));
1688    }
1689    let admitted = admitted_for(inputs.admission, inputs.manifest)?;
1690    if &r.dataset != admitted.dataset() || &r.admission != admitted.id() {
1691        return Err(E::NotAdmitted);
1692    }
1693    let config = inputs.config;
1694    config.check().map_err(|e| E::Config(e.0))?;
1695    if config.id().ok().as_ref() != Some(&r.config) {
1696        return Err(E::Config("the configuration is not the frozen one".into()));
1697    }
1698    let mapping = &admitted.record().task_mapping;
1699    if config.bound_rules() != Some(&mapping.rules) {
1700        return Err(E::Config(
1701            "the configuration does not bind the admitted adapter rules".into(),
1702        ));
1703    }
1704    if config.adapter.reference() != mapping.adapter {
1705        return Err(E::Config("the configuration names another adapter".into()));
1706    }
1707    let declared: BTreeSet<&str> = config.metrics.iter().map(|m| m.name.as_str()).collect();
1708    if r.analysis.is_empty()
1709        || r.analysis
1710            .iter()
1711            .any(|m| !declared.contains(m.split('/').next().unwrap_or_default()))
1712    {
1713        return Err(E::Config(
1714            "the analysis names a metric the configuration does not declare".into(),
1715        ));
1716    }
1717    if let Some(producer) = admitted.related_labeler(&r.candidate.model) {
1718        return Err(E::LabelsNotIndependent {
1719            producer: producer.clone(),
1720        });
1721    }
1722    if !admitted.declares_system(&r.candidate.model) {
1723        return Err(E::UndeclaredSystem);
1724    }
1725    if let ExactBaseline::NotApplicable { reason } = &r.exact_baseline
1726        && blank(reason)
1727    {
1728        return Err(E::Incomplete("why no exact baseline"));
1729    }
1730    if r.min_final_test == 0 {
1731        return Err(E::Incomplete("the final-test sample-size target"));
1732    }
1733    let slices: BTreeSet<&str> = admitted
1734        .record()
1735        .population
1736        .slices
1737        .iter()
1738        .map(|s| s.name.as_str())
1739        .collect();
1740    let minimums: BTreeSet<&str> = r.min_slice.keys().map(String::as_str).collect();
1741    if slices != minimums || r.min_slice.values().any(|m| *m == 0) {
1742        return Err(E::Slice(
1743            "every declared slice, and only those, has a positive minimum".into(),
1744        ));
1745    }
1746    if let Some(s) = slices
1747        .iter()
1748        .find(|s| !config.subgroups.iter().any(|g| g == *s))
1749    {
1750        return Err(E::Slice(format!(
1751            "slice {s} is not a configuration subgroup"
1752        )));
1753    }
1754    // Calibrations: exactly the frozen ones, each with lineage, fitted off
1755    // the final test (spec 004, 3.6.4).
1756    let mut frozen_cals: Vec<&CalibrationId> = r.calibrations.iter().collect();
1757    frozen_cals.sort();
1758    let mut given = vec![];
1759    for (artifact, fit) in inputs.calibrations {
1760        let id = artifact
1761            .id()
1762            .map_err(|_| E::Incomplete("a calibration artifact's identity"))?;
1763        let void = |reason: String| E::CalibrationVoid {
1764            calibration: id.to_string(),
1765            reason,
1766        };
1767        match crate::fit::qualify(artifact, fit.as_ref(), inputs.manifest, Split::FinalTest) {
1768            crate::fit::Qualification::Qualified => {}
1769            crate::fit::Qualification::Unknown { reason }
1770            | crate::fit::Qualification::Refused { reason } => return Err(void(reason)),
1771        }
1772        if fit.as_ref().is_some_and(|f| f.split != Split::Calibration) {
1773            return Err(void("not fitted on the calibration split".into()));
1774        }
1775        given.push(id);
1776    }
1777    given.sort();
1778    if given.iter().collect::<Vec<_>>() != frozen_cals {
1779        return Err(E::Config(
1780            "the calibrations supplied are not exactly the frozen ones".into(),
1781        ));
1782    }
1783    match (&r.supersedes, inputs.superseded) {
1784        (None, None) => {}
1785        (Some(old_id), Some((old, old_manifest))) => {
1786            if old.id().ok().as_ref() != Some(old_id) {
1787                return Err(E::Supersedes(
1788                    "the superseded record's digest differs".into(),
1789                ));
1790            }
1791            if old_manifest.id().ok().as_ref() != Some(&old.dataset) {
1792                return Err(E::Supersedes(
1793                    "the superseded manifest is not its dataset".into(),
1794                ));
1795            }
1796            for c in inputs.manifest.split(Split::FinalTest) {
1797                if old_manifest
1798                    .split(Split::FinalTest)
1799                    .any(|o| o.source == c.source || o.snapshot == c.snapshot)
1800                {
1801                    return Err(E::Supersedes(format!(
1802                        "final-test case {} reuses the superseded final test",
1803                        c.id
1804                    )));
1805                }
1806            }
1807        }
1808        _ => {
1809            return Err(E::Supersedes(
1810                "a replacement names, and is given, what it supersedes".into(),
1811            ));
1812        }
1813    }
1814    let id = r.id().map_err(|_| E::Incomplete("canonical form"))?;
1815    Ok(Frozen {
1816        record: r.clone(),
1817        id,
1818    })
1819}
1820
1821/// One figure. A ratio carries its numerator and denominator; a value is a
1822/// spec 004 measurement with its count. `Unknown` carries the reason a
1823/// figure cannot be given (for example a zero denominator, a slice below its
1824/// minimum, or exposed final-test cases); it is never replaced by a number, and
1825/// `scope_claim` refuses to compose a claim from it.
1826#[derive(Debug, Clone, PartialEq)]
1827pub enum Figure {
1828    Ratio { numerator: u64, denominator: u64 },
1829    Value { value: f64, n: u64 },
1830    Unknown { reason: String },
1831}
1832
1833impl Figure {
1834    fn ratio(numerator: u64, denominator: u64) -> Figure {
1835        if denominator == 0 {
1836            Figure::Unknown {
1837                reason: "zero denominator".into(),
1838            }
1839        } else {
1840            Figure::Ratio {
1841                numerator,
1842                denominator,
1843            }
1844        }
1845    }
1846}
1847
1848#[derive(Debug, Clone, PartialEq)]
1849pub struct Row {
1850    pub name: String,
1851    pub figure: Figure,
1852}
1853
1854/// One slice's figures.
1855#[derive(Debug, Clone, PartialEq)]
1856pub struct SliceRows {
1857    pub slice: String,
1858    /// Final-test cases in the slice, exposed ones excluded.
1859    pub cases: u64,
1860    pub rows: Vec<Row>,
1861}
1862
1863/// One role's figures: coverage and abstention rows always come before
1864/// accuracy and probability rows (spec 023, R-1).
1865#[derive(Debug, Clone, PartialEq)]
1866pub struct RoleFigures {
1867    pub role: Role,
1868    /// The report's record digest.
1869    pub report: ContentDigest,
1870    pub headline: Vec<Row>,
1871    pub slices: Vec<SliceRows>,
1872    /// Exposed final-test cases, reported separately (spec 023, 3.2.1).
1873    pub exposed: Vec<Row>,
1874}
1875
1876/// The side-by-side measurement of one frozen qualification.
1877#[derive(Debug, Clone, PartialEq)]
1878pub struct Measurement {
1879    qualification: ContentDigest,
1880    pub roles: Vec<RoleFigures>,
1881}
1882
1883impl Measurement {
1884    /// The frozen qualification measured.
1885    pub fn qualification(&self) -> &ContentDigest {
1886        &self.qualification
1887    }
1888    pub fn role(&self, role: Role) -> Option<&RoleFigures> {
1889        self.roles.iter().find(|r| r.role == role)
1890    }
1891}
1892
1893/// Coverage, exclusions, acceptance, labels and abstentions over `cases`,
1894/// from the detail's per-case outcomes and the manifest's labels.
1895fn count_rows(detail: &EvalDetail, manifest: &DatasetManifest, cases: &BTreeSet<&str>) -> Vec<Row> {
1896    let mut excluded: BTreeMap<String, u64> = BTreeMap::new();
1897    let mut abstentions: BTreeMap<String, u64> = BTreeMap::new();
1898    let (mut comparable, mut proposals, mut labeled) = (0u64, 0u64, 0u64);
1899    for d in detail
1900        .cases
1901        .iter()
1902        .filter(|d| cases.contains(d.case.as_str()))
1903    {
1904        if let Some(kind) = d.outcome.strip_prefix("abstention:") {
1905            comparable += 1;
1906            bump(&mut abstentions, kind);
1907        } else if d.outcome.starts_with("proposal:") {
1908            comparable += 1;
1909            proposals += 1;
1910            let label = manifest.cases.iter().find(|c| c.id == d.case);
1911            if label.is_some_and(|c| c.label.value().is_some()) {
1912                labeled += 1;
1913            }
1914        } else {
1915            let reason = match (&d.outcome[..], &d.reason) {
1916                ("incomparable", Some(r)) => format!("incomparable:{r}"),
1917                (o, _) => o.to_string(),
1918            };
1919            bump(&mut excluded, reason);
1920        }
1921    }
1922    let n = cases.len() as u64;
1923    let mut rows = vec![Row {
1924        name: "coverage".into(),
1925        figure: Figure::ratio(comparable, n),
1926    }];
1927    for (reason, k) in excluded {
1928        rows.push(Row {
1929            name: format!("excluded/{reason}"),
1930            figure: Figure::ratio(k, n),
1931        });
1932    }
1933    rows.push(Row {
1934        name: "acceptance_coverage".into(),
1935        figure: Figure::ratio(proposals, comparable),
1936    });
1937    for (kind, k) in abstentions {
1938        rows.push(Row {
1939            name: format!("abstention/{kind}"),
1940            figure: Figure::ratio(k, comparable),
1941        });
1942    }
1943    rows.push(Row {
1944        name: "labeled_proposal_coverage".into(),
1945        figure: Figure::ratio(labeled, proposals),
1946    });
1947    rows
1948}
1949
1950/// The report's own values for `names` and series under `prefix`, or
1951/// unknown with `blocked` when they may not be shown.
1952fn value_rows(
1953    report: &EvalReport,
1954    names: &[&str],
1955    prefix: &str,
1956    blocked: Option<&str>,
1957) -> Vec<Row> {
1958    report
1959        .metrics
1960        .iter()
1961        .filter(|m| names.contains(&m.name.as_str()) || m.name.starts_with(prefix))
1962        .map(|m| Row {
1963            name: m.name.clone(),
1964            figure: match (blocked, &m.value) {
1965                (Some(r), _) => Figure::Unknown { reason: r.into() },
1966                (None, MetricValue::Measured { value, n }) if value.is_finite() => Figure::Value {
1967                    value: *value,
1968                    n: *n,
1969                },
1970                (None, MetricValue::Measured { .. }) => Figure::Unknown {
1971                    reason: "non-finite".into(),
1972                },
1973                (None, MetricValue::Unknown { reason }) => Figure::Unknown {
1974                    reason: reason.clone(),
1975                },
1976            },
1977        })
1978        .collect()
1979}
1980
1981fn all_unknown(rows: Vec<Row>, reason: &str) -> Vec<Row> {
1982    rows.into_iter()
1983        .map(|r| Row {
1984            name: r.name,
1985            figure: Figure::Unknown {
1986                reason: reason.into(),
1987            },
1988        })
1989        .collect()
1990}
1991
1992/// Measure a frozen qualification from spec 004 reports of its roles on the
1993/// final-test split (spec 023, 3.4). Every report must be of the admitted
1994/// dataset, the final-test split, every final-test case and the frozen
1995/// configuration and bindings; a report under another configuration is
1996/// [`QualificationError::ConfigChanged`]. Exposed cases are counted apart
1997/// and keep every accuracy and probability figure unknown, since a spec 004
1998/// report cannot leave them out. A slice below its frozen minimum, and a
1999/// headline below the sample-size target, is unknown, never a figure.
2000pub fn measure(
2001    frozen: &Frozen,
2002    admission: Option<&crate::dataset::Admitted>,
2003    manifest: &DatasetManifest,
2004    reports: &[(Role, &Evaluated)],
2005) -> Result<Measurement, QualificationError> {
2006    use QualificationError as E;
2007    let admitted = admitted_for(admission, manifest)?;
2008    let r = &frozen.record;
2009    if &r.dataset != admitted.dataset() || &r.admission != admitted.id() {
2010        return Err(E::NotAdmitted);
2011    }
2012    let mut expected = vec![Role::Candidate, Role::RulesBaseline];
2013    if !r.calibrations.is_empty() {
2014        expected.push(Role::CandidateCalibrated);
2015    }
2016    if r.exact_baseline == ExactBaseline::Evaluated {
2017        expected.push(Role::ExactBaseline);
2018    }
2019    expected.sort();
2020    let mut given: Vec<Role> = reports.iter().map(|(role, _)| *role).collect();
2021    given.sort();
2022    if given != expected {
2023        return Err(E::Report {
2024            role: "all",
2025            reason: format!("roles {given:?} are not the frozen {expected:?}"),
2026        });
2027    }
2028    let final_test: BTreeSet<&str> = manifest
2029        .split(Split::FinalTest)
2030        .map(|c| c.id.as_str())
2031        .collect();
2032    let exposed: BTreeSet<&str> = admitted
2033        .record()
2034        .exposure
2035        .exposed_final_test
2036        .iter()
2037        .map(String::as_str)
2038        .collect();
2039    let cohort: BTreeSet<&str> = final_test.difference(&exposed).copied().collect();
2040    let mut frozen_cals = r.calibrations.clone();
2041    frozen_cals.sort();
2042    let mut roles = vec![];
2043    for (role, e) in reports {
2044        let bad = |reason: &str| E::Report {
2045            role: role.name(),
2046            reason: reason.into(),
2047        };
2048        let digest = e
2049            .report
2050            .record_digest()
2051            .map_err(|_| bad("the report has no record form"))?;
2052        if digest != e.detail.report {
2053            return Err(bad("the detail is not bound to its report"));
2054        }
2055        if e.config.id().ok().as_ref() != Some(&e.report.evaluator_config)
2056            || e.detail.config != e.report.evaluator_config
2057        {
2058            return Err(bad("the configuration is not the report's"));
2059        }
2060        if e.report.evaluator_config != r.config {
2061            return Err(E::ConfigChanged {
2062                frozen: r.config.clone(),
2063                found: e.report.evaluator_config.clone(),
2064            });
2065        }
2066        if &e.report.dataset.id != admitted.dataset()
2067            || e.detail.dataset != e.report.dataset.id
2068            || e.report.dataset.split != Split::FinalTest
2069            || e.detail.split != Split::FinalTest
2070            || e.report.dataset.provenance != manifest.provenance
2071        {
2072            return Err(bad("not the admitted dataset's final-test split"));
2073        }
2074        e.detail.reconcile().map_err(|x| bad(&x))?;
2075        let cases: BTreeSet<&str> = e.detail.cases.iter().map(|c| c.case.as_str()).collect();
2076        if cases != final_test || cases.len() != e.detail.cases.len() {
2077            return Err(bad("the report is not over every final-test case once"));
2078        }
2079        let names = |a: &ArtifactId| e.report.artifacts.contains(a);
2080        let mut cals = e.report.calibrations.clone();
2081        cals.sort();
2082        let binding_ok = match role {
2083            Role::Candidate => names(&r.candidate.artifact) && cals.is_empty(),
2084            Role::CandidateCalibrated => names(&r.candidate.artifact) && cals == frozen_cals,
2085            Role::RulesBaseline => names(&r.rules_baseline),
2086            Role::ExactBaseline => e.report.artifacts.is_empty(),
2087        };
2088        if !binding_ok {
2089            return Err(bad("the report's bindings are not the frozen ones"));
2090        }
2091        let small = format!(
2092            "{} final-test cases, below the frozen target {}",
2093            cohort.len(),
2094            r.min_final_test
2095        );
2096        let below = (cohort.len() as u64) < r.min_final_test;
2097        let blocked = if !exposed.is_empty() {
2098            Some("the report's cohort includes exposed cases")
2099        } else {
2100            None
2101        };
2102        let mut headline = count_rows(&e.detail, manifest, &cohort);
2103        headline.extend(value_rows(
2104            &e.report,
2105            &["error_among_accepted", "mean_log_loss", "brier"],
2106            "reliability/all/",
2107            blocked,
2108        ));
2109        if below {
2110            headline = all_unknown(headline, &small);
2111        }
2112        let mut slices = vec![];
2113        for s in &admitted.record().population.slices {
2114            let members: BTreeSet<&str> = manifest
2115                .split(Split::FinalTest)
2116                .filter(|c| cohort.contains(c.id.as_str()) && c.subgroups.contains(&s.name))
2117                .map(|c| c.id.as_str())
2118                .collect();
2119            let min = r.min_slice.get(&s.name).copied().unwrap_or(u64::MAX);
2120            let mut rows = count_rows(&e.detail, manifest, &members);
2121            rows.push(Row {
2122                name: "error_among_accepted".into(),
2123                figure: Figure::Unknown {
2124                    reason: "a spec 004 report states no per-slice error".into(),
2125                },
2126            });
2127            rows.extend(value_rows(
2128                &e.report,
2129                &[],
2130                &format!("reliability/{}/", s.name),
2131                blocked,
2132            ));
2133            if (members.len() as u64) < min {
2134                rows = all_unknown(
2135                    rows,
2136                    &format!("{} cases, below the frozen minimum {min}", members.len()),
2137                );
2138            }
2139            slices.push(SliceRows {
2140                slice: s.name.clone(),
2141                cases: members.len() as u64,
2142                rows,
2143            });
2144        }
2145        let exposed_rows = if exposed.is_empty() {
2146            vec![]
2147        } else {
2148            count_rows(&e.detail, manifest, &exposed)
2149        };
2150        roles.push(RoleFigures {
2151            role: *role,
2152            report: digest,
2153            headline,
2154            slices,
2155            exposed: exposed_rows,
2156        });
2157    }
2158    roles.sort_by_key(|x| x.role);
2159    Ok(Measurement {
2160        qualification: frozen.id.clone(),
2161        roles,
2162    })
2163}
2164
2165/// Regression against the previous qualified result for the same task
2166/// (spec 023, 3.4.5): each frozen gate over the previous result's report
2167/// (baseline role) and this one's (candidate role), by spec 004's own
2168/// [`evaluate_gate`], which is unknown unless both share dataset, split and
2169/// configuration. Empty when the freeze names no previous result; unknown
2170/// when it names one that is not supplied.
2171pub fn regression(
2172    frozen: &Frozen,
2173    previous: Option<(&Frozen, &Evaluated)>,
2174    current: &Evaluated,
2175) -> Vec<(String, GateResult)> {
2176    let Some(want) = &frozen.record.previous else {
2177        return vec![];
2178    };
2179    let unknown = |reason: &str| {
2180        current
2181            .config
2182            .gates
2183            .iter()
2184            .map(|g| {
2185                (
2186                    g.name.clone(),
2187                    GateResult::Unknown {
2188                        reason: reason.into(),
2189                    },
2190                )
2191            })
2192            .collect()
2193    };
2194    let Some((old, baseline)) = previous else {
2195        return unknown("the previous qualified result was not supplied");
2196    };
2197    if old.id() != want || old.record.task != frozen.record.task {
2198        return unknown("the supplied result is not the frozen previous one");
2199    }
2200    current
2201        .config
2202        .gates
2203        .iter()
2204        .map(|g| (g.name.clone(), evaluate_gate(g, baseline, current)))
2205        .collect()
2206}
2207
2208/// The served model identity, as observed for the calls a claim covers
2209/// (spec 012, 3.6.3).
2210#[derive(Debug, Clone, PartialEq, Eq)]
2211pub enum Served {
2212    Observed { version: String },
2213    Unknown,
2214}
2215
2216/// What a claim asks to state. Its scope is not free text: dataset, split,
2217/// configuration and binding come from the freeze, and the population must
2218/// be the admitted one.
2219pub struct ClaimRequest<'a> {
2220    pub role: Role,
2221    /// A headline row name, or a slice row name with `slice`.
2222    pub figure: &'a str,
2223    pub slice: Option<&'a str>,
2224    pub population: &'a str,
2225    pub call_dates: &'a str,
2226    pub served: &'a Served,
2227}
2228
2229/// A claim sentence and the limits that bound it (spec 023, 3.5).
2230#[derive(Debug, Clone, PartialEq, Eq)]
2231pub struct ScopedClaim {
2232    pub sentence: String,
2233    pub limits: Vec<String>,
2234}
2235
2236/// Why a claim was rejected.
2237#[derive(Debug, Clone, PartialEq, Eq)]
2238pub enum ClaimError {
2239    /// The dataset has no admission record, or the freeze another one.
2240    NotAdmitted,
2241    /// The measurement is of another freeze.
2242    NotThisQualification,
2243    /// The claim names another population than the admitted one.
2244    PopulationOutside {
2245        claimed: String,
2246        admitted: String,
2247    },
2248    Incomplete(&'static str),
2249    NoSuchFigure,
2250    /// An unknown figure is never claimed as a figure.
2251    UnknownFigure {
2252        reason: String,
2253    },
2254}
2255
2256impl fmt::Display for ClaimError {
2257    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2258        write!(f, "{self:?}")
2259    }
2260}
2261
2262impl std::error::Error for ClaimError {}
2263
2264/// Compose a scoped claim (spec 023, 3.5, I-3). The sentence names the
2265/// dataset id, split, evaluator configuration id, binding identity, call
2266/// dates, served identity disclosure and population; the limits carry the
2267/// admission's stated limits. Anything beyond them is rejected.
2268pub fn scope_claim(
2269    admission: Option<&crate::dataset::Admitted>,
2270    frozen: &Frozen,
2271    measurement: &Measurement,
2272    request: &ClaimRequest<'_>,
2273) -> Result<ScopedClaim, ClaimError> {
2274    let admitted = admission.ok_or(ClaimError::NotAdmitted)?;
2275    let r = &frozen.record;
2276    if &r.dataset != admitted.dataset() || &r.admission != admitted.id() {
2277        return Err(ClaimError::NotAdmitted);
2278    }
2279    if measurement.qualification() != frozen.id() {
2280        return Err(ClaimError::NotThisQualification);
2281    }
2282    let a = admitted.record();
2283    if request.population != a.population.id {
2284        return Err(ClaimError::PopulationOutside {
2285            claimed: request.population.into(),
2286            admitted: a.population.id.clone(),
2287        });
2288    }
2289    if request.call_dates.trim().is_empty() {
2290        return Err(ClaimError::Incomplete("call dates"));
2291    }
2292    if let Served::Observed { version } = request.served
2293        && version.trim().is_empty()
2294    {
2295        return Err(ClaimError::Incomplete("served version"));
2296    }
2297    let figures = measurement
2298        .role(request.role)
2299        .ok_or(ClaimError::NoSuchFigure)?;
2300    let rows = match request.slice {
2301        None => &figures.headline,
2302        Some(s) => {
2303            &figures
2304                .slices
2305                .iter()
2306                .find(|x| x.slice == s)
2307                .ok_or(ClaimError::NoSuchFigure)?
2308                .rows
2309        }
2310    };
2311    let row = rows
2312        .iter()
2313        .find(|x| x.name == request.figure)
2314        .ok_or(ClaimError::NoSuchFigure)?;
2315    let value = match &row.figure {
2316        Figure::Unknown { reason } => {
2317            return Err(ClaimError::UnknownFigure {
2318                reason: reason.clone(),
2319            });
2320        }
2321        Figure::Ratio {
2322            numerator,
2323            denominator,
2324        } => format!("{numerator}/{denominator}"),
2325        Figure::Value { value, n } => format!("{value} (n = {n})"),
2326    };
2327    let binding = match request.role {
2328        Role::Candidate => format!("binding {}", r.candidate.artifact),
2329        Role::CandidateCalibrated => {
2330            let cals: Vec<String> = r.calibrations.iter().map(ToString::to_string).collect();
2331            format!(
2332                "binding {} with calibration {}",
2333                r.candidate.artifact,
2334                cals.join(", ")
2335            )
2336        }
2337        Role::RulesBaseline => format!("rules baseline binding {}", r.rules_baseline),
2338        Role::ExactBaseline => "the exact plan steps (no semantic binding)".into(),
2339    };
2340    let served = match request.served {
2341        Served::Observed { version } => format!("served identity {version}"),
2342        Served::Unknown => "served identity not observable".into(),
2343    };
2344    let slice = request
2345        .slice
2346        .map(|s| format!(" in slice {s}"))
2347        .unwrap_or_default();
2348    let sentence = format!(
2349        "On dataset {}, split final_test, evaluator configuration {}, {binding}, calls {}, \
2350         {served}, population {}: {} ({}){slice} is {value}.",
2351        r.dataset,
2352        r.config,
2353        request.call_dates,
2354        a.population.id,
2355        request.figure,
2356        request.role.name(),
2357    );
2358    let mut limits = vec![
2359        format!("population: {}", a.population.limitations),
2360        format!("near-duplicates: {}", a.splits.near_duplicates),
2361        "no claim about another population, another binding, real user data not represented, \
2362         or a served model version not observed"
2363            .to_string(),
2364        "point estimates without intervals (spec 023, 6.3)".to_string(),
2365    ];
2366    if !a.exposure.training_data.trim().is_empty() {
2367        limits.push(format!(
2368            "training-data exposure: {}",
2369            a.exposure.training_data
2370        ));
2371    }
2372    if !a.exposure.exposed_final_test.is_empty() {
2373        limits.push(format!(
2374            "{} exposed final-test cases are reported apart and excluded",
2375            a.exposure.exposed_final_test.len()
2376        ));
2377    }
2378    if let Some(c) = &a.exposure.ranking_caveat {
2379        limits.push(format!("ranking exposure: {c}"));
2380    }
2381    if *request.served == Served::Unknown {
2382        limits.push(
2383            "a change of served model version during the calls would not have been visible".into(),
2384        );
2385    }
2386    Ok(ScopedClaim { sentence, limits })
2387}