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