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