Skip to main content

rill_ml/diagnostics/
baseline_comparator.rs

1//! Baseline comparison across multiple models.
2//!
3//! Tracks progressive error metrics for several models in order to identify
4//! the current best performer. The comparator stores only the rolling error
5//! metrics, not the models themselves, keeping it fully decoupled from any
6//! model trait.
7//!
8//! Space complexity: `O(n * window_size)` where `n` is the number of entries.
9//!
10//! # Examples
11//!
12//! ```
13//! use rill_ml::diagnostics::BaselineComparator;
14//!
15//! let mut cmp = BaselineComparator::new(&["baseline", "candidate"], 16).unwrap();
16//! cmp.record(0, 1.0, 1.4).unwrap(); // baseline error 0.4
17//! cmp.record(1, 1.0, 1.1).unwrap(); // candidate error 0.1
18//! assert_eq!(cmp.update_best(), Some(1));
19//! assert_eq!(cmp.best_name(), Some("candidate"));
20//! ```
21
22use crate::error::RillError;
23use crate::metrics::RollingMae;
24use crate::traits::Metric;
25
26/// Why the active best entry changed (or could not be determined).
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29pub enum SwitchReason {
30    /// The new best entry has a strictly lower error than the previous best.
31    LowerError,
32    /// Not enough data has been observed to compare entries.
33    InsufficientData,
34    /// The new best entry ties with the previous best on error.
35    Tie,
36}
37
38/// A single tracked model entry inside a [`BaselineComparator`].
39///
40/// Stores the entry's name, a rolling MAE over a fixed window, and the total
41/// number of samples recorded against it.
42#[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    /// The name of this entry.
52    pub fn name(&self) -> &str {
53        &self.name
54    }
55
56    /// Current rolling MAE value, or `None` if no observations have been recorded.
57    pub fn rolling_mae(&self) -> Option<f64> {
58        self.rolling_mae.value()
59    }
60
61    /// Total number of observations recorded against this entry.
62    pub const fn total_samples(&self) -> u64 {
63        self.total_samples
64    }
65}
66
67/// Compares multiple models by their rolling error and tracks the current best.
68///
69/// Each entry is identified by a name and maintains its own [`RollingMae`].
70/// The comparator does not store models, only error metrics, so it can be
71/// used alongside any predictor.
72#[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    /// Create a new comparator with one entry per name and the given window size.
83    ///
84    /// # Errors
85    ///
86    /// Returns [`RillError::EmptyFeatures`] if `names` is empty, and
87    /// [`RillError::InvalidParameter`] if `names` contains duplicates.
88    /// A `window_size` of zero propagates [`RillError::InvalidWindowSize`]
89    /// from the underlying [`RollingMae`].
90    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    /// Record a single `(truth, prediction)` observation against the entry at `index`.
121    ///
122    /// # Errors
123    ///
124    /// Returns [`RillError::DimensionMismatch`] if `index` is out of bounds,
125    /// and propagates any finiteness error from the underlying metric.
126    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    /// Recompute the best entry and return the new best index if it changed.
140    ///
141    /// The best entry is the one with the lowest rolling MAE among entries
142    /// that have at least one observation. If all entries are empty, the best
143    /// index is cleared and the last switch reason is set to
144    /// [`SwitchReason::InsufficientData`].
145    ///
146    /// Returns `Some(new_index)` when the best entry changes, and `None`
147    /// otherwise.
148    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 += 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                                // Unreachable: best_err is the minimum across all
180                                // entries, so it cannot exceed the old best's error.
181                                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    /// Index of the current best entry, or `None` if no entry has data.
194    pub const fn best_index(&self) -> Option<usize> {
195        self.best_index
196    }
197
198    /// Name of the current best entry, or `None` if no entry has data.
199    pub fn best_name(&self) -> Option<&str> {
200        self.best_index.map(|i| self.entries[i].name.as_str())
201    }
202
203    /// Rolling MAE of the current best entry, or `None` if unavailable.
204    pub fn best_error(&self) -> Option<f64> {
205        self.best_index.and_then(|i| self.entries[i].rolling_mae())
206    }
207
208    /// Number of tracked entries.
209    pub fn entry_count(&self) -> usize {
210        self.entries.len()
211    }
212
213    /// How many times the best entry has changed since construction or last reset.
214    pub const fn switch_count(&self) -> u64 {
215        self.switch_count
216    }
217
218    /// The reason for the most recent best-entry change (or `InsufficientData`).
219    pub const fn last_switch_reason(&self) -> Option<SwitchReason> {
220        self.last_switch_reason
221    }
222
223    /// Borrow the entry at `index`, or `None` if out of bounds.
224    pub fn entry(&self, index: usize) -> Option<&ComparatorEntry> {
225        self.entries.get(index)
226    }
227
228    /// Reset every entry's rolling metric and sample count, and clear all
229    /// best-entry tracking state.
230    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(); // error 0.5
285        c.record(1, 1.0, 1.2).unwrap(); // error 0.2
286        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(); // a: 0.5
295        assert_eq!(c.update_best(), Some(0));
296        c.record(1, 1.0, 1.1).unwrap(); // b: 0.1, lower
297        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(); // a: 0.5
304        c.record(1, 1.0, 1.9).unwrap(); // b: 0.9
305        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        // Record identical error for "b" first so it becomes the initial best.
322        c.record(1, 1.0, 1.5).unwrap(); // error 0.5
323        assert_eq!(c.update_best(), Some(1));
324        assert_eq!(c.last_switch_reason(), Some(SwitchReason::LowerError));
325        // Record identical error for "a" (index 0). Iteration picks the first
326        // minimum, so the best switches from index 1 to index 0 with equal error.
327        c.record(0, 1.0, 1.5).unwrap(); // error 0.5
328        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(); // a: 0.1
338        assert_eq!(c.update_best(), Some(0));
339        assert_eq!(c.switch_count(), 1);
340
341        c.record(1, 1.0, 1.05).unwrap(); // b: 0.05 < 0.1
342        assert_eq!(c.update_best(), Some(1));
343        assert_eq!(c.switch_count(), 2);
344
345        // a's rolling mae becomes (0.1 + 0.02) / 2 = 0.06, still worse than b's 0.05
346        c.record(0, 1.0, 1.02).unwrap();
347        assert_eq!(c.update_best(), None);
348        assert_eq!(c.switch_count(), 2);
349
350        // a's rolling mae becomes (0.1 + 0.02 + 0.0) / 3 ≈ 0.04 < 0.05
351        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(); // beta: 0.1
360        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(); // x: 0.5
395        c.record(1, 1.0, 1.3).unwrap(); // y: 0.3
396        c.record(2, 1.0, 1.1).unwrap(); // z: 0.1
397        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}