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}