Skip to main content

rustev_eval/
report.rs

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