Skip to main content

rustev_eval/
repeat.rs

1//! Repeat sets and `rustev.repeat-report/1` (spec 029, 3.1, 3.5, 3.6).
2//!
3//! A repeat set is N evaluations of one configuration, each with its
4//! reconciled case measures. The repeat report states, per cohort (`all`,
5//! then each declared slice) and per metric, the per-run values, `mean`,
6//! `sd`, `min`, `max`, `pooled`, the case-clustered interval and the noise
7//! band with its basis, and names every document and seed it came from. It
8//! is evaluation evidence for the host, never a grant, a selection or a
9//! quality claim.
10
11use 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/// Where a repeat set's runs came from (3.1.2).
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum Source {
38    /// One set of retained bundles reproduced N times.
39    Replay,
40    /// N bundle sets, each captured from a separate execution.
41    Captured,
42    /// N fresh executions, including dataset-scale live runs (spec 043).
43    Live,
44}
45
46/// A split's fixed role (3.7.1).
47#[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/// One run: its evaluation and its case measures.
64#[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    /// In run order.
75    pub runs: &'a [Run<'a>],
76}
77
78/// Why no repeat report was produced (`invalid_input`, 3.14).
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum RepeatError {
81    Config(ConfigError),
82    LiveSourceUnavailable,
83    /// N outside 1 to 64.
84    Runs(usize),
85    MixedRuns(String),
86    NotReproducible(String),
87    Reconcile(String),
88    BootstrapTooLarge(String),
89    Shape(String),
90    /// A held-out ledger refused (3.7.3).
91    Ledger(LedgerError),
92    /// A ledger supplied for a split that is not held out.
93    NotHeldOut,
94}
95
96impl RepeatError {
97    /// The refusal code of 3.14.
98    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    /// Whether the runs are candidate evaluations (spec 004, 3.4).
136    pub candidate: bool,
137    pub runs: Vec<RunRef>,
138    /// The cases comparable in every run, in manifest order (3.1.3).
139    pub stable_cohort: Vec<String>,
140    /// Null for a split that is not held out (3.7.4).
141    pub held_out: Option<HeldOut>,
142    pub cohorts: Vec<CohortReport>,
143}
144
145crate::doc::eval_document!(RepeatReport, REPEAT_REPORT, REPLAY_V1);
146
147/// The record digests of one run's documents and the seed handed to it.
148#[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    /// The run seed of 3.4.6, in decimal; whether a backend honored it is
155    /// the host's statement.
156    pub seed: String,
157}
158
159/// The held-out record (3.7.4). Without a ledger the count is unknown,
160/// never zero.
161#[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 from the host's ledger, with the heads before and after.
168    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    /// Fewer stable-cohort cases than the slice's minimum: every metric is
176    /// unknown (3.6.2).
177    BelowMinimum,
178}
179
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181#[serde(deny_unknown_fields)]
182pub struct CohortReport {
183    /// `all`, or a declared slice.
184    pub name: String,
185    pub status: CohortStatus,
186    pub cases: u64,
187    pub stable_cases: u64,
188    /// The slice's declared minimum; none for `all`.
189    pub min_cases: Option<u64>,
190    pub metrics: Vec<MetricReport>,
191}
192
193/// Whether a latency, queue or cost figure was observed by the evaluated
194/// plan (3.11.2).
195#[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    /// For latency, queue and cost metrics only.
207    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
307/// Whether a metric is an observation of the runs themselves (3.11.2):
308/// latency and queue quantiles and every cost series. The four spec 004
309/// cost series, `cost_unknown_attempts` included, are exactly the metrics
310/// a candidate report from `report::evaluate` already replaces with "the
311/// candidate was not executed; latency and cost are the baseline's", so
312/// each is historical for a candidate.
313fn 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
332/// Assemble a repeat report from N reconciled runs.
333pub 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    // Same case ids, in manifest order, in every run (3.1.3).
382    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    // A candidate's cases carry no cost measure (its run records are
404    // historical), so its derived metrics are unknown, never zero.
405    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            // Every case-decomposable metric of the cohort shares its
460            // resamples (3.4.5).
461            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                    // Across-run statistics from the run reports only: a
500                    // total, or a metric without a per-case measure.
501                    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
597/// The host's held-out inputs (3.7.3): its lineage handle, its ledger or
598/// the explicit statement that the ledger is new, and the time to record.
599pub struct HeldOutInput<'a> {
600    pub lineage: Handle,
601    /// None states that the ledger is new.
602    pub ledger: Option<&'a HoldoutLedger>,
603    pub recorded_at_ms: i64,
604}
605
606/// A held-out repeat report counted against the host's ledger (3.7.4):
607/// the report records the counts and heads, and the new ledger (the input
608/// with one entry appended) is returned for the host to keep. The input
609/// ledger is never modified.
610pub 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}