Skip to main content

ipfrs_semantic/
anomaly_detector.rs

1//! Vector Anomaly Detector
2//!
3//! Detects anomalous vectors relative to a reference distribution using
4//! z-score and isolation-score techniques with FNV-1a seeded random projections.
5
6// ── FNV-1a constants ─────────────────────────────────────────────────────────
7const FNV_OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
8const FNV_PRIME: u64 = 1_099_511_628_211;
9
10/// Perform one step of FNV-1a hashing.
11#[inline]
12pub(crate) fn fnv1a_step(hash: u64, byte: u8) -> u64 {
13    (hash ^ (byte as u64)).wrapping_mul(FNV_PRIME)
14}
15
16/// Generate a pseudo-random `f64` in `[0, 1)` from a seed and an index using FNV-1a.
17///
18/// Used by the [`AnomalyMethod::IsolationScore`] detector to derive per-dimension
19/// random splits from `vector_id`.
20#[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    // Map to [0, 1) via mantissa bits
30    let mantissa = h >> 11; // 53 significant bits
31    (mantissa as f64) / (1u64 << 53) as f64
32}
33
34// ─────────────────────────────────────────────────────────────────────────────
35
36/// Method used for anomaly detection.
37#[derive(Clone, Debug, PartialEq)]
38pub enum AnomalyMethod {
39    /// Flag if any dimension's z-score exceeds the threshold.
40    ZScore,
41    /// Mahalanobis-like score using per-dimension std-dev normalisation.
42    MahalanobisApprox,
43    /// Random-projection isolation score (FNV-1a seeded splits).
44    IsolationScore,
45}
46
47/// Result produced for a single vector by [`VectorAnomalyDetector::detect`].
48#[derive(Clone, Debug)]
49pub struct AnomalyResult {
50    /// Identifier of the checked vector.
51    pub vector_id: u64,
52    /// Anomaly score — higher values are more anomalous.
53    pub score: f64,
54    /// Whether the vector is considered anomalous.
55    pub is_anomaly: bool,
56    /// Detection method used.
57    pub method: AnomalyMethod,
58    /// Up to 5 dimension indices that contributed most (by |z-score|).
59    pub flagged_dims: Vec<usize>,
60}
61
62/// Configuration for [`VectorAnomalyDetector`].
63#[derive(Clone, Debug)]
64pub struct DetectorConfig {
65    /// Detection method.
66    pub method: AnomalyMethod,
67    /// Anomaly threshold.
68    ///
69    /// Defaults: `3.0` for [`AnomalyMethod::ZScore`] /
70    /// [`AnomalyMethod::MahalanobisApprox`], `0.7` for
71    /// [`AnomalyMethod::IsolationScore`].
72    pub threshold: f64,
73    /// Minimum number of reference vectors required before detection starts.
74    pub min_samples: usize,
75    /// Maximum number of reference vectors to retain (drop oldest on overflow).
76    pub max_reference: usize,
77}
78
79impl DetectorConfig {
80    /// Create a config with sensible defaults for the given method.
81    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/// Running statistics for a [`VectorAnomalyDetector`].
102#[derive(Clone, Debug, Default)]
103pub struct DetectorStats {
104    /// Number of reference vectors currently held.
105    pub reference_count: usize,
106    /// Total number of vectors that have been checked.
107    pub total_checked: u64,
108    /// Total number of vectors flagged as anomalous.
109    pub total_anomalies: u64,
110}
111
112impl DetectorStats {
113    /// Fraction of checked vectors that were flagged as anomalous.
114    ///
115    /// Returns `0.0` if no vectors have been checked yet.
116    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
125// ─────────────────────────────────────────────────────────────────────────────
126
127/// Detects anomalous vectors relative to a sliding-window reference distribution.
128///
129/// # Example
130/// ```
131/// use ipfrs_semantic::anomaly_detector::{
132///     AnomalyMethod, DetectorConfig, VectorAnomalyDetector,
133/// };
134///
135/// let config = DetectorConfig::with_method(AnomalyMethod::ZScore);
136/// let mut detector = VectorAnomalyDetector::new(config);
137///
138/// // Add reference vectors (all roughly [0.0, 0.0, 0.0])
139/// for _ in 0..12 {
140///     detector.add_reference(vec![0.0_f32, 0.0, 0.0]);
141/// }
142///
143/// // A normal vector should not be flagged
144/// let normal = detector.detect(1, &[0.1, -0.1, 0.05]);
145/// assert!(normal.is_some());
146///
147/// // A large outlier should be flagged
148/// let outlier = detector.detect(2, &[100.0, 100.0, 100.0]);
149/// assert!(outlier.map_or(false, |r| r.is_anomaly));
150/// ```
151pub struct VectorAnomalyDetector {
152    /// Sliding-window set of reference vectors.
153    reference: Vec<Vec<f32>>,
154    /// Detector configuration.
155    config: DetectorConfig,
156    /// Running statistics.
157    stats: DetectorStats,
158}
159
160impl VectorAnomalyDetector {
161    /// Create a new detector with the given configuration.
162    pub fn new(config: DetectorConfig) -> Self {
163        Self {
164            reference: Vec::new(),
165            config,
166            stats: DetectorStats::default(),
167        }
168    }
169
170    /// Add a reference vector.
171    ///
172    /// If the reference set has reached `max_reference`, the oldest vector is
173    /// evicted before the new one is appended.
174    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    /// Compute per-dimension mean and standard deviation over the reference set.
183    ///
184    /// Returns `(means, stds)` where each slice has length equal to the
185    /// dimensionality of the reference vectors.  The std is clamped to a
186    /// minimum of `1e-6` to avoid division by zero.
187    ///
188    /// Panics if the reference set is empty.
189    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    /// Compute z-scores for each dimension and return top-5 flagged indices.
216    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    /// Return the indices of the top-5 dimensions by z-score magnitude.
225    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    /// Check a single vector against the reference distribution.
234    ///
235    /// Returns `None` if the reference set is smaller than `min_samples`.
236    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                // Use FNV-1a hash of vector_id as seed for per-dimension random splits.
262                // A dimension is considered isolated if its normalised deviation exceeds
263                // the random split value drawn for that dimension.
264                let seed = vector_id;
265                let outlier_count = z_scores
266                    .iter()
267                    .enumerate()
268                    .filter(|&(dim_idx, &z)| {
269                        // Split threshold drawn from [0, 1) for this (seed, dim) pair
270                        let split = fnv1a_rand_f64(seed, dim_idx as u64);
271                        // A dimension is "isolated" if |z| > 1.0 AND exceeds the random split
272                        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    /// Access running statistics.
295    pub fn stats(&self) -> &DetectorStats {
296        &self.stats
297    }
298}
299
300// ─────────────────────────────────────────────────────────────────────────────
301// Tests
302// ─────────────────────────────────────────────────────────────────────────────
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    // ── helpers ──────────────────────────────────────────────────────────────
309
310    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    // ── reference management ─────────────────────────────────────────────────
332
333    #[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        // Push sentinel "oldest" vector first
350        det.add_reference(vec![999.0_f32]);
351        det.add_reference(vec![1.0_f32]);
352        det.add_reference(vec![2.0_f32]);
353        // Pushing a 4th should evict 999.0
354        det.add_reference(vec![3.0_f32]);
355        assert_eq!(det.reference.len(), 3);
356        assert_eq!(det.stats().reference_count, 3);
357        // The oldest (999.0) must no longer be present
358        assert!(!det.reference.iter().any(|v| v[0] == 999.0_f32));
359        // The newest value must be present
360        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    // ── detect below min_samples ─────────────────────────────────────────────
375
376    #[test]
377    fn test_detect_none_below_min_samples() {
378        let config = DetectorConfig::with_method(AnomalyMethod::ZScore);
379        let mut det = VectorAnomalyDetector::new(config); // min_samples = 10
380        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    // ── ZScore method ────────────────────────────────────────────────────────
399
400    #[test]
401    fn test_zscore_detects_clear_outlier() {
402        let mut det = build_detector(AnomalyMethod::ZScore, 20, 3);
403        // All refs are [0,0,0] with near-zero std → any large value will have massive z-score
404        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        // Mean of [0..19] = 9.5, mean of [-0..-19] = -9.5
418        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        // Build refs with known variance: all refs are 0; a query of [10,0] gives max_z = 10/std
431        let mut det = build_detector(AnomalyMethod::ZScore, 10, 2);
432        let result = det.detect(1, &[10.0_f32, 0.0]).expect("Some");
433        // score should be much greater than 0
434        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    // ── MahalanobisApprox method ─────────────────────────────────────────────
445
446    #[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        // With all refs = 0 and tiny std, a vector far from mean gives large score
456        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    // ── IsolationScore method ────────────────────────────────────────────────
469
470    #[test]
471    fn test_isolation_detects_outlier() {
472        // All dims far from the mean → score close to 1.0 → exceeds 0.7
473        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        // Build refs uniformly, query with the mean → score near 0
483        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        // A vector very close to mean with |z|<1 on all dims
488        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    // ── flagged_dims ─────────────────────────────────────────────────────────
500
501    #[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        // One dimension is far out, rest are zero
511        let mut det = build_detector(AnomalyMethod::ZScore, 10, 5);
512        let mut query = vec![0.0_f32; 5];
513        query[2] = 1000.0; // dim 2 has the extreme z-score
514        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        // Three differing extremes; top-5 should be ordered by descending z-score
525        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        // dim 0 should appear before dim 3, dim 3 before dim 5 in flagged list
532        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    // ── stats ────────────────────────────────────────────────────────────────
538
539    #[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]); // normal
551        det.detect(2, &[1000.0, 1000.0]); // anomaly
552        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        // All refs are 0.0; outlier at 1000 will be flagged
566        for _ in 0..4 {
567            det.detect(0, &[0.0]);
568        }
569        det.detect(99, &[1000.0]);
570        // 1 anomaly out of 5 checks → rate = 0.2
571        let rate = det.stats().anomaly_rate();
572        assert!((rate - 0.2).abs() < 1e-9, "rate={rate}");
573    }
574
575    // ── compute_mean_std ─────────────────────────────────────────────────────
576
577    #[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        // All reference vectors identical → std should be clamped to 1e-6
593        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    // ── FNV-1a helper ────────────────────────────────────────────────────────
604
605    #[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// ═════════════════════════════════════════════════════════════════════════════
622// SemanticAnomalyDetector — distance-from-centroid and IQR-based detection
623// ═════════════════════════════════════════════════════════════════════════════
624
625/// Method used by [`SemanticAnomalyDetector`] for anomaly detection.
626#[derive(Debug, Clone, Copy, PartialEq, Eq)]
627pub enum SemanticAnomalyMethod {
628    /// Z-score of distance from centroid exceeds threshold.
629    ZScore,
630    /// Distance outside Q3 + multiplier * IQR.
631    IQR,
632    /// Distance from centroid exceeds mean + multiplier * std_dev.
633    DistanceBased,
634}
635
636/// Configuration for [`SemanticAnomalyDetector`].
637#[derive(Debug, Clone)]
638pub struct AnomalyConfig {
639    /// Detection method.
640    pub method: SemanticAnomalyMethod,
641    /// Threshold for z-score method (default 3.0).
642    pub z_threshold: f64,
643    /// Multiplier for IQR method (default 1.5).
644    pub iqr_multiplier: f64,
645    /// Multiplier for distance-based method (default 2.0).
646    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/// Result of anomaly detection for a single document embedding.
661#[derive(Debug, Clone)]
662pub struct SemanticAnomalyResult {
663    /// Document identifier.
664    pub doc_id: String,
665    /// Anomaly score (higher = more anomalous).
666    pub score: f64,
667    /// Whether the embedding is considered anomalous.
668    pub is_anomaly: bool,
669    /// Detection method used.
670    pub method: SemanticAnomalyMethod,
671}
672
673/// Statistics for [`SemanticAnomalyDetector`].
674#[derive(Debug, Clone)]
675pub struct AnomalyDetectorStats {
676    /// Number of embeddings currently stored.
677    pub embedding_count: usize,
678    /// Number of detection runs performed.
679    pub detections_run: u64,
680    /// Detection method configured.
681    pub method: SemanticAnomalyMethod,
682}
683
684/// Detects anomalous embeddings using distance-from-centroid analysis.
685///
686/// Supports three detection methods:
687/// - **ZScore**: z-score of distances to centroid
688/// - **IQR**: interquartile range on distances
689/// - **DistanceBased**: mean + multiplier * std_dev threshold on distances
690///
691/// # Example
692/// ```
693/// use ipfrs_semantic::anomaly_detector::{
694///     SemanticAnomalyDetector, AnomalyConfig, SemanticAnomalyMethod,
695/// };
696///
697/// let config = AnomalyConfig::default();
698/// let mut detector = SemanticAnomalyDetector::new(config);
699///
700/// // Add some normal embeddings
701/// for i in 0..20 {
702///     detector.add_embedding(&format!("doc_{i}"), vec![0.1 * i as f64, 0.0, 0.0]);
703/// }
704///
705/// // Detect anomalies
706/// let results = detector.detect_all();
707/// ```
708pub struct SemanticAnomalyDetector {
709    config: AnomalyConfig,
710    embeddings: Vec<(String, Vec<f64>)>,
711    centroid: Vec<f64>,
712    detections_run: u64,
713}
714
715impl SemanticAnomalyDetector {
716    /// Create a new detector with the given configuration.
717    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    /// Add an embedding and incrementally recompute the centroid.
727    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            // Incremental centroid: new_centroid = old_centroid * n/(n+1) + new_point / (n+1)
733            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                // Dimension mismatch: resize centroid
740                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    /// Remove an embedding by doc_id. Returns `true` if found and removed.
753    ///
754    /// Recomputes the centroid from scratch after removal.
755    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    /// Run detection on all stored embeddings using the configured method.
774    pub fn detect_all(&mut self) -> Vec<SemanticAnomalyResult> {
775        self.detections_run += 1;
776
777        if self.embeddings.len() < 2 {
778            // With 0 or 1 embeddings, nothing can be anomalous
779            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    /// Check if a single new embedding is anomalous against existing data.
804    ///
805    /// Does not add the embedding to the detector.
806    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    /// Compute the centroid (mean vector) of the given embeddings.
854    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    /// Compute centroid from an iterator of embedding slices.
863    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    /// Euclidean distance between two vectors.
886    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    /// Compute (doc_id, distance) pairs for all stored embeddings to centroid.
898    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    /// Number of stored embeddings.
909    pub fn embedding_count(&self) -> usize {
910        self.embeddings.len()
911    }
912
913    /// Get detector statistics.
914    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    // ── private helpers ─────────────────────────────────────────────────────
923
924    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    /// Compute Q1, Q3, and IQR from a slice of values.
991    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    /// Linear interpolation percentile on a pre-sorted slice.
1005    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// ═════════════════════════════════════════════════════════════════════════════
1025// SemanticAnomalyDetector Tests
1026// ═════════════════════════════════════════════════════════════════════════════
1027
1028#[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        // Add a tight cluster around origin
1042        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        // Add an obvious outlier
1049        det.add_embedding("outlier", vec![100.0, 100.0, 100.0]);
1050        det
1051    }
1052
1053    // ── basic construction ──────────────────────────────────────────────────
1054
1055    #[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    // ── add / remove embedding ──────────────────────────────────────────────
1072
1073    #[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        // centroid should be [1.0, 2.0]
1088        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        // centroid = [2.0, 3.0]
1115        det.remove_embedding("c");
1116        // centroid should be [2.0, 3.0] still (mean of [0,0] and [4,6])
1117        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    // ── euclidean_distance ──────────────────────────────────────────────────
1130
1131    #[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    // ── compute_centroid ────────────────────────────────────────────────────
1144
1145    #[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    // ── distances_to_centroid ───────────────────────────────────────────────
1171
1172    #[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        // centroid = [3.0, 4.0]
1178        let dists = det.distances_to_centroid();
1179        assert_eq!(dists.len(), 2);
1180        // distance from [0,0] to [3,4] = 5.0
1181        assert!((dists[0].1 - 5.0).abs() < 1e-9);
1182        // distance from [6,8] to [3,4] = 5.0
1183        assert!((dists[1].1 - 5.0).abs() < 1e-9);
1184    }
1185
1186    // ── detect_all: ZScore ──────────────────────────────────────────────────
1187
1188    #[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        // At most 1-2 edge cases might be flagged, but definitely not all
1210        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    // ── detect_all: IQR ─────────────────────────────────────────────────────
1227
1228    #[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        // With no outlier, very few should be flagged
1258        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    // ── detect_all: DistanceBased ───────────────────────────────────────────
1278
1279    #[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    // ── detect_single ───────────────────────────────────────────────────────
1327
1328    #[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    // ── empty / single embedding edge cases ─────────────────────────────────
1363
1364    #[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    // ── stats ───────────────────────────────────────────────────────────────
1384
1385    #[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    // ── score ordering ──────────────────────────────────────────────────────
1408
1409    #[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    // ── detect_single with each method ──────────────────────────────────────
1456
1457    #[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    // ── custom config thresholds ────────────────────────────────────────────
1481
1482    #[test]
1483    fn test_custom_z_threshold() {
1484        let config = AnomalyConfig {
1485            method: SemanticAnomalyMethod::ZScore,
1486            z_threshold: 100.0, // Very high threshold
1487            ..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        // With threshold=100.0, even moderate outliers should not be flagged
1496        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, // Very tight
1505            ..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        // With very tight multiplier, more should be flagged
1513        let flagged = results.iter().filter(|r| r.is_anomaly).count();
1514        assert!(flagged > 0, "tight iqr_multiplier should flag some");
1515    }
1516}