Skip to main content

fdars_core/
outliers.rs

1//! Outlier detection for functional data.
2//!
3//! This module provides methods for detecting outliers in functional data
4//! based on depth measures and likelihood ratio tests.
5
6use crate::depth::band::{modified_band_1d, modified_epigraph_index_1d};
7use crate::depth::{functional_boxplot, total_variation_depth_1d, DepthMethod};
8use crate::error::FdarError;
9use crate::helpers::{quantile_sorted, sort_nan_safe};
10use crate::iter_maybe_parallel;
11use crate::matrix::FdMatrix;
12use crate::streaming_depth::{SortedReferenceState, StreamingDepth, StreamingFraimanMuniz};
13use rand::prelude::*;
14use rand_distr::StandardNormal;
15#[cfg(feature = "parallel")]
16use rayon::iter::ParallelIterator;
17
18/// Compute trimmed mean and variance from data using depth-based trimming.
19///
20/// Returns (trimmed_mean, trimmed_var) each of length m.
21fn compute_trimmed_stats(data: &FdMatrix, depths: &[f64], n_keep: usize) -> (Vec<f64>, Vec<f64>) {
22    let m = data.ncols();
23
24    let mut depth_idx: Vec<(usize, f64)> =
25        depths.iter().enumerate().map(|(i, &d)| (i, d)).collect();
26    // O(n) partial sort instead of O(n log n) full sort — we only need the top n_keep elements
27    if n_keep < depth_idx.len() {
28        depth_idx.select_nth_unstable_by(n_keep - 1, |a, b| {
29            b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
30        });
31    }
32    let keep_idx: Vec<usize> = depth_idx[..n_keep].iter().map(|(i, _)| *i).collect();
33
34    let results: Vec<(f64, f64)> = iter_maybe_parallel!(0..m)
35        .map(|j| {
36            let mut mean_j = 0.0;
37            for &i in &keep_idx {
38                mean_j += data[(i, j)];
39            }
40            mean_j /= n_keep as f64;
41
42            let mut var_j = 0.0;
43            for &i in &keep_idx {
44                let diff = data[(i, j)] - mean_j;
45                var_j += diff * diff;
46            }
47            var_j /= n_keep as f64;
48            var_j = var_j.max(1e-10);
49
50            (mean_j, var_j)
51        })
52        .collect();
53
54    let trimmed_mean: Vec<f64> = results.iter().map(|&(m, _)| m).collect();
55    let trimmed_var: Vec<f64> = results.iter().map(|&(_, v)| v).collect();
56
57    (trimmed_mean, trimmed_var)
58}
59
60/// Compute normalized Mahalanobis-like distance for a single observation.
61fn normalized_distance(
62    data: &FdMatrix,
63    i: usize,
64    trimmed_mean: &[f64],
65    trimmed_var: &[f64],
66) -> f64 {
67    let m = data.ncols();
68    let mut dist = 0.0;
69    for j in 0..m {
70        let diff = data[(i, j)] - trimmed_mean[j];
71        dist += diff * diff / trimmed_var[j];
72    }
73    (dist / m as f64).sqrt()
74}
75
76/// Compute bootstrap threshold for LRT outlier detection.
77///
78/// # Arguments
79/// * `data` - Functional data matrix (n observations x m evaluation points)
80/// * `nb` - Number of bootstrap iterations
81/// * `smo` - Smoothing parameter for bootstrap
82/// * `trim` - Trimming proportion
83/// * `seed` - Random seed
84/// * `percentile` - Percentile for threshold (e.g., 0.99 for 99th percentile)
85///
86/// # Returns
87/// Threshold at specified percentile for outlier detection
88#[must_use = "expensive computation whose result should not be discarded"]
89pub fn outliers_threshold_lrt(
90    data: &FdMatrix,
91    nb: usize,
92    smo: f64,
93    trim: f64,
94    seed: u64,
95    percentile: f64,
96) -> f64 {
97    outliers_threshold_lrt_with_dist(data, nb, smo, trim, seed, percentile).0
98}
99
100/// Compute bootstrap threshold and full null distribution for LRT outlier detection.
101///
102/// Same as [`outliers_threshold_lrt`] but also returns the sorted bootstrap
103/// distribution of max-distances, enabling per-curve p-value computation:
104/// `p = (sum(boot_dist >= d) + 1) / (B + 1)`.
105///
106/// # Arguments
107/// * `data` - Functional data matrix (n observations x m evaluation points)
108/// * `nb` - Number of bootstrap iterations
109/// * `smo` - Smoothing parameter for bootstrap
110/// * `trim` - Trimming proportion
111/// * `seed` - Random seed
112/// * `percentile` - Percentile for threshold (e.g., 0.99 for 99th percentile)
113///
114/// # Returns
115/// `(threshold, sorted_distribution)` — threshold at specified percentile and the
116/// full sorted bootstrap null distribution of max-distances (length `nb`).
117#[must_use = "expensive computation whose result should not be discarded"]
118pub fn outliers_threshold_lrt_with_dist(
119    data: &FdMatrix,
120    nb: usize,
121    smo: f64,
122    trim: f64,
123    seed: u64,
124    percentile: f64,
125) -> (f64, Vec<f64>) {
126    let n = data.nrows();
127    let m = data.ncols();
128
129    if n < 3 || m == 0 {
130        return (0.0, vec![]);
131    }
132
133    let n_keep = ((1.0 - trim) * n as f64).ceil().max(1.0) as usize;
134    let n_keep = n_keep.min(n);
135
136    // Compute column standard deviations for smoothing
137    let col_vars: Vec<f64> = iter_maybe_parallel!(0..m)
138        .map(|j| {
139            let mut sum = 0.0;
140            let mut sum_sq = 0.0;
141            for i in 0..n {
142                let val = data[(i, j)];
143                sum += val;
144                sum_sq += val * val;
145            }
146            let mean = sum / n as f64;
147            // Bootstrap variance, population formula
148            let var = sum_sq / n as f64 - mean * mean;
149            var.max(0.0).sqrt()
150        })
151        .collect();
152
153    // Run bootstrap iterations in parallel
154    let max_dists: Vec<f64> = iter_maybe_parallel!(0..nb)
155        .map(|b| {
156            let mut rng = crate::helpers::seed_for_thread(seed, b);
157
158            // Resample with replacement and add smoothing noise
159            let indices: Vec<usize> = (0..n).map(|_| rng.gen_range(0..n)).collect();
160            // Pre-generate all noise values to preserve RNG sequence
161            let noise_vals: Vec<f64> = (0..n * m)
162                .map(|_| rng.sample::<f64, _>(StandardNormal))
163                .collect();
164            let mut boot_data = FdMatrix::zeros(n, m);
165            // Column-first iteration for cache-friendly writes in column-major layout
166            for j in 0..m {
167                let smo_var = smo * col_vars[j];
168                for (new_i, &old_i) in indices.iter().enumerate() {
169                    let noise = noise_vals[new_i * m + j] * smo_var;
170                    boot_data[(new_i, j)] = data[(old_i, j)] + noise;
171                }
172            }
173
174            // Compute trimmed stats from bootstrap sample
175            let state = SortedReferenceState::from_reference(&boot_data);
176            let streaming_fm = StreamingFraimanMuniz::new(state, true);
177            let depths = streaming_fm.depth_batch(&boot_data);
178            let (trimmed_mean, trimmed_var) = compute_trimmed_stats(&boot_data, &depths, n_keep);
179
180            // Find max normalized distance across all observations
181            (0..n)
182                .map(|i| normalized_distance(&boot_data, i, &trimmed_mean, &trimmed_var))
183                .fold(0.0_f64, f64::max)
184        })
185        .collect();
186
187    // Sort and extract threshold at specified percentile
188    let mut sorted_dists = max_dists;
189    crate::helpers::sort_nan_safe(&mut sorted_dists);
190    let idx =
191        crate::utility::f64_to_usize_clamped(nb as f64 * percentile).min(nb.saturating_sub(1));
192    let threshold = sorted_dists.get(idx).copied().unwrap_or(0.0);
193    (threshold, sorted_dists)
194}
195
196/// Detect outliers using LRT method.
197///
198/// # Arguments
199/// * `data` - Functional data matrix (n observations x m evaluation points)
200/// * `threshold` - Outlier threshold
201/// * `trim` - Trimming proportion
202///
203/// # Returns
204/// Vector of booleans indicating outliers
205#[must_use = "expensive computation whose result should not be discarded"]
206/// Detect outliers in functional data using the Likelihood Ratio Test.
207///
208/// Compares each observation's normalized distance against a threshold.
209/// Observations exceeding the threshold are flagged as outliers.
210///
211/// # Arguments
212/// * `data` - Functional data matrix (n x m)
213/// * `threshold` - Decision threshold (from [`outliers_threshold_lrt`])
214/// * `trim` - Trimming proportion for robust estimation
215///
216/// # Examples
217///
218/// ```
219/// use fdars_core::matrix::FdMatrix;
220/// use fdars_core::outliers::detect_outliers_lrt;
221///
222/// let data = FdMatrix::from_column_major(
223///     (0..50).map(|i| (i as f64 * 0.1).sin()).collect(),
224///     5, 10,
225/// ).unwrap();
226/// let outliers = detect_outliers_lrt(&data, 3.0, 0.1);
227/// assert_eq!(outliers.len(), 5);
228/// ```
229pub fn detect_outliers_lrt(data: &FdMatrix, threshold: f64, trim: f64) -> Vec<bool> {
230    let n = data.nrows();
231    let m = data.ncols();
232
233    if n < 3 || m == 0 {
234        return vec![false; n];
235    }
236
237    let n_keep = ((1.0 - trim) * n as f64).ceil().max(1.0) as usize;
238    let n_keep = n_keep.min(n);
239
240    let state = SortedReferenceState::from_reference(data);
241    let streaming_fm = StreamingFraimanMuniz::new(state, true);
242    let depths = streaming_fm.depth_batch(data);
243    let (trimmed_mean, trimmed_var) = compute_trimmed_stats(data, &depths, n_keep);
244
245    iter_maybe_parallel!(0..n)
246        .map(|i| normalized_distance(data, i, &trimmed_mean, &trimmed_var) > threshold)
247        .collect()
248}
249
250/// Result of the outliergram analysis.
251#[derive(Debug, Clone, PartialEq)]
252#[non_exhaustive]
253pub struct OutligramResult {
254    /// Modified Epigraph Index for each curve
255    pub mei: Vec<f64>,
256    /// Modified Band Depth for each curve
257    pub mbd: Vec<f64>,
258    /// Parabola coefficients: MBD = a0 + a1*MEI + a2*MEI²
259    pub a0: f64,
260    pub a1: f64,
261    pub a2: f64,
262    /// Outlier threshold (IQR-based)
263    pub threshold: f64,
264    /// Outlier flags (true = outlier)
265    pub outlier_flags: Vec<bool>,
266}
267
268/// Compute outliergram with parabolic outlier boundary.
269///
270/// Combines modified band depth and modified epigraph index to detect
271/// shape outliers. Fits a parabolic upper bound `MBD = a0 + a1*MEI + a2*MEI²`
272/// and flags curves whose residuals fall below an IQR-based threshold.
273///
274/// # Arguments
275/// * `data` - Functional data matrix (n x m)
276/// * `factor` - IQR multiplier for the outlier threshold (typically 1.5)
277///
278/// # Returns
279/// Outliergram result with depths, parabola coefficients, and outlier flags.
280pub fn outliergram(data: &FdMatrix, factor: f64) -> Result<OutligramResult, FdarError> {
281    let n = data.nrows();
282    if n < 3 {
283        return Err(FdarError::InvalidDimension {
284            parameter: "data",
285            expected: "at least 3 rows".to_string(),
286            actual: format!("{n} rows"),
287        });
288    }
289
290    let mei = modified_epigraph_index_1d(data, data);
291    let mbd = modified_band_1d(data, data);
292
293    // Fit parabola: MBD = a0 + a1*MEI + a2*MEI²
294    // Using normal equations: X = [1, mei, mei²], y = mbd
295    let mut xtx = [[0.0; 3]; 3];
296    let mut xty = [0.0; 3];
297    for i in 0..n {
298        let x = [1.0, mei[i], mei[i] * mei[i]];
299        for r in 0..3 {
300            for c in 0..3 {
301                xtx[r][c] += x[r] * x[c];
302            }
303            xty[r] += x[r] * mbd[i];
304        }
305    }
306
307    // Solve 3x3 system via Cramer's rule
308    let (a0, a1, a2) = solve_3x3(xtx, xty);
309
310    // Compute residuals below the parabola
311    let residuals: Vec<f64> = (0..n)
312        .map(|i| mbd[i] - (a0 + a1 * mei[i] + a2 * mei[i] * mei[i]))
313        .collect();
314
315    // IQR-based threshold on residuals
316    let mut sorted_resid = residuals.clone();
317    sorted_resid.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
318    let q1 = sorted_resid[n / 4];
319    let q3 = sorted_resid[3 * n / 4];
320    let iqr = q3 - q1;
321    let threshold = q1 - factor * iqr;
322
323    let outlier_flags: Vec<bool> = residuals.iter().map(|&r| r < threshold).collect();
324
325    Ok(OutligramResult {
326        mei,
327        mbd,
328        a0,
329        a1,
330        a2,
331        threshold,
332        outlier_flags,
333    })
334}
335
336/// Result of magnitude-shape outlyingness decomposition.
337#[derive(Debug, Clone, PartialEq)]
338#[non_exhaustive]
339pub struct MagnitudeShapeResult {
340    /// Magnitude outlyingness: 1 - MBD (how far from the center)
341    pub magnitude: Vec<f64>,
342    /// Shape outlyingness: L2 distance of normalized curve direction from mean direction
343    pub shape: Vec<f64>,
344}
345
346/// Decompose outlyingness into magnitude and shape components.
347///
348/// Magnitude measures how far a curve is from the center (1 - modified band depth).
349/// Shape measures how different the curve's direction is from the mean direction
350/// (L2 distance of normalized centered curves).
351///
352/// # Arguments
353/// * `data` - Functional data matrix (n x m)
354pub fn magnitude_shape_outlyingness(data: &FdMatrix) -> Result<MagnitudeShapeResult, FdarError> {
355    let (n, m) = data.shape();
356    if n < 2 || m == 0 {
357        return Err(FdarError::InvalidDimension {
358            parameter: "data",
359            expected: "at least 2 rows and 1 column".to_string(),
360            actual: format!("{n} rows, {m} columns"),
361        });
362    }
363
364    // Magnitude: 1 - modified band depth
365    let mbd = modified_band_1d(data, data);
366    let magnitude: Vec<f64> = mbd.iter().map(|&d| 1.0 - d).collect();
367
368    // Shape: compute centered curves, normalize directions, compare to mean direction
369    // Step 1: compute column means
370    let mut col_means = vec![0.0; m];
371    for j in 0..m {
372        for i in 0..n {
373            col_means[j] += data[(i, j)];
374        }
375        col_means[j] /= n as f64;
376    }
377
378    // Step 2: center and normalize each curve (direction)
379    let mut directions = vec![vec![0.0; m]; n];
380    for i in 0..n {
381        let mut norm_sq = 0.0;
382        for j in 0..m {
383            let c = data[(i, j)] - col_means[j];
384            directions[i][j] = c;
385            norm_sq += c * c;
386        }
387        let norm = norm_sq.sqrt().max(1e-15);
388        for j in 0..m {
389            directions[i][j] /= norm;
390        }
391    }
392
393    // Step 3: mean direction
394    let mut mean_dir = vec![0.0; m];
395    for i in 0..n {
396        for j in 0..m {
397            mean_dir[j] += directions[i][j];
398        }
399    }
400    let mut mean_norm_sq = 0.0;
401    for j in 0..m {
402        mean_dir[j] /= n as f64;
403        mean_norm_sq += mean_dir[j] * mean_dir[j];
404    }
405    let mean_norm = mean_norm_sq.sqrt().max(1e-15);
406    for j in 0..m {
407        mean_dir[j] /= mean_norm;
408    }
409
410    // Step 4: shape = L2 distance from mean direction
411    let shape: Vec<f64> = (0..n)
412        .map(|i| {
413            let dist_sq: f64 = (0..m)
414                .map(|j| {
415                    let d = directions[i][j] - mean_dir[j];
416                    d * d
417                })
418                .sum();
419            dist_sq.sqrt()
420        })
421        .collect();
422
423    Ok(MagnitudeShapeResult { magnitude, shape })
424}
425
426/// Solve a 3x3 linear system via Cramer's rule.
427fn solve_3x3(a: [[f64; 3]; 3], b: [f64; 3]) -> (f64, f64, f64) {
428    let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
429        - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
430        + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
431    if det.abs() < 1e-15 {
432        return (0.0, 0.0, 0.0);
433    }
434
435    let det_x = b[0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
436        - a[0][1] * (b[1] * a[2][2] - a[1][2] * b[2])
437        + a[0][2] * (b[1] * a[2][1] - a[1][1] * b[2]);
438
439    let det_y = a[0][0] * (b[1] * a[2][2] - a[1][2] * b[2])
440        - b[0] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
441        + a[0][2] * (a[1][0] * b[2] - b[1] * a[2][0]);
442
443    let det_z = a[0][0] * (a[1][1] * b[2] - b[1] * a[2][1])
444        - a[0][1] * (a[1][0] * b[2] - b[1] * a[2][0])
445        + b[0] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
446
447    (det_x / det, det_y / det, det_z / det)
448}
449
450/// Lower and upper IQR fences `(Q1 − factor·IQR, Q3 + factor·IQR)` for a value slice.
451///
452/// Shared cutoff helper for the outlier detectors. Uses linear-interpolation quantiles
453/// ([`quantile_sorted`]) rather than R's floor-index quartiles — the two agree for n > 20 and
454/// differ only marginally for small samples.
455fn iqr_fence(values: &[f64], factor: f64) -> (f64, f64) {
456    let mut sorted = values.to_vec();
457    sort_nan_safe(&mut sorted);
458    let q1 = quantile_sorted(&sorted, 0.25);
459    let q3 = quantile_sorted(&sorted, 0.75);
460    let iqr = q3 - q1;
461    (q1 - factor * iqr, q3 + factor * iqr)
462}
463
464/// Configuration for [`tvdmss`]. Defaults reproduce the `fdaoutlier` `tvdmss` defaults.
465///
466/// Construct via `TvdMssConfig::default()`, then assign the fields you need (e.g. `let mut c = TvdMssConfig::default(); c.field = …;`). This struct is `#[non_exhaustive]`, so external crates cannot build it with a struct literal — not even functional-update `..Default::default()` form.
467#[non_exhaustive]
468#[derive(Debug, Clone, Copy, PartialEq)]
469#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
470pub struct TvdMssConfig {
471    /// IQR multiplier for the shape (MSS lower-fence) stage. Default `1.5`.
472    pub emp_factor_mss: f64,
473    /// Inflation factor for the magnitude-stage functional boxplot. Default `1.5`.
474    pub emp_factor_tvd: f64,
475    /// Fraction of the original n used as the stage-2 central region. Default `0.5`.
476    /// Documented for parity with `fdaoutlier`; fdars' [`functional_boxplot`] fixes the
477    /// central region at the deepest 50%, so this field is currently informational.
478    pub central_region_tvd: f64,
479}
480
481impl Default for TvdMssConfig {
482    fn default() -> Self {
483        Self {
484            emp_factor_mss: 1.5,
485            emp_factor_tvd: 1.5,
486            central_region_tvd: 0.5,
487        }
488    }
489}
490
491/// Numeric output of the [`tvdmss`] two-stage detector (no rendering).
492#[derive(Debug, Clone, PartialEq)]
493#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
494#[non_exhaustive]
495pub struct TvdMssOutliers {
496    /// Row indices flagged as magnitude outliers (stage 2: functional boxplot on TVD).
497    pub magnitude_outliers: Vec<usize>,
498    /// Row indices flagged as shape outliers (stage 1: lower IQR fence on MSS).
499    pub shape_outliers: Vec<usize>,
500    /// Total variation depth per curve (over original indices).
501    pub tvd: Vec<f64>,
502    /// Modified shape similarity index per curve (over original indices).
503    pub mss: Vec<f64>,
504}
505
506/// TVD + MSSI two-stage outlier detector (Huang & Sun 2019 / `fdaoutlier::tvdmss`).
507///
508/// Stage 1 flags **shape outliers** as curves whose MSS falls below the lower IQR fence
509/// (`Q1 − emp_factor_mss·IQR`) and below the mean MSS. Stage 2 removes those curves and runs a
510/// López-Pintado–Romo depth-fence [`functional_boxplot`] on the remaining curves' values to flag
511/// **magnitude outliers**, re-mapping the reduced indices back to the original sample.
512///
513/// Consumes Phase 28's [`total_variation_depth_1d`] directly for the `tvd`/`mss` vectors — no
514/// reimplementation. **Divergence:** `fdaoutlier` scales the stage-2 central region by
515/// `n_orig / n_reduced`; fdars' `functional_boxplot` fixes the central region at the deepest 50%
516/// of the reduced set, so `central_region_tvd` is informational only.
517///
518/// # Errors
519/// Returns [`FdarError::InvalidDimension`] if the sample has fewer than 3 curves or zero columns.
520#[must_use = "outlier detection results should not be discarded"]
521pub fn tvdmss(data: &FdMatrix, config: TvdMssConfig) -> Result<TvdMssOutliers, FdarError> {
522    let (n, m) = data.shape();
523    if n < 3 || m == 0 {
524        return Err(FdarError::InvalidDimension {
525            parameter: "data",
526            expected: "at least 3 curves and 1 column".to_string(),
527            actual: format!("{n} rows, {m} columns"),
528        });
529    }
530
531    let depth = total_variation_depth_1d(data, data)?;
532
533    // Stage 1: shape outliers = low-MSS lower tail.
534    let (lower_mss, _) = iqr_fence(&depth.mss, config.emp_factor_mss);
535    let mean_mss = depth.mss.iter().sum::<f64>() / n as f64;
536    let shape_outliers: Vec<usize> = (0..n)
537        .filter(|&i| depth.mss[i] < lower_mss && depth.mss[i] < mean_mss)
538        .collect();
539
540    // Stage 2: magnitude outliers via functional boxplot on the non-shape curves.
541    let keep: Vec<usize> = (0..n).filter(|i| !shape_outliers.contains(i)).collect();
542    let mut magnitude_outliers = Vec::new();
543    if keep.len() >= 3 {
544        let kn = keep.len();
545        let mut col_major = vec![0.0; kn * m];
546        for (r, &orig) in keep.iter().enumerate() {
547            for j in 0..m {
548                col_major[r + j * kn] = data[(orig, j)];
549            }
550        }
551        let reduced = FdMatrix::from_column_major(col_major, kn, m)?;
552        let fbp = functional_boxplot(&reduced, DepthMethod::ModifiedBand, config.emp_factor_tvd)?;
553        magnitude_outliers = fbp.outliers.iter().map(|&r| keep[r]).collect();
554    }
555
556    Ok(TvdMssOutliers {
557        magnitude_outliers,
558        shape_outliers,
559        tvd: depth.tvd,
560        mss: depth.mss,
561    })
562}
563
564/// Configuration for [`muod`].
565///
566/// Construct via `MuodConfig::default()`, then assign the fields you need (e.g. `let mut c = MuodConfig::default(); c.field = …;`). This struct is `#[non_exhaustive]`, so external crates cannot build it with a struct literal — not even functional-update `..Default::default()` form.
567#[non_exhaustive]
568#[derive(Debug, Clone, Copy, PartialEq)]
569#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
570pub struct MuodConfig {
571    /// IQR multiplier for the per-index upper boxplot cutoff. Default `1.5`.
572    pub factor: f64,
573}
574
575impl Default for MuodConfig {
576    fn default() -> Self {
577        Self { factor: 1.5 }
578    }
579}
580
581/// Numeric output of the [`muod`] detector: per-curve indices and flagged sets (no rendering).
582#[derive(Debug, Clone, PartialEq)]
583#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
584#[non_exhaustive]
585pub struct MuodResult {
586    /// Row indices flagged as shape outliers (upper fence on the shape index).
587    pub shape_outliers: Vec<usize>,
588    /// Row indices flagged as magnitude outliers (upper fence on the magnitude index).
589    pub magnitude_outliers: Vec<usize>,
590    /// Row indices flagged as amplitude outliers (upper fence on the amplitude index).
591    pub amplitude_outliers: Vec<usize>,
592    /// Shape index per curve: `|corr(X_i, μ) − 1|` (0 = same shape as the mean).
593    pub shape_index: Vec<f64>,
594    /// Magnitude index per curve: `|intercept_i|` (0 = same location as the mean).
595    pub magnitude_index: Vec<f64>,
596    /// Amplitude index per curve: `|slope_i − 1|` (0 = same amplitude as the mean).
597    pub amplitude_index: Vec<f64>,
598}
599
600/// Per-curve MUOD indices `(shape, magnitude, amplitude)` from OLS regression on the pointwise mean.
601fn muod_indices(data: &FdMatrix) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
602    let (n, m) = data.shape();
603
604    // Pointwise mean μ_t and its moments.
605    let mut mu = vec![0.0; m];
606    for (j, mu_j) in mu.iter_mut().enumerate() {
607        let mut s = 0.0;
608        for i in 0..n {
609            s += data[(i, j)];
610        }
611        *mu_j = s / n as f64;
612    }
613    let mu_mean = mu.iter().sum::<f64>() / m as f64;
614    let mu_var = mu.iter().map(|&v| (v - mu_mean).powi(2)).sum::<f64>() / (m as f64 - 1.0);
615    let mu_std = mu_var.sqrt();
616
617    let triples: Vec<(f64, f64, f64)> = iter_maybe_parallel!(0..n)
618        .map(|i| {
619            let mut xi_mean = 0.0;
620            for j in 0..m {
621                xi_mean += data[(i, j)];
622            }
623            xi_mean /= m as f64;
624
625            let mut cov = 0.0;
626            let mut xi_var = 0.0;
627            for j in 0..m {
628                let dx = data[(i, j)] - xi_mean;
629                let dmu = mu[j] - mu_mean;
630                cov += dx * dmu;
631                xi_var += dx * dx;
632            }
633            cov /= m as f64 - 1.0;
634            xi_var /= m as f64 - 1.0;
635            let xi_std = xi_var.sqrt();
636
637            let slope = if mu_var < 1e-15 { 1.0 } else { cov / mu_var };
638            let intercept = xi_mean - slope * mu_mean;
639            let corr = if xi_std < 1e-15 || mu_std < 1e-15 {
640                1.0
641            } else {
642                cov / (xi_std * mu_std)
643            };
644
645            ((corr - 1.0).abs(), intercept.abs(), (slope - 1.0).abs())
646        })
647        .collect();
648
649    let mut shape = Vec::with_capacity(n);
650    let mut magnitude = Vec::with_capacity(n);
651    let mut amplitude = Vec::with_capacity(n);
652    for (s, mg, a) in triples {
653        shape.push(s);
654        magnitude.push(mg);
655        amplitude.push(a);
656    }
657    (shape, magnitude, amplitude)
658}
659
660/// Fast-MUOD (Massive Unsupervised Outlier Detection) via per-curve regression on the pointwise mean.
661///
662/// For each curve, regresses it against the sample's pointwise mean μ and forms three indices —
663/// `shape = |corr(X_i, μ) − 1|`, `magnitude = |intercept_i|`, `amplitude = |slope_i − 1|` — then
664/// flags outliers per index with the upper IQR boxplot whisker (`Q3 + factor·IQR`).
665///
666/// **Divergence:** this is the Fast-MUOD variant (regression against the pointwise mean), not
667/// `fdaoutlier`'s pairwise C++ block; results match on the standard synthetic benchmarks. Only the
668/// `boxplot` cutoff is implemented (the `tangent` cutoff is deferred to the backlog). Degenerate
669/// variances (`< 1e-15`) fall back to a neutral index so a constant curve yields no NaN.
670///
671/// # Errors
672/// Returns [`FdarError::InvalidDimension`] if the sample has fewer than 3 curves or fewer than 2 columns.
673#[must_use = "outlier detection results should not be discarded"]
674pub fn muod(data: &FdMatrix, config: MuodConfig) -> Result<MuodResult, FdarError> {
675    let (n, m) = data.shape();
676    if n < 3 {
677        return Err(FdarError::InvalidDimension {
678            parameter: "data",
679            expected: "at least 3 curves".to_string(),
680            actual: format!("{n} rows"),
681        });
682    }
683    if m < 2 {
684        return Err(FdarError::InvalidDimension {
685            parameter: "data",
686            expected: "at least 2 columns".to_string(),
687            actual: format!("{m} columns"),
688        });
689    }
690
691    let (shape_index, magnitude_index, amplitude_index) = muod_indices(data);
692    let flag_upper = |idx: &[f64]| -> Vec<usize> {
693        let (_, upper) = iqr_fence(idx, config.factor);
694        (0..n).filter(|&i| idx[i] > upper).collect()
695    };
696    let shape_outliers = flag_upper(&shape_index);
697    let magnitude_outliers = flag_upper(&magnitude_index);
698    let amplitude_outliers = flag_upper(&amplitude_index);
699
700    Ok(MuodResult {
701        shape_outliers,
702        magnitude_outliers,
703        amplitude_outliers,
704        shape_index,
705        magnitude_index,
706        amplitude_index,
707    })
708}
709
710/// A single step in a [`sequential_transform_outliers`] pipeline.
711///
712/// Transforms are applied **cumulatively** (each step feeds the next):
713/// - `T0` — identity (raw data)
714/// - `T1` — vertical centering: subtract each curve's mean
715/// - `T2` — L2 normalization: divide each curve by its L2 norm
716/// - `D1` — lag-1 first difference (`m → m−1` columns)
717/// - `D2` — identical to `D1` (re-differences when applied after `D1`)
718///
719/// The multivariate outlyingness transform `O` is out of scope for this phase.
720#[derive(Debug, Clone, Copy, PartialEq, Eq)]
721#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
722#[non_exhaustive]
723pub enum SeqTransform {
724    /// Identity (raw data).
725    T0,
726    /// Vertical centering: subtract each curve's mean.
727    T1,
728    /// L2 normalization: divide each curve by its L2 norm.
729    T2,
730    /// Lag-1 first difference.
731    D1,
732    /// Identical to [`SeqTransform::D1`].
733    D2,
734}
735
736/// Configuration for [`sequential_transform_outliers`].
737///
738/// Not `serde`-serializable — it carries a [`DepthMethod`], which does not derive serde.
739///
740/// Construct via `SeqTransformConfig::default()`, then assign the fields you need (e.g. `let mut c = SeqTransformConfig::default(); c.field = …;`). This struct is `#[non_exhaustive]`, so external crates cannot build it with a struct literal — not even functional-update `..Default::default()` form.
741#[non_exhaustive]
742#[derive(Debug, Clone, Copy, PartialEq)]
743pub struct SeqTransformConfig {
744    /// Depth method for the per-step functional boxplot. Default [`DepthMethod::ModifiedBand`].
745    pub depth_method: DepthMethod,
746    /// Fence inflation factor for the per-step functional boxplot. Default `1.5`.
747    pub emp_factor: f64,
748}
749
750impl Default for SeqTransformConfig {
751    fn default() -> Self {
752        Self {
753            depth_method: DepthMethod::ModifiedBand,
754            emp_factor: 1.5,
755        }
756    }
757}
758
759/// Numeric output of [`sequential_transform_outliers`] (no rendering).
760#[derive(Debug, Clone, PartialEq)]
761#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
762#[non_exhaustive]
763pub struct SeqTransformOutliers {
764    /// Outlier row indices flagged after each transform step, in sequence order.
765    pub per_transform_outliers: Vec<(SeqTransform, Vec<usize>)>,
766    /// Union of all per-transform outlier indices (sorted, deduplicated).
767    pub union_outliers: Vec<usize>,
768}
769
770/// Apply a single [`SeqTransform`] to a matrix, returning the transformed matrix.
771fn seq_transform_apply(current: &FdMatrix, t: SeqTransform) -> Result<FdMatrix, FdarError> {
772    let (n, m) = current.shape();
773    match t {
774        SeqTransform::T0 => Ok(current.clone()),
775        SeqTransform::T1 => {
776            let mut cm = vec![0.0; n * m];
777            for i in 0..n {
778                let mut mean = 0.0;
779                for j in 0..m {
780                    mean += current[(i, j)];
781                }
782                mean /= m as f64;
783                for j in 0..m {
784                    cm[i + j * n] = current[(i, j)] - mean;
785                }
786            }
787            FdMatrix::from_column_major(cm, n, m)
788        }
789        SeqTransform::T2 => {
790            let mut cm = vec![0.0; n * m];
791            for i in 0..n {
792                let mut norm = 0.0;
793                for j in 0..m {
794                    norm += current[(i, j)].powi(2);
795                }
796                let norm = norm.sqrt();
797                if norm < 1e-15 {
798                    return Err(FdarError::ComputationFailed {
799                        operation: "T2 normalization",
800                        detail: format!("zero-norm curve at row {i}"),
801                    });
802                }
803                for j in 0..m {
804                    cm[i + j * n] = current[(i, j)] / norm;
805                }
806            }
807            FdMatrix::from_column_major(cm, n, m)
808        }
809        SeqTransform::D1 | SeqTransform::D2 => {
810            if m < 2 {
811                return Err(FdarError::InvalidDimension {
812                    parameter: "data",
813                    expected: "at least 2 columns for lag-1 differencing".to_string(),
814                    actual: format!("{m} columns"),
815                });
816            }
817            let m2 = m - 1;
818            let mut cm = vec![0.0; n * m2];
819            for i in 0..n {
820                for k in 0..m2 {
821                    cm[i + k * n] = current[(i, k + 1)] - current[(i, k)];
822                }
823            }
824            FdMatrix::from_column_major(cm, n, m2)
825        }
826    }
827}
828
829/// Sequential-transformation outlier detection (Dai et al. 2020 / `fdaoutlier::seq_transform`).
830///
831/// Applies each transform in `sequence` **cumulatively** (every step's output feeds the next) and
832/// runs a functional boxplot after each step, collecting the per-step outlier sets. The
833/// `union_outliers` field (all indices flagged by at least one step) is an fdars convenience — the R
834/// baseline returns only the per-transform sets.
835///
836/// # Errors
837/// Returns [`FdarError::InvalidDimension`] if the sample has fewer than 2 curves or if a `D1`/`D2`
838/// step is reached with fewer than 2 columns; returns [`FdarError::ComputationFailed`] if a `T2`
839/// step encounters a zero-norm curve.
840#[must_use = "outlier detection results should not be discarded"]
841pub fn sequential_transform_outliers(
842    data: &FdMatrix,
843    sequence: &[SeqTransform],
844    config: SeqTransformConfig,
845) -> Result<SeqTransformOutliers, FdarError> {
846    let n = data.nrows();
847    if n < 2 {
848        return Err(FdarError::InvalidDimension {
849            parameter: "data",
850            expected: "at least 2 curves".to_string(),
851            actual: format!("{n} rows"),
852        });
853    }
854
855    let mut current = data.clone();
856    let mut per_transform_outliers = Vec::with_capacity(sequence.len());
857    for &t in sequence {
858        current = seq_transform_apply(&current, t)?;
859        let fbp = functional_boxplot(&current, config.depth_method, config.emp_factor)?;
860        per_transform_outliers.push((t, fbp.outliers));
861    }
862
863    let mut union_outliers: Vec<usize> = per_transform_outliers
864        .iter()
865        .flat_map(|(_, v)| v.iter().copied())
866        .collect();
867    union_outliers.sort_unstable();
868    union_outliers.dedup();
869
870    Ok(SeqTransformOutliers {
871        per_transform_outliers,
872        union_outliers,
873    })
874}
875
876/// Configuration for [`depthgram`].
877///
878/// Construct via `DepthgramConfig::default()`, then assign the fields you need (e.g. `let mut c = DepthgramConfig::default(); c.field = …;`). This struct is `#[non_exhaustive]`, so external crates cannot build it with a struct literal — not even functional-update `..Default::default()` form.
879#[non_exhaustive]
880#[derive(Debug, Clone, Copy, PartialEq)]
881#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
882pub struct DepthgramConfig {
883    /// IQR factor for the parabola (shape) upper fence. Default `1.5`.
884    pub outliergram_factor: f64,
885    /// Inflation factor for the MBD magnitude functional boxplot. Default `1.5`.
886    pub boxplot_factor: f64,
887}
888
889impl Default for DepthgramConfig {
890    fn default() -> Self {
891        Self {
892            outliergram_factor: 1.5,
893            boxplot_factor: 1.5,
894        }
895    }
896}
897
898/// Numeric output of the [`depthgram`] statistic (no rendering).
899///
900/// For univariate (p=1) functional data the three representations (dimension-wise `_d`, time-wise
901/// `_t`, correlation-corrected `_t2`) are identical.
902#[derive(Debug, Clone, PartialEq)]
903#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
904#[non_exhaustive]
905pub struct DepthgramResult {
906    /// MBD of the MEI vector, dimension-wise.
907    pub mbd_mei_d: Vec<f64>,
908    /// MEI of the MBD vector, dimension-wise.
909    pub mei_mbd_d: Vec<f64>,
910    /// MBD of the MEI vector, time-wise (equals `_d` for p=1).
911    pub mbd_mei_t: Vec<f64>,
912    /// MEI of the MBD vector, time-wise (equals `_d` for p=1).
913    pub mei_mbd_t: Vec<f64>,
914    /// MBD of the MEI vector, correlation-corrected (equals `_d` for p=1).
915    pub mbd_mei_t2: Vec<f64>,
916    /// MEI of the MBD vector, correlation-corrected (equals `_d` for p=1).
917    pub mei_mbd_t2: Vec<f64>,
918    /// Row indices flagged as shape outliers (upper fence on the parabola deviation).
919    pub shape_outliers: Vec<usize>,
920    /// Row indices flagged as magnitude outliers (functional boxplot on MBD).
921    pub magnitude_outliers: Vec<usize>,
922    /// Modified band depth per curve.
923    pub mbd: Vec<f64>,
924    /// Modified epigraph index per curve.
925    pub mei: Vec<f64>,
926}
927
928/// Depthgram statistic (roahd `depthGram`) — numeric coordinates + outlier flags (no rendering).
929///
930/// Computes the modified band depth (MBD) and modified epigraph index (MEI) of the sample, then the
931/// `(MBD-of-MEI, MEI-of-MBD)` index pairs. Shape outliers are curves whose MBD falls above the
932/// outliergram parabola's upper IQR fence; magnitude outliers come from a functional boxplot on the
933/// MBD values.
934///
935/// **Divergence:** roahd's depthgram is defined for p-variate data and returns three distinct
936/// representations. This implementation handles univariate (p=1) functional data only, where all
937/// three representations are equivalent (the `_d`, `_t`, `_t2` fields are identical). At least ~4
938/// curves are needed for a meaningful parabola IQR.
939///
940/// # Errors
941/// Returns [`FdarError::InvalidDimension`] if the sample has fewer than 2 curves or zero columns.
942#[must_use = "outlier detection results should not be discarded"]
943pub fn depthgram(data: &FdMatrix, config: DepthgramConfig) -> Result<DepthgramResult, FdarError> {
944    let (n, m) = data.shape();
945    if n < 2 || m == 0 {
946        return Err(FdarError::InvalidDimension {
947            parameter: "data",
948            expected: "at least 2 curves and 1 column".to_string(),
949            actual: format!("{n} rows, {m} columns"),
950        });
951    }
952
953    let mbd = modified_band_1d(data, data);
954    let mei = modified_epigraph_index_1d(data, data);
955
956    // (MBD of MEI, MEI of MBD) via n×1 matrix wrapping.
957    let mei_mat = FdMatrix::from_column_major(mei.clone(), n, 1)?;
958    let mbd_mat = FdMatrix::from_column_major(mbd.clone(), n, 1)?;
959    let mbd_mei = modified_band_1d(&mei_mat, &mei_mat);
960    let mei_mbd = modified_epigraph_index_1d(&mbd_mat, &mbd_mat);
961
962    // Shape outliers: deviation above the outliergram parabola, upper IQR fence.
963    let nf = n as f64;
964    let a2 = -2.0 / (nf * (nf - 1.0));
965    let a0 = a2;
966    let a1 = 2.0 * (nf + 1.0) / (nf - 1.0);
967    let dist: Vec<f64> = (0..n)
968        .map(|i| (a0 + a1 * mei[i] + a2 * nf * nf * mei[i] * mei[i]) - mbd[i])
969        .collect();
970    let (_, upper) = iqr_fence(&dist, config.outliergram_factor);
971    let shape_outliers: Vec<usize> = (0..n).filter(|&i| dist[i] > upper).collect();
972
973    // Magnitude outliers: functional boxplot on the MBD values.
974    let mbd_mat2 = FdMatrix::from_column_major(mbd.clone(), n, 1)?;
975    let fbp = functional_boxplot(&mbd_mat2, DepthMethod::ModifiedBand, config.boxplot_factor)?;
976    let magnitude_outliers = fbp.outliers;
977
978    Ok(DepthgramResult {
979        mbd_mei_d: mbd_mei.clone(),
980        mei_mbd_d: mei_mbd.clone(),
981        mbd_mei_t: mbd_mei.clone(),
982        mei_mbd_t: mei_mbd.clone(),
983        mbd_mei_t2: mbd_mei,
984        mei_mbd_t2: mei_mbd,
985        shape_outliers,
986        magnitude_outliers,
987        mbd,
988        mei,
989    })
990}
991
992#[cfg(test)]
993mod tests {
994    use super::*;
995    use std::f64::consts::PI;
996
997    /// Generate homogeneous functional data
998    fn generate_normal_fdata(n: usize, m: usize, seed: u64) -> FdMatrix {
999        let mut rng = StdRng::seed_from_u64(seed);
1000        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
1001
1002        let mut data = FdMatrix::zeros(n, m);
1003        for i in 0..n {
1004            let phase: f64 = rng.gen::<f64>() * 0.2;
1005            let amp: f64 = 1.0 + rng.gen::<f64>() * 0.1;
1006            for j in 0..m {
1007                let noise: f64 = rng.sample::<f64, _>(StandardNormal) * 0.05;
1008                data[(i, j)] = amp * (2.0 * PI * t[j] + phase).sin() + noise;
1009            }
1010        }
1011        data
1012    }
1013
1014    /// Generate data with obvious outliers
1015    fn generate_data_with_outlier(n: usize, m: usize, n_outliers: usize) -> FdMatrix {
1016        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
1017
1018        let mut data = FdMatrix::zeros(n, m);
1019
1020        // Normal curves
1021        for i in 0..(n - n_outliers) {
1022            for j in 0..m {
1023                data[(i, j)] = (2.0 * PI * t[j]).sin();
1024            }
1025        }
1026
1027        // Outlier curves (shifted up by 10)
1028        for i in (n - n_outliers)..n {
1029            for j in 0..m {
1030                data[(i, j)] = (2.0 * PI * t[j]).sin() + 10.0;
1031            }
1032        }
1033
1034        data
1035    }
1036
1037    // ============== Threshold tests ==============
1038
1039    #[test]
1040    fn test_outliers_threshold_lrt_returns_positive() {
1041        let n = 20;
1042        let m = 30;
1043        let data = generate_normal_fdata(n, m, 42);
1044
1045        let threshold = outliers_threshold_lrt(&data, 50, 0.1, 0.1, 42, 0.95);
1046
1047        assert!(threshold > 0.0, "Threshold should be positive");
1048    }
1049
1050    #[test]
1051    fn test_outliers_threshold_lrt_deterministic() {
1052        let n = 15;
1053        let m = 25;
1054        let data = generate_normal_fdata(n, m, 42);
1055
1056        let t1 = outliers_threshold_lrt(&data, 30, 0.1, 0.1, 123, 0.95);
1057        let t2 = outliers_threshold_lrt(&data, 30, 0.1, 0.1, 123, 0.95);
1058
1059        assert!(
1060            (t1 - t2).abs() < 1e-10,
1061            "Same seed should give same threshold"
1062        );
1063    }
1064
1065    #[test]
1066    fn test_outliers_threshold_lrt_percentile_effect() {
1067        let n = 20;
1068        let m = 30;
1069        let data = generate_normal_fdata(n, m, 42);
1070
1071        let t_low = outliers_threshold_lrt(&data, 50, 0.1, 0.1, 42, 0.50);
1072        let t_high = outliers_threshold_lrt(&data, 50, 0.1, 0.1, 42, 0.99);
1073
1074        assert!(
1075            t_high >= t_low,
1076            "Higher percentile should give higher or equal threshold"
1077        );
1078    }
1079
1080    #[test]
1081    fn test_outliers_threshold_lrt_invalid_input() {
1082        // Too few observations
1083        let data = FdMatrix::zeros(2, 30);
1084        let threshold = outliers_threshold_lrt(&data, 50, 0.1, 0.1, 42, 0.95);
1085        assert!(threshold.abs() < 1e-10, "Should return 0 for n < 3");
1086
1087        // Empty m
1088        let data = FdMatrix::zeros(10, 0);
1089        let threshold = outliers_threshold_lrt(&data, 50, 0.1, 0.1, 42, 0.95);
1090        assert!(threshold.abs() < 1e-10);
1091    }
1092
1093    // ============== Detection tests ==============
1094
1095    #[test]
1096    fn test_detect_outliers_lrt_finds_obvious_outlier() {
1097        let n = 20;
1098        let m = 30;
1099        let data = generate_data_with_outlier(n, m, 1);
1100
1101        // Use a reasonable threshold
1102        let outliers = detect_outliers_lrt(&data, 3.0, 0.1);
1103
1104        assert_eq!(outliers.len(), n);
1105
1106        // The last curve (outlier) should be detected
1107        assert!(outliers[n - 1], "Obvious outlier should be detected");
1108
1109        // Most normal curves should not be outliers
1110        let n_detected: usize = outliers.iter().filter(|&&x| x).count();
1111        assert!(n_detected <= 3, "Should not detect too many outliers");
1112    }
1113
1114    #[test]
1115    fn test_detect_outliers_lrt_homogeneous_data() {
1116        let n = 20;
1117        let m = 30;
1118        let data = generate_normal_fdata(n, m, 42);
1119
1120        // With very high threshold, no outliers
1121        let outliers = detect_outliers_lrt(&data, 100.0, 0.1);
1122
1123        let n_detected: usize = outliers.iter().filter(|&&x| x).count();
1124        assert_eq!(
1125            n_detected, 0,
1126            "Very high threshold should detect no outliers"
1127        );
1128    }
1129
1130    #[test]
1131    fn test_detect_outliers_lrt_threshold_effect() {
1132        let n = 20;
1133        let m = 30;
1134        let data = generate_data_with_outlier(n, m, 3);
1135
1136        let low_thresh = detect_outliers_lrt(&data, 2.0, 0.1);
1137        let high_thresh = detect_outliers_lrt(&data, 10.0, 0.1);
1138
1139        let n_low: usize = low_thresh.iter().filter(|&&x| x).count();
1140        let n_high: usize = high_thresh.iter().filter(|&&x| x).count();
1141
1142        assert!(
1143            n_low >= n_high,
1144            "Lower threshold should detect more or equal outliers"
1145        );
1146    }
1147
1148    #[test]
1149    fn test_detect_outliers_lrt_invalid_input() {
1150        // Too few observations
1151        let data = FdMatrix::zeros(2, 30);
1152        let outliers = detect_outliers_lrt(&data, 3.0, 0.1);
1153        assert_eq!(outliers.len(), 2);
1154        assert!(
1155            outliers.iter().all(|&x| !x),
1156            "Should return all false for n < 3"
1157        );
1158    }
1159
1160    #[test]
1161    fn test_identical_data_outliers() {
1162        let n = 10;
1163        let m = 20;
1164        let data = FdMatrix::from_column_major(vec![1.0; n * m], n, m).unwrap();
1165        let flags = detect_outliers_lrt(&data, 1.0, 0.15);
1166        assert_eq!(flags.len(), n);
1167        // All identical → no outliers
1168        for &f in &flags {
1169            assert!(!f);
1170        }
1171    }
1172
1173    #[test]
1174    fn test_n3_minimal_outliers() {
1175        // Minimum viable: 3 curves
1176        let n = 3;
1177        let m = 10;
1178        let mut data_vec = vec![0.0; n * m];
1179        // Third curve is an outlier
1180        for j in 0..m {
1181            data_vec[j * n] = 0.0;
1182            data_vec[1 + j * n] = 0.1;
1183            data_vec[2 + j * n] = 100.0;
1184        }
1185        let data = FdMatrix::from_column_major(data_vec, n, m).unwrap();
1186        let flags = detect_outliers_lrt(&data, 0.5, 0.15);
1187        assert_eq!(flags.len(), n);
1188    }
1189
1190    // ============== With-distribution tests ==============
1191
1192    #[test]
1193    fn test_with_dist_returns_sorted_distribution() {
1194        let data = generate_normal_fdata(20, 30, 42);
1195        let nb = 50;
1196        let (threshold, dist) = outliers_threshold_lrt_with_dist(&data, nb, 0.1, 0.1, 42, 0.95);
1197
1198        assert_eq!(dist.len(), nb, "Distribution length should equal nb");
1199        for w in dist.windows(2) {
1200            assert!(w[0] <= w[1], "Distribution should be sorted");
1201        }
1202        let idx = ((nb as f64 * 0.95) as usize).min(nb - 1);
1203        assert!(
1204            (threshold - dist[idx]).abs() < 1e-10,
1205            "Threshold should match distribution at percentile index"
1206        );
1207    }
1208
1209    #[test]
1210    fn test_with_dist_matches_scalar() {
1211        let data = generate_normal_fdata(15, 25, 99);
1212        let scalar = outliers_threshold_lrt(&data, 40, 0.1, 0.1, 123, 0.95);
1213        let (with_dist, _) = outliers_threshold_lrt_with_dist(&data, 40, 0.1, 0.1, 123, 0.95);
1214        assert!(
1215            (scalar - with_dist).abs() < 1e-10,
1216            "Scalar version should match with_dist version"
1217        );
1218    }
1219
1220    #[test]
1221    fn test_bootstrap_dist_enables_pvalue() {
1222        let n = 20;
1223        let m = 30;
1224        let data = generate_data_with_outlier(n, m, 1);
1225        let trim = 0.1;
1226
1227        let (_, dist) = outliers_threshold_lrt_with_dist(&data, 200, 0.1, trim, 42, 0.99);
1228        let nb = dist.len();
1229
1230        // Compute per-curve distances
1231        let n_keep = ((1.0 - trim) * n as f64).ceil() as usize;
1232        let state = SortedReferenceState::from_reference(&data);
1233        let streaming_fm = StreamingFraimanMuniz::new(state, true);
1234        let depths = streaming_fm.depth_batch(&data);
1235        let (tmean, tvar) = compute_trimmed_stats(&data, &depths, n_keep);
1236
1237        // p-value for the outlier curve (last one)
1238        let d_outlier = normalized_distance(&data, n - 1, &tmean, &tvar);
1239        let p_outlier =
1240            (dist.iter().filter(|&&v| v >= d_outlier).count() as f64 + 1.0) / (nb as f64 + 1.0);
1241
1242        // p-value for a normal curve (first one)
1243        let d_normal = normalized_distance(&data, 0, &tmean, &tvar);
1244        let p_normal =
1245            (dist.iter().filter(|&&v| v >= d_normal).count() as f64 + 1.0) / (nb as f64 + 1.0);
1246
1247        assert!(
1248            p_outlier < 0.05,
1249            "Outlier should have small p-value, got {p_outlier}"
1250        );
1251        assert!(
1252            p_normal > 0.05,
1253            "Normal curve should have large p-value, got {p_normal}"
1254        );
1255    }
1256
1257    #[test]
1258    fn test_with_dist_invalid_input() {
1259        let data = FdMatrix::zeros(2, 30);
1260        let (threshold, dist) = outliers_threshold_lrt_with_dist(&data, 50, 0.1, 0.1, 42, 0.95);
1261        assert!(threshold.abs() < 1e-10);
1262        assert!(dist.is_empty(), "Should return empty dist for n < 3");
1263    }
1264
1265    #[test]
1266    fn test_all_false_high_threshold() {
1267        let n = 10;
1268        let m = 20;
1269        let data_vec: Vec<f64> = (0..n * m).map(|i| (i as f64 * 0.1).sin()).collect();
1270        let data = FdMatrix::from_column_major(data_vec, n, m).unwrap();
1271        // Very high threshold → no outliers
1272        let flags = detect_outliers_lrt(&data, 1e10, 0.15);
1273        for &f in &flags {
1274            assert!(!f, "High threshold should produce no outliers");
1275        }
1276    }
1277
1278    // ============== n_keep clamping tests ==============
1279
1280    #[test]
1281    fn test_trim_zero_no_trimming() {
1282        // trim=0 → n_keep=n, exercises the skip-partial-sort branch in compute_trimmed_stats
1283        let data = generate_normal_fdata(10, 20, 42);
1284        let threshold = outliers_threshold_lrt(&data, 30, 0.1, 0.0, 42, 0.95);
1285        assert!(threshold > 0.0);
1286        let flags = detect_outliers_lrt(&data, threshold, 0.0);
1287        assert_eq!(flags.len(), 10);
1288    }
1289
1290    #[test]
1291    fn test_trim_near_one_heavy_trimming() {
1292        // trim=0.9 → n_keep=1, exercises minimal trim set (single deepest curve)
1293        let data = generate_normal_fdata(10, 20, 42);
1294        let threshold = outliers_threshold_lrt(&data, 30, 0.1, 0.9, 42, 0.95);
1295        assert!(threshold >= 0.0);
1296        let flags = detect_outliers_lrt(&data, threshold, 0.9);
1297        assert_eq!(flags.len(), 10);
1298    }
1299
1300    #[test]
1301    fn test_trim_one_clamps_to_one() {
1302        // trim=1.0 → n_keep would be 0, must clamp to 1 (was a panic before fix)
1303        let data = generate_normal_fdata(10, 20, 42);
1304        let threshold = outliers_threshold_lrt(&data, 30, 0.1, 1.0, 42, 0.95);
1305        assert!(threshold >= 0.0);
1306        let flags = detect_outliers_lrt(&data, threshold, 1.0);
1307        assert_eq!(flags.len(), 10);
1308    }
1309
1310    #[test]
1311    fn test_trim_negative_clamps_to_n() {
1312        // trim=-0.5 → n_keep would exceed n, must clamp to n (was a panic before fix)
1313        let data = generate_normal_fdata(10, 20, 42);
1314        let threshold = outliers_threshold_lrt(&data, 30, 0.1, -0.5, 42, 0.95);
1315        assert!(threshold > 0.0);
1316        let flags = detect_outliers_lrt(&data, threshold, -0.5);
1317        assert_eq!(flags.len(), 10);
1318    }
1319
1320    // ============== Bootstrap parameter edge cases ==============
1321
1322    #[test]
1323    fn test_smo_zero_no_noise() {
1324        // smo=0 → bootstrap resamples without smoothing noise
1325        let data = generate_normal_fdata(10, 20, 42);
1326        let (threshold, dist) = outliers_threshold_lrt_with_dist(&data, 30, 0.0, 0.1, 42, 0.95);
1327        assert!(threshold > 0.0);
1328        assert_eq!(dist.len(), 30);
1329    }
1330
1331    #[test]
1332    fn test_nb_zero_empty_bootstrap() {
1333        let data = generate_normal_fdata(10, 20, 42);
1334        let (threshold, dist) = outliers_threshold_lrt_with_dist(&data, 0, 0.1, 0.1, 42, 0.95);
1335        assert!(threshold.abs() < 1e-10);
1336        assert!(dist.is_empty());
1337    }
1338
1339    #[test]
1340    fn test_nb_one_single_bootstrap() {
1341        let data = generate_normal_fdata(10, 20, 42);
1342        let (threshold, dist) = outliers_threshold_lrt_with_dist(&data, 1, 0.1, 0.1, 42, 0.95);
1343        assert_eq!(dist.len(), 1);
1344        // With 1 iteration, threshold must equal the single value
1345        assert!((threshold - dist[0]).abs() < 1e-10);
1346    }
1347
1348    #[test]
1349    fn test_percentile_zero_returns_minimum() {
1350        let data = generate_normal_fdata(15, 20, 42);
1351        let nb = 50;
1352        let (_, dist) = outliers_threshold_lrt_with_dist(&data, nb, 0.1, 0.1, 42, 0.95);
1353        let t_zero = outliers_threshold_lrt(&data, nb, 0.1, 0.1, 42, 0.0);
1354        assert!(
1355            (t_zero - dist[0]).abs() < 1e-10,
1356            "percentile=0 should return the minimum of the distribution"
1357        );
1358    }
1359
1360    #[test]
1361    fn test_percentile_one_returns_maximum() {
1362        let data = generate_normal_fdata(15, 20, 42);
1363        let nb = 50;
1364        let (_, dist) = outliers_threshold_lrt_with_dist(&data, nb, 0.1, 0.1, 42, 0.95);
1365        let t_one = outliers_threshold_lrt(&data, nb, 0.1, 0.1, 42, 1.0);
1366        assert!(
1367            (t_one - *dist.last().unwrap()).abs() < 1e-10,
1368            "percentile=1 should return the maximum of the distribution"
1369        );
1370    }
1371
1372    // ============== Distribution invariant tests ==============
1373
1374    #[test]
1375    fn test_distribution_values_non_negative() {
1376        let data = generate_normal_fdata(15, 20, 42);
1377        let (_, dist) = outliers_threshold_lrt_with_dist(&data, 50, 0.1, 0.1, 42, 0.95);
1378        for &v in &dist {
1379            assert!(v >= 0.0, "Max-distances must be non-negative, got {v}");
1380        }
1381    }
1382
1383    // ============== detect_outliers_lrt edge cases ==============
1384
1385    #[test]
1386    fn test_detect_m_zero_returns_all_false() {
1387        let data = FdMatrix::zeros(10, 0);
1388        let flags = detect_outliers_lrt(&data, 3.0, 0.1);
1389        assert_eq!(flags.len(), 10);
1390        assert!(flags.iter().all(|&f| !f));
1391    }
1392
1393    #[test]
1394    fn test_detect_multiple_outliers() {
1395        let data = generate_data_with_outlier(20, 30, 3);
1396        let flags = detect_outliers_lrt(&data, 3.0, 0.1);
1397        // All three outlier curves (indices 17, 18, 19) should be detected
1398        let outlier_count = flags[17..20].iter().filter(|&&x| x).count();
1399        assert!(
1400            outlier_count >= 2,
1401            "At least 2 of 3 outliers should be detected, got {outlier_count}"
1402        );
1403    }
1404
1405    // ============== End-to-end integration ==============
1406
1407    #[test]
1408    fn test_end_to_end_threshold_then_detect() {
1409        let data = generate_data_with_outlier(20, 30, 2);
1410        let threshold = outliers_threshold_lrt(&data, 100, 0.1, 0.1, 42, 0.99);
1411        let flags = detect_outliers_lrt(&data, threshold, 0.1);
1412
1413        // Outlier curves (last 2) should be flagged
1414        assert!(
1415            flags[18] || flags[19],
1416            "At least one outlier should be detected in end-to-end flow"
1417        );
1418        // Normal curves should mostly not be flagged
1419        let false_positives = flags[..18].iter().filter(|&&x| x).count();
1420        assert!(
1421            false_positives <= 2,
1422            "False positive count should be low, got {false_positives}"
1423        );
1424    }
1425
1426    #[test]
1427    fn test_end_to_end_with_dist_pvalues_all_curves() {
1428        // Full pipeline: bootstrap dist → per-curve p-values → outlier classification
1429        let n = 25;
1430        let m = 30;
1431        let data = generate_data_with_outlier(n, m, 2);
1432        let trim = 0.1;
1433
1434        let (_, dist) = outliers_threshold_lrt_with_dist(&data, 200, 0.1, trim, 42, 0.99);
1435        let nb = dist.len();
1436
1437        let n_keep = ((1.0 - trim) * n as f64).ceil().max(1.0) as usize;
1438        let n_keep = n_keep.min(n);
1439        let state = SortedReferenceState::from_reference(&data);
1440        let streaming_fm = StreamingFraimanMuniz::new(state, true);
1441        let depths = streaming_fm.depth_batch(&data);
1442        let (tmean, tvar) = compute_trimmed_stats(&data, &depths, n_keep);
1443
1444        // Compute p-values for all curves
1445        let pvalues: Vec<f64> = (0..n)
1446            .map(|i| {
1447                let d = normalized_distance(&data, i, &tmean, &tvar);
1448                (dist.iter().filter(|&&v| v >= d).count() as f64 + 1.0) / (nb as f64 + 1.0)
1449            })
1450            .collect();
1451
1452        // Normal curves (0..23) should have large p-values
1453        let normal_small_p = pvalues[..23].iter().filter(|&&p| p < 0.01).count();
1454        assert_eq!(
1455            normal_small_p, 0,
1456            "Normal curves should not have tiny p-values"
1457        );
1458
1459        // Outlier curves (23, 24) should have small p-values
1460        for &i in &[23, 24] {
1461            assert!(
1462                pvalues[i] < 0.05,
1463                "Outlier curve {i} should have small p-value, got {}",
1464                pvalues[i]
1465            );
1466        }
1467    }
1468
1469    // ============== Outliergram tests ==============
1470
1471    fn outliergram_test_data() -> FdMatrix {
1472        // 20 curves on 30 grid points, with 2 outliers
1473        let n = 20;
1474        let m = 30;
1475        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
1476        let mut vals = vec![0.0; n * m];
1477        for i in 0..n {
1478            for (j, &tj) in t.iter().enumerate() {
1479                let base = tj.sin();
1480                vals[i + j * n] = if i < 18 {
1481                    base + 0.1 * (i as f64 * 0.5).sin()
1482                } else {
1483                    // Outliers: large deviation
1484                    base + 2.0 * (if i == 18 { 1.0 } else { -1.0 })
1485                };
1486            }
1487        }
1488        FdMatrix::from_column_major(vals, n, m).unwrap()
1489    }
1490
1491    #[test]
1492    fn outliergram_runs() {
1493        let data = outliergram_test_data();
1494        let result = outliergram(&data, 1.5).unwrap();
1495        assert_eq!(result.mei.len(), 20);
1496        assert_eq!(result.mbd.len(), 20);
1497        assert_eq!(result.outlier_flags.len(), 20);
1498        // The two outlier curves should have lower MBD
1499        let central_mbd: f64 = result.mbd[..18].iter().sum::<f64>() / 18.0;
1500        assert!(result.mbd[18] < central_mbd || result.mbd[19] < central_mbd);
1501    }
1502
1503    #[test]
1504    fn outliergram_parabola_coefficients() {
1505        let data = outliergram_test_data();
1506        let result = outliergram(&data, 1.5).unwrap();
1507        // Parabola should have a2 <= 0 (concave) for the theoretical relationship
1508        // (this is typical but not strictly required)
1509        assert!(result.a0.is_finite());
1510        assert!(result.a1.is_finite());
1511        assert!(result.a2.is_finite());
1512    }
1513
1514    #[test]
1515    fn magnitude_shape_dimensions() {
1516        let data = outliergram_test_data();
1517        let result = magnitude_shape_outlyingness(&data).unwrap();
1518        assert_eq!(result.magnitude.len(), 20);
1519        assert_eq!(result.shape.len(), 20);
1520        // All values should be non-negative
1521        assert!(result.magnitude.iter().all(|&v| v >= 0.0));
1522        assert!(result.shape.iter().all(|&v| v >= 0.0));
1523    }
1524
1525    #[test]
1526    fn magnitude_outliers_have_high_magnitude() {
1527        let data = outliergram_test_data();
1528        let result = magnitude_shape_outlyingness(&data).unwrap();
1529        let central_mag: f64 = result.magnitude[..18].iter().sum::<f64>() / 18.0;
1530        // At least one outlier should have higher magnitude
1531        assert!(result.magnitude[18] > central_mag || result.magnitude[19] > central_mag);
1532    }
1533
1534    #[test]
1535    fn outliergram_too_few_curves() {
1536        let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0], 2, 2).unwrap();
1537        assert!(outliergram(&data, 1.5).is_err());
1538    }
1539
1540    // --- Phase 29: tvdmss + muod ---
1541
1542    /// `n` sinusoids each with a genuine per-curve shape perturbation (so the derivative
1543    /// ranks that drive MSS are well-defined, not floating-point noise). `outlier_idx` is
1544    /// replaced per `kind`: "magnitude" = same shape +10 vertical shift, "amplitude" = ×5
1545    /// scale, "shape" = a genuinely different high-frequency wiggle, "constant" = a flat curve.
1546    fn outlier_sample(n: usize, m: usize, outlier_idx: usize, kind: &str) -> FdMatrix {
1547        // A normal inlier-shaped curve for row i: a primary sine plus a small curve-specific
1548        // secondary harmonic that gives each curve real (non-degenerate) derivative variation.
1549        let inlier_shape = |i: usize, x: f64| -> f64 {
1550            (x * PI).sin() + 0.1 * (x * 4.0 * PI + 0.3 * i as f64).sin()
1551        };
1552        let mut cm = vec![0.0; n * m];
1553        for i in 0..n {
1554            for t in 0..m {
1555                let x = t as f64 / (m as f64 - 1.0);
1556                let val = if i == outlier_idx {
1557                    match kind {
1558                        // Same shape family as its own inlier, only shifted / scaled.
1559                        "magnitude" => inlier_shape(i, x) + 10.0,
1560                        "amplitude" => 5.0 * inlier_shape(i, x),
1561                        // A genuinely different shape: centered on the sin trend (same
1562                        // vertical band as the inliers) but with a high-frequency wiggle
1563                        // that weaves through the bundle — low MBD, mid MEI, low MSS.
1564                        "shape" => (x * PI).sin() + 0.4 * (x * 12.0 * PI).sin(),
1565                        "constant" => 0.5,
1566                        _ => inlier_shape(i, x),
1567                    }
1568                } else {
1569                    inlier_shape(i, x)
1570                };
1571                cm[i + t * n] = val;
1572            }
1573        }
1574        FdMatrix::from_column_major(cm, n, m).unwrap()
1575    }
1576
1577    #[test]
1578    fn tvdmss_flags_magnitude_outlier() {
1579        let idx = 4usize;
1580        let data = outlier_sample(12, 40, idx, "magnitude");
1581        let res = tvdmss(&data, TvdMssConfig::default()).unwrap();
1582        assert_eq!(res.tvd.len(), 12);
1583        assert_eq!(res.mss.len(), 12);
1584        assert!(
1585            res.magnitude_outliers.contains(&idx),
1586            "magnitude outlier {idx} not flagged: {:?}",
1587            res.magnitude_outliers
1588        );
1589    }
1590
1591    #[test]
1592    fn tvdmss_flags_shape_outlier() {
1593        let idx = 7usize;
1594        let data = outlier_sample(12, 60, idx, "shape");
1595        let res = tvdmss(&data, TvdMssConfig::default()).unwrap();
1596        assert!(
1597            res.shape_outliers.contains(&idx),
1598            "shape outlier {idx} not flagged: {:?}",
1599            res.shape_outliers
1600        );
1601    }
1602
1603    #[test]
1604    fn tvdmss_rejects_empty_and_too_few() {
1605        let empty = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
1606        assert!(matches!(
1607            tvdmss(&empty, TvdMssConfig::default()),
1608            Err(FdarError::InvalidDimension { .. })
1609        ));
1610        let two = outlier_sample(2, 8, 0, "none");
1611        assert!(matches!(
1612            tvdmss(&two, TvdMssConfig::default()),
1613            Err(FdarError::InvalidDimension { .. })
1614        ));
1615    }
1616
1617    #[test]
1618    fn muod_flags_magnitude_amplitude_shape() {
1619        let mag = outlier_sample(12, 40, 3, "magnitude");
1620        let r = muod(&mag, MuodConfig::default()).unwrap();
1621        assert_eq!(r.shape_index.len(), 12);
1622        assert!(
1623            r.magnitude_outliers.contains(&3),
1624            "magnitude: {:?}",
1625            r.magnitude_outliers
1626        );
1627
1628        let amp = outlier_sample(12, 40, 5, "amplitude");
1629        let r = muod(&amp, MuodConfig::default()).unwrap();
1630        assert!(
1631            r.amplitude_outliers.contains(&5),
1632            "amplitude: {:?}",
1633            r.amplitude_outliers
1634        );
1635
1636        let shp = outlier_sample(12, 60, 8, "shape");
1637        let r = muod(&shp, MuodConfig::default()).unwrap();
1638        assert!(
1639            r.shape_outliers.contains(&8),
1640            "shape: {:?}",
1641            r.shape_outliers
1642        );
1643    }
1644
1645    #[test]
1646    fn muod_constant_curve_no_nan() {
1647        let data = outlier_sample(12, 40, 6, "constant");
1648        let r = muod(&data, MuodConfig::default()).unwrap();
1649        for v in r
1650            .shape_index
1651            .iter()
1652            .chain(&r.magnitude_index)
1653            .chain(&r.amplitude_index)
1654        {
1655            assert!(!v.is_nan(), "index produced NaN");
1656        }
1657    }
1658
1659    #[test]
1660    fn muod_rejects_bad_dims() {
1661        let two = outlier_sample(2, 8, 0, "none");
1662        assert!(matches!(
1663            muod(&two, MuodConfig::default()),
1664            Err(FdarError::InvalidDimension { .. })
1665        ));
1666        let one_col = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 3, 1).unwrap();
1667        assert!(matches!(
1668            muod(&one_col, MuodConfig::default()),
1669            Err(FdarError::InvalidDimension { .. })
1670        ));
1671    }
1672
1673    // --- Phase 29 Plan 02: sequential_transform_outliers + depthgram ---
1674
1675    #[test]
1676    fn seq_transform_default_sequence_flags_outlier_and_union_is_flatten() {
1677        let idx = 4usize;
1678        let data = outlier_sample(12, 40, idx, "magnitude");
1679        let seq = [SeqTransform::T0, SeqTransform::T1, SeqTransform::D1];
1680        let res =
1681            sequential_transform_outliers(&data, &seq, SeqTransformConfig::default()).unwrap();
1682
1683        assert_eq!(res.per_transform_outliers.len(), 3);
1684        assert!(
1685            res.per_transform_outliers
1686                .iter()
1687                .any(|(_, v)| !v.is_empty()),
1688            "no transform flagged anything"
1689        );
1690        assert!(
1691            res.union_outliers.contains(&idx),
1692            "union {:?} missing outlier {idx}",
1693            res.union_outliers
1694        );
1695        // union == sorted, deduped flatten of the per-transform sets.
1696        let mut expected: Vec<usize> = res
1697            .per_transform_outliers
1698            .iter()
1699            .flat_map(|(_, v)| v.iter().copied())
1700            .collect();
1701        expected.sort_unstable();
1702        expected.dedup();
1703        assert_eq!(res.union_outliers, expected);
1704    }
1705
1706    #[test]
1707    fn seq_transform_error_paths() {
1708        // D1 with a single column.
1709        let one_col = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 3, 1).unwrap();
1710        assert!(matches!(
1711            sequential_transform_outliers(
1712                &one_col,
1713                &[SeqTransform::D1],
1714                SeqTransformConfig::default()
1715            ),
1716            Err(FdarError::InvalidDimension { .. })
1717        ));
1718        // T2 with an all-zero curve → ComputationFailed.
1719        let mut cm = vec![0.0; 3 * 4];
1720        for i in [0usize, 2] {
1721            for t in 0..4 {
1722                cm[i + t * 3] = 1.0 + i as f64 + t as f64;
1723            }
1724        }
1725        // row 1 stays all-zero
1726        let zero_row = FdMatrix::from_column_major(cm, 3, 4).unwrap();
1727        assert!(matches!(
1728            sequential_transform_outliers(
1729                &zero_row,
1730                &[SeqTransform::T2],
1731                SeqTransformConfig::default()
1732            ),
1733            Err(FdarError::ComputationFailed { .. })
1734        ));
1735        // n == 1.
1736        let one_curve = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
1737        assert!(matches!(
1738            sequential_transform_outliers(
1739                &one_curve,
1740                &[SeqTransform::T0],
1741                SeqTransformConfig::default()
1742            ),
1743            Err(FdarError::InvalidDimension { .. })
1744        ));
1745    }
1746
1747    #[test]
1748    fn depthgram_flags_magnitude_and_shape() {
1749        let mag = outlier_sample(12, 40, 3, "magnitude");
1750        let r = depthgram(&mag, DepthgramConfig::default()).unwrap();
1751        assert_eq!(r.mbd.len(), 12);
1752        assert_eq!(r.mei.len(), 12);
1753        assert_eq!(r.mbd_mei_d.len(), 12);
1754        assert!(
1755            r.magnitude_outliers.contains(&3),
1756            "magnitude: {:?}",
1757            r.magnitude_outliers
1758        );
1759
1760        let shp = outlier_sample(14, 60, 9, "shape");
1761        let r = depthgram(&shp, DepthgramConfig::default()).unwrap();
1762        assert!(
1763            r.shape_outliers.contains(&9),
1764            "shape: {:?}",
1765            r.shape_outliers
1766        );
1767    }
1768
1769    #[test]
1770    fn depthgram_p1_representations_equivalent() {
1771        let data = outlier_sample(10, 30, 2, "magnitude");
1772        let r = depthgram(&data, DepthgramConfig::default()).unwrap();
1773        assert_eq!(r.mbd_mei_d, r.mbd_mei_t);
1774        assert_eq!(r.mbd_mei_d, r.mbd_mei_t2);
1775        assert_eq!(r.mei_mbd_d, r.mei_mbd_t);
1776        assert_eq!(r.mei_mbd_d, r.mei_mbd_t2);
1777    }
1778
1779    #[test]
1780    fn depthgram_rejects_bad_dims() {
1781        let one = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
1782        assert!(matches!(
1783            depthgram(&one, DepthgramConfig::default()),
1784            Err(FdarError::InvalidDimension { .. })
1785        ));
1786        let empty = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
1787        assert!(matches!(
1788            depthgram(&empty, DepthgramConfig::default()),
1789            Err(FdarError::InvalidDimension { .. })
1790        ));
1791    }
1792}