firstpass_core/conformal.rs
1//! Split-conformal risk control on the gate threshold (SPEC §10.1).
2//!
3//! The standing critique of every cascade is that its deferral threshold is a hand-tuned
4//! hyperparameter with no guarantee. We replace that with a calibrated one: given a held-out set
5//! of `(gate_score, was_correct)` pairs, choose the *lowest* score threshold `λ` such that serving
6//! everything scoring `≥ λ` has a failure rate whose finite-sample upper confidence bound is `≤ α`.
7//! Serving on `score ≥ λ` then carries a distribution-free guarantee: **served-failure rate ≤ α**
8//! at confidence `1 − δ`. The bound is Hoeffding's, so it is conservative — it never *under*-covers.
9
10use serde::Serialize;
11
12/// The result of calibrating a conformal threshold.
13#[derive(Debug, Clone, Serialize)]
14pub struct ConformalResult {
15 /// Target served-failure rate.
16 pub alpha: f64,
17 /// Confidence parameter (bound holds with probability ≥ 1 − δ).
18 pub delta: f64,
19 /// Chosen score threshold `λ`; serve iff `score ≥ λ`.
20 pub threshold: f64,
21 /// Fraction of the calibration set that would be served at this threshold.
22 pub served_frac: f64,
23 /// Empirical failure rate among served calibration items.
24 pub calib_risk: f64,
25 /// Whether any threshold met the target (false ⇒ serve-nothing, target infeasible on this data).
26 pub feasible: bool,
27}
28
29/// Calibrate a conformal serving threshold.
30///
31/// `pairs` are `(score, correct)`; `alpha` is the target served-failure rate; `delta` the
32/// confidence parameter. `min_n` guards against certifying a bound on too few served items.
33#[must_use]
34pub fn calibrate(pairs: &[(f64, bool)], alpha: f64, delta: f64, min_n: usize) -> ConformalResult {
35 // Sort by score descending; sweeping downward grows the served set monotonically.
36 let mut sorted: Vec<(f64, bool)> = pairs.to_vec();
37 sorted.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
38
39 let slack = (f64::ln(1.0 / delta) / 2.0).sqrt(); // Hoeffding: + sqrt(ln(1/δ)/(2n))
40 let mut fails = 0usize;
41 let mut best: Option<(f64, usize, usize)> = None; // (threshold, served, fails)
42
43 for (i, (score, correct)) in sorted.iter().enumerate() {
44 if !*correct {
45 fails += 1;
46 }
47 let served = i + 1;
48 if served < min_n {
49 continue;
50 }
51 let rate = fails as f64 / served as f64;
52 let ucb = rate + slack / (served as f64).sqrt();
53 if ucb <= alpha {
54 // Served grows as we descend, so the last satisfying point is the max-coverage one.
55 best = Some((*score, served, fails));
56 }
57 }
58
59 match best {
60 Some((threshold, served, fails)) => ConformalResult {
61 alpha,
62 delta,
63 threshold,
64 served_frac: served as f64 / pairs.len().max(1) as f64,
65 calib_risk: fails as f64 / served as f64,
66 feasible: true,
67 },
68 None => ConformalResult {
69 alpha,
70 delta,
71 threshold: f64::INFINITY, // serve nothing — target infeasible on this data
72 served_frac: 0.0,
73 calib_risk: 0.0,
74 feasible: false,
75 },
76 }
77}
78
79/// Empirical served-failure rate on a fresh set at a given threshold (for validation).
80#[must_use]
81pub fn served_failure_rate(pairs: &[(f64, bool)], threshold: f64) -> (f64, usize) {
82 let served: Vec<bool> = pairs
83 .iter()
84 .filter(|(s, _)| *s >= threshold)
85 .map(|(_, c)| *c)
86 .collect();
87 if served.is_empty() {
88 return (0.0, 0);
89 }
90 let fails = served.iter().filter(|c| !**c).count();
91 (fails as f64 / served.len() as f64, served.len())
92}
93
94/// Online / adaptive conformal — Gibbs & Candès (2021), *Adaptive Conformal Inference Under
95/// Distribution Shift*. [`calibrate`] fixes a threshold ONCE and assumes exchangeability; under real
96/// drift (models change, prompts change, the gate's error rate moves) the realized served-failure
97/// wanders off target. `AdaptiveConformal` instead tracks the serving threshold **online** from
98/// realized outcomes, so the long-run served-failure rate stays at `alpha` as the workload shifts.
99///
100/// This is the "gate that recalibrates itself from live feedback": every deferred verdict nudges the
101/// threshold, so it never drifts too loose (serving junk) or too strict (escalating needlessly). Feed
102/// it the deferred-feedback stream and read [`AdaptiveConformal::threshold`] on the router hot path.
103#[derive(Debug, Clone)]
104pub struct AdaptiveConformal {
105 alpha: f64,
106 gamma: f64,
107 threshold: f64,
108 served: u64,
109 served_fails: u64,
110}
111
112impl AdaptiveConformal {
113 /// `alpha` = target served-failure rate; `gamma` = step size (e.g. 0.01–0.05, larger tracks
114 /// shift faster but noisier); `init_threshold` = starting `λ` (e.g. a [`calibrate`] result).
115 #[must_use]
116 pub fn new(alpha: f64, gamma: f64, init_threshold: f64) -> Self {
117 Self {
118 alpha,
119 gamma,
120 threshold: init_threshold.clamp(0.0, 1.0),
121 served: 0,
122 served_fails: 0,
123 }
124 }
125
126 /// Serve iff `score ≥` the current threshold.
127 #[must_use]
128 pub fn should_serve(&self, score: f64) -> bool {
129 score >= self.threshold
130 }
131
132 /// The current serving threshold `λ_t`.
133 #[must_use]
134 pub fn threshold(&self) -> f64 {
135 self.threshold
136 }
137
138 /// Observe a **served** item's realized correctness (from deferred feedback) and adapt `λ`. The
139 /// ACI update raises `λ` when served errors exceed `alpha` (serve more conservatively) and lowers
140 /// it when they're below (serve more), so realized served-failure converges to `alpha`.
141 pub fn observe_served(&mut self, was_correct: bool) {
142 let err = f64::from(!was_correct);
143 self.threshold = (self.threshold + self.gamma * (err - self.alpha)).clamp(0.0, 1.0);
144 self.served += 1;
145 self.served_fails += u64::from(!was_correct);
146 }
147
148 /// Realized served-failure rate so far (running diagnostic).
149 #[must_use]
150 pub fn realized_served_failure(&self) -> f64 {
151 if self.served == 0 {
152 0.0
153 } else {
154 self.served_fails as f64 / self.served as f64
155 }
156 }
157
158 /// Number of served items observed so far.
159 #[must_use]
160 pub fn served(&self) -> u64 {
161 self.served
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 // ponytail: tiny inline SplitMix64, mirroring firstpass-bench's `sim::hash01` (which this
170 // crate must not depend on) — deterministic, dependency-free draws for the synthetic pairs
171 // below. Keeps the conformal guarantee test self-contained now that it lives in core.
172 fn hash01(seed: u64, a: u64, b: u64) -> f64 {
173 let mut s = seed
174 .wrapping_mul(0xD1B5_4A32_D192_ED03)
175 .wrapping_add(a.wrapping_mul(0x9E37_79B9_7F4A_7C15))
176 .wrapping_add(b.wrapping_mul(0xC2B2_AE3D_27D4_EB4F))
177 .wrapping_add(0x1234_5678_9ABC_DEF0);
178 s = s.wrapping_add(0x9E37_79B9_7F4A_7C15);
179 let mut z = s;
180 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
181 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
182 z ^= z >> 31;
183 (z >> 11) as f64 / (1u64 << 53) as f64
184 }
185
186 /// Produce `(score, correct)` pairs with a gate score correlated with true correctness plus
187 /// noise (correct centers at 0.72, incorrect at 0.30) — the same shape `sim::SimGate` produces
188 /// for a real gate, without pulling in the bench simulation crate.
189 fn pairs(seed: u64, n: usize) -> Vec<(f64, bool)> {
190 (0..n as u64)
191 .map(|id| {
192 let correct = hash01(seed, id, 1) < 0.7;
193 let noise = (hash01(seed ^ 0x00C0_FFEE, id, 2) - 0.5) * 0.4;
194 let base = if correct { 0.72 } else { 0.30 };
195 ((base + noise).clamp(0.0, 1.0), correct)
196 })
197 .collect()
198 }
199
200 #[test]
201 fn guarantee_holds_on_held_out_data() {
202 let alpha = 0.10;
203 let calib = pairs(1, 4000);
204 let test = pairs(2, 4000); // disjoint seed => held-out
205 let r = calibrate(&calib, alpha, 0.05, 50);
206 assert!(r.feasible, "should find a feasible threshold on this data");
207 assert!(
208 r.served_frac > 0.2,
209 "should serve a meaningful fraction, got {}",
210 r.served_frac
211 );
212
213 let (test_risk, n_served) = served_failure_rate(&test, r.threshold);
214 assert!(n_served > 0);
215 // The conformal guarantee: held-out served-failure stays at/under the target (small
216 // tolerance for finite-sample noise; the UCB is conservative so this is comfortable).
217 assert!(
218 test_risk <= alpha + 0.03,
219 "held-out served-failure {test_risk:.3} must be <= alpha {alpha} (+tol)"
220 );
221 }
222
223 #[test]
224 fn infeasible_target_serves_nothing() {
225 // alpha = 0 is unachievable with a noisy gate -> serve nothing rather than lie.
226 let calib = pairs(3, 1000);
227 let r = calibrate(&calib, 0.0, 0.05, 50);
228 assert!(!r.feasible);
229 assert_eq!(r.served_frac, 0.0);
230 }
231
232 #[test]
233 fn adaptive_update_moves_threshold_the_right_way() {
234 let mut a = AdaptiveConformal::new(0.10, 0.05, 0.5);
235 // A served FAILURE raises the threshold (serve more conservatively).
236 a.observe_served(false);
237 assert!(a.threshold() > 0.5);
238 // A served SUCCESS nudges it back down (serve more).
239 let mut b = AdaptiveConformal::new(0.10, 0.05, 0.5);
240 b.observe_served(true);
241 assert!(b.threshold() < 0.5);
242 }
243
244 // Generate a `(score, correct)` stream; after the shift, INCORRECT items score high (the gate
245 // degrades and starts leaking false-accepts past the old threshold).
246 fn shifted(id: u64, shift: bool) -> (f64, bool) {
247 let correct = hash01(42, id, 1) < 0.7;
248 let noise = (hash01(42 ^ 0xBEEF, id, 2) - 0.5) * 0.3;
249 let base = if correct {
250 0.78
251 } else if shift {
252 0.58
253 } else {
254 0.30
255 };
256 ((base + noise).clamp(0.0, 1.0), correct)
257 }
258
259 #[test]
260 fn adaptive_tracks_alpha_under_shift_where_fixed_drifts() {
261 let alpha = 0.10;
262 let n = 6000u64;
263
264 // Fixed threshold calibrated on the pre-shift regime (what a one-shot `calibrate` gives you).
265 let calib: Vec<(f64, bool)> = (0..n).map(|id| shifted(id, false)).collect();
266 let fixed = calibrate(&calib, alpha, 0.05, 50).threshold;
267
268 // Run the FIXED threshold and an ADAPTIVE one over the same post-shift stream.
269 let mut aci = AdaptiveConformal::new(alpha, 0.03, fixed);
270 let (mut fx_served, mut fx_fails) = (0u64, 0u64);
271 let (mut ac_served, mut ac_fails) = (0u64, 0u64);
272 for id in n..(5 * n) {
273 let (score, correct) = shifted(id, true);
274 if score >= fixed {
275 fx_served += 1;
276 fx_fails += u64::from(!correct);
277 }
278 if aci.should_serve(score) {
279 aci.observe_served(correct);
280 ac_served += 1;
281 ac_fails += u64::from(!correct);
282 }
283 }
284 let fixed_rate = fx_fails as f64 / fx_served.max(1) as f64;
285 let aci_rate = ac_fails as f64 / ac_served.max(1) as f64;
286
287 // Under shift the FIXED threshold serves the new false-accepts and drifts above alpha...
288 assert!(
289 fixed_rate > alpha + 0.05,
290 "fixed should drift high under shift, got {fixed_rate:.3}"
291 );
292 // ...while ADAPTIVE re-converges: strictly better than fixed and near the target.
293 assert!(
294 aci_rate < fixed_rate,
295 "adaptive {aci_rate:.3} should beat fixed {fixed_rate:.3}"
296 );
297 assert!(
298 aci_rate <= alpha + 0.06,
299 "adaptive {aci_rate:.3} should track alpha {alpha}"
300 );
301 assert!(ac_served > 0);
302 }
303}