1use crate::Report;
14use serde::Serialize;
15use std::collections::BTreeMap;
16
17#[derive(Debug, Clone, Serialize, PartialEq)]
19pub struct Regression {
20 pub config: String,
22 pub kind: String,
24 pub baseline: f64,
25 pub current: f64,
26 pub detail: String,
27}
28
29#[derive(Debug, Clone, Serialize, PartialEq)]
33pub struct FlakyCase {
34 pub config: String,
35 pub id: String,
36 pub pass_rate: f64,
37}
38
39#[derive(Debug, Clone, Serialize)]
41pub struct GateResult {
42 pub passed: bool,
43 pub tolerance: f64,
44 pub regressions: Vec<Regression>,
45 pub flaky: Vec<FlakyCase>,
47 pub notes: Vec<String>,
49}
50
51fn case_pass_rate_map(config: &crate::ConfigReport) -> BTreeMap<&str, f64> {
53 config
54 .cases
55 .iter()
56 .map(|c| (c.id.as_str(), c.pass_rate))
57 .collect()
58}
59
60fn nonzero_pass_coverage(
68 baseline: &crate::ConfigReport,
69 current: &crate::ConfigReport,
70 tag: Option<&str>,
71) -> (f64, f64) {
72 let current_rates = case_pass_rate_map(current);
73 let cases: Vec<_> = baseline
74 .cases
75 .iter()
76 .filter(|case| tag.is_none_or(|tag| case.tags.iter().any(|case_tag| case_tag == tag)))
77 .collect();
78 if cases.is_empty() {
79 return (0.0, 0.0);
80 }
81
82 let baseline_nonzero = cases.iter().filter(|case| case.pass_rate > 0.0).count();
83 let current_nonzero = cases
84 .iter()
85 .filter(|case| {
86 current_rates
87 .get(case.id.as_str())
88 .is_some_and(|rate| *rate > 0.0)
89 })
90 .count();
91 let n = cases.len() as f64;
92 (baseline_nonzero as f64 / n, current_nonzero as f64 / n)
93}
94
95pub fn run_gate(baseline: &Report, current: &Report, tolerance: f64) -> GateResult {
105 if baseline.backend != current.backend {
106 return GateResult {
107 passed: false,
108 tolerance,
109 regressions: vec![Regression {
110 config: "*".to_string(),
111 kind: "backend".to_string(),
112 baseline: f64::NAN,
113 current: f64::NAN,
114 detail: format!(
115 "baseline backend '{}' != candidate backend '{}' — cross-backend reports are not comparable",
116 baseline.backend, current.backend
117 ),
118 }],
119 flaky: Vec::new(),
120 notes: Vec::new(),
121 };
122 }
123
124 let mut regressions = Vec::new();
125 let mut flaky = Vec::new();
126 let mut notes = Vec::new();
127
128 for base_cfg in &baseline.configs {
129 let Some(cur_cfg) = current.configs.iter().find(|c| c.model == base_cfg.model) else {
130 notes.push(format!(
131 "baseline config '{}' has no match in the candidate report (configs changed?)",
132 base_cfg.model
133 ));
134 continue;
135 };
136
137 let (base_coverage, cur_coverage) = nonzero_pass_coverage(base_cfg, cur_cfg, None);
140 if cur_coverage < base_coverage - tolerance {
141 regressions.push(Regression {
142 config: base_cfg.model.clone(),
143 kind: "overall".to_string(),
144 baseline: base_coverage,
145 current: cur_coverage,
146 detail: format!(
147 "overall non-zero pass coverage {:.3} → {:.3} (drop {:.3} > tolerance {:.3})",
148 base_coverage,
149 cur_coverage,
150 base_coverage - cur_coverage,
151 tolerance
152 ),
153 });
154 }
155
156 for tag in base_cfg.by_tag.keys() {
159 let (base_tag_coverage, cur_tag_coverage) =
160 nonzero_pass_coverage(base_cfg, cur_cfg, Some(tag));
161 if cur_tag_coverage < base_tag_coverage - tolerance {
162 regressions.push(Regression {
163 config: base_cfg.model.clone(),
164 kind: format!("tag:{tag}"),
165 baseline: base_tag_coverage,
166 current: cur_tag_coverage,
167 detail: format!(
168 "tag '{tag}' non-zero pass coverage {base_tag_coverage:.3} → {cur_tag_coverage:.3}"
169 ),
170 });
171 }
172 }
173
174 let cur_rates = case_pass_rate_map(cur_cfg);
179 for base_case in base_cfg.cases.iter().filter(|c| c.pass_rate > 0.0) {
180 match cur_rates.get(base_case.id.as_str()).copied() {
181 Some(rate) if rate >= 1.0 => {} Some(rate) if rate > 0.0 && base_case.pass => flaky.push(FlakyCase {
183 config: base_cfg.model.clone(),
184 id: base_case.id.clone(),
185 pass_rate: rate,
186 }),
187 Some(rate) if rate > 0.0 => {} Some(rate) => regressions.push(Regression {
189 config: base_cfg.model.clone(),
190 kind: format!("case:{}", base_case.id),
191 baseline: base_case.pass_rate,
192 current: rate,
193 detail: format!(
194 "case '{}' had non-zero baseline capability ({:.3}), now fails",
195 base_case.id, base_case.pass_rate
196 ),
197 }),
198 None => regressions.push(Regression {
199 config: base_cfg.model.clone(),
200 kind: format!("case:{}", base_case.id),
201 baseline: base_case.pass_rate,
202 current: f64::NAN,
203 detail: format!(
204 "case '{}' had non-zero baseline capability ({:.3}), now absent",
205 base_case.id, base_case.pass_rate
206 ),
207 }),
208 }
209 }
210 }
211
212 GateResult {
213 passed: regressions.is_empty(),
214 tolerance,
215 regressions,
216 flaky,
217 notes,
218 }
219}
220
221pub fn format_gate(result: &GateResult) -> String {
223 let mut out = String::new();
224 out.push_str("\n=== Warble eval — CI gate (accuracy regression check) ===\n");
225 out.push_str(&format!("tolerance: {:.3}\n", result.tolerance));
226 for note in &result.notes {
227 out.push_str(&format!("note: {note}\n"));
228 }
229 if result.passed {
230 out.push_str("GATE PASS — no accuracy regression beyond tolerance.\n");
231 } else {
232 out.push_str(&format!(
233 "GATE FAIL — {} regression(s):\n",
234 result.regressions.len()
235 ));
236 for r in &result.regressions {
237 out.push_str(&format!(" [{}] {} — {}\n", r.config, r.kind, r.detail));
238 }
239 }
240 if !result.flaky.is_empty() {
241 out.push_str(&format!(
242 "flaky (not failing the gate) — {} case(s) passed in baseline but are now inconsistent:\n",
243 result.flaky.len()
244 ));
245 for f in &result.flaky {
246 out.push_str(&format!(
247 " [{}] {} — pass_rate {:.2}\n",
248 f.config, f.id, f.pass_rate
249 ));
250 }
251 }
252 out
253}
254
255impl Report {
256 pub fn backfill_legacy(&mut self) {
268 for config in &mut self.configs {
269 let mut touched_tags: Vec<String> = Vec::new();
270 for case in &mut config.cases {
271 if case.samples == 0 {
272 case.samples = 1;
273 case.passes = case.pass as u32;
274 case.pass_rate = if case.pass { 1.0 } else { 0.0 };
275 case.cache_hits = 0;
276 case.cache_misses = 1;
277 for tag in &case.tags {
278 if !touched_tags.contains(tag) {
279 touched_tags.push(tag.clone());
280 }
281 }
282 }
283 }
284 for tag in &touched_tags {
285 if let Some(stat) = config.by_tag.get_mut(tag) {
286 stat.pass_rate_sum = config
287 .cases
288 .iter()
289 .filter(|c| c.tags.contains(tag))
290 .map(|c| c.pass_rate)
291 .sum();
292 }
293 }
294 }
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use crate::{CaseResult, ConfigReport, OutputStability, Report, TagStat};
302
303 fn case(id: &str, tag: &str, pass: bool) -> CaseResult {
305 CaseResult {
306 id: id.to_string(),
307 tags: vec![tag.to_string()],
308 samples: 1,
309 passes: if pass { 1 } else { 0 },
310 pass_rate: if pass { 1.0 } else { 0.0 },
311 pass,
312 flaky: false,
313 reason: if pass { "match" } else { "mismatch" }.to_string(),
314 cost: Some(0.0),
315 latency_ms: 100,
316 turns: Some(0),
317 cache_hits: 0,
318 cache_misses: 1,
319 samples_detail: Vec::new(),
320 answer_dist: None,
321 }
322 }
323
324 fn sampled_case(id: &str, tag: &str, passes: u32, samples: u32) -> CaseResult {
327 let pass_rate = passes as f64 / samples as f64;
328 CaseResult {
329 id: id.to_string(),
330 tags: vec![tag.to_string()],
331 samples,
332 passes,
333 pass_rate,
334 pass: passes == samples,
335 flaky: passes > 0 && passes < samples,
336 reason: if passes == samples {
337 "match".to_string()
338 } else {
339 "mismatch".to_string()
340 },
341 cost: Some(0.0),
342 latency_ms: 100,
343 turns: Some(0),
344 cache_hits: 0,
345 cache_misses: samples,
346 samples_detail: Vec::new(),
347 answer_dist: None,
348 }
349 }
350
351 fn config(model: &str, cases: Vec<CaseResult>) -> ConfigReport {
352 let n = cases.len();
353 let pass_rate_sum: f64 = cases.iter().map(|c| c.pass_rate).sum();
354 let flaky_cases = cases.iter().filter(|c| c.flaky).count() as u32;
355 let output_unstable_cases = cases
356 .iter()
357 .filter(|c| {
358 matches!(
359 c.output_stability(),
360 OutputStability::Unstable | OutputStability::Incomplete
361 )
362 })
363 .count() as u32;
364 let mut by_tag: BTreeMap<String, TagStat> = BTreeMap::new();
365 for c in &cases {
366 for t in &c.tags {
367 let e = by_tag.entry(t.clone()).or_insert(TagStat {
368 pass: 0,
369 n: 0,
370 pass_rate_sum: 0.0,
371 });
372 e.n += 1;
373 if c.pass {
374 e.pass += 1;
375 }
376 e.pass_rate_sum += c.pass_rate;
377 }
378 }
379 ConfigReport {
380 model: model.to_string(),
381 n,
382 accuracy: if n > 0 { pass_rate_sum / n as f64 } else { 0.0 },
383 cost_total_usd: Some(0.0),
384 latency_ms_avg: 100,
385 turns_avg: Some(0),
386 cache_hits: 0,
387 cache_misses: 0,
388 flaky_cases,
389 output_unstable_cases,
390 by_tag,
391 cases,
392 }
393 }
394
395 fn report(configs: Vec<ConfigReport>) -> Report {
396 let n = configs.first().map(|c| c.n).unwrap_or(0);
397 Report {
398 dataset: Some("jaffle".into()),
399 context_version: None,
400 context_injection: None,
401 parallel: 1,
402 selected_cases: n,
403 total_cases: n,
404 configs,
405 backend: crate::Backend::default(),
406 }
407 }
408
409 #[test]
410 fn identical_reports_pass() {
411 let base = report(vec![config(
412 "haiku",
413 vec![case("a", "agg", true), case("b", "join", true)],
414 )]);
415 let cur = report(vec![config(
416 "haiku",
417 vec![case("a", "agg", true), case("b", "join", true)],
418 )]);
419 let r = run_gate(&base, &cur, 0.0);
420 assert!(r.passed, "{:?}", r.regressions);
421 }
422
423 #[test]
424 fn improvement_passes() {
425 let base = report(vec![config("haiku", vec![case("a", "agg", false)])]);
426 let cur = report(vec![config("haiku", vec![case("a", "agg", true)])]);
427 assert!(run_gate(&base, &cur, 0.0).passed);
428 }
429
430 #[test]
431 fn a_regressed_case_fails_and_is_named() {
432 let base = report(vec![config(
433 "haiku",
434 vec![case("a", "agg", true), case("b", "join", true)],
435 )]);
436 let cur = report(vec![config(
437 "haiku",
438 vec![case("a", "agg", true), case("b", "join", false)],
439 )]);
440 let r = run_gate(&base, &cur, 0.0);
441 assert!(!r.passed);
442 assert!(r.regressions.iter().any(|x| x.kind == "overall"));
444 assert!(r.regressions.iter().any(|x| x.kind == "tag:join"));
445 assert!(r.regressions.iter().any(|x| x.kind == "case:b"));
446 }
447
448 #[test]
449 fn candidate_only_cases_do_not_change_baseline_coverage() {
450 let base = report(vec![config(
451 "haiku",
452 vec![case("a", "agg", true), case("b", "agg", true)],
453 )]);
454 let cur = report(vec![config(
455 "haiku",
456 vec![
457 case("a", "agg", true),
458 case("b", "agg", true),
459 case("c", "agg", true),
460 case("d", "agg", false),
461 ],
462 )]);
463 assert!(run_gate(&base, &cur, 0.0).passed);
464 }
465
466 #[test]
467 fn aggregate_coverage_respects_tolerance_without_weakening_the_case_hard_line() {
468 let base = report(vec![config(
472 "haiku",
473 vec![
474 case("a", "agg", true),
475 case("b", "agg", true),
476 case("c", "agg", true),
477 case("d", "agg", true),
478 ],
479 )]);
480 let cur = report(vec![config(
481 "haiku",
482 vec![
483 case("a", "agg", true),
484 case("b", "agg", true),
485 case("c", "agg", true),
486 case("d", "agg", false),
487 ],
488 )]);
489
490 let within_tolerance = run_gate(&base, &cur, 0.30);
491 assert!(!within_tolerance.passed);
492 assert!(within_tolerance
493 .regressions
494 .iter()
495 .all(|r| r.kind != "overall" && r.kind != "tag:agg"));
496 assert!(within_tolerance
497 .regressions
498 .iter()
499 .any(|r| r.kind == "case:d"));
500
501 let beyond_tolerance = run_gate(&base, &cur, 0.20);
502 assert!(beyond_tolerance
503 .regressions
504 .iter()
505 .any(|r| r.kind == "overall"));
506 assert!(beyond_tolerance
507 .regressions
508 .iter()
509 .any(|r| r.kind == "tag:agg"));
510 assert!(beyond_tolerance
511 .regressions
512 .iter()
513 .any(|r| r.kind == "case:d"));
514 }
515
516 #[test]
517 fn different_backends_fail_the_gate_unconditionally() {
518 let mut base = report(vec![config("haiku", vec![case("a", "agg", true)])]);
519 let mut cur = report(vec![config("haiku", vec![case("a", "agg", true)])]);
520 base.backend = crate::Backend::ClaudeCodeCli;
521 cur.backend = crate::Backend::ClaudeAgentSdk;
522
523 let r = run_gate(&base, &cur, 1.0);
526 assert!(!r.passed, "cross-backend reports must never gate clean");
527 assert_eq!(r.regressions.len(), 1);
528 assert_eq!(r.regressions[0].kind, "backend");
529 assert!(r.regressions[0].detail.contains("claude-code-cli"));
530 assert!(r.regressions[0].detail.contains("claude-agent-sdk"));
531 }
532
533 #[test]
534 fn missing_config_is_a_note_not_a_crash() {
535 let base = report(vec![config("opus", vec![case("a", "agg", true)])]);
536 let cur = report(vec![config("haiku", vec![case("a", "agg", true)])]);
537 let r = run_gate(&base, &cur, 0.0);
538 assert!(r.passed, "missing config is a note, not a regression");
539 assert!(!r.notes.is_empty());
540 }
541
542 #[test]
543 fn a_flaky_case_is_reported_separately_not_as_a_regression() {
544 let base = report(vec![config("haiku", vec![case("a", "agg", true)])]);
547 let cur = report(vec![config("haiku", vec![sampled_case("a", "agg", 3, 4)])]);
548 let r = run_gate(&base, &cur, 0.0);
549 assert!(
550 r.passed,
551 "a flaky case must not fail the gate: {:?}",
552 r.regressions
553 );
554 assert!(r.regressions.is_empty());
555 assert_eq!(r.flaky.len(), 1);
556 assert_eq!(r.flaky[0].id, "a");
557 assert_eq!(r.flaky[0].config, "haiku");
558 assert!((r.flaky[0].pass_rate - 0.75).abs() < 1e-9);
559 }
560
561 #[test]
562 fn a_partially_passing_baseline_that_drops_to_zero_is_a_regression() {
563 let base = report(vec![config("haiku", vec![sampled_case("a", "agg", 2, 3)])]);
564 let cur = report(vec![config("haiku", vec![sampled_case("a", "agg", 0, 3)])]);
565 let r = run_gate(&base, &cur, 0.0);
566 assert!(!r.passed);
567 assert!(r.regressions.iter().any(|x| x.kind == "overall"));
568 assert!(r.regressions.iter().any(|x| x.kind == "tag:agg"));
569 let case_regression = r
570 .regressions
571 .iter()
572 .find(|x| x.kind == "case:a")
573 .expect("the zero-pass case is named");
574 assert!((case_regression.baseline - 2.0 / 3.0).abs() < 1e-9);
575 assert!(case_regression
576 .detail
577 .contains("non-zero baseline capability"));
578 }
579
580 #[test]
581 fn legacy_report_round_trips_through_backfill() {
582 let legacy = r#"{
587 "dataset": "jaffle",
588 "context_version": null,
589 "parallel": 1,
590 "selected_cases": 2,
591 "total_cases": 2,
592 "configs": [{
593 "model": "haiku",
594 "n": 2,
595 "accuracy": 0.5,
596 "cost_total_usd": 0.2,
597 "latency_ms_avg": 100,
598 "by_tag": { "agg": { "pass": 1, "n": 2 } },
599 "cases": [
600 { "id": "a", "tags": ["agg"], "pass": true, "reason": "match", "cost": 0.1, "latency_ms": 90 },
601 { "id": "b", "tags": ["agg"], "pass": false, "reason": "mismatch", "cost": 0.1, "latency_ms": 110 }
602 ]
603 }]
604 }"#;
605 let mut r: Report = serde_json::from_str(legacy).expect("legacy report deserializes");
606
607 assert_eq!(r.configs[0].cases[0].samples, 0);
609 assert_eq!(r.configs[0].by_tag["agg"].pass_rate_sum, 0.0);
610
611 r.backfill_legacy();
612
613 let cases = &r.configs[0].cases;
614 assert_eq!(cases[0].samples, 1);
615 assert_eq!(cases[0].passes, 1);
616 assert_eq!(cases[0].pass_rate, 1.0);
617 assert_eq!(cases[0].cache_hits, 0);
618 assert_eq!(cases[0].cache_misses, 1);
619 assert!(!cases[0].flaky);
620 assert_eq!(cases[1].samples, 1);
621 assert_eq!(cases[1].passes, 0);
622 assert_eq!(cases[1].pass_rate, 0.0);
623
624 assert_eq!(r.configs[0].by_tag["agg"].pass_rate_sum, 1.0);
626
627 let cur = report(vec![config(
630 "haiku",
631 vec![case("a", "agg", true), case("b", "agg", false)],
632 )]);
633 let gated = run_gate(&r, &cur, 0.0);
634 assert!(gated.passed, "identical (post-backfill) reports gate clean");
635 }
636}