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