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, checked_increment};
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 = checked_increment(entry.total_samples, "total_samples")?;
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                // saturating_add avoids overflow without changing the Option
169                // return signature (changing it would cascade to callers and
170                // doctests); switch_count is a diagnostic counter.
171                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                                // Unreachable: best_err is the minimum across all
183                                // entries, so it cannot exceed the old best's error.
184                                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    /// Index of the current best entry, or `None` if no entry has data.
197    pub const fn best_index(&self) -> Option<usize> {
198        self.best_index
199    }
200
201    /// Name of the current best entry, or `None` if no entry has data.
202    pub fn best_name(&self) -> Option<&str> {
203        self.best_index.map(|i| self.entries[i].name.as_str())
204    }
205
206    /// Rolling MAE of the current best entry, or `None` if unavailable.
207    pub fn best_error(&self) -> Option<f64> {
208        self.best_index.and_then(|i| self.entries[i].rolling_mae())
209    }
210
211    /// Number of tracked entries.
212    pub fn entry_count(&self) -> usize {
213        self.entries.len()
214    }
215
216    /// How many times the best entry has changed since construction or last reset.
217    pub const fn switch_count(&self) -> u64 {
218        self.switch_count
219    }
220
221    /// The reason for the most recent best-entry change (or `InsufficientData`).
222    pub const fn last_switch_reason(&self) -> Option<SwitchReason> {
223        self.last_switch_reason
224    }
225
226    /// Borrow the entry at `index`, or `None` if out of bounds.
227    pub fn entry(&self, index: usize) -> Option<&ComparatorEntry> {
228        self.entries.get(index)
229    }
230
231    /// Reset every entry's rolling metric and sample count, and clear all
232    /// best-entry tracking state.
233    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(); // error 0.5
288        c.record(1, 1.0, 1.2).unwrap(); // error 0.2
289        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(); // a: 0.5
298        assert_eq!(c.update_best(), Some(0));
299        c.record(1, 1.0, 1.1).unwrap(); // b: 0.1, lower
300        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(); // a: 0.5
307        c.record(1, 1.0, 1.9).unwrap(); // b: 0.9
308        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        // Record identical error for "b" first so it becomes the initial best.
325        c.record(1, 1.0, 1.5).unwrap(); // error 0.5
326        assert_eq!(c.update_best(), Some(1));
327        assert_eq!(c.last_switch_reason(), Some(SwitchReason::LowerError));
328        // Record identical error for "a" (index 0). Iteration picks the first
329        // minimum, so the best switches from index 1 to index 0 with equal error.
330        c.record(0, 1.0, 1.5).unwrap(); // error 0.5
331        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(); // a: 0.1
341        assert_eq!(c.update_best(), Some(0));
342        assert_eq!(c.switch_count(), 1);
343
344        c.record(1, 1.0, 1.05).unwrap(); // b: 0.05 < 0.1
345        assert_eq!(c.update_best(), Some(1));
346        assert_eq!(c.switch_count(), 2);
347
348        // a's rolling mae becomes (0.1 + 0.02) / 2 = 0.06, still worse than b's 0.05
349        c.record(0, 1.0, 1.02).unwrap();
350        assert_eq!(c.update_best(), None);
351        assert_eq!(c.switch_count(), 2);
352
353        // a's rolling mae becomes (0.1 + 0.02 + 0.0) / 3 ≈ 0.04 < 0.05
354        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(); // beta: 0.1
363        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(); // x: 0.5
398        c.record(1, 1.0, 1.3).unwrap(); // y: 0.3
399        c.record(2, 1.0, 1.1).unwrap(); // z: 0.1
400        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}