Skip to main content

firstpass_proxy/
guard.rs

1//! Guardrail state: outcome windows, demotion, and cooldown (ADR 0009 D3).
2//!
3//! [`firstpass_core::guardrail`] decides *whether* a window has breached. This holds the state
4//! that decision runs against: a trailing window of resolved outcomes per (tenant, route), and
5//! whether that route is currently demoted.
6//!
7//! Demotion is deliberately sticky. A guardrail that re-promotes the moment its window recovers
8//! oscillates, and every oscillation is a visible behaviour change for real users — models
9//! switching under them for reasons nobody can explain. So a demoted route stays demoted for a
10//! cooldown, and re-promotion is a separate, conservative decision rather than an automatic
11//! consequence of one good window.
12
13use std::collections::HashMap;
14use std::collections::VecDeque;
15use std::sync::Mutex;
16
17use firstpass_core::guardrail::{Guardrail, GuardrailAction, GuardrailVerdict, evaluate};
18
19/// What happened when an outcome was recorded.
20#[derive(Debug, Clone, PartialEq)]
21pub enum Reaction {
22    /// Nothing to do.
23    None,
24    /// The route just breached and has been demoted to observe.
25    Demoted(GuardrailVerdict),
26    /// The route just breached and the operator asked only to be told.
27    Alarmed(GuardrailVerdict),
28}
29
30#[derive(Debug, Default)]
31struct RouteState {
32    outcomes: VecDeque<bool>,
33    /// Unix seconds until which this route stays demoted.
34    demoted_until: Option<i64>,
35}
36
37/// Per-(tenant, route) guardrail state.
38#[derive(Debug, Default)]
39pub struct GuardrailRegistry {
40    inner: Mutex<HashMap<(String, usize), RouteState>>,
41}
42
43impl GuardrailRegistry {
44    /// A fresh registry.
45    #[must_use]
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    /// Whether this route is currently demoted and must take the observe path.
51    ///
52    /// Fails **closed toward safety**: if the lock is poisoned we report "not demoted" rather than
53    /// demoting everything, because a panic in bookkeeping should not silently switch a whole
54    /// deployment's routing. The breach will be re-detected on the next resolved outcome.
55    #[must_use]
56    pub fn is_demoted(&self, tenant: &str, route_ix: usize, now_secs: i64) -> bool {
57        self.inner.lock().is_ok_and(|g| {
58            g.get(&(tenant.to_owned(), route_ix))
59                .and_then(|s| s.demoted_until)
60                .is_some_and(|until| now_secs < until)
61        })
62    }
63
64    /// Record one resolved outcome and react if the window has breached.
65    ///
66    /// `was_correct` must come from a real downstream result (the feedback API), not from a gate
67    /// verdict — otherwise the system grades its own homework.
68    pub fn record(
69        &self,
70        tenant: &str,
71        route_ix: usize,
72        cfg: &Guardrail,
73        was_correct: bool,
74        now_secs: i64,
75        cooldown_secs: i64,
76    ) -> Reaction {
77        let Ok(mut g) = self.inner.lock() else {
78            return Reaction::None;
79        };
80        let st = g.entry((tenant.to_owned(), route_ix)).or_default();
81        st.outcomes.push_back(was_correct);
82        while st.outcomes.len() > cfg.window {
83            st.outcomes.pop_front();
84        }
85        // Already demoted: keep accumulating evidence, but do not re-fire. Re-promotion is a
86        // separate decision, not a side effect of the cooldown lapsing mid-window.
87        if st.demoted_until.is_some_and(|u| now_secs < u) {
88            return Reaction::None;
89        }
90        let window: Vec<bool> = st.outcomes.iter().copied().collect();
91        let verdict = evaluate(cfg, &window);
92        if !verdict.breached {
93            return Reaction::None;
94        }
95        match cfg.action {
96            GuardrailAction::Demote => {
97                st.demoted_until = Some(now_secs + cooldown_secs);
98                // Clear the window on demotion. Keeping it would leave the route re-breaching
99                // instantly on the first outcome after cooldown, using evidence from a regime that
100                // is no longer in effect.
101                st.outcomes.clear();
102                Reaction::Demoted(verdict)
103            }
104            GuardrailAction::Alarm => Reaction::Alarmed(verdict),
105        }
106    }
107
108    /// Clear a demotion — the manual reset an operator reaches for after fixing the cause.
109    pub fn reset(&self, tenant: &str, route_ix: usize) {
110        if let Ok(mut g) = self.inner.lock()
111            && let Some(st) = g.get_mut(&(tenant.to_owned(), route_ix))
112        {
113            st.demoted_until = None;
114            st.outcomes.clear();
115        }
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    fn cfg(action: GuardrailAction) -> Guardrail {
124        Guardrail {
125            alpha: 0.10,
126            delta: 0.05,
127            window: 400,
128            min_n: 200,
129            action,
130        }
131    }
132
133    fn feed(
134        r: &GuardrailRegistry,
135        c: &Guardrail,
136        oks: impl Iterator<Item = bool>,
137    ) -> Vec<Reaction> {
138        oks.map(|ok| r.record("t", 0, c, ok, 1_000, 3_600))
139            .filter(|x| !matches!(x, Reaction::None))
140            .collect()
141    }
142
143    /// A healthy route is never demoted, and stays servable.
144    #[test]
145    fn healthy_traffic_is_never_demoted() {
146        let r = GuardrailRegistry::new();
147        let c = cfg(GuardrailAction::Demote);
148        let fired = feed(&r, &c, std::iter::repeat_n(true, 500));
149        assert!(fired.is_empty(), "healthy route fired: {fired:?}");
150        assert!(!r.is_demoted("t", 0, 1_000));
151    }
152
153    /// A genuinely failing route is demoted — and demotion is what makes the failure mode
154    /// "stops saving money" rather than "keeps serving bad answers".
155    #[test]
156    fn sustained_failure_demotes_the_route() {
157        let r = GuardrailRegistry::new();
158        let c = cfg(GuardrailAction::Demote);
159        let fired = feed(&r, &c, (0..600).map(|i| i % 10 >= 3));
160        assert!(
161            matches!(fired.first(), Some(Reaction::Demoted(_))),
162            "failing route was not demoted: {fired:?}"
163        );
164        assert!(r.is_demoted("t", 0, 1_000), "route not marked demoted");
165    }
166
167    /// Under `alarm` the route must keep serving — an operator who asked to be told must not have
168    /// their traffic rerouted behind their back.
169    #[test]
170    fn alarm_action_reports_without_changing_routing() {
171        let r = GuardrailRegistry::new();
172        let c = cfg(GuardrailAction::Alarm);
173        let fired = feed(&r, &c, (0..600).map(|i| i % 10 >= 3));
174        assert!(
175            matches!(fired.first(), Some(Reaction::Alarmed(_))),
176            "alarm action did not alarm: {fired:?}"
177        );
178        assert!(
179            !r.is_demoted("t", 0, 1_000),
180            "alarm action rerouted traffic — it must only report"
181        );
182    }
183
184    /// Demotion must be sticky for the cooldown. Flapping is a visible behaviour change for users
185    /// every time it happens.
186    #[test]
187    fn demotion_is_sticky_for_the_cooldown_then_lapses() {
188        let r = GuardrailRegistry::new();
189        let c = cfg(GuardrailAction::Demote);
190        for i in 0..600 {
191            r.record("t", 0, &c, i % 10 >= 3, 1_000, 3_600);
192        }
193        assert!(r.is_demoted("t", 0, 1_000), "not demoted at t=1000");
194        assert!(
195            r.is_demoted("t", 0, 4_500),
196            "lapsed before the cooldown expired"
197        );
198        assert!(
199            !r.is_demoted("t", 0, 4_601),
200            "still demoted after the cooldown"
201        );
202    }
203
204    /// A demoted route must not re-fire on every subsequent outcome — one breach is one event.
205    #[test]
206    fn a_demoted_route_does_not_re_fire() {
207        let r = GuardrailRegistry::new();
208        let c = cfg(GuardrailAction::Demote);
209        let fired = feed(&r, &c, (0..600).map(|i| i % 10 >= 3));
210        assert_eq!(fired.len(), 1, "breach fired more than once: {fired:?}");
211    }
212
213    /// One tenant's failures must not demote another's route.
214    #[test]
215    fn demotion_is_scoped_per_tenant_and_route() {
216        let r = GuardrailRegistry::new();
217        let c = cfg(GuardrailAction::Demote);
218        for i in 0..600 {
219            r.record("noisy", 0, &c, i % 10 >= 3, 1_000, 3_600);
220        }
221        assert!(r.is_demoted("noisy", 0, 1_000));
222        assert!(
223            !r.is_demoted("quiet", 0, 1_000),
224            "another tenant was demoted"
225        );
226        assert!(
227            !r.is_demoted("noisy", 1, 1_000),
228            "another route was demoted"
229        );
230    }
231
232    /// The manual reset is what an operator uses after fixing the cause.
233    #[test]
234    fn reset_clears_a_demotion() {
235        let r = GuardrailRegistry::new();
236        let c = cfg(GuardrailAction::Demote);
237        for i in 0..600 {
238            r.record("t", 0, &c, i % 10 >= 3, 1_000, 3_600);
239        }
240        assert!(r.is_demoted("t", 0, 1_000));
241        r.reset("t", 0);
242        assert!(
243            !r.is_demoted("t", 0, 1_000),
244            "reset did not clear the demotion"
245        );
246    }
247
248    /// The reason route attribution exists: a failing route must not hide behind a healthy
249    /// sibling. Before outcomes carried their route, every tenant's feedback pooled onto route 0,
250    /// so a config with one bad route and one good one could stay green while the bad one
251    /// degraded — the guard sitting quiet exactly when it was needed.
252    #[test]
253    fn a_failing_route_is_demoted_without_dragging_down_a_healthy_sibling() {
254        let r = GuardrailRegistry::new();
255        let c = cfg(GuardrailAction::Demote);
256        // Route 0 fails hard; route 1 is clean. Interleaved, as real traffic would arrive.
257        for i in 0..600 {
258            r.record("t", 0, &c, i % 10 >= 3, 1_000, 3_600);
259            r.record("t", 1, &c, true, 1_000, 3_600);
260        }
261        assert!(
262            r.is_demoted("t", 0, 1_000),
263            "the failing route was not demoted"
264        );
265        assert!(
266            !r.is_demoted("t", 1, 1_000),
267            "a healthy route was demoted by its sibling's failures — attribution is not working"
268        );
269    }
270
271    /// The mirror, and the concrete reason this fix matters: pooled onto one bucket, a healthy
272    /// sibling's successes can dilute a failing route below the target so the guardrail never
273    /// fires.
274    ///
275    /// The arithmetic, spelled out because the effect depends on it (delta=0.05, alpha=0.10).
276    /// Note the guardrail evaluates on EVERY outcome, so what matters is the bound at `min_n`,
277    /// where the slack is widest — not at the full window:
278    ///   min_n=800 -> slack 0.043
279    ///   attributed: rate 0.10 + 0.043 = 0.143  -> breaches
280    ///   pooled:     rate 0.05 + 0.043 = 0.093  -> does NOT breach
281    ///
282    /// Two earlier attempts at this test were wrong and both taught something. A 30% rate halves
283    /// to 15% and still breaches — dilution only masks when it pulls the bound under alpha. And
284    /// min_n=400 gives slack 0.061, so even the diluted 5% breached at the moment judging began:
285    /// staying under needs n > ln(1/delta) / (2*(alpha-rate)^2), which is 600 here.
286    #[test]
287    fn a_healthy_sibling_can_mask_a_failing_route_when_outcomes_are_pooled() {
288        let wide = Guardrail {
289            alpha: 0.10,
290            delta: 0.05,
291            window: 4000,
292            min_n: 800,
293            action: GuardrailAction::Demote,
294        };
295        let pooled = GuardrailRegistry::new();
296        let attributed = GuardrailRegistry::new();
297        for i in 0..2000 {
298            let failing = i % 10 != 0; // 10% failures on the bad route
299            // Pre-fix behaviour: both streams land on route 0.
300            pooled.record("t", 0, &wide, failing, 1_000, 3_600);
301            pooled.record("t", 0, &wide, true, 1_000, 3_600);
302            // With attribution: each stream to the route that produced it.
303            attributed.record("t", 0, &wide, failing, 1_000, 3_600);
304            attributed.record("t", 1, &wide, true, 1_000, 3_600);
305        }
306        assert!(
307            attributed.is_demoted("t", 0, 1_000),
308            "attributed: the failing route must be caught"
309        );
310        assert!(
311            !attributed.is_demoted("t", 1, 1_000),
312            "attributed: the healthy sibling must be left alone"
313        );
314        assert!(
315            !pooled.is_demoted("t", 0, 1_000),
316            "pooling was expected to mask this failure — that masking is exactly what route \
317             attribution fixes, so if this fires the demonstration needs new numbers"
318        );
319    }
320}