Skip to main content

ipfrs_semantic/
semantic_anomaly_detector.rs

1//! Semantic Anomaly Detector — production-grade anomaly detection for embedding corpora.
2//!
3//! Supports multiple detection strategies:
4//! - **CentroidDistance**: sigma-gated distance from the corpus centroid (fast, O(dim))
5//! - **MahalanobisApprox**: diagonal covariance-normalised Mahalanobis distance
6//! - **LocalOutlierFactor**: k-NN local density ratio (cosine distance)
7//! - **IsolationForest**: average isolation depth via xorshift64-driven random splits
8//! - **EnsembleVote**: majority vote across all four single-method detectors
9
10use std::collections::VecDeque;
11
12// ─── PRNG ────────────────────────────────────────────────────────────────────
13
14#[inline(always)]
15fn xorshift64(state: &mut u64) -> u64 {
16    let mut x = *state;
17    x ^= x << 13;
18    x ^= x >> 7;
19    x ^= x << 17;
20    *state = x;
21    x
22}
23
24// ─── Geometry helpers ────────────────────────────────────────────────────────
25
26#[inline]
27pub fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
28    if a.len() != b.len() || a.is_empty() {
29        return 0.0;
30    }
31    let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
32    let na: f64 = a.iter().map(|x| x * x).sum::<f64>().sqrt();
33    let nb: f64 = b.iter().map(|x| x * x).sum::<f64>().sqrt();
34    if na < 1e-12 || nb < 1e-12 {
35        return 0.0;
36    }
37    (dot / (na * nb)).clamp(-1.0, 1.0)
38}
39
40#[inline]
41fn cosine_distance(a: &[f64], b: &[f64]) -> f64 {
42    1.0 - cosine_similarity(a, b)
43}
44
45#[inline]
46fn euclidean_sq(a: &[f64], b: &[f64]) -> f64 {
47    a.iter().zip(b.iter()).map(|(x, y)| (x - y).powi(2)).sum()
48}
49
50// ─── Detection method ────────────────────────────────────────────────────────
51
52/// Detection algorithm used to score a candidate embedding.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
54pub enum SadDetectionMethod {
55    /// Distance from corpus centroid normalised by sigma.
56    CentroidDistance,
57    /// Diagonal Mahalanobis approximation (per-dimension variance).
58    MahalanobisApprox,
59    /// k-NN local outlier factor (cosine distance).
60    LocalOutlierFactor,
61    /// Average isolation depth via random recursive splits.
62    IsolationForest,
63    /// Majority vote from all four individual methods.
64    EnsembleVote,
65}
66
67// ─── Config ──────────────────────────────────────────────────────────────────
68
69/// Configuration for the [`SemanticAnomalyDetector`].
70#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
71pub struct SadDetectorConfig {
72    /// How many standard deviations above the mean is considered anomalous.
73    pub threshold_sigma: f64,
74    /// Primary detection algorithm.
75    pub method: SadDetectionMethod,
76    /// k for k-NN methods (LOF / isolation sub-sampling).
77    pub window_size: usize,
78    /// Minimum corpus size before scoring is attempted.
79    pub min_corpus_size: usize,
80}
81
82impl Default for SadDetectorConfig {
83    fn default() -> Self {
84        Self {
85            threshold_sigma: 3.0,
86            method: SadDetectionMethod::CentroidDistance,
87            window_size: 10,
88            min_corpus_size: 5,
89        }
90    }
91}
92
93// ─── Reference point ─────────────────────────────────────────────────────────
94
95/// A labelled reference embedding in the corpus.
96#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
97pub struct ReferencePoint {
98    pub id: u64,
99    pub embedding: Vec<f64>,
100    pub label: Option<String>,
101}
102
103impl ReferencePoint {
104    pub fn new(id: u64, embedding: Vec<f64>, label: Option<String>) -> Self {
105        Self {
106            id,
107            embedding,
108            label,
109        }
110    }
111}
112
113// ─── Anomaly record ──────────────────────────────────────────────────────────
114
115/// Persisted detection event.
116#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
117pub struct AnomalyRecord {
118    pub id: u64,
119    pub score: f64,
120    pub is_anomaly: bool,
121    pub method: SadDetectionMethod,
122    pub timestamp: u64,
123}
124
125// ─── Anomaly score ───────────────────────────────────────────────────────────
126
127/// Result returned from `score_embedding` / `score_batch`.
128#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
129pub struct SadAnomalyScore {
130    pub id: u64,
131    pub score: f64,
132    pub is_anomaly: bool,
133    pub explanation: String,
134}
135
136// ─── Drift report ────────────────────────────────────────────────────────────
137
138/// Summary returned by `detect_drift`.
139#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
140pub struct SadDriftReport {
141    pub is_drift: bool,
142    /// Euclidean distance between old and new centroids.
143    pub centroid_shift: f64,
144    /// Ratio of new variance to old variance (> 1 means expansion).
145    pub variance_change: f64,
146}
147
148// ─── Detector stats ──────────────────────────────────────────────────────────
149
150/// Aggregate statistics over the detector's lifetime.
151#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
152pub struct SadDetectorStats {
153    pub corpus_size: usize,
154    pub total_scored: u64,
155    pub anomaly_count: u64,
156    pub anomaly_rate: f64,
157    pub avg_score: f64,
158}
159
160// ─── Main struct ─────────────────────────────────────────────────────────────
161
162/// Production-quality semantic anomaly detector for embedding corpora.
163///
164/// # Example
165/// ```
166/// use ipfrs_semantic::semantic_anomaly_detector::{
167///     SemanticAnomalyDetector, SadDetectorConfig, SadDetectionMethod,
168/// };
169/// let cfg = SadDetectorConfig {
170///     threshold_sigma: 2.5,
171///     method: SadDetectionMethod::CentroidDistance,
172///     ..Default::default()
173/// };
174/// let mut det = SemanticAnomalyDetector::new(cfg);
175/// for i in 0..20u64 {
176///     det.add_reference(i, vec![0.5, 0.5, 0.5], None);
177/// }
178/// let score = det.score_embedding(99, vec![10.0, -10.0, 10.0]);
179/// assert!(score.is_anomaly);
180/// ```
181pub struct SemanticAnomalyDetector {
182    corpus: Vec<ReferencePoint>,
183    centroid_cache: Option<Vec<f64>>,
184    covariance_diag: Option<Vec<f64>>,
185    history: VecDeque<AnomalyRecord>,
186    config: SadDetectorConfig,
187    total_scored: u64,
188    score_sum: f64,
189    anomaly_count: u64,
190    /// Monotonic logical clock (incremented per score call).
191    clock: u64,
192}
193
194const HISTORY_LIMIT: usize = 1000;
195
196impl SemanticAnomalyDetector {
197    // ── Construction ──────────────────────────────────────────────────────
198
199    pub fn new(config: SadDetectorConfig) -> Self {
200        Self {
201            corpus: Vec::new(),
202            centroid_cache: None,
203            covariance_diag: None,
204            history: VecDeque::with_capacity(HISTORY_LIMIT),
205            config,
206            total_scored: 0,
207            score_sum: 0.0,
208            anomaly_count: 0,
209            clock: 0,
210        }
211    }
212
213    pub fn with_defaults() -> Self {
214        Self::new(SadDetectorConfig::default())
215    }
216
217    // ── Corpus management ─────────────────────────────────────────────────
218
219    pub fn add_reference(&mut self, id: u64, embedding: Vec<f64>, label: Option<String>) {
220        self.corpus.push(ReferencePoint::new(id, embedding, label));
221        self.invalidate_cache();
222    }
223
224    /// Removes the first reference with the given id. Returns `true` if removed.
225    pub fn remove_reference(&mut self, id: u64) -> bool {
226        if let Some(pos) = self.corpus.iter().position(|r| r.id == id) {
227            self.corpus.remove(pos);
228            self.invalidate_cache();
229            true
230        } else {
231            false
232        }
233    }
234
235    pub fn clear_corpus(&mut self) {
236        self.corpus.clear();
237        self.invalidate_cache();
238    }
239
240    pub fn corpus_len(&self) -> usize {
241        self.corpus.len()
242    }
243
244    // ── Cache management ──────────────────────────────────────────────────
245
246    fn invalidate_cache(&mut self) {
247        self.centroid_cache = None;
248        self.covariance_diag = None;
249    }
250
251    /// Returns the per-dimension mean of all reference embeddings (lazy).
252    pub fn compute_centroid(&mut self) -> Option<Vec<f64>> {
253        if self.corpus.is_empty() {
254            return None;
255        }
256        if let Some(ref c) = self.centroid_cache {
257            return Some(c.clone());
258        }
259        let dim = self.corpus[0].embedding.len();
260        if dim == 0 {
261            return None;
262        }
263        let n = self.corpus.len() as f64;
264        let mut centroid = vec![0.0f64; dim];
265        for rp in &self.corpus {
266            if rp.embedding.len() != dim {
267                continue;
268            }
269            for (c, v) in centroid.iter_mut().zip(rp.embedding.iter()) {
270                *c += v;
271            }
272        }
273        for c in centroid.iter_mut() {
274            *c /= n;
275        }
276        self.centroid_cache = Some(centroid.clone());
277        Some(centroid)
278    }
279
280    /// Returns per-dimension variance (diagonal of covariance matrix) (lazy).
281    pub fn compute_covariance_diag(&mut self) -> Option<Vec<f64>> {
282        if self.corpus.is_empty() {
283            return None;
284        }
285        if let Some(ref c) = self.covariance_diag {
286            return Some(c.clone());
287        }
288        let centroid = self.compute_centroid()?;
289        let dim = centroid.len();
290        let n = self.corpus.len() as f64;
291        let mut var = vec![0.0f64; dim];
292        for rp in &self.corpus {
293            if rp.embedding.len() != dim {
294                continue;
295            }
296            for (v, (&e, &c)) in var.iter_mut().zip(rp.embedding.iter().zip(centroid.iter())) {
297                *v += (e - c).powi(2);
298            }
299        }
300        for v in var.iter_mut() {
301            *v /= n.max(1.0);
302            // Guard: ensure minimum variance to avoid division by zero
303            if *v < 1e-12 {
304                *v = 1e-12;
305            }
306        }
307        self.covariance_diag = Some(var.clone());
308        Some(var)
309    }
310
311    // ── Scoring ───────────────────────────────────────────────────────────
312
313    /// Score a single embedding and return a rich `SadAnomalyScore`.
314    pub fn score_embedding(&mut self, id: u64, embedding: Vec<f64>) -> SadAnomalyScore {
315        self.clock += 1;
316        let ts = self.clock;
317        let method = self.config.method;
318
319        if self.corpus.len() < self.config.min_corpus_size {
320            return SadAnomalyScore {
321                id,
322                score: 0.0,
323                is_anomaly: false,
324                explanation: format!(
325                    "corpus too small ({} < {}); skipping detection",
326                    self.corpus.len(),
327                    self.config.min_corpus_size
328                ),
329            };
330        }
331
332        let (raw_score, explanation) = match method {
333            SadDetectionMethod::CentroidDistance => self.score_centroid(&embedding),
334            SadDetectionMethod::MahalanobisApprox => self.score_mahalanobis(&embedding),
335            SadDetectionMethod::LocalOutlierFactor => {
336                let k = self.config.window_size.min(self.corpus.len());
337                let s = self.lof_score(&embedding, k);
338                (s, format!("LOF score={:.4} (k={})", s, k))
339            }
340            SadDetectionMethod::IsolationForest => {
341                let s = self.isolation_score(&embedding, 100, 42 ^ ts);
342                (s, format!("IsolationForest avg_depth={:.4}", s))
343            }
344            SadDetectionMethod::EnsembleVote => self.score_ensemble(&embedding, ts),
345        };
346
347        let threshold = self.dynamic_threshold(method);
348        let is_anomaly = raw_score > threshold;
349
350        // Update running statistics
351        self.total_scored += 1;
352        self.score_sum += raw_score;
353        if is_anomaly {
354            self.anomaly_count += 1;
355        }
356
357        // Persist to history
358        let record = AnomalyRecord {
359            id,
360            score: raw_score,
361            is_anomaly,
362            method,
363            timestamp: ts,
364        };
365        if self.history.len() >= HISTORY_LIMIT {
366            self.history.pop_front();
367        }
368        self.history.push_back(record);
369
370        SadAnomalyScore {
371            id,
372            score: raw_score,
373            is_anomaly,
374            explanation,
375        }
376    }
377
378    /// Score a batch of (id, embedding) pairs in sequence.
379    pub fn score_batch(&mut self, items: &[(u64, Vec<f64>)]) -> Vec<SadAnomalyScore> {
380        items
381            .iter()
382            .map(|(id, emb)| self.score_embedding(*id, emb.clone()))
383            .collect()
384    }
385
386    // ── Drift detection ───────────────────────────────────────────────────
387
388    /// Compare `new_embeddings` against the current corpus to detect distribution drift.
389    pub fn detect_drift(&mut self, new_embeddings: &[Vec<f64>]) -> SadDriftReport {
390        let old_centroid = match self.compute_centroid() {
391            Some(c) => c,
392            None => {
393                return SadDriftReport {
394                    is_drift: false,
395                    centroid_shift: 0.0,
396                    variance_change: 1.0,
397                }
398            }
399        };
400        let old_var = self
401            .compute_covariance_diag()
402            .unwrap_or_else(|| vec![1.0; old_centroid.len()]);
403
404        if new_embeddings.is_empty() {
405            return SadDriftReport {
406                is_drift: false,
407                centroid_shift: 0.0,
408                variance_change: 1.0,
409            };
410        }
411
412        let dim = old_centroid.len();
413        let n = new_embeddings.len() as f64;
414        let mut new_centroid = vec![0.0f64; dim];
415        for emb in new_embeddings {
416            if emb.len() != dim {
417                continue;
418            }
419            for (c, v) in new_centroid.iter_mut().zip(emb.iter()) {
420                *c += v;
421            }
422        }
423        for c in new_centroid.iter_mut() {
424            *c /= n;
425        }
426
427        let mut new_var = vec![0.0f64; dim];
428        for emb in new_embeddings {
429            if emb.len() != dim {
430                continue;
431            }
432            for (v, (&e, &c)) in new_var.iter_mut().zip(emb.iter().zip(new_centroid.iter())) {
433                *v += (e - c).powi(2);
434            }
435        }
436        for v in new_var.iter_mut() {
437            *v /= n;
438        }
439
440        let centroid_shift = euclidean_sq(&old_centroid, &new_centroid).sqrt();
441
442        // Clamp per-dimension variance to a minimum to avoid numerical instability
443        for v in new_var.iter_mut() {
444            if *v < 1e-12 {
445                *v = 1e-12;
446            }
447        }
448
449        let old_total_var: f64 = old_var.iter().sum::<f64>() / dim.max(1) as f64;
450        let new_total_var: f64 = new_var.iter().sum::<f64>() / dim.max(1) as f64;
451        let variance_change = if old_total_var < 1e-10 && new_total_var < 1e-10 {
452            // Both nearly zero variance — treat as no change
453            1.0
454        } else if old_total_var < 1e-12 {
455            1.0
456        } else {
457            new_total_var / old_total_var
458        };
459
460        // Heuristic: drift if centroid moved > 3*sigma or variance changed by > 50 %
461        let sigma = old_total_var.sqrt();
462        let is_drift =
463            centroid_shift > 3.0 * sigma.max(1e-6) || !(0.5..=2.0).contains(&variance_change);
464
465        SadDriftReport {
466            is_drift,
467            centroid_shift,
468            variance_change,
469        }
470    }
471
472    // ── LOF ───────────────────────────────────────────────────────────────
473
474    /// Compute a Local Outlier Factor-style score for `q` using cosine distance.
475    ///
476    /// Returns a ratio > 1 for outliers.  `k` is the neighbourhood size.
477    pub fn lof_score(&self, q: &[f64], k: usize) -> f64 {
478        let k = k.min(self.corpus.len()).max(1);
479
480        // k-NN distances for query point
481        let mut q_dists: Vec<f64> = self
482            .corpus
483            .iter()
484            .map(|rp| cosine_distance(q, &rp.embedding))
485            .collect();
486        q_dists.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
487        let k_dist_q = *q_dists.get(k - 1).unwrap_or(&0.0);
488
489        // Local reachability density of q
490        let lrd_q = self.lrd(q, k, k_dist_q);
491
492        if lrd_q < 1e-12 {
493            return 1.0;
494        }
495
496        // Average ratio of lrd of neighbours over lrd of q
497        let mut lrd_sum = 0.0f64;
498        let mut count = 0usize;
499        for rp in &self.corpus {
500            let d = cosine_distance(q, &rp.embedding);
501            if d <= k_dist_q + 1e-12 {
502                let mut nd: Vec<f64> = self
503                    .corpus
504                    .iter()
505                    .map(|r2| cosine_distance(&rp.embedding, &r2.embedding))
506                    .collect();
507                nd.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
508                let kd_n = *nd.get(k - 1).unwrap_or(&0.0);
509                let lrd_n = self.lrd(&rp.embedding, k, kd_n);
510                lrd_sum += lrd_n;
511                count += 1;
512            }
513        }
514        if count == 0 {
515            return 1.0;
516        }
517        (lrd_sum / count as f64) / lrd_q
518    }
519
520    /// Internal: local reachability density.
521    fn lrd(&self, q: &[f64], k: usize, k_dist: f64) -> f64 {
522        let mut reach_sum = 0.0f64;
523        let mut count = 0usize;
524        for rp in &self.corpus {
525            let d = cosine_distance(q, &rp.embedding);
526            if d <= k_dist + 1e-12 {
527                // reachability distance = max(k_dist of neighbour, d)
528                let mut nd: Vec<f64> = self
529                    .corpus
530                    .iter()
531                    .map(|r2| cosine_distance(&rp.embedding, &r2.embedding))
532                    .collect();
533                nd.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
534                let kd_n = *nd.get(k - 1).unwrap_or(&0.0);
535                reach_sum += kd_n.max(d);
536                count += 1;
537            }
538        }
539        if count == 0 || reach_sum < 1e-12 {
540            return 1.0;
541        }
542        count as f64 / reach_sum
543    }
544
545    // ── Isolation Forest ──────────────────────────────────────────────────
546
547    /// Average normalised isolation depth over `n_trees` random isolation trees.
548    ///
549    /// Lower score → easier to isolate → more anomalous.
550    /// The returned value is inverted to 1 - norm_depth so higher = more anomalous.
551    pub fn isolation_score(&self, q: &[f64], n_trees: usize, seed: u64) -> f64 {
552        if self.corpus.is_empty() || q.is_empty() {
553            return 0.0;
554        }
555        let n = self.corpus.len();
556        let dim = q.len();
557        let max_depth = ((n as f64).log2().ceil() as usize).max(1);
558        let mut state = if seed == 0 { 1 } else { seed };
559
560        let mut total_depth = 0.0f64;
561
562        for _ in 0..n_trees {
563            // Sub-sample up to 256 points (or all if smaller)
564            let sample_size = n.min(256);
565            let mut sample_indices: Vec<usize> = (0..n).collect();
566            // Fisher-Yates shuffle (first sample_size elements)
567            for i in 0..sample_size {
568                let j = i + (xorshift64(&mut state) as usize % (n - i));
569                sample_indices.swap(i, j);
570            }
571            let sample: Vec<&Vec<f64>> = sample_indices[..sample_size]
572                .iter()
573                .map(|&idx| &self.corpus[idx].embedding)
574                .collect();
575
576            // Compute isolation depth for q in this tree
577            let depth = isolation_depth_recursive(q, &sample, dim, max_depth, 0, &mut state);
578            // Normalise by expected depth of a random point in a tree of size sample_size
579            let expected = c_factor(sample_size);
580            total_depth += (depth as f64) / expected.max(1.0);
581        }
582
583        let avg_norm = total_depth / n_trees as f64;
584        // Convert to anomaly score: short path = anomaly → invert
585        2.0_f64.powf(-avg_norm)
586    }
587
588    // ── Stats ─────────────────────────────────────────────────────────────
589
590    pub fn anomaly_stats(&self) -> SadDetectorStats {
591        let avg_score = if self.total_scored > 0 {
592            self.score_sum / self.total_scored as f64
593        } else {
594            0.0
595        };
596        let anomaly_rate = if self.total_scored > 0 {
597            self.anomaly_count as f64 / self.total_scored as f64
598        } else {
599            0.0
600        };
601        SadDetectorStats {
602            corpus_size: self.corpus.len(),
603            total_scored: self.total_scored,
604            anomaly_count: self.anomaly_count,
605            anomaly_rate,
606            avg_score,
607        }
608    }
609
610    pub fn history(&self) -> &VecDeque<AnomalyRecord> {
611        &self.history
612    }
613
614    pub fn config(&self) -> &SadDetectorConfig {
615        &self.config
616    }
617
618    pub fn set_config(&mut self, config: SadDetectorConfig) {
619        self.config = config;
620    }
621
622    // ── Private scoring helpers ───────────────────────────────────────────
623
624    fn score_centroid(&mut self, embedding: &[f64]) -> (f64, String) {
625        let centroid = match self.compute_centroid() {
626            Some(c) => c,
627            None => return (0.0, "no centroid (empty corpus)".to_string()),
628        };
629        let var = self
630            .compute_covariance_diag()
631            .unwrap_or_else(|| vec![1.0; centroid.len()]);
632
633        let dist = euclidean_sq(embedding, &centroid).sqrt();
634        let sigma = var.iter().sum::<f64>().sqrt() / (centroid.len().max(1) as f64).sqrt();
635        let normalised = if sigma < 1e-12 { dist } else { dist / sigma };
636        (
637            normalised,
638            format!(
639                "CentroidDistance: dist={:.4} sigma={:.4} score={:.4} threshold={:.1}σ",
640                dist, sigma, normalised, self.config.threshold_sigma
641            ),
642        )
643    }
644
645    fn score_mahalanobis(&mut self, embedding: &[f64]) -> (f64, String) {
646        let centroid = match self.compute_centroid() {
647            Some(c) => c,
648            None => return (0.0, "no centroid (empty corpus)".to_string()),
649        };
650        let var = match self.compute_covariance_diag() {
651            Some(v) => v,
652            None => return (0.0, "no covariance (empty corpus)".to_string()),
653        };
654        if centroid.len() != embedding.len() {
655            return (0.0, "dimension mismatch".to_string());
656        }
657        let d_sq: f64 = embedding
658            .iter()
659            .zip(centroid.iter())
660            .zip(var.iter())
661            .map(|((e, c), v)| (e - c).powi(2) / v.max(1e-12))
662            .sum();
663        let score = d_sq.sqrt();
664        (
665            score,
666            format!(
667                "MahalanobisApprox: d²={:.4} d={:.4} threshold={:.1}σ",
668                d_sq, score, self.config.threshold_sigma
669            ),
670        )
671    }
672
673    fn score_ensemble(&mut self, embedding: &[f64], ts: u64) -> (f64, String) {
674        let (s_cen, _) = self.score_centroid(embedding);
675        let (s_mah, _) = self.score_mahalanobis(embedding);
676        let k = self.config.window_size.min(self.corpus.len().max(1));
677        let s_lof = self.lof_score(embedding, k);
678        let s_iso = self.isolation_score(embedding, 50, 17 ^ ts);
679
680        // Normalise each score to a 0-1 anomaly indicator using individual thresholds
681        let thr = self.config.threshold_sigma;
682        let votes: [bool; 4] = [
683            s_cen > thr,
684            s_mah > thr,
685            s_lof > thr,
686            // Isolation: score > 0.6 is anomalous (0.5 is expected for normal)
687            s_iso > 0.6,
688        ];
689        let vote_count = votes.iter().filter(|&&v| v).count();
690        // Ensemble score: proportion of detectors that flag anomaly
691        let ensemble_score = vote_count as f64 / 4.0;
692
693        (
694            ensemble_score,
695            format!(
696                "Ensemble: cen={:.3} mah={:.3} lof={:.3} iso={:.3} votes={}/4",
697                s_cen, s_mah, s_lof, s_iso, vote_count
698            ),
699        )
700    }
701
702    /// Method-specific threshold: ensemble uses 0.5, others use threshold_sigma.
703    fn dynamic_threshold(&self, method: SadDetectionMethod) -> f64 {
704        match method {
705            SadDetectionMethod::EnsembleVote => 0.5,
706            SadDetectionMethod::IsolationForest => 0.6,
707            _ => self.config.threshold_sigma,
708        }
709    }
710}
711
712// ─── Isolation Forest helpers ─────────────────────────────────────────────────
713
714/// Expected average path length for a BST of size n (iForest formula).
715fn c_factor(n: usize) -> f64 {
716    if n <= 1 {
717        return 1.0;
718    }
719    let n = n as f64;
720    2.0 * (n - 1.0).ln() + 0.5772156649 - 2.0 * (n - 1.0) / n
721}
722
723/// Recursively compute the isolation depth of `q` given a sub-sample.
724fn isolation_depth_recursive(
725    q: &[f64],
726    sample: &[&Vec<f64>],
727    dim: usize,
728    max_depth: usize,
729    depth: usize,
730    state: &mut u64,
731) -> usize {
732    if sample.len() <= 1 || depth >= max_depth {
733        return depth + c_factor(sample.len()) as usize;
734    }
735
736    // Pick a random split dimension and value
737    let split_dim = (xorshift64(state) as usize) % dim;
738    let min_v = sample
739        .iter()
740        .filter_map(|e| e.get(split_dim).copied())
741        .fold(f64::INFINITY, f64::min);
742    let max_v = sample
743        .iter()
744        .filter_map(|e| e.get(split_dim).copied())
745        .fold(f64::NEG_INFINITY, f64::max);
746
747    if (max_v - min_v).abs() < 1e-14 {
748        return depth + 1;
749    }
750
751    // Random split point in [min, max]
752    let frac = (xorshift64(state) as f64) / u64::MAX as f64;
753    let split_val = min_v + frac * (max_v - min_v);
754
755    let q_val = q.get(split_dim).copied().unwrap_or(0.0);
756    let next_sample: Vec<&Vec<f64>> = if q_val <= split_val {
757        sample
758            .iter()
759            .copied()
760            .filter(|e| e.get(split_dim).copied().unwrap_or(0.0) <= split_val)
761            .collect()
762    } else {
763        sample
764            .iter()
765            .copied()
766            .filter(|e| e.get(split_dim).copied().unwrap_or(0.0) > split_val)
767            .collect()
768    };
769
770    isolation_depth_recursive(q, &next_sample, dim, max_depth, depth + 1, state)
771}
772
773// ─── Type aliases ─────────────────────────────────────────────────────────────
774
775/// Convenience alias for users preferring the `Sad`-prefixed name.
776pub type SadSemanticAnomalyDetector = SemanticAnomalyDetector;
777
778// ─── Tests ────────────────────────────────────────────────────────────────────
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783
784    // ── Helpers ──────────────────────────────────────────────────────────
785
786    fn uniform_corpus(det: &mut SemanticAnomalyDetector, n: usize, dim: usize, val: f64) {
787        for i in 0..n as u64 {
788            det.add_reference(i, vec![val; dim], None);
789        }
790    }
791
792    fn make_detector(method: SadDetectionMethod) -> SemanticAnomalyDetector {
793        SemanticAnomalyDetector::new(SadDetectorConfig {
794            threshold_sigma: 3.0,
795            method,
796            window_size: 5,
797            min_corpus_size: 3,
798        })
799    }
800
801    // ── cosine_similarity ─────────────────────────────────────────────────
802
803    #[test]
804    fn test_cosine_identical() {
805        let v = vec![1.0, 2.0, 3.0];
806        let s = cosine_similarity(&v, &v);
807        assert!(
808            (s - 1.0).abs() < 1e-9,
809            "identical vectors should have cosine=1"
810        );
811    }
812
813    #[test]
814    fn test_cosine_orthogonal() {
815        let a = vec![1.0, 0.0];
816        let b = vec![0.0, 1.0];
817        let s = cosine_similarity(&a, &b);
818        assert!(s.abs() < 1e-9, "orthogonal vectors should have cosine=0");
819    }
820
821    #[test]
822    fn test_cosine_opposite() {
823        let a = vec![1.0, 0.0];
824        let b = vec![-1.0, 0.0];
825        let s = cosine_similarity(&a, &b);
826        assert!(
827            (s + 1.0).abs() < 1e-9,
828            "opposite vectors should have cosine=-1"
829        );
830    }
831
832    #[test]
833    fn test_cosine_zero_vector() {
834        let a = vec![0.0, 0.0];
835        let b = vec![1.0, 2.0];
836        let s = cosine_similarity(&a, &b);
837        assert_eq!(s, 0.0, "zero vector cosine should return 0");
838    }
839
840    #[test]
841    fn test_cosine_dim_mismatch() {
842        let a = vec![1.0, 2.0];
843        let b = vec![1.0, 2.0, 3.0];
844        assert_eq!(cosine_similarity(&a, &b), 0.0);
845    }
846
847    #[test]
848    fn test_cosine_symmetric() {
849        let a = vec![0.3, 0.7, -0.1];
850        let b = vec![0.5, 0.2, 0.9];
851        assert!((cosine_similarity(&a, &b) - cosine_similarity(&b, &a)).abs() < 1e-12);
852    }
853
854    // ── ReferencePoint ─────────────────────────────────────────────────────
855
856    #[test]
857    fn test_reference_point_new() {
858        let rp = ReferencePoint::new(42, vec![1.0, 2.0], Some("test".to_string()));
859        assert_eq!(rp.id, 42);
860        assert_eq!(rp.label, Some("test".to_string()));
861    }
862
863    #[test]
864    fn test_reference_point_unlabelled() {
865        let rp = ReferencePoint::new(1, vec![0.0], None);
866        assert!(rp.label.is_none());
867    }
868
869    // ── Corpus management ─────────────────────────────────────────────────
870
871    #[test]
872    fn test_add_reference() {
873        let mut det = SemanticAnomalyDetector::with_defaults();
874        det.add_reference(1, vec![1.0, 2.0], None);
875        assert_eq!(det.corpus_len(), 1);
876    }
877
878    #[test]
879    fn test_remove_reference_existing() {
880        let mut det = SemanticAnomalyDetector::with_defaults();
881        det.add_reference(10, vec![0.5], None);
882        let removed = det.remove_reference(10);
883        assert!(removed);
884        assert_eq!(det.corpus_len(), 0);
885    }
886
887    #[test]
888    fn test_remove_reference_missing() {
889        let mut det = SemanticAnomalyDetector::with_defaults();
890        let removed = det.remove_reference(99);
891        assert!(!removed);
892    }
893
894    #[test]
895    fn test_clear_corpus() {
896        let mut det = SemanticAnomalyDetector::with_defaults();
897        uniform_corpus(&mut det, 10, 3, 0.5);
898        det.clear_corpus();
899        assert_eq!(det.corpus_len(), 0);
900    }
901
902    // ── Centroid ──────────────────────────────────────────────────────────
903
904    #[test]
905    fn test_centroid_empty() {
906        let mut det = SemanticAnomalyDetector::with_defaults();
907        assert!(det.compute_centroid().is_none());
908    }
909
910    #[test]
911    fn test_centroid_single_point() {
912        let mut det = SemanticAnomalyDetector::with_defaults();
913        det.add_reference(0, vec![1.0, 2.0, 3.0], None);
914        let c = det
915            .compute_centroid()
916            .expect("test: compute_centroid failed");
917        assert_eq!(c, vec![1.0, 2.0, 3.0]);
918    }
919
920    #[test]
921    fn test_centroid_two_points() {
922        let mut det = SemanticAnomalyDetector::with_defaults();
923        det.add_reference(0, vec![0.0, 0.0], None);
924        det.add_reference(1, vec![2.0, 4.0], None);
925        let c = det
926            .compute_centroid()
927            .expect("test: compute_centroid should return Some for two-point corpus");
928        assert!((c[0] - 1.0).abs() < 1e-9);
929        assert!((c[1] - 2.0).abs() < 1e-9);
930    }
931
932    #[test]
933    fn test_centroid_cache_invalidated_on_add() {
934        let mut det = SemanticAnomalyDetector::with_defaults();
935        det.add_reference(0, vec![0.0], None);
936        let _ = det.compute_centroid();
937        det.add_reference(1, vec![2.0], None);
938        // After adding, cache should be cleared; new centroid should be 1.0
939        let c = det
940            .compute_centroid()
941            .expect("test: compute_centroid failed after add");
942        assert!((c[0] - 1.0).abs() < 1e-9);
943    }
944
945    #[test]
946    fn test_centroid_cache_invalidated_on_remove() {
947        let mut det = SemanticAnomalyDetector::with_defaults();
948        det.add_reference(0, vec![0.0], None);
949        det.add_reference(1, vec![4.0], None);
950        let _ = det.compute_centroid();
951        det.remove_reference(1);
952        let c = det
953            .compute_centroid()
954            .expect("test: compute_centroid should return Some after remove");
955        assert!((c[0] - 0.0).abs() < 1e-9);
956    }
957
958    // ── Covariance ────────────────────────────────────────────────────────
959
960    #[test]
961    fn test_covariance_empty() {
962        let mut det = SemanticAnomalyDetector::with_defaults();
963        assert!(det.compute_covariance_diag().is_none());
964    }
965
966    #[test]
967    fn test_covariance_uniform() {
968        let mut det = SemanticAnomalyDetector::with_defaults();
969        // All identical → variance = 0 → clamped to 1e-12
970        for i in 0..5u64 {
971            det.add_reference(i, vec![1.0, 1.0], None);
972        }
973        let var = det
974            .compute_covariance_diag()
975            .expect("test: compute_covariance_diag should return Some for uniform corpus");
976        assert!(var[0] <= 1e-10, "uniform variance should be near 0");
977    }
978
979    #[test]
980    fn test_covariance_spread() {
981        let mut det = SemanticAnomalyDetector::with_defaults();
982        det.add_reference(0, vec![0.0], None);
983        det.add_reference(1, vec![2.0], None);
984        let var = det
985            .compute_covariance_diag()
986            .expect("test: compute_covariance_diag should return Some for spread corpus");
987        assert!(var[0] > 0.0);
988    }
989
990    // ── CentroidDistance scoring ───────────────────────────────────────────
991
992    #[test]
993    fn test_score_centroid_inlier() {
994        let mut det = make_detector(SadDetectionMethod::CentroidDistance);
995        uniform_corpus(&mut det, 20, 3, 0.5);
996        let score = det.score_embedding(99, vec![0.5, 0.5, 0.5]);
997        assert!(!score.is_anomaly, "centroid point should not be anomaly");
998    }
999
1000    #[test]
1001    fn test_score_centroid_outlier() {
1002        let mut det = make_detector(SadDetectionMethod::CentroidDistance);
1003        uniform_corpus(&mut det, 20, 3, 0.0);
1004        let score = det.score_embedding(99, vec![100.0, 100.0, 100.0]);
1005        assert!(score.is_anomaly, "far-away point should be anomaly");
1006    }
1007
1008    #[test]
1009    fn test_score_centroid_explanation() {
1010        let mut det = make_detector(SadDetectionMethod::CentroidDistance);
1011        uniform_corpus(&mut det, 10, 2, 1.0);
1012        let s = det.score_embedding(1, vec![1.0, 1.0]);
1013        assert!(s.explanation.contains("CentroidDistance"));
1014    }
1015
1016    // ── MahalanobisApprox scoring ─────────────────────────────────────────
1017
1018    #[test]
1019    fn test_score_mahalanobis_inlier() {
1020        let mut det = make_detector(SadDetectionMethod::MahalanobisApprox);
1021        uniform_corpus(&mut det, 20, 3, 0.5);
1022        let score = det.score_embedding(1, vec![0.5, 0.5, 0.5]);
1023        assert!(!score.is_anomaly);
1024    }
1025
1026    #[test]
1027    fn test_score_mahalanobis_outlier() {
1028        let mut det = make_detector(SadDetectionMethod::MahalanobisApprox);
1029        // Spread corpus around 0
1030        for i in 0..20u64 {
1031            let v = if i % 2 == 0 { -0.1 } else { 0.1 };
1032            det.add_reference(i, vec![v, v], None);
1033        }
1034        let score = det.score_embedding(99, vec![100.0, 100.0]);
1035        assert!(score.is_anomaly);
1036    }
1037
1038    #[test]
1039    fn test_score_mahalanobis_explanation() {
1040        let mut det = make_detector(SadDetectionMethod::MahalanobisApprox);
1041        uniform_corpus(&mut det, 10, 2, 0.0);
1042        let s = det.score_embedding(1, vec![0.0, 0.0]);
1043        assert!(s.explanation.contains("Mahalanobis"));
1044    }
1045
1046    // ── LOF scoring ───────────────────────────────────────────────────────
1047
1048    #[test]
1049    fn test_lof_inlier() {
1050        let mut det = make_detector(SadDetectionMethod::LocalOutlierFactor);
1051        uniform_corpus(&mut det, 20, 2, 0.5);
1052        let s = det.score_embedding(99, vec![0.5, 0.5]);
1053        // Inlier in a tight cluster should not be anomaly
1054        assert!(!s.is_anomaly || s.score < 5.0, "inlier LOF should be low");
1055    }
1056
1057    #[test]
1058    fn test_lof_outlier() {
1059        let mut det = make_detector(SadDetectionMethod::LocalOutlierFactor);
1060        uniform_corpus(&mut det, 20, 2, 0.0);
1061        let s = det.score_embedding(99, vec![0.9999, 0.0001]);
1062        assert!(s.score >= 0.0);
1063    }
1064
1065    #[test]
1066    fn test_lof_score_direct() {
1067        let mut det = SemanticAnomalyDetector::with_defaults();
1068        uniform_corpus(&mut det, 10, 2, 0.5);
1069        let score = det.lof_score(&[0.5, 0.5], 3);
1070        assert!(score >= 0.0, "LOF score must be non-negative");
1071    }
1072
1073    #[test]
1074    fn test_lof_k_clamped_to_corpus_size() {
1075        let mut det = SemanticAnomalyDetector::with_defaults();
1076        uniform_corpus(&mut det, 3, 2, 0.5);
1077        // k > corpus size should not panic
1078        let score = det.lof_score(&[0.5, 0.5], 100);
1079        assert!(score.is_finite());
1080    }
1081
1082    // ── Isolation Forest scoring ──────────────────────────────────────────
1083
1084    #[test]
1085    fn test_isolation_outlier_higher_score() {
1086        let mut det = SemanticAnomalyDetector::with_defaults();
1087        // Use spread corpus so split dimensions have meaningful ranges
1088        let mut state = 12345u64;
1089        for i in 0..50u64 {
1090            // Small gaussian-ish spread around 0 using xorshift
1091            let v0 = ((xorshift64(&mut state) as f64) / u64::MAX as f64) * 0.2 - 0.1;
1092            let v1 = ((xorshift64(&mut state) as f64) / u64::MAX as f64) * 0.2 - 0.1;
1093            let v2 = ((xorshift64(&mut state) as f64) / u64::MAX as f64) * 0.2 - 0.1;
1094            det.add_reference(i, vec![v0, v1, v2], None);
1095        }
1096        let s_in = det.isolation_score(&[0.0, 0.0, 0.0], 200, 42);
1097        let s_out = det.isolation_score(&[100.0, 100.0, 100.0], 200, 42);
1098        // Outlier should have higher isolation score (shorter path → higher 2^(-depth))
1099        assert!(
1100            s_out > s_in,
1101            "outlier isolation score should exceed inlier: out={s_out:.6} in={s_in:.6}"
1102        );
1103    }
1104
1105    #[test]
1106    fn test_isolation_score_range() {
1107        let mut det = SemanticAnomalyDetector::with_defaults();
1108        uniform_corpus(&mut det, 30, 4, 0.3);
1109        let s = det.isolation_score(&[0.3, 0.3, 0.3, 0.3], 50, 7);
1110        assert!(
1111            (0.0..=1.0).contains(&s),
1112            "isolation score should be in [0,1]: {s}"
1113        );
1114    }
1115
1116    #[test]
1117    fn test_isolation_empty_corpus() {
1118        let det = SemanticAnomalyDetector::with_defaults();
1119        let s = det.isolation_score(&[1.0, 2.0], 10, 1);
1120        assert_eq!(s, 0.0);
1121    }
1122
1123    #[test]
1124    fn test_isolation_score_method() {
1125        let mut det = make_detector(SadDetectionMethod::IsolationForest);
1126        uniform_corpus(&mut det, 20, 2, 0.5);
1127        let result = det.score_embedding(99, vec![0.5, 0.5]);
1128        assert!(result.score.is_finite());
1129    }
1130
1131    // ── Ensemble scoring ──────────────────────────────────────────────────
1132
1133    #[test]
1134    fn test_ensemble_inlier() {
1135        let mut det = make_detector(SadDetectionMethod::EnsembleVote);
1136        uniform_corpus(&mut det, 20, 3, 0.5);
1137        let s = det.score_embedding(1, vec![0.5, 0.5, 0.5]);
1138        assert!(!s.is_anomaly, "ensemble should not flag inlier");
1139    }
1140
1141    #[test]
1142    fn test_ensemble_outlier() {
1143        let mut det = make_detector(SadDetectionMethod::EnsembleVote);
1144        uniform_corpus(&mut det, 30, 3, 0.0);
1145        let s = det.score_embedding(99, vec![1000.0, 1000.0, 1000.0]);
1146        assert!(s.is_anomaly, "ensemble should flag extreme outlier");
1147    }
1148
1149    #[test]
1150    fn test_ensemble_explanation_contains_votes() {
1151        let mut det = make_detector(SadDetectionMethod::EnsembleVote);
1152        uniform_corpus(&mut det, 10, 2, 0.5);
1153        let s = det.score_embedding(1, vec![0.5, 0.5]);
1154        assert!(s.explanation.contains("Ensemble"));
1155    }
1156
1157    // ── Batch scoring ─────────────────────────────────────────────────────
1158
1159    #[test]
1160    fn test_score_batch_returns_all() {
1161        let mut det = make_detector(SadDetectionMethod::CentroidDistance);
1162        uniform_corpus(&mut det, 10, 2, 0.5);
1163        let items: Vec<(u64, Vec<f64>)> = (0..5u64).map(|i| (i + 100, vec![0.5, 0.5])).collect();
1164        let results = det.score_batch(&items);
1165        assert_eq!(results.len(), 5);
1166    }
1167
1168    #[test]
1169    fn test_score_batch_ids_preserved() {
1170        let mut det = make_detector(SadDetectionMethod::CentroidDistance);
1171        uniform_corpus(&mut det, 10, 2, 0.5);
1172        let items = vec![(42u64, vec![0.5f64, 0.5]), (99, vec![100.0, 100.0])];
1173        let results = det.score_batch(&items);
1174        assert_eq!(results[0].id, 42);
1175        assert_eq!(results[1].id, 99);
1176    }
1177
1178    #[test]
1179    fn test_score_batch_anomaly_detected() {
1180        let mut det = make_detector(SadDetectionMethod::CentroidDistance);
1181        uniform_corpus(&mut det, 15, 2, 0.0);
1182        let items = vec![(1u64, vec![0.0, 0.0]), (2, vec![1000.0, 1000.0])];
1183        let results = det.score_batch(&items);
1184        assert!(!results[0].is_anomaly);
1185        assert!(results[1].is_anomaly);
1186    }
1187
1188    // ── Corpus too small ──────────────────────────────────────────────────
1189
1190    #[test]
1191    fn test_score_below_min_corpus() {
1192        let mut det = SemanticAnomalyDetector::new(SadDetectorConfig {
1193            min_corpus_size: 10,
1194            ..Default::default()
1195        });
1196        uniform_corpus(&mut det, 3, 2, 0.5);
1197        let s = det.score_embedding(1, vec![0.5, 0.5]);
1198        assert!(!s.is_anomaly);
1199        assert!(s.explanation.contains("corpus too small"));
1200    }
1201
1202    // ── Drift detection ───────────────────────────────────────────────────
1203
1204    #[test]
1205    fn test_detect_drift_no_drift() {
1206        let mut det = SemanticAnomalyDetector::with_defaults();
1207        uniform_corpus(&mut det, 20, 2, 0.5);
1208        let new_emb: Vec<Vec<f64>> = (0..10).map(|_| vec![0.5, 0.5]).collect();
1209        let report = det.detect_drift(&new_emb);
1210        assert!(!report.is_drift, "identical distribution should not drift");
1211    }
1212
1213    #[test]
1214    fn test_detect_drift_with_drift() {
1215        let mut det = SemanticAnomalyDetector::with_defaults();
1216        uniform_corpus(&mut det, 20, 2, 0.0);
1217        let new_emb: Vec<Vec<f64>> = (0..10).map(|_| vec![100.0, 100.0]).collect();
1218        let report = det.detect_drift(&new_emb);
1219        assert!(report.is_drift, "extreme shift should be detected as drift");
1220        assert!(report.centroid_shift > 100.0);
1221    }
1222
1223    #[test]
1224    fn test_detect_drift_empty_corpus() {
1225        let mut det = SemanticAnomalyDetector::with_defaults();
1226        let new_emb: Vec<Vec<f64>> = vec![vec![1.0, 2.0]];
1227        let report = det.detect_drift(&new_emb);
1228        assert!(!report.is_drift);
1229    }
1230
1231    #[test]
1232    fn test_detect_drift_empty_new() {
1233        let mut det = SemanticAnomalyDetector::with_defaults();
1234        uniform_corpus(&mut det, 10, 2, 0.5);
1235        let report = det.detect_drift(&[]);
1236        assert!(!report.is_drift);
1237    }
1238
1239    #[test]
1240    fn test_drift_variance_change_field() {
1241        let mut det = SemanticAnomalyDetector::with_defaults();
1242        uniform_corpus(&mut det, 10, 1, 0.0);
1243        let new_emb: Vec<Vec<f64>> = vec![vec![-5.0], vec![5.0]];
1244        let report = det.detect_drift(&new_emb);
1245        assert!(report.variance_change > 0.0);
1246    }
1247
1248    // ── Stats ─────────────────────────────────────────────────────────────
1249
1250    #[test]
1251    fn test_stats_initial() {
1252        let det = SemanticAnomalyDetector::with_defaults();
1253        let stats = det.anomaly_stats();
1254        assert_eq!(stats.total_scored, 0);
1255        assert_eq!(stats.anomaly_count, 0);
1256        assert_eq!(stats.anomaly_rate, 0.0);
1257    }
1258
1259    #[test]
1260    fn test_stats_after_scoring() {
1261        let mut det = make_detector(SadDetectionMethod::CentroidDistance);
1262        uniform_corpus(&mut det, 10, 2, 0.5);
1263        det.score_embedding(1, vec![0.5, 0.5]);
1264        det.score_embedding(2, vec![0.5, 0.5]);
1265        let stats = det.anomaly_stats();
1266        assert_eq!(stats.total_scored, 2);
1267        assert_eq!(stats.corpus_size, 10);
1268    }
1269
1270    #[test]
1271    fn test_stats_anomaly_count() {
1272        let mut det = make_detector(SadDetectionMethod::CentroidDistance);
1273        uniform_corpus(&mut det, 15, 2, 0.0);
1274        det.score_embedding(1, vec![0.0, 0.0]);
1275        det.score_embedding(2, vec![1000.0, 1000.0]);
1276        let stats = det.anomaly_stats();
1277        assert!(stats.anomaly_count >= 1);
1278        assert!(stats.anomaly_rate > 0.0 && stats.anomaly_rate <= 1.0);
1279    }
1280
1281    #[test]
1282    fn test_stats_avg_score() {
1283        let mut det = make_detector(SadDetectionMethod::CentroidDistance);
1284        uniform_corpus(&mut det, 10, 2, 0.5);
1285        det.score_embedding(1, vec![0.5, 0.5]);
1286        let stats = det.anomaly_stats();
1287        assert!(stats.avg_score >= 0.0);
1288    }
1289
1290    // ── History ───────────────────────────────────────────────────────────
1291
1292    #[test]
1293    fn test_history_bounded() {
1294        let mut det = make_detector(SadDetectionMethod::CentroidDistance);
1295        uniform_corpus(&mut det, 5, 2, 0.5);
1296        for i in 0..1200u64 {
1297            det.score_embedding(i, vec![0.5, 0.5]);
1298        }
1299        assert!(
1300            det.history().len() <= 1000,
1301            "history must be bounded at 1000"
1302        );
1303    }
1304
1305    #[test]
1306    fn test_history_records_method() {
1307        let mut det = make_detector(SadDetectionMethod::CentroidDistance);
1308        uniform_corpus(&mut det, 5, 2, 0.5);
1309        det.score_embedding(1, vec![0.5, 0.5]);
1310        let rec = det
1311            .history()
1312            .back()
1313            .expect("test: history should have at least one record");
1314        assert_eq!(rec.method, SadDetectionMethod::CentroidDistance);
1315    }
1316
1317    #[test]
1318    fn test_history_timestamp_monotonic() {
1319        let mut det = make_detector(SadDetectionMethod::CentroidDistance);
1320        uniform_corpus(&mut det, 5, 2, 0.5);
1321        det.score_embedding(1, vec![0.5, 0.5]);
1322        det.score_embedding(2, vec![0.5, 0.5]);
1323        let recs: Vec<&AnomalyRecord> = det.history().iter().collect();
1324        assert!(recs[1].timestamp > recs[0].timestamp);
1325    }
1326
1327    // ── Config / set_config ────────────────────────────────────────────────
1328
1329    #[test]
1330    fn test_set_config() {
1331        let mut det = SemanticAnomalyDetector::with_defaults();
1332        let new_cfg = SadDetectorConfig {
1333            threshold_sigma: 1.5,
1334            method: SadDetectionMethod::EnsembleVote,
1335            window_size: 20,
1336            min_corpus_size: 10,
1337        };
1338        det.set_config(new_cfg.clone());
1339        assert_eq!(det.config().threshold_sigma, 1.5);
1340        assert_eq!(det.config().method, SadDetectionMethod::EnsembleVote);
1341    }
1342
1343    // ── xorshift64 ────────────────────────────────────────────────────────
1344
1345    #[test]
1346    fn test_xorshift64_non_zero() {
1347        let mut s = 12345u64;
1348        let v = xorshift64(&mut s);
1349        assert_ne!(v, 0);
1350        assert_ne!(s, 12345);
1351    }
1352
1353    #[test]
1354    fn test_xorshift64_sequence() {
1355        let mut s = 1u64;
1356        let a = xorshift64(&mut s);
1357        let b = xorshift64(&mut s);
1358        assert_ne!(a, b, "consecutive xorshift64 outputs should differ");
1359    }
1360
1361    #[test]
1362    fn test_xorshift64_reproducible() {
1363        let mut s1 = 999u64;
1364        let mut s2 = 999u64;
1365        let v1 = xorshift64(&mut s1);
1366        let v2 = xorshift64(&mut s2);
1367        assert_eq!(v1, v2, "same seed must produce same output");
1368    }
1369
1370    // ── c_factor ──────────────────────────────────────────────────────────
1371
1372    #[test]
1373    fn test_c_factor_one() {
1374        assert_eq!(c_factor(1), 1.0);
1375    }
1376
1377    #[test]
1378    fn test_c_factor_large() {
1379        let c = c_factor(256);
1380        assert!(c > 1.0, "c_factor for n=256 should be > 1");
1381    }
1382
1383    // ── Type alias ────────────────────────────────────────────────────────
1384
1385    #[test]
1386    fn test_type_alias_usable() {
1387        let _det: SadSemanticAnomalyDetector = SemanticAnomalyDetector::with_defaults();
1388    }
1389
1390    // ── Default config ────────────────────────────────────────────────────
1391
1392    #[test]
1393    fn test_default_config() {
1394        let cfg = SadDetectorConfig::default();
1395        assert_eq!(cfg.threshold_sigma, 3.0);
1396        assert_eq!(cfg.method, SadDetectionMethod::CentroidDistance);
1397        assert_eq!(cfg.window_size, 10);
1398        assert_eq!(cfg.min_corpus_size, 5);
1399    }
1400
1401    // ── All detection methods compile & run ────────────────────────────────
1402
1403    #[test]
1404    fn test_all_methods_run() {
1405        let methods = [
1406            SadDetectionMethod::CentroidDistance,
1407            SadDetectionMethod::MahalanobisApprox,
1408            SadDetectionMethod::LocalOutlierFactor,
1409            SadDetectionMethod::IsolationForest,
1410            SadDetectionMethod::EnsembleVote,
1411        ];
1412        for method in methods {
1413            let mut det = make_detector(method);
1414            uniform_corpus(&mut det, 10, 3, 0.5);
1415            let s = det.score_embedding(99, vec![0.5, 0.5, 0.5]);
1416            assert!(
1417                s.score.is_finite(),
1418                "method {method:?} score must be finite"
1419            );
1420        }
1421    }
1422
1423    // ── Robustness / edge cases ────────────────────────────────────────────
1424
1425    #[test]
1426    fn test_single_point_corpus() {
1427        let mut det = make_detector(SadDetectionMethod::CentroidDistance);
1428        det.add_reference(0, vec![1.0, 1.0], None);
1429        // Should not panic; but corpus < min_corpus_size → not flagged
1430        let s = det.score_embedding(1, vec![1.0, 1.0]);
1431        assert!(!s.is_anomaly || s.score == 0.0);
1432    }
1433
1434    #[test]
1435    fn test_high_dimensional_embedding() {
1436        let dim = 768;
1437        let mut det = make_detector(SadDetectionMethod::CentroidDistance);
1438        for i in 0..20u64 {
1439            det.add_reference(i, vec![0.01 * i as f64; dim], None);
1440        }
1441        let s = det.score_embedding(99, vec![0.5; dim]);
1442        assert!(s.score.is_finite());
1443    }
1444
1445    #[test]
1446    fn test_negative_embeddings() {
1447        let mut det = make_detector(SadDetectionMethod::CentroidDistance);
1448        for i in 0..10u64 {
1449            det.add_reference(i, vec![-1.0, -1.0], None);
1450        }
1451        let s = det.score_embedding(99, vec![-1.0, -1.0]);
1452        assert!(s.score.is_finite());
1453        assert!(!s.is_anomaly);
1454    }
1455
1456    #[test]
1457    fn test_mixed_positive_negative() {
1458        let mut det = make_detector(SadDetectionMethod::MahalanobisApprox);
1459        for i in 0..10u64 {
1460            let sign: f64 = if i % 2 == 0 { 1.0 } else { -1.0 };
1461            det.add_reference(i, vec![sign, sign], None);
1462        }
1463        let s = det.score_embedding(99, vec![1.0, 1.0]);
1464        assert!(s.score.is_finite());
1465    }
1466
1467    #[test]
1468    fn test_score_does_not_modify_corpus() {
1469        let mut det = make_detector(SadDetectionMethod::CentroidDistance);
1470        uniform_corpus(&mut det, 10, 2, 0.5);
1471        let before = det.corpus_len();
1472        det.score_embedding(99, vec![0.5, 0.5]);
1473        assert_eq!(det.corpus_len(), before);
1474    }
1475
1476    #[test]
1477    fn test_serde_config_roundtrip() {
1478        let cfg = SadDetectorConfig {
1479            threshold_sigma: 2.5,
1480            method: SadDetectionMethod::EnsembleVote,
1481            window_size: 7,
1482            min_corpus_size: 8,
1483        };
1484        let json = serde_json::to_string(&cfg).expect("test: serialization failed");
1485        let cfg2: SadDetectorConfig =
1486            serde_json::from_str(&json).expect("test: deserialization failed");
1487        assert_eq!(cfg2.threshold_sigma, 2.5);
1488        assert_eq!(cfg2.method, SadDetectionMethod::EnsembleVote);
1489    }
1490
1491    #[test]
1492    fn test_serde_anomaly_score_roundtrip() {
1493        let score = SadAnomalyScore {
1494            id: 7,
1495            score: std::f64::consts::PI,
1496            is_anomaly: true,
1497            explanation: "test".to_string(),
1498        };
1499        let json = serde_json::to_string(&score).expect("test: serialization failed");
1500        let s2: SadAnomalyScore =
1501            serde_json::from_str(&json).expect("test: deserialization failed");
1502        assert_eq!(s2.id, 7);
1503        assert!((s2.score - std::f64::consts::PI).abs() < 1e-9);
1504        assert!(s2.is_anomaly);
1505    }
1506
1507    #[test]
1508    fn test_drift_report_no_panic_on_single_point() {
1509        let mut det = SemanticAnomalyDetector::with_defaults();
1510        det.add_reference(0, vec![1.0], None);
1511        let report = det.detect_drift(&[vec![999.0]]);
1512        // Should not panic; is_drift may be true or false
1513        let _ = report.is_drift;
1514    }
1515}