use crate::diagnostics::prediction_interval::{ResidualInterval, ResidualIntervalConfig};
use crate::diagnostics::training_summary::{TrainingSummary, TrainingSummaryConfig};
use crate::diagnostics::warmup::{WarmupConfig, WarmupState, WarmupTracker};
use crate::error::{RillError, ensure_finite};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Confidence {
None,
Low,
Medium,
High,
}
impl Confidence {
pub const fn as_str(&self) -> &'static str {
match self {
Confidence::None => "none",
Confidence::Low => "low",
Confidence::Medium => "medium",
Confidence::High => "high",
}
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PredictionReport {
prediction: f64,
lower_bound: Option<f64>,
upper_bound: Option<f64>,
confidence: Confidence,
samples_seen: u64,
recent_error: Option<f64>,
baseline_error: Option<f64>,
warmup_state: WarmupState,
beats_baseline: Option<bool>,
}
impl PredictionReport {
pub const fn prediction(&self) -> f64 {
self.prediction
}
pub const fn lower_bound(&self) -> Option<f64> {
self.lower_bound
}
pub const fn upper_bound(&self) -> Option<f64> {
self.upper_bound
}
pub const fn confidence(&self) -> Confidence {
self.confidence
}
pub const fn samples_seen(&self) -> u64 {
self.samples_seen
}
pub const fn recent_error(&self) -> Option<f64> {
self.recent_error
}
pub const fn baseline_error(&self) -> Option<f64> {
self.baseline_error
}
pub const fn warmup_state(&self) -> WarmupState {
self.warmup_state
}
pub const fn beats_baseline(&self) -> Option<bool> {
self.beats_baseline
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PredictionReporter {
interval: ResidualInterval,
warmup: WarmupTracker,
summary: TrainingSummary,
}
impl PredictionReporter {
pub fn new(
interval_config: ResidualIntervalConfig,
warmup_config: WarmupConfig,
summary_config: TrainingSummaryConfig,
) -> Result<Self, RillError> {
Ok(Self {
interval: ResidualInterval::new(interval_config)?,
warmup: WarmupTracker::new(warmup_config)?,
summary: TrainingSummary::new(summary_config)?,
})
}
pub fn observe(&mut self, prediction: f64, truth: f64) -> Result<(), RillError> {
self.interval.observe(prediction, truth)?;
let error = (truth - prediction).abs();
self.warmup.observe_sample(Some(error))?;
self.summary.record_error(error)?;
self.summary.record_sample();
Ok(())
}
pub fn set_baseline(&mut self, baseline: f64) -> Result<(), RillError> {
self.warmup.set_baseline(baseline)?;
self.summary.set_baseline_error(baseline)?;
Ok(())
}
pub fn report(&self, prediction: f64) -> Result<PredictionReport, RillError> {
ensure_finite("prediction", prediction)?;
let (lower_bound, upper_bound) = match self.interval.interval(prediction) {
Ok(iv) => (Some(iv.lower()), Some(iv.upper())),
Err(RillError::InsufficientData) => (None, None),
Err(e) => return Err(e),
};
let warmup_state = self.warmup.state();
let beats_baseline = self.summary.beats_baseline();
let samples_seen = self.summary.total_samples();
let recent_error = self.summary.recent_error();
let baseline_error = self.summary.baseline_error();
let confidence = match warmup_state {
WarmupState::NoData => Confidence::None,
WarmupState::WarmingUp | WarmupState::Degraded => Confidence::Low,
WarmupState::Usable => Confidence::Medium,
WarmupState::Stable => {
if matches!(beats_baseline, Some(true)) {
Confidence::High
} else {
Confidence::Medium
}
}
};
Ok(PredictionReport {
prediction,
lower_bound,
upper_bound,
confidence,
samples_seen,
recent_error,
baseline_error,
warmup_state,
beats_baseline,
})
}
pub fn summary(&self) -> &TrainingSummary {
&self.summary
}
pub fn warmup_state(&self) -> WarmupState {
self.warmup.state()
}
pub fn recent_error(&self) -> Option<f64> {
self.summary.recent_error()
}
pub fn reset(&mut self) {
self.interval.reset();
self.warmup.reset();
self.summary.reset();
}
}
impl Default for PredictionReporter {
fn default() -> Self {
Self::new(
ResidualIntervalConfig::default(),
WarmupConfig::default(),
TrainingSummaryConfig::default(),
)
.expect("default configs are valid")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn confidence_as_str() {
assert_eq!(Confidence::None.as_str(), "none");
assert_eq!(Confidence::Low.as_str(), "low");
assert_eq!(Confidence::Medium.as_str(), "medium");
assert_eq!(Confidence::High.as_str(), "high");
}
#[test]
fn default_reporter_no_data() {
let reporter = PredictionReporter::default();
let r = reporter.report(0.0).unwrap();
assert_eq!(r.prediction(), 0.0);
assert_eq!(r.lower_bound(), None);
assert_eq!(r.upper_bound(), None);
assert_eq!(r.confidence(), Confidence::None);
assert_eq!(r.warmup_state(), WarmupState::NoData);
assert_eq!(r.samples_seen(), 0);
assert_eq!(r.recent_error(), None);
assert_eq!(r.baseline_error(), None);
assert_eq!(r.beats_baseline(), None);
}
#[test]
fn observe_then_report() {
let mut reporter = PredictionReporter::default();
reporter.observe(10.0, 11.0).unwrap(); reporter.observe(10.0, 9.0).unwrap(); let r = reporter.report(10.0).unwrap();
assert_eq!(r.prediction(), 10.0);
assert!(r.lower_bound().is_some());
assert!(r.upper_bound().is_some());
assert!(r.lower_bound().unwrap() < 10.0);
assert!(r.upper_bound().unwrap() > 10.0);
assert_eq!(r.samples_seen(), 2);
assert!(r.recent_error().is_some());
}
#[test]
fn set_baseline_enables_comparison() {
let mut reporter = PredictionReporter::default();
reporter.observe(0.0, 1.0).unwrap();
let r = reporter.report(0.0).unwrap();
assert_eq!(r.beats_baseline(), None);
assert_eq!(r.baseline_error(), None);
reporter.set_baseline(2.0).unwrap();
let r = reporter.report(0.0).unwrap();
assert_eq!(r.baseline_error(), Some(2.0));
assert_eq!(r.beats_baseline(), Some(true)); }
#[test]
fn confidence_progression() {
let warmup_config = WarmupConfig {
warming_up_threshold: 2,
usable_threshold: 5,
stable_threshold: 10,
degraded_error_ratio: 2.0,
};
let summary_config = TrainingSummaryConfig { error_alpha: 1.0 };
let mut reporter = PredictionReporter::new(
ResidualIntervalConfig::default(),
warmup_config,
summary_config,
)
.unwrap();
let r = reporter.report(0.0).unwrap();
assert_eq!(r.warmup_state(), WarmupState::NoData);
assert_eq!(r.confidence(), Confidence::None);
reporter.observe(0.0, 0.5).unwrap();
let r = reporter.report(0.0).unwrap();
assert_eq!(r.warmup_state(), WarmupState::WarmingUp);
assert_eq!(r.confidence(), Confidence::Low);
for _ in 0..4 {
reporter.observe(0.0, 0.5).unwrap();
}
let r = reporter.report(0.0).unwrap();
assert_eq!(r.warmup_state(), WarmupState::Usable);
assert_eq!(r.confidence(), Confidence::Medium);
reporter.set_baseline(1.0).unwrap();
let r = reporter.report(0.0).unwrap();
assert_eq!(r.warmup_state(), WarmupState::Usable);
assert_eq!(r.confidence(), Confidence::Medium);
for _ in 0..5 {
reporter.observe(0.0, 0.5).unwrap();
}
let r = reporter.report(0.0).unwrap();
assert_eq!(r.warmup_state(), WarmupState::Stable);
assert_eq!(r.confidence(), Confidence::High);
}
#[test]
fn degraded_state() {
let mut reporter = PredictionReporter::default();
reporter.set_baseline(0.4).unwrap();
for _ in 0..5 {
reporter.observe(0.0, 1.0).unwrap();
}
let r = reporter.report(0.0).unwrap();
assert_eq!(r.warmup_state(), WarmupState::Degraded);
assert_eq!(r.confidence(), Confidence::Low);
}
#[test]
fn reset_clears_all() {
let mut reporter = PredictionReporter::default();
reporter.observe(10.0, 12.0).unwrap();
reporter.set_baseline(2.0).unwrap();
reporter.reset();
let r = reporter.report(0.0).unwrap();
assert_eq!(r.lower_bound(), None);
assert_eq!(r.upper_bound(), None);
assert_eq!(r.confidence(), Confidence::None);
assert_eq!(r.warmup_state(), WarmupState::NoData);
assert_eq!(r.samples_seen(), 0);
assert_eq!(r.recent_error(), None);
assert_eq!(r.baseline_error(), None);
assert_eq!(r.beats_baseline(), None);
}
#[test]
fn report_with_non_finite_prediction_errors() {
let mut reporter = PredictionReporter::default();
reporter.observe(0.0, 1.0).unwrap();
assert!(reporter.report(f64::NAN).is_err());
assert!(reporter.report(f64::INFINITY).is_err());
assert!(reporter.report(f64::NEG_INFINITY).is_err());
}
#[test]
fn observe_with_non_finite_rejected() {
let mut reporter = PredictionReporter::default();
assert!(reporter.observe(0.0, f64::NAN).is_err());
assert!(reporter.observe(0.0, f64::INFINITY).is_err());
assert!(reporter.observe(f64::NAN, 0.0).is_err());
let r = reporter.report(0.0).unwrap();
assert_eq!(r.samples_seen(), 0);
assert_eq!(r.warmup_state(), WarmupState::NoData);
}
#[test]
fn samples_seen_tracked() {
let mut reporter = PredictionReporter::default();
for i in 0..10 {
reporter.observe(0.0, i as f64).unwrap();
}
let r = reporter.report(0.0).unwrap();
assert_eq!(r.samples_seen(), 10);
}
#[cfg(feature = "serde")]
#[test]
fn serde_roundtrip() {
let mut reporter = PredictionReporter::default();
reporter.observe(10.0, 12.0).unwrap();
reporter.observe(10.0, 9.0).unwrap();
reporter.set_baseline(3.0).unwrap();
let json = serde_json::to_string(&reporter).unwrap();
let restored: PredictionReporter = serde_json::from_str(&json).unwrap();
let r = restored.report(10.0).unwrap();
assert_eq!(r.samples_seen(), 2);
assert_eq!(r.baseline_error(), Some(3.0));
assert!(r.beats_baseline().is_some());
}
}