use crate::diagnostics::baseline_comparator::{BaselineComparator, ComparatorEntry};
use crate::error::RillError;
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SelectorConfig {
pub window_size: usize,
pub cooling_period: u64,
pub min_samples_before_switch: u64,
}
impl Default for SelectorConfig {
fn default() -> Self {
Self {
window_size: 50,
cooling_period: 20,
min_samples_before_switch: 10,
}
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct OnlineModelSelector {
comparator: BaselineComparator,
config: SelectorConfig,
current_best: Option<usize>,
samples_since_switch: u64,
}
impl OnlineModelSelector {
pub fn new(names: &[&str], config: SelectorConfig) -> Result<Self, RillError> {
if config.window_size == 0 {
return Err(RillError::InvalidWindowSize);
}
let comparator = BaselineComparator::new(names, config.window_size)?;
Ok(Self {
comparator,
config,
current_best: None,
samples_since_switch: 0,
})
}
pub fn record(&mut self, index: usize, truth: f64, prediction: f64) -> Result<(), RillError> {
self.comparator.record(index, truth, prediction)?;
if self.current_best == Some(index) {
self.samples_since_switch += 1;
}
Ok(())
}
pub fn select(&mut self) -> Option<usize> {
let new_best = match self.comparator.update_best() {
None => return self.current_best,
Some(idx) => idx,
};
let min_samples = self.config.min_samples_before_switch;
let has_enough = self
.comparator
.entry(new_best)
.map(|e| e.total_samples() >= min_samples)
.unwrap_or(false);
if !has_enough {
return self.current_best;
}
match self.current_best {
None => {
self.current_best = Some(new_best);
self.samples_since_switch = 0;
}
Some(current) if new_best == current => {
return self.current_best;
}
Some(_) => {
if self.samples_since_switch < self.config.cooling_period {
return self.current_best;
}
self.current_best = Some(new_best);
self.samples_since_switch = 0;
}
}
self.current_best
}
pub const fn current_best(&self) -> Option<usize> {
self.current_best
}
pub fn current_best_name(&self) -> Option<&str> {
let idx = self.current_best?;
self.comparator.entry(idx).map(|e| e.name())
}
pub const fn switch_count(&self) -> u64 {
self.comparator.switch_count()
}
pub fn entry_count(&self) -> usize {
self.comparator.entry_count()
}
pub fn entry_metrics(&self, index: usize) -> Option<&ComparatorEntry> {
self.comparator.entry(index)
}
pub fn reset(&mut self) {
self.comparator.reset();
self.current_best = None;
self.samples_since_switch = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_names_rejected() {
let config = SelectorConfig::default();
assert!(OnlineModelSelector::new(&[], config).is_err());
}
#[test]
fn zero_window_rejected() {
let config = SelectorConfig {
window_size: 0,
..Default::default()
};
assert!(OnlineModelSelector::new(&["a"], config).is_err());
}
#[test]
fn select_returns_none_initially() {
let mut selector =
OnlineModelSelector::new(&["a", "b"], SelectorConfig::default()).unwrap();
assert_eq!(selector.select(), None);
assert_eq!(selector.current_best(), None);
assert_eq!(selector.current_best_name(), None);
}
#[test]
fn select_after_min_samples() {
let config = SelectorConfig {
window_size: 50,
cooling_period: 0,
min_samples_before_switch: 5,
};
let mut selector = OnlineModelSelector::new(&["a", "b"], config).unwrap();
for _ in 0..10 {
selector.record(0, 1.0, 1.0).unwrap();
selector.record(1, 1.0, 2.0).unwrap();
}
assert_eq!(selector.select(), Some(0));
}
#[test]
fn cooling_period_prevents_switch() {
let config = SelectorConfig {
window_size: 50,
cooling_period: 100,
min_samples_before_switch: 2,
};
let mut selector = OnlineModelSelector::new(&["a", "b"], config).unwrap();
for _ in 0..10 {
selector.record(0, 1.0, 1.0).unwrap();
selector.record(1, 1.0, 2.0).unwrap();
}
assert_eq!(selector.select(), Some(0));
for _ in 0..20 {
selector.record(0, 1.0, 2.0).unwrap();
selector.record(1, 1.0, 1.0).unwrap();
}
assert_eq!(selector.select(), Some(0));
}
#[test]
fn min_samples_required() {
let config = SelectorConfig {
window_size: 50,
cooling_period: 0,
min_samples_before_switch: 100,
};
let mut selector = OnlineModelSelector::new(&["a", "b"], config).unwrap();
for _ in 0..10 {
selector.record(0, 1.0, 1.0).unwrap();
selector.record(1, 1.0, 2.0).unwrap();
}
assert_eq!(selector.select(), None);
}
#[test]
fn best_model_selected() {
let mut selector =
OnlineModelSelector::new(&["a", "b", "c"], SelectorConfig::default()).unwrap();
for _ in 0..15 {
selector.record(0, 1.0, 2.0).unwrap(); selector.record(1, 1.0, 1.0).unwrap(); selector.record(2, 1.0, 3.0).unwrap(); }
assert_eq!(selector.select(), Some(1));
}
#[test]
fn switch_count_tracked() {
let mut selector =
OnlineModelSelector::new(&["a", "b"], SelectorConfig::default()).unwrap();
assert_eq!(selector.switch_count(), 0);
for _ in 0..15 {
selector.record(0, 1.0, 1.0).unwrap();
selector.record(1, 1.0, 2.0).unwrap();
}
selector.select();
assert_eq!(selector.switch_count(), 1);
}
#[test]
fn reset_clears_all() {
let mut selector =
OnlineModelSelector::new(&["a", "b"], SelectorConfig::default()).unwrap();
for _ in 0..15 {
selector.record(0, 1.0, 1.0).unwrap();
selector.record(1, 1.0, 2.0).unwrap();
}
selector.select();
assert!(selector.current_best().is_some());
selector.reset();
assert_eq!(selector.current_best(), None);
assert_eq!(selector.current_best_name(), None);
assert_eq!(selector.switch_count(), 0);
assert_eq!(selector.entry_count(), 2);
}
#[test]
fn record_out_of_bounds_rejected() {
let mut selector =
OnlineModelSelector::new(&["a", "b"], SelectorConfig::default()).unwrap();
assert!(selector.record(2, 1.0, 1.0).is_err());
assert!(selector.record(0, 1.0, 1.0).is_ok());
}
#[test]
fn two_models_alternating() {
let config = SelectorConfig {
window_size: 10,
cooling_period: 100,
min_samples_before_switch: 2,
};
let mut selector = OnlineModelSelector::new(&["a", "b"], config).unwrap();
for _ in 0..3 {
selector.record(0, 1.0, 1.0).unwrap(); selector.record(1, 1.0, 2.0).unwrap(); }
assert_eq!(selector.select(), Some(0));
for _ in 0..7 {
selector.record(0, 1.0, 2.0).unwrap(); selector.record(1, 1.0, 1.0).unwrap(); }
assert_eq!(selector.select(), Some(0));
for _ in 0..10 {
selector.record(0, 1.0, 1.0).unwrap();
selector.record(1, 1.0, 2.0).unwrap();
}
assert_eq!(selector.select(), Some(0));
}
#[test]
fn current_best_name() {
let mut selector =
OnlineModelSelector::new(&["alpha", "beta"], SelectorConfig::default()).unwrap();
for _ in 0..15 {
selector.record(0, 1.0, 1.0).unwrap();
selector.record(1, 1.0, 2.0).unwrap();
}
selector.select();
assert_eq!(selector.current_best(), Some(0));
assert_eq!(selector.current_best_name(), Some("alpha"));
}
#[test]
fn entry_metrics_access() {
let mut selector =
OnlineModelSelector::new(&["a", "b"], SelectorConfig::default()).unwrap();
for _ in 0..5 {
selector.record(0, 1.0, 1.0).unwrap();
selector.record(1, 1.0, 2.0).unwrap();
}
let entry0 = selector.entry_metrics(0).expect("entry 0 should exist");
assert_eq!(entry0.name(), "a");
assert!(entry0.total_samples() >= 5);
let entry1 = selector.entry_metrics(1).expect("entry 1 should exist");
assert_eq!(entry1.name(), "b");
assert!(entry1.total_samples() >= 5);
assert!(selector.entry_metrics(2).is_none());
}
#[test]
fn three_models_selection() {
let mut selector =
OnlineModelSelector::new(&["x", "y", "z"], SelectorConfig::default()).unwrap();
for _ in 0..15 {
selector.record(0, 1.0, 3.0).unwrap(); selector.record(1, 1.0, 1.0).unwrap(); selector.record(2, 1.0, 2.0).unwrap(); }
assert_eq!(selector.select(), Some(1));
assert_eq!(selector.current_best_name(), Some("y"));
assert_eq!(selector.entry_count(), 3);
}
#[cfg(feature = "serde")]
#[test]
fn serde_roundtrip() {
let mut selector =
OnlineModelSelector::new(&["a", "b"], SelectorConfig::default()).unwrap();
for _ in 0..15 {
selector.record(0, 1.0, 1.0).unwrap();
selector.record(1, 1.0, 2.0).unwrap();
}
selector.select();
let json = serde_json::to_string(&selector).unwrap();
let restored: OnlineModelSelector = serde_json::from_str(&json).unwrap();
assert_eq!(restored.current_best(), selector.current_best());
assert_eq!(restored.entry_count(), selector.entry_count());
assert_eq!(restored.switch_count(), selector.switch_count());
assert_eq!(restored.current_best_name(), Some("a"));
}
}