1use std::collections::{BTreeMap, BTreeSet};
4use std::fmt::Write;
5
6use rustev_contract::ids::ContentDigest;
7use rustev_contract::limits::REPLAY_V1;
8use rustev_contract::{Document, Identified};
9use serde::{Deserialize, Serialize};
10
11use crate::baseline::{BASELINE, Baseline};
12use crate::config::AdapterRules;
13use crate::eval_cases::{EVAL_CASES, EvalCases};
14use crate::repeat::{BandDoc, FigureDoc, IntervalDoc, REPEAT_REPORT, RepeatReport, RunRef};
15use crate::repeat_config::RepeatConfig;
16
17pub const EVAL_DIFF: &str = "rustev.eval-diff/1";
18pub const MAX_CHANGED_CASES: usize = 10_000;
19
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21#[serde(deny_unknown_fields)]
22pub struct EvalDiff {
23 pub schema: String,
24 pub baseline: ContentDigest,
25 pub run: ContentDigest,
26 pub result: DiffResult,
27}
28
29crate::doc::eval_document!(EvalDiff, EVAL_DIFF, REPLAY_V1);
30
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case", deny_unknown_fields)]
33pub enum DiffResult {
34 Unknown {
35 reason: String,
36 },
37 Compared {
38 cohorts: Vec<DiffCohort>,
39 changed_cases: Vec<ChangedCase>,
40 total_changed_cases: u64,
41 truncated: bool,
42 },
43}
44
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct DiffCohort {
48 pub name: String,
49 pub metrics: Vec<DiffMetric>,
50}
51
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53#[serde(deny_unknown_fields)]
54pub struct DiffMetric {
55 pub name: String,
56 pub baseline: FigureDoc,
57 pub run: FigureDoc,
58 pub delta: FigureDoc,
59 pub baseline_interval: IntervalDoc,
60 pub run_interval: IntervalDoc,
61 pub overlap: DiffBool,
62 pub within_noise: DiffBool,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case", deny_unknown_fields)]
67pub enum DiffBool {
68 Value(bool),
69 Unknown(String),
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct ChangedCase {
75 pub case: String,
76 pub baseline: CaseDistribution,
77 pub run: CaseDistribution,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(deny_unknown_fields)]
82pub struct CaseDistribution {
83 pub outcomes: BTreeMap<String, u64>,
85 pub top_k: Option<Vec<TopKCount>>,
88 pub runs: Vec<Option<ContentDigest>>,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(deny_unknown_fields)]
94pub struct TopKCount {
95 pub list: Option<Vec<String>>,
96 pub count: u64,
97}
98
99pub fn diff(
104 baseline: &Baseline,
105 run: &RepeatReport,
106 config: &RepeatConfig,
107 baseline_cases: &[&EvalCases],
108 run_cases: &[&EvalCases],
109) -> Result<EvalDiff, String> {
110 if baseline.schema != BASELINE || run.schema != REPEAT_REPORT {
111 return Err("wrong baseline or repeat report schema".into());
112 }
113 let mut doc = EvalDiff {
114 schema: EVAL_DIFF.into(),
115 baseline: baseline.record_digest().map_err(|e| e.to_string())?,
116 run: run.record_digest().map_err(|e| e.to_string())?,
117 result: DiffResult::Unknown {
118 reason: String::new(),
119 },
120 };
121 let mismatch = if baseline.dataset != run.dataset {
122 Some("dataset mismatch")
123 } else if baseline.split != run.split {
124 Some("split mismatch")
125 } else if baseline.evaluator_config != run.evaluator_config {
126 Some("evaluator configuration mismatch")
127 } else if baseline.adapter_rules != run.adapter_rules {
128 Some("adapter rules mismatch")
129 } else if !matches!(baseline.adapter_rules, Some(AdapterRules::Bound(_))) {
130 Some("adapter rules are not bound")
131 } else if baseline.repeat_config != run.repeat_config {
132 Some("repeat configuration mismatch")
133 } else {
134 None
135 };
136 if let Some(reason) = mismatch {
137 doc.result = DiffResult::Unknown {
138 reason: reason.into(),
139 };
140 return Ok(doc);
141 }
142 config.check().map_err(|e| e.to_string())?;
143 if config.id().map_err(|e| e.to_string())? != baseline.repeat_config {
144 return Err("supplied repeat configuration does not bind the reports".into());
145 }
146 check_cases(&baseline.runs, baseline_cases, config)?;
147 check_cases(&run.runs, run_cases, config)?;
148 let manifest = &baseline_cases[0].cases;
149 if run_cases[0]
150 .cases
151 .iter()
152 .map(|c| &c.case)
153 .ne(manifest.iter().map(|c| &c.case))
154 {
155 return Err("mixed-runs: case order differs between sides".into());
156 }
157 let names: Vec<&str> = std::iter::once("all")
158 .chain(config.slices.iter().map(|s| s.name.as_str()))
159 .collect();
160 if baseline
161 .cohorts
162 .iter()
163 .map(|c| c.name.as_str())
164 .ne(names.iter().copied())
165 || run
166 .cohorts
167 .iter()
168 .map(|c| c.name.as_str())
169 .ne(names.iter().copied())
170 {
171 return Err("cohorts do not match the declared order".into());
172 }
173 let mut cohorts = Vec::new();
174 for (b, r) in baseline.cohorts.iter().zip(&run.cohorts) {
175 if b.metrics
176 .iter()
177 .map(|m| &m.name)
178 .ne(r.metrics.iter().map(|m| &m.name))
179 {
180 return Err("metric lists differ".into());
181 }
182 let metrics = b
183 .metrics
184 .iter()
185 .zip(&r.metrics)
186 .map(|(b, r)| {
187 let delta = match (&b.pooled, &r.pooled) {
188 (FigureDoc::Unknown(s), _) => FigureDoc::Unknown(format!("baseline: {s}")),
189 (_, FigureDoc::Unknown(s)) => FigureDoc::Unknown(format!("run: {s}")),
190 (FigureDoc::Value(b), FigureDoc::Value(r)) => {
191 let d = r - b;
192 if d.is_finite() {
193 FigureDoc::Value(d)
194 } else {
195 FigureDoc::Unknown("non-finite delta".into())
196 }
197 }
198 };
199 let overlap = match (&b.interval, &r.interval) {
200 (IntervalDoc::Unknown { reason, .. }, _) => {
201 DiffBool::Unknown(format!("baseline interval: {reason}"))
202 }
203 (_, IntervalDoc::Unknown { reason, .. }) => {
204 DiffBool::Unknown(format!("run interval: {reason}"))
205 }
206 (
207 IntervalDoc::Bounds {
208 lower: bl,
209 upper: bu,
210 },
211 IntervalDoc::Bounds {
212 lower: rl,
213 upper: ru,
214 },
215 ) => DiffBool::Value(bl <= ru && rl <= bu),
216 };
217 let within_noise = match (&b.band, &r.band, &delta) {
218 (BandDoc::Unknown(s), _, _) => DiffBool::Unknown(format!("baseline band: {s}")),
219 (_, BandDoc::Unknown(s), _) => DiffBool::Unknown(format!("run band: {s}")),
220 (_, _, FigureDoc::Unknown(s)) => DiffBool::Unknown(s.clone()),
221 (
222 BandDoc::Value { value: b, .. },
223 BandDoc::Value { value: r, .. },
224 FigureDoc::Value(d),
225 ) => DiffBool::Value(d.abs() <= b.max(*r)),
226 };
227 DiffMetric {
228 name: b.name.clone(),
229 baseline: b.pooled.clone(),
230 run: r.pooled.clone(),
231 delta,
232 baseline_interval: b.interval.clone(),
233 run_interval: r.interval.clone(),
234 overlap,
235 within_noise,
236 }
237 })
238 .collect();
239 cohorts.push(DiffCohort {
240 name: b.name.clone(),
241 metrics,
242 });
243 }
244 let mut changed_cases = Vec::new();
245 let mut total_changed_cases = 0;
246 for (i, case) in manifest.iter().enumerate() {
247 let b = distribution(baseline_cases, i, config.top_k.is_some());
248 let r = distribution(run_cases, i, config.top_k.is_some());
249 if b.outcomes != r.outcomes || b.top_k != r.top_k {
250 total_changed_cases += 1;
251 if changed_cases.len() < MAX_CHANGED_CASES {
252 changed_cases.push(ChangedCase {
253 case: case.case.clone(),
254 baseline: b,
255 run: r,
256 });
257 }
258 }
259 }
260 doc.result = DiffResult::Compared {
261 cohorts,
262 truncated: total_changed_cases > MAX_CHANGED_CASES as u64,
263 changed_cases,
264 total_changed_cases,
265 };
266 Ok(doc)
267}
268
269fn check_cases(refs: &[RunRef], cases: &[&EvalCases], config: &RepeatConfig) -> Result<(), String> {
270 if refs.is_empty() || refs.len() > 64 || refs.len() != cases.len() {
271 return Err("case documents must bind 1 to 64 runs".into());
272 }
273 let first = &cases[0].cases;
274 let mut seen = BTreeSet::new();
275 if first.iter().any(|c| !seen.insert(&c.case)) {
276 return Err("duplicate case id".into());
277 }
278 for (r, c) in refs.iter().zip(cases) {
279 if c.schema != EVAL_CASES
280 || c.report != r.report
281 || c.record_digest().map_err(|e| e.to_string())? != r.cases
282 {
283 return Err("case document does not bind its run".into());
284 }
285 if c.top_k != config.top_k
286 || c.cases
287 .iter()
288 .map(|c| &c.case)
289 .ne(first.iter().map(|c| &c.case))
290 {
291 return Err("mixed-runs: case order or top-k declaration differs".into());
292 }
293 }
294 Ok(())
295}
296
297fn distribution(cases: &[&EvalCases], i: usize, top_k: bool) -> CaseDistribution {
298 let mut outcomes = BTreeMap::new();
299 let mut lists = BTreeMap::new();
300 for c in cases {
301 *outcomes.entry(c.cases[i].outcome.clone()).or_insert(0) += 1;
302 if top_k {
303 *lists.entry(c.cases[i].top_k.clone()).or_insert(0) += 1;
304 }
305 }
306 CaseDistribution {
307 outcomes,
308 top_k: top_k.then(|| {
309 lists
310 .into_iter()
311 .map(|(list, count)| TopKCount { list, count })
312 .collect()
313 }),
314 runs: cases.iter().map(|c| c.cases[i].run.clone()).collect(),
315 }
316}
317
318pub fn render_markdown(doc: &EvalDiff) -> String {
320 let mut out = String::from("# Evaluation diff\n\n");
321 let DiffResult::Compared {
322 cohorts,
323 changed_cases,
324 total_changed_cases,
325 truncated,
326 } = &doc.result
327 else {
328 if let DiffResult::Unknown { reason } = &doc.result {
329 writeln!(out, "unknown: {}", escape(reason)).unwrap();
330 }
331 return out;
332 };
333 for c in cohorts {
334 writeln!(out, "## {}\n", escape(&c.name)).unwrap();
335 out.push_str("| Metric | Baseline | Run | Delta | Baseline interval | Run interval | Overlap | Within noise |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n");
336 for m in &c.metrics {
337 writeln!(
338 out,
339 "| {} | {} | {} | {} | {} | {} | {} | {} |",
340 escape(&m.name),
341 figure(&m.baseline),
342 figure(&m.run),
343 figure(&m.delta),
344 interval(&m.baseline_interval),
345 interval(&m.run_interval),
346 boolean(&m.overlap),
347 boolean(&m.within_noise)
348 )
349 .unwrap();
350 }
351 out.push('\n');
352 }
353 writeln!(
354 out,
355 "## Changed cases\n\nTotal: {total_changed_cases}; truncated: {truncated}\n"
356 )
357 .unwrap();
358 for c in changed_cases {
359 writeln!(out, "### {}\n", escape(&c.case)).unwrap();
360 for (side, d) in [("Baseline", &c.baseline), ("Run", &c.run)] {
361 writeln!(out, "{side}:\n").unwrap();
362 for (key, count) in &d.outcomes {
363 writeln!(out, "- Outcome {}: {count}", escape(key)).unwrap();
364 }
365 if let Some(lists) = &d.top_k {
366 for t in lists {
367 writeln!(
368 out,
369 "- Top-k {}: {}",
370 escape(&serde_json::to_string(&t.list).unwrap()),
371 t.count
372 )
373 .unwrap();
374 }
375 }
376 writeln!(
377 out,
378 "- Run records: {}\n",
379 escape(&serde_json::to_string(&d.runs).unwrap())
380 )
381 .unwrap();
382 }
383 }
384 out
385}
386
387fn escape(s: &str) -> String {
388 s.chars()
389 .map(|c| match c {
390 '&' => "&".into(),
391 '<' => "<".into(),
392 '>' => ">".into(),
393 '|' => "|".into(),
394 '\n' => " ".into(),
395 '\r' => " ".into(),
396 '\\' | '`' | '*' | '_' | '[' | ']' | '#' => format!("\\{c}"),
397 _ => c.to_string(),
398 })
399 .collect()
400}
401fn number(v: f64) -> String {
402 let mut bytes = Vec::new();
403 rustev_contract::canonical::write_binary64(v, &mut bytes);
404 String::from_utf8(bytes).expect("binary64 is ASCII")
405}
406fn figure(v: &FigureDoc) -> String {
407 match v {
408 FigureDoc::Value(v) => number(*v),
409 FigureDoc::Unknown(s) => format!("unknown: {}", escape(s)),
410 }
411}
412fn interval(v: &IntervalDoc) -> String {
413 match v {
414 IntervalDoc::Bounds { lower, upper } => format!("[{}, {}]", number(*lower), number(*upper)),
415 IntervalDoc::Unknown { reason, .. } => format!("unknown: {}", escape(reason)),
416 }
417}
418fn boolean(v: &DiffBool) -> String {
419 match v {
420 DiffBool::Value(v) => v.to_string(),
421 DiffBool::Unknown(s) => format!("unknown: {}", escape(s)),
422 }
423}