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::{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
18pub 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 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 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 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
171pub fn compare_candidates(
174 baseline: &Baseline,
175 baseline_evidence: &RepeatEvidence<'_>,
176 candidates: &[NamedCandidate<'_>],
177) -> Result<Comparison, String> {
178 check_names(candidates)?;
179 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 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 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
292pub 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 '&' => "&".into(),
359 '<' => "<".into(),
360 '>' => ">".into(),
361 '|' => "|".into(),
362 '\n' => " ".into(),
363 '\r' => " ".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}