Skip to main content

rill_ml/drift/
strategy.rs

1//! Drift handling strategies.
2//!
3//! A [`DriftStrategy`] maps a [`DriftLevel`] to a [`DriftAction`]. This
4//! decouples the detection (performed by a [`DriftDetector`](crate::drift::DriftDetector))
5//! from the response (executed by
6//! [`DriftAwareModel`](crate::drift::aware_model::DriftAwareModel)).
7
8use crate::drift::action::DriftAction;
9use crate::drift::detector::DriftLevel;
10
11/// Strategy for deciding what action to take when a drift detector reports
12/// a change.
13///
14/// Implementations may be stateless (like [`StaticStrategy`]) or stateful
15/// (e.g. a strategy that escalates from `NotifyOnly` to `ResetModel` after
16/// repeated warnings).
17pub trait DriftStrategy {
18    /// Decide the action for the given drift level and sample count.
19    fn decide(&self, level: DriftLevel, samples_seen: u64) -> DriftAction;
20}
21
22/// A simple stateless strategy that maps each drift level to a fixed action.
23///
24/// `None` always maps to [`DriftAction::NotifyOnly`]. The caller configures
25/// the actions for `Warning` and `Drift`.
26///
27/// # Examples
28///
29/// ```
30/// use rill_ml::drift::{DriftAction, DriftLevel, StaticStrategy};
31/// use rill_ml::drift::DriftStrategy;
32///
33/// let strategy = StaticStrategy::new(
34///     DriftAction::ReduceConfidence,
35///     DriftAction::ResetModel,
36/// );
37/// assert_eq!(strategy.decide(DriftLevel::None, 100), DriftAction::NotifyOnly);
38/// assert_eq!(strategy.decide(DriftLevel::Warning, 100), DriftAction::ReduceConfidence);
39/// assert_eq!(strategy.decide(DriftLevel::Drift, 100), DriftAction::ResetModel);
40/// ```
41#[derive(Debug, Clone)]
42#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
43pub struct StaticStrategy {
44    /// Action to take when the detector reports a warning.
45    pub warning_action: DriftAction,
46    /// Action to take when the detector reports a confirmed drift.
47    pub drift_action: DriftAction,
48}
49
50impl StaticStrategy {
51    /// Create a new static strategy with the given warning and drift actions.
52    pub const fn new(warning_action: DriftAction, drift_action: DriftAction) -> Self {
53        Self {
54            warning_action,
55            drift_action,
56        }
57    }
58}
59
60impl Default for StaticStrategy {
61    fn default() -> Self {
62        // The safest default: never take destructive action automatically.
63        Self {
64            warning_action: DriftAction::NotifyOnly,
65            drift_action: DriftAction::NotifyOnly,
66        }
67    }
68}
69
70impl DriftStrategy for StaticStrategy {
71    fn decide(&self, level: DriftLevel, _samples_seen: u64) -> DriftAction {
72        match level {
73            DriftLevel::None => DriftAction::NotifyOnly,
74            DriftLevel::Warning => self.warning_action,
75            DriftLevel::Drift => self.drift_action,
76        }
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn default_strategy_is_notify_only() {
86        let s = StaticStrategy::default();
87        assert_eq!(s.decide(DriftLevel::None, 0), DriftAction::NotifyOnly);
88        assert_eq!(s.decide(DriftLevel::Warning, 10), DriftAction::NotifyOnly);
89        assert_eq!(s.decide(DriftLevel::Drift, 100), DriftAction::NotifyOnly);
90    }
91
92    #[test]
93    fn custom_strategy() {
94        let s = StaticStrategy::new(DriftAction::ReduceConfidence, DriftAction::ResetModel);
95        assert_eq!(s.decide(DriftLevel::None, 50), DriftAction::NotifyOnly);
96        assert_eq!(
97            s.decide(DriftLevel::Warning, 50),
98            DriftAction::ReduceConfidence
99        );
100        assert_eq!(s.decide(DriftLevel::Drift, 50), DriftAction::ResetModel);
101    }
102
103    #[test]
104    fn samples_seen_does_not_affect_static_strategy() {
105        let s = StaticStrategy::new(DriftAction::NotifyOnly, DriftAction::ResetModel);
106        // Same level at different sample counts returns the same action.
107        assert_eq!(
108            s.decide(DriftLevel::Drift, 1),
109            s.decide(DriftLevel::Drift, 1000)
110        );
111    }
112
113    #[cfg(feature = "serde")]
114    #[test]
115    fn serde_roundtrip() {
116        let s = StaticStrategy::new(DriftAction::ReduceConfidence, DriftAction::ResetModel);
117        let json = serde_json::to_string(&s).unwrap();
118        let restored: StaticStrategy = serde_json::from_str(&json).unwrap();
119        assert_eq!(restored.warning_action, DriftAction::ReduceConfidence);
120        assert_eq!(restored.drift_action, DriftAction::ResetModel);
121    }
122}