use crate::error::{RillError, ensure_finite};
use crate::stats::ExponentiallyWeightedMean;
use crate::traits::OnlineStatistic;
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TrainingSummaryConfig {
pub error_alpha: f64,
}
impl Default for TrainingSummaryConfig {
fn default() -> Self {
Self { error_alpha: 0.1 }
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TrainingSummary {
total_samples: u64,
rejected_samples: u64,
error_ew: ExponentiallyWeightedMean,
best_error: Option<f64>,
baseline_error: Option<f64>,
model_switches: u64,
reset_count: u64,
load_failures: u64,
}
impl TrainingSummary {
pub fn new(config: TrainingSummaryConfig) -> Result<Self, RillError> {
Ok(Self {
total_samples: 0,
rejected_samples: 0,
error_ew: ExponentiallyWeightedMean::new(config.error_alpha)?,
best_error: None,
baseline_error: None,
model_switches: 0,
reset_count: 0,
load_failures: 0,
})
}
pub fn record_sample(&mut self) {
self.total_samples += 1;
}
pub fn record_rejection(&mut self) {
self.rejected_samples += 1;
}
pub fn record_error(&mut self, error: f64) -> Result<(), RillError> {
ensure_finite("error", error)?;
let abs_error = error.abs();
self.error_ew.update(abs_error)?;
match self.best_error {
None => self.best_error = Some(abs_error),
Some(b) if abs_error < b => self.best_error = Some(abs_error),
_ => {}
}
Ok(())
}
pub fn set_baseline_error(&mut self, error: f64) -> Result<(), RillError> {
ensure_finite("baseline_error", error)?;
self.baseline_error = Some(error.abs());
Ok(())
}
pub fn record_switch(&mut self) {
self.model_switches += 1;
}
pub fn record_reset(&mut self) {
self.reset_count += 1;
}
pub fn record_load_failure(&mut self) {
self.load_failures += 1;
}
pub const fn total_samples(&self) -> u64 {
self.total_samples
}
pub const fn rejected_samples(&self) -> u64 {
self.rejected_samples
}
pub fn recent_error(&self) -> Option<f64> {
if self.error_ew.count() == 0 {
None
} else {
Some(self.error_ew.value())
}
}
pub const fn best_error(&self) -> Option<f64> {
self.best_error
}
pub const fn baseline_error(&self) -> Option<f64> {
self.baseline_error
}
pub const fn model_switches(&self) -> u64 {
self.model_switches
}
pub const fn reset_count(&self) -> u64 {
self.reset_count
}
pub const fn load_failures(&self) -> u64 {
self.load_failures
}
pub fn beats_baseline(&self) -> Option<bool> {
match (self.recent_error(), self.baseline_error) {
(Some(recent), Some(baseline)) => Some(recent < baseline),
_ => None,
}
}
pub fn reset(&mut self) {
self.total_samples = 0;
self.rejected_samples = 0;
self.error_ew.reset();
self.best_error = None;
self.baseline_error = None;
self.model_switches = 0;
self.reset_count = 0;
self.load_failures = 0;
}
}
impl Default for TrainingSummary {
fn default() -> Self {
Self::new(TrainingSummaryConfig::default()).expect("default config is valid")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_summary_has_no_data() {
let s = TrainingSummary::default();
assert_eq!(s.total_samples(), 0);
assert_eq!(s.rejected_samples(), 0);
assert_eq!(s.recent_error(), None);
assert_eq!(s.best_error(), None);
assert_eq!(s.baseline_error(), None);
assert_eq!(s.beats_baseline(), None);
assert_eq!(s.model_switches(), 0);
assert_eq!(s.reset_count(), 0);
assert_eq!(s.load_failures(), 0);
}
#[test]
fn record_error_updates_recent_and_best() {
let mut s = TrainingSummary::default();
s.record_error(10.0).unwrap();
s.record_error(5.0).unwrap();
s.record_error(8.0).unwrap();
assert_eq!(s.best_error(), Some(5.0));
assert!((s.recent_error().unwrap() - 9.35).abs() < 1e-9);
}
#[test]
fn beats_baseline_comparison() {
let mut s = TrainingSummary::default();
s.record_error(5.0).unwrap();
s.set_baseline_error(10.0).unwrap();
assert_eq!(s.beats_baseline(), Some(true));
s.set_baseline_error(3.0).unwrap();
assert_eq!(s.beats_baseline(), Some(false));
}
#[test]
fn beats_baseline_none_without_errors() {
let mut s = TrainingSummary::default();
s.set_baseline_error(10.0).unwrap();
assert_eq!(s.beats_baseline(), None);
}
#[test]
fn counts_tracked_correctly() {
let mut s = TrainingSummary::default();
s.record_sample();
s.record_sample();
s.record_rejection();
s.record_switch();
s.record_switch();
s.record_reset();
s.record_load_failure();
s.record_load_failure();
s.record_load_failure();
assert_eq!(s.total_samples(), 2);
assert_eq!(s.rejected_samples(), 1);
assert_eq!(s.model_switches(), 2);
assert_eq!(s.reset_count(), 1);
assert_eq!(s.load_failures(), 3);
}
#[test]
fn reset_clears_all() {
let mut s = TrainingSummary::default();
s.record_sample();
s.record_error(1.0).unwrap();
s.set_baseline_error(2.0).unwrap();
s.record_switch();
s.record_reset();
s.record_load_failure();
s.record_rejection();
s.reset();
assert_eq!(s.total_samples(), 0);
assert_eq!(s.rejected_samples(), 0);
assert_eq!(s.recent_error(), None);
assert_eq!(s.best_error(), None);
assert_eq!(s.baseline_error(), None);
assert_eq!(s.model_switches(), 0);
assert_eq!(s.reset_count(), 0);
assert_eq!(s.load_failures(), 0);
}
#[test]
fn non_finite_error_rejected() {
let mut s = TrainingSummary::default();
assert!(s.record_error(f64::NAN).is_err());
assert!(s.record_error(f64::INFINITY).is_err());
assert!(s.record_error(f64::NEG_INFINITY).is_err());
}
#[test]
fn non_finite_baseline_rejected() {
let mut s = TrainingSummary::default();
assert!(s.set_baseline_error(f64::NAN).is_err());
assert!(s.set_baseline_error(f64::INFINITY).is_err());
}
#[test]
fn invalid_alpha_rejected() {
let config = TrainingSummaryConfig { error_alpha: 0.0 };
assert!(TrainingSummary::new(config).is_err());
}
#[test]
fn negative_error_uses_absolute_value() {
let mut s = TrainingSummary::default();
s.record_error(-5.0).unwrap();
assert_eq!(s.best_error(), Some(5.0));
assert!((s.recent_error().unwrap() - 5.0).abs() < 1e-12);
}
#[test]
fn custom_alpha_changes_memory() {
let config = TrainingSummaryConfig { error_alpha: 1.0 };
let mut s = TrainingSummary::new(config).unwrap();
s.record_error(10.0).unwrap();
s.record_error(5.0).unwrap();
s.record_error(8.0).unwrap();
assert!((s.recent_error().unwrap() - 8.0).abs() < 1e-12);
}
#[cfg(feature = "serde")]
#[test]
fn serde_roundtrip() {
let mut s = TrainingSummary::default();
s.record_sample();
s.record_sample();
s.record_error(2.0).unwrap();
s.record_error(1.5).unwrap();
s.set_baseline_error(3.0).unwrap();
s.record_switch();
let json = serde_json::to_string(&s).unwrap();
let restored: TrainingSummary = serde_json::from_str(&json).unwrap();
assert_eq!(restored.total_samples(), 2);
assert_eq!(restored.best_error(), Some(1.5));
assert_eq!(restored.baseline_error(), Some(3.0));
assert_eq!(restored.model_switches(), 1);
assert!(restored.beats_baseline().unwrap());
}
}