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;
6
7use crate::multivariate::context::MultiSeriesContext;
8use crate::multivariate::error::MultivariateError;
9
10/// Score for a multivariate anomaly at a given timestamp.
11#[derive(Debug, Clone)]
12pub struct MultivariateAnomalyScore {
13    /// Timestamp (nanoseconds since epoch) of the observation.
14    pub timestamp: i64,
15    /// Anomaly score (higher = more anomalous).
16    pub score: f64,
17    /// Whether this point exceeds the anomaly threshold.
18    pub is_anomaly: bool,
19    /// Per-series contribution to the score (explainability).
20    pub contributions: Vec<(String, f64)>,
21}
22
23/// Multivariate anomaly detection contract.
24pub trait MultivariateAnomalyDetector: Send + Sync {
25    /// Fit the detector to the multi-series context.
26    fn fit(&mut self, ctx: &MultiSeriesContext) -> Result<(), MultivariateError>;
27    /// Detect anomalies and return per-timestamp scores.
28    fn detect(
29        &self,
30        ctx: &MultiSeriesContext,
31    ) -> Result<Vec<MultivariateAnomalyScore>, MultivariateError>;
32}
33
34// ─── Mahalanobis ────────────────────────────────────────────────────
35
36/// Multivariate anomaly detection via Mahalanobis distance.
37///
38/// Points with distance > `threshold` are flagged.
39pub struct MahalanobisDetector {
40    threshold: f64,
41    epsilon: f64,
42    means: Vec<f64>,
43    /// Inverse covariance matrix (k × k flattened row-major).
44    inv_cov: Vec<f64>,
45    k: usize,
46    fitted: bool,
47}
48
49impl MahalanobisDetector {
50    /// Creates a new detector with the given threshold and regularisation epsilon.
51    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        // Pre-compute mean-centred vectors for SIMD dot products.
68        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                // Use SIMD-accelerated dot product instead of
78                // scalar loop for the inner O(n) summation.
79                // Both slices have identical length `n`, so the call cannot fail.
80                let s = simd_dot_product(&centered[i], &centered[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    /// Invert the k×k covariance matrix using LU decomposition.
90    ///
91    /// Single LU factorisation O(k³) + k back-substitutions
92    /// O(k²) each = O(k³) total, replacing the previous O(k⁴) approach
93    /// that called batch_matrix_solve k separate times.
94    fn invert_matrix(cov: &[f64], k: usize) -> Result<Vec<f64>, MultivariateError> {
95        // LU decomposition with partial pivoting (in-place on a copy).
96        let mut lu = cov.to_vec();
97        let mut piv: Vec<usize> = (0..k).collect();
98
99        for col in 0..k {
100            // Find pivot.
101            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; // Store L factor in lower part.
122                for j in (col + 1)..k {
123                    lu[target_row * k + j] -= factor * lu[pivot_row * k + j];
124                }
125            }
126        }
127
128        // Solve for each column of the identity matrix.
129        let mut inv = vec![0.0; k * k];
130        for col in 0..k {
131            // Forward substitution (Ly = Pb).
132            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                // L diagonal is implicitly 1 — no division needed.
141                y[i] = sum;
142            }
143
144            // Back substitution (Ux = y).
145            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        // d² = (x-μ)ᵀ Σ⁻¹ (x-μ)
160        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        // Regularize
196        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            // Per-series contribution: |diff_i| / dist
228            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
249// ─── Isolation Forest ───────────────────────────────────────────────
250
251/// A single isolation tree node.
252enum 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
264/// Isolation Forest multivariate anomaly detector.
265///
266/// Recursively partitions data along random features and split values.
267/// Anomalous points are isolated in fewer splits (shorter path length).
268/// Score is normalized to [0, 1] where values close to 1 indicate anomalies.
269///
270/// # Deterministic Seeding
271///
272/// The forest uses a **fixed base seed** (42) combined with the tree
273/// index to derive per-tree PRNG streams. This makes results fully
274/// deterministic for a given dataset, which is valuable for
275/// reproducibility and testing.
276///
277/// The seed does **not** incorporate data shape (n_series, n_timestamps)
278/// because the subsample selection and split decisions are already
279/// conditioned on the actual data values. Adding shape to the seed
280/// would change results when the same logical dataset is padded or
281/// truncated, without improving detection quality.
282///
283/// If caller-controlled seeding is needed (e.g. for ensemble
284/// diversity), a `seed` parameter can be added to the constructor
285/// in a future release.
286pub struct IsolationForestDetector {
287    n_trees: usize,
288    subsample_size: usize,
289    /// Actual subsample size used during fit (may be smaller than `subsample_size`
290    /// when the dataset has fewer points).
291    actual_subsample: usize,
292    threshold: f64,
293    seed: u64,
294    trees: Vec<IsolationNode>,
295    k: usize,
296    fitted: bool,
297}
298
299impl IsolationForestDetector {
300    /// Creates a new Isolation Forest detector with optional parameters.
301    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    /// Sets the seed for deterministic PRNG. Default is 42.
319    pub fn with_seed(mut self, seed: u64) -> Self {
320        self.seed = seed;
321        self
322    }
323
324    /// Average path length of unsuccessful search in BST of n elements.
325    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    /// Simple deterministic pseudo-random from seed.
334    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        // Pick random feature
357        let feature = (Self::splitmix64(rng) as usize) % k;
358
359        // Find min/max along feature
360        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        // Random split value in [fmin, fmax)
379        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        // Guard against degenerate splits
393        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    /// Traverse the isolation tree and accumulate per-feature split
440    /// counts weighted by depth.  Splits closer to the root (lower
441    /// depth) are more influential in isolating the point, so each
442    /// split contributes `1 / (1 + depth)` to its feature.  This
443    /// mirrors the Mean Decrease in Impurity concept from Random
444    /// Forests adapted for Isolation Forest path structure.
445    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                // Depth-weighted contribution: early splits matter more
460                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        // Cap max_depth to prevent stack overflow on pathological inputs.
491        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            // Derive per-tree seed from a base seed mixed with the tree
498            // index so that each tree gets a unique PRNG stream while the
499            // overall forest remains deterministic for a given base seed.
500            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            // Subsample without allocating the full 0..n index vector.
508            // Use rejection sampling into a HashSet when sample_size << n.
509            let sample_size = sub.min(n);
510            let indices: Vec<usize> = if sample_size >= n / 2 {
511                // If sampling more than half, Fisher-Yates is more efficient.
512                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                // Rejection sampling — O(sample_size) expected time.
521                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            // Average path length across all trees
559            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            // Per-series contribution via depth-weighted split frequency
570            // across all trees.  Features split more often (especially
571            // near the root) are more responsible for isolating the point.
572            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            // Normalize to sum to 1.0 (relative contribution)
577            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
602// ─── PCA Reconstruction Error ───────────────────────────────────────
603
604/// PCA-based multivariate anomaly detection.
605///
606/// Fits principal components retaining `variance_threshold` of total
607/// variance, then flags points with high reconstruction error.
608pub struct PcaAnomalyDetector {
609    variance_threshold: f64,
610    anomaly_threshold: f64,
611    means: Vec<f64>,
612    /// Principal component vectors (n_components × k, row-major).
613    components: Vec<Vec<f64>>,
614    k: usize,
615    fitted: bool,
616}
617
618impl PcaAnomalyDetector {
619    /// Creates a new PCA anomaly detector with optional thresholds.
620    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    /// Extract top eigenvectors via Randomized SVD (Halko-Martinsson-Tropp).
632    ///
633    /// For a k×k symmetric positive semi-definite matrix A:
634    /// 1. Draw a k × (target+oversampling) Gaussian random matrix Ω
635    /// 2. Form Y = A · Ω  (project into random subspace)
636    /// 3. QR decomposition: Y = Q·R  (orthonormal basis for range)
637    /// 4. Form B = Qᵀ · A · Q  (small matrix)
638    /// 5. Eigen-decompose B = V·Λ·Vᵀ  (via Jacobi rotations)
639    /// 6. Recover eigenvectors: U = Q · V
640    ///
641    /// Runs in O(k²·r) where r = target rank, vs O(k²·r·iter) for
642    /// iterated power method.  More robust with near-degenerate gaps.
643    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        // Step 1: Generate pseudo-random Gaussian matrix Ω (k × r)
652        // Use rand crate (ChaCha12 by default) instead of
653        // correlated LCG. Box-Muller on cryptographic-quality uniform draws
654        // produces independent standard-normal samples.
655        let mut omega = vec![0.0; k * r];
656        let mut rng = rand::rng();
657        for v in omega.iter_mut() {
658            // Box-Muller: two uniform draws → one standard normal
659            let u1: f64 = rng.random_range(1e-15_f64..1.0_f64); // avoid log(0)
660            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        // Step 2: Y = A · Ω  (k × r)
665        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        // Power iterations to improve range approximation
677        // Y ← A · Y — helps with slowly decaying spectrum
678        // Halko et al. (2011) recommend 2-3 iterations
679        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        // Step 3: QR decomposition via modified Gram-Schmidt
695        let mut q = vec![0.0; k * r];
696        q.copy_from_slice(&y);
697
698        for j in 0..r {
699            // Normalize column j
700            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                // Degenerate column — zero it out
707                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            // Orthogonalize subsequent columns against column j
717            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        // Step 4: B = Qᵀ · A · Q  (r × r symmetric matrix)
729        // Validate Q columns for NaN before proceeding to avoid
730        // silent NaN propagation through the Jacobi eigensolver.
731        if q.iter().any(|v| v.is_nan()) {
732            tracing::warn!("PCA: NaN detected in randomized Q matrix — input may be degenerate");
733            // Return zero eigenvalues and identity-like eigenvectors for graceful degradation.
734            return (vec![0.0; n_components], vec![vec![0.0; k]; n_components]);
735        }
736
737        // First compute AQ = A · Q (k × r)
738        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        // Then B = Qᵀ · AQ (r × r)
749        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        // Step 5: Eigen-decompose B via Jacobi eigenvalue algorithm.
761        // B is symmetric, so Jacobi rotations converge to diagonal form.
762        // Convergence is typically quadratic for well-conditioned matrices;
763        // 1000 iterations suffices for matrices up to several hundred in
764        // dimension. If the loop exhausts all iterations without reaching
765        // the 1e-12 tolerance, a tracing warning is emitted.
766        const MAX_JACOBI_ITERATIONS: usize = 1000;
767
768        let mut eigvecs = vec![0.0; r * r]; // identity
769        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            // Find largest off-diagonal element
778            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            // Compute Jacobi rotation angle
796            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            // Apply rotation B ← Gᵀ · B · G
809            // Update rows p, q for all columns
810            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            // Update columns p, q for all rows
817            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            // Accumulate eigenvectors
825            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        // Warn if Jacobi iteration did not converge
834        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        // Extract eigenvalues (diagonal of B) and sort descending
846        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        // Step 6: Recover eigenvectors U = Q · V for top n_components
850        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            // u = Q · v_idx  (k-dimensional vector)
861            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            // Normalize
870            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        // Project onto components and reconstruct
889        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        // Reconstruction error = ||centered - reconstructed||
897        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        // Extract top components via Randomized SVD (Halko-Martinsson-Tropp).
926        // Request up to k components with 2 extra for oversampling.
927        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        // Compute all reconstruction errors for threshold calibration
961        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        // Use Bessel's correction (n-1) for consistency with simd_std_dev
971        // and features::zscore across the codebase.
972        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        // Inject a multivariate anomaly at t=50 (individually plausible, jointly abnormal)
1036        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        // Most should not be anomalous
1056        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        // The injected anomaly at t=50 should have a high score
1067        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        // With 2 series, should retain 1-2 components
1099        assert!(!det.components.is_empty());
1100        assert!(det.components.len() <= 2);
1101    }
1102
1103    #[test]
1104    fn randomized_svd_recovers_known_eigenvalues() {
1105        // Construct a 4×4 covariance matrix with known eigenvalues:
1106        // λ = [10, 5, 1, 0.1]
1107        // Use Q·Λ·Qᵀ where Q is an orthogonal rotation.
1108        let eigenvalues = [10.0, 5.0, 1.0, 0.1];
1109        let k = 4usize;
1110
1111        // Simple orthogonal matrix (Hadamard-like, normalized)
1112        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        // Construct A = Q · diag(λ) · Qᵀ
1117        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        // Eigenvalues should be close to the true ones (sorted descending)
1132        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        // Eigenvectors should be orthonormal
1141        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    // ── Isolation Forest ────────────────────────────────────────────
1162
1163    #[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        // All scores should be in [0, 1]
1171        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        // The injected anomaly at t=50 should have a higher score than median
1187        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        // A 3×3 identity matrix is already diagonal — Jacobi should
1221        // converge immediately without emitting a warning.
1222        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        // Run the randomized eigen path with n_components = k (no rank reduction)
1228        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        // Eigenvectors should be orthonormal
1237        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}