1use std::collections::BTreeSet;
12use std::fmt;
13
14use rustev_contract::eval_report::{DatasetProvenance, MetricValue, Split};
15use rustev_contract::ids::{ContentDigest, DatasetId, EvaluatorConfigId, PlanId};
16use rustev_contract::limits::REPLAY_V1;
17use rustev_contract::{Document, Identified};
18use serde::{Deserialize, Serialize};
19
20use crate::config::{AdapterRules, ConfigError};
21use crate::dataset::DatasetManifest;
22use crate::eval_cases::{self, EvalCases, derived, form_in, metric_cases};
23use crate::generator::run_seed;
24use crate::holdout::{Append, HeldOutRecord, HoldoutLedger, LedgerError};
25use crate::repeat_config::RepeatConfig;
26use crate::repeat_stats::{
27 self, Band, Basis, Bootstrap, Figure, Interval, MetricCases, StatsError, across_runs, run_stats,
28};
29use crate::report::Evaluated;
30use rustev_contract::scope::Handle;
31
32pub const REPEAT_REPORT: &str = "rustev.repeat-report/1";
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum Source {
38 Replay,
40 Captured,
42 Live,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum SplitRole {
50 Train,
51 Validation,
52 HeldOut,
53}
54
55pub fn split_role(split: Split) -> SplitRole {
56 match split {
57 Split::Training => SplitRole::Train,
58 Split::ModelSelection | Split::Calibration => SplitRole::Validation,
59 Split::FinalTest => SplitRole::HeldOut,
60 }
61}
62
63#[derive(Clone, Copy)]
65pub struct Run<'a> {
66 pub evaluated: &'a Evaluated,
67 pub cases: &'a EvalCases,
68}
69
70pub struct RepeatInputs<'a> {
71 pub source: Source,
72 pub config: &'a RepeatConfig,
73 pub manifest: &'a DatasetManifest,
74 pub runs: &'a [Run<'a>],
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum RepeatError {
81 Config(ConfigError),
82 LiveSourceUnavailable,
83 Runs(usize),
85 MixedRuns(String),
86 NotReproducible(String),
87 Reconcile(String),
88 BootstrapTooLarge(String),
89 Shape(String),
90 Ledger(LedgerError),
92 NotHeldOut,
94}
95
96impl RepeatError {
97 pub fn code(&self) -> &'static str {
99 match self {
100 RepeatError::Config(_) | RepeatError::Runs(_) | RepeatError::Shape(_) => {
101 "repeat-config"
102 }
103 RepeatError::LiveSourceUnavailable => "live-source-unavailable",
104 RepeatError::MixedRuns(_) => "mixed-runs",
105 RepeatError::NotReproducible(_) => "not-reproducible",
106 RepeatError::Reconcile(_) => "reconcile",
107 RepeatError::BootstrapTooLarge(_) => "bootstrap-too-large",
108 RepeatError::Ledger(_) => "holdout-ledger",
109 RepeatError::NotHeldOut => "not-held-out",
110 }
111 }
112}
113
114impl fmt::Display for RepeatError {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 write!(f, "{}: {self:?}", self.code())
117 }
118}
119
120impl std::error::Error for RepeatError {}
121
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct RepeatReport {
125 pub schema: String,
126 pub source: Source,
127 pub repeat_config: ContentDigest,
128 pub dataset: DatasetId,
129 pub split: Split,
130 pub split_role: SplitRole,
131 pub provenance: DatasetProvenance,
132 pub evaluator_config: EvaluatorConfigId,
133 pub adapter_rules: Option<AdapterRules>,
134 pub plan: PlanId,
135 pub candidate: bool,
137 pub runs: Vec<RunRef>,
138 pub stable_cohort: Vec<String>,
140 pub held_out: Option<HeldOut>,
142 pub cohorts: Vec<CohortReport>,
143}
144
145crate::doc::eval_document!(RepeatReport, REPEAT_REPORT, REPLAY_V1);
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(deny_unknown_fields)]
150pub struct RunRef {
151 pub report: ContentDigest,
152 pub detail: ContentDigest,
153 pub cases: ContentDigest,
154 pub seed: String,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case", deny_unknown_fields)]
163pub enum HeldOut {
164 Unknown {
165 reason: String,
166 },
167 Counted(HeldOutRecord),
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(rename_all = "snake_case")]
173pub enum CohortStatus {
174 Reported,
175 BelowMinimum,
178}
179
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181#[serde(deny_unknown_fields)]
182pub struct CohortReport {
183 pub name: String,
185 pub status: CohortStatus,
186 pub cases: u64,
187 pub stable_cases: u64,
188 pub min_cases: Option<u64>,
190 pub metrics: Vec<MetricReport>,
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "snake_case")]
197pub enum Observation {
198 Candidate,
199 Historical,
200}
201
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
203#[serde(deny_unknown_fields)]
204pub struct MetricReport {
205 pub name: String,
206 pub observation: Option<Observation>,
208 pub runs: Vec<FigureDoc>,
209 pub mean: FigureDoc,
210 pub sd: FigureDoc,
211 pub min: FigureDoc,
212 pub max: FigureDoc,
213 pub pooled: FigureDoc,
214 pub interval: IntervalDoc,
215 pub band: BandDoc,
216}
217
218#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
219#[serde(rename_all = "snake_case", deny_unknown_fields)]
220pub enum FigureDoc {
221 Value(f64),
222 Unknown(String),
223}
224
225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
226#[serde(rename_all = "snake_case", deny_unknown_fields)]
227pub enum IntervalDoc {
228 Bounds { lower: f64, upper: f64 },
229 Unknown { reason: String, undefined: u64 },
230}
231
232#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
233#[serde(rename_all = "snake_case", deny_unknown_fields)]
234pub enum BandDoc {
235 Value { value: f64, basis: BasisDoc },
236 Unknown(String),
237}
238
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "snake_case")]
241pub enum BasisDoc {
242 KSd,
243 IntervalWidth,
244 DeterministicReplay,
245 Floor,
246}
247
248fn figure(f: &Figure) -> FigureDoc {
249 match f {
250 Figure::Known(v) => FigureDoc::Value(*v),
251 Figure::Unknown { reason } => FigureDoc::Unknown(reason.clone()),
252 }
253}
254
255fn interval(i: &Interval) -> IntervalDoc {
256 match i {
257 Interval::Known { lower, upper } => IntervalDoc::Bounds {
258 lower: *lower,
259 upper: *upper,
260 },
261 Interval::Unknown { reason, undefined } => IntervalDoc::Unknown {
262 reason: reason.clone(),
263 undefined: *undefined,
264 },
265 }
266}
267
268fn band_doc(b: &Band) -> BandDoc {
269 match b {
270 Band::Known { value, basis } => BandDoc::Value {
271 value: *value,
272 basis: match basis {
273 Basis::KSd => BasisDoc::KSd,
274 Basis::IntervalWidth => BasisDoc::IntervalWidth,
275 Basis::DeterministicReplay => BasisDoc::DeterministicReplay,
276 Basis::Floor => BasisDoc::Floor,
277 },
278 },
279 Band::Unknown { reason } => BandDoc::Unknown(reason.clone()),
280 }
281}
282
283fn unknown_metric(
284 name: &str,
285 observation: Option<Observation>,
286 runs: usize,
287 reason: &str,
288) -> MetricReport {
289 let u = || FigureDoc::Unknown(reason.into());
290 MetricReport {
291 name: name.into(),
292 observation,
293 runs: (0..runs).map(|_| u()).collect(),
294 mean: u(),
295 sd: u(),
296 min: u(),
297 max: u(),
298 pooled: u(),
299 interval: IntervalDoc::Unknown {
300 reason: reason.into(),
301 undefined: 0,
302 },
303 band: BandDoc::Unknown(reason.into()),
304 }
305}
306
307fn observation(metric: &str, candidate: bool) -> Option<Observation> {
314 let observed = metric.starts_with("latency_ms/")
315 || metric.starts_with("queue_ms/")
316 || matches!(
317 metric,
318 "cost_observed_units"
319 | "cost_estimated_units"
320 | "cost_unknown_attempts"
321 | "cost_liability_units"
322 | eval_cases::COST_PER_1000_OBSERVED
323 | eval_cases::COST_PER_1000_ESTIMATED
324 );
325 observed.then_some(if candidate {
326 Observation::Historical
327 } else {
328 Observation::Candidate
329 })
330}
331
332pub fn repeat(inputs: &RepeatInputs<'_>) -> Result<RepeatReport, RepeatError> {
334 let config = inputs.config;
335 config.check().map_err(RepeatError::Config)?;
336 let runs = inputs.runs;
337 let n = runs.len();
338 if !(1..=repeat_stats::MAX_RUNS).contains(&n) {
339 return Err(RepeatError::Runs(n));
340 }
341 for r in runs {
342 eval_cases::reconcile(r.evaluated, r.cases).map_err(RepeatError::Reconcile)?;
343 }
344 let first = runs[0].evaluated;
345 let names: Vec<&str> = first
346 .report
347 .metrics
348 .iter()
349 .map(|m| m.name.as_str())
350 .collect();
351 let candidate = names.contains(&"candidate_outcome_change");
352 for r in runs {
353 let e = r.evaluated;
354 let same = e.report.dataset == first.report.dataset
355 && e.report.evaluator_config == first.report.evaluator_config
356 && e.report.plan == first.report.plan
357 && e.report
358 .metrics
359 .iter()
360 .map(|m| m.name.as_str())
361 .eq(names.iter().copied());
362 if !same {
363 return Err(RepeatError::MixedRuns(
364 "the runs differ in dataset, split, evaluator configuration, plan or metrics"
365 .into(),
366 ));
367 }
368 }
369 let dataset = inputs
370 .manifest
371 .id()
372 .map_err(|_| RepeatError::Shape("the manifest has no identity".into()))?;
373 if first.report.dataset.id != dataset {
374 return Err(RepeatError::MixedRuns(
375 "the runs evaluate another dataset".into(),
376 ));
377 }
378 let split = first.report.dataset.split;
379 let split_cases: Vec<_> = inputs.manifest.split(split).collect();
380 let docs: Vec<&EvalCases> = runs.iter().map(|r| r.cases).collect();
381 if docs.iter().any(|d| {
383 d.cases.len() != split_cases.len()
384 || d.cases
385 .iter()
386 .zip(&split_cases)
387 .any(|(a, b)| a.case != b.id)
388 }) {
389 return Err(RepeatError::MixedRuns(
390 "the runs do not list the split's cases in manifest order".into(),
391 ));
392 }
393 if inputs.source == Source::Replay
394 && let Some(r) = runs.iter().position(|r| {
395 r.cases.cases != runs[0].cases.cases || r.evaluated.report != first.report
396 })
397 {
398 return Err(RepeatError::NotReproducible(format!(
399 "run {r} differs from run 0: replaying the same retained bundles must reproduce them"
400 )));
401 }
402 let mut reported: BTreeSet<String> = names.iter().map(|s| s.to_string()).collect();
403 let derived_names = [
406 eval_cases::COST_PER_1000_OBSERVED,
407 eval_cases::COST_PER_1000_ESTIMATED,
408 ];
409 reported.extend(derived_names.iter().map(|s| s.to_string()));
410 config
411 .check_metrics(&reported)
412 .map_err(RepeatError::Config)?;
413
414 let stable: Vec<usize> = (0..split_cases.len())
415 .filter(|&c| {
416 docs.iter().all(|d| {
417 matches!(
418 d.cases[c].measures.get("coverage"),
419 Some(eval_cases::CaseMeasure::Count { num: 1, .. })
420 )
421 })
422 })
423 .collect();
424 let deterministic = inputs.source == Source::Replay;
425 let all_names: Vec<&str> = names
426 .iter()
427 .copied()
428 .chain(derived_names.iter().copied())
429 .collect();
430
431 let subgroups: Vec<&[String]> = split_cases.iter().map(|c| c.subgroups.as_slice()).collect();
432 let form = |m: &str| form_in(m, &docs);
433 let observe = |m: &str| observation(m, candidate);
434 let across = |m: &str| -> Option<Vec<Figure>> {
437 (!derived(m)).then(|| {
438 runs.iter()
439 .map(|r| {
440 match r
441 .evaluated
442 .report
443 .metrics
444 .iter()
445 .find(|x| x.name == *m)
446 .map(|x| &x.value)
447 {
448 Some(MetricValue::Measured { value, .. }) if value.is_finite() => {
449 Figure::Known(*value)
450 }
451 Some(MetricValue::Unknown { reason }) => Figure::Unknown {
452 reason: reason.clone(),
453 },
454 _ => Figure::Unknown {
455 reason: "non-finite value".into(),
456 },
457 }
458 })
459 .collect()
460 })
461 };
462 let cohorts = cohort_reports(&CohortPlan {
463 config,
464 n,
465 deterministic,
466 subgroups: &subgroups,
467 stable: &stable,
468 names: &all_names,
469 docs: &docs,
470 form: &form,
471 observation: &observe,
472 across: &across,
473 })?;
474
475 let digest = |d: Result<ContentDigest, _>| {
476 d.map_err(|_| RepeatError::Shape("a document has no record digest".into()))
477 };
478 let mut run_refs = vec![];
479 for (r, run) in runs.iter().enumerate() {
480 run_refs.push(RunRef {
481 report: digest(run.evaluated.report.record_digest())?,
482 detail: digest(run.evaluated.detail.record_digest())?,
483 cases: digest(run.cases.record_digest())?,
484 seed: run_seed(&config.bootstrap.seed, r).to_string(),
485 });
486 }
487 Ok(RepeatReport {
488 schema: REPEAT_REPORT.into(),
489 source: inputs.source,
490 repeat_config: config
491 .id()
492 .map_err(|_| RepeatError::Shape("the repeat configuration is not canonical".into()))?,
493 dataset,
494 split,
495 split_role: split_role(split),
496 provenance: first.report.dataset.provenance.clone(),
497 evaluator_config: first.report.evaluator_config.clone(),
498 adapter_rules: first.config.adapter.rules.clone(),
499 plan: first.report.plan.clone(),
500 candidate,
501 runs: run_refs,
502 stable_cohort: stable.iter().map(|&c| split_cases[c].id.clone()).collect(),
503 held_out: (split_role(split) == SplitRole::HeldOut).then(|| HeldOut::Unknown {
504 reason: "no ledger supplied".into(),
505 }),
506 cohorts,
507 })
508}
509
510pub(crate) struct CohortPlan<'a> {
516 pub config: &'a RepeatConfig,
517 pub n: usize,
518 pub deterministic: bool,
519 pub subgroups: &'a [&'a [String]],
521 pub stable: &'a [usize],
522 pub names: &'a [&'a str],
523 pub docs: &'a [&'a EvalCases],
524 pub form: &'a dyn Fn(&str) -> Option<repeat_stats::Form>,
525 pub observation: &'a dyn Fn(&str) -> Option<Observation>,
526 pub across: &'a dyn Fn(&str) -> Option<Vec<Figure>>,
528}
529
530pub(crate) fn cohort_reports(p: &CohortPlan<'_>) -> Result<Vec<CohortReport>, RepeatError> {
532 let (config, n, deterministic, stable) = (p.config, p.n, p.deterministic, p.stable);
533 let all_names = p.names;
534 let docs = p.docs;
535 let bootstrap = Bootstrap {
536 seed: config.bootstrap.seed.clone(),
537 level_units: config.level_units(),
538 resamples: config.bootstrap.resamples,
539 };
540 let mut cohorts = vec![];
541 let mut cohort_specs: Vec<(String, Vec<usize>, Option<u64>)> =
542 vec![("all".into(), (0..p.subgroups.len()).collect(), None)];
543 for s in &config.slices {
544 let members = (0..p.subgroups.len())
545 .filter(|&c| p.subgroups[c].contains(&s.name))
546 .collect();
547 cohort_specs.push((s.name.clone(), members, Some(s.min_cases)));
548 }
549 for (name, members, min_cases) in cohort_specs {
550 let stable_cases = members.iter().filter(|c| stable.contains(c)).count() as u64;
551 let below = min_cases.is_some_and(|m| stable_cases < m);
552 let mut metrics = vec![];
553 if below {
554 for m in all_names {
555 metrics.push(unknown_metric(m, (p.observation)(m), n, "below_minimum"));
556 }
557 } else {
558 let mut decomposable: Vec<MetricCases> = vec![];
561 for m in all_names {
562 if let Some(form) = (p.form)(m) {
563 let full = metric_cases(m, form, docs).map_err(RepeatError::MixedRuns)?;
564 decomposable.push(MetricCases {
565 cases: members.iter().map(|&c| full.cases[c].clone()).collect(),
566 ..full
567 });
568 }
569 }
570 let intervals = repeat_stats::intervals(&bootstrap, &name, n, &decomposable).map_err(
571 |e| match e {
572 StatsError::BootstrapTooLarge { .. } => {
573 RepeatError::BootstrapTooLarge(e.to_string())
574 }
575 StatsError::Shape(s) => RepeatError::Shape(s),
576 },
577 )?;
578 for m in all_names {
579 let (rule, floor) = config.band_for(m);
580 let obs = (p.observation)(m);
581 let report = if let Some(i) = decomposable.iter().position(|d| d.name == *m) {
582 let s = run_stats(&decomposable[i], n, deterministic)
583 .map_err(|e| RepeatError::Shape(e.to_string()))?;
584 let b = repeat_stats::band(rule, floor, deterministic, &s.sd, &intervals[i]);
585 MetricReport {
586 name: m.to_string(),
587 observation: obs,
588 runs: s.runs.iter().map(figure).collect(),
589 mean: figure(&s.mean),
590 sd: figure(&s.sd),
591 min: figure(&s.min),
592 max: figure(&s.max),
593 pooled: figure(&s.pooled),
594 interval: interval(&intervals[i]),
595 band: band_doc(&b),
596 }
597 } else if let Some(values) = (name == "all").then(|| (p.across)(m)).flatten() {
598 let (mean, sd, min, max) = across_runs(&values, deterministic);
599 let no_interval = Interval::Unknown {
600 reason: "not-case-decomposable".into(),
601 undefined: 0,
602 };
603 let b = repeat_stats::band(rule, floor, deterministic, &sd, &no_interval);
604 MetricReport {
605 name: m.to_string(),
606 observation: obs,
607 runs: values.iter().map(figure).collect(),
608 mean: figure(&mean),
609 sd: figure(&sd),
610 min: figure(&min),
611 max: figure(&max),
612 pooled: FigureDoc::Unknown("not-case-decomposable".into()),
613 interval: interval(&no_interval),
614 band: band_doc(&b),
615 }
616 } else {
617 unknown_metric(m, obs, n, "not-case-decomposable")
618 };
619 metrics.push(report);
620 }
621 }
622 cohorts.push(CohortReport {
623 name,
624 status: if below {
625 CohortStatus::BelowMinimum
626 } else {
627 CohortStatus::Reported
628 },
629 cases: members.len() as u64,
630 stable_cases,
631 min_cases,
632 metrics,
633 });
634 }
635
636 Ok(cohorts)
637}
638
639pub struct HeldOutInput<'a> {
642 pub lineage: Handle,
643 pub ledger: Option<&'a HoldoutLedger>,
645 pub recorded_at_ms: i64,
646}
647
648pub fn repeat_held_out(
653 inputs: &RepeatInputs<'_>,
654 held: &HeldOutInput<'_>,
655) -> Result<(RepeatReport, HoldoutLedger), RepeatError> {
656 let mut report = repeat(inputs)?;
657 if report.split_role != SplitRole::HeldOut {
658 return Err(RepeatError::NotHeldOut);
659 }
660 let fresh;
661 let ledger = match held.ledger {
662 Some(l) => l,
663 None => {
664 fresh = HoldoutLedger::new(report.dataset.clone());
665 &fresh
666 }
667 };
668 let (next, record) = ledger
669 .append(
670 &report.dataset,
671 Append {
672 lineage: held.lineage.clone(),
673 plan: report.plan.clone(),
674 repeat_config: report.repeat_config.clone(),
675 runs: report.runs.iter().map(|r| r.report.clone()).collect(),
676 recorded_at_ms: held.recorded_at_ms,
677 },
678 )
679 .map_err(RepeatError::Ledger)?;
680 report.held_out = Some(HeldOut::Counted(record));
681 Ok((report, next))
682}