Skip to main content

ipfrs_semantic/
drift_monitor.rs

1//! Embedding Drift Monitor
2//!
3//! Monitors concept drift in embedding distributions by comparing incoming vectors
4//! against a baseline, alerting when the distribution shifts significantly.
5
6/// Signal indicating the degree of distributional drift detected.
7#[derive(Clone, Debug, PartialEq)]
8pub enum DriftSignal {
9    /// No significant drift detected.
10    NoDrift,
11    /// Minor drift detected; magnitude in [0.1, 0.3).
12    MinorDrift { magnitude: f64 },
13    /// Major drift detected; magnitude >= 0.3.
14    MajorDrift { magnitude: f64 },
15    /// Insufficient data to compute drift (fewer than min_baseline_samples).
16    InsufficientData,
17}
18
19/// Per-dimension statistics computed from the baseline window.
20#[derive(Clone, Debug)]
21pub struct BaselineStats {
22    /// Per-dimension mean of baseline vectors.
23    pub mean: Vec<f32>,
24    /// Per-dimension standard deviation of baseline vectors (minimum 1e-6).
25    pub std: Vec<f32>,
26    /// Number of samples used to compute this baseline.
27    pub sample_count: usize,
28}
29
30impl BaselineStats {
31    /// Compute baseline statistics from a slice of embedding vectors.
32    ///
33    /// Returns `None` if `samples` is empty or all vectors have zero length.
34    pub fn compute(samples: &[Vec<f32>]) -> Option<Self> {
35        if samples.is_empty() {
36            return None;
37        }
38        let dims = samples[0].len();
39        if dims == 0 {
40            return None;
41        }
42        let n = samples.len() as f64;
43
44        // Compute per-dimension mean.
45        let mut mean = vec![0.0_f64; dims];
46        for vec in samples {
47            for (d, &v) in vec.iter().enumerate() {
48                mean[d] += v as f64;
49            }
50        }
51        for m in &mut mean {
52            *m /= n;
53        }
54
55        // Compute per-dimension variance, then std.
56        let mut variance = vec![0.0_f64; dims];
57        for vec in samples {
58            for (d, &v) in vec.iter().enumerate() {
59                let diff = v as f64 - mean[d];
60                variance[d] += diff * diff;
61            }
62        }
63        let mean_f32: Vec<f32> = mean.iter().map(|&m| m as f32).collect();
64        let std_f32: Vec<f32> = variance
65            .iter()
66            .map(|&v| {
67                let s = (v / n).sqrt();
68                s.max(1e-6_f64) as f32
69            })
70            .collect();
71
72        Some(BaselineStats {
73            mean: mean_f32,
74            std: std_f32,
75            sample_count: samples.len(),
76        })
77    }
78}
79
80/// Configuration for the [`EmbeddingDriftMonitor`].
81#[derive(Clone, Debug)]
82pub struct DriftMonitorConfig {
83    /// Minimum number of baseline samples required before drift is computed.
84    pub min_baseline_samples: usize,
85    /// Normalised magnitude threshold below which drift is classified as [`DriftSignal::NoDrift`].
86    pub minor_drift_threshold: f64,
87    /// Normalised magnitude threshold at or above which drift is classified as [`DriftSignal::MajorDrift`].
88    pub major_drift_threshold: f64,
89    /// Maximum number of recent vectors retained in the sliding window.
90    pub window_size: usize,
91    /// Re-compute the baseline every N new vectors added.
92    pub baseline_update_interval: usize,
93}
94
95impl Default for DriftMonitorConfig {
96    fn default() -> Self {
97        Self {
98            min_baseline_samples: 50,
99            minor_drift_threshold: 0.1,
100            major_drift_threshold: 0.3,
101            window_size: 100,
102            baseline_update_interval: 25,
103        }
104    }
105}
106
107/// Cumulative statistics tracked by the monitor.
108#[derive(Clone, Debug, Default)]
109pub struct DriftMonitorStats {
110    /// Total number of vectors checked for drift.
111    pub total_checked: u64,
112    /// Number of vectors that triggered a [`DriftSignal::MinorDrift`].
113    pub minor_drift_count: u64,
114    /// Number of vectors that triggered a [`DriftSignal::MajorDrift`].
115    pub major_drift_count: u64,
116    /// Number of times the baseline has been recomputed.
117    pub baseline_updates: u64,
118}
119
120impl DriftMonitorStats {
121    /// Fraction of checked vectors that resulted in any drift signal.
122    ///
123    /// Returns `0.0` when no vectors have been checked yet.
124    pub fn drift_rate(&self) -> f64 {
125        let total = self.total_checked.max(1);
126        (self.minor_drift_count + self.major_drift_count) as f64 / total as f64
127    }
128}
129
130/// Monitors concept drift in embedding distributions.
131///
132/// Maintains a sliding window of recent vectors, periodically recomputes
133/// a baseline, and classifies each new vector against the baseline using
134/// normalised per-dimension deviation.
135pub struct EmbeddingDriftMonitor {
136    /// Current baseline statistics, if enough samples have been seen.
137    pub baseline: Option<BaselineStats>,
138    /// Sliding window of recent vectors.
139    pub recent: Vec<Vec<f32>>,
140    /// Monitor configuration.
141    pub config: DriftMonitorConfig,
142    /// Cumulative statistics.
143    pub stats: DriftMonitorStats,
144    /// Number of vectors added since the last baseline update.
145    pub update_counter: usize,
146}
147
148impl EmbeddingDriftMonitor {
149    /// Create a new monitor with the given configuration.
150    pub fn new(config: DriftMonitorConfig) -> Self {
151        Self {
152            baseline: None,
153            recent: Vec::new(),
154            config,
155            stats: DriftMonitorStats::default(),
156            update_counter: 0,
157        }
158    }
159
160    /// Add a vector to the monitor and return a [`DriftSignal`].
161    ///
162    /// Steps:
163    /// 1. Push `vec` into the sliding window, evicting the oldest entry when
164    ///    `recent.len() > window_size`.
165    /// 2. Increment `update_counter`; if `update_counter >= baseline_update_interval`
166    ///    and enough samples exist, recompute the baseline.
167    /// 3. If no baseline is available, return [`DriftSignal::InsufficientData`].
168    /// 4. Otherwise, compute the mean absolute normalised deviation and classify.
169    pub fn add_vector(&mut self, vec: Vec<f32>) -> DriftSignal {
170        // 1. Maintain sliding window.
171        self.recent.push(vec.clone());
172        if self.recent.len() > self.config.window_size {
173            self.recent.remove(0);
174        }
175
176        // 2. Possibly recompute baseline.
177        self.update_counter += 1;
178        if self.update_counter >= self.config.baseline_update_interval
179            && self.recent.len() >= self.config.min_baseline_samples
180        {
181            if let Some(new_baseline) = BaselineStats::compute(&self.recent) {
182                self.baseline = Some(new_baseline);
183                self.update_counter = 0;
184                self.stats.baseline_updates += 1;
185            }
186        }
187
188        // 3. Guard: no baseline yet.
189        let baseline = match &self.baseline {
190            Some(b) => b,
191            None => return DriftSignal::InsufficientData,
192        };
193
194        // 4. Compute mean absolute normalised deviation.
195        let dims = baseline.mean.len();
196        if dims == 0 || vec.len() != dims {
197            return DriftSignal::InsufficientData;
198        }
199
200        let magnitude: f64 = vec
201            .iter()
202            .enumerate()
203            .map(|(i, &v)| {
204                let deviation = (v as f64 - baseline.mean[i] as f64) / baseline.std[i] as f64;
205                deviation.abs()
206            })
207            .sum::<f64>()
208            / dims as f64;
209
210        self.stats.total_checked += 1;
211
212        if magnitude >= self.config.major_drift_threshold {
213            self.stats.major_drift_count += 1;
214            DriftSignal::MajorDrift { magnitude }
215        } else if magnitude >= self.config.minor_drift_threshold {
216            self.stats.minor_drift_count += 1;
217            DriftSignal::MinorDrift { magnitude }
218        } else {
219            DriftSignal::NoDrift
220        }
221    }
222
223    /// Return a reference to the current baseline statistics, if available.
224    pub fn baseline(&self) -> Option<&BaselineStats> {
225        self.baseline.as_ref()
226    }
227
228    /// Return a reference to the cumulative statistics.
229    pub fn stats(&self) -> &DriftMonitorStats {
230        &self.stats
231    }
232
233    /// Clear the baseline, recent window, and reset counters.
234    pub fn reset_baseline(&mut self) {
235        self.baseline = None;
236        self.recent.clear();
237        self.update_counter = 0;
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    // Helper: build a monitor with a small config for quick tests.
246    fn small_config(min_baseline: usize, window: usize, interval: usize) -> DriftMonitorConfig {
247        DriftMonitorConfig {
248            min_baseline_samples: min_baseline,
249            window_size: window,
250            baseline_update_interval: interval,
251            minor_drift_threshold: 0.1,
252            major_drift_threshold: 0.3,
253        }
254    }
255
256    // Helper: constant embedding vector of given dimension.
257    fn const_vec(dim: usize, value: f32) -> Vec<f32> {
258        vec![value; dim]
259    }
260
261    // Helper: populate monitor with `n` identical vectors so baseline is ready.
262    fn seed_monitor(monitor: &mut EmbeddingDriftMonitor, n: usize, dim: usize, value: f32) {
263        for _ in 0..n {
264            monitor.add_vector(const_vec(dim, value));
265        }
266    }
267
268    // ---------------------------------------------------------------------------
269    // 1. InsufficientData before min_baseline_samples
270    // ---------------------------------------------------------------------------
271    #[test]
272    fn test_insufficient_data_initially() {
273        let cfg = small_config(5, 20, 5);
274        let mut monitor = EmbeddingDriftMonitor::new(cfg);
275        let signal = monitor.add_vector(const_vec(4, 0.5));
276        assert_eq!(signal, DriftSignal::InsufficientData);
277    }
278
279    #[test]
280    fn test_insufficient_data_before_threshold() {
281        let cfg = small_config(5, 20, 5);
282        let mut monitor = EmbeddingDriftMonitor::new(cfg);
283        // Add 4 vectors — one short of min_baseline_samples=5.
284        for _ in 0..4 {
285            let s = monitor.add_vector(const_vec(4, 0.5));
286            assert_eq!(s, DriftSignal::InsufficientData);
287        }
288    }
289
290    // ---------------------------------------------------------------------------
291    // 2. Baseline computed after enough samples
292    // ---------------------------------------------------------------------------
293    #[test]
294    fn test_baseline_computed_after_enough_samples() {
295        let cfg = small_config(5, 20, 5);
296        let mut monitor = EmbeddingDriftMonitor::new(cfg);
297        seed_monitor(&mut monitor, 5, 4, 1.0);
298        assert!(monitor.baseline().is_some());
299    }
300
301    #[test]
302    fn test_baseline_mean_values() {
303        let cfg = small_config(4, 20, 4);
304        let mut monitor = EmbeddingDriftMonitor::new(cfg);
305        // All vectors identical → mean should equal that value.
306        seed_monitor(&mut monitor, 4, 3, 2.0);
307        let baseline = monitor.baseline().expect("baseline should exist");
308        for &m in &baseline.mean {
309            assert!((m - 2.0_f32).abs() < 1e-4, "mean={m}");
310        }
311    }
312
313    // ---------------------------------------------------------------------------
314    // 3. NoDrift for mean-similar vector
315    // ---------------------------------------------------------------------------
316    #[test]
317    fn test_no_drift_for_identical_vector() {
318        let cfg = small_config(5, 20, 5);
319        let mut monitor = EmbeddingDriftMonitor::new(cfg);
320        // Build baseline with constant vectors.
321        for _ in 0..4 {
322            monitor.add_vector(const_vec(4, 0.0));
323        }
324        // 5th vector triggers baseline computation.
325        let signal = monitor.add_vector(const_vec(4, 0.0));
326        // Same as mean → NoDrift.
327        assert_eq!(signal, DriftSignal::NoDrift);
328    }
329
330    #[test]
331    fn test_no_drift_small_deviation() {
332        let cfg = small_config(5, 20, 5);
333        let mut monitor = EmbeddingDriftMonitor::new(cfg);
334        seed_monitor(&mut monitor, 5, 4, 1.0);
335        // Tiny perturbation that stays below 0.1 normalised threshold.
336        // std will be floored at 1e-6, so even a tiny absolute deviation yields large magnitude.
337        // Use a vector that is exactly the mean.
338        let signal = monitor.add_vector(const_vec(4, 1.0));
339        assert_eq!(signal, DriftSignal::NoDrift);
340    }
341
342    // ---------------------------------------------------------------------------
343    // 4. MinorDrift
344    // ---------------------------------------------------------------------------
345    #[test]
346    fn test_minor_drift_detected() {
347        // Use a varied baseline so std is non-trivial.
348        let cfg = small_config(4, 20, 4);
349        let mut monitor = EmbeddingDriftMonitor::new(cfg);
350        // Baseline: alternating 0.0 and 2.0 per sample so mean=1.0 std≈1.0.
351        monitor.add_vector(vec![0.0, 0.0, 0.0, 0.0]);
352        monitor.add_vector(vec![2.0, 2.0, 2.0, 2.0]);
353        monitor.add_vector(vec![0.0, 0.0, 0.0, 0.0]);
354        // 4th vector triggers baseline (interval=4).
355        monitor.add_vector(vec![2.0, 2.0, 2.0, 2.0]);
356        // mean ≈ 1.0, std ≈ 1.0 → deviation of 0.15 → magnitude ≈ 0.15.
357        let signal = monitor.add_vector(vec![1.15, 1.15, 1.15, 1.15]);
358        match signal {
359            DriftSignal::MinorDrift { magnitude } => {
360                assert!((0.1..0.3).contains(&magnitude), "magnitude={magnitude}");
361            }
362            other => panic!("expected MinorDrift, got {other:?}"),
363        }
364    }
365
366    #[test]
367    fn test_minor_drift_stats_increment() {
368        let cfg = small_config(4, 20, 4);
369        let mut monitor = EmbeddingDriftMonitor::new(cfg);
370        monitor.add_vector(vec![0.0; 4]);
371        monitor.add_vector(vec![2.0; 4]);
372        monitor.add_vector(vec![0.0; 4]);
373        monitor.add_vector(vec![2.0; 4]);
374        monitor.add_vector(vec![1.15; 4]);
375        assert_eq!(monitor.stats().minor_drift_count, 1);
376    }
377
378    // ---------------------------------------------------------------------------
379    // 5. MajorDrift
380    // ---------------------------------------------------------------------------
381    #[test]
382    fn test_major_drift_detected() {
383        let cfg = small_config(4, 20, 4);
384        let mut monitor = EmbeddingDriftMonitor::new(cfg);
385        monitor.add_vector(vec![0.0; 4]);
386        monitor.add_vector(vec![2.0; 4]);
387        monitor.add_vector(vec![0.0; 4]);
388        monitor.add_vector(vec![2.0; 4]);
389        // magnitude = |5.0 - 1.0| / std ≈ 4.0 >> 0.3.
390        let signal = monitor.add_vector(vec![5.0; 4]);
391        match signal {
392            DriftSignal::MajorDrift { magnitude } => {
393                assert!(magnitude >= 0.3, "magnitude={magnitude}");
394            }
395            other => panic!("expected MajorDrift, got {other:?}"),
396        }
397    }
398
399    #[test]
400    fn test_major_drift_stats_increment() {
401        let cfg = small_config(4, 20, 4);
402        let mut monitor = EmbeddingDriftMonitor::new(cfg);
403        // Baseline is built from these 4 vectors (mean≈1.0, std≈1.0).
404        // The 4th vector (2.0) is checked after baseline is set → magnitude≈1.0 → MajorDrift.
405        monitor.add_vector(vec![0.0; 4]);
406        monitor.add_vector(vec![2.0; 4]);
407        monitor.add_vector(vec![0.0; 4]);
408        monitor.add_vector(vec![2.0; 4]);
409        // 5th vector far from mean → additional MajorDrift.
410        monitor.add_vector(vec![5.0; 4]);
411        // Two major drift events: the 4th and 5th vectors.
412        assert_eq!(monitor.stats().major_drift_count, 2);
413    }
414
415    // ---------------------------------------------------------------------------
416    // 6. Sliding window evicts oldest
417    // ---------------------------------------------------------------------------
418    #[test]
419    fn test_sliding_window_size_capped() {
420        let cfg = small_config(3, 5, 3);
421        let mut monitor = EmbeddingDriftMonitor::new(cfg);
422        for i in 0..10_u8 {
423            monitor.add_vector(vec![i as f32; 2]);
424        }
425        assert!(
426            monitor.recent.len() <= 5,
427            "window exceeded: {}",
428            monitor.recent.len()
429        );
430    }
431
432    #[test]
433    fn test_sliding_window_oldest_evicted() {
434        let cfg = small_config(2, 3, 2);
435        let mut monitor = EmbeddingDriftMonitor::new(cfg);
436        monitor.add_vector(vec![1.0, 1.0]);
437        monitor.add_vector(vec![2.0, 2.0]);
438        monitor.add_vector(vec![3.0, 3.0]);
439        // After 3 inserts into a window of size 3, vec [1.0,1.0] should be gone.
440        monitor.add_vector(vec![4.0, 4.0]);
441        // Window should now be [2,3,4]; the first vector [1,1] evicted.
442        assert!(!monitor.recent.contains(&vec![1.0, 1.0]));
443        assert_eq!(monitor.recent.len(), 3);
444    }
445
446    // ---------------------------------------------------------------------------
447    // 7. Baseline updates at interval
448    // ---------------------------------------------------------------------------
449    #[test]
450    fn test_baseline_update_increments_counter() {
451        // interval=3, min_baseline=3, window=20
452        let cfg = small_config(3, 20, 3);
453        let mut monitor = EmbeddingDriftMonitor::new(cfg);
454        // First baseline update at sample 3.
455        seed_monitor(&mut monitor, 3, 2, 1.0);
456        assert_eq!(monitor.stats().baseline_updates, 1);
457        // Next update at sample 6 (3 more).
458        seed_monitor(&mut monitor, 3, 2, 1.0);
459        assert_eq!(monitor.stats().baseline_updates, 2);
460    }
461
462    #[test]
463    fn test_no_baseline_update_before_interval() {
464        let cfg = small_config(3, 20, 5);
465        let mut monitor = EmbeddingDriftMonitor::new(cfg);
466        // Only 3 vectors added; interval=5 not reached.
467        seed_monitor(&mut monitor, 3, 2, 1.0);
468        assert_eq!(monitor.stats().baseline_updates, 0);
469        // Baseline is None because update never triggered.
470        assert!(monitor.baseline().is_none());
471    }
472
473    // ---------------------------------------------------------------------------
474    // 8. drift_rate
475    // ---------------------------------------------------------------------------
476    #[test]
477    fn test_drift_rate_zero_initially() {
478        let stats = DriftMonitorStats::default();
479        assert_eq!(stats.drift_rate(), 0.0);
480    }
481
482    #[test]
483    fn test_drift_rate_calculation() {
484        let stats = DriftMonitorStats {
485            total_checked: 10,
486            minor_drift_count: 2,
487            major_drift_count: 3,
488            baseline_updates: 1,
489        };
490        let rate = stats.drift_rate();
491        assert!((rate - 0.5).abs() < 1e-9, "rate={rate}");
492    }
493
494    #[test]
495    fn test_drift_rate_all_drift() {
496        let stats = DriftMonitorStats {
497            total_checked: 4,
498            minor_drift_count: 4,
499            major_drift_count: 0,
500            baseline_updates: 0,
501        };
502        assert!((stats.drift_rate() - 1.0).abs() < 1e-9);
503    }
504
505    // ---------------------------------------------------------------------------
506    // 9. reset_baseline clears state
507    // ---------------------------------------------------------------------------
508    #[test]
509    fn test_reset_baseline_clears_baseline() {
510        let cfg = small_config(3, 20, 3);
511        let mut monitor = EmbeddingDriftMonitor::new(cfg);
512        seed_monitor(&mut monitor, 3, 2, 1.0);
513        assert!(monitor.baseline().is_some());
514        monitor.reset_baseline();
515        assert!(monitor.baseline().is_none());
516    }
517
518    #[test]
519    fn test_reset_baseline_clears_recent() {
520        let cfg = small_config(3, 20, 3);
521        let mut monitor = EmbeddingDriftMonitor::new(cfg);
522        seed_monitor(&mut monitor, 3, 2, 1.0);
523        monitor.reset_baseline();
524        assert!(monitor.recent.is_empty());
525    }
526
527    #[test]
528    fn test_reset_baseline_resets_update_counter() {
529        let cfg = small_config(3, 20, 3);
530        let mut monitor = EmbeddingDriftMonitor::new(cfg);
531        seed_monitor(&mut monitor, 3, 2, 1.0);
532        monitor.reset_baseline();
533        assert_eq!(monitor.update_counter, 0);
534    }
535
536    // ---------------------------------------------------------------------------
537    // 10. stats.baseline_updates increments correctly
538    // ---------------------------------------------------------------------------
539    #[test]
540    fn test_baseline_updates_exact_count() {
541        // interval=2, min_baseline=2, window=20 → update at every 2 vectors.
542        let cfg = small_config(2, 20, 2);
543        let mut monitor = EmbeddingDriftMonitor::new(cfg);
544        for _ in 0..8 {
545            monitor.add_vector(const_vec(2, 1.0));
546        }
547        // Updates at samples 2, 4, 6, 8 → 4 updates.
548        assert_eq!(monitor.stats().baseline_updates, 4);
549    }
550
551    // ---------------------------------------------------------------------------
552    // 11. total_checked increments only when baseline exists
553    // ---------------------------------------------------------------------------
554    #[test]
555    fn test_total_checked_only_after_baseline() {
556        let cfg = small_config(3, 20, 3);
557        let mut monitor = EmbeddingDriftMonitor::new(cfg);
558        // First 3 vectors trigger baseline; 3rd one gets drift-checked.
559        seed_monitor(&mut monitor, 3, 2, 1.0);
560        // total_checked should be 1 (the 3rd vector, which triggered baseline and was then checked).
561        assert_eq!(monitor.stats().total_checked, 1);
562    }
563}