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