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_of, 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: unavailable until live re-execution reaches
43    /// dataset scale (#76 E3).
44    Live,
45}
46
47/// A split's fixed role (3.7.1).
48#[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/// One run: its evaluation and its case measures.
65#[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    /// In run order.
76    pub runs: &'a [Run<'a>],
77}
78
79/// Why no repeat report was produced (`invalid_input`, 3.14).
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum RepeatError {
82    Config(ConfigError),
83    LiveSourceUnavailable,
84    /// N outside 1 to 64.
85    Runs(usize),
86    MixedRuns(String),
87    NotReproducible(String),
88    Reconcile(String),
89    BootstrapTooLarge(String),
90    Shape(String),
91    /// A held-out ledger refused (3.7.3).
92    Ledger(LedgerError),
93    /// A ledger supplied for a split that is not held out.
94    NotHeldOut,
95}
96
97impl RepeatError {
98    /// The refusal code of 3.14.
99    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    /// Whether the runs are candidate evaluations (spec 004, 3.4).
137    pub candidate: bool,
138    pub runs: Vec<RunRef>,
139    /// The cases comparable in every run, in manifest order (3.1.3).
140    pub stable_cohort: Vec<String>,
141    /// Null for a split that is not held out (3.7.4).
142    pub held_out: Option<HeldOut>,
143    pub cohorts: Vec<CohortReport>,
144}
145
146crate::doc::eval_document!(RepeatReport, REPEAT_REPORT, REPLAY_V1);
147
148/// The record digests of one run's documents and the seed handed to it.
149#[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    /// The run seed of 3.4.6, in decimal; whether a backend honored it is
156    /// the host's statement.
157    pub seed: String,
158}
159
160/// The held-out record (3.7.4). Without a ledger the count is unknown,
161/// never zero.
162#[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 from the host's ledger, with the heads before and after.
169    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    /// Fewer stable-cohort cases than the slice's minimum: every metric is
177    /// unknown (3.6.2).
178    BelowMinimum,
179}
180
181#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
182#[serde(deny_unknown_fields)]
183pub struct CohortReport {
184    /// `all`, or a declared slice.
185    pub name: String,
186    pub status: CohortStatus,
187    pub cases: u64,
188    pub stable_cases: u64,
189    /// The slice's declared minimum; none for `all`.
190    pub min_cases: Option<u64>,
191    pub metrics: Vec<MetricReport>,
192}
193
194/// Whether a latency, queue or cost figure was observed by the evaluated
195/// plan (3.11.2).
196#[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    /// For latency, queue and cost metrics only.
208    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
308/// Whether a metric is an observation of the runs themselves (3.11.2):
309/// latency and queue quantiles and every cost series. The four spec 004
310/// cost series, `cost_unknown_attempts` included, are exactly the metrics
311/// a candidate report from `report::evaluate` already replaces with "the
312/// candidate was not executed; latency and cost are the baseline's", so
313/// each is historical for a candidate.
314fn 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
333/// Assemble a repeat report from N reconciled runs.
334pub 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    // Same case ids, in manifest order, in every run (3.1.3).
386    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    // A candidate's cases carry no cost measure (its run records are
408    // historical), so its derived metrics are unknown, never zero.
409    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            // Every case-decomposable metric of the cohort shares its
464            // resamples (3.4.5).
465            let mut decomposable: Vec<MetricCases> = vec![];
466            for m in &all_names {
467                if let Some(form) = form_of(m) {
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                    // Across-run statistics from the run reports only: a
504                    // total, or a metric without a per-case measure.
505                    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
601/// The host's held-out inputs (3.7.3): its lineage handle, its ledger or
602/// the explicit statement that the ledger is new, and the time to record.
603pub struct HeldOutInput<'a> {
604    pub lineage: Handle,
605    /// None states that the ledger is new.
606    pub ledger: Option<&'a HoldoutLedger>,
607    pub recorded_at_ms: i64,
608}
609
610/// A held-out repeat report counted against the host's ledger (3.7.4):
611/// the report records the counts and heads, and the new ledger (the input
612/// with one entry appended) is returned for the host to keep. The input
613/// ledger is never modified.
614pub 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}