Skip to main content

ipfrs_semantic/
drift_detector.rs

1//! Embedding Drift Detector
2//!
3//! Detects distributional drift in embedding populations over time.
4//! Used to trigger index rebuilds when the embedding space has shifted significantly.
5
6/// Signal level for detected drift.
7#[derive(Debug, Clone, PartialEq)]
8pub enum DriftSignal {
9    /// No significant drift detected.
10    None,
11    /// Mild drift; score in [0.1, 0.3).
12    Mild { score: f64 },
13    /// Moderate drift; score in [0.3, 0.6).
14    Moderate { score: f64 },
15    /// Severe drift; score >= 0.6.
16    Severe { score: f64 },
17}
18
19/// Statistics computed from a population of embeddings.
20#[derive(Debug, Clone)]
21pub struct PopulationStats {
22    /// Mean vector across all embeddings.
23    pub centroid: Vec<f32>,
24    /// Average cosine distance between sampled pairs.
25    pub avg_pairwise_dist: f64,
26    /// Number of embeddings used to compute these stats.
27    pub sample_count: usize,
28    /// Unix timestamp (seconds) when these stats were computed.
29    pub computed_at_secs: u64,
30}
31
32impl PopulationStats {
33    /// Returns `true` if no embeddings were included.
34    pub fn is_empty(&self) -> bool {
35        self.sample_count == 0
36    }
37}
38
39/// Report produced by [`EmbeddingDriftDetector::detect`].
40#[derive(Debug, Clone)]
41pub struct DriftReport {
42    /// Classified drift signal.
43    pub signal: DriftSignal,
44    /// Cosine distance between the baseline centroid and the current centroid.
45    pub centroid_shift: f64,
46    /// Relative change in average pairwise distance:
47    /// `|new - old| / old.max(1e-9)`.
48    pub spread_change: f64,
49    /// Weighted combined score: `0.7 * centroid_shift + 0.3 * spread_change`.
50    pub combined_score: f64,
51    /// Human-readable recommendation based on the drift signal.
52    pub recommendation: String,
53}
54
55/// Configuration for [`EmbeddingDriftDetector`].
56#[derive(Debug, Clone)]
57pub struct DriftDetectorConfig {
58    /// Score threshold below which drift is considered absent. Default: 0.1.
59    pub mild_threshold: f64,
60    /// Score threshold below which drift is considered mild. Default: 0.3.
61    pub moderate_threshold: f64,
62    /// Score threshold below which drift is considered moderate. Default: 0.6.
63    pub severe_threshold: f64,
64    /// Maximum number of pairs to sample when estimating pairwise distance. Default: 100.
65    pub sample_size: usize,
66}
67
68impl Default for DriftDetectorConfig {
69    fn default() -> Self {
70        Self {
71            mild_threshold: 0.1,
72            moderate_threshold: 0.3,
73            severe_threshold: 0.6,
74            sample_size: 100,
75        }
76    }
77}
78
79/// Detects distributional drift between two snapshots of an embedding population.
80pub struct EmbeddingDriftDetector {
81    /// Detector configuration.
82    pub config: DriftDetectorConfig,
83    /// Reference snapshot against which current populations are compared.
84    pub baseline: Option<PopulationStats>,
85}
86
87impl EmbeddingDriftDetector {
88    /// Creates a new detector with the supplied configuration.
89    pub fn new(config: DriftDetectorConfig) -> Self {
90        Self {
91            config,
92            baseline: None,
93        }
94    }
95
96    /// Computes [`PopulationStats`] for a slice of embeddings.
97    ///
98    /// Centroid is the element-wise mean; pairwise distance is estimated by
99    /// sampling consecutive pairs `(i, i+1 mod n)` up to `config.sample_size`.
100    pub fn compute_stats(&self, embeddings: &[Vec<f32>], now_secs: u64) -> PopulationStats {
101        let n = embeddings.len();
102
103        if n == 0 {
104            return PopulationStats {
105                centroid: Vec::new(),
106                avg_pairwise_dist: 0.0,
107                sample_count: 0,
108                computed_at_secs: now_secs,
109            };
110        }
111
112        let dim = embeddings[0].len();
113
114        // Compute element-wise mean (centroid).
115        let mut centroid = vec![0.0_f64; dim];
116        for emb in embeddings {
117            for (c, &v) in centroid.iter_mut().zip(emb.iter()) {
118                *c += v as f64;
119            }
120        }
121        let centroid: Vec<f32> = centroid.iter().map(|&s| (s / n as f64) as f32).collect();
122
123        // Estimate average pairwise cosine distance using consecutive-pair sampling.
124        let max_pairs = if n < 2 {
125            0
126        } else {
127            // n*(n-1)/2 but guard against overflow
128            n.saturating_mul(n.saturating_sub(1)) / 2
129        };
130        let pairs_to_sample = self.config.sample_size.min(max_pairs);
131
132        let avg_pairwise_dist = if pairs_to_sample == 0 {
133            0.0
134        } else {
135            let mut total_dist = 0.0_f64;
136            let mut counted = 0usize;
137            let mut i = 0usize;
138            while counted < pairs_to_sample {
139                let a = i % n;
140                let b = (i + 1) % n;
141                if a != b {
142                    total_dist += Self::cosine_distance(&embeddings[a], &embeddings[b]);
143                    counted += 1;
144                }
145                i += 1;
146                if i >= n && counted == 0 {
147                    // Only one unique element; bail out.
148                    break;
149                }
150                // Safety: prevent infinite loop when only identical pairs exist.
151                if i > n * 2 {
152                    break;
153                }
154            }
155            if counted > 0 {
156                total_dist / counted as f64
157            } else {
158                0.0
159            }
160        };
161
162        PopulationStats {
163            centroid,
164            avg_pairwise_dist,
165            sample_count: n,
166            computed_at_secs: now_secs,
167        }
168    }
169
170    /// Stores `stats` as the new baseline.
171    pub fn set_baseline(&mut self, stats: PopulationStats) {
172        self.baseline = Some(stats);
173    }
174
175    /// Returns `true` if a baseline has been set.
176    pub fn has_baseline(&self) -> bool {
177        self.baseline.is_some()
178    }
179
180    /// Detects drift between the stored baseline and the `current` snapshot.
181    ///
182    /// If no baseline has been set (or the baseline is empty), returns a
183    /// zero-score no-drift report.
184    pub fn detect(&self, current: &PopulationStats) -> DriftReport {
185        let baseline = match &self.baseline {
186            Some(b) if !b.is_empty() => b,
187            _ => {
188                return DriftReport {
189                    signal: DriftSignal::None,
190                    centroid_shift: 0.0,
191                    spread_change: 0.0,
192                    combined_score: 0.0,
193                    recommendation: "No action".to_string(),
194                };
195            }
196        };
197
198        let centroid_shift = Self::cosine_distance(&baseline.centroid, &current.centroid);
199
200        let spread_change = (current.avg_pairwise_dist - baseline.avg_pairwise_dist).abs()
201            / baseline.avg_pairwise_dist.max(1e-9);
202
203        let combined_score = 0.7 * centroid_shift + 0.3 * spread_change;
204
205        let signal = if combined_score < self.config.mild_threshold {
206            DriftSignal::None
207        } else if combined_score < self.config.moderate_threshold {
208            DriftSignal::Mild {
209                score: combined_score,
210            }
211        } else if combined_score < self.config.severe_threshold {
212            DriftSignal::Moderate {
213                score: combined_score,
214            }
215        } else {
216            DriftSignal::Severe {
217                score: combined_score,
218            }
219        };
220
221        let recommendation = match &signal {
222            DriftSignal::None => "No action".to_string(),
223            DriftSignal::Mild { .. } => "Monitor".to_string(),
224            DriftSignal::Moderate { .. } => "Consider rebuild".to_string(),
225            DriftSignal::Severe { .. } => "Rebuild required".to_string(),
226        };
227
228        DriftReport {
229            signal,
230            centroid_shift,
231            spread_change,
232            combined_score,
233            recommendation,
234        }
235    }
236
237    /// Computes the cosine distance between two vectors.
238    ///
239    /// Returns `1.0` if either vector has zero norm.
240    pub fn cosine_distance(a: &[f32], b: &[f32]) -> f64 {
241        let len = a.len().min(b.len());
242        if len == 0 {
243            return 1.0;
244        }
245
246        let mut dot = 0.0_f64;
247        let mut norm_a = 0.0_f64;
248        let mut norm_b = 0.0_f64;
249
250        for i in 0..len {
251            let ai = a[i] as f64;
252            let bi = b[i] as f64;
253            dot += ai * bi;
254            norm_a += ai * ai;
255            norm_b += bi * bi;
256        }
257
258        let norm_a = norm_a.sqrt();
259        let norm_b = norm_b.sqrt();
260
261        if norm_a == 0.0 || norm_b == 0.0 {
262            1.0
263        } else {
264            let cosine_sim = (dot / (norm_a * norm_b)).clamp(-1.0, 1.0);
265            1.0 - cosine_sim
266        }
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    fn default_detector() -> EmbeddingDriftDetector {
275        EmbeddingDriftDetector::new(DriftDetectorConfig::default())
276    }
277
278    // ── 1. new() with config ──────────────────────────────────────────────────
279    #[test]
280    fn test_new_with_config() {
281        let config = DriftDetectorConfig {
282            mild_threshold: 0.15,
283            moderate_threshold: 0.35,
284            severe_threshold: 0.65,
285            sample_size: 50,
286        };
287        let detector = EmbeddingDriftDetector::new(config.clone());
288        assert_eq!(detector.config.mild_threshold, 0.15);
289        assert_eq!(detector.config.sample_size, 50);
290        assert!(detector.baseline.is_none());
291    }
292
293    // ── 2. compute_stats: empty embeddings ───────────────────────────────────
294    #[test]
295    fn test_compute_stats_empty() {
296        let detector = default_detector();
297        let stats = detector.compute_stats(&[], 0);
298        assert!(stats.centroid.is_empty());
299        assert_eq!(stats.avg_pairwise_dist, 0.0);
300        assert_eq!(stats.sample_count, 0);
301        assert!(stats.is_empty());
302    }
303
304    // ── 3. compute_stats: single embedding ───────────────────────────────────
305    #[test]
306    fn test_compute_stats_single() {
307        let detector = default_detector();
308        let emb = vec![1.0_f32, 2.0, 3.0];
309        let stats = detector.compute_stats(std::slice::from_ref(&emb), 42);
310        assert_eq!(stats.sample_count, 1);
311        assert_eq!(stats.computed_at_secs, 42);
312        // Centroid should equal the single embedding.
313        for (c, e) in stats.centroid.iter().zip(emb.iter()) {
314            assert!((c - e).abs() < 1e-5, "centroid {c} != emb {e}");
315        }
316        // No pairs possible.
317        assert_eq!(stats.avg_pairwise_dist, 0.0);
318    }
319
320    // ── 4. compute_stats: two identical embeddings ───────────────────────────
321    #[test]
322    fn test_compute_stats_two_identical() {
323        let detector = default_detector();
324        let emb = vec![1.0_f32, 0.0, 0.0];
325        let stats = detector.compute_stats(&[emb.clone(), emb.clone()], 0);
326        assert_eq!(stats.sample_count, 2);
327        for (c, e) in stats.centroid.iter().zip(emb.iter()) {
328            assert!((c - e).abs() < 1e-5, "centroid mismatch: {c} vs {e}");
329        }
330        // Two identical unit vectors → cosine distance = 0.
331        assert!(
332            stats.avg_pairwise_dist < 1e-9,
333            "expected ~0, got {}",
334            stats.avg_pairwise_dist
335        );
336    }
337
338    // ── 5. compute_stats: sample_count set correctly ─────────────────────────
339    #[test]
340    fn test_compute_stats_sample_count() {
341        let detector = default_detector();
342        let embeddings: Vec<Vec<f32>> = (0..7).map(|i| vec![i as f32, 0.0]).collect();
343        let stats = detector.compute_stats(&embeddings, 0);
344        assert_eq!(stats.sample_count, 7);
345    }
346
347    // ── 6. set_baseline sets baseline ────────────────────────────────────────
348    #[test]
349    fn test_set_baseline() {
350        let mut detector = default_detector();
351        assert!(!detector.has_baseline());
352        let stats = detector.compute_stats(&[vec![1.0_f32, 0.0]], 0);
353        detector.set_baseline(stats);
354        assert!(detector.has_baseline());
355    }
356
357    // ── 7. detect: no baseline → no-drift report ─────────────────────────────
358    #[test]
359    fn test_detect_no_baseline() {
360        let detector = default_detector();
361        let current = PopulationStats {
362            centroid: vec![1.0],
363            avg_pairwise_dist: 0.1,
364            sample_count: 5,
365            computed_at_secs: 0,
366        };
367        let report = detector.detect(&current);
368        assert_eq!(report.signal, DriftSignal::None);
369        assert_eq!(report.combined_score, 0.0);
370        assert_eq!(report.recommendation, "No action");
371    }
372
373    // ── 8. detect: identical current and baseline → combined_score ≈ 0 ───────
374    #[test]
375    fn test_detect_identical_populations() {
376        let mut detector = default_detector();
377        let embeddings: Vec<Vec<f32>> = vec![
378            vec![1.0, 0.0, 0.0],
379            vec![0.0, 1.0, 0.0],
380            vec![0.0, 0.0, 1.0],
381        ];
382        let baseline = detector.compute_stats(&embeddings, 0);
383        let current = detector.compute_stats(&embeddings, 1);
384        detector.set_baseline(baseline);
385        let report = detector.detect(&current);
386        assert!(
387            report.combined_score < 1e-6,
388            "expected ≈0, got {}",
389            report.combined_score
390        );
391    }
392
393    // ── 9. detect: shifted centroid → centroid_shift > 0 ─────────────────────
394    #[test]
395    fn test_detect_shifted_centroid() {
396        let mut detector = default_detector();
397        let baseline_embs: Vec<Vec<f32>> = vec![vec![1.0_f32, 0.0, 0.0]];
398        let current_embs: Vec<Vec<f32>> = vec![vec![0.0_f32, 1.0, 0.0]];
399        let baseline = detector.compute_stats(&baseline_embs, 0);
400        let current = detector.compute_stats(&current_embs, 1);
401        detector.set_baseline(baseline);
402        let report = detector.detect(&current);
403        assert!(
404            report.centroid_shift > 0.0,
405            "expected centroid_shift > 0, got {}",
406            report.centroid_shift
407        );
408    }
409
410    // ── 10. DriftSignal::None for score < 0.1 ────────────────────────────────
411    #[test]
412    fn test_signal_none() {
413        let config = DriftDetectorConfig::default();
414        let mut detector = EmbeddingDriftDetector::new(config);
415
416        // Baseline: unit vector along X.
417        let baseline = PopulationStats {
418            centroid: vec![1.0_f32, 0.0],
419            avg_pairwise_dist: 0.05,
420            sample_count: 10,
421            computed_at_secs: 0,
422        };
423        // Current: almost the same centroid — tiny shift → combined_score < 0.1.
424        let current = PopulationStats {
425            centroid: vec![0.9999_f32, 0.0141], // ~cos dist ≈ 0.0001
426            avg_pairwise_dist: 0.05,
427            sample_count: 10,
428            computed_at_secs: 1,
429        };
430        detector.set_baseline(baseline);
431        let report = detector.detect(&current);
432        assert_eq!(report.signal, DriftSignal::None);
433    }
434
435    // ── 11. DriftSignal::Mild for score in [0.1, 0.3) ────────────────────────
436    #[test]
437    fn test_signal_mild() {
438        let mut detector = default_detector();
439        // combined_score = 0.7 * centroid_shift + 0.3 * spread_change
440        // We drive it to ~0.2 by using a moderate centroid shift.
441        // Two orthogonal unit vectors → cosine_dist = 1.0; we'll use partial rotation.
442        // cos(θ)≈0.8 → dist≈0.2, combined ≈ 0.7*0.2 + 0 = 0.14  (mild)
443        let theta: f64 = std::f64::consts::PI * 0.2; // 36°, cos≈0.809
444        let baseline = PopulationStats {
445            centroid: vec![1.0_f32, 0.0],
446            avg_pairwise_dist: 0.1,
447            sample_count: 10,
448            computed_at_secs: 0,
449        };
450        let current = PopulationStats {
451            centroid: vec![theta.cos() as f32, theta.sin() as f32],
452            avg_pairwise_dist: 0.1,
453            sample_count: 10,
454            computed_at_secs: 1,
455        };
456        detector.set_baseline(baseline);
457        let report = detector.detect(&current);
458        let score = report.combined_score;
459        assert!(
460            (0.1..0.3).contains(&score),
461            "expected mild [0.1,0.3), got {score}"
462        );
463        assert!(matches!(report.signal, DriftSignal::Mild { .. }));
464    }
465
466    // ── 12. DriftSignal::Moderate for score in [0.3, 0.6) ───────────────────
467    #[test]
468    fn test_signal_moderate() {
469        let mut detector = default_detector();
470        // cos(θ)≈0.5 → dist≈0.5 → combined ≈ 0.7*0.5 = 0.35 (moderate)
471        let theta: f64 = std::f64::consts::PI / 3.0; // 60°, cos=0.5
472        let baseline = PopulationStats {
473            centroid: vec![1.0_f32, 0.0],
474            avg_pairwise_dist: 0.1,
475            sample_count: 10,
476            computed_at_secs: 0,
477        };
478        let current = PopulationStats {
479            centroid: vec![theta.cos() as f32, theta.sin() as f32],
480            avg_pairwise_dist: 0.1,
481            sample_count: 10,
482            computed_at_secs: 1,
483        };
484        detector.set_baseline(baseline);
485        let report = detector.detect(&current);
486        let score = report.combined_score;
487        assert!(
488            (0.3..0.6).contains(&score),
489            "expected moderate [0.3,0.6), got {score}"
490        );
491        assert!(matches!(report.signal, DriftSignal::Moderate { .. }));
492    }
493
494    // ── 13. DriftSignal::Severe for score >= 0.6 ─────────────────────────────
495    #[test]
496    fn test_signal_severe() {
497        let mut detector = default_detector();
498        // Orthogonal vectors: dist = 1.0, combined = 0.7 → severe
499        let baseline = PopulationStats {
500            centroid: vec![1.0_f32, 0.0],
501            avg_pairwise_dist: 0.1,
502            sample_count: 10,
503            computed_at_secs: 0,
504        };
505        let current = PopulationStats {
506            centroid: vec![0.0_f32, 1.0],
507            avg_pairwise_dist: 0.1,
508            sample_count: 10,
509            computed_at_secs: 1,
510        };
511        detector.set_baseline(baseline);
512        let report = detector.detect(&current);
513        assert!(
514            report.combined_score >= 0.6,
515            "expected >=0.6, got {}",
516            report.combined_score
517        );
518        assert!(matches!(report.signal, DriftSignal::Severe { .. }));
519    }
520
521    // ── 14. recommendation matches signal ────────────────────────────────────
522    #[test]
523    fn test_recommendation_matches_signal() {
524        let mut detector = default_detector();
525        // Severe case.
526        let baseline = PopulationStats {
527            centroid: vec![1.0_f32, 0.0],
528            avg_pairwise_dist: 0.1,
529            sample_count: 5,
530            computed_at_secs: 0,
531        };
532        let current = PopulationStats {
533            centroid: vec![0.0_f32, 1.0],
534            avg_pairwise_dist: 0.1,
535            sample_count: 5,
536            computed_at_secs: 1,
537        };
538        detector.set_baseline(baseline.clone());
539        let report = detector.detect(&current);
540        match &report.signal {
541            DriftSignal::None => assert_eq!(report.recommendation, "No action"),
542            DriftSignal::Mild { .. } => assert_eq!(report.recommendation, "Monitor"),
543            DriftSignal::Moderate { .. } => {
544                assert_eq!(report.recommendation, "Consider rebuild")
545            }
546            DriftSignal::Severe { .. } => {
547                assert_eq!(report.recommendation, "Rebuild required")
548            }
549        }
550
551        // Also check the None case explicitly.
552        detector.set_baseline(baseline.clone());
553        let same_current = PopulationStats {
554            centroid: vec![1.0_f32, 0.0],
555            avg_pairwise_dist: 0.1,
556            sample_count: 5,
557            computed_at_secs: 2,
558        };
559        let report2 = detector.detect(&same_current);
560        assert_eq!(report2.recommendation, "No action");
561    }
562
563    // ── 15. cosine_distance identical → 0.0 ──────────────────────────────────
564    #[test]
565    fn test_cosine_distance_identical() {
566        let v = vec![3.0_f32, 4.0, 0.0];
567        let dist = EmbeddingDriftDetector::cosine_distance(&v, &v);
568        assert!(dist < 1e-9, "expected 0, got {dist}");
569    }
570
571    // ── 16. cosine_distance orthogonal → 1.0 ─────────────────────────────────
572    #[test]
573    fn test_cosine_distance_orthogonal() {
574        let a = vec![1.0_f32, 0.0];
575        let b = vec![0.0_f32, 1.0];
576        let dist = EmbeddingDriftDetector::cosine_distance(&a, &b);
577        assert!((dist - 1.0).abs() < 1e-9, "expected 1.0, got {dist}");
578    }
579
580    // ── 17. has_baseline true after set_baseline ──────────────────────────────
581    #[test]
582    fn test_has_baseline_true_after_set() {
583        let mut detector = default_detector();
584        assert!(!detector.has_baseline(), "should be false before set");
585        let stats = PopulationStats {
586            centroid: vec![1.0_f32],
587            avg_pairwise_dist: 0.0,
588            sample_count: 1,
589            computed_at_secs: 0,
590        };
591        detector.set_baseline(stats);
592        assert!(detector.has_baseline(), "should be true after set");
593    }
594}