Skip to main content

rill_ml/drift/
action.rs

1//! Drift action and event types.
2//!
3//! When a drift detector reports a change, a [`DriftAction`] describes what
4//! the system should do about it. Actions are intentionally decoupled from
5//! detectors: a detector only reports the level, and a
6//! [`DriftStrategy`](crate::drift::strategy::DriftStrategy) decides the action.
7
8use crate::drift::detector::DriftLevel;
9
10/// The action to take when drift is detected.
11///
12/// This enum is returned by a [`DriftStrategy`](crate::drift::strategy::DriftStrategy)
13/// and executed by [`DriftAwareModel`](crate::drift::aware_model::DriftAwareModel).
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
16#[non_exhaustive]
17pub enum DriftAction {
18    /// Record the event but do not change model behavior. This is the
19    /// safest default and should be used when the cost of a wrong reset
20    /// exceeds the cost of a slow adaptation.
21    #[default]
22    NotifyOnly,
23    /// Lower the confidence associated with subsequent predictions. The
24    /// interpretation is left to the caller (e.g. widen prediction intervals
25    /// or flag predictions as uncertain).
26    ReduceConfidence,
27    /// Reset the wrapped model's parameters to their initial state. Use
28    /// when the concept drift is severe enough that relearning from scratch
29    /// is faster than incremental adaptation.
30    ResetModel,
31    /// Reset the preprocessor's running statistics (e.g. StandardScaler
32    /// mean and variance). Useful when feature distributions have shifted
33    /// but the target relationship remains similar.
34    ResetPreprocessor,
35    /// Replace the current model with a baseline model. The replacement
36    /// logic is handled by the caller; this action signals intent.
37    ReplaceWithBaseline,
38    /// Increase the model's adaptation rate (e.g. raise the learning rate)
39    /// so it can relearn faster on the new distribution. The exact mechanism
40    /// is model-dependent.
41    IncreaseAdaptationRate,
42}
43
44impl DriftAction {
45    /// Returns a short, stable string identifier.
46    ///
47    /// Possible values: `"notify_only"`, `"reduce_confidence"`,
48    /// `"reset_model"`, `"reset_preprocessor"`, `"replace_with_baseline"`,
49    /// `"increase_adaptation_rate"`.
50    pub const fn as_str(&self) -> &'static str {
51        match self {
52            DriftAction::NotifyOnly => "notify_only",
53            DriftAction::ReduceConfidence => "reduce_confidence",
54            DriftAction::ResetModel => "reset_model",
55            DriftAction::ResetPreprocessor => "reset_preprocessor",
56            DriftAction::ReplaceWithBaseline => "replace_with_baseline",
57            DriftAction::IncreaseAdaptationRate => "increase_adaptation_rate",
58        }
59    }
60
61    /// Returns `true` if this action modifies the model or preprocessor state.
62    ///
63    /// `NotifyOnly` and `ReduceConfidence` return `false`; all others return
64    /// `true`.
65    pub const fn is_destructive(self) -> bool {
66        matches!(
67            self,
68            DriftAction::ResetModel
69                | DriftAction::ResetPreprocessor
70                | DriftAction::ReplaceWithBaseline
71                | DriftAction::IncreaseAdaptationRate
72        )
73    }
74}
75
76/// An immutable record of a single drift event.
77///
78/// Produced by [`DriftAwareModel`](crate::drift::aware_model::DriftAwareModel)
79/// whenever the detector reports a change (warning or drift).
80#[derive(Debug, Clone)]
81#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
82pub struct DriftEvent {
83    /// The sample index at which the event was triggered (0-based).
84    pub sample_index: u64,
85    /// The drift level that triggered the event.
86    pub level: DriftLevel,
87    /// The action that was taken in response.
88    pub action: DriftAction,
89    /// The detector-specific value at the time of triggering (e.g. cumulative
90    /// sum for Page-Hinkley, KS statistic for KSWIN). Useful for diagnostics.
91    pub detector_value: f64,
92}
93
94impl DriftEvent {
95    /// Create a new drift event record.
96    pub const fn new(
97        sample_index: u64,
98        level: DriftLevel,
99        action: DriftAction,
100        detector_value: f64,
101    ) -> Self {
102        Self {
103            sample_index,
104            level,
105            action,
106            detector_value,
107        }
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn action_as_str() {
117        assert_eq!(DriftAction::NotifyOnly.as_str(), "notify_only");
118        assert_eq!(DriftAction::ReduceConfidence.as_str(), "reduce_confidence");
119        assert_eq!(DriftAction::ResetModel.as_str(), "reset_model");
120        assert_eq!(
121            DriftAction::ResetPreprocessor.as_str(),
122            "reset_preprocessor"
123        );
124        assert_eq!(
125            DriftAction::ReplaceWithBaseline.as_str(),
126            "replace_with_baseline"
127        );
128        assert_eq!(
129            DriftAction::IncreaseAdaptationRate.as_str(),
130            "increase_adaptation_rate"
131        );
132    }
133
134    #[test]
135    fn action_is_destructive() {
136        assert!(!DriftAction::NotifyOnly.is_destructive());
137        assert!(!DriftAction::ReduceConfidence.is_destructive());
138        assert!(DriftAction::ResetModel.is_destructive());
139        assert!(DriftAction::ResetPreprocessor.is_destructive());
140        assert!(DriftAction::ReplaceWithBaseline.is_destructive());
141        assert!(DriftAction::IncreaseAdaptationRate.is_destructive());
142    }
143
144    #[test]
145    fn action_default_is_notify_only() {
146        assert_eq!(DriftAction::default(), DriftAction::NotifyOnly);
147    }
148
149    #[test]
150    fn drift_event_construction() {
151        let event = DriftEvent::new(42, DriftLevel::Drift, DriftAction::ResetModel, 1.23);
152        assert_eq!(event.sample_index, 42);
153        assert_eq!(event.level, DriftLevel::Drift);
154        assert_eq!(event.action, DriftAction::ResetModel);
155        assert!((event.detector_value - 1.23).abs() < 1e-12);
156    }
157
158    #[cfg(feature = "serde")]
159    #[test]
160    fn action_serde_roundtrip() {
161        for action in [
162            DriftAction::NotifyOnly,
163            DriftAction::ReduceConfidence,
164            DriftAction::ResetModel,
165            DriftAction::ResetPreprocessor,
166            DriftAction::ReplaceWithBaseline,
167            DriftAction::IncreaseAdaptationRate,
168        ] {
169            let json = serde_json::to_string(&action).unwrap();
170            let restored: DriftAction = serde_json::from_str(&json).unwrap();
171            assert_eq!(restored, action);
172        }
173    }
174
175    #[cfg(feature = "serde")]
176    #[test]
177    fn drift_event_serde_roundtrip() {
178        let event = DriftEvent::new(10, DriftLevel::Warning, DriftAction::ReduceConfidence, 1.5);
179        let json = serde_json::to_string(&event).unwrap();
180        let restored: DriftEvent = serde_json::from_str(&json).unwrap();
181        assert_eq!(restored.sample_index, 10);
182        assert_eq!(restored.level, DriftLevel::Warning);
183        assert_eq!(restored.action, DriftAction::ReduceConfidence);
184        assert!((restored.detector_value - 1.5).abs() < 1e-12);
185    }
186}