Skip to main content

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}