1use crate::diagnostics::baseline_comparator::{BaselineComparator, ComparatorEntry};
32use crate::error::{RillError, checked_increment};
33
34#[derive(Debug, Clone)]
36#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
37#[non_exhaustive]
38pub struct SelectorConfig {
39 pub window_size: usize,
43
44 pub cooling_period: u64,
47
48 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#[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 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 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 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 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 self.current_best = Some(new_best);
160 self.samples_since_switch = 0;
161 }
162 Some(current) if new_best == current => {
163 return self.current_best;
166 }
167 Some(_) => {
168 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 pub const fn current_best(&self) -> Option<usize> {
183 self.current_best
184 }
185
186 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 pub const fn switch_count(&self) -> u64 {
194 self.comparator.switch_count()
195 }
196
197 pub fn entry_count(&self) -> usize {
199 self.comparator.entry_count()
200 }
201
202 pub fn entry_metrics(&self, index: usize) -> Option<&ComparatorEntry> {
204 self.comparator.entry(index)
205 }
206
207 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 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 for _ in 0..20 {
280 selector.record(0, 1.0, 2.0).unwrap();
281 selector.record(1, 1.0, 1.0).unwrap();
282 }
283 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 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(); selector.record(1, 1.0, 1.0).unwrap(); selector.record(2, 1.0, 3.0).unwrap(); }
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 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 for _ in 0..3 {
369 selector.record(0, 1.0, 1.0).unwrap(); selector.record(1, 1.0, 2.0).unwrap(); }
372 assert_eq!(selector.select(), Some(0));
373
374 for _ in 0..7 {
376 selector.record(0, 1.0, 2.0).unwrap(); selector.record(1, 1.0, 1.0).unwrap(); }
379 assert_eq!(selector.select(), Some(0));
381
382 for _ in 0..10 {
384 selector.record(0, 1.0, 1.0).unwrap();
385 selector.record(1, 1.0, 2.0).unwrap();
386 }
387 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(); selector.record(1, 1.0, 1.0).unwrap(); selector.record(2, 1.0, 2.0).unwrap(); }
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}