1#[derive(Debug, Clone, PartialEq)]
8pub enum DriftSignal {
9 None,
11 Mild { score: f64 },
13 Moderate { score: f64 },
15 Severe { score: f64 },
17}
18
19#[derive(Debug, Clone)]
21pub struct PopulationStats {
22 pub centroid: Vec<f32>,
24 pub avg_pairwise_dist: f64,
26 pub sample_count: usize,
28 pub computed_at_secs: u64,
30}
31
32impl PopulationStats {
33 pub fn is_empty(&self) -> bool {
35 self.sample_count == 0
36 }
37}
38
39#[derive(Debug, Clone)]
41pub struct DriftReport {
42 pub signal: DriftSignal,
44 pub centroid_shift: f64,
46 pub spread_change: f64,
49 pub combined_score: f64,
51 pub recommendation: String,
53}
54
55#[derive(Debug, Clone)]
57pub struct DriftDetectorConfig {
58 pub mild_threshold: f64,
60 pub moderate_threshold: f64,
62 pub severe_threshold: f64,
64 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
79pub struct EmbeddingDriftDetector {
81 pub config: DriftDetectorConfig,
83 pub baseline: Option<PopulationStats>,
85}
86
87impl EmbeddingDriftDetector {
88 pub fn new(config: DriftDetectorConfig) -> Self {
90 Self {
91 config,
92 baseline: None,
93 }
94 }
95
96 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 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 let max_pairs = if n < 2 {
125 0
126 } else {
127 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 break;
149 }
150 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 pub fn set_baseline(&mut self, stats: PopulationStats) {
172 self.baseline = Some(stats);
173 }
174
175 pub fn has_baseline(&self) -> bool {
177 self.baseline.is_some()
178 }
179
180 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, ¤t.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 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 #[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 #[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 #[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 for (c, e) in stats.centroid.iter().zip(emb.iter()) {
314 assert!((c - e).abs() < 1e-5, "centroid {c} != emb {e}");
315 }
316 assert_eq!(stats.avg_pairwise_dist, 0.0);
318 }
319
320 #[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 assert!(
332 stats.avg_pairwise_dist < 1e-9,
333 "expected ~0, got {}",
334 stats.avg_pairwise_dist
335 );
336 }
337
338 #[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 #[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 #[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(¤t);
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 #[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(¤t);
386 assert!(
387 report.combined_score < 1e-6,
388 "expected ≈0, got {}",
389 report.combined_score
390 );
391 }
392
393 #[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(¤t_embs, 1);
401 detector.set_baseline(baseline);
402 let report = detector.detect(¤t);
403 assert!(
404 report.centroid_shift > 0.0,
405 "expected centroid_shift > 0, got {}",
406 report.centroid_shift
407 );
408 }
409
410 #[test]
412 fn test_signal_none() {
413 let config = DriftDetectorConfig::default();
414 let mut detector = EmbeddingDriftDetector::new(config);
415
416 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 let current = PopulationStats {
425 centroid: vec![0.9999_f32, 0.0141], 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(¤t);
432 assert_eq!(report.signal, DriftSignal::None);
433 }
434
435 #[test]
437 fn test_signal_mild() {
438 let mut detector = default_detector();
439 let theta: f64 = std::f64::consts::PI * 0.2; 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(¤t);
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 #[test]
468 fn test_signal_moderate() {
469 let mut detector = default_detector();
470 let theta: f64 = std::f64::consts::PI / 3.0; 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(¤t);
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 #[test]
496 fn test_signal_severe() {
497 let mut detector = default_detector();
498 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(¤t);
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 #[test]
523 fn test_recommendation_matches_signal() {
524 let mut detector = default_detector();
525 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(¤t);
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 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 #[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 #[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 #[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}