use serde::Deserialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum GuardrailAction {
#[default]
Demote,
Alarm,
}
#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Guardrail {
pub alpha: f64,
#[serde(default = "default_delta")]
pub delta: f64,
pub window: usize,
pub min_n: usize,
#[serde(default)]
pub action: GuardrailAction,
}
const fn default_delta() -> f64 {
0.05
}
impl Guardrail {
pub fn validate(&self) -> Result<(), String> {
if !self.alpha.is_finite() || self.alpha <= 0.0 || self.alpha >= 1.0 {
return Err(format!(
"guardrail.alpha must be within (0, 1), got {}",
self.alpha
));
}
if !self.delta.is_finite() || self.delta <= 0.0 || self.delta >= 1.0 {
return Err(format!(
"guardrail.delta must be within (0, 1), got {}",
self.delta
));
}
if self.window == 0 {
return Err("guardrail.window must be > 0".to_owned());
}
let floor = (1.0 / self.delta).ln() / (2.0 * self.alpha * self.alpha);
#[expect(
clippy::cast_precision_loss,
reason = "min_n is operational scale; f64 is exact far beyond any realistic window"
)]
let min_n_f = self.min_n as f64;
if min_n_f <= floor {
let slack = ((1.0 / self.delta).ln() / (2.0 * min_n_f.max(1.0))).sqrt();
return Err(format!(
"guardrail.min_n ({}) is too small for alpha={} delta={}: with that few samples \
the confidence slack alone is {slack:.3}, which already exceeds alpha — so even \
a window with zero failures would breach and every healthy route would be \
demoted. Use min_n > {:.0}.",
self.min_n,
self.alpha,
self.delta,
floor.ceil()
));
}
if self.min_n > self.window {
return Err(format!(
"guardrail.min_n ({}) exceeds guardrail.window ({}) — the guardrail could never \
act, which is worse than having none because it looks like protection",
self.min_n, self.window
));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GuardrailVerdict {
pub n: usize,
pub rate: f64,
pub bound: f64,
pub breached: bool,
pub insufficient_samples: bool,
}
#[must_use]
pub fn evaluate(g: &Guardrail, outcomes: &[bool]) -> GuardrailVerdict {
let window = outcomes.len().min(g.window);
let recent = &outcomes[outcomes.len() - window..];
let n = recent.len();
if n < g.min_n {
return GuardrailVerdict {
n,
rate: 0.0,
bound: 0.0,
breached: false,
insufficient_samples: true,
};
}
#[expect(
clippy::cast_precision_loss,
reason = "window sizes are operational scale; f64 is exact well past any realistic window"
)]
let n_f = n as f64;
#[expect(
clippy::cast_precision_loss,
reason = "failure counts are bounded by the window"
)]
let fails = recent.iter().filter(|ok| !**ok).count() as f64;
let rate = fails / n_f;
let slack = (1.0 / g.delta).ln().max(0.0);
let bound = rate + (slack / (2.0 * n_f)).sqrt();
GuardrailVerdict {
n,
rate,
bound,
breached: bound > g.alpha,
insufficient_samples: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn g(alpha: f64, window: usize, min_n: usize) -> Guardrail {
Guardrail {
alpha,
delta: 0.05,
window,
min_n,
action: GuardrailAction::Demote,
}
}
#[test]
fn clean_traffic_does_not_breach() {
let outcomes = vec![true; 500];
let v = evaluate(&g(0.10, 500, 200), &outcomes);
assert!(!v.breached, "perfect traffic breached: bound {}", v.bound);
assert!((v.rate - 0.0).abs() < f64::EPSILON);
}
#[test]
fn sustained_failure_breaches() {
let outcomes: Vec<bool> = (0..500).map(|i| i % 10 >= 3).collect();
let v = evaluate(&g(0.10, 500, 200), &outcomes);
assert!(v.breached, "30% failure rate did not breach a 10% target");
assert!(v.bound > 0.10);
}
#[test]
fn a_couple_of_early_failures_cannot_trip_it() {
let outcomes = vec![false, false, true];
let v = evaluate(&g(0.10, 500, 200), &outcomes);
assert!(v.insufficient_samples, "judged on 3 samples");
assert!(
!v.breached,
"tripped on 3 samples — 2 bad calls would demote a route"
);
}
#[test]
fn only_the_trailing_window_is_judged() {
let mut outcomes = vec![false; 400]; outcomes.extend(vec![true; 200]); let v = evaluate(&g(0.10, 200, 150), &outcomes);
assert_eq!(v.n, 200);
assert!(
!v.breached,
"old failures outside the window still tripped it"
);
}
#[test]
fn the_bound_not_the_raw_rate_is_what_is_judged() {
let outcomes: Vec<bool> = (0..500).map(|i| i % 10 != 0).collect();
let v = evaluate(&g(0.10, 500, 200), &outcomes);
assert!((v.rate - 0.10).abs() < 0.01, "rate was {}", v.rate);
assert!(
v.breached,
"raw rate at target did not breach; bound {} must exceed alpha",
v.bound
);
}
#[test]
fn the_bound_tightens_as_samples_accumulate() {
let small: Vec<bool> = (0..200).map(|i| i % 10 != 0).collect();
let large: Vec<bool> = (0..5000).map(|i| i % 10 != 0).collect();
let vs = evaluate(&g(0.10, 200, 200), &small);
let vl = evaluate(&g(0.10, 5000, 200), &large);
assert!(
vl.bound < vs.bound,
"bound did not tighten with evidence: {} samples -> {}, {} samples -> {}",
vs.n,
vs.bound,
vl.n,
vl.bound
);
}
#[test]
fn validate_rejects_incoherent_settings() {
assert!(g(0.0, 100, 10).validate().is_err());
assert!(g(1.0, 100, 10).validate().is_err());
assert!(g(0.1, 0, 0).validate().is_err());
assert!(g(0.1, 100, 500).validate().is_err());
assert!(g(0.10, 500, 100).validate().is_err());
assert!(g(0.1, 500, 400).validate().is_ok());
}
}