1use crate::compute::simd_dot_product;
5use rand::RngExt;
6
7use crate::multivariate::context::MultiSeriesContext;
8use crate::multivariate::error::MultivariateError;
9
10#[derive(Debug, Clone)]
12pub struct MultivariateAnomalyScore {
13 pub timestamp: i64,
15 pub score: f64,
17 pub is_anomaly: bool,
19 pub contributions: Vec<(String, f64)>,
21}
22
23pub trait MultivariateAnomalyDetector: Send + Sync {
25 fn fit(&mut self, ctx: &MultiSeriesContext) -> Result<(), MultivariateError>;
27 fn detect(
29 &self,
30 ctx: &MultiSeriesContext,
31 ) -> Result<Vec<MultivariateAnomalyScore>, MultivariateError>;
32}
33
34pub struct MahalanobisDetector {
40 threshold: f64,
41 epsilon: f64,
42 means: Vec<f64>,
43 inv_cov: Vec<f64>,
45 k: usize,
46 fitted: bool,
47}
48
49impl MahalanobisDetector {
50 pub fn new(threshold: Option<f64>, epsilon: Option<f64>) -> Self {
52 Self {
53 threshold: threshold.unwrap_or(3.0),
54 epsilon: epsilon.unwrap_or(1e-6),
55 means: Vec::new(),
56 inv_cov: Vec::new(),
57 k: 0,
58 fitted: false,
59 }
60 }
61
62 fn compute_covariance(data: &[Vec<f64>], means: &[f64]) -> Vec<f64> {
63 let k = data.len();
64 let n = data[0].len();
65 let divisor = if n > 1 { (n - 1) as f64 } else { 1.0 };
66
67 let centered: Vec<Vec<f64>> = data
69 .iter()
70 .zip(means.iter())
71 .map(|(col, &m)| col.iter().map(|&v| v - m).collect())
72 .collect();
73
74 let mut cov = vec![0.0; k * k];
75 for i in 0..k {
76 for j in i..k {
77 let s = simd_dot_product(¢ered[i], ¢ered[j])
81 .expect("centered vectors have equal length");
82 cov[i * k + j] = s / divisor;
83 cov[j * k + i] = cov[i * k + j];
84 }
85 }
86 cov
87 }
88
89 fn invert_matrix(cov: &[f64], k: usize) -> Result<Vec<f64>, MultivariateError> {
95 let mut lu = cov.to_vec();
97 let mut piv: Vec<usize> = (0..k).collect();
98
99 for col in 0..k {
100 let mut max_val = lu[piv[col] * k + col].abs();
102 let mut max_row = col;
103 for row in (col + 1)..k {
104 let v = lu[piv[row] * k + col].abs();
105 if v > max_val {
106 max_val = v;
107 max_row = row;
108 }
109 }
110 if max_val < 1e-15 {
111 return Err(MultivariateError::Compute(
112 "covariance matrix is singular".into(),
113 ));
114 }
115 piv.swap(col, max_row);
116
117 let pivot_row = piv[col];
118 for row in (col + 1)..k {
119 let target_row = piv[row];
120 let factor = lu[target_row * k + col] / lu[pivot_row * k + col];
121 lu[target_row * k + col] = factor; for j in (col + 1)..k {
123 lu[target_row * k + j] -= factor * lu[pivot_row * k + j];
124 }
125 }
126 }
127
128 let mut inv = vec![0.0; k * k];
130 for col in 0..k {
131 let mut y = vec![0.0; k];
133 for i in 0..k {
134 let pi = piv[i];
135 let b_val = if pi == col { 1.0 } else { 0.0 };
136 let mut sum = b_val;
137 for j in 0..i {
138 sum -= lu[pi * k + j] * y[j];
139 }
140 y[i] = sum;
142 }
143
144 for i in (0..k).rev() {
146 let pi = piv[i];
147 let mut sum = y[i];
148 for j in (i + 1)..k {
149 sum -= lu[pi * k + j] * inv[j * k + col];
150 }
151 inv[i * k + col] = sum / lu[pi * k + i];
152 }
153 }
154 Ok(inv)
155 }
156
157 fn mahalanobis_distance(&self, point: &[f64]) -> f64 {
158 let k = self.k;
159 let diff: Vec<f64> = point
161 .iter()
162 .zip(self.means.iter())
163 .map(|(a, b)| a - b)
164 .collect();
165 let mut d2 = 0.0;
166 for i in 0..k {
167 let mut row_sum = 0.0;
168 #[allow(clippy::needless_range_loop)]
169 for j in 0..k {
170 row_sum += self.inv_cov[i * k + j] * diff[j];
171 }
172 d2 += diff[i] * row_sum;
173 }
174 d2.max(0.0).sqrt()
175 }
176}
177
178impl MultivariateAnomalyDetector for MahalanobisDetector {
179 fn fit(&mut self, ctx: &MultiSeriesContext) -> Result<(), MultivariateError> {
180 let k = ctx.matrix.n_series();
181 let n = ctx.matrix.n_timestamps();
182 if n < k + 1 {
183 return Err(MultivariateError::InsufficientData { min: k + 1, got: n });
184 }
185
186 self.k = k;
187 self.means = ctx
188 .matrix
189 .data
190 .iter()
191 .map(|s| s.iter().sum::<f64>() / s.len() as f64)
192 .collect();
193
194 let mut cov = Self::compute_covariance(&ctx.matrix.data, &self.means);
195 for i in 0..k {
197 cov[i * k + i] += self.epsilon;
198 }
199 self.inv_cov = Self::invert_matrix(&cov, k)?;
200 self.fitted = true;
201 Ok(())
202 }
203
204 fn detect(
205 &self,
206 ctx: &MultiSeriesContext,
207 ) -> Result<Vec<MultivariateAnomalyScore>, MultivariateError> {
208 if !self.fitted {
209 return Err(MultivariateError::NotFitted);
210 }
211 let _start = std::time::Instant::now();
212 let k = ctx.matrix.n_series();
213 if k != self.k {
214 return Err(MultivariateError::InsufficientData {
215 min: self.k,
216 got: k,
217 });
218 }
219 let n = ctx.matrix.n_timestamps();
220 let mut scores = Vec::with_capacity(n);
221
222 for t in 0..n {
223 let point: Vec<f64> = (0..k).map(|s| ctx.matrix.data[s][t]).collect();
224 let dist = self.mahalanobis_distance(&point);
225 let is_anomaly = dist > self.threshold;
226
227 let contributions: Vec<(String, f64)> = (0..k)
229 .map(|s| {
230 let diff = (point[s] - self.means[s]).abs();
231 let contribution = if dist > 1e-15 { diff / dist } else { 0.0 };
232 (ctx.matrix.series_ids[s].clone(), contribution)
233 })
234 .collect();
235
236 scores.push(MultivariateAnomalyScore {
237 timestamp: ctx.matrix.timestamps[t],
238 score: dist,
239 is_anomaly,
240 contributions,
241 });
242 }
243 metrics::histogram!("chronix_multivariate_anomaly_detect_duration_seconds")
244 .record(_start.elapsed().as_secs_f64());
245 Ok(scores)
246 }
247}
248
249enum IsolationNode {
253 External {
254 size: usize,
255 },
256 Internal {
257 split_feature: usize,
258 split_value: f64,
259 left: Box<IsolationNode>,
260 right: Box<IsolationNode>,
261 },
262}
263
264pub struct IsolationForestDetector {
287 n_trees: usize,
288 subsample_size: usize,
289 actual_subsample: usize,
292 threshold: f64,
293 seed: u64,
294 trees: Vec<IsolationNode>,
295 k: usize,
296 fitted: bool,
297}
298
299impl IsolationForestDetector {
300 pub fn new(
302 n_trees: Option<usize>,
303 subsample_size: Option<usize>,
304 threshold: Option<f64>,
305 ) -> Self {
306 Self {
307 n_trees: n_trees.unwrap_or(100).max(1),
308 subsample_size: subsample_size.unwrap_or(256).max(1),
309 actual_subsample: 0,
310 threshold: threshold.unwrap_or(0.6),
311 seed: 42,
312 trees: Vec::new(),
313 k: 0,
314 fitted: false,
315 }
316 }
317
318 pub fn with_seed(mut self, seed: u64) -> Self {
320 self.seed = seed;
321 self
322 }
323
324 fn avg_path_length(n: usize) -> f64 {
326 if n <= 1 {
327 return 0.0;
328 }
329 let n = n as f64;
330 2.0 * (n.ln() + 0.5772156649) - 2.0 * (n - 1.0) / n
331 }
332
333 fn splitmix64(state: &mut u64) -> u64 {
335 *state = state.wrapping_add(0x9e3779b97f4a7c15);
336 let mut z = *state;
337 z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
338 z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb);
339 z ^ (z >> 31)
340 }
341
342 fn build_tree(
343 data: &[Vec<f64>],
344 indices: &[usize],
345 k: usize,
346 depth: usize,
347 max_depth: usize,
348 rng: &mut u64,
349 ) -> IsolationNode {
350 if indices.len() <= 1 || depth >= max_depth {
351 return IsolationNode::External {
352 size: indices.len(),
353 };
354 }
355
356 let feature = (Self::splitmix64(rng) as usize) % k;
358
359 let mut fmin = f64::INFINITY;
361 let mut fmax = f64::NEG_INFINITY;
362 for &idx in indices {
363 let v = data[feature][idx];
364 if v < fmin {
365 fmin = v;
366 }
367 if v > fmax {
368 fmax = v;
369 }
370 }
371
372 if (fmax - fmin).abs() < 1e-15 {
373 return IsolationNode::External {
374 size: indices.len(),
375 };
376 }
377
378 let r = (Self::splitmix64(rng) as f64) / (u64::MAX as f64);
380 let split_value = fmin + r * (fmax - fmin);
381
382 let mut left_idx = Vec::new();
383 let mut right_idx = Vec::new();
384 for &idx in indices {
385 if data[feature][idx] < split_value {
386 left_idx.push(idx);
387 } else {
388 right_idx.push(idx);
389 }
390 }
391
392 if left_idx.is_empty() || right_idx.is_empty() {
394 return IsolationNode::External {
395 size: indices.len(),
396 };
397 }
398
399 IsolationNode::Internal {
400 split_feature: feature,
401 split_value,
402 left: Box::new(Self::build_tree(
403 data,
404 &left_idx,
405 k,
406 depth + 1,
407 max_depth,
408 rng,
409 )),
410 right: Box::new(Self::build_tree(
411 data,
412 &right_idx,
413 k,
414 depth + 1,
415 max_depth,
416 rng,
417 )),
418 }
419 }
420
421 fn path_length(node: &IsolationNode, point: &[f64], depth: usize) -> f64 {
422 match node {
423 IsolationNode::External { size } => depth as f64 + Self::avg_path_length(*size),
424 IsolationNode::Internal {
425 split_feature,
426 split_value,
427 left,
428 right,
429 } => {
430 if point[*split_feature] < *split_value {
431 Self::path_length(left, point, depth + 1)
432 } else {
433 Self::path_length(right, point, depth + 1)
434 }
435 }
436 }
437 }
438
439 fn path_feature_importance(
446 node: &IsolationNode,
447 point: &[f64],
448 depth: usize,
449 importance: &mut [f64],
450 ) {
451 match node {
452 IsolationNode::External { .. } => {}
453 IsolationNode::Internal {
454 split_feature,
455 split_value,
456 left,
457 right,
458 } => {
459 importance[*split_feature] += 1.0 / (1 + depth) as f64;
461 if point[*split_feature] < *split_value {
462 Self::path_feature_importance(left, point, depth + 1, importance);
463 } else {
464 Self::path_feature_importance(right, point, depth + 1, importance);
465 }
466 }
467 }
468 }
469
470 fn anomaly_score_from_path(&self, avg_path: f64) -> f64 {
471 let c = Self::avg_path_length(self.actual_subsample);
472 if c <= 0.0 {
473 return 0.5;
474 }
475 2.0_f64.powf(-avg_path / c)
476 }
477}
478
479impl MultivariateAnomalyDetector for IsolationForestDetector {
480 fn fit(&mut self, ctx: &MultiSeriesContext) -> Result<(), MultivariateError> {
481 let k = ctx.matrix.n_series();
482 let n = ctx.matrix.n_timestamps();
483 if n < 2 {
484 return Err(MultivariateError::InsufficientData { min: 2, got: n });
485 }
486
487 self.k = k;
488 let sub = self.subsample_size.min(n);
489 self.actual_subsample = sub;
490 const ABSOLUTE_MAX_DEPTH: usize = 64;
492 let max_depth = ((sub as f64).log2().ceil() as usize).min(ABSOLUTE_MAX_DEPTH);
493
494 self.trees.clear();
495 self.trees.reserve(self.n_trees);
496 for tree_idx in 0..self.n_trees {
497 let mut rng: u64 = self
501 .seed
502 .wrapping_mul(6364136223846793005)
503 .wrapping_add(1442695040888963407)
504 .wrapping_add(tree_idx as u64)
505 .wrapping_mul(6364136223846793005);
506
507 let sample_size = sub.min(n);
510 let indices: Vec<usize> = if sample_size >= n / 2 {
511 let mut all: Vec<usize> = (0..n).collect();
513 for i in 0..sample_size {
514 let j = i + (Self::splitmix64(&mut rng) as usize) % (n - i);
515 all.swap(i, j);
516 }
517 all.truncate(sample_size);
518 all
519 } else {
520 let mut selected = std::collections::HashSet::with_capacity(sample_size);
522 while selected.len() < sample_size {
523 let idx = (Self::splitmix64(&mut rng) as usize) % n;
524 selected.insert(idx);
525 }
526 selected.into_iter().collect()
527 };
528
529 let tree = Self::build_tree(&ctx.matrix.data, &indices, k, 0, max_depth, &mut rng);
530 self.trees.push(tree);
531 }
532
533 self.fitted = true;
534 Ok(())
535 }
536
537 fn detect(
538 &self,
539 ctx: &MultiSeriesContext,
540 ) -> Result<Vec<MultivariateAnomalyScore>, MultivariateError> {
541 if !self.fitted {
542 return Err(MultivariateError::NotFitted);
543 }
544 let _start = std::time::Instant::now();
545 let k = ctx.matrix.n_series();
546 if k != self.k {
547 return Err(MultivariateError::InsufficientData {
548 min: self.k,
549 got: k,
550 });
551 }
552 let n = ctx.matrix.n_timestamps();
553 let mut scores = Vec::with_capacity(n);
554
555 for t in 0..n {
556 let point: Vec<f64> = (0..k).map(|s| ctx.matrix.data[s][t]).collect();
557
558 let avg_path: f64 = self
560 .trees
561 .iter()
562 .map(|tree| Self::path_length(tree, &point, 0))
563 .sum::<f64>()
564 / self.trees.len() as f64;
565
566 let score = self.anomaly_score_from_path(avg_path);
567 let is_anomaly = score > self.threshold;
568
569 let mut importance = vec![0.0; k];
573 for tree in &self.trees {
574 Self::path_feature_importance(tree, &point, 0, &mut importance);
575 }
576 let total: f64 = importance.iter().sum();
578 let contributions: Vec<(String, f64)> = (0..k)
579 .map(|s| {
580 let contrib = if total > 1e-15 {
581 importance[s] / total
582 } else {
583 1.0 / k as f64
584 };
585 (ctx.matrix.series_ids[s].clone(), contrib)
586 })
587 .collect();
588
589 scores.push(MultivariateAnomalyScore {
590 timestamp: ctx.matrix.timestamps[t],
591 score,
592 is_anomaly,
593 contributions,
594 });
595 }
596 metrics::histogram!("chronix_multivariate_anomaly_detect_duration_seconds")
597 .record(_start.elapsed().as_secs_f64());
598 Ok(scores)
599 }
600}
601
602pub struct PcaAnomalyDetector {
609 variance_threshold: f64,
610 anomaly_threshold: f64,
611 means: Vec<f64>,
612 components: Vec<Vec<f64>>,
614 k: usize,
615 fitted: bool,
616}
617
618impl PcaAnomalyDetector {
619 pub fn new(variance_threshold: Option<f64>, anomaly_threshold: Option<f64>) -> Self {
621 Self {
622 variance_threshold: variance_threshold.unwrap_or(0.95),
623 anomaly_threshold: anomaly_threshold.unwrap_or(3.0),
624 means: Vec::new(),
625 components: Vec::new(),
626 k: 0,
627 fitted: false,
628 }
629 }
630
631 fn randomized_eigen(
644 matrix: &[f64],
645 k: usize,
646 n_components: usize,
647 n_oversampling: usize,
648 ) -> (Vec<f64>, Vec<Vec<f64>>) {
649 let r = (n_components + n_oversampling).min(k);
650
651 let mut omega = vec![0.0; k * r];
656 let mut rng = rand::rng();
657 for v in omega.iter_mut() {
658 let u1: f64 = rng.random_range(1e-15_f64..1.0_f64); let u2: f64 = rng.random::<f64>();
661 *v = (-2.0_f64 * u1.ln()).sqrt() * (2.0_f64 * std::f64::consts::PI * u2).cos();
662 }
663
664 let mut y = vec![0.0; k * r];
666 for i in 0..k {
667 for j in 0..r {
668 let mut sum = 0.0;
669 for l in 0..k {
670 sum += matrix[i * k + l] * omega[l * r + j];
671 }
672 y[i * r + j] = sum;
673 }
674 }
675
676 let n_power_iterations = 2;
680 for _ in 0..n_power_iterations {
681 let mut tmp = vec![0.0; k * r];
682 for i in 0..k {
683 for j in 0..r {
684 let mut sum = 0.0;
685 for l in 0..k {
686 sum += matrix[i * k + l] * y[l * r + j];
687 }
688 tmp[i * r + j] = sum;
689 }
690 }
691 y = tmp;
692 }
693
694 let mut q = vec![0.0; k * r];
696 q.copy_from_slice(&y);
697
698 for j in 0..r {
699 let mut norm = 0.0;
701 for i in 0..k {
702 norm += q[i * r + j] * q[i * r + j];
703 }
704 norm = norm.sqrt();
705 if norm < 1e-14 {
706 for i in 0..k {
708 q[i * r + j] = 0.0;
709 }
710 continue;
711 }
712 for i in 0..k {
713 q[i * r + j] /= norm;
714 }
715
716 for j2 in (j + 1)..r {
718 let mut dot = 0.0;
719 for i in 0..k {
720 dot += q[i * r + j] * q[i * r + j2];
721 }
722 for i in 0..k {
723 q[i * r + j2] -= dot * q[i * r + j];
724 }
725 }
726 }
727
728 if q.iter().any(|v| v.is_nan()) {
732 tracing::warn!("PCA: NaN detected in randomized Q matrix — input may be degenerate");
733 return (vec![0.0; n_components], vec![vec![0.0; k]; n_components]);
735 }
736
737 let mut aq = vec![0.0; k * r];
739 for i in 0..k {
740 for j in 0..r {
741 let mut sum = 0.0;
742 for l in 0..k {
743 sum += matrix[i * k + l] * q[l * r + j];
744 }
745 aq[i * r + j] = sum;
746 }
747 }
748 let mut b = vec![0.0; r * r];
750 for i in 0..r {
751 for j in 0..r {
752 let mut sum = 0.0;
753 for l in 0..k {
754 sum += q[l * r + i] * aq[l * r + j];
755 }
756 b[i * r + j] = sum;
757 }
758 }
759
760 const MAX_JACOBI_ITERATIONS: usize = 1000;
767
768 let mut eigvecs = vec![0.0; r * r]; for i in 0..r {
770 eigvecs[i * r + i] = 1.0;
771 }
772
773 let mut final_max_offdiag = 0.0f64;
774 let mut jacobi_iters_used: usize = 0;
775 for _ in 0..MAX_JACOBI_ITERATIONS {
776 jacobi_iters_used += 1;
777 let mut max_val = 0.0f64;
779 let mut p_idx = 0;
780 let mut q_idx = 1;
781 for i in 0..r {
782 for j in (i + 1)..r {
783 if b[i * r + j].abs() > max_val {
784 max_val = b[i * r + j].abs();
785 p_idx = i;
786 q_idx = j;
787 }
788 }
789 }
790 if max_val < 1e-12 {
791 break;
792 }
793 final_max_offdiag = max_val;
794
795 let bpp = b[p_idx * r + p_idx];
797 let bqq = b[q_idx * r + q_idx];
798 let bpq = b[p_idx * r + q_idx];
799
800 let theta = if (bpp - bqq).abs() < 1e-15 {
801 std::f64::consts::FRAC_PI_4
802 } else {
803 0.5 * (2.0 * bpq / (bpp - bqq)).atan()
804 };
805 let c = theta.cos();
806 let s = theta.sin();
807
808 for j in 0..r {
811 let bp = b[p_idx * r + j];
812 let bq = b[q_idx * r + j];
813 b[p_idx * r + j] = c * bp + s * bq;
814 b[q_idx * r + j] = -s * bp + c * bq;
815 }
816 for i in 0..r {
818 let bp = b[i * r + p_idx];
819 let bq = b[i * r + q_idx];
820 b[i * r + p_idx] = c * bp + s * bq;
821 b[i * r + q_idx] = -s * bp + c * bq;
822 }
823
824 for i in 0..r {
826 let vp = eigvecs[i * r + p_idx];
827 let vq = eigvecs[i * r + q_idx];
828 eigvecs[i * r + p_idx] = c * vp + s * vq;
829 eigvecs[i * r + q_idx] = -s * vp + c * vq;
830 }
831 }
832
833 if final_max_offdiag >= 1e-12 {
835 tracing::warn!(
836 max_offdiag = final_max_offdiag,
837 iterations_used = jacobi_iters_used,
838 max_iterations = MAX_JACOBI_ITERATIONS,
839 matrix_size = r,
840 "PCA Jacobi eigenvalue did not converge within {MAX_JACOBI_ITERATIONS} iterations \
841 (remaining off-diag magnitude: {final_max_offdiag:.2e}) — results may be inaccurate"
842 );
843 }
844
845 let mut eigen_pairs: Vec<(f64, usize)> = (0..r).map(|i| (b[i * r + i], i)).collect();
847 eigen_pairs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
848
849 let n_out = n_components.min(eigen_pairs.len());
851 let mut eigenvalues = Vec::with_capacity(n_out);
852 let mut eigenvectors = Vec::with_capacity(n_out);
853
854 for &(eval, idx) in eigen_pairs.iter().take(n_out) {
855 if eval < 1e-12 {
856 break;
857 }
858 eigenvalues.push(eval);
859
860 let mut u = vec![0.0; k];
862 for i in 0..k {
863 let mut sum = 0.0;
864 for j in 0..r {
865 sum += q[i * r + j] * eigvecs[j * r + idx];
866 }
867 u[i] = sum;
868 }
869 let norm: f64 = u.iter().map(|x| x * x).sum::<f64>().sqrt();
871 if norm > 1e-15 {
872 for x in &mut u {
873 *x /= norm;
874 }
875 }
876 eigenvectors.push(u);
877 }
878
879 (eigenvalues, eigenvectors)
880 }
881
882 fn reconstruction_error(&self, point: &[f64]) -> f64 {
883 let centered: Vec<f64> = point
884 .iter()
885 .zip(self.means.iter())
886 .map(|(a, b)| a - b)
887 .collect();
888 let mut reconstructed = vec![0.0; self.k];
890 for comp in &self.components {
891 let proj: f64 = centered.iter().zip(comp.iter()).map(|(a, b)| a * b).sum();
892 for (i, &c) in comp.iter().enumerate() {
893 reconstructed[i] += proj * c;
894 }
895 }
896 centered
898 .iter()
899 .zip(reconstructed.iter())
900 .map(|(a, b)| (a - b).powi(2))
901 .sum::<f64>()
902 .sqrt()
903 }
904}
905
906impl MultivariateAnomalyDetector for PcaAnomalyDetector {
907 fn fit(&mut self, ctx: &MultiSeriesContext) -> Result<(), MultivariateError> {
908 let k = ctx.matrix.n_series();
909 let n = ctx.matrix.n_timestamps();
910 if n < k + 1 {
911 return Err(MultivariateError::InsufficientData { min: k + 1, got: n });
912 }
913
914 self.k = k;
915 self.means = ctx
916 .matrix
917 .data
918 .iter()
919 .map(|s| s.iter().sum::<f64>() / s.len() as f64)
920 .collect();
921
922 let cov = MahalanobisDetector::compute_covariance(&ctx.matrix.data, &self.means);
923 let total_var: f64 = (0..k).map(|i| cov[i * k + i]).sum();
924
925 let (eigenvalues, eigenvectors) = Self::randomized_eigen(&cov, k, k, 2);
928
929 self.components.clear();
930 let mut explained = 0.0;
931 for (i, &eval) in eigenvalues.iter().enumerate() {
932 explained += eval;
933 self.components.push(eigenvectors[i].clone());
934 if explained / total_var >= self.variance_threshold {
935 break;
936 }
937 }
938
939 self.fitted = true;
940 Ok(())
941 }
942
943 fn detect(
944 &self,
945 ctx: &MultiSeriesContext,
946 ) -> Result<Vec<MultivariateAnomalyScore>, MultivariateError> {
947 if !self.fitted {
948 return Err(MultivariateError::NotFitted);
949 }
950 let _start = std::time::Instant::now();
951 let k = ctx.matrix.n_series();
952 if k != self.k {
953 return Err(MultivariateError::InsufficientData {
954 min: self.k,
955 got: k,
956 });
957 }
958 let n = ctx.matrix.n_timestamps();
959
960 let mut errors = Vec::with_capacity(n);
962 let mut points = Vec::with_capacity(n);
963 for t in 0..n {
964 let point: Vec<f64> = (0..k).map(|s| ctx.matrix.data[s][t]).collect();
965 errors.push(self.reconstruction_error(&point));
966 points.push(point);
967 }
968
969 let mean_err = errors.iter().sum::<f64>() / n as f64;
970 let denom = if n > 1 { (n - 1) as f64 } else { n as f64 };
973 let std_err = (errors.iter().map(|&e| (e - mean_err).powi(2)).sum::<f64>() / denom).sqrt();
974
975 let mut scores = Vec::with_capacity(n);
976 for t in 0..n {
977 let z = if std_err > 1e-15 {
978 (errors[t] - mean_err) / std_err
979 } else {
980 0.0
981 };
982 let is_anomaly = z > self.anomaly_threshold;
983
984 let contributions: Vec<(String, f64)> = (0..k)
985 .map(|s| {
986 let diff = (points[t][s] - self.means[s]).abs();
987 (ctx.matrix.series_ids[s].clone(), diff)
988 })
989 .collect();
990
991 scores.push(MultivariateAnomalyScore {
992 timestamp: ctx.matrix.timestamps[t],
993 score: errors[t],
994 is_anomaly,
995 contributions,
996 });
997 }
998 metrics::histogram!("chronix_multivariate_anomaly_detect_duration_seconds")
999 .record(_start.elapsed().as_secs_f64());
1000 Ok(scores)
1001 }
1002}
1003
1004#[cfg(test)]
1005mod tests {
1006 use super::*;
1007
1008 fn normal_ctx(n: usize) -> MultiSeriesContext {
1009 let ts: Vec<i64> = (0..n as i64).map(|i| i * 1_000_000_000).collect();
1010 let s1: Vec<f64> = (0..n)
1011 .map(|i| (i as f64 * 0.1).sin() * 5.0 + 50.0)
1012 .collect();
1013 let s2: Vec<f64> = (0..n)
1014 .map(|i| (i as f64 * 0.1).cos() * 5.0 + 50.0)
1015 .collect();
1016 MultiSeriesContext::build(
1017 vec![
1018 ("s1".to_string(), ts.clone(), s1),
1019 ("s2".to_string(), ts, s2),
1020 ],
1021 None,
1022 )
1023 .unwrap()
1024 }
1025
1026 fn anomalous_ctx() -> MultiSeriesContext {
1027 let n = 100;
1028 let ts: Vec<i64> = (0..n as i64).map(|i| i * 1_000_000_000).collect();
1029 let mut s1: Vec<f64> = (0..n)
1030 .map(|i| (i as f64 * 0.1).sin() * 5.0 + 50.0)
1031 .collect();
1032 let mut s2: Vec<f64> = (0..n)
1033 .map(|i| (i as f64 * 0.1).cos() * 5.0 + 50.0)
1034 .collect();
1035 s1[50] = 70.0;
1037 s2[50] = 70.0;
1038 MultiSeriesContext::build(
1039 vec![
1040 ("s1".to_string(), ts.clone(), s1),
1041 ("s2".to_string(), ts, s2),
1042 ],
1043 None,
1044 )
1045 .unwrap()
1046 }
1047
1048 #[test]
1049 fn mahalanobis_fit_and_detect() {
1050 let ctx = normal_ctx(200);
1051 let mut det = MahalanobisDetector::new(None, None);
1052 det.fit(&ctx).unwrap();
1053 let scores = det.detect(&ctx).unwrap();
1054 assert_eq!(scores.len(), ctx.matrix.n_timestamps());
1055 let anomaly_count = scores.iter().filter(|s| s.is_anomaly).count();
1057 assert!(anomaly_count < scores.len() / 2);
1058 }
1059
1060 #[test]
1061 fn mahalanobis_detects_joint_anomaly() {
1062 let ctx = anomalous_ctx();
1063 let mut det = MahalanobisDetector::new(Some(2.0), None);
1064 det.fit(&ctx).unwrap();
1065 let scores = det.detect(&ctx).unwrap();
1066 let t50 = &scores[50];
1068 assert!(
1069 t50.score > 1.5,
1070 "score at anomaly should be high: {}",
1071 t50.score
1072 );
1073 }
1074
1075 #[test]
1076 fn mahalanobis_contributions() {
1077 let ctx = anomalous_ctx();
1078 let mut det = MahalanobisDetector::new(None, None);
1079 det.fit(&ctx).unwrap();
1080 let scores = det.detect(&ctx).unwrap();
1081 assert_eq!(scores[50].contributions.len(), 2);
1082 }
1083
1084 #[test]
1085 fn pca_fit_and_detect() {
1086 let ctx = normal_ctx(200);
1087 let mut det = PcaAnomalyDetector::new(None, None);
1088 det.fit(&ctx).unwrap();
1089 let scores = det.detect(&ctx).unwrap();
1090 assert_eq!(scores.len(), ctx.matrix.n_timestamps());
1091 }
1092
1093 #[test]
1094 fn pca_components_retained() {
1095 let ctx = normal_ctx(200);
1096 let mut det = PcaAnomalyDetector::new(Some(0.95), None);
1097 det.fit(&ctx).unwrap();
1098 assert!(!det.components.is_empty());
1100 assert!(det.components.len() <= 2);
1101 }
1102
1103 #[test]
1104 fn randomized_svd_recovers_known_eigenvalues() {
1105 let eigenvalues = [10.0, 5.0, 1.0, 0.1];
1109 let k = 4usize;
1110
1111 let q = [
1113 0.5, 0.5, 0.5, 0.5, 0.5, -0.5, 0.5, -0.5, 0.5, 0.5, -0.5, -0.5, 0.5, -0.5, -0.5, 0.5,
1114 ];
1115
1116 let mut a = vec![0.0; k * k];
1118 for i in 0..k {
1119 for j in 0..k {
1120 let mut sum = 0.0;
1121 for l in 0..k {
1122 sum += q[i * k + l] * eigenvalues[l] * q[j * k + l];
1123 }
1124 a[i * k + j] = sum;
1125 }
1126 }
1127
1128 let (evals, evecs) = PcaAnomalyDetector::randomized_eigen(&a, k, k, 2);
1129 assert_eq!(evals.len(), k);
1130
1131 for (i, &expected) in eigenvalues.iter().enumerate() {
1133 assert!(
1134 (evals[i] - expected).abs() < 0.5,
1135 "eigenvalue[{i}]: got {}, expected {expected}",
1136 evals[i]
1137 );
1138 }
1139
1140 for i in 0..evals.len() {
1142 let norm: f64 = evecs[i].iter().map(|x| x * x).sum::<f64>().sqrt();
1143 assert!(
1144 (norm - 1.0).abs() < 1e-10,
1145 "eigenvector[{i}] not unit: norm = {norm}"
1146 );
1147 for j in (i + 1)..evals.len() {
1148 let dot: f64 = evecs[i]
1149 .iter()
1150 .zip(evecs[j].iter())
1151 .map(|(a, b)| a * b)
1152 .sum();
1153 assert!(
1154 dot.abs() < 1e-8,
1155 "eigenvectors [{i}] and [{j}] not orthogonal: dot = {dot}"
1156 );
1157 }
1158 }
1159 }
1160
1161 #[test]
1164 fn isolation_forest_fit_and_detect() {
1165 let ctx = normal_ctx(200);
1166 let mut det = IsolationForestDetector::new(Some(50), Some(128), None);
1167 det.fit(&ctx).unwrap();
1168 let scores = det.detect(&ctx).unwrap();
1169 assert_eq!(scores.len(), 200);
1170 for s in &scores {
1172 assert!(
1173 s.score >= 0.0 && s.score <= 1.0,
1174 "score out of range: {}",
1175 s.score
1176 );
1177 }
1178 }
1179
1180 #[test]
1181 fn isolation_forest_detects_anomaly() {
1182 let ctx = anomalous_ctx();
1183 let mut det = IsolationForestDetector::new(Some(100), Some(256), Some(0.55));
1184 det.fit(&ctx).unwrap();
1185 let scores = det.detect(&ctx).unwrap();
1186 let median_score = {
1188 let mut sorted: Vec<f64> = scores.iter().map(|s| s.score).collect();
1189 sorted.sort_by(f64::total_cmp);
1190 sorted[sorted.len() / 2]
1191 };
1192 assert!(
1193 scores[50].score > median_score,
1194 "anomaly score {} should exceed median {}",
1195 scores[50].score,
1196 median_score
1197 );
1198 }
1199
1200 #[test]
1201 fn isolation_forest_contributions() {
1202 let ctx = anomalous_ctx();
1203 let mut det = IsolationForestDetector::new(Some(50), None, None);
1204 det.fit(&ctx).unwrap();
1205 let scores = det.detect(&ctx).unwrap();
1206 assert_eq!(scores[50].contributions.len(), 2);
1207 }
1208
1209 #[test]
1210 fn isolation_forest_insufficient_data() {
1211 let ts = vec![0i64];
1212 let s1 = vec![1.0];
1213 let ctx = MultiSeriesContext::build(vec![("s1".to_string(), ts, s1)], None).unwrap();
1214 let mut det = IsolationForestDetector::new(None, None, None);
1215 assert!(det.fit(&ctx).is_err());
1216 }
1217
1218 #[test]
1219 fn jacobi_converges_for_small_identity_matrix() {
1220 let k = 3;
1223 let mut identity = vec![0.0; k * k];
1224 for i in 0..k {
1225 identity[i * k + i] = 1.0;
1226 }
1227 let (evals, evecs) = PcaAnomalyDetector::randomized_eigen(&identity, k, k, 0);
1229 assert_eq!(evals.len(), k);
1230 for &ev in &evals {
1231 assert!(
1232 (ev - 1.0).abs() < 0.1,
1233 "eigenvalue should be ≈1.0, got {ev}"
1234 );
1235 }
1236 for i in 0..evals.len() {
1238 let norm: f64 = evecs[i].iter().map(|x| x * x).sum::<f64>().sqrt();
1239 assert!(
1240 (norm - 1.0).abs() < 1e-8,
1241 "eigenvector[{i}] not unit: norm = {norm}"
1242 );
1243 }
1244 }
1245}