firstpass_core/guardrail.rs
1//! The guardrail: watching the thing that was promised (ADR 0009 D3).
2//!
3//! Firstpass publishes a distribution-free bound on served failures, calibrates it once, and then
4//! trusts it indefinitely. `/metrics` exposes counters and gate error budgets trip individual
5//! gates, but nothing watched the *served-failure rate itself* — the quantity the headline
6//! guarantee is actually about — or reacted when it degraded.
7//!
8//! This does. It computes the same Hoeffding upper confidence bound used to earn the published
9//! guarantee (see [`crate::conformal`]) over a trailing window of resolved outcomes, and when the
10//! bound exceeds the target it demotes the route to observe.
11//!
12//! Three properties matter more than the feature:
13//!
14//! * **It acts on resolved outcomes, not gate verdicts.** A gate verdict is Firstpass's own
15//! opinion. The guarantee is about real downstream results, which arrive via `/v1/feedback`.
16//! Tripping on self-reported verdicts would let the system grade its own homework.
17//! * **A minimum sample size is mandatory.** Without it a route trips on the first two failures
18//! of the day. A bound over 3 samples is noise, and a guardrail that fires on noise is a
19//! guardrail an operator turns off.
20//! * **Demotion is the safe direction.** The failure mode is "you stop saving money", never
21//! "you serve worse answers".
22
23use serde::Deserialize;
24
25/// What a breached guardrail does.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
27#[serde(rename_all = "lowercase")]
28pub enum GuardrailAction {
29 /// Revert the route to observe. Traffic is served exactly as it would be without Firstpass.
30 #[default]
31 Demote,
32 /// Emit the event and change nothing — for operators who want a human in the loop.
33 Alarm,
34}
35
36/// Guardrail settings.
37#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct Guardrail {
40 /// The served-failure target being defended, in `(0, 1)`.
41 pub alpha: f64,
42 /// Confidence parameter for the bound, in `(0, 1)`. Matches the `delta` used to earn the
43 /// published guarantee, so the guardrail defends the same number that was advertised.
44 #[serde(default = "default_delta")]
45 pub delta: f64,
46 /// Trailing window of resolved outcomes to judge over.
47 pub window: usize,
48 /// Never act on fewer resolved samples than this.
49 pub min_n: usize,
50 /// What to do on breach.
51 #[serde(default)]
52 pub action: GuardrailAction,
53}
54
55const fn default_delta() -> f64 {
56 0.05
57}
58
59impl Guardrail {
60 /// Whether this configuration is coherent.
61 ///
62 /// # Errors
63 /// If `alpha`/`delta` are outside `(0, 1)`, the window is zero, or `min_n` exceeds the window
64 /// (which would make the guardrail unable to ever act — a silently inert guard is worse than
65 /// none, because an operator believes they are protected).
66 pub fn validate(&self) -> Result<(), String> {
67 if !self.alpha.is_finite() || self.alpha <= 0.0 || self.alpha >= 1.0 {
68 return Err(format!(
69 "guardrail.alpha must be within (0, 1), got {}",
70 self.alpha
71 ));
72 }
73 if !self.delta.is_finite() || self.delta <= 0.0 || self.delta >= 1.0 {
74 return Err(format!(
75 "guardrail.delta must be within (0, 1), got {}",
76 self.delta
77 ));
78 }
79 if self.window == 0 {
80 return Err("guardrail.window must be > 0".to_owned());
81 }
82 // The slack term alone must leave room under alpha, or a ZERO-failure window breaches
83 // and every healthy route is demoted the moment it reaches min_n. Caught by a test that
84 // fed 500 perfect outcomes and watched the route get demoted anyway.
85 //
86 // bound(rate=0) = sqrt(ln(1/delta) / 2n) < alpha => n > ln(1/delta) / (2*alpha^2)
87 let floor = (1.0 / self.delta).ln() / (2.0 * self.alpha * self.alpha);
88 #[expect(
89 clippy::cast_precision_loss,
90 reason = "min_n is operational scale; f64 is exact far beyond any realistic window"
91 )]
92 let min_n_f = self.min_n as f64;
93 if min_n_f <= floor {
94 let slack = ((1.0 / self.delta).ln() / (2.0 * min_n_f.max(1.0))).sqrt();
95 return Err(format!(
96 "guardrail.min_n ({}) is too small for alpha={} delta={}: with that few samples \
97 the confidence slack alone is {slack:.3}, which already exceeds alpha — so even \
98 a window with zero failures would breach and every healthy route would be \
99 demoted. Use min_n > {:.0}.",
100 self.min_n,
101 self.alpha,
102 self.delta,
103 floor.ceil()
104 ));
105 }
106 if self.min_n > self.window {
107 return Err(format!(
108 "guardrail.min_n ({}) exceeds guardrail.window ({}) — the guardrail could never \
109 act, which is worse than having none because it looks like protection",
110 self.min_n, self.window
111 ));
112 }
113 Ok(())
114 }
115}
116
117/// The verdict of one guardrail evaluation.
118#[derive(Debug, Clone, Copy, PartialEq)]
119pub struct GuardrailVerdict {
120 /// Resolved samples considered.
121 pub n: usize,
122 /// Observed failure rate over the window.
123 pub rate: f64,
124 /// Hoeffding upper confidence bound on that rate.
125 pub bound: f64,
126 /// Whether the bound exceeds the target with enough samples to say so.
127 pub breached: bool,
128 /// Set when the guardrail declined to judge, and why.
129 pub insufficient_samples: bool,
130}
131
132/// Evaluate a trailing window of resolved outcomes. `outcomes` is oldest-first; `true` means the
133/// served answer was correct.
134///
135/// Uses the same bound as [`crate::conformal::calibrate`] — `rate + sqrt(ln(1/delta) / 2n)` — so
136/// the guardrail defends exactly the quantity the published guarantee describes. A guardrail
137/// computing a *different* bound than the advertised one would be defending a number nobody was
138/// promised.
139#[must_use]
140pub fn evaluate(g: &Guardrail, outcomes: &[bool]) -> GuardrailVerdict {
141 let window = outcomes.len().min(g.window);
142 let recent = &outcomes[outcomes.len() - window..];
143 let n = recent.len();
144 if n < g.min_n {
145 return GuardrailVerdict {
146 n,
147 rate: 0.0,
148 bound: 0.0,
149 breached: false,
150 insufficient_samples: true,
151 };
152 }
153 #[expect(
154 clippy::cast_precision_loss,
155 reason = "window sizes are operational scale; f64 is exact well past any realistic window"
156 )]
157 let n_f = n as f64;
158 #[expect(
159 clippy::cast_precision_loss,
160 reason = "failure counts are bounded by the window"
161 )]
162 let fails = recent.iter().filter(|ok| !**ok).count() as f64;
163 let rate = fails / n_f;
164 let slack = (1.0 / g.delta).ln().max(0.0);
165 let bound = rate + (slack / (2.0 * n_f)).sqrt();
166 GuardrailVerdict {
167 n,
168 rate,
169 bound,
170 breached: bound > g.alpha,
171 insufficient_samples: false,
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 fn g(alpha: f64, window: usize, min_n: usize) -> Guardrail {
180 Guardrail {
181 alpha,
182 delta: 0.05,
183 window,
184 min_n,
185 action: GuardrailAction::Demote,
186 }
187 }
188
189 /// Healthy traffic must not trip. A guardrail that fires on a good route gets disabled, and a
190 /// disabled guardrail protects nothing.
191 #[test]
192 fn clean_traffic_does_not_breach() {
193 let outcomes = vec![true; 500];
194 let v = evaluate(&g(0.10, 500, 200), &outcomes);
195 assert!(!v.breached, "perfect traffic breached: bound {}", v.bound);
196 assert!((v.rate - 0.0).abs() < f64::EPSILON);
197 }
198
199 /// Genuinely bad traffic must trip — the whole point.
200 #[test]
201 fn sustained_failure_breaches() {
202 // 30% failures against a 10% target.
203 let outcomes: Vec<bool> = (0..500).map(|i| i % 10 >= 3).collect();
204 let v = evaluate(&g(0.10, 500, 200), &outcomes);
205 assert!(v.breached, "30% failure rate did not breach a 10% target");
206 assert!(v.bound > 0.10);
207 }
208
209 /// The minimum-sample rule is what separates a guardrail from a noise generator: two early
210 /// failures must not demote a route.
211 #[test]
212 fn a_couple_of_early_failures_cannot_trip_it() {
213 let outcomes = vec![false, false, true];
214 let v = evaluate(&g(0.10, 500, 200), &outcomes);
215 assert!(v.insufficient_samples, "judged on 3 samples");
216 assert!(
217 !v.breached,
218 "tripped on 3 samples — 2 bad calls would demote a route"
219 );
220 }
221
222 /// Only the trailing window counts: a route that failed badly last week but is healthy now
223 /// must not stay demoted on ancient history.
224 #[test]
225 fn only_the_trailing_window_is_judged() {
226 let mut outcomes = vec![false; 400]; // ancient failures
227 outcomes.extend(vec![true; 200]); // recent health
228 let v = evaluate(&g(0.10, 200, 150), &outcomes);
229 assert_eq!(v.n, 200);
230 assert!(
231 !v.breached,
232 "old failures outside the window still tripped it"
233 );
234 }
235
236 /// A rate sitting exactly at the target must still breach once the confidence slack is added —
237 /// otherwise the guardrail lets the bound sit above the promised number indefinitely.
238 #[test]
239 fn the_bound_not_the_raw_rate_is_what_is_judged() {
240 // Exactly 10% failures against a 10% target: the raw rate ties, the bound exceeds.
241 let outcomes: Vec<bool> = (0..500).map(|i| i % 10 != 0).collect();
242 let v = evaluate(&g(0.10, 500, 200), &outcomes);
243 assert!((v.rate - 0.10).abs() < 0.01, "rate was {}", v.rate);
244 assert!(
245 v.breached,
246 "raw rate at target did not breach; bound {} must exceed alpha",
247 v.bound
248 );
249 }
250
251 /// More samples must tighten the bound, or the guardrail would grow more trigger-happy with
252 /// evidence rather than less.
253 #[test]
254 fn the_bound_tightens_as_samples_accumulate() {
255 let small: Vec<bool> = (0..200).map(|i| i % 10 != 0).collect();
256 let large: Vec<bool> = (0..5000).map(|i| i % 10 != 0).collect();
257 let vs = evaluate(&g(0.10, 200, 200), &small);
258 let vl = evaluate(&g(0.10, 5000, 200), &large);
259 assert!(
260 vl.bound < vs.bound,
261 "bound did not tighten with evidence: {} samples -> {}, {} samples -> {}",
262 vs.n,
263 vs.bound,
264 vl.n,
265 vl.bound
266 );
267 }
268
269 #[test]
270 fn validate_rejects_incoherent_settings() {
271 assert!(g(0.0, 100, 10).validate().is_err());
272 assert!(g(1.0, 100, 10).validate().is_err());
273 assert!(g(0.1, 0, 0).validate().is_err());
274 // min_n > window: could never act, but looks like protection.
275 assert!(g(0.1, 100, 500).validate().is_err());
276 // min_n too small for the bound to ever clear alpha
277 assert!(g(0.10, 500, 100).validate().is_err());
278 assert!(g(0.1, 500, 400).validate().is_ok());
279 }
280}