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