1use crate::error::{RillError, checked_increment};
23use crate::metrics::RollingMae;
24use crate::traits::Metric;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29pub enum SwitchReason {
30 LowerError,
32 InsufficientData,
34 Tie,
36}
37
38#[derive(Debug, Clone)]
43#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
44pub struct ComparatorEntry {
45 name: String,
46 rolling_mae: RollingMae,
47 total_samples: u64,
48}
49
50impl ComparatorEntry {
51 pub fn name(&self) -> &str {
53 &self.name
54 }
55
56 pub fn rolling_mae(&self) -> Option<f64> {
58 self.rolling_mae.value()
59 }
60
61 pub const fn total_samples(&self) -> u64 {
63 self.total_samples
64 }
65}
66
67#[derive(Debug, Clone)]
73#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
74pub struct BaselineComparator {
75 entries: Vec<ComparatorEntry>,
76 best_index: Option<usize>,
77 switch_count: u64,
78 last_switch_reason: Option<SwitchReason>,
79}
80
81impl BaselineComparator {
82 pub fn new(names: &[&str], window_size: usize) -> Result<Self, RillError> {
91 if names.is_empty() {
92 return Err(RillError::EmptyFeatures);
93 }
94 for i in 0..names.len() {
95 for j in (i + 1)..names.len() {
96 if names[i] == names[j] {
97 return Err(RillError::InvalidParameter {
98 name: "names",
99 value: 0.0,
100 });
101 }
102 }
103 }
104 let mut entries = Vec::with_capacity(names.len());
105 for name in names {
106 entries.push(ComparatorEntry {
107 name: (*name).to_string(),
108 rolling_mae: RollingMae::new(window_size)?,
109 total_samples: 0,
110 });
111 }
112 Ok(Self {
113 entries,
114 best_index: None,
115 switch_count: 0,
116 last_switch_reason: None,
117 })
118 }
119
120 pub fn record(&mut self, index: usize, truth: f64, prediction: f64) -> Result<(), RillError> {
127 let len = self.entries.len();
128 let entry = self.entries.get_mut(index).ok_or({
129 RillError::DimensionMismatch {
130 expected: len,
131 actual: index,
132 }
133 })?;
134 entry.rolling_mae.update(truth, prediction)?;
135 entry.total_samples = checked_increment(entry.total_samples, "total_samples")?;
136 Ok(())
137 }
138
139 pub fn update_best(&mut self) -> Option<usize> {
149 let mut new_best: Option<usize> = None;
150 let mut best_err: f64 = f64::INFINITY;
151 for (i, entry) in self.entries.iter().enumerate() {
152 if let Some(err) = entry.rolling_mae()
153 && err < best_err
154 {
155 best_err = err;
156 new_best = Some(i);
157 }
158 }
159
160 match new_best {
161 None => {
162 self.best_index = None;
163 self.last_switch_reason = Some(SwitchReason::InsufficientData);
164 None
165 }
166 Some(new_idx) if Some(new_idx) == self.best_index => None,
167 Some(new_idx) => {
168 self.switch_count = self.switch_count.saturating_add(1);
172 let reason = match self.best_index {
173 None => SwitchReason::LowerError,
174 Some(old_idx) => match self.entries[old_idx].rolling_mae() {
175 None => SwitchReason::LowerError,
176 Some(old_err) => {
177 if best_err < old_err {
178 SwitchReason::LowerError
179 } else if best_err == old_err {
180 SwitchReason::Tie
181 } else {
182 SwitchReason::LowerError
185 }
186 }
187 },
188 };
189 self.best_index = Some(new_idx);
190 self.last_switch_reason = Some(reason);
191 Some(new_idx)
192 }
193 }
194 }
195
196 pub const fn best_index(&self) -> Option<usize> {
198 self.best_index
199 }
200
201 pub fn best_name(&self) -> Option<&str> {
203 self.best_index.map(|i| self.entries[i].name.as_str())
204 }
205
206 pub fn best_error(&self) -> Option<f64> {
208 self.best_index.and_then(|i| self.entries[i].rolling_mae())
209 }
210
211 pub fn entry_count(&self) -> usize {
213 self.entries.len()
214 }
215
216 pub const fn switch_count(&self) -> u64 {
218 self.switch_count
219 }
220
221 pub const fn last_switch_reason(&self) -> Option<SwitchReason> {
223 self.last_switch_reason
224 }
225
226 pub fn entry(&self, index: usize) -> Option<&ComparatorEntry> {
228 self.entries.get(index)
229 }
230
231 pub fn reset(&mut self) {
234 for entry in &mut self.entries {
235 entry.rolling_mae.reset();
236 entry.total_samples = 0;
237 }
238 self.best_index = None;
239 self.switch_count = 0;
240 self.last_switch_reason = None;
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn empty_names_rejected() {
250 assert!(matches!(
251 BaselineComparator::new(&[], 10),
252 Err(RillError::EmptyFeatures)
253 ));
254 }
255
256 #[test]
257 fn duplicate_names_rejected() {
258 assert!(BaselineComparator::new(&["a", "a"], 10).is_err());
259 assert!(BaselineComparator::new(&["a", "b", "a"], 10).is_err());
260 }
261
262 #[test]
263 fn zero_window_rejected() {
264 assert!(BaselineComparator::new(&["a"], 0).is_err());
265 }
266
267 #[test]
268 fn record_updates_entry() {
269 let mut c = BaselineComparator::new(&["a"], 10).unwrap();
270 assert_eq!(c.entry(0).unwrap().total_samples(), 0);
271 assert!(c.entry(0).unwrap().rolling_mae().is_none());
272 c.record(0, 1.0, 1.5).unwrap();
273 assert_eq!(c.entry(0).unwrap().total_samples(), 1);
274 assert_eq!(c.entry(0).unwrap().rolling_mae(), Some(0.5));
275 }
276
277 #[test]
278 fn record_out_of_bounds_rejected() {
279 let mut c = BaselineComparator::new(&["a"], 10).unwrap();
280 assert!(c.record(1, 1.0, 1.0).is_err());
281 assert!(c.record(0, 1.0, 1.0).is_ok());
282 }
283
284 #[test]
285 fn update_best_selects_lowest_error() {
286 let mut c = BaselineComparator::new(&["a", "b"], 10).unwrap();
287 c.record(0, 1.0, 1.5).unwrap(); c.record(1, 1.0, 1.2).unwrap(); assert_eq!(c.update_best(), Some(1));
290 assert_eq!(c.best_index(), Some(1));
291 assert!((c.best_error().unwrap() - 0.2).abs() < 1e-9);
292 }
293
294 #[test]
295 fn update_best_returns_new_index_on_switch() {
296 let mut c = BaselineComparator::new(&["a", "b"], 10).unwrap();
297 c.record(0, 1.0, 1.5).unwrap(); assert_eq!(c.update_best(), Some(0));
299 c.record(1, 1.0, 1.1).unwrap(); assert_eq!(c.update_best(), Some(1));
301 }
302
303 #[test]
304 fn update_best_returns_none_on_no_change() {
305 let mut c = BaselineComparator::new(&["a", "b"], 10).unwrap();
306 c.record(0, 1.0, 1.5).unwrap(); c.record(1, 1.0, 1.9).unwrap(); assert_eq!(c.update_best(), Some(0));
309 assert_eq!(c.update_best(), None);
310 }
311
312 #[test]
313 fn insufficient_data_when_all_empty() {
314 let mut c = BaselineComparator::new(&["a", "b"], 10).unwrap();
315 assert_eq!(c.update_best(), None);
316 assert_eq!(c.best_index(), None);
317 assert_eq!(c.last_switch_reason(), Some(SwitchReason::InsufficientData));
318 assert_eq!(c.switch_count(), 0);
319 }
320
321 #[test]
322 fn tie_records_tie_reason() {
323 let mut c = BaselineComparator::new(&["a", "b"], 10).unwrap();
324 c.record(1, 1.0, 1.5).unwrap(); assert_eq!(c.update_best(), Some(1));
327 assert_eq!(c.last_switch_reason(), Some(SwitchReason::LowerError));
328 c.record(0, 1.0, 1.5).unwrap(); assert_eq!(c.update_best(), Some(0));
332 assert_eq!(c.last_switch_reason(), Some(SwitchReason::Tie));
333 }
334
335 #[test]
336 fn switch_count_increments() {
337 let mut c = BaselineComparator::new(&["a", "b"], 10).unwrap();
338 assert_eq!(c.switch_count(), 0);
339
340 c.record(0, 1.0, 1.1).unwrap(); assert_eq!(c.update_best(), Some(0));
342 assert_eq!(c.switch_count(), 1);
343
344 c.record(1, 1.0, 1.05).unwrap(); assert_eq!(c.update_best(), Some(1));
346 assert_eq!(c.switch_count(), 2);
347
348 c.record(0, 1.0, 1.02).unwrap();
350 assert_eq!(c.update_best(), None);
351 assert_eq!(c.switch_count(), 2);
352
353 c.record(0, 1.0, 1.0).unwrap();
355 assert_eq!(c.update_best(), Some(0));
356 assert_eq!(c.switch_count(), 3);
357 }
358
359 #[test]
360 fn best_name_maps_index() {
361 let mut c = BaselineComparator::new(&["alpha", "beta"], 10).unwrap();
362 c.record(1, 1.0, 1.1).unwrap(); assert_eq!(c.update_best(), Some(1));
364 assert_eq!(c.best_name(), Some("beta"));
365 assert_eq!(c.entry(1).unwrap().name(), "beta");
366 }
367
368 #[test]
369 fn entry_out_of_bounds() {
370 let c = BaselineComparator::new(&["a"], 10).unwrap();
371 assert!(c.entry(0).is_some());
372 assert!(c.entry(99).is_none());
373 }
374
375 #[test]
376 fn reset_clears_all() {
377 let mut c = BaselineComparator::new(&["a", "b"], 10).unwrap();
378 c.record(0, 1.0, 1.5).unwrap();
379 c.record(1, 1.0, 1.3).unwrap();
380 c.update_best();
381 assert_eq!(c.switch_count(), 1);
382
383 c.reset();
384 assert_eq!(c.entry_count(), 2);
385 assert_eq!(c.entry(0).unwrap().total_samples(), 0);
386 assert!(c.entry(0).unwrap().rolling_mae().is_none());
387 assert_eq!(c.entry(1).unwrap().total_samples(), 0);
388 assert!(c.entry(1).unwrap().rolling_mae().is_none());
389 assert_eq!(c.best_index(), None);
390 assert_eq!(c.switch_count(), 0);
391 assert_eq!(c.last_switch_reason(), None);
392 }
393
394 #[test]
395 fn three_way_comparison() {
396 let mut c = BaselineComparator::new(&["x", "y", "z"], 10).unwrap();
397 c.record(0, 1.0, 1.5).unwrap(); c.record(1, 1.0, 1.3).unwrap(); c.record(2, 1.0, 1.1).unwrap(); assert_eq!(c.update_best(), Some(2));
401 assert_eq!(c.best_name(), Some("z"));
402 assert!((c.best_error().unwrap() - 0.1).abs() < 1e-9);
403 assert_eq!(c.entry_count(), 3);
404 }
405
406 #[cfg(feature = "serde")]
407 #[test]
408 fn serde_roundtrip() {
409 let mut c = BaselineComparator::new(&["a", "b", "c"], 5).unwrap();
410 c.record(0, 1.0, 1.5).unwrap();
411 c.record(1, 1.0, 1.2).unwrap();
412 c.record(2, 1.0, 1.4).unwrap();
413 c.update_best();
414
415 let json = serde_json::to_string(&c).unwrap();
416 let restored: BaselineComparator = serde_json::from_str(&json).unwrap();
417 assert_eq!(restored.entry_count(), 3);
418 assert_eq!(restored.best_index(), Some(1));
419 assert_eq!(restored.best_name(), Some("b"));
420 assert_eq!(restored.switch_count(), 1);
421 assert_eq!(
422 restored.last_switch_reason(),
423 Some(SwitchReason::LowerError)
424 );
425 assert_eq!(restored.entry(0).unwrap().total_samples(), 1);
426 }
427}