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