1use std::collections::VecDeque;
11
12#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
54pub enum SadDetectionMethod {
55 CentroidDistance,
57 MahalanobisApprox,
59 LocalOutlierFactor,
61 IsolationForest,
63 EnsembleVote,
65}
66
67#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
71pub struct SadDetectorConfig {
72 pub threshold_sigma: f64,
74 pub method: SadDetectionMethod,
76 pub window_size: usize,
78 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#[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#[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#[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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
140pub struct SadDriftReport {
141 pub is_drift: bool,
142 pub centroid_shift: f64,
144 pub variance_change: f64,
146}
147
148#[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
160pub 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 clock: u64,
192}
193
194const HISTORY_LIMIT: usize = 1000;
195
196impl SemanticAnomalyDetector {
197 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 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 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 fn invalidate_cache(&mut self) {
247 self.centroid_cache = None;
248 self.covariance_diag = None;
249 }
250
251 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 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 if *v < 1e-12 {
304 *v = 1e-12;
305 }
306 }
307 self.covariance_diag = Some(var.clone());
308 Some(var)
309 }
310
311 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 self.total_scored += 1;
352 self.score_sum += raw_score;
353 if is_anomaly {
354 self.anomaly_count += 1;
355 }
356
357 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 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 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 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 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 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 pub fn lof_score(&self, q: &[f64], k: usize) -> f64 {
478 let k = k.min(self.corpus.len()).max(1);
479
480 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 let lrd_q = self.lrd(q, k, k_dist_q);
491
492 if lrd_q < 1e-12 {
493 return 1.0;
494 }
495
496 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 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 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 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 let sample_size = n.min(256);
565 let mut sample_indices: Vec<usize> = (0..n).collect();
566 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 let depth = isolation_depth_recursive(q, &sample, dim, max_depth, 0, &mut state);
578 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 2.0_f64.powf(-avg_norm)
586 }
587
588 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 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, ¢roid).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 let thr = self.config.threshold_sigma;
682 let votes: [bool; 4] = [
683 s_cen > thr,
684 s_mah > thr,
685 s_lof > thr,
686 s_iso > 0.6,
688 ];
689 let vote_count = votes.iter().filter(|&&v| v).count();
690 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 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
712fn 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
723fn 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 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 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
773pub type SadSemanticAnomalyDetector = SemanticAnomalyDetector;
777
778#[cfg(test)]
781mod tests {
782 use super::*;
783
784 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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 #[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 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 #[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 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 let score = det.lof_score(&[0.5, 0.5], 100);
1079 assert!(score.is_finite());
1080 }
1081
1082 #[test]
1085 fn test_isolation_outlier_higher_score() {
1086 let mut det = SemanticAnomalyDetector::with_defaults();
1087 let mut state = 12345u64;
1089 for i in 0..50u64 {
1090 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
1386 fn test_type_alias_usable() {
1387 let _det: SadSemanticAnomalyDetector = SemanticAnomalyDetector::with_defaults();
1388 }
1389
1390 #[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 #[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 #[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 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 let _ = report.is_drift;
1514 }
1515}