1use crate::error::RillError;
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 += 1;
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 if err < best_err {
154 best_err = err;
155 new_best = Some(i);
156 }
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 += 1;
169 let reason = match self.best_index {
170 None => SwitchReason::LowerError,
171 Some(old_idx) => match self.entries[old_idx].rolling_mae() {
172 None => SwitchReason::LowerError,
173 Some(old_err) => {
174 if best_err < old_err {
175 SwitchReason::LowerError
176 } else if best_err == old_err {
177 SwitchReason::Tie
178 } else {
179 SwitchReason::LowerError
182 }
183 }
184 },
185 };
186 self.best_index = Some(new_idx);
187 self.last_switch_reason = Some(reason);
188 Some(new_idx)
189 }
190 }
191 }
192
193 pub const fn best_index(&self) -> Option<usize> {
195 self.best_index
196 }
197
198 pub fn best_name(&self) -> Option<&str> {
200 self.best_index.map(|i| self.entries[i].name.as_str())
201 }
202
203 pub fn best_error(&self) -> Option<f64> {
205 self.best_index.and_then(|i| self.entries[i].rolling_mae())
206 }
207
208 pub fn entry_count(&self) -> usize {
210 self.entries.len()
211 }
212
213 pub const fn switch_count(&self) -> u64 {
215 self.switch_count
216 }
217
218 pub const fn last_switch_reason(&self) -> Option<SwitchReason> {
220 self.last_switch_reason
221 }
222
223 pub fn entry(&self, index: usize) -> Option<&ComparatorEntry> {
225 self.entries.get(index)
226 }
227
228 pub fn reset(&mut self) {
231 for entry in &mut self.entries {
232 entry.rolling_mae.reset();
233 entry.total_samples = 0;
234 }
235 self.best_index = None;
236 self.switch_count = 0;
237 self.last_switch_reason = None;
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn empty_names_rejected() {
247 assert!(matches!(
248 BaselineComparator::new(&[], 10),
249 Err(RillError::EmptyFeatures)
250 ));
251 }
252
253 #[test]
254 fn duplicate_names_rejected() {
255 assert!(BaselineComparator::new(&["a", "a"], 10).is_err());
256 assert!(BaselineComparator::new(&["a", "b", "a"], 10).is_err());
257 }
258
259 #[test]
260 fn zero_window_rejected() {
261 assert!(BaselineComparator::new(&["a"], 0).is_err());
262 }
263
264 #[test]
265 fn record_updates_entry() {
266 let mut c = BaselineComparator::new(&["a"], 10).unwrap();
267 assert_eq!(c.entry(0).unwrap().total_samples(), 0);
268 assert!(c.entry(0).unwrap().rolling_mae().is_none());
269 c.record(0, 1.0, 1.5).unwrap();
270 assert_eq!(c.entry(0).unwrap().total_samples(), 1);
271 assert_eq!(c.entry(0).unwrap().rolling_mae(), Some(0.5));
272 }
273
274 #[test]
275 fn record_out_of_bounds_rejected() {
276 let mut c = BaselineComparator::new(&["a"], 10).unwrap();
277 assert!(c.record(1, 1.0, 1.0).is_err());
278 assert!(c.record(0, 1.0, 1.0).is_ok());
279 }
280
281 #[test]
282 fn update_best_selects_lowest_error() {
283 let mut c = BaselineComparator::new(&["a", "b"], 10).unwrap();
284 c.record(0, 1.0, 1.5).unwrap(); c.record(1, 1.0, 1.2).unwrap(); assert_eq!(c.update_best(), Some(1));
287 assert_eq!(c.best_index(), Some(1));
288 assert!((c.best_error().unwrap() - 0.2).abs() < 1e-9);
289 }
290
291 #[test]
292 fn update_best_returns_new_index_on_switch() {
293 let mut c = BaselineComparator::new(&["a", "b"], 10).unwrap();
294 c.record(0, 1.0, 1.5).unwrap(); assert_eq!(c.update_best(), Some(0));
296 c.record(1, 1.0, 1.1).unwrap(); assert_eq!(c.update_best(), Some(1));
298 }
299
300 #[test]
301 fn update_best_returns_none_on_no_change() {
302 let mut c = BaselineComparator::new(&["a", "b"], 10).unwrap();
303 c.record(0, 1.0, 1.5).unwrap(); c.record(1, 1.0, 1.9).unwrap(); assert_eq!(c.update_best(), Some(0));
306 assert_eq!(c.update_best(), None);
307 }
308
309 #[test]
310 fn insufficient_data_when_all_empty() {
311 let mut c = BaselineComparator::new(&["a", "b"], 10).unwrap();
312 assert_eq!(c.update_best(), None);
313 assert_eq!(c.best_index(), None);
314 assert_eq!(c.last_switch_reason(), Some(SwitchReason::InsufficientData));
315 assert_eq!(c.switch_count(), 0);
316 }
317
318 #[test]
319 fn tie_records_tie_reason() {
320 let mut c = BaselineComparator::new(&["a", "b"], 10).unwrap();
321 c.record(1, 1.0, 1.5).unwrap(); assert_eq!(c.update_best(), Some(1));
324 assert_eq!(c.last_switch_reason(), Some(SwitchReason::LowerError));
325 c.record(0, 1.0, 1.5).unwrap(); assert_eq!(c.update_best(), Some(0));
329 assert_eq!(c.last_switch_reason(), Some(SwitchReason::Tie));
330 }
331
332 #[test]
333 fn switch_count_increments() {
334 let mut c = BaselineComparator::new(&["a", "b"], 10).unwrap();
335 assert_eq!(c.switch_count(), 0);
336
337 c.record(0, 1.0, 1.1).unwrap(); assert_eq!(c.update_best(), Some(0));
339 assert_eq!(c.switch_count(), 1);
340
341 c.record(1, 1.0, 1.05).unwrap(); assert_eq!(c.update_best(), Some(1));
343 assert_eq!(c.switch_count(), 2);
344
345 c.record(0, 1.0, 1.02).unwrap();
347 assert_eq!(c.update_best(), None);
348 assert_eq!(c.switch_count(), 2);
349
350 c.record(0, 1.0, 1.0).unwrap();
352 assert_eq!(c.update_best(), Some(0));
353 assert_eq!(c.switch_count(), 3);
354 }
355
356 #[test]
357 fn best_name_maps_index() {
358 let mut c = BaselineComparator::new(&["alpha", "beta"], 10).unwrap();
359 c.record(1, 1.0, 1.1).unwrap(); assert_eq!(c.update_best(), Some(1));
361 assert_eq!(c.best_name(), Some("beta"));
362 assert_eq!(c.entry(1).unwrap().name(), "beta");
363 }
364
365 #[test]
366 fn entry_out_of_bounds() {
367 let c = BaselineComparator::new(&["a"], 10).unwrap();
368 assert!(c.entry(0).is_some());
369 assert!(c.entry(99).is_none());
370 }
371
372 #[test]
373 fn reset_clears_all() {
374 let mut c = BaselineComparator::new(&["a", "b"], 10).unwrap();
375 c.record(0, 1.0, 1.5).unwrap();
376 c.record(1, 1.0, 1.3).unwrap();
377 c.update_best();
378 assert_eq!(c.switch_count(), 1);
379
380 c.reset();
381 assert_eq!(c.entry_count(), 2);
382 assert_eq!(c.entry(0).unwrap().total_samples(), 0);
383 assert!(c.entry(0).unwrap().rolling_mae().is_none());
384 assert_eq!(c.entry(1).unwrap().total_samples(), 0);
385 assert!(c.entry(1).unwrap().rolling_mae().is_none());
386 assert_eq!(c.best_index(), None);
387 assert_eq!(c.switch_count(), 0);
388 assert_eq!(c.last_switch_reason(), None);
389 }
390
391 #[test]
392 fn three_way_comparison() {
393 let mut c = BaselineComparator::new(&["x", "y", "z"], 10).unwrap();
394 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));
398 assert_eq!(c.best_name(), Some("z"));
399 assert!((c.best_error().unwrap() - 0.1).abs() < 1e-9);
400 assert_eq!(c.entry_count(), 3);
401 }
402
403 #[cfg(feature = "serde")]
404 #[test]
405 fn serde_roundtrip() {
406 let mut c = BaselineComparator::new(&["a", "b", "c"], 5).unwrap();
407 c.record(0, 1.0, 1.5).unwrap();
408 c.record(1, 1.0, 1.2).unwrap();
409 c.record(2, 1.0, 1.4).unwrap();
410 c.update_best();
411
412 let json = serde_json::to_string(&c).unwrap();
413 let restored: BaselineComparator = serde_json::from_str(&json).unwrap();
414 assert_eq!(restored.entry_count(), 3);
415 assert_eq!(restored.best_index(), Some(1));
416 assert_eq!(restored.best_name(), Some("b"));
417 assert_eq!(restored.switch_count(), 1);
418 assert_eq!(
419 restored.last_switch_reason(),
420 Some(SwitchReason::LowerError)
421 );
422 assert_eq!(restored.entry(0).unwrap().total_samples(), 1);
423 }
424}