1use 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
20pub 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 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 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 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
174pub fn compare_candidates(
177 baseline: &Baseline,
178 baseline_evidence: &RepeatEvidence<'_>,
179 candidates: &[NamedCandidate<'_>],
180) -> Result<Comparison, String> {
181 check_names(candidates)?;
182 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 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 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
295pub 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 '&' => "&".into(),
362 '<' => "<".into(),
363 '>' => ">".into(),
364 '|' => "|".into(),
365 '\n' => " ".into(),
366 '\r' => " ".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}