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 bootstrap = Bootstrap {
426 seed: config.bootstrap.seed.clone(),
427 level_units: config.level_units(),
428 resamples: config.bootstrap.resamples,
429 };
430 let all_names: Vec<&str> = names
431 .iter()
432 .copied()
433 .chain(derived_names.iter().copied())
434 .collect();
435
436 let mut cohorts = vec![];
437 let mut cohort_specs: Vec<(String, Vec<usize>, Option<u64>)> =
438 vec![("all".into(), (0..split_cases.len()).collect(), None)];
439 for s in &config.slices {
440 let members = (0..split_cases.len())
441 .filter(|&c| split_cases[c].subgroups.contains(&s.name))
442 .collect();
443 cohort_specs.push((s.name.clone(), members, Some(s.min_cases)));
444 }
445 for (name, members, min_cases) in cohort_specs {
446 let stable_cases = members.iter().filter(|c| stable.contains(c)).count() as u64;
447 let below = min_cases.is_some_and(|m| stable_cases < m);
448 let mut metrics = vec![];
449 if below {
450 for m in &all_names {
451 metrics.push(unknown_metric(
452 m,
453 observation(m, candidate),
454 n,
455 "below_minimum",
456 ));
457 }
458 } else {
459 let mut decomposable: Vec<MetricCases> = vec![];
462 for m in &all_names {
463 if let Some(form) = form_in(m, &docs) {
464 let full = metric_cases(m, form, &docs).map_err(RepeatError::MixedRuns)?;
465 decomposable.push(MetricCases {
466 cases: members.iter().map(|&c| full.cases[c].clone()).collect(),
467 ..full
468 });
469 }
470 }
471 let intervals = repeat_stats::intervals(&bootstrap, &name, n, &decomposable).map_err(
472 |e| match e {
473 StatsError::BootstrapTooLarge { .. } => {
474 RepeatError::BootstrapTooLarge(e.to_string())
475 }
476 StatsError::Shape(s) => RepeatError::Shape(s),
477 },
478 )?;
479 for m in &all_names {
480 let (rule, floor) = config.band_for(m);
481 let obs = observation(m, candidate);
482 let report = if let Some(i) = decomposable.iter().position(|d| d.name == *m) {
483 let s = run_stats(&decomposable[i], n, deterministic)
484 .map_err(|e| RepeatError::Shape(e.to_string()))?;
485 let b = repeat_stats::band(rule, floor, deterministic, &s.sd, &intervals[i]);
486 MetricReport {
487 name: m.to_string(),
488 observation: obs,
489 runs: s.runs.iter().map(figure).collect(),
490 mean: figure(&s.mean),
491 sd: figure(&s.sd),
492 min: figure(&s.min),
493 max: figure(&s.max),
494 pooled: figure(&s.pooled),
495 interval: interval(&intervals[i]),
496 band: band_doc(&b),
497 }
498 } else if name == "all" && !derived(m) {
499 let values: Vec<Figure> = runs
502 .iter()
503 .map(|r| {
504 match r
505 .evaluated
506 .report
507 .metrics
508 .iter()
509 .find(|x| x.name == *m)
510 .map(|x| &x.value)
511 {
512 Some(MetricValue::Measured { value, .. }) if value.is_finite() => {
513 Figure::Known(*value)
514 }
515 Some(MetricValue::Unknown { reason }) => Figure::Unknown {
516 reason: reason.clone(),
517 },
518 _ => Figure::Unknown {
519 reason: "non-finite value".into(),
520 },
521 }
522 })
523 .collect();
524 let (mean, sd, min, max) = across_runs(&values, deterministic);
525 let no_interval = Interval::Unknown {
526 reason: "not-case-decomposable".into(),
527 undefined: 0,
528 };
529 let b = repeat_stats::band(rule, floor, deterministic, &sd, &no_interval);
530 MetricReport {
531 name: m.to_string(),
532 observation: obs,
533 runs: values.iter().map(figure).collect(),
534 mean: figure(&mean),
535 sd: figure(&sd),
536 min: figure(&min),
537 max: figure(&max),
538 pooled: FigureDoc::Unknown("not-case-decomposable".into()),
539 interval: interval(&no_interval),
540 band: band_doc(&b),
541 }
542 } else {
543 unknown_metric(m, obs, n, "not-case-decomposable")
544 };
545 metrics.push(report);
546 }
547 }
548 cohorts.push(CohortReport {
549 name,
550 status: if below {
551 CohortStatus::BelowMinimum
552 } else {
553 CohortStatus::Reported
554 },
555 cases: members.len() as u64,
556 stable_cases,
557 min_cases,
558 metrics,
559 });
560 }
561
562 let digest = |d: Result<ContentDigest, _>| {
563 d.map_err(|_| RepeatError::Shape("a document has no record digest".into()))
564 };
565 let mut run_refs = vec![];
566 for (r, run) in runs.iter().enumerate() {
567 run_refs.push(RunRef {
568 report: digest(run.evaluated.report.record_digest())?,
569 detail: digest(run.evaluated.detail.record_digest())?,
570 cases: digest(run.cases.record_digest())?,
571 seed: run_seed(&config.bootstrap.seed, r).to_string(),
572 });
573 }
574 Ok(RepeatReport {
575 schema: REPEAT_REPORT.into(),
576 source: inputs.source,
577 repeat_config: config
578 .id()
579 .map_err(|_| RepeatError::Shape("the repeat configuration is not canonical".into()))?,
580 dataset,
581 split,
582 split_role: split_role(split),
583 provenance: first.report.dataset.provenance.clone(),
584 evaluator_config: first.report.evaluator_config.clone(),
585 adapter_rules: first.config.adapter.rules.clone(),
586 plan: first.report.plan.clone(),
587 candidate,
588 runs: run_refs,
589 stable_cohort: stable.iter().map(|&c| split_cases[c].id.clone()).collect(),
590 held_out: (split_role(split) == SplitRole::HeldOut).then(|| HeldOut::Unknown {
591 reason: "no ledger supplied".into(),
592 }),
593 cohorts,
594 })
595}
596
597pub struct HeldOutInput<'a> {
600 pub lineage: Handle,
601 pub ledger: Option<&'a HoldoutLedger>,
603 pub recorded_at_ms: i64,
604}
605
606pub fn repeat_held_out(
611 inputs: &RepeatInputs<'_>,
612 held: &HeldOutInput<'_>,
613) -> Result<(RepeatReport, HoldoutLedger), RepeatError> {
614 let mut report = repeat(inputs)?;
615 if report.split_role != SplitRole::HeldOut {
616 return Err(RepeatError::NotHeldOut);
617 }
618 let fresh;
619 let ledger = match held.ledger {
620 Some(l) => l,
621 None => {
622 fresh = HoldoutLedger::new(report.dataset.clone());
623 &fresh
624 }
625 };
626 let (next, record) = ledger
627 .append(
628 &report.dataset,
629 Append {
630 lineage: held.lineage.clone(),
631 plan: report.plan.clone(),
632 repeat_config: report.repeat_config.clone(),
633 runs: report.runs.iter().map(|r| r.report.clone()).collect(),
634 recorded_at_ms: held.recorded_at_ms,
635 },
636 )
637 .map_err(RepeatError::Ledger)?;
638 report.held_out = Some(HeldOut::Counted(record));
639 Ok((report, next))
640}