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