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,
20};
21use rustev_contract::judgment::OutValue;
22use rustev_contract::limits::REPLAY_V1;
23use rustev_contract::plan::{BoundCalibration, Plan, PlanExecution, PlanStepDetail};
24use rustev_contract::run::{Charge, RunRecord};
25use rustev_contract::scope::Scope;
26use rustev_contract::{Document, Identified, schema};
27use rustev_core::Compiled;
28use serde::{Deserialize, Serialize};
29
30use crate::bundle::BundleInput;
31use crate::compare::compare;
32use crate::config::{ConfigError, Direction, EvaluatorConfig, Gate};
33use crate::dataset::{DatasetCase, DatasetError, DatasetManifest, ModelRef};
34use crate::doc::eval_document;
35use crate::eval_cases::{self, CaseMeasure, CaseMeasures, EVAL_CASES, EvalCases};
36use crate::fit::CalibrationFit;
37use crate::metrics::{
38 self, CaseOutcome, Measure, Scored, TaskAdapter, brier, log_loss_term, mean_log_loss,
39 nearest_rank, probabilities, reliability, reliability_bin,
40};
41use crate::repeat_config::TopK;
42use crate::replay::{ReplayConfig, ReplayOutcome, RetainedValue, reproduce};
43use crate::resolve::Resolver;
44
45pub const EVAL_DETAIL: &str = "rustev.eval-detail/1";
46
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct EvalDetail {
50 pub schema: String,
51 pub report: ContentDigest,
53 pub dataset: DatasetId,
54 pub split: Split,
55 pub config: EvaluatorConfigId,
56 pub cohort: String,
58 pub counts: Vec<Count>,
59 pub cases: Vec<CaseDetail>,
60}
61
62eval_document!(EvalDetail, EVAL_DETAIL, REPLAY_V1);
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub struct Count {
69 pub metric: String,
70 pub numerator: u64,
71 pub denominator: u64,
72 pub breakdown: BTreeMap<String, u64>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct CaseDetail {
78 pub case: String,
79 pub scope: Option<Scope>,
81 pub outcome: String,
83 pub reason: Option<String>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum EvalError {
89 Config(ConfigError),
90 Dataset(DatasetError),
91 Adapter,
93 EmptySplit,
94 MixedPlans,
96 UndeclaredMetric(String),
98 Reconcile(String),
100 NotCanonical,
101}
102
103impl fmt::Display for EvalError {
104 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105 write!(f, "{self:?}")
106 }
107}
108
109impl std::error::Error for EvalError {}
110
111pub struct Inputs<'a> {
113 pub manifest: &'a DatasetManifest,
114 pub split: Split,
115 pub config: &'a EvaluatorConfig,
116 pub adapter: &'a dyn TaskAdapter,
117 pub bundles: &'a BTreeMap<String, BundleInput>,
120 pub resolver: &'a dyn Resolver,
121 pub replay: &'a ReplayConfig,
122 pub candidate: Option<Candidate<'a>>,
124}
125
126#[derive(Clone, Copy)]
128pub struct Candidate<'a> {
129 pub plan: &'a Compiled,
130 pub calibrations: &'a [CalibrationArtifact],
131}
132
133#[derive(Debug, Clone)]
135pub struct Evaluated {
136 pub report: EvalReport,
137 pub detail: EvalDetail,
138 pub config: EvaluatorConfig,
139}
140
141enum Status {
142 Incomparable(String),
143 Diverged,
144 Comparable {
145 outcome: CaseOutcome,
146 historical: Option<CaseOutcome>,
147 probabilities: Result<Vec<(String, f64)>, String>,
148 run: Box<RunRecord>,
149 plan: Box<Plan>,
150 },
151}
152
153struct CaseResult<'a> {
154 case: &'a DatasetCase,
155 scope: Option<Scope>,
156 status: Status,
157}
158
159fn evaluate_case<'a>(inputs: &Inputs<'_>, case: &'a DatasetCase) -> CaseResult<'a> {
160 let incomparable = |scope, code: &str| CaseResult {
161 case,
162 scope,
163 status: Status::Incomparable(code.into()),
164 };
165 let bundle = match inputs.bundles.get(&case.id) {
166 None => return incomparable(None, "bundle-missing"),
167 Some(BundleInput::Failed(f)) => return incomparable(None, f.kind.code()),
169 Some(BundleInput::Loaded(b)) => b,
170 };
171 let scope = Some(bundle.scope.clone());
172 if bundle.snapshot_id != case.snapshot {
173 return incomparable(scope, "dataset-mismatch");
174 }
175 let r = match reproduce(bundle, inputs.resolver, inputs.replay) {
176 ReplayOutcome::Reproduced(r) => r,
177 ReplayOutcome::Diverged { .. } => {
178 return CaseResult {
179 case,
180 scope,
181 status: Status::Diverged,
182 };
183 }
184 ReplayOutcome::Incomparable(i) => return incomparable(scope, &i.code()),
185 };
186 let agreement = &inputs.config.agreement;
187 let historical = CaseOutcome::of(&r.judgment, agreement);
188 let step = inputs.config.probability_step.as_deref();
189 let status = match inputs.candidate {
190 None => {
191 let probabilities = match step {
192 None => Err("no-probability-step".into()),
193 Some(s) => r
194 .supplies
195 .iter()
196 .find(|x| x.step == s && x.instance.is_empty())
197 .ok_or_else(|| "no-output".to_string())
198 .and_then(|x| match &x.value {
199 RetainedValue::Output { output, .. } => {
200 probabilities(&r.plan, &r.calibrations, s, output)
201 }
202 RetainedValue::Reason(_) => Err("runtime-failure".into()),
203 }),
204 };
205 Status::Comparable {
206 outcome: historical,
207 historical: None,
208 probabilities,
209 run: Box::new(r.run.clone()),
210 plan: Box::new(r.plan.clone()),
211 }
212 }
213 Some(cand) => match compare(&r, cand.plan) {
214 Err(e) => return incomparable(scope, e.code()),
215 Ok(cmp) => {
216 let probabilities = match step {
217 None => Err("no-probability-step".into()),
218 Some(s) => cmp
219 .supplied
220 .iter()
221 .find(|(st, inst, _)| st == s && inst.is_empty())
222 .ok_or_else(|| "no-output".to_string())
223 .and_then(|(_, _, raw)| {
224 probabilities(&cand.plan.plan, cand.calibrations, s, raw)
225 }),
226 };
227 Status::Comparable {
228 outcome: CaseOutcome::of(&cmp.candidate, agreement),
229 historical: Some(historical),
230 probabilities,
231 run: Box::new(r.run.clone()),
232 plan: Box::new(cand.plan.plan.clone()),
233 }
234 }
235 },
236 };
237 CaseResult {
238 case,
239 scope,
240 status,
241 }
242}
243
244fn bump(m: &mut BTreeMap<String, u64>, k: impl Into<String>) {
245 *m.entry(k.into()).or_default() += 1;
246}
247
248fn plan_parts(plan: &Plan) -> (Vec<ArtifactId>, Vec<CalibrationId>) {
249 let mut artifacts = BTreeSet::new();
250 let mut calibrations = BTreeSet::new();
251 for s in &plan.steps {
252 if let PlanStepDetail::Semantic(b) = &s.detail {
253 artifacts.insert(b.artifact.clone());
254 if let BoundCalibration::Bound { id, .. } = &b.calibration {
255 calibrations.insert(id.clone());
256 }
257 }
258 }
259 if let PlanExecution::Declared(d) = &plan.execution {
260 artifacts.extend(d.fallbacks.iter().map(|f| f.artifact.clone()));
261 }
262 (
263 artifacts.into_iter().collect(),
264 calibrations.into_iter().collect(),
265 )
266}
267
268fn metric(name: impl Into<String>, m: Measure) -> Metric {
269 Metric {
270 name: name.into(),
271 value: match m {
272 Measure::Value { value, n } => MetricValue::Measured { value, n },
273 Measure::Unknown { reason } => MetricValue::Unknown { reason },
274 },
275 }
276}
277
278fn quantile_name(q: rustev_contract::decimal::Decimal) -> String {
279 format!("p{}", q.to_canonical_string())
280}
281
282pub fn evaluate(inputs: &Inputs<'_>) -> Result<Evaluated, EvalError> {
284 evaluate_inner(inputs, None).map(|(e, _)| e)
285}
286
287pub fn evaluate_with_cases(
292 inputs: &Inputs<'_>,
293 top_k: Option<&TopK>,
294) -> Result<(Evaluated, EvalCases), EvalError> {
295 let (evaluated, cases) = evaluate_inner(inputs, Some(top_k))?;
296 let cases = EvalCases {
297 schema: EVAL_CASES.into(),
298 report: evaluated.detail.report.clone(),
299 top_k: top_k.cloned(),
300 cases: cases.unwrap_or_default(),
301 };
302 eval_cases::reconcile(&evaluated, &cases).map_err(EvalError::Reconcile)?;
303 Ok((evaluated, cases))
304}
305
306fn top_k_of(outcome: &CaseOutcome, top_k: &TopK) -> Option<Vec<String>> {
308 let CaseOutcome::Proposal { params, .. } = outcome else {
309 return None;
310 };
311 let k = usize::try_from(top_k.k).unwrap_or(usize::MAX);
312 match params.get(&top_k.param)? {
313 OutValue::Ranking(r) => Some(
314 r.entries
315 .iter()
316 .take(k)
317 .map(|e| e.candidate.clone())
318 .collect(),
319 ),
320 OutValue::Shortlist(s) => Some(s.ids.iter().take(k).cloned().collect()),
321 _ => None,
322 }
323}
324
325fn count_measure(hit: bool) -> CaseMeasure {
326 CaseMeasure::Count {
327 num: u64::from(hit),
328 den: 1,
329 }
330}
331
332fn mean_measure(term: Result<f64, String>) -> CaseMeasure {
333 match term {
334 Ok(num) if num.is_finite() => CaseMeasure::Mean { num, den: 1 },
335 Ok(_) => CaseMeasure::Unknown {
336 reason: "non-finite value".into(),
337 },
338 Err(reason) => CaseMeasure::Unknown { reason },
339 }
340}
341
342fn evaluate_inner(
345 inputs: &Inputs<'_>,
346 cases_for: Option<Option<&TopK>>,
347) -> Result<(Evaluated, Option<Vec<CaseMeasures>>), EvalError> {
348 let config = inputs.config;
349 config.check().map_err(EvalError::Config)?;
350 if config.adapter.reference() != inputs.adapter.adapter()
352 || config
353 .adapter
354 .rules
355 .as_ref()
356 .is_some_and(|r| *r != inputs.adapter.rules())
357 {
358 return Err(EvalError::Adapter);
359 }
360 let adapter = inputs.adapter;
361 inputs
362 .manifest
363 .check(&config.adapter.reference(), |l| adapter.check_label(l))
364 .map_err(EvalError::Dataset)?;
365 let cases: Vec<&DatasetCase> = inputs.manifest.split(inputs.split).collect();
366 if cases.is_empty() {
367 return Err(EvalError::EmptySplit);
368 }
369 let results: Vec<CaseResult<'_>> = cases.iter().map(|c| evaluate_case(inputs, c)).collect();
370
371 let total = results.len() as u64;
373 let mut coverage_out = BTreeMap::new();
374 let mut abstentions = BTreeMap::new();
375 let (mut comparable, mut proposals, mut labeled, mut wrong, mut changed) = (0, 0, 0, 0, 0);
376 let mut unlabeled = BTreeMap::new();
377 let mut prob_out = BTreeMap::new();
378 let mut scored_rows: Vec<(&DatasetCase, Vec<(String, f64)>)> = vec![];
379 let mut runs: Vec<&RunRecord> = vec![];
380 let mut plans: BTreeSet<PlanId> = BTreeSet::new();
381 let mut plan_doc: Option<&Plan> = None;
382 let mut details = vec![];
383 let mut case_measures: Vec<CaseMeasures> = vec![];
384 for r in &results {
385 let mut measures = BTreeMap::new();
386 let mut run_digest = None;
387 let mut ranked = None;
388 let (outcome, reason) = match &r.status {
389 Status::Incomparable(code) => {
390 bump(&mut coverage_out, format!("incomparable:{code}"));
391 ("incomparable".to_string(), Some(code.clone()))
392 }
393 Status::Diverged => {
394 bump(&mut coverage_out, "diverged");
395 (
396 "diverged".to_string(),
397 Some("historical judgment diverged".into()),
398 )
399 }
400 Status::Comparable {
401 outcome,
402 historical,
403 probabilities,
404 run,
405 plan,
406 } => {
407 comparable += 1;
408 runs.push(run);
409 if cases_for.is_some() {
410 run_digest = run.record_digest().ok();
411 ranked = cases_for.flatten().and_then(|t| top_k_of(outcome, t));
412 case_measures_of(
413 &mut measures,
414 inputs,
415 r.case,
416 outcome,
417 historical.as_ref(),
418 probabilities,
419 run,
420 );
421 }
422 if let Ok(id) = plan.id() {
423 plans.insert(id);
424 }
425 plan_doc = Some(plan);
426 if let Some(h) = historical {
427 if h.name() != outcome.name() {
429 changed += 1;
430 }
431 }
432 let label = r.case.label.value();
433 match outcome {
434 CaseOutcome::Proposal { action, params, .. } => {
435 proposals += 1;
436 match label {
437 Some(l) => {
438 labeled += 1;
439 if !adapter.correct(action, params, l) {
440 wrong += 1;
441 }
442 }
443 None => bump(&mut unlabeled, "unlabeled"),
444 }
445 }
446 CaseOutcome::Abstention { kind } => bump(&mut abstentions, kind.clone()),
447 }
448 match (label, probabilities) {
449 (None, _) => bump(&mut prob_out, "unlabeled"),
450 (Some(_), Err(e)) => bump(&mut prob_out, e.split(':').next().unwrap_or(e)),
451 (Some(_), Ok(p)) => scored_rows.push((r.case, p.clone())),
452 }
453 (outcome.name(), None)
454 }
455 };
456 if cases_for.is_some() {
457 measures.insert(
458 "coverage".into(),
459 count_measure(matches!(r.status, Status::Comparable { .. })),
460 );
461 case_measures.push(CaseMeasures {
462 case: r.case.id.clone(),
463 run: run_digest,
464 outcome: outcome.clone(),
465 top_k: ranked,
466 measures,
467 });
468 }
469 details.push(CaseDetail {
470 case: r.case.id.clone(),
471 scope: r.scope.clone(),
472 outcome,
473 reason,
474 });
475 }
476 if plans.len() > 1 {
477 return Err(EvalError::MixedPlans);
478 }
479 let mut counts = vec![
480 Count {
481 metric: "coverage".into(),
482 numerator: comparable,
483 denominator: total,
484 breakdown: coverage_out,
485 },
486 Count {
487 metric: "acceptance_coverage".into(),
488 numerator: proposals,
489 denominator: comparable,
490 breakdown: abstentions,
491 },
492 Count {
493 metric: "labeled_proposal_coverage".into(),
494 numerator: labeled,
495 denominator: proposals,
496 breakdown: unlabeled,
497 },
498 Count {
499 metric: "error_among_accepted".into(),
500 numerator: wrong,
501 denominator: labeled,
502 breakdown: BTreeMap::new(),
503 },
504 Count {
505 metric: "probability_scored".into(),
506 numerator: scored_rows.len() as u64,
507 denominator: comparable,
508 breakdown: prob_out,
509 },
510 ];
511 if inputs.candidate.is_some() {
512 counts.push(Count {
513 metric: "candidate_outcome_change".into(),
514 numerator: changed,
515 denominator: comparable,
516 breakdown: BTreeMap::new(),
517 });
518 }
519
520 let mut ms = vec![];
522 for c in &counts {
523 if c.metric != "probability_scored" {
524 ms.push(metric(
525 &c.metric,
526 Measure::ratio(c.numerator, c.denominator),
527 ));
528 }
529 }
530 let scored: Vec<Scored<'_>> = scored_rows
531 .iter()
532 .map(|(c, p)| Scored {
533 case: &c.id,
534 probabilities: p,
535 label: c.label.value().unwrap_or_default(),
536 })
537 .collect();
538 ms.push(metric("mean_log_loss", mean_log_loss(&scored)));
539 ms.push(metric("brier", brier(&scored)));
540 let mut groups: Vec<(String, Vec<&Scored<'_>>)> = vec![("all".into(), scored.iter().collect())];
541 for g in &config.subgroups {
542 groups.push((
543 g.clone(),
544 scored
545 .iter()
546 .filter(|s| {
547 scored_rows
548 .iter()
549 .any(|(c, _)| c.id == s.case && c.subgroups.contains(g))
550 })
551 .collect(),
552 ));
553 }
554 for (g, members) in &groups {
555 let owned: Vec<Scored<'_>> = members
556 .iter()
557 .map(|s| Scored {
558 case: s.case,
559 probabilities: s.probabilities,
560 label: s.label,
561 })
562 .collect();
563 for (i, b) in reliability(&owned, &config.reliability_bins)
564 .into_iter()
565 .enumerate()
566 {
567 ms.push(metric(
568 format!("reliability/{g}/{i}/confidence"),
569 b.mean_confidence,
570 ));
571 ms.push(metric(format!("reliability/{g}/{i}/accuracy"), b.accuracy));
572 }
573 }
574 let historical_only = |name: &str| {
575 metric(
576 name,
577 Measure::Unknown {
578 reason: "the candidate was not executed; latency and cost are the baseline's"
579 .into(),
580 },
581 )
582 };
583 let service: Option<Vec<u64>> = runs
585 .iter()
586 .map(|r| r.timing.elapsed_ms.checked_sub(r.timing.queued_ms))
587 .collect();
588 let queued: Vec<u64> = runs.iter().map(|r| r.timing.queued_ms).collect();
589 for q in &config.latency_quantiles {
590 let (a, b) = (
591 format!("latency_ms/{}", quantile_name(*q)),
592 format!("queue_ms/{}", quantile_name(*q)),
593 );
594 if inputs.candidate.is_some() {
595 ms.push(historical_only(&a));
596 ms.push(historical_only(&b));
597 } else {
598 ms.push(metric(
599 a,
600 match &service {
601 Some(v) => nearest_rank(v, *q),
602 None => Measure::Unknown {
603 reason: "a run records more queueing than elapsed time".into(),
604 },
605 },
606 ));
607 ms.push(metric(b, nearest_rank(&queued, *q)));
608 }
609 }
610 let cost = metrics::cost(&runs, config.cost_units_comparable);
611 if inputs.candidate.is_some() {
612 for n in [
613 "cost_observed_units",
614 "cost_estimated_units",
615 "cost_unknown_attempts",
616 "cost_liability_units",
617 ] {
618 ms.push(historical_only(n));
619 }
620 } else {
621 ms.push(metric("cost_observed_units", cost.observed_units));
622 ms.push(metric("cost_estimated_units", cost.estimated_units));
623 let n = runs.len() as u64;
624 let count = |v: u64| {
625 if n == 0 {
626 Measure::Unknown {
627 reason: "zero denominator".into(),
628 }
629 } else {
630 Measure::Value { value: v as f64, n }
631 }
632 };
633 ms.push(metric(
634 "cost_unknown_attempts",
635 count(cost.unknown_attempts),
636 ));
637 ms.push(metric("cost_liability_units", count(cost.liability_units)));
638 }
639 let declared: BTreeSet<&str> = config.metrics.iter().map(|m| m.name.as_str()).collect();
640 for m in &ms {
641 let base = m.name.split('/').next().unwrap_or(&m.name);
642 if !declared.contains(base) {
643 return Err(EvalError::UndeclaredMetric(base.into()));
644 }
645 }
646
647 let plan_id = match (inputs.candidate, plans.iter().next()) {
649 (Some(c), _) => c.plan.id.clone(),
650 (None, Some(p)) => p.clone(),
651 (None, None) => inputs
652 .bundles
653 .values()
654 .find_map(|b| match b {
655 BundleInput::Loaded(b) => Some(b.plan_id.clone()),
656 BundleInput::Failed(_) => None,
657 })
658 .ok_or(EvalError::EmptySplit)?,
659 };
660 let (artifacts, calibrations) = match (inputs.candidate, plan_doc) {
661 (Some(c), _) => plan_parts(&c.plan.plan),
662 (None, Some(p)) => plan_parts(p),
663 (None, None) => (vec![], vec![]),
664 };
665 let unknown = ms
666 .iter()
667 .filter(|m| matches!(m.value, MetricValue::Unknown { .. }))
668 .count();
669 let status = if comparable == 0 {
670 ReportStatus::Incomparable {
671 reason: "no comparable case".into(),
672 }
673 } else if comparable < total || unknown > 0 {
674 ReportStatus::Incomplete {
675 reason: format!(
676 "{} of {total} cases not comparable; {unknown} metrics unknown",
677 total - comparable
678 ),
679 }
680 } else {
681 ReportStatus::Complete
682 };
683 let dataset = inputs.manifest.id().map_err(|_| EvalError::NotCanonical)?;
684 let config_id = config.id().map_err(|_| EvalError::NotCanonical)?;
685 let report = EvalReport {
686 schema: schema::EVAL_REPORT.into(),
687 plan: plan_id,
688 artifacts,
689 calibrations,
690 dataset: DatasetRef {
691 id: dataset.clone(),
692 split: inputs.split,
693 provenance: inputs.manifest.provenance.clone(),
694 },
695 evaluator_config: config_id.clone(),
696 status,
697 metrics: ms,
698 };
699 let detail = EvalDetail {
700 schema: EVAL_DETAIL.into(),
701 report: report
702 .record_digest()
703 .map_err(|_| EvalError::NotCanonical)?,
704 dataset,
705 split: inputs.split,
706 config: config_id,
707 cohort: format!(
708 "{} split of dataset {}: {total} cases{}",
709 split_name(inputs.split),
710 inputs.manifest.name,
711 if inputs.candidate.is_some() {
712 ", candidate"
713 } else {
714 ", baseline"
715 }
716 ),
717 counts,
718 cases: details,
719 };
720 detail.reconcile().map_err(EvalError::Reconcile)?;
721 Ok((
722 Evaluated {
723 report,
724 detail,
725 config: config.clone(),
726 },
727 cases_for.map(|_| case_measures),
728 ))
729}
730
731fn case_measures_of(
735 measures: &mut BTreeMap<String, CaseMeasure>,
736 inputs: &Inputs<'_>,
737 case: &DatasetCase,
738 outcome: &CaseOutcome,
739 historical: Option<&CaseOutcome>,
740 probabilities: &Result<Vec<(String, f64)>, String>,
741 run: &RunRecord,
742) {
743 let config = inputs.config;
744 let label = case.label.value();
745 let proposal = matches!(outcome, CaseOutcome::Proposal { .. });
746 measures.insert("acceptance_coverage".into(), count_measure(proposal));
747 if let CaseOutcome::Proposal { action, params, .. } = outcome {
748 measures.insert(
749 "labeled_proposal_coverage".into(),
750 count_measure(label.is_some()),
751 );
752 if let Some(l) = label {
753 measures.insert(
754 "error_among_accepted".into(),
755 count_measure(!inputs.adapter.correct(action, params, l)),
756 );
757 }
758 }
759 if let Some(h) = historical {
760 measures.insert(
761 "candidate_outcome_change".into(),
762 count_measure(h.name() != outcome.name()),
763 );
764 }
765 if let (Some(l), Ok(p)) = (label, probabilities) {
766 let scored = Scored {
767 case: &case.id,
768 probabilities: p,
769 label: l,
770 };
771 measures.insert("mean_log_loss".into(), mean_measure(log_loss_term(&scored)));
772 if let Some((i, conf, top)) = reliability_bin(p, &config.reliability_bins) {
773 let groups = std::iter::once("all").chain(
774 config
775 .subgroups
776 .iter()
777 .map(String::as_str)
778 .filter(|g| case.subgroups.iter().any(|s| s == g)),
779 );
780 for g in groups {
781 measures.insert(
782 format!("reliability/{g}/{i}/confidence"),
783 CaseMeasure::Mean { num: conf, den: 1 },
784 );
785 measures.insert(
786 format!("reliability/{g}/{i}/accuracy"),
787 count_measure(top == l),
788 );
789 }
790 }
791 }
792 if inputs.candidate.is_none() {
795 for q in &config.latency_quantiles {
796 let service = match run.timing.elapsed_ms.checked_sub(run.timing.queued_ms) {
797 Some(v) => CaseMeasure::Sample { value: v as f64 },
798 None => CaseMeasure::Unknown {
799 reason: "the run records more queueing than elapsed time".into(),
800 },
801 };
802 measures.insert(format!("latency_ms/{}", quantile_name(*q)), service);
803 measures.insert(
804 format!("queue_ms/{}", quantile_name(*q)),
805 CaseMeasure::Sample {
806 value: run.timing.queued_ms as f64,
807 },
808 );
809 }
810 let not_comparable = || CaseMeasure::Unknown {
815 reason: "cost units are not declared comparable".into(),
816 };
817 let per_1000 = |units: u64| CaseMeasure::Mean {
818 num: 1000.0 * units as f64,
819 den: 1,
820 };
821 let unknown_charge = run.cost.liability > 0
822 || run
823 .requests
824 .iter()
825 .flat_map(|q| q.attempts.iter())
826 .any(|a| a.cost.charge == Charge::Unknown);
827 let observed = match (config.cost_units_comparable, unknown_charge) {
828 (false, _) => not_comparable(),
829 (true, true) => CaseMeasure::Unknown {
830 reason: "the case carries unknown cost liability".into(),
831 },
832 (true, false) => per_1000(run.cost.observed),
833 };
834 let estimated = if config.cost_units_comparable {
835 per_1000(run.cost.estimated)
836 } else {
837 not_comparable()
838 };
839 measures.insert(eval_cases::COST_PER_1000_OBSERVED.into(), observed);
840 measures.insert(eval_cases::COST_PER_1000_ESTIMATED.into(), estimated);
841 }
842}
843
844pub fn split_name(s: Split) -> &'static str {
845 match s {
846 Split::Training => "training",
847 Split::ModelSelection => "model_selection",
848 Split::Calibration => "calibration",
849 Split::FinalTest => "final_test",
850 }
851}
852
853impl EvalDetail {
854 pub fn count(&self, metric: &str) -> Option<&Count> {
855 self.counts.iter().find(|c| c.metric == metric)
856 }
857
858 pub fn reconcile(&self) -> Result<(), String> {
862 let get = |m: &str| self.count(m).ok_or(format!("no {m} count"));
863 let sum = |c: &Count| c.numerator + c.breakdown.values().sum::<u64>();
864 let coverage = get("coverage")?;
865 if coverage.denominator != self.cases.len() as u64 || sum(coverage) != coverage.denominator
866 {
867 return Err("coverage does not reconcile with the cases".into());
868 }
869 let accept = get("acceptance_coverage")?;
870 if accept.denominator != coverage.numerator || sum(accept) != accept.denominator {
871 return Err("acceptance does not reconcile with comparable cases".into());
872 }
873 let labeled = get("labeled_proposal_coverage")?;
874 if labeled.denominator != accept.numerator || sum(labeled) != labeled.denominator {
875 return Err("labels do not reconcile with proposals".into());
876 }
877 let error = get("error_among_accepted")?;
878 if error.denominator != labeled.numerator || error.numerator > error.denominator {
879 return Err("errors do not reconcile with labeled proposals".into());
880 }
881 let scored = get("probability_scored")?;
882 if scored.denominator != coverage.numerator || sum(scored) != scored.denominator {
883 return Err("probability scoring does not reconcile with comparable cases".into());
884 }
885 let per_case = |prefix: &str| {
886 self.cases
887 .iter()
888 .filter(|c| c.outcome.starts_with(prefix))
889 .count() as u64
890 };
891 if per_case("proposal:") != accept.numerator
892 || per_case("abstention:") != accept.denominator - accept.numerator
893 || per_case("incomparable") + per_case("diverged")
894 != coverage.denominator - coverage.numerator
895 {
896 return Err("per-case outcomes do not match the counts".into());
897 }
898 Ok(())
899 }
900}
901
902#[derive(Debug, Clone, PartialEq)]
904pub enum GateResult {
905 Pass,
906 Fail { reason: String },
907 Unknown { reason: String },
908}
909
910pub fn evaluate_gate(gate: &Gate, baseline: &Evaluated, candidate: &Evaluated) -> GateResult {
914 let unknown = |r: &str| GateResult::Unknown { reason: r.into() };
915 for e in [baseline, candidate] {
916 if e.report.record_digest().ok().as_ref() != Some(&e.detail.report) {
917 return unknown("a detail is not bound to its report");
918 }
919 if e.config.id().ok().as_ref() != Some(&e.report.evaluator_config)
920 || e.detail.config != e.report.evaluator_config
921 {
922 return unknown("a configuration is not the report's");
923 }
924 if e.detail.dataset != e.report.dataset.id || e.detail.split != e.report.dataset.split {
925 return unknown("a detail names another dataset or split than its report");
926 }
927 if let Err(r) = e.detail.reconcile() {
928 return unknown(&format!("a detail does not reconcile: {r}"));
929 }
930 if !e.config.gates.contains(gate) {
931 return unknown("the configuration does not declare this gate");
932 }
933 }
934 if baseline.detail.count("candidate_outcome_change").is_some()
936 || candidate.detail.count("candidate_outcome_change").is_none()
937 {
938 return unknown("the baseline and candidate roles do not hold");
939 }
940 let (b, c) = (&baseline.report, &candidate.report);
941 if b.dataset.id != c.dataset.id || b.dataset.split != c.dataset.split {
942 return unknown("the cohorts are different datasets or splits");
943 }
944 match (
946 baseline.config.bound_rules(),
947 candidate.config.bound_rules(),
948 ) {
949 (None, _) => return unknown("the baseline's task adapter rules are unbound"),
950 (_, None) => return unknown("the candidate's task adapter rules are unbound"),
951 (Some(x), Some(y)) if x != y => {
952 return unknown("the task adapter rules differ");
953 }
954 _ => {}
955 }
956 if b.evaluator_config != c.evaluator_config {
957 return unknown("the cohorts use different evaluator configurations");
958 }
959 let comparable = |e: &Evaluated| -> BTreeSet<String> {
962 e.detail
963 .cases
964 .iter()
965 .filter(|c| c.outcome.starts_with("proposal:") || c.outcome.starts_with("abstention:"))
966 .map(|c| c.case.clone())
967 .collect()
968 };
969 if comparable(baseline) != comparable(candidate) {
970 return unknown("the cohorts compare different cases");
971 }
972 for e in [baseline, candidate] {
973 for (metric, min, what) in [
974 ("coverage", gate.min_comparable_coverage, "comparable"),
975 (
976 "labeled_proposal_coverage",
977 gate.min_labeled_coverage,
978 "labeled",
979 ),
980 ] {
981 let Some(count) = e.detail.count(metric).filter(|c| c.denominator > 0) else {
982 return unknown(&format!("{what} coverage has a zero denominator"));
983 };
984 let lhs = i128::from(count.numerator) * 1_000_000_000;
986 let rhs = min.units() * i128::from(count.denominator);
987 if lhs < rhs {
988 return GateResult::Fail {
989 reason: format!("{what} coverage below the minimum"),
990 };
991 }
992 }
993 }
994 let value = |r: &EvalReport| {
995 r.metrics
996 .iter()
997 .find(|m| m.name == gate.metric)
998 .and_then(|m| match m.value {
999 MetricValue::Measured { value, .. } if value.is_finite() => Some(value),
1000 _ => None,
1001 })
1002 };
1003 let (Some(bv), Some(cv)) = (value(b), value(c)) else {
1004 return unknown("a measurement is missing, unknown or non-finite");
1005 };
1006 let tol = gate.tolerance.to_f64_nearest();
1007 let ok = match gate.direction {
1008 Direction::LowerIsBetter => cv <= bv + tol,
1009 Direction::HigherIsBetter => cv >= bv - tol,
1010 };
1011 if ok {
1012 GateResult::Pass
1013 } else {
1014 GateResult::Fail {
1015 reason: format!(
1016 "{}: candidate {cv} against baseline {bv} beyond {tol}",
1017 gate.metric
1018 ),
1019 }
1020 }
1021}
1022
1023pub const QUALIFICATION: &str = "rustev.qualification/1";
1038
1039#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1042#[serde(deny_unknown_fields)]
1043pub struct QualificationRecord {
1044 pub schema: String,
1045 pub task: String,
1047 pub dataset: DatasetId,
1048 pub admission: ContentDigest,
1050 pub config: EvaluatorConfigId,
1053 pub candidate: CandidateBinding,
1054 pub rules_baseline: ArtifactId,
1056 pub exact_baseline: ExactBaseline,
1057 pub calibrations: Vec<CalibrationId>,
1059 pub min_final_test: u64,
1062 pub min_slice: BTreeMap<String, u64>,
1064 pub analysis: Vec<String>,
1066 pub previous: Option<ContentDigest>,
1068 pub supersedes: Option<ContentDigest>,
1070 pub frozen_on: String,
1071}
1072
1073eval_document!(QualificationRecord, QUALIFICATION, REPLAY_V1, ContentDigest);
1074
1075#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1077#[serde(deny_unknown_fields)]
1078pub struct CandidateBinding {
1079 pub artifact: ArtifactId,
1080 pub model: ModelRef,
1081}
1082
1083#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1086#[serde(rename_all = "snake_case", deny_unknown_fields)]
1087pub enum ExactBaseline {
1088 Evaluated,
1089 NotApplicable { reason: String },
1090}
1091
1092#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1094pub enum Role {
1095 Candidate,
1096 CandidateCalibrated,
1098 RulesBaseline,
1099 ExactBaseline,
1100}
1101
1102impl Role {
1103 pub fn name(self) -> &'static str {
1104 match self {
1105 Role::Candidate => "candidate",
1106 Role::CandidateCalibrated => "candidate, calibrated",
1107 Role::RulesBaseline => "rules baseline",
1108 Role::ExactBaseline => "exact baseline",
1109 }
1110 }
1111}
1112
1113#[derive(Debug, Clone, PartialEq, Eq)]
1115pub enum QualificationError {
1116 Schema,
1117 Incomplete(&'static str),
1118 NotAdmitted,
1121 Config(String),
1123 LabelsNotIndependent {
1125 producer: ModelRef,
1126 },
1127 UndeclaredSystem,
1129 CalibrationVoid {
1132 calibration: String,
1133 reason: String,
1134 },
1135 Slice(String),
1137 Supersedes(String),
1139 ConfigChanged {
1142 frozen: EvaluatorConfigId,
1143 found: EvaluatorConfigId,
1144 },
1145 Report {
1148 role: &'static str,
1149 reason: String,
1150 },
1151}
1152
1153impl fmt::Display for QualificationError {
1154 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1155 write!(f, "{self:?}")
1156 }
1157}
1158
1159impl std::error::Error for QualificationError {}
1160
1161#[derive(Debug, Clone)]
1163pub struct Frozen {
1164 record: QualificationRecord,
1165 id: ContentDigest,
1166}
1167
1168impl Frozen {
1169 pub fn record(&self) -> &QualificationRecord {
1170 &self.record
1171 }
1172 pub fn id(&self) -> &ContentDigest {
1174 &self.id
1175 }
1176}
1177
1178pub struct FreezeInputs<'a> {
1180 pub record: &'a QualificationRecord,
1181 pub manifest: &'a DatasetManifest,
1182 pub admission: Option<&'a crate::dataset::Admitted>,
1184 pub config: &'a EvaluatorConfig,
1185 pub calibrations: &'a [(CalibrationArtifact, Option<CalibrationFit>)],
1187 pub superseded: Option<(&'a QualificationRecord, &'a DatasetManifest)>,
1189}
1190
1191fn admitted_for<'a>(
1192 admission: Option<&'a crate::dataset::Admitted>,
1193 manifest: &DatasetManifest,
1194) -> Result<&'a crate::dataset::Admitted, QualificationError> {
1195 let a = admission.ok_or(QualificationError::NotAdmitted)?;
1196 if manifest.id().ok().as_ref() != Some(a.dataset()) {
1197 return Err(QualificationError::NotAdmitted);
1198 }
1199 Ok(a)
1200}
1201
1202pub fn freeze(inputs: &FreezeInputs<'_>) -> Result<Frozen, QualificationError> {
1208 use QualificationError as E;
1209 let r = inputs.record;
1210 if r.schema != QUALIFICATION {
1211 return Err(E::Schema);
1212 }
1213 let blank = |s: &str| s.trim().is_empty();
1214 if blank(&r.task) || blank(&r.frozen_on) {
1215 return Err(E::Incomplete("task and freeze date"));
1216 }
1217 let admitted = admitted_for(inputs.admission, inputs.manifest)?;
1218 if &r.dataset != admitted.dataset() || &r.admission != admitted.id() {
1219 return Err(E::NotAdmitted);
1220 }
1221 let config = inputs.config;
1222 config.check().map_err(|e| E::Config(e.0))?;
1223 if config.id().ok().as_ref() != Some(&r.config) {
1224 return Err(E::Config("the configuration is not the frozen one".into()));
1225 }
1226 let mapping = &admitted.record().task_mapping;
1227 if config.bound_rules() != Some(&mapping.rules) {
1228 return Err(E::Config(
1229 "the configuration does not bind the admitted adapter rules".into(),
1230 ));
1231 }
1232 if config.adapter.reference() != mapping.adapter {
1233 return Err(E::Config("the configuration names another adapter".into()));
1234 }
1235 let declared: BTreeSet<&str> = config.metrics.iter().map(|m| m.name.as_str()).collect();
1236 if r.analysis.is_empty()
1237 || r.analysis
1238 .iter()
1239 .any(|m| !declared.contains(m.split('/').next().unwrap_or_default()))
1240 {
1241 return Err(E::Config(
1242 "the analysis names a metric the configuration does not declare".into(),
1243 ));
1244 }
1245 if let Some(producer) = admitted.related_labeler(&r.candidate.model) {
1246 return Err(E::LabelsNotIndependent {
1247 producer: producer.clone(),
1248 });
1249 }
1250 if !admitted.declares_system(&r.candidate.model) {
1251 return Err(E::UndeclaredSystem);
1252 }
1253 if let ExactBaseline::NotApplicable { reason } = &r.exact_baseline
1254 && blank(reason)
1255 {
1256 return Err(E::Incomplete("why no exact baseline"));
1257 }
1258 if r.min_final_test == 0 {
1259 return Err(E::Incomplete("the final-test sample-size target"));
1260 }
1261 let slices: BTreeSet<&str> = admitted
1262 .record()
1263 .population
1264 .slices
1265 .iter()
1266 .map(|s| s.name.as_str())
1267 .collect();
1268 let minimums: BTreeSet<&str> = r.min_slice.keys().map(String::as_str).collect();
1269 if slices != minimums || r.min_slice.values().any(|m| *m == 0) {
1270 return Err(E::Slice(
1271 "every declared slice, and only those, has a positive minimum".into(),
1272 ));
1273 }
1274 if let Some(s) = slices
1275 .iter()
1276 .find(|s| !config.subgroups.iter().any(|g| g == *s))
1277 {
1278 return Err(E::Slice(format!(
1279 "slice {s} is not a configuration subgroup"
1280 )));
1281 }
1282 let mut frozen_cals: Vec<&CalibrationId> = r.calibrations.iter().collect();
1285 frozen_cals.sort();
1286 let mut given = vec![];
1287 for (artifact, fit) in inputs.calibrations {
1288 let id = artifact
1289 .id()
1290 .map_err(|_| E::Incomplete("a calibration artifact's identity"))?;
1291 let void = |reason: String| E::CalibrationVoid {
1292 calibration: id.to_string(),
1293 reason,
1294 };
1295 match crate::fit::qualify(artifact, fit.as_ref(), inputs.manifest, Split::FinalTest) {
1296 crate::fit::Qualification::Qualified => {}
1297 crate::fit::Qualification::Unknown { reason }
1298 | crate::fit::Qualification::Refused { reason } => return Err(void(reason)),
1299 }
1300 if fit.as_ref().is_some_and(|f| f.split != Split::Calibration) {
1301 return Err(void("not fitted on the calibration split".into()));
1302 }
1303 given.push(id);
1304 }
1305 given.sort();
1306 if given.iter().collect::<Vec<_>>() != frozen_cals {
1307 return Err(E::Config(
1308 "the calibrations supplied are not exactly the frozen ones".into(),
1309 ));
1310 }
1311 match (&r.supersedes, inputs.superseded) {
1312 (None, None) => {}
1313 (Some(old_id), Some((old, old_manifest))) => {
1314 if old.id().ok().as_ref() != Some(old_id) {
1315 return Err(E::Supersedes(
1316 "the superseded record's digest differs".into(),
1317 ));
1318 }
1319 if old_manifest.id().ok().as_ref() != Some(&old.dataset) {
1320 return Err(E::Supersedes(
1321 "the superseded manifest is not its dataset".into(),
1322 ));
1323 }
1324 for c in inputs.manifest.split(Split::FinalTest) {
1325 if old_manifest
1326 .split(Split::FinalTest)
1327 .any(|o| o.source == c.source || o.snapshot == c.snapshot)
1328 {
1329 return Err(E::Supersedes(format!(
1330 "final-test case {} reuses the superseded final test",
1331 c.id
1332 )));
1333 }
1334 }
1335 }
1336 _ => {
1337 return Err(E::Supersedes(
1338 "a replacement names, and is given, what it supersedes".into(),
1339 ));
1340 }
1341 }
1342 let id = r.id().map_err(|_| E::Incomplete("canonical form"))?;
1343 Ok(Frozen {
1344 record: r.clone(),
1345 id,
1346 })
1347}
1348
1349#[derive(Debug, Clone, PartialEq)]
1355pub enum Figure {
1356 Ratio { numerator: u64, denominator: u64 },
1357 Value { value: f64, n: u64 },
1358 Unknown { reason: String },
1359}
1360
1361impl Figure {
1362 fn ratio(numerator: u64, denominator: u64) -> Figure {
1363 if denominator == 0 {
1364 Figure::Unknown {
1365 reason: "zero denominator".into(),
1366 }
1367 } else {
1368 Figure::Ratio {
1369 numerator,
1370 denominator,
1371 }
1372 }
1373 }
1374}
1375
1376#[derive(Debug, Clone, PartialEq)]
1377pub struct Row {
1378 pub name: String,
1379 pub figure: Figure,
1380}
1381
1382#[derive(Debug, Clone, PartialEq)]
1384pub struct SliceRows {
1385 pub slice: String,
1386 pub cases: u64,
1388 pub rows: Vec<Row>,
1389}
1390
1391#[derive(Debug, Clone, PartialEq)]
1394pub struct RoleFigures {
1395 pub role: Role,
1396 pub report: ContentDigest,
1398 pub headline: Vec<Row>,
1399 pub slices: Vec<SliceRows>,
1400 pub exposed: Vec<Row>,
1402}
1403
1404#[derive(Debug, Clone, PartialEq)]
1406pub struct Measurement {
1407 qualification: ContentDigest,
1408 pub roles: Vec<RoleFigures>,
1409}
1410
1411impl Measurement {
1412 pub fn qualification(&self) -> &ContentDigest {
1414 &self.qualification
1415 }
1416 pub fn role(&self, role: Role) -> Option<&RoleFigures> {
1417 self.roles.iter().find(|r| r.role == role)
1418 }
1419}
1420
1421fn count_rows(detail: &EvalDetail, manifest: &DatasetManifest, cases: &BTreeSet<&str>) -> Vec<Row> {
1424 let mut excluded: BTreeMap<String, u64> = BTreeMap::new();
1425 let mut abstentions: BTreeMap<String, u64> = BTreeMap::new();
1426 let (mut comparable, mut proposals, mut labeled) = (0u64, 0u64, 0u64);
1427 for d in detail
1428 .cases
1429 .iter()
1430 .filter(|d| cases.contains(d.case.as_str()))
1431 {
1432 if let Some(kind) = d.outcome.strip_prefix("abstention:") {
1433 comparable += 1;
1434 bump(&mut abstentions, kind);
1435 } else if d.outcome.starts_with("proposal:") {
1436 comparable += 1;
1437 proposals += 1;
1438 let label = manifest.cases.iter().find(|c| c.id == d.case);
1439 if label.is_some_and(|c| c.label.value().is_some()) {
1440 labeled += 1;
1441 }
1442 } else {
1443 let reason = match (&d.outcome[..], &d.reason) {
1444 ("incomparable", Some(r)) => format!("incomparable:{r}"),
1445 (o, _) => o.to_string(),
1446 };
1447 bump(&mut excluded, reason);
1448 }
1449 }
1450 let n = cases.len() as u64;
1451 let mut rows = vec![Row {
1452 name: "coverage".into(),
1453 figure: Figure::ratio(comparable, n),
1454 }];
1455 for (reason, k) in excluded {
1456 rows.push(Row {
1457 name: format!("excluded/{reason}"),
1458 figure: Figure::ratio(k, n),
1459 });
1460 }
1461 rows.push(Row {
1462 name: "acceptance_coverage".into(),
1463 figure: Figure::ratio(proposals, comparable),
1464 });
1465 for (kind, k) in abstentions {
1466 rows.push(Row {
1467 name: format!("abstention/{kind}"),
1468 figure: Figure::ratio(k, comparable),
1469 });
1470 }
1471 rows.push(Row {
1472 name: "labeled_proposal_coverage".into(),
1473 figure: Figure::ratio(labeled, proposals),
1474 });
1475 rows
1476}
1477
1478fn value_rows(
1481 report: &EvalReport,
1482 names: &[&str],
1483 prefix: &str,
1484 blocked: Option<&str>,
1485) -> Vec<Row> {
1486 report
1487 .metrics
1488 .iter()
1489 .filter(|m| names.contains(&m.name.as_str()) || m.name.starts_with(prefix))
1490 .map(|m| Row {
1491 name: m.name.clone(),
1492 figure: match (blocked, &m.value) {
1493 (Some(r), _) => Figure::Unknown { reason: r.into() },
1494 (None, MetricValue::Measured { value, n }) if value.is_finite() => Figure::Value {
1495 value: *value,
1496 n: *n,
1497 },
1498 (None, MetricValue::Measured { .. }) => Figure::Unknown {
1499 reason: "non-finite".into(),
1500 },
1501 (None, MetricValue::Unknown { reason }) => Figure::Unknown {
1502 reason: reason.clone(),
1503 },
1504 },
1505 })
1506 .collect()
1507}
1508
1509fn all_unknown(rows: Vec<Row>, reason: &str) -> Vec<Row> {
1510 rows.into_iter()
1511 .map(|r| Row {
1512 name: r.name,
1513 figure: Figure::Unknown {
1514 reason: reason.into(),
1515 },
1516 })
1517 .collect()
1518}
1519
1520pub fn measure(
1529 frozen: &Frozen,
1530 admission: Option<&crate::dataset::Admitted>,
1531 manifest: &DatasetManifest,
1532 reports: &[(Role, &Evaluated)],
1533) -> Result<Measurement, QualificationError> {
1534 use QualificationError as E;
1535 let admitted = admitted_for(admission, manifest)?;
1536 let r = &frozen.record;
1537 if &r.dataset != admitted.dataset() || &r.admission != admitted.id() {
1538 return Err(E::NotAdmitted);
1539 }
1540 let mut expected = vec![Role::Candidate, Role::RulesBaseline];
1541 if !r.calibrations.is_empty() {
1542 expected.push(Role::CandidateCalibrated);
1543 }
1544 if r.exact_baseline == ExactBaseline::Evaluated {
1545 expected.push(Role::ExactBaseline);
1546 }
1547 expected.sort();
1548 let mut given: Vec<Role> = reports.iter().map(|(role, _)| *role).collect();
1549 given.sort();
1550 if given != expected {
1551 return Err(E::Report {
1552 role: "all",
1553 reason: format!("roles {given:?} are not the frozen {expected:?}"),
1554 });
1555 }
1556 let final_test: BTreeSet<&str> = manifest
1557 .split(Split::FinalTest)
1558 .map(|c| c.id.as_str())
1559 .collect();
1560 let exposed: BTreeSet<&str> = admitted
1561 .record()
1562 .exposure
1563 .exposed_final_test
1564 .iter()
1565 .map(String::as_str)
1566 .collect();
1567 let cohort: BTreeSet<&str> = final_test.difference(&exposed).copied().collect();
1568 let mut frozen_cals = r.calibrations.clone();
1569 frozen_cals.sort();
1570 let mut roles = vec![];
1571 for (role, e) in reports {
1572 let bad = |reason: &str| E::Report {
1573 role: role.name(),
1574 reason: reason.into(),
1575 };
1576 let digest = e
1577 .report
1578 .record_digest()
1579 .map_err(|_| bad("the report has no record form"))?;
1580 if digest != e.detail.report {
1581 return Err(bad("the detail is not bound to its report"));
1582 }
1583 if e.config.id().ok().as_ref() != Some(&e.report.evaluator_config)
1584 || e.detail.config != e.report.evaluator_config
1585 {
1586 return Err(bad("the configuration is not the report's"));
1587 }
1588 if e.report.evaluator_config != r.config {
1589 return Err(E::ConfigChanged {
1590 frozen: r.config.clone(),
1591 found: e.report.evaluator_config.clone(),
1592 });
1593 }
1594 if &e.report.dataset.id != admitted.dataset()
1595 || e.detail.dataset != e.report.dataset.id
1596 || e.report.dataset.split != Split::FinalTest
1597 || e.detail.split != Split::FinalTest
1598 || e.report.dataset.provenance != manifest.provenance
1599 {
1600 return Err(bad("not the admitted dataset's final-test split"));
1601 }
1602 e.detail.reconcile().map_err(|x| bad(&x))?;
1603 let cases: BTreeSet<&str> = e.detail.cases.iter().map(|c| c.case.as_str()).collect();
1604 if cases != final_test || cases.len() != e.detail.cases.len() {
1605 return Err(bad("the report is not over every final-test case once"));
1606 }
1607 let names = |a: &ArtifactId| e.report.artifacts.contains(a);
1608 let mut cals = e.report.calibrations.clone();
1609 cals.sort();
1610 let binding_ok = match role {
1611 Role::Candidate => names(&r.candidate.artifact) && cals.is_empty(),
1612 Role::CandidateCalibrated => names(&r.candidate.artifact) && cals == frozen_cals,
1613 Role::RulesBaseline => names(&r.rules_baseline),
1614 Role::ExactBaseline => e.report.artifacts.is_empty(),
1615 };
1616 if !binding_ok {
1617 return Err(bad("the report's bindings are not the frozen ones"));
1618 }
1619 let small = format!(
1620 "{} final-test cases, below the frozen target {}",
1621 cohort.len(),
1622 r.min_final_test
1623 );
1624 let below = (cohort.len() as u64) < r.min_final_test;
1625 let blocked = if !exposed.is_empty() {
1626 Some("the report's cohort includes exposed cases")
1627 } else {
1628 None
1629 };
1630 let mut headline = count_rows(&e.detail, manifest, &cohort);
1631 headline.extend(value_rows(
1632 &e.report,
1633 &["error_among_accepted", "mean_log_loss", "brier"],
1634 "reliability/all/",
1635 blocked,
1636 ));
1637 if below {
1638 headline = all_unknown(headline, &small);
1639 }
1640 let mut slices = vec![];
1641 for s in &admitted.record().population.slices {
1642 let members: BTreeSet<&str> = manifest
1643 .split(Split::FinalTest)
1644 .filter(|c| cohort.contains(c.id.as_str()) && c.subgroups.contains(&s.name))
1645 .map(|c| c.id.as_str())
1646 .collect();
1647 let min = r.min_slice.get(&s.name).copied().unwrap_or(u64::MAX);
1648 let mut rows = count_rows(&e.detail, manifest, &members);
1649 rows.push(Row {
1650 name: "error_among_accepted".into(),
1651 figure: Figure::Unknown {
1652 reason: "a spec 004 report states no per-slice error".into(),
1653 },
1654 });
1655 rows.extend(value_rows(
1656 &e.report,
1657 &[],
1658 &format!("reliability/{}/", s.name),
1659 blocked,
1660 ));
1661 if (members.len() as u64) < min {
1662 rows = all_unknown(
1663 rows,
1664 &format!("{} cases, below the frozen minimum {min}", members.len()),
1665 );
1666 }
1667 slices.push(SliceRows {
1668 slice: s.name.clone(),
1669 cases: members.len() as u64,
1670 rows,
1671 });
1672 }
1673 let exposed_rows = if exposed.is_empty() {
1674 vec![]
1675 } else {
1676 count_rows(&e.detail, manifest, &exposed)
1677 };
1678 roles.push(RoleFigures {
1679 role: *role,
1680 report: digest,
1681 headline,
1682 slices,
1683 exposed: exposed_rows,
1684 });
1685 }
1686 roles.sort_by_key(|x| x.role);
1687 Ok(Measurement {
1688 qualification: frozen.id.clone(),
1689 roles,
1690 })
1691}
1692
1693pub fn regression(
1700 frozen: &Frozen,
1701 previous: Option<(&Frozen, &Evaluated)>,
1702 current: &Evaluated,
1703) -> Vec<(String, GateResult)> {
1704 let Some(want) = &frozen.record.previous else {
1705 return vec![];
1706 };
1707 let unknown = |reason: &str| {
1708 current
1709 .config
1710 .gates
1711 .iter()
1712 .map(|g| {
1713 (
1714 g.name.clone(),
1715 GateResult::Unknown {
1716 reason: reason.into(),
1717 },
1718 )
1719 })
1720 .collect()
1721 };
1722 let Some((old, baseline)) = previous else {
1723 return unknown("the previous qualified result was not supplied");
1724 };
1725 if old.id() != want || old.record.task != frozen.record.task {
1726 return unknown("the supplied result is not the frozen previous one");
1727 }
1728 current
1729 .config
1730 .gates
1731 .iter()
1732 .map(|g| (g.name.clone(), evaluate_gate(g, baseline, current)))
1733 .collect()
1734}
1735
1736#[derive(Debug, Clone, PartialEq, Eq)]
1739pub enum Served {
1740 Observed { version: String },
1741 Unknown,
1742}
1743
1744pub struct ClaimRequest<'a> {
1748 pub role: Role,
1749 pub figure: &'a str,
1751 pub slice: Option<&'a str>,
1752 pub population: &'a str,
1753 pub call_dates: &'a str,
1754 pub served: &'a Served,
1755}
1756
1757#[derive(Debug, Clone, PartialEq, Eq)]
1759pub struct ScopedClaim {
1760 pub sentence: String,
1761 pub limits: Vec<String>,
1762}
1763
1764#[derive(Debug, Clone, PartialEq, Eq)]
1766pub enum ClaimError {
1767 NotAdmitted,
1769 NotThisQualification,
1771 PopulationOutside {
1773 claimed: String,
1774 admitted: String,
1775 },
1776 Incomplete(&'static str),
1777 NoSuchFigure,
1778 UnknownFigure {
1780 reason: String,
1781 },
1782}
1783
1784impl fmt::Display for ClaimError {
1785 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1786 write!(f, "{self:?}")
1787 }
1788}
1789
1790impl std::error::Error for ClaimError {}
1791
1792pub fn scope_claim(
1797 admission: Option<&crate::dataset::Admitted>,
1798 frozen: &Frozen,
1799 measurement: &Measurement,
1800 request: &ClaimRequest<'_>,
1801) -> Result<ScopedClaim, ClaimError> {
1802 let admitted = admission.ok_or(ClaimError::NotAdmitted)?;
1803 let r = &frozen.record;
1804 if &r.dataset != admitted.dataset() || &r.admission != admitted.id() {
1805 return Err(ClaimError::NotAdmitted);
1806 }
1807 if measurement.qualification() != frozen.id() {
1808 return Err(ClaimError::NotThisQualification);
1809 }
1810 let a = admitted.record();
1811 if request.population != a.population.id {
1812 return Err(ClaimError::PopulationOutside {
1813 claimed: request.population.into(),
1814 admitted: a.population.id.clone(),
1815 });
1816 }
1817 if request.call_dates.trim().is_empty() {
1818 return Err(ClaimError::Incomplete("call dates"));
1819 }
1820 if let Served::Observed { version } = request.served
1821 && version.trim().is_empty()
1822 {
1823 return Err(ClaimError::Incomplete("served version"));
1824 }
1825 let figures = measurement
1826 .role(request.role)
1827 .ok_or(ClaimError::NoSuchFigure)?;
1828 let rows = match request.slice {
1829 None => &figures.headline,
1830 Some(s) => {
1831 &figures
1832 .slices
1833 .iter()
1834 .find(|x| x.slice == s)
1835 .ok_or(ClaimError::NoSuchFigure)?
1836 .rows
1837 }
1838 };
1839 let row = rows
1840 .iter()
1841 .find(|x| x.name == request.figure)
1842 .ok_or(ClaimError::NoSuchFigure)?;
1843 let value = match &row.figure {
1844 Figure::Unknown { reason } => {
1845 return Err(ClaimError::UnknownFigure {
1846 reason: reason.clone(),
1847 });
1848 }
1849 Figure::Ratio {
1850 numerator,
1851 denominator,
1852 } => format!("{numerator}/{denominator}"),
1853 Figure::Value { value, n } => format!("{value} (n = {n})"),
1854 };
1855 let binding = match request.role {
1856 Role::Candidate => format!("binding {}", r.candidate.artifact),
1857 Role::CandidateCalibrated => {
1858 let cals: Vec<String> = r.calibrations.iter().map(ToString::to_string).collect();
1859 format!(
1860 "binding {} with calibration {}",
1861 r.candidate.artifact,
1862 cals.join(", ")
1863 )
1864 }
1865 Role::RulesBaseline => format!("rules baseline binding {}", r.rules_baseline),
1866 Role::ExactBaseline => "the exact plan steps (no semantic binding)".into(),
1867 };
1868 let served = match request.served {
1869 Served::Observed { version } => format!("served identity {version}"),
1870 Served::Unknown => "served identity not observable".into(),
1871 };
1872 let slice = request
1873 .slice
1874 .map(|s| format!(" in slice {s}"))
1875 .unwrap_or_default();
1876 let sentence = format!(
1877 "On dataset {}, split final_test, evaluator configuration {}, {binding}, calls {}, \
1878 {served}, population {}: {} ({}){slice} is {value}.",
1879 r.dataset,
1880 r.config,
1881 request.call_dates,
1882 a.population.id,
1883 request.figure,
1884 request.role.name(),
1885 );
1886 let mut limits = vec![
1887 format!("population: {}", a.population.limitations),
1888 format!("near-duplicates: {}", a.splits.near_duplicates),
1889 "no claim about another population, another binding, real user data not represented, \
1890 or a served model version not observed"
1891 .to_string(),
1892 "point estimates without intervals (spec 023, 6.3)".to_string(),
1893 ];
1894 if !a.exposure.training_data.trim().is_empty() {
1895 limits.push(format!(
1896 "training-data exposure: {}",
1897 a.exposure.training_data
1898 ));
1899 }
1900 if !a.exposure.exposed_final_test.is_empty() {
1901 limits.push(format!(
1902 "{} exposed final-test cases are reported apart and excluded",
1903 a.exposure.exposed_final_test.len()
1904 ));
1905 }
1906 if let Some(c) = &a.exposure.ranking_caveat {
1907 limits.push(format!("ranking exposure: {c}"));
1908 }
1909 if *request.served == Served::Unknown {
1910 limits.push(
1911 "a change of served model version during the calls would not have been visible".into(),
1912 );
1913 }
1914 Ok(ScopedClaim { sentence, limits })
1915}