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;
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 += 1;
127        }
128        Ok(())
129    }
130
131    /// Select the best model, applying cooling period and minimum sample
132    /// constraints.
133    ///
134    /// Returns the index of the selected model, or `None` if no model has
135    /// enough samples yet. The method is `&mut self` because it calls
136    /// [`BaselineComparator::update_best`] internally.
137    pub fn select(&mut self) -> Option<usize> {
138        let new_best = match self.comparator.update_best() {
139            None => return self.current_best,
140            Some(idx) => idx,
141        };
142
143        // Check minimum samples for the candidate.
144        let min_samples = self.config.min_samples_before_switch;
145        let has_enough = self
146            .comparator
147            .entry(new_best)
148            .map(|e| e.total_samples() >= min_samples)
149            .unwrap_or(false);
150        if !has_enough {
151            return self.current_best;
152        }
153
154        match self.current_best {
155            None => {
156                // First selection: only the minimum-samples gate applies.
157                self.current_best = Some(new_best);
158                self.samples_since_switch = 0;
159            }
160            Some(current) if new_best == current => {
161                // The comparator's best returned to the currently selected
162                // model; no switch is needed.
163                return self.current_best;
164            }
165            Some(_) => {
166                // A different model is now the best. Enforce the cooling
167                // period before committing to the switch.
168                if self.samples_since_switch < self.config.cooling_period {
169                    return self.current_best;
170                }
171                self.current_best = Some(new_best);
172                self.samples_since_switch = 0;
173            }
174        }
175
176        self.current_best
177    }
178
179    /// Returns the index of the currently selected best model, if any.
180    pub const fn current_best(&self) -> Option<usize> {
181        self.current_best
182    }
183
184    /// Returns the name of the currently selected best model, if any.
185    pub fn current_best_name(&self) -> Option<&str> {
186        let idx = self.current_best?;
187        self.comparator.entry(idx).map(|e| e.name())
188    }
189
190    /// Number of times the underlying comparator has switched its best model.
191    pub const fn switch_count(&self) -> u64 {
192        self.comparator.switch_count()
193    }
194
195    /// Number of models tracked by the selector.
196    pub fn entry_count(&self) -> usize {
197        self.comparator.entry_count()
198    }
199
200    /// Returns the metrics entry for the model at `index`, if it exists.
201    pub fn entry_metrics(&self, index: usize) -> Option<&ComparatorEntry> {
202        self.comparator.entry(index)
203    }
204
205    /// Reset the selector and underlying comparator to their initial state.
206    ///
207    /// The number of tracked entries is preserved; only their data and the
208    /// selection state are cleared.
209    pub fn reset(&mut self) {
210        self.comparator.reset();
211        self.current_best = None;
212        self.samples_since_switch = 0;
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn empty_names_rejected() {
222        let config = SelectorConfig::default();
223        assert!(OnlineModelSelector::new(&[], config).is_err());
224    }
225
226    #[test]
227    fn zero_window_rejected() {
228        let config = SelectorConfig {
229            window_size: 0,
230            ..Default::default()
231        };
232        assert!(OnlineModelSelector::new(&["a"], config).is_err());
233    }
234
235    #[test]
236    fn select_returns_none_initially() {
237        let mut selector =
238            OnlineModelSelector::new(&["a", "b"], SelectorConfig::default()).unwrap();
239        assert_eq!(selector.select(), None);
240        assert_eq!(selector.current_best(), None);
241        assert_eq!(selector.current_best_name(), None);
242    }
243
244    #[test]
245    fn select_after_min_samples() {
246        let config = SelectorConfig {
247            window_size: 50,
248            cooling_period: 0,
249            min_samples_before_switch: 5,
250        };
251        let mut selector = OnlineModelSelector::new(&["a", "b"], config).unwrap();
252
253        for _ in 0..10 {
254            selector.record(0, 1.0, 1.0).unwrap();
255            selector.record(1, 1.0, 2.0).unwrap();
256        }
257        assert_eq!(selector.select(), Some(0));
258    }
259
260    #[test]
261    fn cooling_period_prevents_switch() {
262        let config = SelectorConfig {
263            window_size: 50,
264            cooling_period: 100,
265            min_samples_before_switch: 2,
266        };
267        let mut selector = OnlineModelSelector::new(&["a", "b"], config).unwrap();
268
269        // Model 0 is better.
270        for _ in 0..10 {
271            selector.record(0, 1.0, 1.0).unwrap();
272            selector.record(1, 1.0, 2.0).unwrap();
273        }
274        assert_eq!(selector.select(), Some(0));
275
276        // Model 1 becomes better, but cooling period prevents switch.
277        for _ in 0..20 {
278            selector.record(0, 1.0, 2.0).unwrap();
279            selector.record(1, 1.0, 1.0).unwrap();
280        }
281        // samples_since_switch = 20 < 100, no switch.
282        assert_eq!(selector.select(), Some(0));
283    }
284
285    #[test]
286    fn min_samples_required() {
287        let config = SelectorConfig {
288            window_size: 50,
289            cooling_period: 0,
290            min_samples_before_switch: 100,
291        };
292        let mut selector = OnlineModelSelector::new(&["a", "b"], config).unwrap();
293
294        for _ in 0..10 {
295            selector.record(0, 1.0, 1.0).unwrap();
296            selector.record(1, 1.0, 2.0).unwrap();
297        }
298        // Model 0 is best but has only 10 samples < 100.
299        assert_eq!(selector.select(), None);
300    }
301
302    #[test]
303    fn best_model_selected() {
304        let mut selector =
305            OnlineModelSelector::new(&["a", "b", "c"], SelectorConfig::default()).unwrap();
306
307        for _ in 0..15 {
308            selector.record(0, 1.0, 2.0).unwrap(); // error 1
309            selector.record(1, 1.0, 1.0).unwrap(); // error 0
310            selector.record(2, 1.0, 3.0).unwrap(); // error 2
311        }
312        assert_eq!(selector.select(), Some(1));
313    }
314
315    #[test]
316    fn switch_count_tracked() {
317        let mut selector =
318            OnlineModelSelector::new(&["a", "b"], SelectorConfig::default()).unwrap();
319        assert_eq!(selector.switch_count(), 0);
320
321        for _ in 0..15 {
322            selector.record(0, 1.0, 1.0).unwrap();
323            selector.record(1, 1.0, 2.0).unwrap();
324        }
325        selector.select();
326        // update_best detected a change (None -> Some(0)).
327        assert_eq!(selector.switch_count(), 1);
328    }
329
330    #[test]
331    fn reset_clears_all() {
332        let mut selector =
333            OnlineModelSelector::new(&["a", "b"], SelectorConfig::default()).unwrap();
334        for _ in 0..15 {
335            selector.record(0, 1.0, 1.0).unwrap();
336            selector.record(1, 1.0, 2.0).unwrap();
337        }
338        selector.select();
339        assert!(selector.current_best().is_some());
340
341        selector.reset();
342        assert_eq!(selector.current_best(), None);
343        assert_eq!(selector.current_best_name(), None);
344        assert_eq!(selector.switch_count(), 0);
345        assert_eq!(selector.entry_count(), 2);
346    }
347
348    #[test]
349    fn record_out_of_bounds_rejected() {
350        let mut selector =
351            OnlineModelSelector::new(&["a", "b"], SelectorConfig::default()).unwrap();
352        assert!(selector.record(2, 1.0, 1.0).is_err());
353        assert!(selector.record(0, 1.0, 1.0).is_ok());
354    }
355
356    #[test]
357    fn two_models_alternating() {
358        let config = SelectorConfig {
359            window_size: 10,
360            cooling_period: 100,
361            min_samples_before_switch: 2,
362        };
363        let mut selector = OnlineModelSelector::new(&["a", "b"], config).unwrap();
364
365        // Phase 1: model 0 is better.
366        for _ in 0..3 {
367            selector.record(0, 1.0, 1.0).unwrap(); // error 0
368            selector.record(1, 1.0, 2.0).unwrap(); // error 1
369        }
370        assert_eq!(selector.select(), Some(0));
371
372        // Phase 2: model 1 becomes better, but cooling period prevents switch.
373        for _ in 0..7 {
374            selector.record(0, 1.0, 2.0).unwrap(); // error 1
375            selector.record(1, 1.0, 1.0).unwrap(); // error 0
376        }
377        // samples_since_switch = 7 < 100, no switch.
378        assert_eq!(selector.select(), Some(0));
379
380        // Phase 3: model 0 becomes better again.
381        for _ in 0..10 {
382            selector.record(0, 1.0, 1.0).unwrap();
383            selector.record(1, 1.0, 2.0).unwrap();
384        }
385        // update_best returns Some(0), but current_best is already 0.
386        assert_eq!(selector.select(), Some(0));
387    }
388
389    #[test]
390    fn current_best_name() {
391        let mut selector =
392            OnlineModelSelector::new(&["alpha", "beta"], SelectorConfig::default()).unwrap();
393
394        for _ in 0..15 {
395            selector.record(0, 1.0, 1.0).unwrap();
396            selector.record(1, 1.0, 2.0).unwrap();
397        }
398        selector.select();
399        assert_eq!(selector.current_best(), Some(0));
400        assert_eq!(selector.current_best_name(), Some("alpha"));
401    }
402
403    #[test]
404    fn entry_metrics_access() {
405        let mut selector =
406            OnlineModelSelector::new(&["a", "b"], SelectorConfig::default()).unwrap();
407        for _ in 0..5 {
408            selector.record(0, 1.0, 1.0).unwrap();
409            selector.record(1, 1.0, 2.0).unwrap();
410        }
411
412        let entry0 = selector.entry_metrics(0).expect("entry 0 should exist");
413        assert_eq!(entry0.name(), "a");
414        assert!(entry0.total_samples() >= 5);
415
416        let entry1 = selector.entry_metrics(1).expect("entry 1 should exist");
417        assert_eq!(entry1.name(), "b");
418        assert!(entry1.total_samples() >= 5);
419
420        assert!(selector.entry_metrics(2).is_none());
421    }
422
423    #[test]
424    fn three_models_selection() {
425        let mut selector =
426            OnlineModelSelector::new(&["x", "y", "z"], SelectorConfig::default()).unwrap();
427
428        for _ in 0..15 {
429            selector.record(0, 1.0, 3.0).unwrap(); // error 2
430            selector.record(1, 1.0, 1.0).unwrap(); // error 0
431            selector.record(2, 1.0, 2.0).unwrap(); // error 1
432        }
433        assert_eq!(selector.select(), Some(1));
434        assert_eq!(selector.current_best_name(), Some("y"));
435        assert_eq!(selector.entry_count(), 3);
436    }
437
438    #[cfg(feature = "serde")]
439    #[test]
440    fn serde_roundtrip() {
441        let mut selector =
442            OnlineModelSelector::new(&["a", "b"], SelectorConfig::default()).unwrap();
443        for _ in 0..15 {
444            selector.record(0, 1.0, 1.0).unwrap();
445            selector.record(1, 1.0, 2.0).unwrap();
446        }
447        selector.select();
448
449        let json = serde_json::to_string(&selector).unwrap();
450        let restored: OnlineModelSelector = serde_json::from_str(&json).unwrap();
451        assert_eq!(restored.current_best(), selector.current_best());
452        assert_eq!(restored.entry_count(), selector.entry_count());
453        assert_eq!(restored.switch_count(), selector.switch_count());
454        assert_eq!(restored.current_best_name(), Some("a"));
455    }
456}