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 previously-capable case that drops to zero pass rate is reported by name, with overall and
5//! per-tag capability coverage pinpointing the affected area. A partial pass remains flaky rather
6//! than becoming an aggregate hard failure. This is what turns "did this profile/context/eval PR
7//! make the agent dumber?" from a vibe into a build break.
8//!
9//! Honest bounds (roadmap risk #2): the gate *logic* runs anywhere (locally, pre-push). Wiring it to
10//! an actual CI run needs a remote + secrets + a queryable eval project — see the shipped GitHub
11//! Actions template. Nothing here pretends CI is live.
12
13use crate::Report;
14use serde::Serialize;
15use std::collections::BTreeMap;
16
17/// One regression the gate found, named so a human knows exactly what got worse.
18#[derive(Debug, Clone, Serialize, PartialEq)]
19pub struct Regression {
20    /// The config (tier→model binding label) the regression is in.
21    pub config: String,
22    /// What regressed: `overall`, `tag:<tag>`, or `case:<id>`.
23    pub kind: String,
24    pub baseline: f64,
25    pub current: f64,
26    pub detail: String,
27}
28
29/// A case that passed fully in the baseline but is inconsistent (`0 < pass_rate < 1`) in the
30/// candidate — reported separately from [`Regression`] because it isn't a hard regression: the
31/// case can still pass, just not every time.
32#[derive(Debug, Clone, Serialize, PartialEq)]
33pub struct FlakyCase {
34    pub config: String,
35    pub id: String,
36    pub pass_rate: f64,
37}
38
39/// The gate verdict: `passed` plus every regression and any structural notes.
40#[derive(Debug, Clone, Serialize)]
41pub struct GateResult {
42    pub passed: bool,
43    pub tolerance: f64,
44    pub regressions: Vec<Regression>,
45    /// Cases that flipped from fully-passing to inconsistent — surfaced, but never fails the gate.
46    pub flaky: Vec<FlakyCase>,
47    /// Non-fatal structural observations (e.g. a baseline config absent from the candidate).
48    pub notes: Vec<String>,
49}
50
51/// Index a config's cases by id → pass_rate, so case-level regressions/flakiness can be found by id.
52fn 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
60/// Compare non-zero pass coverage over the baseline's case universe.
61///
62/// Repeated sampling deliberately treats `0 < pass_rate < 1` as flaky rather than a hard
63/// regression. Using the report's mean pass rate here would let that same partial pass fail
64/// indirectly through the overall or tag aggregate. Coverage therefore answers the gate's hard
65/// question instead: "for how many previously tracked cases can this config still succeed at
66/// least once?" Candidate-only cases do not change the comparison.
67fn 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
95/// Gate a candidate report against a baseline. A metric regresses when
96/// `current < baseline - tolerance`. Configs are matched by their `model` label; a case regresses
97/// when it passed in the baseline but fails or is absent in the candidate.
98///
99/// A baseline and candidate recorded against different [`crate::Backend`]s are never compared: a
100/// dropped case there could mean "the profile got worse" or just "this back-end's capability
101/// envelope differs from that one" — those aren't the same finding, and conflating them would make
102/// the gate lie about what regressed. This check is unconditional (no tolerance applies) and short-
103/// circuits before any per-config comparison runs.
104pub 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        // Overall non-zero pass coverage. A partial candidate pass stays flaky and cannot fail
138        // indirectly through this aggregate; a drop to zero still reduces coverage.
139        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        // Per-tag non-zero pass coverage — pinpoints *which class* lost all capability without
157        // turning a partial/flaky pass into a hard failure.
158        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        // Case-level — name every case that had non-zero baseline capability and now has none.
175        // A fully-passing baseline case that drops to a partial pass_rate is flaky (it still
176        // passes sometimes), not a hard regression; only 0.0 (or the case going missing) fails
177        // the gate.
178        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 => {} // fully passing (or improved to fully passing)
182                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 => {} // baseline and candidate are both partially capable
188                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
221/// Render the gate verdict for humans / CI logs.
222pub 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    /// Migrate a report from before repeated sampling to today's shape. A case with `samples == 0`
257    /// is the legacy sentinel — freshly-produced results always set `samples >= 1` — so such a case
258    /// is backfilled to a single deterministic sample matching its recorded `pass` outcome, and each
259    /// touched tag's `pass_rate_sum` is rebuilt from the (now-backfilled) case pass rates.
260    ///
261    /// One historical distinction is lost in the backfill: whether a legacy case was a cache hit.
262    /// The old schema's single `cache_hit` bool has no home in the new per-case `cache_hits`/
263    /// `cache_misses` counts once it's not in the JSON at all (serde drops unknown fields), so every
264    /// legacy case backfills to `cache_hits: 0, cache_misses: 1` (as if freshly run). This only
265    /// affects cache-visibility reporting on a re-processed old report, never gate correctness —
266    /// the gate never looks at cache counts.
267    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    /// A fully-passing or fully-failing case at `samples == 1` — today's ordinary single-run shape.
304    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    /// A case sampled `samples` times with `passes` of them passing — for exercising the
325    /// flaky (`0 < passes < samples`) path specifically.
326    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        // Both the overall accuracy, the join tag, and the specific case are flagged.
443        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        // One of four baseline cases loses all capability: aggregate coverage drops by 0.25.
469        // Tolerance may suppress that aggregate signal, but the named zero-pass case remains an
470        // unconditional hard regression.
471        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        // Even at maximal tolerance, and with an otherwise-identical report, a backend mismatch
524        // is a hard fail — the tolerance parameter never applies to it.
525        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        // Baseline: fully passing at samples == 1 (today's ordinary case). Candidate: same case
545        // now sampled 4x, passing 3 of them — inconsistent, but still passes most of the time.
546        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        // Hand-written JSON shaped like a report from before repeated sampling: no samples/passes/
583        // pass_rate/flaky/cache_hits/cache_misses/samples_detail/answer_dist on the case, no
584        // pass_rate_sum on the tag stat, no flaky_cases on the config. Every one of those is
585        // `#[serde(default)]`, so it still deserializes — with the `samples == 0` sentinel.
586        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        // Pre-backfill: the sentinel is visible.
608        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        // by_tag.pass_rate_sum is rebuilt from the (now-backfilled) case pass rates: 1.0 + 0.0.
625        assert_eq!(r.configs[0].by_tag["agg"].pass_rate_sum, 1.0);
626
627        // And the gate can now run its pass_rate-based comparisons against this migrated report
628        // without panicking or misclassifying anything.
629        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}