1use std::collections::HashMap;
14use std::collections::VecDeque;
15use std::sync::Mutex;
16
17use firstpass_core::guardrail::{Guardrail, GuardrailAction, GuardrailVerdict, evaluate};
18
19#[derive(Debug, Clone, PartialEq)]
21pub enum Reaction {
22 None,
24 Demoted(GuardrailVerdict),
26 Alarmed(GuardrailVerdict),
28}
29
30#[derive(Debug, Default)]
31struct RouteState {
32 outcomes: VecDeque<bool>,
33 demoted_until: Option<i64>,
35}
36
37#[derive(Debug, Default)]
39pub struct GuardrailRegistry {
40 inner: Mutex<HashMap<(String, usize), RouteState>>,
41}
42
43impl GuardrailRegistry {
44 #[must_use]
46 pub fn new() -> Self {
47 Self::default()
48 }
49
50 #[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 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 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 st.outcomes.clear();
102 Reaction::Demoted(verdict)
103 }
104 GuardrailAction::Alarm => Reaction::Alarmed(verdict),
105 }
106 }
107
108 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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; pooled.record("t", 0, &wide, failing, 1_000, 3_600);
301 pooled.record("t", 0, &wide, true, 1_000, 3_600);
302 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}