rill_ml/diagnostics/
training_summary.rs1use crate::error::{RillError, checked_increment, ensure_finite};
9use crate::stats::ExponentiallyWeightedMean;
10use crate::traits::OnlineStatistic;
11
12#[derive(Debug, Clone)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15#[non_exhaustive]
16pub struct TrainingSummaryConfig {
17 pub error_alpha: f64,
21}
22
23impl Default for TrainingSummaryConfig {
24 fn default() -> Self {
25 Self { error_alpha: 0.1 }
26 }
27}
28
29#[derive(Debug, Clone)]
48#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
49pub struct TrainingSummary {
50 total_samples: u64,
51 rejected_samples: u64,
52 error_ew: ExponentiallyWeightedMean,
53 best_error: Option<f64>,
54 baseline_error: Option<f64>,
55 model_switches: u64,
56 reset_count: u64,
57 load_failures: u64,
58}
59
60impl TrainingSummary {
61 pub fn new(config: TrainingSummaryConfig) -> Result<Self, RillError> {
63 Ok(Self {
64 total_samples: 0,
65 rejected_samples: 0,
66 error_ew: ExponentiallyWeightedMean::new(config.error_alpha)?,
67 best_error: None,
68 baseline_error: None,
69 model_switches: 0,
70 reset_count: 0,
71 load_failures: 0,
72 })
73 }
74
75 pub fn record_sample(&mut self) -> Result<(), RillError> {
77 self.total_samples = checked_increment(self.total_samples, "total_samples")?;
78 Ok(())
79 }
80
81 pub fn record_rejection(&mut self) -> Result<(), RillError> {
83 self.rejected_samples = checked_increment(self.rejected_samples, "rejected_samples")?;
84 Ok(())
85 }
86
87 pub fn record_error(&mut self, error: f64) -> Result<(), RillError> {
92 ensure_finite("error", error)?;
93 let abs_error = error.abs();
94 self.error_ew.update(abs_error)?;
95 match self.best_error {
96 None => self.best_error = Some(abs_error),
97 Some(b) if abs_error < b => self.best_error = Some(abs_error),
98 _ => {}
99 }
100 Ok(())
101 }
102
103 pub fn set_baseline_error(&mut self, error: f64) -> Result<(), RillError> {
105 ensure_finite("baseline_error", error)?;
106 self.baseline_error = Some(error.abs());
107 Ok(())
108 }
109
110 pub fn record_switch(&mut self) -> Result<(), RillError> {
112 self.model_switches = checked_increment(self.model_switches, "model_switches")?;
113 Ok(())
114 }
115
116 pub fn record_reset(&mut self) -> Result<(), RillError> {
118 self.reset_count = checked_increment(self.reset_count, "reset_count")?;
119 Ok(())
120 }
121
122 pub fn record_load_failure(&mut self) -> Result<(), RillError> {
124 self.load_failures = checked_increment(self.load_failures, "load_failures")?;
125 Ok(())
126 }
127
128 pub const fn total_samples(&self) -> u64 {
130 self.total_samples
131 }
132
133 pub const fn rejected_samples(&self) -> u64 {
135 self.rejected_samples
136 }
137
138 pub fn recent_error(&self) -> Option<f64> {
140 if self.error_ew.count() == 0 {
141 None
142 } else {
143 Some(self.error_ew.value())
144 }
145 }
146
147 pub const fn best_error(&self) -> Option<f64> {
149 self.best_error
150 }
151
152 pub const fn baseline_error(&self) -> Option<f64> {
154 self.baseline_error
155 }
156
157 pub const fn model_switches(&self) -> u64 {
159 self.model_switches
160 }
161
162 pub const fn reset_count(&self) -> u64 {
164 self.reset_count
165 }
166
167 pub const fn load_failures(&self) -> u64 {
169 self.load_failures
170 }
171
172 pub fn beats_baseline(&self) -> Option<bool> {
176 match (self.recent_error(), self.baseline_error) {
177 (Some(recent), Some(baseline)) => Some(recent < baseline),
178 _ => None,
179 }
180 }
181
182 pub fn reset(&mut self) {
184 self.total_samples = 0;
185 self.rejected_samples = 0;
186 self.error_ew.reset();
187 self.best_error = None;
188 self.baseline_error = None;
189 self.model_switches = 0;
190 self.reset_count = 0;
191 self.load_failures = 0;
192 }
193}
194
195impl Default for TrainingSummary {
196 fn default() -> Self {
197 Self::new(TrainingSummaryConfig::default()).expect("default config is valid")
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn default_summary_has_no_data() {
207 let s = TrainingSummary::default();
208 assert_eq!(s.total_samples(), 0);
209 assert_eq!(s.rejected_samples(), 0);
210 assert_eq!(s.recent_error(), None);
211 assert_eq!(s.best_error(), None);
212 assert_eq!(s.baseline_error(), None);
213 assert_eq!(s.beats_baseline(), None);
214 assert_eq!(s.model_switches(), 0);
215 assert_eq!(s.reset_count(), 0);
216 assert_eq!(s.load_failures(), 0);
217 }
218
219 #[test]
220 fn record_error_updates_recent_and_best() {
221 let mut s = TrainingSummary::default();
222 s.record_error(10.0).unwrap();
223 s.record_error(5.0).unwrap();
224 s.record_error(8.0).unwrap();
225 assert_eq!(s.best_error(), Some(5.0));
226 assert!((s.recent_error().unwrap() - 9.35).abs() < 1e-9);
228 }
229
230 #[test]
231 fn beats_baseline_comparison() {
232 let mut s = TrainingSummary::default();
233 s.record_error(5.0).unwrap();
234 s.set_baseline_error(10.0).unwrap();
235 assert_eq!(s.beats_baseline(), Some(true));
236
237 s.set_baseline_error(3.0).unwrap();
238 assert_eq!(s.beats_baseline(), Some(false));
239 }
240
241 #[test]
242 fn beats_baseline_none_without_errors() {
243 let mut s = TrainingSummary::default();
244 s.set_baseline_error(10.0).unwrap();
245 assert_eq!(s.beats_baseline(), None);
246 }
247
248 #[test]
249 fn counts_tracked_correctly() {
250 let mut s = TrainingSummary::default();
251 s.record_sample().unwrap();
252 s.record_sample().unwrap();
253 s.record_rejection().unwrap();
254 s.record_switch().unwrap();
255 s.record_switch().unwrap();
256 s.record_reset().unwrap();
257 s.record_load_failure().unwrap();
258 s.record_load_failure().unwrap();
259 s.record_load_failure().unwrap();
260 assert_eq!(s.total_samples(), 2);
261 assert_eq!(s.rejected_samples(), 1);
262 assert_eq!(s.model_switches(), 2);
263 assert_eq!(s.reset_count(), 1);
264 assert_eq!(s.load_failures(), 3);
265 }
266
267 #[test]
268 fn reset_clears_all() {
269 let mut s = TrainingSummary::default();
270 s.record_sample().unwrap();
271 s.record_error(1.0).unwrap();
272 s.set_baseline_error(2.0).unwrap();
273 s.record_switch().unwrap();
274 s.record_reset().unwrap();
275 s.record_load_failure().unwrap();
276 s.record_rejection().unwrap();
277 s.reset();
278 assert_eq!(s.total_samples(), 0);
279 assert_eq!(s.rejected_samples(), 0);
280 assert_eq!(s.recent_error(), None);
281 assert_eq!(s.best_error(), None);
282 assert_eq!(s.baseline_error(), None);
283 assert_eq!(s.model_switches(), 0);
284 assert_eq!(s.reset_count(), 0);
285 assert_eq!(s.load_failures(), 0);
286 }
287
288 #[test]
289 fn non_finite_error_rejected() {
290 let mut s = TrainingSummary::default();
291 assert!(s.record_error(f64::NAN).is_err());
292 assert!(s.record_error(f64::INFINITY).is_err());
293 assert!(s.record_error(f64::NEG_INFINITY).is_err());
294 }
295
296 #[test]
297 fn non_finite_baseline_rejected() {
298 let mut s = TrainingSummary::default();
299 assert!(s.set_baseline_error(f64::NAN).is_err());
300 assert!(s.set_baseline_error(f64::INFINITY).is_err());
301 }
302
303 #[test]
304 fn invalid_alpha_rejected() {
305 let config = TrainingSummaryConfig { error_alpha: 0.0 };
306 assert!(TrainingSummary::new(config).is_err());
307 }
308
309 #[test]
310 fn negative_error_uses_absolute_value() {
311 let mut s = TrainingSummary::default();
312 s.record_error(-5.0).unwrap();
313 assert_eq!(s.best_error(), Some(5.0));
314 assert!((s.recent_error().unwrap() - 5.0).abs() < 1e-12);
315 }
316
317 #[test]
318 fn custom_alpha_changes_memory() {
319 let config = TrainingSummaryConfig { error_alpha: 1.0 };
320 let mut s = TrainingSummary::new(config).unwrap();
321 s.record_error(10.0).unwrap();
322 s.record_error(5.0).unwrap();
323 s.record_error(8.0).unwrap();
324 assert!((s.recent_error().unwrap() - 8.0).abs() < 1e-12);
326 }
327
328 #[cfg(feature = "serde")]
329 #[test]
330 fn serde_roundtrip() {
331 let mut s = TrainingSummary::default();
332 s.record_sample().unwrap();
333 s.record_sample().unwrap();
334 s.record_error(2.0).unwrap();
335 s.record_error(1.5).unwrap();
336 s.set_baseline_error(3.0).unwrap();
337 s.record_switch().unwrap();
338 let json = serde_json::to_string(&s).unwrap();
339 let restored: TrainingSummary = serde_json::from_str(&json).unwrap();
340 assert_eq!(restored.total_samples(), 2);
341 assert_eq!(restored.best_error(), Some(1.5));
342 assert_eq!(restored.baseline_error(), Some(3.0));
343 assert_eq!(restored.model_switches(), 1);
344 assert!(restored.beats_baseline().unwrap());
345 }
346}