#[derive(Debug, Clone)]
struct QuantileExpert {
estimate: f64,
step_size: f64,
wealth: f64,
}
#[derive(Debug, Clone)]
pub struct StronglyAdaptiveConformal {
experts: Vec<QuantileExpert>,
alpha_target: f64,
n_updates: u64,
cumulative_coverage: f64,
}
impl StronglyAdaptiveConformal {
pub fn new(alpha_target: f64, n_experts: usize) -> Self {
assert!(
alpha_target > 0.0 && alpha_target < 1.0,
"alpha_target must be in (0, 1), got {alpha_target}"
);
assert!(n_experts > 0, "n_experts must be > 0, got {n_experts}");
let experts = (0..n_experts)
.map(|i| QuantileExpert {
estimate: alpha_target,
step_size: 1.0 / (1u64 << i) as f64, wealth: 1.0,
})
.collect();
Self {
experts,
alpha_target,
n_updates: 0,
cumulative_coverage: 0.0,
}
}
pub fn default_90() -> Self {
Self::new(0.1, 10)
}
pub fn default_95() -> Self {
Self::new(0.05, 10)
}
pub fn update(&mut self, prediction: f64, target: f64) {
let score = (prediction - target).abs();
for expert in &mut self.experts {
let err = if score > expert.estimate { 1.0 } else { 0.0 };
let gradient = self.alpha_target - err;
let bet_payoff = expert.step_size * gradient;
expert.wealth *= 1.0 + bet_payoff.clamp(-0.4, 0.4);
expert.wealth = expert.wealth.max(1e-10);
expert.estimate += expert.step_size * gradient;
expert.estimate = expert.estimate.max(0.0);
}
self.n_updates += 1;
let threshold = self.current_threshold();
let covered = if score <= threshold { 1.0 } else { 0.0 };
self.cumulative_coverage += (covered - self.cumulative_coverage) / self.n_updates as f64;
}
pub fn current_threshold(&self) -> f64 {
let total_wealth: f64 = self.experts.iter().map(|e| e.wealth).sum();
if total_wealth <= 0.0 {
return self.alpha_target;
}
self.experts
.iter()
.map(|e| e.wealth * e.estimate)
.sum::<f64>()
/ total_wealth
}
pub fn coverage(&self) -> f64 {
self.cumulative_coverage
}
pub fn n_updates(&self) -> u64 {
self.n_updates
}
pub fn reset(&mut self) {
for expert in &mut self.experts {
expert.estimate = self.alpha_target;
expert.wealth = 1.0;
}
self.n_updates = 0;
self.cumulative_coverage = 0.0;
}
pub fn n_experts(&self) -> usize {
self.experts.len()
}
pub fn expert_step_sizes(&self) -> Vec<f64> {
self.experts.iter().map(|e| e.step_size).collect()
}
pub fn expert_weights(&self) -> Vec<f64> {
self.experts.iter().map(|e| e.wealth).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn coverage_converges_to_target() {
let mut sac = StronglyAdaptiveConformal::default_90();
for i in 0..1000 {
let target = (i as f64) * 0.01;
let offset = if i % 10 == 0 { 5.0 } else { 0.01 };
sac.update(target + offset, target);
}
assert!(
sac.coverage() > 0.3,
"coverage {} should be > 0.3",
sac.coverage()
);
assert!(
sac.coverage() < 1.0,
"coverage {} should be < 1.0",
sac.coverage()
);
}
#[test]
fn adapts_to_sudden_shift() {
let mut sac = StronglyAdaptiveConformal::new(0.1, 8);
for i in 0..500 {
let t = i as f64;
sac.update(t + 0.1, t);
}
let threshold_before = sac.current_threshold();
for i in 0..500 {
let t = i as f64;
sac.update(t + 10.0, t);
}
let threshold_after = sac.current_threshold();
assert!(
threshold_after > threshold_before,
"threshold should increase after regime shift: before={threshold_before}, after={threshold_after}"
);
}
#[test]
fn multiple_experts_have_different_scales() {
let sac = StronglyAdaptiveConformal::new(0.1, 5);
let step_sizes = sac.expert_step_sizes();
assert_eq!(step_sizes.len(), 5);
assert!((step_sizes[0] - 1.0).abs() < 1e-10);
assert!((step_sizes[1] - 0.5).abs() < 1e-10);
assert!((step_sizes[2] - 0.25).abs() < 1e-10);
assert!((step_sizes[3] - 0.125).abs() < 1e-10);
assert!((step_sizes[4] - 0.0625).abs() < 1e-10);
for i in 1..step_sizes.len() {
assert!(
(step_sizes[i] - step_sizes[i - 1] / 2.0).abs() < 1e-10,
"expert {i} step_size {} should be half of expert {} step_size {}",
step_sizes[i],
i - 1,
step_sizes[i - 1]
);
}
}
#[test]
fn reset_clears_all_experts() {
let mut sac = StronglyAdaptiveConformal::default_90();
for i in 0..100 {
sac.update(i as f64 + 5.0, i as f64);
}
assert!(sac.n_updates() > 0);
sac.reset();
assert_eq!(sac.n_updates(), 0);
assert!((sac.coverage()).abs() < 1e-10);
for w in sac.expert_weights() {
assert!(
(w - 1.0).abs() < 1e-10,
"expert wealth should be 1.0 after reset, got {w}"
);
}
}
#[test]
fn wealth_weighting_favors_accurate_experts() {
let mut sac = StronglyAdaptiveConformal::new(0.1, 5);
for cycle in 0..10 {
let offset = if cycle % 2 == 0 { 0.1 } else { 5.0 };
for i in 0..50 {
let t = i as f64;
sac.update(t + offset, t);
}
}
let weights = sac.expert_weights();
let min_w = weights.iter().cloned().fold(f64::INFINITY, f64::min);
let max_w = weights.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
assert!(
max_w > min_w * 1.01 || (max_w - min_w).abs() > 1e-8,
"expert weights should diverge with regime changes, but min={min_w}, max={max_w}"
);
}
#[test]
fn default_constructors_work() {
let sac90 = StronglyAdaptiveConformal::default_90();
assert_eq!(sac90.n_experts(), 10);
assert_eq!(sac90.n_updates(), 0);
assert!((sac90.current_threshold() - 0.1).abs() < 1e-10);
let sac95 = StronglyAdaptiveConformal::default_95();
assert_eq!(sac95.n_experts(), 10);
assert!((sac95.current_threshold() - 0.05).abs() < 1e-10);
}
}