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