use crate::error::{RillError, ensure_finite};
use crate::stats::ExponentiallyWeightedMean;
use crate::traits::OnlineStatistic;
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PredictionInterval {
lower: f64,
upper: f64,
}
impl PredictionInterval {
pub const fn lower(&self) -> f64 {
self.lower
}
pub const fn upper(&self) -> f64 {
self.upper
}
pub fn contains(&self, value: f64) -> bool {
self.lower <= value && value <= self.upper
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ResidualIntervalConfig {
pub k: f64,
pub alpha: f64,
}
impl Default for ResidualIntervalConfig {
fn default() -> Self {
Self { k: 1.0, alpha: 0.1 }
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ResidualInterval {
config: ResidualIntervalConfig,
error_ew: ExponentiallyWeightedMean,
}
impl ResidualInterval {
pub fn new(config: ResidualIntervalConfig) -> Result<Self, RillError> {
ensure_finite("k", config.k)?;
if config.k <= 0.0 {
return Err(RillError::InvalidParameter {
name: "k",
value: config.k,
});
}
Ok(Self {
config: ResidualIntervalConfig {
k: config.k,
alpha: config.alpha,
},
error_ew: ExponentiallyWeightedMean::new(config.alpha)?,
})
}
pub fn observe(&mut self, prediction: f64, truth: f64) -> Result<(), RillError> {
let abs_error = (truth - prediction).abs();
self.error_ew.update(abs_error)
}
pub fn interval(&self, prediction: f64) -> Result<PredictionInterval, RillError> {
ensure_finite("prediction", prediction)?;
if self.error_ew.count() == 0 {
return Err(RillError::InsufficientData);
}
let margin = self.config.k * self.error_ew.value();
Ok(PredictionInterval {
lower: prediction - margin,
upper: prediction + margin,
})
}
pub fn recent_error(&self) -> Option<f64> {
if self.error_ew.count() == 0 {
None
} else {
Some(self.error_ew.value())
}
}
pub fn samples_seen(&self) -> u64 {
self.error_ew.samples_seen()
}
pub fn reset(&mut self) {
self.error_ew.reset();
}
}
impl Default for ResidualInterval {
fn default() -> Self {
Self::new(ResidualIntervalConfig::default()).expect("default config is valid")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn interval_constructs_correctly() {
let iv = PredictionInterval {
lower: 1.0,
upper: 3.0,
};
assert_eq!(iv.lower(), 1.0);
assert_eq!(iv.upper(), 3.0);
}
#[test]
fn contains_checks_bounds() {
let iv = PredictionInterval {
lower: 1.0,
upper: 3.0,
};
assert!(iv.contains(1.0)); assert!(iv.contains(3.0)); assert!(iv.contains(2.0)); assert!(!iv.contains(0.9)); assert!(!iv.contains(3.1)); }
#[test]
fn observe_then_interval() {
let mut ri = ResidualInterval::default();
ri.observe(10.0, 11.0).unwrap(); ri.observe(10.0, 9.0).unwrap(); let iv = ri.interval(10.0).unwrap();
assert!((iv.lower() - 9.0).abs() < 1e-9);
assert!((iv.upper() - 11.0).abs() < 1e-9);
}
#[test]
fn interval_without_observations_errors() {
let ri = ResidualInterval::default();
assert!(matches!(
ri.interval(10.0),
Err(RillError::InsufficientData)
));
}
#[test]
fn recent_error_none_initially() {
let ri = ResidualInterval::default();
assert_eq!(ri.recent_error(), None);
assert_eq!(ri.samples_seen(), 0);
}
#[test]
fn custom_k_widens_interval() {
let mut ri1 = ResidualInterval::new(ResidualIntervalConfig { k: 1.0, alpha: 0.1 }).unwrap();
let mut ri2 = ResidualInterval::new(ResidualIntervalConfig { k: 2.0, alpha: 0.1 }).unwrap();
ri1.observe(10.0, 12.0).unwrap(); ri2.observe(10.0, 12.0).unwrap();
let iv1 = ri1.interval(10.0).unwrap();
let iv2 = ri2.interval(10.0).unwrap();
let width1 = iv1.upper() - iv1.lower();
let width2 = iv2.upper() - iv2.lower();
assert!(width2 > width1);
assert!((width2 - 2.0 * width1).abs() < 1e-9);
}
#[test]
fn alpha_affects_memory() {
let mut ri = ResidualInterval::new(ResidualIntervalConfig { k: 1.0, alpha: 1.0 }).unwrap();
ri.observe(0.0, 10.0).unwrap(); ri.observe(0.0, 5.0).unwrap(); ri.observe(0.0, 8.0).unwrap(); assert!((ri.recent_error().unwrap() - 8.0).abs() < 1e-12);
}
#[test]
fn reset_clears_state() {
let mut ri = ResidualInterval::default();
ri.observe(10.0, 12.0).unwrap();
assert!(ri.recent_error().is_some());
ri.reset();
assert_eq!(ri.recent_error(), None);
assert_eq!(ri.samples_seen(), 0);
assert!(matches!(
ri.interval(10.0),
Err(RillError::InsufficientData)
));
}
#[test]
fn non_finite_prediction_rejected() {
let mut ri = ResidualInterval::default();
ri.observe(10.0, 11.0).unwrap();
assert!(ri.interval(f64::NAN).is_err());
assert!(ri.interval(f64::INFINITY).is_err());
assert!(ri.interval(f64::NEG_INFINITY).is_err());
}
#[test]
fn non_finite_truth_rejected() {
let mut ri = ResidualInterval::default();
assert!(ri.observe(10.0, f64::NAN).is_err());
assert!(ri.observe(10.0, f64::INFINITY).is_err());
assert!(ri.observe(10.0, f64::NEG_INFINITY).is_err());
assert_eq!(ri.samples_seen(), 0);
assert_eq!(ri.recent_error(), None);
}
#[test]
fn invalid_k_rejected() {
let config = ResidualIntervalConfig { k: 0.0, alpha: 0.1 };
assert!(ResidualInterval::new(config).is_err());
let config = ResidualIntervalConfig {
k: -1.0,
alpha: 0.1,
};
assert!(ResidualInterval::new(config).is_err());
}
#[test]
fn invalid_alpha_rejected() {
let config = ResidualIntervalConfig { k: 1.0, alpha: 0.0 };
assert!(ResidualInterval::new(config).is_err());
}
fn next_unit(seed: &mut u64) -> f64 {
*seed = seed
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
((*seed >> 11) as f64) / ((1u64 << 53) as f64)
}
#[test]
fn interval_contains_subsequent_observation() {
let config = ResidualIntervalConfig { k: 3.0, alpha: 0.1 };
let mut ri = ResidualInterval::new(config).unwrap();
let mut seed: u64 = 42;
let prediction = 10.0;
for _ in 0..50 {
let noise = 2.0 * next_unit(&mut seed) - 1.0;
ri.observe(prediction, prediction + noise).unwrap();
}
let mut contained = 0u64;
let total = 100u64;
for _ in 0..total {
let noise = 2.0 * next_unit(&mut seed) - 1.0;
let truth = prediction + noise;
ri.observe(prediction, truth).unwrap();
let iv = ri.interval(prediction).unwrap();
if iv.contains(truth) {
contained += 1;
}
}
assert!(
contained as f64 / total as f64 > 0.9,
"only {}/{} observations contained",
contained,
total
);
}
#[cfg(feature = "serde")]
#[test]
fn serde_roundtrip() {
let mut ri = ResidualInterval::default();
ri.observe(10.0, 12.0).unwrap();
ri.observe(10.0, 9.0).unwrap();
let json = serde_json::to_string(&ri).unwrap();
let restored: ResidualInterval = serde_json::from_str(&json).unwrap();
assert_eq!(restored.samples_seen(), 2);
assert!((restored.recent_error().unwrap() - ri.recent_error().unwrap()).abs() < 1e-12);
let iv = restored.interval(10.0).unwrap();
assert!(iv.contains(10.0));
}
}