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 check_name_list(&candidates.iter().map(|c| c.name).collect::<Vec<_>>())
75}
76
77pub(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 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
179pub fn compare_candidates(
182 baseline: &Baseline,
183 baseline_evidence: &RepeatEvidence<'_>,
184 candidates: &[NamedCandidate<'_>],
185) -> Result<Comparison, String> {
186 check_names(candidates)?;
187 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 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 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
300pub 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 '&' => "&".into(),
367 '<' => "<".into(),
368 '>' => ">".into(),
369 '|' => "|".into(),
370 '\n' => " ".into(),
371 '\r' => " ".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}