1use crate::diagnostics::baseline_comparator::{BaselineComparator, ComparatorEntry};
32use crate::error::RillError;
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 += 1;
127 }
128 Ok(())
129 }
130
131 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 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 self.current_best = Some(new_best);
158 self.samples_since_switch = 0;
159 }
160 Some(current) if new_best == current => {
161 return self.current_best;
164 }
165 Some(_) => {
166 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 pub const fn current_best(&self) -> Option<usize> {
181 self.current_best
182 }
183
184 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 pub const fn switch_count(&self) -> u64 {
192 self.comparator.switch_count()
193 }
194
195 pub fn entry_count(&self) -> usize {
197 self.comparator.entry_count()
198 }
199
200 pub fn entry_metrics(&self, index: usize) -> Option<&ComparatorEntry> {
202 self.comparator.entry(index)
203 }
204
205 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 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 for _ in 0..20 {
278 selector.record(0, 1.0, 2.0).unwrap();
279 selector.record(1, 1.0, 1.0).unwrap();
280 }
281 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 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(); selector.record(1, 1.0, 1.0).unwrap(); selector.record(2, 1.0, 3.0).unwrap(); }
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 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 for _ in 0..3 {
367 selector.record(0, 1.0, 1.0).unwrap(); selector.record(1, 1.0, 2.0).unwrap(); }
370 assert_eq!(selector.select(), Some(0));
371
372 for _ in 0..7 {
374 selector.record(0, 1.0, 2.0).unwrap(); selector.record(1, 1.0, 1.0).unwrap(); }
377 assert_eq!(selector.select(), Some(0));
379
380 for _ in 0..10 {
382 selector.record(0, 1.0, 1.0).unwrap();
383 selector.record(1, 1.0, 2.0).unwrap();
384 }
385 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(); selector.record(1, 1.0, 1.0).unwrap(); selector.record(2, 1.0, 2.0).unwrap(); }
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}