Skip to main content

rustev_eval/
host_repeat.rs

1//! Host repeat sets, `rustev.host-repeat-report/1` (spec 061, 3.2 to 3.5).
2//!
3//! N host runs of one host flow version, each bound to its views and
4//! scores, aggregate with spec 029's statistics, intervals, bands and
5//! slices, computed by the same functions over the host case measures. The
6//! source is `host`: statistically a `captured` set, never replayed, never
7//! deterministic. Every figure is computed by Rustev from host-asserted
8//! observations, and the report says so.
9
10use std::collections::BTreeSet;
11
12use rustev_contract::eval_report::{MetricValue, Split};
13use rustev_contract::ids::{ContentDigest, EvaluatorConfigId};
14use rustev_contract::judgment::Derivation;
15use rustev_contract::limits::REPLAY_V1;
16use rustev_contract::scope::Handle;
17use rustev_contract::{Document, Identified};
18use serde::{Deserialize, Serialize};
19
20use crate::config::EvaluatorConfig;
21use crate::eval_cases::{CaseMeasure, CaseMeasures, EVAL_CASES, EvalCases, form_in};
22use crate::host_run::{
23    self, Assertion, HostCases, HostDataset, HostError, HostMetric, HostOutcome, HostRunInputs,
24    code, metric,
25};
26use crate::repeat::{
27    BandDoc, CohortPlan, CohortReport, CohortStatus, FigureDoc, HeldOut, IntervalDoc, MetricReport,
28    SplitRole, cohort_reports, split_role,
29};
30use crate::repeat_config::RepeatConfig;
31use crate::repeat_stats::{Figure, MAX_RUNS};
32use crate::scoring::ScorerKind;
33
34pub const HOST_REPEAT_REPORT: &str = "rustev.host-repeat-report/1";
35/// Why a host set on a held-out split has no count (3.8).
36pub const HOST_LEDGER_UNSUPPORTED: &str = "host-ledger-unsupported";
37/// Why a repeat configuration with `top_k` is refused (3.4.5).
38pub const TOP_K_NOT_OBSERVED: &str = "top-k-not-observed";
39/// The `mixed-runs` detail for a repeated `host.run` (3.2.3).
40pub const DUPLICATE_RUN: &str = "duplicate-run";
41
42/// A host set's only source (3.4.3).
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum HostSource {
46    Host,
47}
48
49/// What Rustev did not check for a host set (3.4.6, 3.5.2).
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum NotChecked {
53    NotChecked,
54}
55
56/// Every latency and cost figure of a host set is the host's own
57/// observation (3.5.3), never a candidate's or a historical one.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum HostObservation {
61    Host,
62}
63
64/// The host identities every run of a set shares (3.2.3); the run handle
65/// is per run.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub struct HostFlow {
69    pub system: Handle,
70    pub flow: Handle,
71    pub flow_version: Handle,
72}
73
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75#[serde(deny_unknown_fields)]
76pub struct HostRepeatReport {
77    pub schema: String,
78    pub assertion: Assertion,
79    pub source: HostSource,
80    pub repeat_config: ContentDigest,
81    pub evaluator_config: EvaluatorConfigId,
82    pub host: HostFlow,
83    pub dataset: HostDataset,
84    pub split: Split,
85    pub split_role: SplitRole,
86    pub cost_unit: Option<String>,
87    pub runs: Vec<HostRunRef>,
88    pub scorers: Vec<ScorerCheck>,
89    /// The cases not `failed` in any run, in record order (3.4.4).
90    pub stable_cohort: Vec<String>,
91    /// Null for a split that is not held out; unknown otherwise (3.8).
92    pub held_out: Option<HeldOut>,
93    pub cohorts: Vec<HostCohortReport>,
94}
95
96crate::doc::eval_document!(HostRepeatReport, HOST_REPEAT_REPORT, REPLAY_V1);
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(deny_unknown_fields)]
100pub struct HostRunRef {
101    /// The record digest of the run's `rustev.host-run/1`.
102    pub host_run: ContentDigest,
103    pub run: Handle,
104    /// The record digest of its `rustev.host-cases/1`.
105    pub cases: ContentDigest,
106    /// Always null: Rustev hands a host run no seed (3.4.3).
107    pub seed: Option<String>,
108}
109
110/// A declared scorer, and what Rustev did not reproduce of it (3.4.6,
111/// 3.5.2).
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(deny_unknown_fields)]
114pub struct ScorerCheck {
115    pub scorer: String,
116    pub scorer_reproduction: NotChecked,
117    /// A model scorer's judge runs are bound as supplied, never
118    /// reproduced; null for a deterministic scorer.
119    pub judge_runs: Option<NotChecked>,
120}
121
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct HostCohortReport {
125    pub name: String,
126    pub status: CohortStatus,
127    pub cases: u64,
128    pub stable_cases: u64,
129    pub min_cases: Option<u64>,
130    pub metrics: Vec<HostMetricReport>,
131}
132
133#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
134#[serde(deny_unknown_fields)]
135pub struct HostMetricReport {
136    pub name: String,
137    pub derivation: Derivation,
138    /// For latency and cost metrics only.
139    pub observation: Option<HostObservation>,
140    /// The cost unit, for `host/cost_per_1000` only.
141    pub unit: Option<String>,
142    pub runs: Vec<FigureDoc>,
143    pub mean: FigureDoc,
144    pub sd: FigureDoc,
145    pub min: FigureDoc,
146    pub max: FigureDoc,
147    pub pooled: FigureDoc,
148    pub interval: IntervalDoc,
149    pub band: BandDoc,
150}
151
152/// Whether a host metric is an observation of the host's runs (3.5.3).
153pub fn observed(name: &str) -> bool {
154    name.starts_with("host/latency_ms/") || name == metric::COST_PER_1000
155}
156
157/// The inputs of a host set: the configurations and each run, in run
158/// order.
159pub struct HostSetInputs<'a> {
160    pub config: &'a RepeatConfig,
161    pub evaluator: &'a EvaluatorConfig,
162    pub runs: &'a [HostRunInputs<'a>],
163}
164
165/// An assembled host set: its report and each run's case measures.
166#[derive(Debug, Clone, PartialEq)]
167pub struct HostSet {
168    pub report: HostRepeatReport,
169    pub cases: Vec<HostCases>,
170}
171
172fn mixed(detail: impl Into<String>) -> HostError {
173    HostError::new(code::MIXED_RUNS, detail)
174}
175
176fn repeat_config(detail: impl Into<String>) -> HostError {
177    HostError::new(code::REPEAT_CONFIG, detail)
178}
179
180/// The cross-run checks of 3.2.3.
181fn check_runs(runs: &[HostRunInputs<'_>]) -> Result<(), HostError> {
182    let first = runs[0].record;
183    let views = runs[0].views.expect("bound");
184    let ids = |r: &crate::host_run::HostRun| -> Vec<(String, Vec<String>)> {
185        r.cases
186            .iter()
187            .map(|c| (c.case.clone(), c.subgroups.clone()))
188            .collect()
189    };
190    let mut handles = BTreeSet::new();
191    for (i, run) in runs.iter().enumerate() {
192        let r = run.record;
193        let v = run.views.expect("bound");
194        let same = r.host.system == first.host.system
195            && r.host.flow == first.host.flow
196            && r.host.flow_version == first.host.flow_version
197            && r.dataset == first.dataset
198            && r.split == first.split
199            && r.cost_unit == first.cost_unit
200            && ids(r) == ids(first)
201            && v.dataset == views.dataset
202            && v.plan == views.plan;
203        if !same {
204            return Err(mixed(
205                "the runs differ in host system, flow, flow version, dataset, split, cost unit, \
206                 cases, subgroups, or the views' dataset or plan member",
207            )
208            .at(i));
209        }
210        if !handles.insert(r.host.run.clone()) {
211            return Err(mixed(format!(
212                "{DUPLICATE_RUN}: host.run {:?} is repeated",
213                r.host.run.as_str()
214            ))
215            .at(i));
216        }
217    }
218    Ok(())
219}
220
221/// A host case measures document as spec 029's per-case document, so its
222/// functions read it unchanged; internal, never written.
223fn as_eval_cases(c: &HostCases) -> EvalCases {
224    EvalCases {
225        schema: EVAL_CASES.into(),
226        report: c.host_run.clone(),
227        top_k: None,
228        cases: c
229            .cases
230            .iter()
231            .map(|m| CaseMeasures {
232                case: m.case.clone(),
233                run: None,
234                outcome: match &m.outcome {
235                    HostOutcome::Answered => "answered".into(),
236                    HostOutcome::Abstained => "abstained".into(),
237                    HostOutcome::Failed(r) => format!("failed:{r}"),
238                },
239                top_k: None,
240                measures: m.measures.clone(),
241            })
242            .collect(),
243    }
244}
245
246/// Assemble a host repeat set (3.2.3 to 3.5): bind and measure every run,
247/// check the runs against each other, and compute spec 029's figures per
248/// cohort and metric.
249pub fn import(inputs: &HostSetInputs<'_>) -> Result<HostSet, HostError> {
250    let config = inputs.config;
251    config.check().map_err(|e| repeat_config(e.to_string()))?;
252    if config.top_k.is_some() {
253        return Err(repeat_config(format!(
254            "{TOP_K_NOT_OBSERVED}: a host run carries no ranking parameter Rustev can read"
255        )));
256    }
257    let runs = inputs.runs;
258    let n = runs.len();
259    if !(1..=MAX_RUNS).contains(&n) {
260        return Err(repeat_config(format!("{n} runs; a set has 1 to 64")));
261    }
262    let mut measured = vec![];
263    for (i, run) in runs.iter().enumerate() {
264        measured.push(host_run::measure(inputs.evaluator, run).map_err(|e| e.at(i))?);
265    }
266    check_runs(runs)?;
267    let first = runs[0].record;
268    let info: &[HostMetric] = &measured[0].metrics;
269    let names: Vec<&str> = info.iter().map(|m| m.name.as_str()).collect();
270    config
271        .check_metrics(&names.iter().map(|s| s.to_string()).collect())
272        .map_err(|e| repeat_config(e.to_string()))?;
273
274    let cases: Vec<HostCases> = measured.iter().map(|m| m.cases.clone()).collect();
275    let eval: Vec<EvalCases> = cases.iter().map(as_eval_cases).collect();
276    let docs: Vec<&EvalCases> = eval.iter().collect();
277    let stable: Vec<usize> = (0..first.cases.len())
278        .filter(|&c| {
279            runs.iter()
280                .all(|r| !matches!(r.record.cases[c].outcome, HostOutcome::Failed(_)))
281        })
282        .collect();
283    let subgroups: Vec<&[String]> = first.cases.iter().map(|c| c.subgroups.as_slice()).collect();
284    let form = |m: &str| host_run::form(m).or_else(|| form_in(m, &docs));
285    let no_observation = |_: &str| None;
286    // A metric without a per-case measure (`qwk`) takes each run's value
287    // from spec 041's function, as a Rustev set takes it from its report.
288    let across = |m: &str| -> Option<Vec<Figure>> {
289        measured
290            .iter()
291            .map(|r| match &r.metrics.iter().find(|x| x.name == m)?.value {
292                Some(MetricValue::Measured { value, .. }) if value.is_finite() => {
293                    Some(Figure::Known(*value))
294                }
295                Some(MetricValue::Unknown { reason }) => Some(Figure::Unknown {
296                    reason: reason.clone(),
297                }),
298                Some(_) => Some(Figure::Unknown {
299                    reason: "non-finite value".into(),
300                }),
301                None => None,
302            })
303            .collect()
304    };
305    let reports = cohort_reports(&CohortPlan {
306        config,
307        n,
308        // Never `deterministic_replay` (3.4.3).
309        deterministic: false,
310        subgroups: &subgroups,
311        stable: &stable,
312        names: &names,
313        docs: &docs,
314        form: &form,
315        observation: &no_observation,
316        across: &across,
317    })
318    .map_err(|e| HostError::new(e.code(), e.to_string()))?;
319
320    let cohorts = reports
321        .into_iter()
322        .map(|c| host_cohort(c, info, &cases, &subgroups, first.cost_unit.as_deref()))
323        .collect();
324    let mut run_refs = vec![];
325    for (r, c) in runs.iter().zip(&cases) {
326        let digest = |d: Result<ContentDigest, _>| {
327            d.map_err(|_| HostError::new(code::HOST_RUN, "a document has no record digest"))
328        };
329        run_refs.push(HostRunRef {
330            host_run: digest(r.record.record_digest())?,
331            run: r.record.host.run.clone(),
332            cases: digest(c.record_digest())?,
333            seed: None,
334        });
335    }
336    let declared = host_run::declarations(inputs.evaluator)?;
337    let role = split_role(first.split);
338    let report = HostRepeatReport {
339        schema: HOST_REPEAT_REPORT.into(),
340        assertion: Assertion::Host,
341        source: HostSource::Host,
342        repeat_config: config
343            .id()
344            .map_err(|_| repeat_config("the repeat configuration is not canonical"))?,
345        evaluator_config: inputs
346            .evaluator
347            .id()
348            .map_err(|e| HostError::new(code::CONFIG, e.to_string()))?,
349        host: HostFlow {
350            system: first.host.system.clone(),
351            flow: first.host.flow.clone(),
352            flow_version: first.host.flow_version.clone(),
353        },
354        dataset: first.dataset.clone(),
355        split: first.split,
356        split_role: role,
357        cost_unit: first.cost_unit.clone(),
358        runs: run_refs,
359        scorers: declared
360            .scorers
361            .iter()
362            .map(|s| ScorerCheck {
363                scorer: s.id.clone(),
364                scorer_reproduction: NotChecked::NotChecked,
365                judge_runs: (s.kind == ScorerKind::Model).then_some(NotChecked::NotChecked),
366            })
367            .collect(),
368        stable_cohort: stable
369            .iter()
370            .map(|&c| first.cases[c].case.clone())
371            .collect(),
372        held_out: (role == SplitRole::HeldOut).then(|| HeldOut::Unknown {
373            reason: HOST_LEDGER_UNSUPPORTED.into(),
374        }),
375        cohorts,
376    };
377    Ok(HostSet { report, cases })
378}
379
380/// One cohort as a host cohort: each metric with its derivation, the
381/// host observation and unit where they apply, and an unknown run value
382/// naming the case that made it unknown (3.3.3).
383fn host_cohort(
384    c: CohortReport,
385    info: &[HostMetric],
386    cases: &[HostCases],
387    subgroups: &[&[String]],
388    cost_unit: Option<&str>,
389) -> HostCohortReport {
390    let members: Vec<usize> = (0..subgroups.len())
391        .filter(|&i| c.name == "all" || subgroups[i].contains(&c.name))
392        .collect();
393    let metrics = c
394        .metrics
395        .into_iter()
396        .map(|m| {
397            let derivation = info
398                .iter()
399                .find(|x| x.name == m.name)
400                .map_or(Derivation::ExactDerived, |x| x.derivation);
401            let not_declared = m.name == metric::COST_PER_1000 && cost_unit.is_none();
402            let MetricReport {
403                name,
404                mut runs,
405                mean,
406                sd,
407                min,
408                max,
409                mut pooled,
410                interval,
411                band,
412                ..
413            } = if not_declared {
414                unknown(m, host_run::COST_NOT_DECLARED)
415            } else {
416                m
417            };
418            for (r, figure) in runs.iter_mut().enumerate() {
419                if let (FigureDoc::Unknown(_), Some(why)) =
420                    (&figure, first_unknown(cases, r, &members, &name))
421                    && !not_declared
422                {
423                    *figure = FigureDoc::Unknown(why);
424                }
425            }
426            // The pooled figure names the first run and case too.
427            if let FigureDoc::Unknown(_) = pooled
428                && !not_declared
429                && let Some(why) = (0..runs.len()).find_map(|r| {
430                    Some(format!(
431                        "run {r}: {}",
432                        first_unknown(cases, r, &members, &name)?
433                    ))
434                })
435            {
436                pooled = FigureDoc::Unknown(why);
437            }
438            HostMetricReport {
439                observation: observed(&name).then_some(HostObservation::Host),
440                unit: (name == metric::COST_PER_1000)
441                    .then(|| cost_unit.map(str::to_string))
442                    .flatten(),
443                name,
444                derivation,
445                runs,
446                mean,
447                sd,
448                min,
449                max,
450                pooled,
451                interval,
452                band,
453            }
454        })
455        .collect();
456    HostCohortReport {
457        name: c.name,
458        status: c.status,
459        cases: c.cases,
460        stable_cases: c.stable_cases,
461        min_cases: c.min_cases,
462        metrics,
463    }
464}
465
466/// Every figure of a metric unknown for one reason.
467fn unknown(m: MetricReport, reason: &str) -> MetricReport {
468    let u = || FigureDoc::Unknown(reason.into());
469    MetricReport {
470        runs: m.runs.iter().map(|_| u()).collect(),
471        mean: u(),
472        sd: u(),
473        min: u(),
474        max: u(),
475        pooled: u(),
476        interval: IntervalDoc::Unknown {
477            reason: reason.into(),
478            undefined: 0,
479        },
480        band: BandDoc::Unknown(reason.into()),
481        ..m
482    }
483}
484
485/// The first member case whose measure of `metric` is unknown in run `r`,
486/// named with its reason.
487fn first_unknown(cases: &[HostCases], r: usize, members: &[usize], metric: &str) -> Option<String> {
488    members.iter().find_map(|&i| {
489        let c = &cases[r].cases[i];
490        match c.measures.get(metric) {
491            Some(CaseMeasure::Unknown { reason }) => Some(format!("case {}: {reason}", c.case)),
492            _ => None,
493        }
494    })
495}
496
497/// Re-assembly (3.3.4): the set rebuilt from its inputs must reproduce the
498/// supplied report and case documents byte for byte (`reconcile`).
499pub fn reassemble(
500    inputs: &HostSetInputs<'_>,
501    report: &HostRepeatReport,
502    cases: &[HostCases],
503) -> Result<HostSet, HostError> {
504    let again = import(inputs)?;
505    let bytes = |d: &dyn Fn() -> Result<Vec<u8>, rustev_contract::canonical::CanonicalError>| {
506        d().map_err(|e| HostError::new(code::RECONCILE, e.to_string()))
507    };
508    let same_report =
509        bytes(&|| again.report.record_canonical())? == bytes(&|| report.record_canonical())?;
510    // A document that cannot be canonicalized is refused, never equal to
511    // another that cannot.
512    let canonical = |d: &HostCases| {
513        d.record_canonical().map_err(|e| {
514            HostError::new(
515                code::RECONCILE,
516                format!("a case document is not canonical: {e}"),
517            )
518        })
519    };
520    let mut same_cases = again.cases.len() == cases.len();
521    for (a, b) in again.cases.iter().zip(cases) {
522        same_cases &= canonical(a)? == canonical(b)?;
523    }
524    if !same_report || !same_cases {
525        return Err(HostError::new(
526            code::RECONCILE,
527            "the import output does not reproduce its host repeat report and case measures",
528        ));
529    }
530    Ok(again)
531}