Skip to main content

rill_ml/drift/
aware_model.rs

1//! A model wrapper that integrates drift detection into the predict → learn loop.
2//!
3//! [`DriftAwareModel`] combines an online regressor, a drift detector, and a
4//! drift strategy into a single component. On each `learn` call, the prediction
5//! error is fed to the detector; if the detector reports a change, the strategy
6//! decides the response action and the event is recorded.
7//!
8//! ## What the wrapper does and does not do
9//!
10//! - **Does**: feed `|target - prediction|` to the detector, record
11//!   [`DriftEvent`]s, and execute `ResetModel` when
12//!   the strategy returns it.
13//! - **Does not**: auto-reset the model by default. The default
14//!   [`StaticStrategy`](crate::drift::StaticStrategy) returns `NotifyOnly` for
15//!   both warning and drift, so the wrapper only logs events unless the caller
16//!   configures a more aggressive strategy.
17//! - **Does not**: execute `ResetPreprocessor`, `ReplaceWithBaseline`, or
18//!   `IncreaseAdaptationRate`. These actions are recorded in
19//!   [`last_action`](DriftAwareModel::last_action) for the caller to interpret.
20//!
21//! ## Space complexity
22//!
23//! `O(max_events)` for the event log, plus the space of the wrapped model,
24//! detector, and strategy.
25
26use crate::drift::action::{DriftAction, DriftEvent};
27use crate::drift::detector::DriftDetector;
28use crate::drift::strategy::DriftStrategy;
29use crate::error::RillError;
30use crate::traits::OnlineRegressor;
31
32/// Default maximum number of retained drift events.
33const DEFAULT_MAX_EVENTS: usize = 1000;
34
35/// A wrapper that feeds prediction errors to a drift detector and applies
36/// the strategy's action when drift is detected.
37///
38/// The wrapper is generic over the model `M`, detector `D`, and strategy `A`
39/// to avoid trait-object overhead and preserve concrete types. See the
40/// [module documentation](crate::drift::aware_model) for the full contract.
41///
42/// # Examples
43///
44/// ```
45/// use rill_ml::drift::{
46///     DriftAction, DriftAwareModel, PageHinkley, StaticStrategy,
47/// };
48/// use rill_ml::models::{BaselineConfig, MeanRegressor};
49///
50/// let model = MeanRegressor::new(BaselineConfig::default()).unwrap();
51/// let detector = PageHinkley::default();
52/// let strategy = StaticStrategy::new(DriftAction::NotifyOnly, DriftAction::ResetModel);
53/// let mut aware = DriftAwareModel::new(model, detector, strategy);
54///
55/// // Stable stream: no events.
56/// for i in 0..50 {
57///     let x = [i as f64 * 0.1];
58///     aware.learn(&x, 1.0).unwrap();
59/// }
60/// assert!(aware.events().is_empty());
61/// ```
62#[derive(Debug, Clone)]
63#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
64pub struct DriftAwareModel<M, D, A>
65where
66    D: DriftDetector,
67    A: DriftStrategy,
68{
69    model: M,
70    detector: D,
71    strategy: A,
72    events: Vec<DriftEvent>,
73    max_events: usize,
74    samples_seen: u64,
75    last_action: Option<DriftAction>,
76}
77
78impl<M, D, A> DriftAwareModel<M, D, A>
79where
80    M: OnlineRegressor,
81    D: DriftDetector,
82    A: DriftStrategy,
83{
84    /// Create a new drift-aware model with the default event log capacity
85    /// (`1000` entries).
86    pub fn new(model: M, detector: D, strategy: A) -> Self {
87        Self {
88            model,
89            detector,
90            strategy,
91            events: Vec::new(),
92            max_events: DEFAULT_MAX_EVENTS,
93            samples_seen: 0,
94            last_action: None,
95        }
96    }
97
98    /// Create a new drift-aware model with a custom event log capacity.
99    ///
100    /// Returns an error if `max_events` is zero.
101    pub fn with_max_events(
102        model: M,
103        detector: D,
104        strategy: A,
105        max_events: usize,
106    ) -> Result<Self, RillError> {
107        if max_events == 0 {
108            return Err(RillError::InvalidCapacity(max_events));
109        }
110        Ok(Self {
111            model,
112            detector,
113            strategy,
114            events: Vec::with_capacity(max_events),
115            max_events,
116            samples_seen: 0,
117            last_action: None,
118        })
119    }
120
121    /// Predict the target for the given features.
122    ///
123    /// This is a pure delegation to the wrapped model's `predict` and does
124    /// not update any state.
125    pub fn predict(&self, features: &[f64]) -> Result<f64, RillError> {
126        self.model.predict(features)
127    }
128
129    /// Update the model using a single labeled sample.
130    ///
131    /// The learning sequence is:
132    /// 1. Predict with the current model (no state change).
133    /// 2. Feed the absolute prediction error to the drift detector.
134    /// 3. Ask the strategy for the response action.
135    /// 4. If the detector reported a change, record a [`DriftEvent`].
136    /// 5. If the action is `ResetModel`, reset the wrapped model.
137    /// 6. Learn from the sample.
138    pub fn learn(&mut self, features: &[f64], target: f64) -> Result<(), RillError> {
139        let prediction = self.model.predict(features)?;
140        let error = (target - prediction).abs();
141        let level = self.detector.update(error)?;
142        let action = self.strategy.decide(level, self.samples_seen);
143
144        if level.is_change() {
145            let event =
146                DriftEvent::new(self.samples_seen, level, action, self.detector.last_value());
147            self.events.push(event);
148            while self.events.len() > self.max_events {
149                self.events.remove(0);
150            }
151        }
152
153        if action == DriftAction::ResetModel {
154            self.model.reset();
155        }
156        self.last_action = Some(action);
157
158        self.model.learn(features, target)?;
159        self.samples_seen += 1;
160        Ok(())
161    }
162
163    /// Borrow the wrapped model.
164    pub const fn model(&self) -> &M {
165        &self.model
166    }
167
168    /// Borrow the wrapped detector.
169    pub const fn detector(&self) -> &D {
170        &self.detector
171    }
172
173    /// Borrow the wrapped strategy.
174    pub const fn strategy(&self) -> &A {
175        &self.strategy
176    }
177
178    /// The recorded drift events, oldest first.
179    ///
180    /// The returned slice is guaranteed to be contiguous. When the event log
181    /// exceeds `max_events`, the oldest entries are dropped.
182    pub fn events(&self) -> &[DriftEvent] {
183        &self.events
184    }
185
186    /// The most recent action taken by the wrapper, or `None` if `learn` has
187    /// not been called yet.
188    pub const fn last_action(&self) -> Option<DriftAction> {
189        self.last_action
190    }
191
192    /// The number of samples processed by `learn`.
193    pub const fn samples_seen(&self) -> u64 {
194        self.samples_seen
195    }
196
197    /// The maximum number of retained drift events.
198    pub const fn max_events(&self) -> usize {
199        self.max_events
200    }
201
202    /// Reset the model, detector, and event log to their initial states.
203    ///
204    /// The strategy is not reset (it is typically stateless). The
205    /// `max_events` capacity is preserved.
206    pub fn reset(&mut self) {
207        self.model.reset();
208        self.detector.reset();
209        self.events.clear();
210        self.samples_seen = 0;
211        self.last_action = None;
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::drift::detector::DriftLevel;
219    use crate::drift::page_hinkley::PageHinkley;
220    use crate::drift::strategy::StaticStrategy;
221    use crate::models::{BaselineConfig, MeanRegressor};
222
223    /// Build a minimal drift-aware model for tests.
224    fn build(
225        strategy: StaticStrategy,
226    ) -> DriftAwareModel<MeanRegressor, PageHinkley, StaticStrategy> {
227        let model = MeanRegressor::new(BaselineConfig::default()).unwrap();
228        let detector = PageHinkley::default();
229        DriftAwareModel::new(model, detector, strategy)
230    }
231
232    #[test]
233    fn predict_delegates_to_model() {
234        let aware = build(StaticStrategy::default());
235        // MeanRegressor with no data returns the initial prediction (0.0).
236        let p = aware.predict(&[]).unwrap();
237        assert!((p - 0.0).abs() < 1e-12);
238    }
239
240    #[test]
241    fn learn_feeds_error_to_detector() {
242        let mut aware = build(StaticStrategy::default());
243        assert_eq!(aware.detector().samples_seen(), 0);
244        aware.learn(&[], 1.0).unwrap();
245        assert_eq!(aware.detector().samples_seen(), 1);
246        aware.learn(&[], 2.0).unwrap();
247        assert_eq!(aware.detector().samples_seen(), 2);
248    }
249
250    #[test]
251    fn samples_seen_tracks_learn_calls() {
252        let mut aware = build(StaticStrategy::default());
253        assert_eq!(aware.samples_seen(), 0);
254        for i in 0..10u64 {
255            aware.learn(&[], i as f64).unwrap();
256        }
257        assert_eq!(aware.samples_seen(), 10);
258    }
259
260    #[test]
261    fn last_action_updated_after_learn() {
262        let mut aware = build(StaticStrategy::default());
263        assert_eq!(aware.last_action(), None);
264        aware.learn(&[], 1.0).unwrap();
265        assert_eq!(aware.last_action(), Some(DriftAction::NotifyOnly));
266    }
267
268    #[test]
269    fn default_strategy_does_not_reset_model() {
270        let mut aware = build(StaticStrategy::default());
271        // Feed several samples; model should accumulate them.
272        for i in 0..20 {
273            aware.learn(&[], i as f64).unwrap();
274        }
275        // With NotifyOnly, the model's samples_seen should keep growing.
276        assert!(aware.model().samples_seen() > 0);
277        let before = aware.model().samples_seen();
278        aware.learn(&[], 100.0).unwrap();
279        assert_eq!(aware.model().samples_seen(), before + 1);
280    }
281
282    #[test]
283    fn reset_model_action_calls_model_reset() {
284        // Strategy that returns ResetModel on drift.
285        let strategy = StaticStrategy::new(DriftAction::NotifyOnly, DriftAction::ResetModel);
286        let mut aware = build(strategy);
287
288        // Feed a stable stream first to accumulate model state.
289        for _ in 0..30 {
290            aware.learn(&[], 1.0).unwrap();
291        }
292        assert!(aware.model().samples_seen() > 0);
293
294        // Now introduce a large shift to trigger drift.
295        // PageHinkley should detect the sudden change and the strategy
296        // should return ResetModel, which resets the model.
297        let mut reset_happened = false;
298        for _ in 0..100 {
299            aware.learn(&[], 100.0).unwrap();
300            // After a reset, model samples_seen would drop to a small value
301            // relative to the total learn calls.
302            if aware.model().samples_seen() < aware.samples_seen() {
303                reset_happened = true;
304                break;
305            }
306        }
307        assert!(
308            reset_happened,
309            "ResetModel action should have reset the model"
310        );
311        // Events should have been recorded.
312        assert!(!aware.events().is_empty());
313    }
314
315    #[test]
316    fn detects_drift_and_records_event() {
317        let strategy = StaticStrategy::new(DriftAction::NotifyOnly, DriftAction::NotifyOnly);
318        let mut aware = build(strategy);
319
320        // Stable stream: no events expected.
321        for _ in 0..50 {
322            aware.learn(&[], 1.0).unwrap();
323        }
324        assert!(aware.events().is_empty());
325
326        // Sudden shift: keep feeding until a confirmed Drift (not just Warning)
327        // is recorded. The first event may be a Warning because the PH
328        // statistic exceeds the warning threshold before the drift threshold.
329        let mut drift_recorded = false;
330        for _ in 0..100 {
331            aware.learn(&[], 50.0).unwrap();
332            if let Some(last) = aware.events().last()
333                && last.level == DriftLevel::Drift
334            {
335                drift_recorded = true;
336                break;
337            }
338        }
339        assert!(drift_recorded, "a drift event should have been recorded");
340        let last_event = aware.events().last().unwrap();
341        assert_eq!(last_event.level, DriftLevel::Drift);
342    }
343
344    #[test]
345    fn replace_with_baseline_action_recorded() {
346        // Use a strategy that returns ReplaceWithBaseline on drift.
347        let strategy =
348            StaticStrategy::new(DriftAction::NotifyOnly, DriftAction::ReplaceWithBaseline);
349        let mut aware = build(strategy);
350
351        // Stable baseline: the model learns to predict ~1.0 and PH settles.
352        for _ in 0..50 {
353            aware.learn(&[], 1.0).unwrap();
354        }
355        // Trigger drift with a large shift. The model still predicts ~1.0
356        // initially, so the error jumps and PH detects the change.
357        let mut seen_action = false;
358        for _ in 0..200 {
359            aware.learn(&[], 100.0).unwrap();
360            if let Some(action) = aware.last_action()
361                && action == DriftAction::ReplaceWithBaseline
362            {
363                seen_action = true;
364                break;
365            }
366        }
367        assert!(
368            seen_action,
369            "ReplaceWithBaseline action should have been recorded"
370        );
371    }
372
373    #[test]
374    fn increase_adaptation_rate_action_recorded() {
375        let strategy =
376            StaticStrategy::new(DriftAction::NotifyOnly, DriftAction::IncreaseAdaptationRate);
377        let mut aware = build(strategy);
378
379        // Stable baseline.
380        for _ in 0..50 {
381            aware.learn(&[], 1.0).unwrap();
382        }
383        // Trigger drift.
384        let mut seen_action = false;
385        for _ in 0..200 {
386            aware.learn(&[], 100.0).unwrap();
387            if let Some(action) = aware.last_action()
388                && action == DriftAction::IncreaseAdaptationRate
389            {
390                seen_action = true;
391                break;
392            }
393        }
394        assert!(
395            seen_action,
396            "IncreaseAdaptationRate action should have been recorded"
397        );
398    }
399
400    #[test]
401    fn events_bounded_by_max_events() {
402        let strategy = StaticStrategy::new(DriftAction::NotifyOnly, DriftAction::NotifyOnly);
403        let model = MeanRegressor::new(BaselineConfig::default()).unwrap();
404        let detector = PageHinkley::default();
405        let mut aware = DriftAwareModel::with_max_events(model, detector, strategy, 3).unwrap();
406        assert_eq!(aware.max_events(), 3);
407
408        // Trigger many drift events by feeding a highly shifting stream.
409        // Alternate between two very different means to trigger repeated drift.
410        for i in 0..500 {
411            let target = if i % 50 < 25 { 0.0 } else { 100.0 };
412            aware.learn(&[], target).unwrap();
413        }
414        // The event log should never exceed max_events.
415        assert!(aware.events().len() <= 3);
416    }
417
418    #[test]
419    fn reset_clears_all_state() {
420        let mut aware = build(StaticStrategy::default());
421        for i in 0..20 {
422            aware.learn(&[], i as f64).unwrap();
423        }
424        assert_eq!(aware.samples_seen(), 20);
425        assert!(aware.last_action().is_some());
426
427        aware.reset();
428        assert_eq!(aware.samples_seen(), 0);
429        assert_eq!(aware.last_action(), None);
430        assert!(aware.events().is_empty());
431        assert_eq!(aware.model().samples_seen(), 0);
432        assert_eq!(aware.detector().samples_seen(), 0);
433    }
434
435    #[test]
436    fn rejects_zero_max_events() {
437        let model = MeanRegressor::new(BaselineConfig::default()).unwrap();
438        let detector = PageHinkley::default();
439        let strategy = StaticStrategy::default();
440        let result = DriftAwareModel::with_max_events(model, detector, strategy, 0);
441        assert!(result.is_err());
442    }
443
444    #[test]
445    fn model_detector_strategy_accessors() {
446        let strategy = StaticStrategy::new(DriftAction::ReduceConfidence, DriftAction::ResetModel);
447        let aware = build(strategy);
448        assert_eq!(
449            aware.strategy().decide(DriftLevel::Warning, 0),
450            DriftAction::ReduceConfidence
451        );
452        assert_eq!(
453            aware.strategy().decide(DriftLevel::Drift, 0),
454            DriftAction::ResetModel
455        );
456        // Detector starts with no samples.
457        assert_eq!(aware.detector().samples_seen(), 0);
458        // Model starts with no samples.
459        assert_eq!(aware.model().samples_seen(), 0);
460    }
461
462    #[cfg(feature = "serde")]
463    #[test]
464    fn serde_roundtrip() {
465        let strategy = StaticStrategy::new(DriftAction::NotifyOnly, DriftAction::ResetModel);
466        let model = MeanRegressor::new(BaselineConfig::default()).unwrap();
467        let detector = PageHinkley::default();
468        let mut aware = DriftAwareModel::new(model, detector, strategy);
469        // Feed a few samples to populate state.
470        for i in 0..10 {
471            aware.learn(&[], i as f64).unwrap();
472        }
473
474        let json = serde_json::to_string(&aware).unwrap();
475        let restored: DriftAwareModel<MeanRegressor, PageHinkley, StaticStrategy> =
476            serde_json::from_str(&json).unwrap();
477        assert_eq!(restored.samples_seen(), aware.samples_seen());
478        assert_eq!(restored.last_action(), aware.last_action());
479        assert_eq!(restored.events().len(), aware.events().len());
480    }
481}