1const FNV_OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
8const FNV_PRIME: u64 = 1_099_511_628_211;
9
10#[inline]
12pub(crate) fn fnv1a_step(hash: u64, byte: u8) -> u64 {
13 (hash ^ (byte as u64)).wrapping_mul(FNV_PRIME)
14}
15
16#[inline]
21pub(crate) fn fnv1a_rand_f64(seed: u64, index: u64) -> f64 {
22 let mut h = FNV_OFFSET_BASIS;
23 for b in seed.to_le_bytes() {
24 h = fnv1a_step(h, b);
25 }
26 for b in index.to_le_bytes() {
27 h = fnv1a_step(h, b);
28 }
29 let mantissa = h >> 11; (mantissa as f64) / (1u64 << 53) as f64
32}
33
34#[derive(Clone, Debug, PartialEq)]
38pub enum AnomalyMethod {
39 ZScore,
41 MahalanobisApprox,
43 IsolationScore,
45}
46
47#[derive(Clone, Debug)]
49pub struct AnomalyResult {
50 pub vector_id: u64,
52 pub score: f64,
54 pub is_anomaly: bool,
56 pub method: AnomalyMethod,
58 pub flagged_dims: Vec<usize>,
60}
61
62#[derive(Clone, Debug)]
64pub struct DetectorConfig {
65 pub method: AnomalyMethod,
67 pub threshold: f64,
73 pub min_samples: usize,
75 pub max_reference: usize,
77}
78
79impl DetectorConfig {
80 pub fn with_method(method: AnomalyMethod) -> Self {
82 let threshold = match method {
83 AnomalyMethod::IsolationScore => 0.7,
84 _ => 3.0,
85 };
86 Self {
87 method,
88 threshold,
89 min_samples: 10,
90 max_reference: 1000,
91 }
92 }
93}
94
95impl Default for DetectorConfig {
96 fn default() -> Self {
97 Self::with_method(AnomalyMethod::ZScore)
98 }
99}
100
101#[derive(Clone, Debug, Default)]
103pub struct DetectorStats {
104 pub reference_count: usize,
106 pub total_checked: u64,
108 pub total_anomalies: u64,
110}
111
112impl DetectorStats {
113 pub fn anomaly_rate(&self) -> f64 {
117 if self.total_checked == 0 {
118 0.0
119 } else {
120 self.total_anomalies as f64 / self.total_checked as f64
121 }
122 }
123}
124
125pub struct VectorAnomalyDetector {
152 reference: Vec<Vec<f32>>,
154 config: DetectorConfig,
156 stats: DetectorStats,
158}
159
160impl VectorAnomalyDetector {
161 pub fn new(config: DetectorConfig) -> Self {
163 Self {
164 reference: Vec::new(),
165 config,
166 stats: DetectorStats::default(),
167 }
168 }
169
170 pub fn add_reference(&mut self, vec: Vec<f32>) {
175 if self.reference.len() >= self.config.max_reference {
176 self.reference.remove(0);
177 }
178 self.reference.push(vec);
179 self.stats.reference_count = self.reference.len();
180 }
181
182 pub fn compute_mean_std(&self) -> (Vec<f32>, Vec<f32>) {
190 let dims = self.reference[0].len();
191 let n = self.reference.len() as f32;
192
193 let mut means = vec![0.0_f32; dims];
194 for vec in &self.reference {
195 for (d, &v) in vec.iter().enumerate() {
196 means[d] += v;
197 }
198 }
199 for m in &mut means {
200 *m /= n;
201 }
202
203 let mut vars = vec![0.0_f32; dims];
204 for vec in &self.reference {
205 for (d, &v) in vec.iter().enumerate() {
206 let diff = v - means[d];
207 vars[d] += diff * diff;
208 }
209 }
210 let stds: Vec<f32> = vars.iter().map(|&v| (v / n).sqrt().max(1e-6_f32)).collect();
211
212 (means, stds)
213 }
214
215 fn compute_z_scores(vec: &[f32], means: &[f32], stds: &[f32]) -> Vec<f64> {
217 vec.iter()
218 .zip(means.iter())
219 .zip(stds.iter())
220 .map(|((&v, &m), &s)| ((v - m) / s).abs() as f64)
221 .collect()
222 }
223
224 fn top5_flagged(z_scores: &[f64]) -> Vec<usize> {
226 let mut indexed: Vec<(usize, f64)> =
227 z_scores.iter().enumerate().map(|(i, &z)| (i, z)).collect();
228 indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
229 indexed.truncate(5);
230 indexed.into_iter().map(|(i, _)| i).collect()
231 }
232
233 pub fn detect(&mut self, vector_id: u64, vec: &[f32]) -> Option<AnomalyResult> {
237 if self.reference.len() < self.config.min_samples {
238 return None;
239 }
240
241 let (means, stds) = self.compute_mean_std();
242 let dims = means.len();
243 let z_scores = Self::compute_z_scores(vec, &means, &stds);
244 let flagged_dims = Self::top5_flagged(&z_scores);
245 let threshold = self.config.threshold;
246
247 let (score, is_anomaly) = match self.config.method {
248 AnomalyMethod::ZScore => {
249 let max_z = z_scores.iter().cloned().fold(0.0_f64, f64::max);
250 let anomaly = z_scores.iter().any(|&z| z > threshold);
251 (max_z, anomaly)
252 }
253
254 AnomalyMethod::MahalanobisApprox => {
255 let sum_sq: f64 = z_scores.iter().map(|&z| z * z).sum();
256 let score = (sum_sq / dims as f64).sqrt();
257 (score, score > threshold)
258 }
259
260 AnomalyMethod::IsolationScore => {
261 let seed = vector_id;
265 let outlier_count = z_scores
266 .iter()
267 .enumerate()
268 .filter(|&(dim_idx, &z)| {
269 let split = fnv1a_rand_f64(seed, dim_idx as u64);
271 z > 1.0 && (z / (z + 1.0)) > split
273 })
274 .count();
275 let score = outlier_count as f64 / dims as f64;
276 (score, score > threshold)
277 }
278 };
279
280 self.stats.total_checked += 1;
281 if is_anomaly {
282 self.stats.total_anomalies += 1;
283 }
284
285 Some(AnomalyResult {
286 vector_id,
287 score,
288 is_anomaly,
289 method: self.config.method.clone(),
290 flagged_dims,
291 })
292 }
293
294 pub fn stats(&self) -> &DetectorStats {
296 &self.stats
297 }
298}
299
300#[cfg(test)]
305mod tests {
306 use super::*;
307
308 fn build_detector(method: AnomalyMethod, n_ref: usize, dims: usize) -> VectorAnomalyDetector {
311 let config = DetectorConfig::with_method(method);
312 let mut det = VectorAnomalyDetector::new(config);
313 for _ in 0..n_ref {
314 det.add_reference(vec![0.0_f32; dims]);
315 }
316 det
317 }
318
319 fn build_detector_with_refs(
320 method: AnomalyMethod,
321 refs: Vec<Vec<f32>>,
322 ) -> VectorAnomalyDetector {
323 let config = DetectorConfig::with_method(method);
324 let mut det = VectorAnomalyDetector::new(config);
325 for r in refs {
326 det.add_reference(r);
327 }
328 det
329 }
330
331 #[test]
334 fn test_add_reference_basic() {
335 let config = DetectorConfig::with_method(AnomalyMethod::ZScore);
336 let mut det = VectorAnomalyDetector::new(config);
337 for i in 0..5 {
338 det.add_reference(vec![i as f32]);
339 }
340 assert_eq!(det.reference.len(), 5);
341 assert_eq!(det.stats().reference_count, 5);
342 }
343
344 #[test]
345 fn test_add_reference_evicts_oldest_at_max() {
346 let mut config = DetectorConfig::with_method(AnomalyMethod::ZScore);
347 config.max_reference = 3;
348 let mut det = VectorAnomalyDetector::new(config);
349 det.add_reference(vec![999.0_f32]);
351 det.add_reference(vec![1.0_f32]);
352 det.add_reference(vec![2.0_f32]);
353 det.add_reference(vec![3.0_f32]);
355 assert_eq!(det.reference.len(), 3);
356 assert_eq!(det.stats().reference_count, 3);
357 assert!(!det.reference.iter().any(|v| v[0] == 999.0_f32));
359 assert!(det.reference.iter().any(|v| v[0] == 3.0_f32));
361 }
362
363 #[test]
364 fn test_add_reference_exactly_at_max_no_eviction() {
365 let mut config = DetectorConfig::with_method(AnomalyMethod::ZScore);
366 config.max_reference = 5;
367 let mut det = VectorAnomalyDetector::new(config);
368 for i in 0..5 {
369 det.add_reference(vec![i as f32]);
370 }
371 assert_eq!(det.reference.len(), 5);
372 }
373
374 #[test]
377 fn test_detect_none_below_min_samples() {
378 let config = DetectorConfig::with_method(AnomalyMethod::ZScore);
379 let mut det = VectorAnomalyDetector::new(config); for _ in 0..9 {
381 det.add_reference(vec![0.0_f32]);
382 }
383 let result = det.detect(1, &[0.0]);
384 assert!(result.is_none());
385 }
386
387 #[test]
388 fn test_detect_some_at_min_samples() {
389 let config = DetectorConfig::with_method(AnomalyMethod::ZScore);
390 let mut det = VectorAnomalyDetector::new(config);
391 for _ in 0..10 {
392 det.add_reference(vec![0.0_f32]);
393 }
394 let result = det.detect(1, &[0.0]);
395 assert!(result.is_some());
396 }
397
398 #[test]
401 fn test_zscore_detects_clear_outlier() {
402 let mut det = build_detector(AnomalyMethod::ZScore, 20, 3);
403 let result = det
405 .detect(42, &[100.0, 100.0, 100.0])
406 .expect("should return Some");
407 assert!(result.is_anomaly, "Expected outlier to be flagged");
408 assert!(result.score > 3.0, "score={}", result.score);
409 }
410
411 #[test]
412 fn test_zscore_no_anomaly_for_mean_vector() {
413 let mut det = build_detector_with_refs(
414 AnomalyMethod::ZScore,
415 (0..20).map(|i| vec![i as f32, -(i as f32)]).collect(),
416 );
417 let result = det
419 .detect(1, &[9.5_f32, -9.5_f32])
420 .expect("should return Some");
421 assert!(
422 !result.is_anomaly,
423 "Mean vector should not be an anomaly; score={}",
424 result.score
425 );
426 }
427
428 #[test]
429 fn test_zscore_score_is_max_z() {
430 let mut det = build_detector(AnomalyMethod::ZScore, 10, 2);
432 let result = det.detect(1, &[10.0_f32, 0.0]).expect("Some");
433 assert!(result.score > 0.0);
435 }
436
437 #[test]
438 fn test_zscore_method_field_in_result() {
439 let mut det = build_detector(AnomalyMethod::ZScore, 10, 2);
440 let result = det.detect(7, &[0.0, 0.0]).expect("Some");
441 assert_eq!(result.method, AnomalyMethod::ZScore);
442 }
443
444 #[test]
447 fn test_mahalanobis_detects_outlier() {
448 let mut det = build_detector(AnomalyMethod::MahalanobisApprox, 20, 4);
449 let result = det.detect(1, &[50.0_f32, 50.0, 50.0, 50.0]).expect("Some");
450 assert!(result.is_anomaly, "score={}", result.score);
451 }
452
453 #[test]
454 fn test_mahalanobis_score_formula() {
455 let mut det = build_detector(AnomalyMethod::MahalanobisApprox, 10, 2);
457 let result = det.detect(1, &[30.0_f32, 40.0]).expect("Some");
458 assert!(result.score > 3.0, "score={}", result.score);
459 }
460
461 #[test]
462 fn test_mahalanobis_method_field_in_result() {
463 let mut det = build_detector(AnomalyMethod::MahalanobisApprox, 10, 2);
464 let result = det.detect(3, &[0.0, 0.0]).expect("Some");
465 assert_eq!(result.method, AnomalyMethod::MahalanobisApprox);
466 }
467
468 #[test]
471 fn test_isolation_detects_outlier() {
472 let mut det = build_detector(AnomalyMethod::IsolationScore, 20, 10);
474 let far: Vec<f32> = vec![50.0_f32; 10];
475 let result = det.detect(1, &far).expect("Some");
476 assert!(result.is_anomaly, "score={}", result.score);
477 assert!(result.score > 0.7, "score={}", result.score);
478 }
479
480 #[test]
481 fn test_isolation_no_anomaly_for_normal_vector() {
482 let refs: Vec<Vec<f32>> = (0..20)
484 .map(|i| vec![i as f32 * 0.01, i as f32 * 0.01])
485 .collect();
486 let mut det = build_detector_with_refs(AnomalyMethod::IsolationScore, refs);
487 let result = det.detect(99, &[0.095_f32, 0.095]).expect("Some");
489 assert!(!result.is_anomaly, "score={}", result.score);
490 }
491
492 #[test]
493 fn test_isolation_method_field_in_result() {
494 let mut det = build_detector(AnomalyMethod::IsolationScore, 10, 3);
495 let result = det.detect(5, &[0.0, 0.0, 0.0]).expect("Some");
496 assert_eq!(result.method, AnomalyMethod::IsolationScore);
497 }
498
499 #[test]
502 fn test_flagged_dims_at_most_5() {
503 let mut det = build_detector(AnomalyMethod::ZScore, 10, 20);
504 let result = det.detect(1, &[100.0_f32; 20]).expect("Some");
505 assert!(result.flagged_dims.len() <= 5);
506 }
507
508 #[test]
509 fn test_flagged_dims_contains_highest_z_dim() {
510 let mut det = build_detector(AnomalyMethod::ZScore, 10, 5);
512 let mut query = vec![0.0_f32; 5];
513 query[2] = 1000.0; let result = det.detect(1, &query).expect("Some");
515 assert!(
516 result.flagged_dims.contains(&2),
517 "Expected dim 2 in flagged_dims: {:?}",
518 result.flagged_dims
519 );
520 }
521
522 #[test]
523 fn test_flagged_dims_ordering() {
524 let mut det = build_detector(AnomalyMethod::ZScore, 10, 6);
526 let mut query = vec![0.0_f32; 6];
527 query[0] = 300.0;
528 query[3] = 200.0;
529 query[5] = 100.0;
530 let result = det.detect(1, &query).expect("Some");
531 let pos = |d: usize| result.flagged_dims.iter().position(|&x| x == d);
533 assert!(pos(0) < pos(3), "flagged_dims={:?}", result.flagged_dims);
534 assert!(pos(3) < pos(5), "flagged_dims={:?}", result.flagged_dims);
535 }
536
537 #[test]
540 fn test_stats_total_checked_increments() {
541 let mut det = build_detector(AnomalyMethod::ZScore, 10, 2);
542 det.detect(1, &[0.0, 0.0]);
543 det.detect(2, &[0.0, 0.0]);
544 assert_eq!(det.stats().total_checked, 2);
545 }
546
547 #[test]
548 fn test_stats_total_anomalies() {
549 let mut det = build_detector(AnomalyMethod::ZScore, 10, 2);
550 det.detect(1, &[0.0, 0.0]); det.detect(2, &[1000.0, 1000.0]); assert_eq!(det.stats().total_anomalies, 1);
553 }
554
555 #[test]
556 fn test_anomaly_rate_zero_when_no_checks() {
557 let config = DetectorConfig::with_method(AnomalyMethod::ZScore);
558 let det = VectorAnomalyDetector::new(config);
559 assert_eq!(det.stats().anomaly_rate(), 0.0);
560 }
561
562 #[test]
563 fn test_anomaly_rate_correct() {
564 let mut det = build_detector(AnomalyMethod::ZScore, 10, 1);
565 for _ in 0..4 {
567 det.detect(0, &[0.0]);
568 }
569 det.detect(99, &[1000.0]);
570 let rate = det.stats().anomaly_rate();
572 assert!((rate - 0.2).abs() < 1e-9, "rate={rate}");
573 }
574
575 #[test]
578 fn test_compute_mean_std_correct_mean() {
579 let refs = vec![vec![1.0_f32, 2.0], vec![3.0_f32, 4.0]];
580 let config = DetectorConfig::with_method(AnomalyMethod::ZScore);
581 let mut det = VectorAnomalyDetector::new(config);
582 for r in refs {
583 det.add_reference(r);
584 }
585 let (means, _stds) = det.compute_mean_std();
586 assert!((means[0] - 2.0).abs() < 1e-5, "mean[0]={}", means[0]);
587 assert!((means[1] - 3.0).abs() < 1e-5, "mean[1]={}", means[1]);
588 }
589
590 #[test]
591 fn test_compute_mean_std_clamped_std() {
592 let refs = vec![vec![5.0_f32]; 10];
594 let det = build_detector_with_refs(AnomalyMethod::ZScore, refs);
595 let (_means, stds) = det.compute_mean_std();
596 assert!(
597 stds[0] >= 1e-6_f32,
598 "std should be clamped to at least 1e-6, got {}",
599 stds[0]
600 );
601 }
602
603 #[test]
606 fn test_fnv1a_rand_f64_in_range() {
607 for i in 0..100u64 {
608 let v = fnv1a_rand_f64(42, i);
609 assert!((0.0..1.0).contains(&v), "v={v}");
610 }
611 }
612
613 #[test]
614 fn test_fnv1a_rand_f64_different_seeds() {
615 let a = fnv1a_rand_f64(1, 0);
616 let b = fnv1a_rand_f64(2, 0);
617 assert_ne!(a, b);
618 }
619}
620
621#[derive(Debug, Clone, Copy, PartialEq, Eq)]
627pub enum SemanticAnomalyMethod {
628 ZScore,
630 IQR,
632 DistanceBased,
634}
635
636#[derive(Debug, Clone)]
638pub struct AnomalyConfig {
639 pub method: SemanticAnomalyMethod,
641 pub z_threshold: f64,
643 pub iqr_multiplier: f64,
645 pub distance_multiplier: f64,
647}
648
649impl Default for AnomalyConfig {
650 fn default() -> Self {
651 Self {
652 method: SemanticAnomalyMethod::ZScore,
653 z_threshold: 3.0,
654 iqr_multiplier: 1.5,
655 distance_multiplier: 2.0,
656 }
657 }
658}
659
660#[derive(Debug, Clone)]
662pub struct SemanticAnomalyResult {
663 pub doc_id: String,
665 pub score: f64,
667 pub is_anomaly: bool,
669 pub method: SemanticAnomalyMethod,
671}
672
673#[derive(Debug, Clone)]
675pub struct AnomalyDetectorStats {
676 pub embedding_count: usize,
678 pub detections_run: u64,
680 pub method: SemanticAnomalyMethod,
682}
683
684pub struct SemanticAnomalyDetector {
709 config: AnomalyConfig,
710 embeddings: Vec<(String, Vec<f64>)>,
711 centroid: Vec<f64>,
712 detections_run: u64,
713}
714
715impl SemanticAnomalyDetector {
716 pub fn new(config: AnomalyConfig) -> Self {
718 Self {
719 config,
720 embeddings: Vec::new(),
721 centroid: Vec::new(),
722 detections_run: 0,
723 }
724 }
725
726 pub fn add_embedding(&mut self, doc_id: &str, embedding: Vec<f64>) {
728 let n = self.embeddings.len();
729 if n == 0 {
730 self.centroid = embedding.clone();
731 } else {
732 let new_n = (n + 1) as f64;
734 if self.centroid.len() == embedding.len() {
735 for (c, &e) in self.centroid.iter_mut().zip(embedding.iter()) {
736 *c = *c * (n as f64 / new_n) + e / new_n;
737 }
738 } else {
739 self.centroid = Self::compute_centroid_from_iter(
741 self.embeddings
742 .iter()
743 .map(|(_, v)| v.as_slice())
744 .chain(std::iter::once(embedding.as_slice())),
745 embedding.len(),
746 );
747 }
748 }
749 self.embeddings.push((doc_id.to_string(), embedding));
750 }
751
752 pub fn remove_embedding(&mut self, doc_id: &str) -> bool {
756 let before = self.embeddings.len();
757 self.embeddings.retain(|(id, _)| id != doc_id);
758 let removed = self.embeddings.len() < before;
759 if removed {
760 if self.embeddings.is_empty() {
761 self.centroid.clear();
762 } else {
763 let dims = self.embeddings[0].1.len();
764 self.centroid = Self::compute_centroid_from_iter(
765 self.embeddings.iter().map(|(_, v)| v.as_slice()),
766 dims,
767 );
768 }
769 }
770 removed
771 }
772
773 pub fn detect_all(&mut self) -> Vec<SemanticAnomalyResult> {
775 self.detections_run += 1;
776
777 if self.embeddings.len() < 2 {
778 return self
780 .embeddings
781 .iter()
782 .map(|(id, _)| SemanticAnomalyResult {
783 doc_id: id.clone(),
784 score: 0.0,
785 is_anomaly: false,
786 method: self.config.method,
787 })
788 .collect();
789 }
790
791 let distances = self.distances_to_centroid();
792 let dist_values: Vec<f64> = distances.iter().map(|(_, d)| *d).collect();
793
794 match self.config.method {
795 SemanticAnomalyMethod::ZScore => self.detect_zscore(&distances, &dist_values),
796 SemanticAnomalyMethod::IQR => self.detect_iqr(&distances, &dist_values),
797 SemanticAnomalyMethod::DistanceBased => {
798 self.detect_distance_based(&distances, &dist_values)
799 }
800 }
801 }
802
803 pub fn detect_single(&self, embedding: &[f64]) -> SemanticAnomalyResult {
807 if self.embeddings.len() < 2 || self.centroid.is_empty() {
808 return SemanticAnomalyResult {
809 doc_id: String::new(),
810 score: 0.0,
811 is_anomaly: false,
812 method: self.config.method,
813 };
814 }
815
816 let dist = Self::euclidean_distance(embedding, &self.centroid);
817 let existing_dists: Vec<f64> = self
818 .embeddings
819 .iter()
820 .map(|(_, v)| Self::euclidean_distance(v, &self.centroid))
821 .collect();
822
823 let (score, is_anomaly) = match self.config.method {
824 SemanticAnomalyMethod::ZScore => {
825 let (mean, std) = Self::mean_std(&existing_dists);
826 let z = if std < 1e-12 {
827 0.0
828 } else {
829 (dist - mean) / std
830 };
831 (z.abs(), z.abs() > self.config.z_threshold)
832 }
833 SemanticAnomalyMethod::IQR => {
834 let (_, q3, iqr) = Self::quartiles(&existing_dists);
835 let upper = q3 + self.config.iqr_multiplier * iqr;
836 (dist, dist > upper)
837 }
838 SemanticAnomalyMethod::DistanceBased => {
839 let (mean, std) = Self::mean_std(&existing_dists);
840 let threshold = mean + self.config.distance_multiplier * std;
841 (dist, dist > threshold)
842 }
843 };
844
845 SemanticAnomalyResult {
846 doc_id: String::new(),
847 score,
848 is_anomaly,
849 method: self.config.method,
850 }
851 }
852
853 pub fn compute_centroid(embeddings: &[(String, Vec<f64>)]) -> Vec<f64> {
855 if embeddings.is_empty() {
856 return Vec::new();
857 }
858 let dims = embeddings[0].1.len();
859 Self::compute_centroid_from_iter(embeddings.iter().map(|(_, v)| v.as_slice()), dims)
860 }
861
862 fn compute_centroid_from_iter<'a>(
864 iter: impl Iterator<Item = &'a [f64]>,
865 dims: usize,
866 ) -> Vec<f64> {
867 let mut sum = vec![0.0_f64; dims];
868 let mut count = 0usize;
869 for v in iter {
870 for (s, &val) in sum.iter_mut().zip(v.iter()) {
871 *s += val;
872 }
873 count += 1;
874 }
875 if count == 0 {
876 return sum;
877 }
878 let n = count as f64;
879 for s in &mut sum {
880 *s /= n;
881 }
882 sum
883 }
884
885 pub fn euclidean_distance(a: &[f64], b: &[f64]) -> f64 {
887 a.iter()
888 .zip(b.iter())
889 .map(|(&x, &y)| {
890 let d = x - y;
891 d * d
892 })
893 .sum::<f64>()
894 .sqrt()
895 }
896
897 pub fn distances_to_centroid(&self) -> Vec<(String, f64)> {
899 self.embeddings
900 .iter()
901 .map(|(id, v)| {
902 let d = Self::euclidean_distance(v, &self.centroid);
903 (id.clone(), d)
904 })
905 .collect()
906 }
907
908 pub fn embedding_count(&self) -> usize {
910 self.embeddings.len()
911 }
912
913 pub fn stats(&self) -> AnomalyDetectorStats {
915 AnomalyDetectorStats {
916 embedding_count: self.embeddings.len(),
917 detections_run: self.detections_run,
918 method: self.config.method,
919 }
920 }
921
922 fn detect_zscore(
925 &self,
926 distances: &[(String, f64)],
927 dist_values: &[f64],
928 ) -> Vec<SemanticAnomalyResult> {
929 let (mean, std) = Self::mean_std(dist_values);
930 distances
931 .iter()
932 .map(|(id, d)| {
933 let z = if std < 1e-12 { 0.0 } else { (*d - mean) / std };
934 SemanticAnomalyResult {
935 doc_id: id.clone(),
936 score: z.abs(),
937 is_anomaly: z.abs() > self.config.z_threshold,
938 method: SemanticAnomalyMethod::ZScore,
939 }
940 })
941 .collect()
942 }
943
944 fn detect_iqr(
945 &self,
946 distances: &[(String, f64)],
947 dist_values: &[f64],
948 ) -> Vec<SemanticAnomalyResult> {
949 let (_q1, q3, iqr) = Self::quartiles(dist_values);
950 let upper = q3 + self.config.iqr_multiplier * iqr;
951 distances
952 .iter()
953 .map(|(id, d)| SemanticAnomalyResult {
954 doc_id: id.clone(),
955 score: *d,
956 is_anomaly: *d > upper,
957 method: SemanticAnomalyMethod::IQR,
958 })
959 .collect()
960 }
961
962 fn detect_distance_based(
963 &self,
964 distances: &[(String, f64)],
965 dist_values: &[f64],
966 ) -> Vec<SemanticAnomalyResult> {
967 let (mean, std) = Self::mean_std(dist_values);
968 let threshold = mean + self.config.distance_multiplier * std;
969 distances
970 .iter()
971 .map(|(id, d)| SemanticAnomalyResult {
972 doc_id: id.clone(),
973 score: *d,
974 is_anomaly: *d > threshold,
975 method: SemanticAnomalyMethod::DistanceBased,
976 })
977 .collect()
978 }
979
980 fn mean_std(values: &[f64]) -> (f64, f64) {
981 if values.is_empty() {
982 return (0.0, 0.0);
983 }
984 let n = values.len() as f64;
985 let mean = values.iter().sum::<f64>() / n;
986 let variance = values.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / n;
987 (mean, variance.sqrt())
988 }
989
990 fn quartiles(values: &[f64]) -> (f64, f64, f64) {
992 if values.len() < 2 {
993 let v = values.first().copied().unwrap_or(0.0);
994 return (v, v, 0.0);
995 }
996 let mut sorted: Vec<f64> = values.to_vec();
997 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
998
999 let q1 = Self::percentile_sorted(&sorted, 25.0);
1000 let q3 = Self::percentile_sorted(&sorted, 75.0);
1001 (q1, q3, q3 - q1)
1002 }
1003
1004 fn percentile_sorted(sorted: &[f64], pct: f64) -> f64 {
1006 if sorted.is_empty() {
1007 return 0.0;
1008 }
1009 if sorted.len() == 1 {
1010 return sorted[0];
1011 }
1012 let rank = (pct / 100.0) * (sorted.len() - 1) as f64;
1013 let lo = rank.floor() as usize;
1014 let hi = rank.ceil() as usize;
1015 let frac = rank - lo as f64;
1016 if lo == hi {
1017 sorted[lo]
1018 } else {
1019 sorted[lo] * (1.0 - frac) + sorted[hi] * frac
1020 }
1021 }
1022}
1023
1024#[cfg(test)]
1029mod semantic_anomaly_tests {
1030 use super::*;
1031
1032 fn make_config(method: SemanticAnomalyMethod) -> AnomalyConfig {
1033 AnomalyConfig {
1034 method,
1035 ..AnomalyConfig::default()
1036 }
1037 }
1038
1039 fn cluster_with_outlier() -> SemanticAnomalyDetector {
1040 let mut det = SemanticAnomalyDetector::new(make_config(SemanticAnomalyMethod::ZScore));
1041 for i in 0..20 {
1043 det.add_embedding(
1044 &format!("normal_{i}"),
1045 vec![0.01 * i as f64, -0.01 * i as f64, 0.0],
1046 );
1047 }
1048 det.add_embedding("outlier", vec![100.0, 100.0, 100.0]);
1050 det
1051 }
1052
1053 #[test]
1056 fn test_new_creates_empty_detector() {
1057 let det = SemanticAnomalyDetector::new(AnomalyConfig::default());
1058 assert_eq!(det.embedding_count(), 0);
1059 assert!(det.centroid.is_empty());
1060 }
1061
1062 #[test]
1063 fn test_default_config_values() {
1064 let cfg = AnomalyConfig::default();
1065 assert_eq!(cfg.method, SemanticAnomalyMethod::ZScore);
1066 assert!((cfg.z_threshold - 3.0).abs() < f64::EPSILON);
1067 assert!((cfg.iqr_multiplier - 1.5).abs() < f64::EPSILON);
1068 assert!((cfg.distance_multiplier - 2.0).abs() < f64::EPSILON);
1069 }
1070
1071 #[test]
1074 fn test_add_embedding_increments_count() {
1075 let mut det = SemanticAnomalyDetector::new(AnomalyConfig::default());
1076 det.add_embedding("a", vec![1.0, 2.0]);
1077 det.add_embedding("b", vec![3.0, 4.0]);
1078 assert_eq!(det.embedding_count(), 2);
1079 }
1080
1081 #[test]
1082 fn test_add_embedding_updates_centroid() {
1083 let mut det = SemanticAnomalyDetector::new(AnomalyConfig::default());
1084 det.add_embedding("a", vec![0.0, 0.0]);
1085 assert!((det.centroid[0]).abs() < 1e-12);
1086 det.add_embedding("b", vec![2.0, 4.0]);
1087 assert!((det.centroid[0] - 1.0).abs() < 1e-9);
1089 assert!((det.centroid[1] - 2.0).abs() < 1e-9);
1090 }
1091
1092 #[test]
1093 fn test_remove_embedding_returns_true_if_found() {
1094 let mut det = SemanticAnomalyDetector::new(AnomalyConfig::default());
1095 det.add_embedding("x", vec![1.0]);
1096 assert!(det.remove_embedding("x"));
1097 assert_eq!(det.embedding_count(), 0);
1098 }
1099
1100 #[test]
1101 fn test_remove_embedding_returns_false_if_not_found() {
1102 let mut det = SemanticAnomalyDetector::new(AnomalyConfig::default());
1103 det.add_embedding("x", vec![1.0]);
1104 assert!(!det.remove_embedding("y"));
1105 assert_eq!(det.embedding_count(), 1);
1106 }
1107
1108 #[test]
1109 fn test_remove_embedding_updates_centroid() {
1110 let mut det = SemanticAnomalyDetector::new(AnomalyConfig::default());
1111 det.add_embedding("a", vec![0.0, 0.0]);
1112 det.add_embedding("b", vec![4.0, 6.0]);
1113 det.add_embedding("c", vec![2.0, 3.0]);
1114 det.remove_embedding("c");
1116 assert!((det.centroid[0] - 2.0).abs() < 1e-9);
1118 assert!((det.centroid[1] - 3.0).abs() < 1e-9);
1119 }
1120
1121 #[test]
1122 fn test_remove_last_embedding_clears_centroid() {
1123 let mut det = SemanticAnomalyDetector::new(AnomalyConfig::default());
1124 det.add_embedding("only", vec![5.0, 5.0]);
1125 det.remove_embedding("only");
1126 assert!(det.centroid.is_empty());
1127 }
1128
1129 #[test]
1132 fn test_euclidean_distance_basic() {
1133 let d = SemanticAnomalyDetector::euclidean_distance(&[0.0, 0.0], &[3.0, 4.0]);
1134 assert!((d - 5.0).abs() < 1e-9);
1135 }
1136
1137 #[test]
1138 fn test_euclidean_distance_same_point() {
1139 let d = SemanticAnomalyDetector::euclidean_distance(&[1.0, 2.0, 3.0], &[1.0, 2.0, 3.0]);
1140 assert!(d.abs() < 1e-12);
1141 }
1142
1143 #[test]
1146 fn test_compute_centroid_empty() {
1147 let c = SemanticAnomalyDetector::compute_centroid(&[]);
1148 assert!(c.is_empty());
1149 }
1150
1151 #[test]
1152 fn test_compute_centroid_single() {
1153 let embs = vec![("a".to_string(), vec![3.0, 6.0])];
1154 let c = SemanticAnomalyDetector::compute_centroid(&embs);
1155 assert!((c[0] - 3.0).abs() < 1e-9);
1156 assert!((c[1] - 6.0).abs() < 1e-9);
1157 }
1158
1159 #[test]
1160 fn test_compute_centroid_multiple() {
1161 let embs = vec![
1162 ("a".to_string(), vec![0.0, 0.0]),
1163 ("b".to_string(), vec![4.0, 8.0]),
1164 ];
1165 let c = SemanticAnomalyDetector::compute_centroid(&embs);
1166 assert!((c[0] - 2.0).abs() < 1e-9);
1167 assert!((c[1] - 4.0).abs() < 1e-9);
1168 }
1169
1170 #[test]
1173 fn test_distances_to_centroid() {
1174 let mut det = SemanticAnomalyDetector::new(AnomalyConfig::default());
1175 det.add_embedding("a", vec![0.0, 0.0]);
1176 det.add_embedding("b", vec![6.0, 8.0]);
1177 let dists = det.distances_to_centroid();
1179 assert_eq!(dists.len(), 2);
1180 assert!((dists[0].1 - 5.0).abs() < 1e-9);
1182 assert!((dists[1].1 - 5.0).abs() < 1e-9);
1184 }
1185
1186 #[test]
1189 fn test_zscore_detects_obvious_outlier() {
1190 let mut det = cluster_with_outlier();
1191 let results = det.detect_all();
1192 let outlier = results
1193 .iter()
1194 .find(|r| r.doc_id == "outlier")
1195 .expect("outlier should be in results");
1196 assert!(outlier.is_anomaly, "outlier should be flagged");
1197 assert!(outlier.score > 3.0, "score={}", outlier.score);
1198 }
1199
1200 #[test]
1201 fn test_zscore_normal_not_flagged() {
1202 let mut det = cluster_with_outlier();
1203 let results = det.detect_all();
1204 let normals: Vec<_> = results
1205 .iter()
1206 .filter(|r| r.doc_id.starts_with("normal_"))
1207 .collect();
1208 let flagged_count = normals.iter().filter(|r| r.is_anomaly).count();
1209 assert!(
1211 flagged_count <= 2,
1212 "Too many normals flagged: {flagged_count}/{}",
1213 normals.len()
1214 );
1215 }
1216
1217 #[test]
1218 fn test_zscore_method_in_result() {
1219 let mut det = cluster_with_outlier();
1220 let results = det.detect_all();
1221 for r in &results {
1222 assert_eq!(r.method, SemanticAnomalyMethod::ZScore);
1223 }
1224 }
1225
1226 #[test]
1229 fn test_iqr_detects_obvious_outlier() {
1230 let mut det = SemanticAnomalyDetector::new(make_config(SemanticAnomalyMethod::IQR));
1231 for i in 0..20 {
1232 det.add_embedding(
1233 &format!("normal_{i}"),
1234 vec![0.01 * i as f64, -0.01 * i as f64, 0.0],
1235 );
1236 }
1237 det.add_embedding("outlier", vec![100.0, 100.0, 100.0]);
1238 let results = det.detect_all();
1239 let outlier = results
1240 .iter()
1241 .find(|r| r.doc_id == "outlier")
1242 .expect("outlier in results");
1243 assert!(outlier.is_anomaly, "outlier should be flagged by IQR");
1244 }
1245
1246 #[test]
1247 fn test_iqr_normal_not_flagged() {
1248 let mut det = SemanticAnomalyDetector::new(make_config(SemanticAnomalyMethod::IQR));
1249 for i in 0..20 {
1250 det.add_embedding(
1251 &format!("normal_{i}"),
1252 vec![0.01 * i as f64, -0.01 * i as f64],
1253 );
1254 }
1255 let results = det.detect_all();
1256 let flagged = results.iter().filter(|r| r.is_anomaly).count();
1257 assert!(
1259 flagged <= 3,
1260 "Too many flagged: {flagged}/{}",
1261 results.len()
1262 );
1263 }
1264
1265 #[test]
1266 fn test_iqr_method_in_result() {
1267 let mut det = SemanticAnomalyDetector::new(make_config(SemanticAnomalyMethod::IQR));
1268 det.add_embedding("a", vec![1.0]);
1269 det.add_embedding("b", vec![2.0]);
1270 det.add_embedding("c", vec![3.0]);
1271 let results = det.detect_all();
1272 for r in &results {
1273 assert_eq!(r.method, SemanticAnomalyMethod::IQR);
1274 }
1275 }
1276
1277 #[test]
1280 fn test_distance_based_detects_outlier() {
1281 let mut det =
1282 SemanticAnomalyDetector::new(make_config(SemanticAnomalyMethod::DistanceBased));
1283 for i in 0..20 {
1284 det.add_embedding(&format!("normal_{i}"), vec![0.01 * i as f64, 0.0]);
1285 }
1286 det.add_embedding("outlier", vec![100.0, 100.0]);
1287 let results = det.detect_all();
1288 let outlier = results
1289 .iter()
1290 .find(|r| r.doc_id == "outlier")
1291 .expect("outlier in results");
1292 assert!(
1293 outlier.is_anomaly,
1294 "outlier should be flagged by DistanceBased"
1295 );
1296 }
1297
1298 #[test]
1299 fn test_distance_based_normal_not_flagged() {
1300 let mut det =
1301 SemanticAnomalyDetector::new(make_config(SemanticAnomalyMethod::DistanceBased));
1302 for i in 0..20 {
1303 det.add_embedding(&format!("normal_{i}"), vec![0.01 * i as f64, 0.0]);
1304 }
1305 let results = det.detect_all();
1306 let flagged = results.iter().filter(|r| r.is_anomaly).count();
1307 assert!(
1308 flagged <= 3,
1309 "Too many flagged: {flagged}/{}",
1310 results.len()
1311 );
1312 }
1313
1314 #[test]
1315 fn test_distance_based_method_in_result() {
1316 let mut det =
1317 SemanticAnomalyDetector::new(make_config(SemanticAnomalyMethod::DistanceBased));
1318 det.add_embedding("a", vec![1.0]);
1319 det.add_embedding("b", vec![2.0]);
1320 let results = det.detect_all();
1321 for r in &results {
1322 assert_eq!(r.method, SemanticAnomalyMethod::DistanceBased);
1323 }
1324 }
1325
1326 #[test]
1329 fn test_detect_single_flags_outlier() {
1330 let mut det = SemanticAnomalyDetector::new(AnomalyConfig::default());
1331 for i in 0..20 {
1332 det.add_embedding(
1333 &format!("n{i}"),
1334 vec![0.01 * i as f64, -0.01 * i as f64, 0.005 * i as f64],
1335 );
1336 }
1337 let result = det.detect_single(&[100.0, 100.0, 100.0]);
1338 assert!(result.is_anomaly, "single outlier should be flagged");
1339 }
1340
1341 #[test]
1342 fn test_detect_single_normal_not_flagged() {
1343 let mut det = SemanticAnomalyDetector::new(AnomalyConfig::default());
1344 for i in 0..20 {
1345 det.add_embedding(&format!("n{i}"), vec![0.01 * i as f64, -0.01 * i as f64]);
1346 }
1347 let result = det.detect_single(&[0.1, -0.1]);
1348 assert!(!result.is_anomaly, "normal point should not be flagged");
1349 }
1350
1351 #[test]
1352 fn test_detect_single_empty_detector() {
1353 let det = SemanticAnomalyDetector::new(AnomalyConfig::default());
1354 let result = det.detect_single(&[1.0, 2.0]);
1355 assert!(
1356 !result.is_anomaly,
1357 "empty detector should not flag anything"
1358 );
1359 assert!((result.score).abs() < 1e-12);
1360 }
1361
1362 #[test]
1365 fn test_detect_all_empty_returns_empty() {
1366 let mut det = SemanticAnomalyDetector::new(AnomalyConfig::default());
1367 let results = det.detect_all();
1368 assert!(results.is_empty());
1369 }
1370
1371 #[test]
1372 fn test_detect_all_single_embedding_no_anomaly() {
1373 let mut det = SemanticAnomalyDetector::new(AnomalyConfig::default());
1374 det.add_embedding("solo", vec![42.0, 42.0]);
1375 let results = det.detect_all();
1376 assert_eq!(results.len(), 1);
1377 assert!(
1378 !results[0].is_anomaly,
1379 "single embedding cannot be anomalous"
1380 );
1381 }
1382
1383 #[test]
1386 fn test_stats_initial() {
1387 let det = SemanticAnomalyDetector::new(AnomalyConfig::default());
1388 let s = det.stats();
1389 assert_eq!(s.embedding_count, 0);
1390 assert_eq!(s.detections_run, 0);
1391 assert_eq!(s.method, SemanticAnomalyMethod::ZScore);
1392 }
1393
1394 #[test]
1395 fn test_stats_after_operations() {
1396 let mut det = SemanticAnomalyDetector::new(make_config(SemanticAnomalyMethod::IQR));
1397 det.add_embedding("a", vec![1.0]);
1398 det.add_embedding("b", vec![2.0]);
1399 det.detect_all();
1400 det.detect_all();
1401 let s = det.stats();
1402 assert_eq!(s.embedding_count, 2);
1403 assert_eq!(s.detections_run, 2);
1404 assert_eq!(s.method, SemanticAnomalyMethod::IQR);
1405 }
1406
1407 #[test]
1410 fn test_score_ordering_outlier_highest() {
1411 let mut det = cluster_with_outlier();
1412 let results = det.detect_all();
1413 let outlier_score = results
1414 .iter()
1415 .find(|r| r.doc_id == "outlier")
1416 .map(|r| r.score)
1417 .expect("outlier in results");
1418 let max_normal_score = results
1419 .iter()
1420 .filter(|r| r.doc_id != "outlier")
1421 .map(|r| r.score)
1422 .fold(0.0_f64, f64::max);
1423 assert!(
1424 outlier_score > max_normal_score,
1425 "outlier score ({outlier_score}) should exceed max normal ({max_normal_score})"
1426 );
1427 }
1428
1429 #[test]
1430 fn test_score_ordering_closer_to_centroid_lower_score() {
1431 let mut det =
1432 SemanticAnomalyDetector::new(make_config(SemanticAnomalyMethod::DistanceBased));
1433 for i in 0..20 {
1434 det.add_embedding(&format!("n{i}"), vec![0.0, 0.0]);
1435 }
1436 det.add_embedding("far", vec![10.0, 10.0]);
1437 det.add_embedding("farther", vec![50.0, 50.0]);
1438 let results = det.detect_all();
1439 let far_score = results
1440 .iter()
1441 .find(|r| r.doc_id == "far")
1442 .map(|r| r.score)
1443 .expect("far");
1444 let farther_score = results
1445 .iter()
1446 .find(|r| r.doc_id == "farther")
1447 .map(|r| r.score)
1448 .expect("farther");
1449 assert!(
1450 farther_score > far_score,
1451 "farther ({farther_score}) should score higher than far ({far_score})"
1452 );
1453 }
1454
1455 #[test]
1458 fn test_detect_single_iqr() {
1459 let mut det = SemanticAnomalyDetector::new(make_config(SemanticAnomalyMethod::IQR));
1460 for i in 0..20 {
1461 det.add_embedding(&format!("n{i}"), vec![0.0, 0.0]);
1462 }
1463 let result = det.detect_single(&[100.0, 100.0]);
1464 assert!(result.is_anomaly);
1465 assert_eq!(result.method, SemanticAnomalyMethod::IQR);
1466 }
1467
1468 #[test]
1469 fn test_detect_single_distance_based() {
1470 let mut det =
1471 SemanticAnomalyDetector::new(make_config(SemanticAnomalyMethod::DistanceBased));
1472 for i in 0..20 {
1473 det.add_embedding(&format!("n{i}"), vec![0.0, 0.0]);
1474 }
1475 let result = det.detect_single(&[100.0, 100.0]);
1476 assert!(result.is_anomaly);
1477 assert_eq!(result.method, SemanticAnomalyMethod::DistanceBased);
1478 }
1479
1480 #[test]
1483 fn test_custom_z_threshold() {
1484 let config = AnomalyConfig {
1485 method: SemanticAnomalyMethod::ZScore,
1486 z_threshold: 100.0, ..AnomalyConfig::default()
1488 };
1489 let mut det = SemanticAnomalyDetector::new(config);
1490 for i in 0..20 {
1491 det.add_embedding(&format!("n{i}"), vec![0.0]);
1492 }
1493 det.add_embedding("outlier", vec![10.0]);
1494 let results = det.detect_all();
1495 let flagged = results.iter().filter(|r| r.is_anomaly).count();
1497 assert_eq!(flagged, 0, "high z_threshold should prevent flagging");
1498 }
1499
1500 #[test]
1501 fn test_custom_iqr_multiplier() {
1502 let config = AnomalyConfig {
1503 method: SemanticAnomalyMethod::IQR,
1504 iqr_multiplier: 0.01, ..AnomalyConfig::default()
1506 };
1507 let mut det = SemanticAnomalyDetector::new(config);
1508 for i in 0..20 {
1509 det.add_embedding(&format!("n{i}"), vec![i as f64, 0.0]);
1510 }
1511 let results = det.detect_all();
1512 let flagged = results.iter().filter(|r| r.is_anomaly).count();
1514 assert!(flagged > 0, "tight iqr_multiplier should flag some");
1515 }
1516}