firstpass_core/
conformal.rs1use serde::Serialize;
11
12#[derive(Debug, Clone, Serialize)]
14pub struct ConformalResult {
15 pub alpha: f64,
17 pub delta: f64,
19 pub threshold: f64,
21 pub served_frac: f64,
23 pub calib_risk: f64,
25 pub feasible: bool,
27}
28
29#[must_use]
34pub fn calibrate(pairs: &[(f64, bool)], alpha: f64, delta: f64, min_n: usize) -> ConformalResult {
35 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(); let mut fails = 0usize;
41 let mut best: Option<(f64, usize, usize)> = None; 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 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, served_frac: 0.0,
73 calib_risk: 0.0,
74 feasible: false,
75 },
76 }
77}
78
79#[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 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 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); 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 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 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}