Skip to main content

chronix_analytics/multivariate/
mv_anomaly.rs

1//! Multivariate anomaly detection: Mahalanobis distance, Isolation Forest,
2//! and PCA reconstruction error.
3
4use crate::compute::simd_dot_product;
5use rand::{RngExt, SeedableRng};
6
7/// Seed for the randomized-SVD sketch in [`PcaAnomalyDetector`].
8///
9/// Fixed so that fitting the same data twice yields the same components. Any
10/// value works — the Halko–Martinsson–Tropp bound holds with high probability
11/// for any draw — but changing it moves every fitted model, so it does not
12/// change.
13const RANDOMIZED_EIGEN_SEED: u64 = 0x_C047_0817_5EED_0001;
14
15use crate::multivariate::context::MultiSeriesContext;
16use crate::multivariate::error::MultivariateError;
17
18/// Score for a multivariate anomaly at a given timestamp.
19#[derive(Debug, Clone)]
20pub struct MultivariateAnomalyScore {
21    /// Timestamp (nanoseconds since epoch) of the observation.
22    pub timestamp: i64,
23    /// Anomaly score (higher = more anomalous).
24    pub score: f64,
25    /// Whether this point exceeds the anomaly threshold.
26    pub is_anomaly: bool,
27    /// Per-series contribution to the score (explainability).
28    pub contributions: Vec<(String, f64)>,
29}
30
31/// Multivariate anomaly detection contract.
32pub trait MultivariateAnomalyDetector: Send + Sync {
33    /// Fit the detector to the multi-series context.
34    fn fit(&mut self, ctx: &MultiSeriesContext) -> Result<(), MultivariateError>;
35    /// Detect anomalies and return per-timestamp scores.
36    fn detect(
37        &self,
38        ctx: &MultiSeriesContext,
39    ) -> Result<Vec<MultivariateAnomalyScore>, MultivariateError>;
40}
41
42// ─── Mahalanobis ────────────────────────────────────────────────────
43
44/// Multivariate anomaly detection via Mahalanobis distance.
45///
46/// Points with distance > `threshold` are flagged.
47pub struct MahalanobisDetector {
48    threshold: f64,
49    epsilon: f64,
50    means: Vec<f64>,
51    /// Inverse covariance matrix (k × k flattened row-major).
52    inv_cov: Vec<f64>,
53    k: usize,
54    fitted: bool,
55}
56
57impl MahalanobisDetector {
58    /// Creates a new detector with the given threshold and regularisation epsilon.
59    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        // Pre-compute mean-centred vectors for SIMD dot products.
76        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                // Use SIMD-accelerated dot product instead of
86                // scalar loop for the inner O(n) summation.
87                // Both slices have identical length `n`, so the call cannot fail.
88                let s = simd_dot_product(&centered[i], &centered[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    /// Invert the k×k covariance matrix using LU decomposition.
98    ///
99    /// Single LU factorisation O(k³) + k back-substitutions
100    /// O(k²) each = O(k³) total, replacing the previous O(k⁴) approach
101    /// that called batch_matrix_solve k separate times.
102    fn invert_matrix(cov: &[f64], k: usize) -> Result<Vec<f64>, MultivariateError> {
103        // LU decomposition with partial pivoting (in-place on a copy).
104        let mut lu = cov.to_vec();
105        let mut piv: Vec<usize> = (0..k).collect();
106
107        for col in 0..k {
108            // Find pivot.
109            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; // Store L factor in lower part.
130                for j in (col + 1)..k {
131                    lu[target_row * k + j] -= factor * lu[pivot_row * k + j];
132                }
133            }
134        }
135
136        // Solve for each column of the identity matrix.
137        let mut inv = vec![0.0; k * k];
138        for col in 0..k {
139            // Forward substitution (Ly = Pb).
140            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                // L diagonal is implicitly 1 — no division needed.
149                y[i] = sum;
150            }
151
152            // Back substitution (Ux = y).
153            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        // d² = (x-μ)ᵀ Σ⁻¹ (x-μ)
168        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        // Regularize
204        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            // Per-series contribution: |diff_i| / dist
236            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
257// ─── Isolation Forest ───────────────────────────────────────────────
258
259/// A single isolation tree node.
260enum 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
272/// Isolation Forest multivariate anomaly detector.
273///
274/// Recursively partitions data along random features and split values.
275/// Anomalous points are isolated in fewer splits (shorter path length).
276/// Score is normalized to [0, 1] where values close to 1 indicate anomalies.
277///
278/// # Deterministic Seeding
279///
280/// The forest uses a **fixed base seed** (42) combined with the tree
281/// index to derive per-tree PRNG streams. This makes results fully
282/// deterministic for a given dataset, which is valuable for
283/// reproducibility and testing.
284///
285/// The seed does **not** incorporate data shape (n_series, n_timestamps)
286/// because the subsample selection and split decisions are already
287/// conditioned on the actual data values. Adding shape to the seed
288/// would change results when the same logical dataset is padded or
289/// truncated, without improving detection quality.
290///
291/// If caller-controlled seeding is needed (e.g. for ensemble
292/// diversity), a `seed` parameter can be added to the constructor
293/// in a future release.
294pub struct IsolationForestDetector {
295    n_trees: usize,
296    subsample_size: usize,
297    /// Actual subsample size used during fit (may be smaller than `subsample_size`
298    /// when the dataset has fewer points).
299    actual_subsample: usize,
300    threshold: f64,
301    seed: u64,
302    trees: Vec<IsolationNode>,
303    k: usize,
304    fitted: bool,
305}
306
307impl IsolationForestDetector {
308    /// Creates a new Isolation Forest detector with optional parameters.
309    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    /// Sets the seed for deterministic PRNG. Default is 42.
327    pub fn with_seed(mut self, seed: u64) -> Self {
328        self.seed = seed;
329        self
330    }
331
332    /// Average path length of unsuccessful search in BST of n elements.
333    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    /// Simple deterministic pseudo-random from seed.
342    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        // Pick random feature
365        let feature = (Self::splitmix64(rng) as usize) % k;
366
367        // Find min/max along feature
368        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        // Random split value in [fmin, fmax)
387        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        // Guard against degenerate splits
401        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    /// Traverse the isolation tree and accumulate per-feature split
448    /// counts weighted by depth.  Splits closer to the root (lower
449    /// depth) are more influential in isolating the point, so each
450    /// split contributes `1 / (1 + depth)` to its feature.  This
451    /// mirrors the Mean Decrease in Impurity concept from Random
452    /// Forests adapted for Isolation Forest path structure.
453    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                // Depth-weighted contribution: early splits matter more
468                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        // Cap max_depth to prevent stack overflow on pathological inputs.
499        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            // Derive per-tree seed from a base seed mixed with the tree
506            // index so that each tree gets a unique PRNG stream while the
507            // overall forest remains deterministic for a given base seed.
508            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            // Subsample without allocating the full 0..n index vector.
516            // Use rejection sampling into a HashSet when sample_size << n.
517            let sample_size = sub.min(n);
518            let indices: Vec<usize> = if sample_size >= n / 2 {
519                // If sampling more than half, Fisher-Yates is more efficient.
520                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                // Rejection sampling — O(sample_size) expected time.
529                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            // Average path length across all trees
567            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            // Per-series contribution via depth-weighted split frequency
578            // across all trees.  Features split more often (especially
579            // near the root) are more responsible for isolating the point.
580            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            // Normalize to sum to 1.0 (relative contribution)
585            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
610// ─── PCA Reconstruction Error ───────────────────────────────────────
611
612/// PCA-based multivariate anomaly detection.
613///
614/// Fits principal components retaining `variance_threshold` of total
615/// variance, then flags points with high reconstruction error.
616pub struct PcaAnomalyDetector {
617    variance_threshold: f64,
618    anomaly_threshold: f64,
619    means: Vec<f64>,
620    /// Principal component vectors (n_components × k, row-major).
621    components: Vec<Vec<f64>>,
622    k: usize,
623    fitted: bool,
624}
625
626impl PcaAnomalyDetector {
627    /// Creates a new PCA anomaly detector with optional thresholds.
628    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    /// Extract top eigenvectors via Randomized SVD (Halko-Martinsson-Tropp).
640    ///
641    /// For a k×k symmetric positive semi-definite matrix A:
642    /// 1. Draw a k × (target+oversampling) Gaussian random matrix Ω
643    /// 2. Form Y = A · Ω  (project into random subspace)
644    /// 3. QR decomposition: Y = Q·R  (orthonormal basis for range)
645    /// 4. Form B = Qᵀ · A · Q  (small matrix)
646    /// 5. Eigen-decompose B = V·Λ·Vᵀ  (via Jacobi rotations)
647    /// 6. Recover eigenvectors: U = Q · V
648    ///
649    /// Runs in O(k²·r) where r = target rank, vs O(k²·r·iter) for
650    /// iterated power method.  More robust with near-degenerate gaps.
651    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        // Step 1: Generate a pseudo-random Gaussian matrix Ω (k × r) by
660        // Box-Muller, from a **fixed seed**.
661        //
662        // The Halko–Martinsson–Tropp guarantee holds with high probability
663        // for any draw, so nothing is lost by fixing one — and reproducibility
664        // is a requirement rather than a nicety: an anomaly detector whose
665        // `fit` returns different components for the same input gives a
666        // different score for the same point on a re-run, so an operator
667        // cannot re-derive an alert. The thread RNG also made the recovery
668        // test fail on a rare draw, which reads as flakiness.
669        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            // Box-Muller: two uniform draws → one standard normal
673            let u1: f64 = rng.random_range(1e-15_f64..1.0_f64); // avoid log(0)
674            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        // Step 2: Y = A · Ω  (k × r)
679        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        // Power iterations to improve range approximation
691        // Y ← A · Y — helps with slowly decaying spectrum
692        // Halko et al. (2011) recommend 2-3 iterations
693        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        // Step 3: QR decomposition via modified Gram-Schmidt
709        let mut q = vec![0.0; k * r];
710        q.copy_from_slice(&y);
711
712        for j in 0..r {
713            // Normalize column j
714            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                // Degenerate column — zero it out
721                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            // Orthogonalize subsequent columns against column j
731            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        // Step 4: B = Qᵀ · A · Q  (r × r symmetric matrix)
743        // Validate Q columns for NaN before proceeding to avoid
744        // silent NaN propagation through the Jacobi eigensolver.
745        if q.iter().any(|v| v.is_nan()) {
746            tracing::warn!("PCA: NaN detected in randomized Q matrix — input may be degenerate");
747            // Return zero eigenvalues and identity-like eigenvectors for graceful degradation.
748            return (vec![0.0; n_components], vec![vec![0.0; k]; n_components]);
749        }
750
751        // First compute AQ = A · Q (k × r)
752        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        // Then B = Qᵀ · AQ (r × r)
763        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        // Step 5: Eigen-decompose B via Jacobi eigenvalue algorithm.
775        // B is symmetric, so Jacobi rotations converge to diagonal form.
776        // Convergence is typically quadratic for well-conditioned matrices;
777        // 1000 iterations suffices for matrices up to several hundred in
778        // dimension. If the loop exhausts all iterations without reaching
779        // the 1e-12 tolerance, a tracing warning is emitted.
780        const MAX_JACOBI_ITERATIONS: usize = 1000;
781
782        let mut eigvecs = vec![0.0; r * r]; // identity
783        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            // Find largest off-diagonal element
792            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            // Compute Jacobi rotation angle
810            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            // Apply rotation B ← Gᵀ · B · G
823            // Update rows p, q for all columns
824            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            // Update columns p, q for all rows
831            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            // Accumulate eigenvectors
839            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        // Warn if Jacobi iteration did not converge
848        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        // Extract eigenvalues (diagonal of B) and sort descending
860        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        // Step 6: Recover eigenvectors U = Q · V for top n_components
864        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            // u = Q · v_idx  (k-dimensional vector)
875            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            // Normalize
884            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        // Project onto components and reconstruct
903        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        // Reconstruction error = ||centered - reconstructed||
911        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        // Extract top components via Randomized SVD (Halko-Martinsson-Tropp).
940        // Request up to k components with 2 extra for oversampling.
941        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        // Compute all reconstruction errors for threshold calibration
975        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        // Use Bessel's correction (n-1) for consistency with simd_std_dev
985        // and features::zscore across the codebase.
986        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        // Inject a multivariate anomaly at t=50 (individually plausible, jointly abnormal)
1050        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        // Most should not be anomalous
1070        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        // The injected anomaly at t=50 should have a high score
1081        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        // With 2 series, should retain 1-2 components
1113        assert!(!det.components.is_empty());
1114        assert!(det.components.len() <= 2);
1115    }
1116
1117    #[test]
1118    fn randomized_svd_recovers_known_eigenvalues() {
1119        // Construct a 4×4 covariance matrix with known eigenvalues:
1120        // λ = [10, 5, 1, 0.1]
1121        // Use Q·Λ·Qᵀ where Q is an orthogonal rotation.
1122        let eigenvalues = [10.0, 5.0, 1.0, 0.1];
1123        let k = 4usize;
1124
1125        // Simple orthogonal matrix (Hadamard-like, normalized)
1126        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        // Construct A = Q · diag(λ) · Qᵀ
1131        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        // Same input, same answer. The sketch is seeded, so this is exact —
1146        // and it is the property an operator needs: a detector that scores
1147        // the same point differently on a re-run cannot have its alerts
1148        // re-derived. Unseeded, this test also failed on a rare draw, which
1149        // reads as flakiness rather than as non-determinism.
1150        let (again, _) = PcaAnomalyDetector::randomized_eigen(&a, k, k, 2);
1151        assert_eq!(evals, again, "randomized_eigen is deterministic");
1152
1153        // Eigenvalues should be close to the true ones (sorted descending)
1154        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        // Eigenvectors should be orthonormal
1163        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    // ── Isolation Forest ────────────────────────────────────────────
1184
1185    #[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        // All scores should be in [0, 1]
1193        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        // The injected anomaly at t=50 should have a higher score than median
1209        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        // A 3×3 identity matrix is already diagonal — Jacobi should
1243        // converge immediately without emitting a warning.
1244        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        // Run the randomized eigen path with n_components = k (no rank reduction)
1250        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        // Eigenvectors should be orthonormal
1259        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}