Skip to main content

rustev_eval/
host_select.rs

1//! Host baselines, reproducibility and selection, `rustev.host-baseline/1`
2//! and `rustev.host-selection/1` (spec 061, 3.6 to 3.8).
3//!
4//! A host baseline records one host set; the reproducibility check and the
5//! selection gate compare host sets with it by spec 029's rules, with the
6//! host identities in place of Rustev's. A host document is never combined
7//! with, compared against or verified as a Rustev-evaluated one (I-1):
8//! [`provenance`] tells them apart by schema, and a command given both
9//! refuses with `mixed-provenance`. A selection is evidence for the host's
10//! choice, never the choice and never a grant.
11
12use rustev_contract::eval_report::Split;
13use rustev_contract::ids::{ContentDigest, EvaluatorConfigId};
14use rustev_contract::judgment::Derivation;
15use rustev_contract::limits::REPLAY_V1;
16use rustev_contract::{Document, Identified};
17use serde::{Deserialize, Serialize};
18
19use crate::baseline::{self, BASELINE, Reproducibility, valid_name};
20use crate::eval_cases::{CaseMeasure, EVAL_CASES};
21use crate::host_repeat::{
22    HOST_REPEAT_REPORT, HostCohortReport, HostFlow, HostObservation, HostRepeatReport, HostRunRef,
23    HostSetInputs, HostSource, reassemble,
24};
25use crate::host_run::{
26    Assertion, HOST_CASES, HOST_RUN, HostCases, HostDataset, HostError, code, declarations, metric,
27};
28use crate::repeat::{
29    BandDoc, CohortReport, CohortStatus, FigureDoc, HeldOut, IntervalDoc, MetricReport,
30    Observation, REPEAT_REPORT, SplitRole,
31};
32use crate::repeat_config::RepeatConfig;
33use crate::selection::{
34    ClauseOutcome, ClauseResult, SELECTION, SelectionGate, Side, Unranked, Verdict, judge,
35    sort_ranks,
36};
37
38pub const HOST_BASELINE: &str = "rustev.host-baseline/1";
39pub const HOST_SELECTION: &str = "rustev.host-selection/1";
40
41/// Where a document's figures come from (3.6).
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Provenance {
44    /// Evaluated by Rustev from its own bundles (spec 029).
45    Rustev,
46    /// Computed from host-asserted observations (spec 061).
47    Host,
48}
49
50/// A repeat-set document's provenance by its schema; none for any other.
51pub fn provenance(schema: &str) -> Option<Provenance> {
52    match schema {
53        HOST_RUN | HOST_CASES | HOST_REPEAT_REPORT | HOST_BASELINE | HOST_SELECTION => {
54            Some(Provenance::Host)
55        }
56        REPEAT_REPORT | BASELINE | EVAL_CASES | SELECTION => Some(Provenance::Rustev),
57        _ => None,
58    }
59}
60
61/// The one provenance of a command's documents, by schema, or
62/// `mixed-provenance` when they hold both (I-1). A schema of neither kind
63/// is left to the document's own parser.
64pub fn one_provenance(schemas: &[&str]) -> Result<Option<Provenance>, HostError> {
65    let mut seen = None;
66    for p in schemas.iter().filter_map(|s| provenance(s)) {
67        match seen {
68            Some(q) if q != p => {
69                return Err(HostError::new(
70                    code::MIXED_PROVENANCE,
71                    "host and Rustev-evaluated documents are never combined or compared",
72                ));
73            }
74            _ => seen = Some(p),
75        }
76    }
77    Ok(seen)
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81#[serde(deny_unknown_fields)]
82pub struct HostBaseline {
83    pub schema: String,
84    pub assertion: Assertion,
85    pub name: String,
86    /// The record digest of the host repeat report.
87    pub host_repeat_report: ContentDigest,
88    pub runs: Vec<HostRunRef>,
89    pub host: HostFlow,
90    pub dataset: HostDataset,
91    pub split: Split,
92    pub split_role: SplitRole,
93    pub cost_unit: Option<String>,
94    pub evaluator_config: EvaluatorConfigId,
95    pub repeat_config: ContentDigest,
96    pub source: HostSource,
97    pub n: u64,
98    /// Supplied by the host, never read from a clock.
99    pub recorded_at_ms: i64,
100    pub held_out: Option<HeldOut>,
101    pub cohorts: Vec<HostBaselineCohort>,
102}
103
104crate::doc::eval_document!(HostBaseline, HOST_BASELINE, REPLAY_V1);
105
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107#[serde(deny_unknown_fields)]
108pub struct HostBaselineCohort {
109    pub name: String,
110    pub status: CohortStatus,
111    pub metrics: Vec<HostBaselineMetric>,
112}
113
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
115#[serde(deny_unknown_fields)]
116pub struct HostBaselineMetric {
117    pub name: String,
118    pub derivation: Derivation,
119    pub observation: Option<HostObservation>,
120    pub unit: Option<String>,
121    pub pooled: FigureDoc,
122    pub interval: IntervalDoc,
123    pub band: BandDoc,
124}
125
126/// A host set with the inputs that rebuild it (3.3.4).
127pub struct HostSetEvidence<'a> {
128    pub report: &'a HostRepeatReport,
129    pub cases: &'a [HostCases],
130    pub inputs: HostSetInputs<'a>,
131}
132
133/// Record a host baseline (3.7.1): the set is re-assembled from its
134/// inputs and must reproduce the supplied report and case documents.
135pub fn record(
136    name: &str,
137    set: &HostSetEvidence<'_>,
138    recorded_at_ms: i64,
139) -> Result<HostBaseline, HostError> {
140    if !valid_name(name) {
141        return Err(HostError::new(
142            "baseline",
143            "a baseline name is 1 to 64 bytes of [A-Za-z0-9._-], starting alphanumeric",
144        ));
145    }
146    reassemble(&set.inputs, set.report, set.cases)?;
147    let r = set.report;
148    Ok(HostBaseline {
149        schema: HOST_BASELINE.into(),
150        assertion: Assertion::Host,
151        name: name.into(),
152        host_repeat_report: r
153            .record_digest()
154            .map_err(|e| HostError::new(code::RECONCILE, e.to_string()))?,
155        runs: r.runs.clone(),
156        host: r.host.clone(),
157        dataset: r.dataset.clone(),
158        split: r.split,
159        split_role: r.split_role,
160        cost_unit: r.cost_unit.clone(),
161        evaluator_config: r.evaluator_config.clone(),
162        repeat_config: r.repeat_config.clone(),
163        source: r.source,
164        n: r.runs.len() as u64,
165        recorded_at_ms,
166        held_out: r.held_out.clone(),
167        cohorts: r
168            .cohorts
169            .iter()
170            .map(|c| HostBaselineCohort {
171                name: c.name.clone(),
172                status: c.status,
173                metrics: c
174                    .metrics
175                    .iter()
176                    .map(|m| HostBaselineMetric {
177                        name: m.name.clone(),
178                        derivation: m.derivation,
179                        observation: m.observation,
180                        unit: m.unit.clone(),
181                        pooled: m.pooled.clone(),
182                        interval: m.interval.clone(),
183                        band: m.band.clone(),
184                    })
185                    .collect(),
186            })
187            .collect(),
188    })
189}
190
191/// A baseline that verifies against its import output (3.7.4): recording
192/// it again from the set gives the same document.
193pub fn verify_baseline(
194    baseline: &HostBaseline,
195    set: &HostSetEvidence<'_>,
196) -> Result<(), HostError> {
197    if record(&baseline.name, set, baseline.recorded_at_ms)? != *baseline {
198        return Err(HostError::new(
199            "baseline",
200            "the host baseline does not bind its import output",
201        ));
202    }
203    Ok(())
204}
205
206/// A host metric as spec 029's clauses read it: a host observation is the
207/// set's own, so it reads as the candidate's (3.5.3), never historical.
208fn as_metric(m: &crate::host_repeat::HostMetricReport) -> MetricReport {
209    MetricReport {
210        name: m.name.clone(),
211        observation: m
212            .observation
213            .map(|HostObservation::Host| Observation::Candidate),
214        runs: m.runs.clone(),
215        mean: m.mean.clone(),
216        sd: m.sd.clone(),
217        min: m.min.clone(),
218        max: m.max.clone(),
219        pooled: m.pooled.clone(),
220        interval: m.interval.clone(),
221        band: m.band.clone(),
222    }
223}
224
225fn as_cohorts(cohorts: &[HostCohortReport]) -> Vec<CohortReport> {
226    cohorts
227        .iter()
228        .map(|c| CohortReport {
229            name: c.name.clone(),
230            status: c.status,
231            cases: c.cases,
232            stable_cases: c.stable_cases,
233            min_cases: c.min_cases,
234            metrics: c.metrics.iter().map(as_metric).collect(),
235        })
236        .collect()
237}
238
239/// The reproducibility check over host sets (3.7.2): spec 029 3.9 with
240/// "same configuration" meaning equal host system, flow, flow version,
241/// dataset, split, evaluator configuration and repeat configuration.
242pub fn reproducibility(
243    baseline: &HostBaseline,
244    run: &HostRepeatReport,
245    config: &RepeatConfig,
246) -> Reproducibility {
247    let mut reasons = vec![];
248    if config.id().ok().as_ref() != Some(&baseline.repeat_config) {
249        reasons.push("the repeat configuration is not the baseline's".to_string());
250    }
251    if run.host != baseline.host
252        || run.dataset != baseline.dataset
253        || run.split != baseline.split
254        || run.evaluator_config != baseline.evaluator_config
255        || run.repeat_config != baseline.repeat_config
256    {
257        reasons.push(
258            "the host set's system, flow, flow version, dataset, split, evaluator \
259             configuration or repeat configuration is not the baseline's"
260                .into(),
261        );
262    }
263    let base: Vec<baseline::BaselineCohort> = baseline
264        .cohorts
265        .iter()
266        .map(|c| baseline::BaselineCohort {
267            name: c.name.clone(),
268            status: c.status,
269            metrics: c
270                .metrics
271                .iter()
272                .map(|m| baseline::BaselineMetric {
273                    name: m.name.clone(),
274                    observation: None,
275                    pooled: m.pooled.clone(),
276                    interval: m.interval.clone(),
277                    band: m.band.clone(),
278                })
279                .collect(),
280        })
281        .collect();
282    baseline::stability(reasons, &base, &as_cohorts(&run.cohorts), config)
283}
284
285/// A host selection (3.7.3): spec 029 3.12.7's content with the host
286/// baseline's digest and each candidate's host repeat report and host
287/// identities.
288#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
289#[serde(deny_unknown_fields)]
290pub struct HostSelection {
291    pub schema: String,
292    pub assertion: Assertion,
293    pub gate: ContentDigest,
294    /// The host baseline's record digest.
295    pub baseline: ContentDigest,
296    pub candidates: Vec<HostCandidateSelection>,
297    pub ranked: Vec<String>,
298    pub unranked: Vec<Unranked>,
299}
300
301crate::doc::eval_document!(HostSelection, HOST_SELECTION, REPLAY_V1);
302
303#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
304#[serde(deny_unknown_fields)]
305pub struct HostCandidateSelection {
306    pub name: String,
307    pub host_repeat_report: ContentDigest,
308    pub host: HostFlow,
309    pub verdict: Verdict,
310    pub clauses: Vec<ClauseResult>,
311}
312
313impl HostSelection {
314    /// Spec 029 3.14's 0, 11 and 12, unchanged (R-1).
315    pub fn exit_code(&self) -> u8 {
316        if self
317            .candidates
318            .iter()
319            .any(|c| matches!(c.verdict, Verdict::Unknown { .. }))
320        {
321            12
322        } else if self
323            .candidates
324            .iter()
325            .any(|c| c.verdict == Verdict::Qualifies)
326        {
327            0
328        } else {
329            11
330        }
331    }
332}
333
334pub struct NamedHostCandidate<'a> {
335    pub name: &'a str,
336    pub evidence: HostSetEvidence<'a>,
337}
338
339/// The preconditions of 3.7.4 that compare the two sides: equal host
340/// system, dataset, split, case list and order, evaluator configuration,
341/// repeat configuration and cost unit. Flow and flow version may differ;
342/// stable cohorts are not compared (decision 4).
343fn comparable(b: &HostSetEvidence<'_>, c: &HostSetEvidence<'_>) -> Result<(), String> {
344    let (br, cr) = (b.report, c.report);
345    if br.host.system != cr.host.system {
346        return Err("host system mismatch".into());
347    }
348    if br.dataset != cr.dataset || br.split != cr.split {
349        return Err("dataset or split mismatch".into());
350    }
351    if br.evaluator_config != cr.evaluator_config {
352        return Err("evaluator configuration mismatch".into());
353    }
354    if br.repeat_config != cr.repeat_config {
355        return Err("repeat configuration mismatch".into());
356    }
357    if br.cost_unit != cr.cost_unit {
358        return Err("cost unit mismatch".into());
359    }
360    let ids = |e: &HostSetEvidence<'_>| -> Option<Vec<String>> {
361        Some(
362            e.cases
363                .first()?
364                .cases
365                .iter()
366                .map(|c| c.case.clone())
367                .collect(),
368        )
369    };
370    if ids(b) != ids(c) {
371        return Err("case lists or order differ".into());
372    }
373    Ok(())
374}
375
376/// Per run, the counts of a count-ratio measure: `(numerator, denominator)`.
377fn counts(cases: &HostCases, name: &str) -> (u64, u64) {
378    cases
379        .cases
380        .iter()
381        .filter_map(|c| match c.measures.get(name) {
382            Some(CaseMeasure::Count { num, den }) => Some((*num, *den)),
383            _ => None,
384        })
385        .fold((0, 0), |(n, d), (a, b)| (n + a, d + b))
386}
387
388/// The coverage minimums of 3.7.5, with spec 029's arithmetic:
389/// `min_comparable_coverage` over `1 - host/failed_rate` and
390/// `min_labeled_coverage` over `host/scored_rate`, per run on each side.
391fn coverage(gate: &SelectionGate, side: &str, e: &HostSetEvidence<'_>) -> Vec<ClauseResult> {
392    let mut out = vec![];
393    for (r, cases) in e.cases.iter().enumerate() {
394        let (failed, all) = counts(cases, metric::FAILED_RATE);
395        let (scored, scored_all) = counts(cases, metric::SCORED_RATE);
396        for (label, num, den, min) in [
397            (
398                "comparable_coverage",
399                all - failed,
400                all,
401                gate.min_comparable_coverage,
402            ),
403            (
404                "labeled_coverage",
405                scored,
406                scored_all,
407                gate.min_labeled_coverage,
408            ),
409        ] {
410            let result = if den == 0 {
411                ClauseOutcome::Unknown {
412                    reason: "coverage has a zero denominator".into(),
413                }
414            } else if i128::from(num) * 1_000_000_000 >= min.units() * i128::from(den) {
415                ClauseOutcome::Pass {
416                    reason: "coverage minimum".into(),
417                }
418            } else {
419                ClauseOutcome::Fail {
420                    reason: "coverage minimum".into(),
421                }
422            };
423            out.push(ClauseResult {
424                clause: format!("{side}.runs[{r}].{label}"),
425                result,
426                slices: vec![],
427            });
428        }
429    }
430    out
431}
432
433fn opaque_metrics(e: &HostSetEvidence<'_>) -> Vec<String> {
434    declarations(e.inputs.evaluator)
435        .map(|d| {
436            d.metric_names()
437                .into_iter()
438                .filter(|(_, (_, opaque))| *opaque)
439                .map(|(m, _)| m)
440                .collect()
441        })
442        .unwrap_or_default()
443}
444
445/// Gate host candidates against a host baseline (3.7): spec 029 3.12's
446/// clauses, verdicts, ranking and exit codes, with the preconditions of
447/// 3.7.4 and the coverage minimums of 3.7.5 (R-1).
448pub fn evaluate_selection(
449    gate: &SelectionGate,
450    baseline: &HostBaseline,
451    base: &HostSetEvidence<'_>,
452    candidates: &[NamedHostCandidate<'_>],
453) -> Result<HostSelection, String> {
454    gate.check()?;
455    SelectionGate::parse(&gate.canonical().map_err(|e| e.to_string())?)
456        .map_err(|e| e.to_string())?;
457    crate::comparison::check_name_list(&candidates.iter().map(|c| c.name).collect::<Vec<_>>())?;
458    let base_ok = verify_baseline(baseline, base).map_err(|e| e.to_string());
459    let base_cohorts = as_cohorts(&base.report.cohorts);
460    let base_opaque = opaque_metrics(base);
461    let base_side = Side {
462        cohorts: &base_cohorts,
463        split_role: base.report.split_role,
464        held_out: base.report.held_out.as_ref(),
465        opaque: &|m: &str| base_opaque.iter().any(|x| x == m),
466        host: true,
467    };
468    let mut decisions = vec![];
469    let mut ranks = vec![];
470    let mut unranked = vec![];
471    for c in candidates {
472        let e = &c.evidence;
473        let precondition = base_ok
474            .clone()
475            .and_then(|()| comparable(base, e))
476            .and_then(|()| {
477                reassemble(&e.inputs, e.report, e.cases)
478                    .map(|_| ())
479                    .map_err(|e| e.to_string())
480            });
481        let reason = precondition.err();
482        let mut clauses = vec![ClauseResult {
483            clause: "preconditions".into(),
484            result: match &reason {
485                Some(r) => ClauseOutcome::Unknown { reason: r.clone() },
486                None => ClauseOutcome::Pass {
487                    reason: "bindings verified".into(),
488                },
489            },
490            slices: vec![],
491        }];
492        if reason.is_none() {
493            clauses.extend(coverage(gate, "baseline", base));
494            clauses.extend(coverage(gate, "candidate", e));
495        }
496        let cohorts = as_cohorts(&e.report.cohorts);
497        let opaque = opaque_metrics(e);
498        let side = Side {
499            cohorts: &cohorts,
500            split_role: e.report.split_role,
501            held_out: e.report.held_out.as_ref(),
502            opaque: &|m: &str| opaque.iter().any(|x| x == m),
503            host: true,
504        };
505        let (verdict, clauses) = judge(
506            gate,
507            &base_side,
508            &side,
509            reason.as_deref(),
510            clauses,
511            c.name,
512            &mut ranks,
513            &mut unranked,
514        );
515        decisions.push(HostCandidateSelection {
516            name: c.name.into(),
517            host_repeat_report: e.report.record_digest().map_err(|e| e.to_string())?,
518            host: e.report.host.clone(),
519            verdict,
520            clauses,
521        });
522    }
523    sort_ranks(gate, &mut ranks);
524    let doc = HostSelection {
525        schema: HOST_SELECTION.into(),
526        assertion: Assertion::Host,
527        gate: gate.id().map_err(|e| e.to_string())?,
528        baseline: baseline.record_digest().map_err(|e| e.to_string())?,
529        candidates: decisions,
530        ranked: ranks.into_iter().map(|(n, _)| n).collect(),
531        unranked,
532    };
533    HostSelection::parse(&doc.record_canonical().map_err(|e| e.to_string())?)
534        .map_err(|e| e.to_string())
535}