Skip to main content

warble_eval_runner/
gate.rs

1//! CI gate — the G4 hard line (roadmap Phase 1.4 step 7).
2//!
3//! Compares a candidate eval [`Report`] against a committed baseline and **fails on regression**:
4//! any config whose overall accuracy, per-tag accuracy, or a previously-passing case drops beyond
5//! `tolerance` is reported by name, and the caller turns that into a non-zero exit. This is what
6//! turns "did this profile/context/eval PR make the agent dumber?" from a vibe into a build break.
7//!
8//! Honest bounds (roadmap risk #2): the gate *logic* runs anywhere (locally, pre-push). Wiring it to
9//! an actual CI run needs a remote + secrets + a queryable eval project — see the shipped GitHub
10//! Actions template. Nothing here pretends CI is live.
11
12use crate::Report;
13use serde::Serialize;
14use std::collections::BTreeMap;
15
16/// One regression the gate found, named so a human knows exactly what got worse.
17#[derive(Debug, Clone, Serialize, PartialEq)]
18pub struct Regression {
19    /// The config (tier→model binding label) the regression is in.
20    pub config: String,
21    /// What regressed: `overall`, `tag:<tag>`, or `case:<id>`.
22    pub kind: String,
23    pub baseline: f64,
24    pub current: f64,
25    pub detail: String,
26}
27
28/// A case that passed fully in the baseline but is inconsistent (`0 < pass_rate < 1`) in the
29/// candidate — reported separately from [`Regression`] because it isn't a hard regression: the
30/// case can still pass, just not every time.
31#[derive(Debug, Clone, Serialize, PartialEq)]
32pub struct FlakyCase {
33    pub config: String,
34    pub id: String,
35    pub pass_rate: f64,
36}
37
38/// The gate verdict: `passed` plus every regression and any structural notes.
39#[derive(Debug, Clone, Serialize)]
40pub struct GateResult {
41    pub passed: bool,
42    pub tolerance: f64,
43    pub regressions: Vec<Regression>,
44    /// Cases that flipped from fully-passing to inconsistent — surfaced, but never fails the gate.
45    pub flaky: Vec<FlakyCase>,
46    /// Non-fatal structural observations (e.g. a baseline config absent from the candidate).
47    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
57/// Index a config's cases by id → pass_rate, so case-level regressions/flakiness can be found by id.
58fn 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
66/// Gate a candidate report against a baseline. A metric regresses when
67/// `current < baseline - tolerance`. Configs are matched by their `model` label; a case regresses
68/// when it passed in the baseline but fails or is absent in the candidate.
69pub 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        // Overall accuracy.
84        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        // Per-tag accuracy — pinpoints *which class* of question regressed.
101        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        // Case-level — name every case that used to pass fully and now doesn't. A case that
120        // dropped to a partial pass_rate is flaky (it still passes sometimes), not a hard
121        // regression; only 0.0 (or the case going missing) fails the gate.
122        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 => {} // still fully passing
126                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
158/// Render the gate verdict for humans / CI logs.
159pub 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    /// Migrate a report from before repeated sampling to today's shape. A case with `samples == 0`
194    /// is the legacy sentinel — freshly-produced results always set `samples >= 1` — so such a case
195    /// is backfilled to a single deterministic sample matching its recorded `pass` outcome, and each
196    /// touched tag's `pass_rate_sum` is rebuilt from the (now-backfilled) case pass rates.
197    ///
198    /// One historical distinction is lost in the backfill: whether a legacy case was a cache hit.
199    /// The old schema's single `cache_hit` bool has no home in the new per-case `cache_hits`/
200    /// `cache_misses` counts once it's not in the JSON at all (serde drops unknown fields), so every
201    /// legacy case backfills to `cache_hits: 0, cache_misses: 1` (as if freshly run). This only
202    /// affects cache-visibility reporting on a re-processed old report, never gate correctness —
203    /// the gate never looks at cache counts.
204    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    /// A fully-passing or fully-failing case at `samples == 1` — today's ordinary single-run shape.
241    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    /// A case sampled `samples` times with `passes` of them passing — for exercising the
262    /// flaky (`0 < passes < samples`) path specifically.
263    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        // Both the overall accuracy, the join tag, and the specific case are flagged.
368        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        // 2/2 → 3/4 = 0.75, a 0.25 drop. Tolerance 0.30 forgives it; 0.10 does not.
376        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        // Baseline: fully passing at samples == 1 (today's ordinary case). Candidate: same case
405        // now sampled 4x, passing 3 of them — inconsistent, but still passes most of the time.
406        // Tolerance 0.30 absorbs the resulting 0.25 drop in overall/tag accuracy so only the
407        // case-level classification is under test here.
408        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        // Hand-written JSON shaped like a report from before repeated sampling: no samples/passes/
426        // pass_rate/flaky/cache_hits/cache_misses/samples_detail/answer_dist on the case, no
427        // pass_rate_sum on the tag stat, no flaky_cases on the config. Every one of those is
428        // `#[serde(default)]`, so it still deserializes — with the `samples == 0` sentinel.
429        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        // Pre-backfill: the sentinel is visible.
451        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        // by_tag.pass_rate_sum is rebuilt from the (now-backfilled) case pass rates: 1.0 + 0.0.
468        assert_eq!(r.configs[0].by_tag["agg"].pass_rate_sum, 1.0);
469
470        // And the gate can now run its pass_rate-based comparisons against this migrated report
471        // without panicking or misclassifying anything.
472        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}