use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct ConformalResult {
pub alpha: f64,
pub delta: f64,
pub threshold: f64,
pub served_frac: f64,
pub calib_risk: f64,
pub feasible: bool,
}
#[must_use]
pub fn calibrate(pairs: &[(f64, bool)], alpha: f64, delta: f64, min_n: usize) -> ConformalResult {
let mut sorted: Vec<(f64, bool)> = pairs.to_vec();
sorted.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
let slack = (f64::ln(1.0 / delta) / 2.0).sqrt(); let mut fails = 0usize;
let mut best: Option<(f64, usize, usize)> = None;
for (i, (score, correct)) in sorted.iter().enumerate() {
if !*correct {
fails += 1;
}
let served = i + 1;
if served < min_n {
continue;
}
let rate = fails as f64 / served as f64;
let ucb = rate + slack / (served as f64).sqrt();
if ucb <= alpha {
best = Some((*score, served, fails));
}
}
match best {
Some((threshold, served, fails)) => ConformalResult {
alpha,
delta,
threshold,
served_frac: served as f64 / pairs.len().max(1) as f64,
calib_risk: fails as f64 / served as f64,
feasible: true,
},
None => ConformalResult {
alpha,
delta,
threshold: f64::INFINITY, served_frac: 0.0,
calib_risk: 0.0,
feasible: false,
},
}
}
#[must_use]
pub fn served_failure_rate(pairs: &[(f64, bool)], threshold: f64) -> (f64, usize) {
let served: Vec<bool> = pairs
.iter()
.filter(|(s, _)| *s >= threshold)
.map(|(_, c)| *c)
.collect();
if served.is_empty() {
return (0.0, 0);
}
let fails = served.iter().filter(|c| !**c).count();
(fails as f64 / served.len() as f64, served.len())
}
#[derive(Debug, Clone)]
pub struct AdaptiveConformal {
alpha: f64,
gamma: f64,
threshold: f64,
served: u64,
served_fails: u64,
}
impl AdaptiveConformal {
#[must_use]
pub fn new(alpha: f64, gamma: f64, init_threshold: f64) -> Self {
Self {
alpha,
gamma,
threshold: init_threshold.clamp(0.0, 1.0),
served: 0,
served_fails: 0,
}
}
#[must_use]
pub fn should_serve(&self, score: f64) -> bool {
score >= self.threshold
}
#[must_use]
pub fn threshold(&self) -> f64 {
self.threshold
}
pub fn observe_served(&mut self, was_correct: bool) {
let err = f64::from(!was_correct);
self.threshold = (self.threshold + self.gamma * (err - self.alpha)).clamp(0.0, 1.0);
self.served += 1;
self.served_fails += u64::from(!was_correct);
}
#[must_use]
pub fn realized_served_failure(&self) -> f64 {
if self.served == 0 {
0.0
} else {
self.served_fails as f64 / self.served as f64
}
}
#[must_use]
pub fn served(&self) -> u64 {
self.served
}
}
#[cfg(test)]
mod tests {
use super::*;
fn hash01(seed: u64, a: u64, b: u64) -> f64 {
let mut s = seed
.wrapping_mul(0xD1B5_4A32_D192_ED03)
.wrapping_add(a.wrapping_mul(0x9E37_79B9_7F4A_7C15))
.wrapping_add(b.wrapping_mul(0xC2B2_AE3D_27D4_EB4F))
.wrapping_add(0x1234_5678_9ABC_DEF0);
s = s.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = s;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
(z >> 11) as f64 / (1u64 << 53) as f64
}
fn pairs(seed: u64, n: usize) -> Vec<(f64, bool)> {
(0..n as u64)
.map(|id| {
let correct = hash01(seed, id, 1) < 0.7;
let noise = (hash01(seed ^ 0x00C0_FFEE, id, 2) - 0.5) * 0.4;
let base = if correct { 0.72 } else { 0.30 };
((base + noise).clamp(0.0, 1.0), correct)
})
.collect()
}
#[test]
fn guarantee_holds_on_held_out_data() {
let alpha = 0.10;
let calib = pairs(1, 4000);
let test = pairs(2, 4000); let r = calibrate(&calib, alpha, 0.05, 50);
assert!(r.feasible, "should find a feasible threshold on this data");
assert!(
r.served_frac > 0.2,
"should serve a meaningful fraction, got {}",
r.served_frac
);
let (test_risk, n_served) = served_failure_rate(&test, r.threshold);
assert!(n_served > 0);
assert!(
test_risk <= alpha + 0.03,
"held-out served-failure {test_risk:.3} must be <= alpha {alpha} (+tol)"
);
}
#[test]
fn infeasible_target_serves_nothing() {
let calib = pairs(3, 1000);
let r = calibrate(&calib, 0.0, 0.05, 50);
assert!(!r.feasible);
assert_eq!(r.served_frac, 0.0);
}
#[test]
fn adaptive_update_moves_threshold_the_right_way() {
let mut a = AdaptiveConformal::new(0.10, 0.05, 0.5);
a.observe_served(false);
assert!(a.threshold() > 0.5);
let mut b = AdaptiveConformal::new(0.10, 0.05, 0.5);
b.observe_served(true);
assert!(b.threshold() < 0.5);
}
fn shifted(id: u64, shift: bool) -> (f64, bool) {
let correct = hash01(42, id, 1) < 0.7;
let noise = (hash01(42 ^ 0xBEEF, id, 2) - 0.5) * 0.3;
let base = if correct {
0.78
} else if shift {
0.58
} else {
0.30
};
((base + noise).clamp(0.0, 1.0), correct)
}
#[test]
fn adaptive_tracks_alpha_under_shift_where_fixed_drifts() {
let alpha = 0.10;
let n = 6000u64;
let calib: Vec<(f64, bool)> = (0..n).map(|id| shifted(id, false)).collect();
let fixed = calibrate(&calib, alpha, 0.05, 50).threshold;
let mut aci = AdaptiveConformal::new(alpha, 0.03, fixed);
let (mut fx_served, mut fx_fails) = (0u64, 0u64);
let (mut ac_served, mut ac_fails) = (0u64, 0u64);
for id in n..(5 * n) {
let (score, correct) = shifted(id, true);
if score >= fixed {
fx_served += 1;
fx_fails += u64::from(!correct);
}
if aci.should_serve(score) {
aci.observe_served(correct);
ac_served += 1;
ac_fails += u64::from(!correct);
}
}
let fixed_rate = fx_fails as f64 / fx_served.max(1) as f64;
let aci_rate = ac_fails as f64 / ac_served.max(1) as f64;
assert!(
fixed_rate > alpha + 0.05,
"fixed should drift high under shift, got {fixed_rate:.3}"
);
assert!(
aci_rate < fixed_rate,
"adaptive {aci_rate:.3} should beat fixed {fixed_rate:.3}"
);
assert!(
aci_rate <= alpha + 0.06,
"adaptive {aci_rate:.3} should track alpha {alpha}"
);
assert!(ac_served > 0);
}
}