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