Skip to main content

rustev_eval/
comparison.rs

1//! Multi-candidate measurement evidence (spec 029, 3.11). No direction or choice.
2use std::collections::{BTreeMap, BTreeSet};
3use std::fmt::Write;
4
5use rustev_contract::ids::ContentDigest;
6use rustev_contract::limits::REPLAY_V1;
7use rustev_contract::{Document, Identified};
8use serde::{Deserialize, Serialize};
9
10use crate::baseline::{Baseline, record};
11use crate::config::AdapterRules;
12use crate::repeat::{
13    FigureDoc, IntervalDoc, Observation, RepeatInputs, RepeatReport, Source, repeat,
14};
15
16pub const COMPARISON: &str = "rustev.comparison/1";
17pub const MAX_CANDIDATES: usize = 64;
18pub const MAX_CANDIDATE_NAME: usize = 128;
19
20/// Complete evidence for a repeat set. Verification recomputes the report;
21/// the host's held-out ledger statement is retained, as in baseline recording.
22pub struct RepeatEvidence<'a> {
23    pub report: &'a RepeatReport,
24    pub inputs: RepeatInputs<'a>,
25}
26
27pub struct NamedCandidate<'a> {
28    pub name: &'a str,
29    pub evidence: RepeatEvidence<'a>,
30}
31
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct Comparison {
35    pub schema: String,
36    pub baseline: ContentDigest,
37    /// Baseline first; candidates in caller order.
38    pub rows: Vec<ComparisonRow>,
39}
40crate::doc::eval_document!(Comparison, COMPARISON, REPLAY_V1);
41
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct ComparisonRow {
45    pub name: String,
46    pub repeat_report: ContentDigest,
47    pub result: ComparisonResult,
48}
49
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case", deny_unknown_fields)]
52pub enum ComparisonResult {
53    Unknown {
54        reason: String,
55    },
56    Measured {
57        metrics: Vec<ComparisonMetric>,
58        /// Counts over all case/run observations, not distinct cases.
59        abstentions: BTreeMap<String, u64>,
60        incomparable: BTreeMap<String, u64>,
61    },
62}
63
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct ComparisonMetric {
67    pub name: String,
68    pub observation: Option<Observation>,
69    pub pooled: FigureDoc,
70    pub interval: IntervalDoc,
71}
72
73pub(crate) fn check_names(candidates: &[NamedCandidate<'_>]) -> Result<(), String> {
74    if candidates.is_empty() || candidates.len() > MAX_CANDIDATES {
75        return Err("expected 1 to 64 candidates".into());
76    }
77    let mut names = BTreeSet::new();
78    for c in candidates {
79        let n = c.name;
80        if n.is_empty()
81            || n.len() > MAX_CANDIDATE_NAME
82            || !n.as_bytes()[0].is_ascii_alphanumeric()
83            || !n
84                .bytes()
85                .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
86            || !names.insert(n.to_ascii_lowercase())
87        {
88            return Err(
89                "candidate names must obey the case-id rule and be unique ignoring ASCII case"
90                    .into(),
91            );
92        }
93    }
94    Ok(())
95}
96
97pub(crate) fn verify(e: &RepeatEvidence<'_>) -> Result<(), String> {
98    let again = repeat(&e.inputs).map_err(|e| e.to_string())?;
99    let mut supplied = e.report.clone();
100    supplied.held_out = again.held_out.clone();
101    if supplied != again {
102        return Err("runs do not reproduce the repeat report".into());
103    }
104    for r in e.inputs.runs {
105        let v = r.evaluated;
106        v.config.check().map_err(|e| e.to_string())?;
107        if v.config.id().map_err(|e| e.to_string())? != v.report.evaluator_config
108            || v.detail.config != v.report.evaluator_config
109            || v.detail.dataset != v.report.dataset.id
110            || v.detail.split != v.report.dataset.split
111            || v.report.record_digest().map_err(|e| e.to_string())? != v.detail.report
112        {
113            return Err("run detail or configuration does not bind its report".into());
114        }
115        v.detail.reconcile()?;
116        if v.config.adapter.rules.as_ref() != e.report.adapter_rules.as_ref() {
117            return Err("run adapter rules differ from the repeat report".into());
118        }
119    }
120    Ok(())
121}
122
123pub(crate) fn verify_baseline(b: &Baseline, e: &RepeatEvidence<'_>) -> Result<(), String> {
124    verify(e)?;
125    let again =
126        record(&b.name, &e.inputs, e.report, b.recorded_at_ms).map_err(|e| e.to_string())?;
127    if &again != b {
128        return Err("baseline does not bind its repeat set".into());
129    }
130    if e.report.candidate {
131        return Err("baseline is a candidate evaluation".into());
132    }
133    Ok(())
134}
135
136pub(crate) fn compatible(b: &RepeatEvidence<'_>, c: &RepeatEvidence<'_>) -> Result<(), String> {
137    let (br, cr) = (b.report, c.report);
138    if !matches!(br.adapter_rules, Some(AdapterRules::Bound(_)))
139        || br.adapter_rules != cr.adapter_rules
140    {
141        return Err("adapter rules must be bound and equal".into());
142    }
143    if br.dataset != cr.dataset
144        || br.split != cr.split
145        || br.evaluator_config != cr.evaluator_config
146    {
147        return Err("dataset, split or evaluator configuration mismatch".into());
148    }
149    if br.repeat_config != cr.repeat_config {
150        return Err("repeat configuration mismatch".into());
151    }
152    if !cr.candidate && cr.source != Source::Live {
153        return Err("candidate is not a candidate evaluation".into());
154    }
155    if br.source != cr.source || br.runs.len() != cr.runs.len() {
156        return Err("repeat sources or run counts differ".into());
157    }
158    // Comparable offline cases must reuse the same retained run records.
159    // An incomparable case legitimately has no run record.
160    for (b, c) in b.inputs.runs.iter().zip(c.inputs.runs) {
161        if b.cases.cases.len() != c.cases.cases.len() {
162            return Err("case lists differ".into());
163        }
164        for (b, c) in b.cases.cases.iter().zip(&c.cases.cases) {
165            if b.case != c.case || (cr.source != Source::Live && c.run.is_some() && b.run != c.run)
166            {
167                return Err("case order or retained run records differ".into());
168            }
169        }
170    }
171    Ok(())
172}
173
174/// Build one baseline row followed by each named candidate. Invalid evidence
175/// is reported as unknown in its row, never used as a measurement.
176pub fn compare_candidates(
177    baseline: &Baseline,
178    baseline_evidence: &RepeatEvidence<'_>,
179    candidates: &[NamedCandidate<'_>],
180) -> Result<Comparison, String> {
181    check_names(candidates)?;
182    // Rows are keyed by name: a candidate may not share the baseline's
183    // (compared as `check_names` compares candidates, ignoring ASCII case).
184    if candidates
185        .iter()
186        .any(|c| c.name.eq_ignore_ascii_case(&baseline.name))
187    {
188        return Err("a candidate name equals the baseline name".into());
189    }
190    let base = verify_baseline(baseline, baseline_evidence).and_then(|()| {
191        if matches!(baseline.adapter_rules, Some(AdapterRules::Bound(_))) {
192            Ok(())
193        } else {
194            Err("baseline adapter rules are not bound".into())
195        }
196    });
197    let mut rows = vec![row(&baseline.name, baseline_evidence, base.clone())?];
198    for c in candidates {
199        let valid = base
200            .clone()
201            .and_then(|()| compatible(baseline_evidence, &c.evidence))
202            .and_then(|()| verify(&c.evidence));
203        rows.push(row(c.name, &c.evidence, valid)?);
204    }
205    let doc = Comparison {
206        schema: COMPARISON.into(),
207        baseline: baseline.record_digest().map_err(|e| e.to_string())?,
208        rows,
209    };
210    // Enforce REPLAY_V1 on produced documents as well as parsed documents.
211    Comparison::parse(&doc.record_canonical().map_err(|e| e.to_string())?)
212        .map_err(|e| e.to_string())
213}
214
215fn row(
216    name: &str,
217    e: &RepeatEvidence<'_>,
218    valid: Result<(), String>,
219) -> Result<ComparisonRow, String> {
220    // A repeat report without an `all` cohort is reported in its row as
221    // unknown, like every other evidence failure, never as a fatal error.
222    let all = e.report.cohorts.iter().find(|c| c.name == "all");
223    let result = match (valid, all) {
224        (Err(reason), _) => ComparisonResult::Unknown { reason },
225        (Ok(()), None) => ComparisonResult::Unknown {
226            reason: "missing all cohort".into(),
227        },
228        (Ok(()), Some(all)) => {
229            let mut metrics: Vec<_> = all
230                .metrics
231                .iter()
232                .map(|m| ComparisonMetric {
233                    name: m.name.clone(),
234                    observation: m.observation,
235                    pooled: m.pooled.clone(),
236                    interval: m.interval.clone(),
237                })
238                .collect();
239            for name in [
240                "coverage",
241                "acceptance_coverage",
242                "error_among_accepted",
243                "latency_ms/p0.5",
244                "latency_ms/p0.95",
245                "cost_per_1000/observed",
246                "cost_per_1000/estimated",
247            ] {
248                if !metrics.iter().any(|m| m.name == name) {
249                    metrics.push(ComparisonMetric {
250                        name: name.into(),
251                        observation: observed(name).then_some(if e.report.candidate {
252                            Observation::Historical
253                        } else {
254                            Observation::Candidate
255                        }),
256                        pooled: FigureDoc::Unknown("not declared".into()),
257                        interval: IntervalDoc::Unknown {
258                            reason: "not declared".into(),
259                            undefined: 0,
260                        },
261                    });
262                }
263            }
264            let mut abstentions = BTreeMap::new();
265            let mut incomparable = BTreeMap::new();
266            for run in e.inputs.runs {
267                for c in &run.evaluated.detail.cases {
268                    if let Some(kind) = c.outcome.strip_prefix("abstention:") {
269                        *abstentions.entry(kind.into()).or_insert(0) += 1;
270                    } else if c.outcome == "incomparable" || c.outcome == "diverged" {
271                        *incomparable
272                            .entry(c.reason.clone().unwrap_or_else(|| c.outcome.clone()))
273                            .or_insert(0) += 1;
274                    }
275                }
276            }
277            ComparisonResult::Measured {
278                metrics,
279                abstentions,
280                incomparable,
281            }
282        }
283    };
284    Ok(ComparisonRow {
285        name: name.into(),
286        repeat_report: e.report.record_digest().map_err(|e| e.to_string())?,
287        result,
288    })
289}
290
291pub(crate) fn observed(name: &str) -> bool {
292    name.starts_with("latency_ms/") || name.starts_with("queue_ms/") || name.starts_with("cost_")
293}
294
295/// Deterministic rendering from the document alone. Historical observations
296/// remain visibly labeled alongside their values and intervals.
297pub fn render_markdown(doc: &Comparison) -> String {
298    let mut out = String::from(
299        "# Candidate comparison\n\n| Variant | Metric | Pooled | Interval | Observation |\n| --- | --- | --- | --- | --- |\n",
300    );
301    for row in &doc.rows {
302        match &row.result {
303            ComparisonResult::Unknown { reason } => {
304                writeln!(
305                    out,
306                    "| {} | unknown: {} | | | |",
307                    escape(&row.name),
308                    escape(reason)
309                )
310                .unwrap();
311            }
312            ComparisonResult::Measured {
313                metrics,
314                abstentions,
315                incomparable,
316            } => {
317                for m in metrics {
318                    let interval = match &m.interval {
319                        IntervalDoc::Bounds { lower, upper } => {
320                            format!("[{}, {}]", number(*lower), number(*upper))
321                        }
322                        IntervalDoc::Unknown { reason, .. } => {
323                            format!("unknown: {}", escape(reason))
324                        }
325                    };
326                    let pooled = match &m.pooled {
327                        FigureDoc::Value(v) => number(*v),
328                        FigureDoc::Unknown(r) => format!("unknown: {}", escape(r)),
329                    };
330                    let observation = match m.observation {
331                        Some(Observation::Historical) => "historical",
332                        Some(Observation::Candidate) => "candidate",
333                        None => "",
334                    };
335                    writeln!(
336                        out,
337                        "| {} | {} | {pooled} | {interval} | {observation} |",
338                        escape(&row.name),
339                        escape(&m.name)
340                    )
341                    .unwrap();
342                }
343                for (kind, counts) in [("abstentions", abstentions), ("incomparable", incomparable)]
344                {
345                    writeln!(
346                        out,
347                        "| {} | {kind} (case/run counts) | {} | | |",
348                        escape(&row.name),
349                        escape(&serde_json::to_string(counts).unwrap())
350                    )
351                    .unwrap();
352                }
353            }
354        }
355    }
356    out
357}
358fn escape(s: &str) -> String {
359    s.chars()
360        .map(|c| match c {
361            '&' => "&amp;".into(),
362            '<' => "&lt;".into(),
363            '>' => "&gt;".into(),
364            '|' => "&#124;".into(),
365            '\n' => "&#10;".into(),
366            '\r' => "&#13;".into(),
367            '\\' | '`' | '*' | '_' | '[' | ']' | '#' => format!("\\{c}"),
368            _ => c.to_string(),
369        })
370        .collect()
371}
372fn number(v: f64) -> String {
373    let mut b = Vec::new();
374    rustev_contract::canonical::write_binary64(v, &mut b);
375    String::from_utf8(b).expect("binary64 is ASCII")
376}