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 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    // Across-run statistics from the run reports only: a total, or a
435    // metric without a per-case measure.
436    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
510/// What one repeat set's cohorts are computed from (3.3 to 3.6): its
511/// cases' subgroups and stable cohort, its metric names in report order,
512/// its case documents, and how each metric decomposes, is observed and,
513/// without a per-case measure, takes its per-run values. Spec 061's host
514/// sets use it unchanged.
515pub(crate) struct CohortPlan<'a> {
516    pub config: &'a RepeatConfig,
517    pub n: usize,
518    pub deterministic: bool,
519    /// Per case, in case order.
520    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    /// For the cohort `all` only; none for a metric with no run values.
527    pub across: &'a dyn Fn(&str) -> Option<Vec<Figure>>,
528}
529
530/// Every cohort's report: `all`, then each declared slice (3.6).
531pub(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            // Every case-decomposable metric of the cohort shares its
559            // resamples (3.4.5).
560            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
639/// The host's held-out inputs (3.7.3): its lineage handle, its ledger or
640/// the explicit statement that the ledger is new, and the time to record.
641pub struct HeldOutInput<'a> {
642    pub lineage: Handle,
643    /// None states that the ledger is new.
644    pub ledger: Option<&'a HoldoutLedger>,
645    pub recorded_at_ms: i64,
646}
647
648/// A held-out repeat report counted against the host's ledger (3.7.4):
649/// the report records the counts and heads, and the new ledger (the input
650/// with one entry appended) is returned for the host to keep. The input
651/// ledger is never modified.
652pub 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}