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