1use crate::Report;
13use serde::Serialize;
14use std::collections::BTreeMap;
15
16#[derive(Debug, Clone, Serialize, PartialEq)]
18pub struct Regression {
19 pub config: String,
21 pub kind: String,
23 pub baseline: f64,
24 pub current: f64,
25 pub detail: String,
26}
27
28#[derive(Debug, Clone, Serialize, PartialEq)]
32pub struct FlakyCase {
33 pub config: String,
34 pub id: String,
35 pub pass_rate: f64,
36}
37
38#[derive(Debug, Clone, Serialize)]
40pub struct GateResult {
41 pub passed: bool,
42 pub tolerance: f64,
43 pub regressions: Vec<Regression>,
44 pub flaky: Vec<FlakyCase>,
46 pub notes: Vec<String>,
48}
49
50fn tag_accuracy(config: &crate::ConfigReport, tag: &str) -> Option<f64> {
51 config
52 .by_tag
53 .get(tag)
54 .map(|s| s.pass_rate_sum / s.n.max(1) as f64)
55}
56
57fn case_pass_rate_map(config: &crate::ConfigReport) -> BTreeMap<&str, f64> {
59 config
60 .cases
61 .iter()
62 .map(|c| (c.id.as_str(), c.pass_rate))
63 .collect()
64}
65
66pub fn run_gate(baseline: &Report, current: &Report, tolerance: f64) -> GateResult {
70 let mut regressions = Vec::new();
71 let mut flaky = Vec::new();
72 let mut notes = Vec::new();
73
74 for base_cfg in &baseline.configs {
75 let Some(cur_cfg) = current.configs.iter().find(|c| c.model == base_cfg.model) else {
76 notes.push(format!(
77 "baseline config '{}' has no match in the candidate report (configs changed?)",
78 base_cfg.model
79 ));
80 continue;
81 };
82
83 if cur_cfg.accuracy < base_cfg.accuracy - tolerance {
85 regressions.push(Regression {
86 config: base_cfg.model.clone(),
87 kind: "overall".to_string(),
88 baseline: base_cfg.accuracy,
89 current: cur_cfg.accuracy,
90 detail: format!(
91 "overall accuracy {:.3} → {:.3} (drop {:.3} > tolerance {:.3})",
92 base_cfg.accuracy,
93 cur_cfg.accuracy,
94 base_cfg.accuracy - cur_cfg.accuracy,
95 tolerance
96 ),
97 });
98 }
99
100 for tag in base_cfg.by_tag.keys() {
102 let base_acc = tag_accuracy(base_cfg, tag).unwrap_or(0.0);
103 match tag_accuracy(cur_cfg, tag) {
104 Some(cur_acc) if cur_acc < base_acc - tolerance => regressions.push(Regression {
105 config: base_cfg.model.clone(),
106 kind: format!("tag:{tag}"),
107 baseline: base_acc,
108 current: cur_acc,
109 detail: format!("tag '{tag}' accuracy {base_acc:.3} → {cur_acc:.3}"),
110 }),
111 Some(_) => {}
112 None => notes.push(format!(
113 "config '{}': tag '{tag}' present in baseline but absent in candidate",
114 base_cfg.model
115 )),
116 }
117 }
118
119 let cur_rates = case_pass_rate_map(cur_cfg);
123 for base_case in base_cfg.cases.iter().filter(|c| c.pass) {
124 match cur_rates.get(base_case.id.as_str()).copied() {
125 Some(rate) if rate >= 1.0 => {} Some(rate) if rate > 0.0 => flaky.push(FlakyCase {
127 config: base_cfg.model.clone(),
128 id: base_case.id.clone(),
129 pass_rate: rate,
130 }),
131 Some(rate) => regressions.push(Regression {
132 config: base_cfg.model.clone(),
133 kind: format!("case:{}", base_case.id),
134 baseline: 1.0,
135 current: rate,
136 detail: format!("case '{}' passed in baseline, now fails", base_case.id),
137 }),
138 None => regressions.push(Regression {
139 config: base_cfg.model.clone(),
140 kind: format!("case:{}", base_case.id),
141 baseline: 1.0,
142 current: f64::NAN,
143 detail: format!("case '{}' passed in baseline, now absent", base_case.id),
144 }),
145 }
146 }
147 }
148
149 GateResult {
150 passed: regressions.is_empty(),
151 tolerance,
152 regressions,
153 flaky,
154 notes,
155 }
156}
157
158pub fn format_gate(result: &GateResult) -> String {
160 let mut out = String::new();
161 out.push_str("\n=== Warble eval — CI gate (accuracy regression check) ===\n");
162 out.push_str(&format!("tolerance: {:.3}\n", result.tolerance));
163 for note in &result.notes {
164 out.push_str(&format!("note: {note}\n"));
165 }
166 if result.passed {
167 out.push_str("GATE PASS — no accuracy regression beyond tolerance.\n");
168 } else {
169 out.push_str(&format!(
170 "GATE FAIL — {} regression(s):\n",
171 result.regressions.len()
172 ));
173 for r in &result.regressions {
174 out.push_str(&format!(" [{}] {} — {}\n", r.config, r.kind, r.detail));
175 }
176 }
177 if !result.flaky.is_empty() {
178 out.push_str(&format!(
179 "flaky (not failing the gate) — {} case(s) passed in baseline but are now inconsistent:\n",
180 result.flaky.len()
181 ));
182 for f in &result.flaky {
183 out.push_str(&format!(
184 " [{}] {} — pass_rate {:.2}\n",
185 f.config, f.id, f.pass_rate
186 ));
187 }
188 }
189 out
190}
191
192impl Report {
193 pub fn backfill_legacy(&mut self) {
205 for config in &mut self.configs {
206 let mut touched_tags: Vec<String> = Vec::new();
207 for case in &mut config.cases {
208 if case.samples == 0 {
209 case.samples = 1;
210 case.passes = case.pass as u32;
211 case.pass_rate = if case.pass { 1.0 } else { 0.0 };
212 case.cache_hits = 0;
213 case.cache_misses = 1;
214 for tag in &case.tags {
215 if !touched_tags.contains(tag) {
216 touched_tags.push(tag.clone());
217 }
218 }
219 }
220 }
221 for tag in &touched_tags {
222 if let Some(stat) = config.by_tag.get_mut(tag) {
223 stat.pass_rate_sum = config
224 .cases
225 .iter()
226 .filter(|c| c.tags.contains(tag))
227 .map(|c| c.pass_rate)
228 .sum();
229 }
230 }
231 }
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238 use crate::{CaseResult, ConfigReport, Report, TagStat};
239
240 fn case(id: &str, tag: &str, pass: bool) -> CaseResult {
242 CaseResult {
243 id: id.to_string(),
244 tags: vec![tag.to_string()],
245 samples: 1,
246 passes: if pass { 1 } else { 0 },
247 pass_rate: if pass { 1.0 } else { 0.0 },
248 pass,
249 flaky: false,
250 reason: if pass { "match" } else { "mismatch" }.to_string(),
251 cost: 0.0,
252 latency_ms: 100,
253 turns: 0,
254 cache_hits: 0,
255 cache_misses: 1,
256 samples_detail: Vec::new(),
257 answer_dist: None,
258 }
259 }
260
261 fn sampled_case(id: &str, tag: &str, passes: u32, samples: u32) -> CaseResult {
264 let pass_rate = passes as f64 / samples as f64;
265 CaseResult {
266 id: id.to_string(),
267 tags: vec![tag.to_string()],
268 samples,
269 passes,
270 pass_rate,
271 pass: passes == samples,
272 flaky: passes > 0 && passes < samples,
273 reason: if passes == samples {
274 "match".to_string()
275 } else {
276 "mismatch".to_string()
277 },
278 cost: 0.0,
279 latency_ms: 100,
280 turns: 0,
281 cache_hits: 0,
282 cache_misses: samples,
283 samples_detail: Vec::new(),
284 answer_dist: None,
285 }
286 }
287
288 fn config(model: &str, cases: Vec<CaseResult>) -> ConfigReport {
289 let n = cases.len();
290 let pass_rate_sum: f64 = cases.iter().map(|c| c.pass_rate).sum();
291 let flaky_cases = cases.iter().filter(|c| c.flaky).count() as u32;
292 let mut by_tag: BTreeMap<String, TagStat> = BTreeMap::new();
293 for c in &cases {
294 for t in &c.tags {
295 let e = by_tag.entry(t.clone()).or_insert(TagStat {
296 pass: 0,
297 n: 0,
298 pass_rate_sum: 0.0,
299 });
300 e.n += 1;
301 if c.pass {
302 e.pass += 1;
303 }
304 e.pass_rate_sum += c.pass_rate;
305 }
306 }
307 ConfigReport {
308 model: model.to_string(),
309 n,
310 accuracy: if n > 0 { pass_rate_sum / n as f64 } else { 0.0 },
311 cost_total_usd: 0.0,
312 latency_ms_avg: 100,
313 turns_avg: 0,
314 cache_hits: 0,
315 cache_misses: 0,
316 flaky_cases,
317 by_tag,
318 cases,
319 }
320 }
321
322 fn report(configs: Vec<ConfigReport>) -> Report {
323 let n = configs.first().map(|c| c.n).unwrap_or(0);
324 Report {
325 dataset: Some("jaffle".into()),
326 context_version: None,
327 parallel: 1,
328 selected_cases: n,
329 total_cases: n,
330 configs,
331 }
332 }
333
334 #[test]
335 fn identical_reports_pass() {
336 let base = report(vec![config(
337 "haiku",
338 vec![case("a", "agg", true), case("b", "join", true)],
339 )]);
340 let cur = report(vec![config(
341 "haiku",
342 vec![case("a", "agg", true), case("b", "join", true)],
343 )]);
344 let r = run_gate(&base, &cur, 0.0);
345 assert!(r.passed, "{:?}", r.regressions);
346 }
347
348 #[test]
349 fn improvement_passes() {
350 let base = report(vec![config("haiku", vec![case("a", "agg", false)])]);
351 let cur = report(vec![config("haiku", vec![case("a", "agg", true)])]);
352 assert!(run_gate(&base, &cur, 0.0).passed);
353 }
354
355 #[test]
356 fn a_regressed_case_fails_and_is_named() {
357 let base = report(vec![config(
358 "haiku",
359 vec![case("a", "agg", true), case("b", "join", true)],
360 )]);
361 let cur = report(vec![config(
362 "haiku",
363 vec![case("a", "agg", true), case("b", "join", false)],
364 )]);
365 let r = run_gate(&base, &cur, 0.0);
366 assert!(!r.passed);
367 assert!(r.regressions.iter().any(|x| x.kind == "overall"));
369 assert!(r.regressions.iter().any(|x| x.kind == "tag:join"));
370 assert!(r.regressions.iter().any(|x| x.kind == "case:b"));
371 }
372
373 #[test]
374 fn drop_within_tolerance_passes() {
375 let base = report(vec![config(
377 "haiku",
378 vec![case("a", "agg", true), case("b", "agg", true)],
379 )]);
380 let cur = report(vec![config(
381 "haiku",
382 vec![
383 case("a", "agg", true),
384 case("b", "agg", true),
385 case("c", "agg", true),
386 case("d", "agg", false),
387 ],
388 )]);
389 assert!(run_gate(&base, &cur, 0.30).passed);
390 assert!(!run_gate(&base, &cur, 0.10).passed);
391 }
392
393 #[test]
394 fn missing_config_is_a_note_not_a_crash() {
395 let base = report(vec![config("opus", vec![case("a", "agg", true)])]);
396 let cur = report(vec![config("haiku", vec![case("a", "agg", true)])]);
397 let r = run_gate(&base, &cur, 0.0);
398 assert!(r.passed, "missing config is a note, not a regression");
399 assert!(!r.notes.is_empty());
400 }
401
402 #[test]
403 fn a_flaky_case_is_reported_separately_not_as_a_regression() {
404 let base = report(vec![config("haiku", vec![case("a", "agg", true)])]);
409 let cur = report(vec![config("haiku", vec![sampled_case("a", "agg", 3, 4)])]);
410 let r = run_gate(&base, &cur, 0.30);
411 assert!(
412 r.passed,
413 "a flaky case must not fail the gate: {:?}",
414 r.regressions
415 );
416 assert!(r.regressions.is_empty());
417 assert_eq!(r.flaky.len(), 1);
418 assert_eq!(r.flaky[0].id, "a");
419 assert_eq!(r.flaky[0].config, "haiku");
420 assert!((r.flaky[0].pass_rate - 0.75).abs() < 1e-9);
421 }
422
423 #[test]
424 fn legacy_report_round_trips_through_backfill() {
425 let legacy = r#"{
430 "dataset": "jaffle",
431 "context_version": null,
432 "parallel": 1,
433 "selected_cases": 2,
434 "total_cases": 2,
435 "configs": [{
436 "model": "haiku",
437 "n": 2,
438 "accuracy": 0.5,
439 "cost_total_usd": 0.2,
440 "latency_ms_avg": 100,
441 "by_tag": { "agg": { "pass": 1, "n": 2 } },
442 "cases": [
443 { "id": "a", "tags": ["agg"], "pass": true, "reason": "match", "cost": 0.1, "latency_ms": 90 },
444 { "id": "b", "tags": ["agg"], "pass": false, "reason": "mismatch", "cost": 0.1, "latency_ms": 110 }
445 ]
446 }]
447 }"#;
448 let mut r: Report = serde_json::from_str(legacy).expect("legacy report deserializes");
449
450 assert_eq!(r.configs[0].cases[0].samples, 0);
452 assert_eq!(r.configs[0].by_tag["agg"].pass_rate_sum, 0.0);
453
454 r.backfill_legacy();
455
456 let cases = &r.configs[0].cases;
457 assert_eq!(cases[0].samples, 1);
458 assert_eq!(cases[0].passes, 1);
459 assert_eq!(cases[0].pass_rate, 1.0);
460 assert_eq!(cases[0].cache_hits, 0);
461 assert_eq!(cases[0].cache_misses, 1);
462 assert!(!cases[0].flaky);
463 assert_eq!(cases[1].samples, 1);
464 assert_eq!(cases[1].passes, 0);
465 assert_eq!(cases[1].pass_rate, 0.0);
466
467 assert_eq!(r.configs[0].by_tag["agg"].pass_rate_sum, 1.0);
469
470 let cur = report(vec![config(
473 "haiku",
474 vec![case("a", "agg", true), case("b", "agg", false)],
475 )]);
476 let gated = run_gate(&r, &cur, 0.0);
477 assert!(gated.passed, "identical (post-backfill) reports gate clean");
478 }
479}