Skip to main content

firstpass_core/
ltt.rs

1//! Learn-then-Test (LTT) threshold calibration — distribution-free, finite-sample risk control
2//! on the gate serving threshold (SPEC §10.1).
3//!
4//! ## Method
5//! Angelopoulos et al. (2021), "Learn then Test: Calibrating Predictive Algorithms to Achieve
6//! Risk Control"; Bates et al. (2021), "Testing for Outliers with Conformal P-values" (RCPS).
7//!
8//! The guarantee: the returned threshold λ carries a finite-sample risk certificate —
9//! served-failure rate ≤ α at family-wise confidence 1 − δ — without assuming exchangeability
10//! or a parametric model of the gate's error distribution.
11//!
12//! ## Algorithm
13//! 1. Build candidate thresholds from the observed score grid in descending order (strictest
14//!    first). One candidate per distinct observed score suffices — finer grids do not tighten
15//!    the guarantee (only the binomial test size δ and sample n do).
16//! 2. For each λ compute: the served set (pairs with score ≥ λ), empirical risk
17//!    = n_failures / n_served, and the exact-binomial p-value
18//!    `P(Bin(n_served, α) ≤ n_failures)` testing H₀: risk(λ) > α.
19//!    Reject H₀ (certify λ) when p-value ≤ δ.
20//! 3. Walk candidates in the fixed-sequence order. Stop at the first *qualified* candidate
21//!    (n_served ≥ min_n) whose test fails — subsequent candidates are never certified, even if
22//!    their empirical risk dips below α again. Candidates below min_n are skipped (not in the
23//!    test sequence) and do not break the walk.
24//! 4. Return the last certified λ (least strict, maximum coverage), or `INFINITY` (serve
25//!    nothing) when infeasible.
26//!
27//! ## Why the fixed-sequence walk controls FWER at δ without Bonferroni
28//! A false certification is a false rejection of a true null H₀: risk(λ) > α. In any fixed
29//! pre-specified test sequence the family-wise error rate (FWER = P(≥ 1 false rejection))
30//! equals the probability that the *first* true H₀ encountered in the walk is falsely rejected,
31//! which is at most δ by the individual test level. The stopping rule is the key: once a test
32//! fails the sequence ends, so accumulating multiple false rejections in one run is impossible.
33//! Theorem 2 of Angelopoulos et al. (2021) formalises this for any fixed order, without a
34//! Bonferroni correction factor, provided each individual test is valid at level δ. The
35//! one-sided exact-binomial test used here is conservative (it never over-rejects under H₀), so
36//! the FWER guarantee holds even though all tests share the same calibration set.
37
38use serde::Serialize;
39
40/// Per-λ diagnostic row emitted by [`calibrate`].
41#[derive(Debug, Clone, Serialize)]
42pub struct LttDiagnostic {
43    /// Candidate threshold value.
44    pub lambda: f64,
45    /// Number of calibration items with score ≥ λ (served set size).
46    pub n_served: usize,
47    /// Empirical served-failure rate at this λ (failures / n_served).
48    pub empirical_risk: f64,
49    /// Exact-binomial p-value for H₀: risk(λ) > α.  Small ⇒ H₀ rejected ⇒ λ certified.
50    /// Set to `1.0` for candidates excluded by `min_n` (not in the test sequence).
51    pub p_value: f64,
52    /// Whether this λ was certified by the fixed-sequence walk.
53    pub certified: bool,
54}
55
56/// Result of LTT threshold calibration.
57#[derive(Debug, Clone, Serialize)]
58pub struct LttResult {
59    /// Target served-failure rate.
60    pub alpha: f64,
61    /// Family-wise error rate bound (FWER ≤ δ over the qualified candidate grid).
62    pub delta: f64,
63    /// Chosen threshold `λ`; serve iff `score ≥ λ`.
64    /// `f64::INFINITY` when no threshold is certified (infeasible, serve nothing).
65    pub threshold: f64,
66    /// Whether any threshold was certified.  `false` ⇒ target infeasible on this data.
67    pub feasible: bool,
68    /// Empirical served-failure rate at `threshold` (failures / n_served on the calibration set).
69    pub empirical_risk: f64,
70    /// Served-set size at `threshold`.
71    pub n_served: usize,
72    /// Gate empirical false-accept rate at `threshold`: fraction of INCORRECT calibration items
73    /// with score ≥ λ — the verifier ROC point the threshold is built on.
74    /// `None` when the calibration set contains no incorrect items.
75    pub false_accept_rate: Option<f64>,
76    /// Per-λ diagnostics for every candidate in the grid, strictest to loosest.
77    /// Includes candidates below `min_n` (which appear with `certified: false, p_value: 1.0`).
78    pub diagnostics: Vec<LttDiagnostic>,
79}
80
81/// Calibrate an LTT serving threshold.
82///
83/// `pairs` are `(score, correct)`; `alpha` is the target served-failure rate; `delta` the
84/// family-wise error rate bound; `min_n` guards against certifying on too small a served set.
85/// Candidates below `min_n` are excluded from the test sequence but do not break the walk.
86///
87/// Returns the least-strict certified threshold (maximum coverage), or an infeasible result
88/// (`feasible: false`, `threshold: INFINITY`) when no candidate passes.  The `diagnostics`
89/// field carries the full per-λ grid for operator inspection.
90#[must_use]
91pub fn calibrate(pairs: &[(f64, bool)], alpha: f64, delta: f64, min_n: usize) -> LttResult {
92    if pairs.is_empty() {
93        return mk_infeasible(alpha, delta, Vec::new());
94    }
95
96    // Sort by score descending to enable an O(n) sweep as λ decreases.
97    let mut sorted = pairs.to_vec();
98    sorted.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
99
100    // Candidate thresholds: one per distinct observed score, descending.
101    // ponytail: exact dedup is correct here — scores are calibration values, not computed floats.
102    let candidates: Vec<f64> = {
103        let mut c: Vec<f64> = sorted.iter().map(|(s, _)| *s).collect();
104        c.dedup();
105        c
106    };
107
108    let n_incorrect = sorted.iter().filter(|(_, c)| !c).count();
109
110    let mut diagnostics: Vec<LttDiagnostic> = Vec::with_capacity(candidates.len());
111    // `walk_active`: still in the certified prefix of the fixed-sequence walk.
112    let mut walk_active = true;
113    // Index into `diagnostics` of the last certified λ (the least-strict certified one).
114    let mut best: Option<usize> = None;
115
116    // Running served-set counters (valid because candidates are descending and sorted is sorted).
117    let mut ptr = 0usize;
118    let mut failures = 0usize;
119
120    for &lambda in &candidates {
121        // Advance pointer to include all items with score >= lambda.
122        while ptr < sorted.len() && sorted[ptr].0 >= lambda {
123            if !sorted[ptr].1 {
124                failures += 1;
125            }
126            ptr += 1;
127        }
128        let n_served = ptr;
129        let empirical_risk = if n_served == 0 {
130            0.0
131        } else {
132            failures as f64 / n_served as f64
133        };
134
135        if n_served < min_n {
136            // Below minimum size: skip from the test sequence, do not break the walk.
137            diagnostics.push(LttDiagnostic {
138                lambda,
139                n_served,
140                empirical_risk,
141                p_value: 1.0,
142                certified: false,
143            });
144            continue;
145        }
146
147        // Exact-binomial p-value: P(Bin(n_served, alpha) <= failures) testing H0: risk > alpha.
148        let p_value = binomial_cdf(n_served, failures, alpha);
149        let certified = walk_active && p_value <= delta;
150
151        if walk_active && !certified {
152            // First failure in the qualified walk — no subsequent candidate may be certified.
153            walk_active = false;
154        }
155
156        if certified {
157            best = Some(diagnostics.len()); // index of the item about to be pushed
158        }
159
160        diagnostics.push(LttDiagnostic {
161            lambda,
162            n_served,
163            empirical_risk,
164            p_value,
165            certified,
166        });
167    }
168
169    let Some(best_idx) = best else {
170        return mk_infeasible(alpha, delta, diagnostics);
171    };
172
173    let d = &diagnostics[best_idx];
174    let false_accept_rate = if n_incorrect == 0 {
175        None
176    } else {
177        // Among incorrect items, how many have score >= chosen threshold?
178        let fa = sorted.iter().filter(|(s, c)| !c && *s >= d.lambda).count();
179        Some(fa as f64 / n_incorrect as f64)
180    };
181
182    LttResult {
183        alpha,
184        delta,
185        threshold: d.lambda,
186        feasible: true,
187        empirical_risk: d.empirical_risk,
188        n_served: d.n_served,
189        false_accept_rate,
190        diagnostics,
191    }
192}
193
194fn mk_infeasible(alpha: f64, delta: f64, diagnostics: Vec<LttDiagnostic>) -> LttResult {
195    LttResult {
196        alpha,
197        delta,
198        threshold: f64::INFINITY,
199        feasible: false,
200        empirical_risk: 0.0,
201        n_served: 0,
202        false_accept_rate: None,
203        diagnostics,
204    }
205}
206
207/// Exact binomial CDF: P(X ≤ k) for X ~ Binomial(n, p).
208///
209/// Computed in log-space to avoid underflow at large n.  The per-term recurrence
210/// `log P(X = i) = log P(X = i−1) + log(n−i+1) − log(i) + log(p/(1−p))`
211/// is numerically stable for the sample sizes firstpass operates on (up to ~10 000 pairs).
212/// A log-sum-exp pass accumulates the k+1 terms without cancellation.
213fn binomial_cdf(n: usize, k: usize, p: f64) -> f64 {
214    if p <= 0.0 {
215        // With p=0, X=0 with certainty, so P(X <= k) = 1 for all k >= 0.
216        return 1.0;
217    }
218    if p >= 1.0 {
219        // X = n with certainty.
220        return if k >= n { 1.0 } else { 0.0 };
221    }
222    if k >= n {
223        return 1.0;
224    }
225
226    let log_p = p.ln();
227    let log_q = (1.0 - p).ln();
228    let log_ratio = log_p - log_q; // log(p / (1−p))
229
230    // Seed: log P(X = 0) = n · log(1−p)
231    let mut log_term = n as f64 * log_q;
232    let mut log_terms = Vec::with_capacity(k + 1);
233    log_terms.push(log_term);
234
235    for i in 1..=k {
236        // Recurrence: log P(X=i) = log P(X=i−1) + log(n−i+1) − log(i) + log(p/(1−p))
237        log_term += ((n - i + 1) as f64).ln() - (i as f64).ln() + log_ratio;
238        log_terms.push(log_term);
239    }
240
241    // log-sum-exp: subtract max before exponentiating to prevent overflow/underflow.
242    let max_log = log_terms.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
243    if max_log == f64::NEG_INFINITY {
244        return 0.0;
245    }
246    let sum: f64 = log_terms.iter().map(|&l| (l - max_log).exp()).sum();
247    (sum * max_log.exp()).min(1.0)
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    // Same inline SplitMix64 as conformal.rs — deterministic, dep-free draws for synthetic pairs.
255    fn hash01(seed: u64, a: u64, b: u64) -> f64 {
256        let mut s = seed
257            .wrapping_mul(0xD1B5_4A32_D192_ED03)
258            .wrapping_add(a.wrapping_mul(0x9E37_79B9_7F4A_7C15))
259            .wrapping_add(b.wrapping_mul(0xC2B2_AE3D_27D4_EB4F))
260            .wrapping_add(0x1234_5678_9ABC_DEF0);
261        s = s.wrapping_add(0x9E37_79B9_7F4A_7C15);
262        let mut z = s;
263        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
264        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
265        z ^= z >> 31;
266        (z >> 11) as f64 / (1u64 << 53) as f64
267    }
268
269    /// Same gate-like score distribution as conformal.rs: correct centers at 0.72, incorrect
270    /// at 0.30, with ±0.2 uniform noise — disjoint seeds give independent draws.
271    fn pairs(seed: u64, n: usize) -> Vec<(f64, bool)> {
272        (0..n as u64)
273            .map(|id| {
274                let correct = hash01(seed, id, 1) < 0.7;
275                let noise = (hash01(seed ^ 0x00C0_FFEE, id, 2) - 0.5) * 0.4;
276                let base = if correct { 0.72 } else { 0.30 };
277                ((base + noise).clamp(0.0, 1.0), correct)
278            })
279            .collect()
280    }
281
282    // ── 1. Binomial CDF correctness vs hand-computed values ──────────────────────────────────
283
284    #[test]
285    fn binomial_cdf_hand_computed() {
286        // P(X <= 0 | Bin(10, 0.1)) = 0.9^10 ≈ 0.34868
287        let got = binomial_cdf(10, 0, 0.1);
288        assert!(
289            (got - 0.34868_f64).abs() < 1e-4,
290            "Bin(10,0.1) CDF at 0: {got}"
291        );
292
293        // P(X <= 1 | Bin(10, 0.1)) = 0.9^10 + 10*0.1*0.9^9 ≈ 0.73610
294        let got = binomial_cdf(10, 1, 0.1);
295        assert!(
296            (got - 0.73610_f64).abs() < 1e-4,
297            "Bin(10,0.1) CDF at 1: {got}"
298        );
299
300        // P(X <= 2 | Bin(5, 0.3)):
301        //   P(0) = 0.7^5            ≈ 0.16807
302        //   P(1) = 5*0.3*0.7^4     ≈ 0.36015
303        //   P(2) = 10*0.09*0.7^3   ≈ 0.30870
304        //   sum                    ≈ 0.83692
305        let got = binomial_cdf(5, 2, 0.3);
306        assert!(
307            (got - 0.83692_f64).abs() < 1e-4,
308            "Bin(5,0.3) CDF at 2: {got}"
309        );
310
311        // Edge cases.
312        assert_eq!(binomial_cdf(10, 10, 0.5), 1.0, "k=n must be 1");
313        assert_eq!(binomial_cdf(10, 0, 0.0), 1.0, "p=0 must be 1");
314        assert_eq!(binomial_cdf(10, 9, 1.0), 0.0, "k<n and p=1 must be 0");
315    }
316
317    // ── 2. LTT guarantee holds on held-out data ──────────────────────────────────────────────
318
319    #[test]
320    fn certified_lambda_risk_at_most_alpha_on_held_out() {
321        let alpha = 0.10;
322        let calib = pairs(1, 4000);
323        let test = pairs(2, 4000); // disjoint seed ⇒ held-out
324
325        let r = calibrate(&calib, alpha, 0.05, 50);
326        assert!(
327            r.feasible,
328            "should find a feasible threshold on n=4000 gate-like data"
329        );
330
331        let served_n = calib.iter().filter(|(s, _)| *s >= r.threshold).count();
332        assert!(
333            served_n as f64 / calib.len() as f64 > 0.2,
334            "should serve > 20% of calibration set"
335        );
336
337        // Measure held-out risk at the certified threshold.
338        let held_out_served: Vec<bool> = test
339            .iter()
340            .filter(|(s, _)| *s >= r.threshold)
341            .map(|(_, c)| *c)
342            .collect();
343        assert!(
344            !held_out_served.is_empty(),
345            "must serve some held-out items"
346        );
347        let held_out_risk =
348            held_out_served.iter().filter(|c| !**c).count() as f64 / held_out_served.len() as f64;
349
350        // The LTT guarantee: held-out risk ≤ alpha (small tolerance for finite-sample variation;
351        // the binomial test is conservative so the margin should be comfortable).
352        assert!(
353            held_out_risk <= alpha + 0.03,
354            "held-out risk {held_out_risk:.3} must be ≤ alpha {alpha} (+tol)"
355        );
356    }
357
358    // ── 3. Fixed-sequence stops at first failure even if risk dips ────────────────────────────
359
360    #[test]
361    fn fixed_sequence_stops_at_first_failure_even_if_risk_dips() {
362        // Data layout (scores descending):
363        //   λ = 0.95: 100 correct     → risk = 0/100 = 0%    → certified (p-value ≈ 0)
364        //   λ = 0.80: +30 incorrect   → risk = 30/130 ≈ 23%  → FAIL → walk stops
365        //   λ = 0.60: +200 correct    → risk = 30/330 ≈ 9%   → must NOT certify (walk stopped)
366        let alpha = 0.10;
367        let mut data: Vec<(f64, bool)> = Vec::new();
368        for _ in 0..100 {
369            data.push((0.95, true));
370        }
371        for _ in 0..30 {
372            data.push((0.80, false));
373        }
374        for _ in 0..200 {
375            data.push((0.60, true));
376        }
377
378        let r = calibrate(&data, alpha, 0.05, 30);
379        // The λ = 0.95 row must be certified.
380        let d095 = r
381            .diagnostics
382            .iter()
383            .find(|d| (d.lambda - 0.95).abs() < 1e-9)
384            .expect("0.95 must appear in diagnostics");
385        assert!(d095.certified, "λ=0.95 must be certified");
386
387        // The λ = 0.80 row failed the test and must break the walk.
388        let d080 = r
389            .diagnostics
390            .iter()
391            .find(|d| (d.lambda - 0.80).abs() < 1e-9)
392            .expect("0.80 must appear in diagnostics");
393        assert!(
394            !d080.certified,
395            "λ=0.80 has risk > alpha; must not be certified"
396        );
397        assert!(
398            d080.p_value > 0.05,
399            "λ=0.80 p-value must be large (H0 not rejected)"
400        );
401
402        // The λ = 0.60 row has low empirical risk but must NOT be certified (walk stopped).
403        let d060 = r
404            .diagnostics
405            .iter()
406            .find(|d| (d.lambda - 0.60).abs() < 1e-9)
407            .expect("0.60 must appear in diagnostics");
408        assert!(
409            !d060.certified,
410            "λ=0.60 must not be certified after walk stopped"
411        );
412        assert!(
413            d060.empirical_risk <= alpha,
414            "empirical risk at 0.60 dipped to {:.3} but must still be uncertified",
415            d060.empirical_risk
416        );
417
418        // Chosen threshold is the last certified = 0.95.
419        assert!(r.feasible);
420        assert!((r.threshold - 0.95).abs() < 1e-9, "threshold must be 0.95");
421    }
422
423    // ── 4. Infeasible on tiny n ───────────────────────────────────────────────────────────────
424
425    #[test]
426    fn infeasible_on_tiny_n() {
427        let tiny = vec![(0.9, true), (0.9, true), (0.1, false)];
428        let r = calibrate(&tiny, 0.10, 0.05, 100);
429        assert!(!r.feasible, "3 pairs below min_n=100 must be infeasible");
430        assert_eq!(r.threshold, f64::INFINITY);
431        assert_eq!(r.n_served, 0);
432    }
433
434    #[test]
435    fn infeasible_target_zero_serves_nothing() {
436        // alpha=0 is unachievable with any noisy gate.
437        let r = calibrate(&pairs(3, 1000), 0.0, 0.05, 30);
438        assert!(!r.feasible);
439    }
440
441    // ── 5. false_accept_rate is reported at chosen threshold ─────────────────────────────────
442
443    #[test]
444    fn false_accept_rate_reported_correctly() {
445        // 200 correct at 0.9, 5 incorrect at 0.9 (false accepts), 15 incorrect at 0.2.
446        // At λ=0.9: n_served=205, failures=5, risk≈2.4% < alpha=10% → certified.
447        // false_accept_rate = 5/20 = 0.25 (5 of 20 total incorrect have score >= 0.9).
448        let mut data: Vec<(f64, bool)> = Vec::new();
449        for _ in 0..200 {
450            data.push((0.9, true));
451        }
452        for _ in 0..5 {
453            data.push((0.9, false));
454        }
455        for _ in 0..15 {
456            data.push((0.2, false));
457        }
458
459        let r = calibrate(&data, 0.10, 0.05, 30);
460        assert!(r.feasible, "must certify a threshold on this data");
461        let far = r
462            .false_accept_rate
463            .expect("must report a false_accept_rate");
464        assert!(
465            (far - 0.25).abs() < 1e-9,
466            "false_accept_rate must be 5/20 = 0.25, got {far}"
467        );
468    }
469}