Skip to main content

rill_ml/diagnostics/
model_selector.rs

1//! Online model selector with cooling period and minimum sample requirements.
2//!
3//! Wraps a [`BaselineComparator`] with additional constraints to prevent
4//! frequent model switching:
5//!
6//! - **Cooling period**: after a switch, no new switch is allowed for a
7//!   configurable number of samples recorded on the current best model.
8//! - **Minimum samples**: a model must have at least a configurable number of
9//!   recorded samples before it can be selected as the best.
10//!
11//! Space complexity: `O(n * window_size)` where `n` is the number of models.
12//!
13//! # Examples
14//!
15//! ```
16//! use rill_ml::diagnostics::{OnlineModelSelector, SelectorConfig};
17//!
18//! let config = SelectorConfig::default();
19//! let mut selector = OnlineModelSelector::new(&["model_a", "model_b"], config).unwrap();
20//!
21//! for _ in 0..20 {
22//!     let truth = 1.0;
23//!     selector.record(0, truth, truth + 1.0).unwrap();
24//!     selector.record(1, truth, truth + 0.5).unwrap();
25//! }
26//!
27//! let best = selector.select();
28//! assert_eq!(best, Some(1));
29//! ```
30
31use crate::diagnostics::baseline_comparator::{BaselineComparator, ComparatorEntry};
32use crate::error::{RillError, checked_increment};
33
34/// Configuration for [`OnlineModelSelector`].
35#[derive(Debug, Clone)]
36#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
37pub struct SelectorConfig {
38    /// Rolling window size passed to the underlying [`BaselineComparator`].
39    ///
40    /// Must be greater than zero.
41    pub window_size: usize,
42
43    /// Number of samples that must be recorded on the current best model
44    /// before another switch is allowed.
45    pub cooling_period: u64,
46
47    /// Minimum number of samples a model must have before it can be selected
48    /// as the best.
49    pub min_samples_before_switch: u64,
50}
51
52impl Default for SelectorConfig {
53    fn default() -> Self {
54        Self {
55            window_size: 50,
56            cooling_period: 20,
57            min_samples_before_switch: 10,
58        }
59    }
60}
61
62/// Online model selector with cooling period and minimum sample requirements.
63///
64/// Wraps a [`BaselineComparator`] to prevent frequent model switching. The
65/// selector delegates error tracking and best-entry detection to the
66/// comparator, then applies two additional gates before committing to a
67/// switch:
68///
69/// 1. The candidate must have at least [`SelectorConfig::min_samples_before_switch`]
70///    total observations.
71/// 2. At least [`SelectorConfig::cooling_period`] samples must have been
72///    recorded on the *current* best model since the last switch.
73///
74/// # Examples
75///
76/// ```
77/// use rill_ml::diagnostics::{OnlineModelSelector, SelectorConfig};
78///
79/// let mut selector = OnlineModelSelector::new(&["a", "b"], SelectorConfig::default()).unwrap();
80/// selector.record(0, 1.0, 1.1).unwrap();
81/// selector.record(1, 1.0, 0.9).unwrap();
82/// let _ = selector.select();
83/// ```
84#[derive(Debug, Clone)]
85#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
86pub struct OnlineModelSelector {
87    comparator: BaselineComparator,
88    config: SelectorConfig,
89    current_best: Option<usize>,
90    samples_since_switch: u64,
91}
92
93impl OnlineModelSelector {
94    /// Create a new model selector.
95    ///
96    /// # Errors
97    ///
98    /// Returns [`RillError::InvalidWindowSize`] if `config.window_size` is zero.
99    /// Returns [`RillError::EmptyFeatures`] if `names` is empty (delegated to
100    /// [`BaselineComparator::new`]).
101    pub fn new(names: &[&str], config: SelectorConfig) -> Result<Self, RillError> {
102        if config.window_size == 0 {
103            return Err(RillError::InvalidWindowSize);
104        }
105        let comparator = BaselineComparator::new(names, config.window_size)?;
106        Ok(Self {
107            comparator,
108            config,
109            current_best: None,
110            samples_since_switch: 0,
111        })
112    }
113
114    /// Record a prediction from the model at `index`.
115    ///
116    /// If `index` matches the current best model, the sample counter is
117    /// incremented (used for the cooling period).
118    ///
119    /// # Errors
120    ///
121    /// Returns [`RillError::DimensionMismatch`] if `index` is out of bounds,
122    /// and propagates any finiteness error from the underlying metric.
123    pub fn record(&mut self, index: usize, truth: f64, prediction: f64) -> Result<(), RillError> {
124        self.comparator.record(index, truth, prediction)?;
125        if self.current_best == Some(index) {
126            self.samples_since_switch =
127                checked_increment(self.samples_since_switch, "samples_since_switch")?;
128        }
129        Ok(())
130    }
131
132    /// Select the best model, applying cooling period and minimum sample
133    /// constraints.
134    ///
135    /// Returns the index of the selected model, or `None` if no model has
136    /// enough samples yet. The method is `&mut self` because it calls
137    /// [`BaselineComparator::update_best`] internally.
138    pub fn select(&mut self) -> Option<usize> {
139        let new_best = match self.comparator.update_best() {
140            None => return self.current_best,
141            Some(idx) => idx,
142        };
143
144        // Check minimum samples for the candidate.
145        let min_samples = self.config.min_samples_before_switch;
146        let has_enough = self
147            .comparator
148            .entry(new_best)
149            .map(|e| e.total_samples() >= min_samples)
150            .unwrap_or(false);
151        if !has_enough {
152            return self.current_best;
153        }
154
155        match self.current_best {
156            None => {
157                // First selection: only the minimum-samples gate applies.
158                self.current_best = Some(new_best);
159                self.samples_since_switch = 0;
160            }
161            Some(current) if new_best == current => {
162                // The comparator's best returned to the currently selected
163                // model; no switch is needed.
164                return self.current_best;
165            }
166            Some(_) => {
167                // A different model is now the best. Enforce the cooling
168                // period before committing to the switch.
169                if self.samples_since_switch < self.config.cooling_period {
170                    return self.current_best;
171                }
172                self.current_best = Some(new_best);
173                self.samples_since_switch = 0;
174            }
175        }
176
177        self.current_best
178    }
179
180    /// Returns the index of the currently selected best model, if any.
181    pub const fn current_best(&self) -> Option<usize> {
182        self.current_best
183    }
184
185    /// Returns the name of the currently selected best model, if any.
186    pub fn current_best_name(&self) -> Option<&str> {
187        let idx = self.current_best?;
188        self.comparator.entry(idx).map(|e| e.name())
189    }
190
191    /// Number of times the underlying comparator has switched its best model.
192    pub const fn switch_count(&self) -> u64 {
193        self.comparator.switch_count()
194    }
195
196    /// Number of models tracked by the selector.
197    pub fn entry_count(&self) -> usize {
198        self.comparator.entry_count()
199    }
200
201    /// Returns the metrics entry for the model at `index`, if it exists.
202    pub fn entry_metrics(&self, index: usize) -> Option<&ComparatorEntry> {
203        self.comparator.entry(index)
204    }
205
206    /// Reset the selector and underlying comparator to their initial state.
207    ///
208    /// The number of tracked entries is preserved; only their data and the
209    /// selection state are cleared.
210    pub fn reset(&mut self) {
211        self.comparator.reset();
212        self.current_best = None;
213        self.samples_since_switch = 0;
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn empty_names_rejected() {
223        let config = SelectorConfig::default();
224        assert!(OnlineModelSelector::new(&[], config).is_err());
225    }
226
227    #[test]
228    fn zero_window_rejected() {
229        let config = SelectorConfig {
230            window_size: 0,
231            ..Default::default()
232        };
233        assert!(OnlineModelSelector::new(&["a"], config).is_err());
234    }
235
236    #[test]
237    fn select_returns_none_initially() {
238        let mut selector =
239            OnlineModelSelector::new(&["a", "b"], SelectorConfig::default()).unwrap();
240        assert_eq!(selector.select(), None);
241        assert_eq!(selector.current_best(), None);
242        assert_eq!(selector.current_best_name(), None);
243    }
244
245    #[test]
246    fn select_after_min_samples() {
247        let config = SelectorConfig {
248            window_size: 50,
249            cooling_period: 0,
250            min_samples_before_switch: 5,
251        };
252        let mut selector = OnlineModelSelector::new(&["a", "b"], config).unwrap();
253
254        for _ in 0..10 {
255            selector.record(0, 1.0, 1.0).unwrap();
256            selector.record(1, 1.0, 2.0).unwrap();
257        }
258        assert_eq!(selector.select(), Some(0));
259    }
260
261    #[test]
262    fn cooling_period_prevents_switch() {
263        let config = SelectorConfig {
264            window_size: 50,
265            cooling_period: 100,
266            min_samples_before_switch: 2,
267        };
268        let mut selector = OnlineModelSelector::new(&["a", "b"], config).unwrap();
269
270        // Model 0 is better.
271        for _ in 0..10 {
272            selector.record(0, 1.0, 1.0).unwrap();
273            selector.record(1, 1.0, 2.0).unwrap();
274        }
275        assert_eq!(selector.select(), Some(0));
276
277        // Model 1 becomes better, but cooling period prevents switch.
278        for _ in 0..20 {
279            selector.record(0, 1.0, 2.0).unwrap();
280            selector.record(1, 1.0, 1.0).unwrap();
281        }
282        // samples_since_switch = 20 < 100, no switch.
283        assert_eq!(selector.select(), Some(0));
284    }
285
286    #[test]
287    fn min_samples_required() {
288        let config = SelectorConfig {
289            window_size: 50,
290            cooling_period: 0,
291            min_samples_before_switch: 100,
292        };
293        let mut selector = OnlineModelSelector::new(&["a", "b"], config).unwrap();
294
295        for _ in 0..10 {
296            selector.record(0, 1.0, 1.0).unwrap();
297            selector.record(1, 1.0, 2.0).unwrap();
298        }
299        // Model 0 is best but has only 10 samples < 100.
300        assert_eq!(selector.select(), None);
301    }
302
303    #[test]
304    fn best_model_selected() {
305        let mut selector =
306            OnlineModelSelector::new(&["a", "b", "c"], SelectorConfig::default()).unwrap();
307
308        for _ in 0..15 {
309            selector.record(0, 1.0, 2.0).unwrap(); // error 1
310            selector.record(1, 1.0, 1.0).unwrap(); // error 0
311            selector.record(2, 1.0, 3.0).unwrap(); // error 2
312        }
313        assert_eq!(selector.select(), Some(1));
314    }
315
316    #[test]
317    fn switch_count_tracked() {
318        let mut selector =
319            OnlineModelSelector::new(&["a", "b"], SelectorConfig::default()).unwrap();
320        assert_eq!(selector.switch_count(), 0);
321
322        for _ in 0..15 {
323            selector.record(0, 1.0, 1.0).unwrap();
324            selector.record(1, 1.0, 2.0).unwrap();
325        }
326        selector.select();
327        // update_best detected a change (None -> Some(0)).
328        assert_eq!(selector.switch_count(), 1);
329    }
330
331    #[test]
332    fn reset_clears_all() {
333        let mut selector =
334            OnlineModelSelector::new(&["a", "b"], SelectorConfig::default()).unwrap();
335        for _ in 0..15 {
336            selector.record(0, 1.0, 1.0).unwrap();
337            selector.record(1, 1.0, 2.0).unwrap();
338        }
339        selector.select();
340        assert!(selector.current_best().is_some());
341
342        selector.reset();
343        assert_eq!(selector.current_best(), None);
344        assert_eq!(selector.current_best_name(), None);
345        assert_eq!(selector.switch_count(), 0);
346        assert_eq!(selector.entry_count(), 2);
347    }
348
349    #[test]
350    fn record_out_of_bounds_rejected() {
351        let mut selector =
352            OnlineModelSelector::new(&["a", "b"], SelectorConfig::default()).unwrap();
353        assert!(selector.record(2, 1.0, 1.0).is_err());
354        assert!(selector.record(0, 1.0, 1.0).is_ok());
355    }
356
357    #[test]
358    fn two_models_alternating() {
359        let config = SelectorConfig {
360            window_size: 10,
361            cooling_period: 100,
362            min_samples_before_switch: 2,
363        };
364        let mut selector = OnlineModelSelector::new(&["a", "b"], config).unwrap();
365
366        // Phase 1: model 0 is better.
367        for _ in 0..3 {
368            selector.record(0, 1.0, 1.0).unwrap(); // error 0
369            selector.record(1, 1.0, 2.0).unwrap(); // error 1
370        }
371        assert_eq!(selector.select(), Some(0));
372
373        // Phase 2: model 1 becomes better, but cooling period prevents switch.
374        for _ in 0..7 {
375            selector.record(0, 1.0, 2.0).unwrap(); // error 1
376            selector.record(1, 1.0, 1.0).unwrap(); // error 0
377        }
378        // samples_since_switch = 7 < 100, no switch.
379        assert_eq!(selector.select(), Some(0));
380
381        // Phase 3: model 0 becomes better again.
382        for _ in 0..10 {
383            selector.record(0, 1.0, 1.0).unwrap();
384            selector.record(1, 1.0, 2.0).unwrap();
385        }
386        // update_best returns Some(0), but current_best is already 0.
387        assert_eq!(selector.select(), Some(0));
388    }
389
390    #[test]
391    fn current_best_name() {
392        let mut selector =
393            OnlineModelSelector::new(&["alpha", "beta"], SelectorConfig::default()).unwrap();
394
395        for _ in 0..15 {
396            selector.record(0, 1.0, 1.0).unwrap();
397            selector.record(1, 1.0, 2.0).unwrap();
398        }
399        selector.select();
400        assert_eq!(selector.current_best(), Some(0));
401        assert_eq!(selector.current_best_name(), Some("alpha"));
402    }
403
404    #[test]
405    fn entry_metrics_access() {
406        let mut selector =
407            OnlineModelSelector::new(&["a", "b"], SelectorConfig::default()).unwrap();
408        for _ in 0..5 {
409            selector.record(0, 1.0, 1.0).unwrap();
410            selector.record(1, 1.0, 2.0).unwrap();
411        }
412
413        let entry0 = selector.entry_metrics(0).expect("entry 0 should exist");
414        assert_eq!(entry0.name(), "a");
415        assert!(entry0.total_samples() >= 5);
416
417        let entry1 = selector.entry_metrics(1).expect("entry 1 should exist");
418        assert_eq!(entry1.name(), "b");
419        assert!(entry1.total_samples() >= 5);
420
421        assert!(selector.entry_metrics(2).is_none());
422    }
423
424    #[test]
425    fn three_models_selection() {
426        let mut selector =
427            OnlineModelSelector::new(&["x", "y", "z"], SelectorConfig::default()).unwrap();
428
429        for _ in 0..15 {
430            selector.record(0, 1.0, 3.0).unwrap(); // error 2
431            selector.record(1, 1.0, 1.0).unwrap(); // error 0
432            selector.record(2, 1.0, 2.0).unwrap(); // error 1
433        }
434        assert_eq!(selector.select(), Some(1));
435        assert_eq!(selector.current_best_name(), Some("y"));
436        assert_eq!(selector.entry_count(), 3);
437    }
438
439    #[cfg(feature = "serde")]
440    #[test]
441    fn serde_roundtrip() {
442        let mut selector =
443            OnlineModelSelector::new(&["a", "b"], SelectorConfig::default()).unwrap();
444        for _ in 0..15 {
445            selector.record(0, 1.0, 1.0).unwrap();
446            selector.record(1, 1.0, 2.0).unwrap();
447        }
448        selector.select();
449
450        let json = serde_json::to_string(&selector).unwrap();
451        let restored: OnlineModelSelector = serde_json::from_str(&json).unwrap();
452        assert_eq!(restored.current_best(), selector.current_best());
453        assert_eq!(restored.entry_count(), selector.entry_count());
454        assert_eq!(restored.switch_count(), selector.switch_count());
455        assert_eq!(restored.current_best_name(), Some("a"));
456    }
457}