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