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))]
37pub struct SelectorConfig {
38 pub window_size: usize,
42
43 pub cooling_period: u64,
46
47 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#[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 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 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 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 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 self.current_best = Some(new_best);
159 self.samples_since_switch = 0;
160 }
161 Some(current) if new_best == current => {
162 return self.current_best;
165 }
166 Some(_) => {
167 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 pub const fn current_best(&self) -> Option<usize> {
182 self.current_best
183 }
184
185 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 pub const fn switch_count(&self) -> u64 {
193 self.comparator.switch_count()
194 }
195
196 pub fn entry_count(&self) -> usize {
198 self.comparator.entry_count()
199 }
200
201 pub fn entry_metrics(&self, index: usize) -> Option<&ComparatorEntry> {
203 self.comparator.entry(index)
204 }
205
206 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 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 for _ in 0..20 {
279 selector.record(0, 1.0, 2.0).unwrap();
280 selector.record(1, 1.0, 1.0).unwrap();
281 }
282 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 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(); selector.record(1, 1.0, 1.0).unwrap(); selector.record(2, 1.0, 3.0).unwrap(); }
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 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 for _ in 0..3 {
368 selector.record(0, 1.0, 1.0).unwrap(); selector.record(1, 1.0, 2.0).unwrap(); }
371 assert_eq!(selector.select(), Some(0));
372
373 for _ in 0..7 {
375 selector.record(0, 1.0, 2.0).unwrap(); selector.record(1, 1.0, 1.0).unwrap(); }
378 assert_eq!(selector.select(), Some(0));
380
381 for _ in 0..10 {
383 selector.record(0, 1.0, 1.0).unwrap();
384 selector.record(1, 1.0, 2.0).unwrap();
385 }
386 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(); selector.record(1, 1.0, 1.0).unwrap(); selector.record(2, 1.0, 2.0).unwrap(); }
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}