Skip to main content

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#[cfg(test)]
95mod tests {
96    use super::*;
97
98    // ponytail: tiny inline SplitMix64, mirroring firstpass-bench's `sim::hash01` (which this
99    // crate must not depend on) — deterministic, dependency-free draws for the synthetic pairs
100    // below. Keeps the conformal guarantee test self-contained now that it lives in core.
101    fn hash01(seed: u64, a: u64, b: u64) -> f64 {
102        let mut s = seed
103            .wrapping_mul(0xD1B5_4A32_D192_ED03)
104            .wrapping_add(a.wrapping_mul(0x9E37_79B9_7F4A_7C15))
105            .wrapping_add(b.wrapping_mul(0xC2B2_AE3D_27D4_EB4F))
106            .wrapping_add(0x1234_5678_9ABC_DEF0);
107        s = s.wrapping_add(0x9E37_79B9_7F4A_7C15);
108        let mut z = s;
109        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
110        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
111        z ^= z >> 31;
112        (z >> 11) as f64 / (1u64 << 53) as f64
113    }
114
115    /// Produce `(score, correct)` pairs with a gate score correlated with true correctness plus
116    /// noise (correct centers at 0.72, incorrect at 0.30) — the same shape `sim::SimGate` produces
117    /// for a real gate, without pulling in the bench simulation crate.
118    fn pairs(seed: u64, n: usize) -> Vec<(f64, bool)> {
119        (0..n as u64)
120            .map(|id| {
121                let correct = hash01(seed, id, 1) < 0.7;
122                let noise = (hash01(seed ^ 0x00C0_FFEE, id, 2) - 0.5) * 0.4;
123                let base = if correct { 0.72 } else { 0.30 };
124                ((base + noise).clamp(0.0, 1.0), correct)
125            })
126            .collect()
127    }
128
129    #[test]
130    fn guarantee_holds_on_held_out_data() {
131        let alpha = 0.10;
132        let calib = pairs(1, 4000);
133        let test = pairs(2, 4000); // disjoint seed => held-out
134        let r = calibrate(&calib, alpha, 0.05, 50);
135        assert!(r.feasible, "should find a feasible threshold on this data");
136        assert!(
137            r.served_frac > 0.2,
138            "should serve a meaningful fraction, got {}",
139            r.served_frac
140        );
141
142        let (test_risk, n_served) = served_failure_rate(&test, r.threshold);
143        assert!(n_served > 0);
144        // The conformal guarantee: held-out served-failure stays at/under the target (small
145        // tolerance for finite-sample noise; the UCB is conservative so this is comfortable).
146        assert!(
147            test_risk <= alpha + 0.03,
148            "held-out served-failure {test_risk:.3} must be <= alpha {alpha} (+tol)"
149        );
150    }
151
152    #[test]
153    fn infeasible_target_serves_nothing() {
154        // alpha = 0 is unachievable with a noisy gate -> serve nothing rather than lie.
155        let calib = pairs(3, 1000);
156        let r = calibrate(&calib, 0.0, 0.05, 50);
157        assert!(!r.feasible);
158        assert_eq!(r.served_frac, 0.0);
159    }
160}