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