rill_ml/drift/detector.rs
1//! Core drift detector trait.
2//!
3//! All drift detectors in RillML implement [`DriftDetector`]. A detector
4//! receives a scalar value (typically a prediction error or a target value)
5//! and reports a [`DriftLevel`]: no drift, a warning, or a confirmed drift.
6//!
7//! Implementations must use bounded memory. They never store raw feature
8//! vectors or labels — only scalar statistics derived from the stream.
9
10use crate::error::RillError;
11
12/// The severity level reported by a drift detector.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15pub enum DriftLevel {
16 /// No drift detected; the stream appears stable.
17 #[default]
18 None,
19 /// A possible change has been detected but confidence is insufficient
20 /// for a confirmed drift. Callers may wish to reduce confidence or
21 /// increase monitoring.
22 Warning,
23 /// A confirmed drift has been detected. Callers should consider
24 /// taking corrective action via a [`DriftStrategy`](crate::drift::DriftStrategy).
25 Drift,
26}
27
28impl DriftLevel {
29 /// Returns a short, stable string identifier.
30 ///
31 /// Possible values: `"none"`, `"warning"`, `"drift"`.
32 pub const fn as_str(&self) -> &'static str {
33 match self {
34 DriftLevel::None => "none",
35 DriftLevel::Warning => "warning",
36 DriftLevel::Drift => "drift",
37 }
38 }
39
40 /// Returns `true` if the level indicates any kind of change
41 /// (either `Warning` or `Drift`).
42 pub const fn is_change(self) -> bool {
43 matches!(self, DriftLevel::Warning | DriftLevel::Drift)
44 }
45}
46
47/// Online drift detector trait.
48///
49/// Implementations track a scalar stream (prediction errors or target values)
50/// and report when the stream's distribution appears to have changed. All
51/// implementations must use bounded memory.
52///
53/// The detector is decoupled from any model: it only observes a scalar and
54/// reports a level. The decision of what to do about a drift is delegated to
55/// a [`DriftStrategy`](crate::drift::DriftStrategy).
56pub trait DriftDetector {
57 /// Update the detector with a new scalar observation.
58 ///
59 /// Returns the current [`DriftLevel`] after incorporating the value.
60 /// Returns an error if the value is not finite.
61 fn update(&mut self, value: f64) -> Result<DriftLevel, RillError>;
62
63 /// Returns `true` if the detector currently reports a confirmed drift.
64 fn detected(&self) -> bool;
65
66 /// Returns `true` if the detector currently reports a warning.
67 fn warning(&self) -> bool;
68
69 /// The current drift level.
70 fn level(&self) -> DriftLevel;
71
72 /// Number of observations incorporated so far.
73 fn samples_seen(&self) -> u64;
74
75 /// Reset the detector to its initial (no-data) state.
76 fn reset(&mut self);
77
78 /// The detector-specific statistic value from the last update.
79 ///
80 /// Useful for diagnostics and logging in
81 /// [`DriftEvent`](crate::drift::DriftEvent). For example, Page-Hinkley
82 /// returns the cumulative-sum statistic, KSWIN returns the p-value.
83 /// The default implementation returns `0.0`.
84 fn last_value(&self) -> f64 {
85 0.0
86 }
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92
93 #[test]
94 fn drift_level_as_str() {
95 assert_eq!(DriftLevel::None.as_str(), "none");
96 assert_eq!(DriftLevel::Warning.as_str(), "warning");
97 assert_eq!(DriftLevel::Drift.as_str(), "drift");
98 }
99
100 #[test]
101 fn drift_level_is_change() {
102 assert!(!DriftLevel::None.is_change());
103 assert!(DriftLevel::Warning.is_change());
104 assert!(DriftLevel::Drift.is_change());
105 }
106
107 #[test]
108 fn drift_level_default_is_none() {
109 assert_eq!(DriftLevel::default(), DriftLevel::None);
110 }
111}