Skip to main content

fdars_core/
depth.rs

1//! Depth measures for functional data.
2//!
3//! This module provides various depth measures for assessing the centrality
4//! of functional observations within a reference sample.
5
6use crate::helpers::simpsons_weights;
7use crate::iter_maybe_parallel;
8use crate::matrix::FdMatrix;
9use crate::streaming_depth::{
10    FullReferenceState, SortedReferenceState, StreamingBd, StreamingDepth, StreamingFraimanMuniz,
11    StreamingMbd,
12};
13use rand::prelude::*;
14use rand_distr::StandardNormal;
15#[cfg(feature = "parallel")]
16use rayon::iter::ParallelIterator;
17
18/// Compute Fraiman-Muniz depth for 1D functional data.
19///
20/// Uses the FM1 formula: d = 1 - |0.5 - Fn(x)|
21/// With scale=true: d = 2 * min(Fn(x), 1-Fn(x))
22///
23/// # Arguments
24/// * `data_obj` - Data to compute depth for (nobj x n_points)
25/// * `data_ori` - Reference data (nori x n_points)
26/// * `scale` - Whether to scale the depth values
27pub fn fraiman_muniz_1d(data_obj: &FdMatrix, data_ori: &FdMatrix, scale: bool) -> Vec<f64> {
28    if data_obj.nrows() == 0 || data_ori.nrows() == 0 || data_obj.ncols() == 0 {
29        return Vec::new();
30    }
31    let state = SortedReferenceState::from_reference(data_ori);
32    let streaming = StreamingFraimanMuniz::new(state, scale);
33    streaming.depth_batch(data_obj)
34}
35
36/// Compute Fraiman-Muniz depth for 2D functional data (surfaces).
37pub fn fraiman_muniz_2d(data_obj: &FdMatrix, data_ori: &FdMatrix, scale: bool) -> Vec<f64> {
38    // Same implementation as 1D - iterate over all grid points
39    fraiman_muniz_1d(data_obj, data_ori, scale)
40}
41
42/// Compute modal depth for 1D functional data.
43///
44/// Uses a Gaussian kernel to measure density around each curve.
45///
46/// # Arguments
47/// * `data_obj` - Data to compute depth for
48/// * `data_ori` - Reference data
49/// * `h` - Bandwidth parameter
50pub fn modal_1d(data_obj: &FdMatrix, data_ori: &FdMatrix, h: f64) -> Vec<f64> {
51    let nobj = data_obj.nrows();
52    let nori = data_ori.nrows();
53    let n_points = data_obj.ncols();
54
55    if nobj == 0 || nori == 0 || n_points == 0 {
56        return Vec::new();
57    }
58
59    iter_maybe_parallel!(0..nobj)
60        .map(|i| {
61            let mut depth = 0.0;
62
63            for j in 0..nori {
64                let mut dist_sq = 0.0;
65                for t in 0..n_points {
66                    let diff = data_obj[(i, t)] - data_ori[(j, t)];
67                    dist_sq += diff * diff;
68                }
69                let dist = (dist_sq / n_points as f64).sqrt();
70                let kernel_val = (-0.5 * (dist / h).powi(2)).exp();
71                depth += kernel_val;
72            }
73
74            depth / nori as f64
75        })
76        .collect()
77}
78
79/// Compute modal depth for 2D functional data.
80pub fn modal_2d(data_obj: &FdMatrix, data_ori: &FdMatrix, h: f64) -> Vec<f64> {
81    modal_1d(data_obj, data_ori, h)
82}
83
84/// Shared implementation for random projection-based depth measures.
85///
86/// Generates `nproj` random projections, projects both object and reference
87/// curves to scalars, and computes univariate depth via binary-search ranking.
88/// The `aggregate` and `finalize` closures control how per-projection depths
89/// are combined (e.g. average for RP depth, minimum for Tukey depth).
90fn random_depth_core(
91    data_obj: &FdMatrix,
92    data_ori: &FdMatrix,
93    nproj: usize,
94    init: f64,
95    aggregate: impl Fn(f64, f64) -> f64 + Sync,
96    finalize: impl Fn(f64, usize) -> f64 + Sync,
97) -> Vec<f64> {
98    let nobj = data_obj.nrows();
99    let nori = data_ori.nrows();
100    let n_points = data_obj.ncols();
101
102    if nobj == 0 || nori == 0 || n_points == 0 || nproj == 0 {
103        return Vec::new();
104    }
105
106    let mut rng = rand::thread_rng();
107    let m = n_points;
108    let mut projections = vec![0.0; nproj * m];
109    for p_idx in 0..nproj {
110        let base = p_idx * m;
111        let mut norm_sq = 0.0;
112        for t in 0..m {
113            let v: f64 = rng.sample(StandardNormal);
114            projections[base + t] = v;
115            norm_sq += v * v;
116        }
117        let inv_norm = 1.0 / norm_sq.sqrt();
118        for t in 0..m {
119            projections[base + t] *= inv_norm;
120        }
121    }
122
123    // Pre-compute and pre-sort reference projections (identical for every query curve)
124    let mut sorted_proj_ori = vec![0.0; nproj * nori];
125    for p_idx in 0..nproj {
126        let proj = &projections[p_idx * m..(p_idx + 1) * m];
127        let spo = &mut sorted_proj_ori[p_idx * nori..(p_idx + 1) * nori];
128        for j in 0..nori {
129            let mut dot = 0.0;
130            for t in 0..m {
131                dot += data_ori[(j, t)] * proj[t];
132            }
133            spo[j] = dot;
134        }
135        spo.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
136    }
137
138    let denom = nori as f64 + 1.0;
139
140    iter_maybe_parallel!(0..nobj)
141        .map(|i| {
142            let mut acc = init;
143
144            for p_idx in 0..nproj {
145                let proj = &projections[p_idx * m..(p_idx + 1) * m];
146                let sorted_ori = &sorted_proj_ori[p_idx * nori..(p_idx + 1) * nori];
147
148                let mut proj_i = 0.0;
149                for t in 0..m {
150                    proj_i += data_obj[(i, t)] * proj[t];
151                }
152
153                // O(log N) rank lookup via binary search
154                let below = sorted_ori.partition_point(|&v| v < proj_i);
155                let above = nori - sorted_ori.partition_point(|&v| v <= proj_i);
156                let depth = (below.min(above) as f64 + 1.0) / denom;
157
158                acc = aggregate(acc, depth);
159            }
160
161            finalize(acc, nproj)
162        })
163        .collect()
164}
165
166/// Compute random projection depth for 1D functional data.
167///
168/// Projects curves to scalars using random projections and computes
169/// average univariate depth.
170///
171/// # Arguments
172/// * `data_obj` - Data to compute depth for
173/// * `data_ori` - Reference data
174/// * `nproj` - Number of random projections
175pub fn random_projection_1d(data_obj: &FdMatrix, data_ori: &FdMatrix, nproj: usize) -> Vec<f64> {
176    random_depth_core(
177        data_obj,
178        data_ori,
179        nproj,
180        0.0,
181        |acc, d| acc + d,
182        |acc, n| acc / n as f64,
183    )
184}
185
186/// Compute random projection depth for 2D functional data.
187pub fn random_projection_2d(data_obj: &FdMatrix, data_ori: &FdMatrix, nproj: usize) -> Vec<f64> {
188    random_projection_1d(data_obj, data_ori, nproj)
189}
190
191/// Compute random Tukey depth for 1D functional data.
192///
193/// Takes the minimum over all random projections (more conservative than RP depth).
194pub fn random_tukey_1d(data_obj: &FdMatrix, data_ori: &FdMatrix, nproj: usize) -> Vec<f64> {
195    random_depth_core(
196        data_obj,
197        data_ori,
198        nproj,
199        f64::INFINITY,
200        f64::min,
201        |acc, _| acc,
202    )
203}
204
205/// Compute random Tukey depth for 2D functional data.
206pub fn random_tukey_2d(data_obj: &FdMatrix, data_ori: &FdMatrix, nproj: usize) -> Vec<f64> {
207    random_tukey_1d(data_obj, data_ori, nproj)
208}
209
210/// Compute Functional Spatial Depth for 1D functional data.
211pub fn functional_spatial_1d(data_obj: &FdMatrix, data_ori: &FdMatrix) -> Vec<f64> {
212    let nobj = data_obj.nrows();
213    let nori = data_ori.nrows();
214    let n_points = data_obj.ncols();
215
216    if nobj == 0 || nori == 0 || n_points == 0 {
217        return Vec::new();
218    }
219
220    iter_maybe_parallel!(0..nobj)
221        .map(|i| {
222            let mut sum_unit = vec![0.0; n_points];
223
224            for j in 0..nori {
225                // First pass: compute norm without allocating a direction buffer
226                let mut norm_sq = 0.0;
227                for t in 0..n_points {
228                    let d = data_ori[(j, t)] - data_obj[(i, t)];
229                    norm_sq += d * d;
230                }
231
232                let norm = norm_sq.sqrt();
233                if norm > 1e-10 {
234                    // Second pass: accumulate unit-vector sum using the known norm
235                    let inv_norm = 1.0 / norm;
236                    for t in 0..n_points {
237                        sum_unit[t] += (data_ori[(j, t)] - data_obj[(i, t)]) * inv_norm;
238                    }
239                }
240            }
241
242            let mut avg_norm_sq = 0.0;
243            for t in 0..n_points {
244                let avg = sum_unit[t] / nori as f64;
245                avg_norm_sq += avg * avg;
246            }
247
248            1.0 - avg_norm_sq.sqrt()
249        })
250        .collect()
251}
252
253/// Compute Functional Spatial Depth for 2D functional data.
254pub fn functional_spatial_2d(data_obj: &FdMatrix, data_ori: &FdMatrix) -> Vec<f64> {
255    functional_spatial_1d(data_obj, data_ori)
256}
257
258/// Compute kernel distance contribution for a single (j,k) pair.
259fn kernel_pair_contribution(j: usize, k: usize, m1: &FdMatrix, m2: &[f64]) -> Option<f64> {
260    let denom_j_sq = 2.0 - 2.0 * m2[j];
261    if denom_j_sq < 1e-20 {
262        return None;
263    }
264    let denom_k_sq = 2.0 - 2.0 * m2[k];
265    if denom_k_sq < 1e-20 {
266        return None;
267    }
268    let denom = denom_j_sq.sqrt() * denom_k_sq.sqrt();
269    if denom <= 1e-20 {
270        return None;
271    }
272    let m_ijk = (1.0 + m1[(j, k)] - m2[j] - m2[k]) / denom;
273    if m_ijk.is_finite() {
274        Some(m_ijk)
275    } else {
276        None
277    }
278}
279
280/// Accumulate the kernel spatial depth statistic for a single observation.
281/// Returns (total_sum, valid_count) from the double sum over reference pairs.
282///
283/// Exploits symmetry: `kernel_pair_contribution(j, k, m1, m2) == kernel_pair_contribution(k, j, m1, m2)`
284/// since m1 is symmetric and the formula `(1 + m1[j][k] - m2[j] - m2[k]) / (sqrt(2-2*m2[j]) * sqrt(2-2*m2[k]))`
285/// is symmetric in j and k. Loops over the upper triangle only.
286fn kfsd_accumulate(m2: &[f64], m1: &FdMatrix, nori: usize) -> (f64, usize) {
287    let mut total_sum = 0.0;
288    let mut valid_count = 0;
289
290    // Diagonal contributions (j == k)
291    for j in 0..nori {
292        if let Some(val) = kernel_pair_contribution(j, j, m1, m2) {
293            total_sum += val;
294            valid_count += 1;
295        }
296    }
297
298    // Upper triangle contributions (j < k), counted twice by symmetry
299    for j in 0..nori {
300        for k in (j + 1)..nori {
301            if let Some(val) = kernel_pair_contribution(j, k, m1, m2) {
302                total_sum += 2.0 * val;
303                valid_count += 2;
304            }
305        }
306    }
307
308    (total_sum, valid_count)
309}
310
311/// Shared implementation for kernel functional spatial depth.
312/// Uses weighted L2 norm: sum_t weights[t] * (f(t) - g(t))^2.
313fn kfsd_weighted(data_obj: &FdMatrix, data_ori: &FdMatrix, h: f64, weights: &[f64]) -> Vec<f64> {
314    let nobj = data_obj.nrows();
315    let nori = data_ori.nrows();
316    let n_points = data_obj.ncols();
317    let h_sq = h * h;
318
319    // Pre-compute M1[j,k] = K(X_j, X_k) for reference data
320    let m1_upper: Vec<(usize, usize, f64)> = iter_maybe_parallel!(0..nori)
321        .flat_map(|j| {
322            ((j + 1)..nori)
323                .map(|k| {
324                    let mut sum = 0.0;
325                    for t in 0..n_points {
326                        let diff = data_ori[(j, t)] - data_ori[(k, t)];
327                        sum += weights[t] * diff * diff;
328                    }
329                    (j, k, (-sum / h_sq).exp())
330                })
331                .collect::<Vec<_>>()
332        })
333        .collect();
334
335    let mut m1 = FdMatrix::zeros(nori, nori);
336    for j in 0..nori {
337        m1[(j, j)] = 1.0;
338    }
339    for (j, k, kval) in m1_upper {
340        m1[(j, k)] = kval;
341        m1[(k, j)] = kval;
342    }
343
344    let nori_f64 = nori as f64;
345
346    iter_maybe_parallel!(0..nobj)
347        .map(|i| {
348            let m2: Vec<f64> = (0..nori)
349                .map(|j| {
350                    let mut sum = 0.0;
351                    for t in 0..n_points {
352                        let diff = data_obj[(i, t)] - data_ori[(j, t)];
353                        sum += weights[t] * diff * diff;
354                    }
355                    (-sum / h_sq).exp()
356                })
357                .collect();
358
359            let (total_sum, valid_count) = kfsd_accumulate(&m2, &m1, nori);
360
361            if valid_count > 0 && total_sum >= 0.0 {
362                1.0 - total_sum.sqrt() / nori_f64
363            } else if total_sum < 0.0 {
364                1.0
365            } else {
366                0.0
367            }
368        })
369        .collect()
370}
371
372
373/// Compute Kernel Functional Spatial Depth (KFSD) for 1D functional data.
374///
375/// Implements the RKHS-based formulation.
376pub fn kernel_functional_spatial_1d(
377    data_obj: &FdMatrix,
378    data_ori: &FdMatrix,
379    argvals: &[f64],
380    h: f64,
381) -> Vec<f64> {
382    let nobj = data_obj.nrows();
383    let nori = data_ori.nrows();
384    let n_points = data_obj.ncols();
385
386    if nobj == 0 || nori == 0 || n_points == 0 {
387        return Vec::new();
388    }
389
390    let weights = simpsons_weights(argvals);
391    kfsd_weighted(data_obj, data_ori, h, &weights)
392}
393
394/// Compute Kernel Functional Spatial Depth (KFSD) for 2D functional data.
395pub fn kernel_functional_spatial_2d(data_obj: &FdMatrix, data_ori: &FdMatrix, h: f64) -> Vec<f64> {
396    let nobj = data_obj.nrows();
397    let nori = data_ori.nrows();
398    let n_points = data_obj.ncols();
399
400    if nobj == 0 || nori == 0 || n_points == 0 {
401        return Vec::new();
402    }
403
404    let weights = vec![1.0; n_points];
405    kfsd_weighted(data_obj, data_ori, h, &weights)
406}
407
408/// Compute Band Depth (BD) for 1D functional data.
409///
410/// BD(x) = proportion of pairs (i,j) where x lies within the band formed by curves i and j.
411pub fn band_1d(data_obj: &FdMatrix, data_ori: &FdMatrix) -> Vec<f64> {
412    if data_obj.nrows() == 0 || data_ori.nrows() < 2 || data_obj.ncols() == 0 {
413        return Vec::new();
414    }
415    let state = FullReferenceState::from_reference(data_ori);
416    let streaming = StreamingBd::new(state);
417    streaming.depth_batch(data_obj)
418}
419
420/// Compute Modified Band Depth (MBD) for 1D functional data.
421///
422/// MBD(x) = average over pairs of the proportion of the domain where x is inside the band.
423pub fn modified_band_1d(data_obj: &FdMatrix, data_ori: &FdMatrix) -> Vec<f64> {
424    if data_obj.nrows() == 0 || data_ori.nrows() < 2 || data_obj.ncols() == 0 {
425        return Vec::new();
426    }
427    let state = SortedReferenceState::from_reference(data_ori);
428    let streaming = StreamingMbd::new(state);
429    streaming.depth_batch(data_obj)
430}
431
432/// Compute Modified Epigraph Index (MEI) for 1D functional data.
433///
434/// MEI measures the proportion of time a curve is below other curves.
435pub fn modified_epigraph_index_1d(data_obj: &FdMatrix, data_ori: &FdMatrix) -> Vec<f64> {
436    let nobj = data_obj.nrows();
437    let nori = data_ori.nrows();
438    let n_points = data_obj.ncols();
439
440    if nobj == 0 || nori == 0 || n_points == 0 {
441        return Vec::new();
442    }
443
444    iter_maybe_parallel!(0..nobj)
445        .map(|i| {
446            let mut total = 0.0;
447
448            for j in 0..nori {
449                let mut count = 0.0;
450
451                for t in 0..n_points {
452                    let xi = data_obj[(i, t)];
453                    let xj = data_ori[(j, t)];
454
455                    if xi < xj {
456                        count += 1.0;
457                    } else if (xi - xj).abs() < 1e-12 {
458                        count += 0.5;
459                    }
460                }
461
462                total += count / n_points as f64;
463            }
464
465            total / nori as f64
466        })
467        .collect()
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use std::f64::consts::PI;
474
475    fn uniform_grid(n: usize) -> Vec<f64> {
476        (0..n).map(|i| i as f64 / (n - 1) as f64).collect()
477    }
478
479    fn generate_centered_data(n: usize, m: usize) -> FdMatrix {
480        let argvals = uniform_grid(m);
481        let mut data = vec![0.0; n * m];
482        for i in 0..n {
483            let offset = (i as f64 - n as f64 / 2.0) / (n as f64);
484            for j in 0..m {
485                data[i + j * n] = (2.0 * PI * argvals[j]).sin() + offset;
486            }
487        }
488        FdMatrix::from_column_major(data, n, m).unwrap()
489    }
490
491    // ============== Fraiman-Muniz tests ==============
492
493    #[test]
494    fn test_fraiman_muniz() {
495        // Simple test: identical data should give maximum depth
496        let data = FdMatrix::from_column_major(vec![1.0, 1.0, 2.0, 2.0], 2, 2).unwrap(); // 2 identical curves, 2 points each
497        let depths = fraiman_muniz_1d(&data, &data, true);
498        assert_eq!(depths.len(), 2);
499    }
500
501    #[test]
502    fn test_fraiman_muniz_central_deeper() {
503        let n = 20;
504        let m = 30;
505        let data = generate_centered_data(n, m);
506        let depths = fraiman_muniz_1d(&data, &data, true);
507
508        // Central curve (index n/2) should have higher depth than extreme curves
509        let central_depth = depths[n / 2];
510        let edge_depth = depths[0];
511        assert!(
512            central_depth > edge_depth,
513            "Central curve should be deeper: {} > {}",
514            central_depth,
515            edge_depth
516        );
517    }
518
519    #[test]
520    fn test_fraiman_muniz_range() {
521        let n = 15;
522        let m = 20;
523        let data = generate_centered_data(n, m);
524        let depths = fraiman_muniz_1d(&data, &data, true);
525
526        for d in &depths {
527            assert!(*d >= 0.0 && *d <= 1.0, "Depth should be in [0, 1]");
528        }
529    }
530
531    #[test]
532    fn test_fraiman_muniz_invalid() {
533        let empty = FdMatrix::zeros(0, 0);
534        assert!(fraiman_muniz_1d(&empty, &empty, true).is_empty());
535    }
536
537    // ============== Modal depth tests ==============
538
539    #[test]
540    fn test_modal_central_deeper() {
541        let n = 20;
542        let m = 30;
543        let data = generate_centered_data(n, m);
544        let depths = modal_1d(&data, &data, 0.5);
545
546        let central_depth = depths[n / 2];
547        let edge_depth = depths[0];
548        assert!(central_depth > edge_depth, "Central curve should be deeper");
549    }
550
551    #[test]
552    fn test_modal_positive() {
553        let n = 10;
554        let m = 20;
555        let data = generate_centered_data(n, m);
556        let depths = modal_1d(&data, &data, 0.5);
557
558        for d in &depths {
559            assert!(*d > 0.0, "Modal depth should be positive");
560        }
561    }
562
563    #[test]
564    fn test_modal_invalid() {
565        let empty = FdMatrix::zeros(0, 0);
566        assert!(modal_1d(&empty, &empty, 0.5).is_empty());
567    }
568
569    // ============== Random projection depth tests ==============
570
571    #[test]
572    fn test_rp_depth_range() {
573        let n = 15;
574        let m = 20;
575        let data = generate_centered_data(n, m);
576        let depths = random_projection_1d(&data, &data, 50);
577
578        for d in &depths {
579            assert!(*d >= 0.0 && *d <= 1.0, "RP depth should be in [0, 1]");
580        }
581    }
582
583    #[test]
584    fn test_rp_depth_invalid() {
585        let empty = FdMatrix::zeros(0, 0);
586        assert!(random_projection_1d(&empty, &empty, 10).is_empty());
587    }
588
589    // ============== Random Tukey depth tests ==============
590
591    #[test]
592    fn test_random_tukey_range() {
593        let n = 15;
594        let m = 20;
595        let data = generate_centered_data(n, m);
596        let depths = random_tukey_1d(&data, &data, 50);
597
598        for d in &depths {
599            assert!(*d >= 0.0 && *d <= 1.0, "Tukey depth should be in [0, 1]");
600        }
601    }
602
603    #[test]
604    fn test_random_tukey_invalid() {
605        let empty = FdMatrix::zeros(0, 0);
606        assert!(random_tukey_1d(&empty, &empty, 10).is_empty());
607    }
608
609    // ============== Functional spatial depth tests ==============
610
611    #[test]
612    fn test_functional_spatial_range() {
613        let n = 15;
614        let m = 20;
615        let data = generate_centered_data(n, m);
616        let depths = functional_spatial_1d(&data, &data);
617
618        for d in &depths {
619            assert!(*d >= 0.0 && *d <= 1.0, "Spatial depth should be in [0, 1]");
620        }
621    }
622
623    #[test]
624    fn test_functional_spatial_invalid() {
625        let empty = FdMatrix::zeros(0, 0);
626        assert!(functional_spatial_1d(&empty, &empty).is_empty());
627    }
628
629    // ============== Band depth tests ==============
630
631    #[test]
632    fn test_band_depth_central_deeper() {
633        let n = 10;
634        let m = 20;
635        let data = generate_centered_data(n, m);
636        let depths = band_1d(&data, &data);
637
638        // Central curve should be in more bands
639        let central_depth = depths[n / 2];
640        let edge_depth = depths[0];
641        assert!(
642            central_depth >= edge_depth,
643            "Central curve should have higher band depth"
644        );
645    }
646
647    #[test]
648    fn test_band_depth_range() {
649        let n = 10;
650        let m = 20;
651        let data = generate_centered_data(n, m);
652        let depths = band_1d(&data, &data);
653
654        for d in &depths {
655            assert!(*d >= 0.0 && *d <= 1.0, "Band depth should be in [0, 1]");
656        }
657    }
658
659    #[test]
660    fn test_band_depth_invalid() {
661        let empty = FdMatrix::zeros(0, 0);
662        assert!(band_1d(&empty, &empty).is_empty());
663        let single = FdMatrix::from_column_major(vec![1.0, 2.0], 1, 2).unwrap();
664        assert!(band_1d(&single, &single).is_empty()); // need at least 2 ref curves
665    }
666
667    // ============== Modified band depth tests ==============
668
669    #[test]
670    fn test_modified_band_depth_range() {
671        let n = 10;
672        let m = 20;
673        let data = generate_centered_data(n, m);
674        let depths = modified_band_1d(&data, &data);
675
676        for d in &depths {
677            assert!(*d >= 0.0 && *d <= 1.0, "MBD should be in [0, 1]");
678        }
679    }
680
681    #[test]
682    fn test_modified_band_depth_invalid() {
683        let empty = FdMatrix::zeros(0, 0);
684        assert!(modified_band_1d(&empty, &empty).is_empty());
685    }
686
687    // ============== Modified epigraph index tests ==============
688
689    #[test]
690    fn test_mei_range() {
691        let n = 15;
692        let m = 20;
693        let data = generate_centered_data(n, m);
694        let mei = modified_epigraph_index_1d(&data, &data);
695
696        for d in &mei {
697            assert!(*d >= 0.0 && *d <= 1.0, "MEI should be in [0, 1]");
698        }
699    }
700
701    #[test]
702    fn test_mei_invalid() {
703        let empty = FdMatrix::zeros(0, 0);
704        assert!(modified_epigraph_index_1d(&empty, &empty).is_empty());
705    }
706
707    // ============== KFSD 1D tests ==============
708
709    #[test]
710    fn test_kfsd_1d_range() {
711        let n = 10;
712        let m = 20;
713        let argvals = uniform_grid(m);
714        let data = generate_centered_data(n, m);
715        let depths = kernel_functional_spatial_1d(&data, &data, &argvals, 0.5);
716
717        assert_eq!(depths.len(), n);
718        for d in &depths {
719            assert!(
720                *d >= -0.01 && *d <= 1.01,
721                "KFSD depth should be near [0, 1], got {}",
722                d
723            );
724            assert!(d.is_finite(), "KFSD depth should be finite");
725        }
726
727        // Central curve should have higher depth
728        let central_depth = depths[n / 2];
729        let edge_depth = depths[0];
730        assert!(
731            central_depth > edge_depth,
732            "Central KFSD depth {} should be > edge depth {}",
733            central_depth,
734            edge_depth
735        );
736    }
737
738    #[test]
739    fn test_kfsd_1d_identical() {
740        // All identical curves should exercise the denom_j_sq < 1e-20 path
741        let n = 5;
742        let m = 10;
743        let argvals = uniform_grid(m);
744        let data_vec: Vec<f64> = (0..n * m)
745            .map(|i| (2.0 * PI * (i % m) as f64 / (m - 1) as f64).sin())
746            .collect();
747        let data = FdMatrix::from_column_major(data_vec, n, m).unwrap();
748
749        // When all curves are identical, kernel distances are all 1.0
750        // and denom_j_sq = K(x,x) + K(y,y) - 2*K(x,y) = 1 + 1 - 2*1 = 0
751        let depths = kernel_functional_spatial_1d(&data, &data, &argvals, 0.5);
752
753        assert_eq!(depths.len(), n);
754        for d in &depths {
755            assert!(
756                d.is_finite(),
757                "KFSD depth should be finite for identical curves"
758            );
759        }
760    }
761
762    #[test]
763    fn test_kfsd_1d_invalid() {
764        let argvals = uniform_grid(10);
765        let empty = FdMatrix::zeros(0, 0);
766        assert!(kernel_functional_spatial_1d(&empty, &empty, &argvals, 0.5).is_empty());
767        let empty_obj = FdMatrix::zeros(0, 0);
768        assert!(kernel_functional_spatial_1d(&empty_obj, &empty_obj, &argvals, 0.5).is_empty());
769    }
770
771    // ============== KFSD 2D tests ==============
772
773    #[test]
774    fn test_kfsd_2d_range() {
775        let n = 8;
776        let m = 15;
777        let data = generate_centered_data(n, m);
778        let depths = kernel_functional_spatial_2d(&data, &data, 0.5);
779
780        assert_eq!(depths.len(), n);
781        for d in &depths {
782            assert!(
783                *d >= -0.01 && *d <= 1.01,
784                "KFSD 2D depth should be near [0, 1], got {}",
785                d
786            );
787            assert!(d.is_finite(), "KFSD 2D depth should be finite");
788        }
789    }
790
791    // ============== 2D delegation tests ==============
792
793    #[test]
794    fn test_fraiman_muniz_2d_delegates() {
795        let n = 10;
796        let m = 15;
797        let data = generate_centered_data(n, m);
798        let depths_1d = fraiman_muniz_1d(&data, &data, true);
799        let depths_2d = fraiman_muniz_2d(&data, &data, true);
800        assert_eq!(depths_1d, depths_2d);
801    }
802
803    #[test]
804    fn test_modal_2d_delegates() {
805        let n = 10;
806        let m = 15;
807        let data = generate_centered_data(n, m);
808        let depths_1d = modal_1d(&data, &data, 0.5);
809        let depths_2d = modal_2d(&data, &data, 0.5);
810        assert_eq!(depths_1d, depths_2d);
811    }
812
813    #[test]
814    fn test_functional_spatial_2d_delegates() {
815        let n = 10;
816        let m = 15;
817        let data = generate_centered_data(n, m);
818        let depths_1d = functional_spatial_1d(&data, &data);
819        let depths_2d = functional_spatial_2d(&data, &data);
820        assert_eq!(depths_1d, depths_2d);
821    }
822
823    #[test]
824    fn test_random_projection_2d_returns_valid() {
825        let n = 10;
826        let m = 15;
827        let data = generate_centered_data(n, m);
828        let depths = random_projection_2d(&data, &data, 20);
829        assert_eq!(depths.len(), n);
830        for d in &depths {
831            assert!(*d >= 0.0 && *d <= 1.0, "RP 2D depth should be in [0, 1]");
832        }
833    }
834
835    // ============== Golden-value regression tests ==============
836
837    /// Fixed small dataset: 5 curves, 10 time points (deterministic)
838    fn golden_data() -> FdMatrix {
839        let n = 5;
840        let m = 10;
841        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
842        let mut data = vec![0.0; n * m];
843        for i in 0..n {
844            let offset = (i as f64 - n as f64 / 2.0) / (n as f64);
845            for j in 0..m {
846                data[i + j * n] = (2.0 * PI * argvals[j]).sin() + offset;
847            }
848        }
849        FdMatrix::from_column_major(data, n, m).unwrap()
850    }
851
852    #[test]
853    fn test_fm_golden_values_scaled() {
854        let data = golden_data();
855        let depths = fraiman_muniz_1d(&data, &data, true);
856        let expected = [0.4, 0.8, 0.8, 0.4, 0.0];
857        assert_eq!(depths.len(), expected.len());
858        for (d, e) in depths.iter().zip(expected.iter()) {
859            assert!(
860                (d - e).abs() < 1e-10,
861                "FM scaled golden mismatch: got {}, expected {}",
862                d,
863                e
864            );
865        }
866    }
867
868    #[test]
869    fn test_fm_golden_values_unscaled() {
870        let data = golden_data();
871        let depths = fraiman_muniz_1d(&data, &data, false);
872        let expected = [0.2, 0.4, 0.4, 0.2, 0.0];
873        assert_eq!(depths.len(), expected.len());
874        for (d, e) in depths.iter().zip(expected.iter()) {
875            assert!(
876                (d - e).abs() < 1e-10,
877                "FM unscaled golden mismatch: got {}, expected {}",
878                d,
879                e
880            );
881        }
882    }
883
884    #[test]
885    fn test_mbd_golden_values() {
886        let data = golden_data();
887        let depths = modified_band_1d(&data, &data);
888        let expected = [0.4, 0.7, 0.8, 0.7, 0.4];
889        assert_eq!(depths.len(), expected.len());
890        for (d, e) in depths.iter().zip(expected.iter()) {
891            assert!(
892                (d - e).abs() < 1e-10,
893                "MBD golden mismatch: got {}, expected {}",
894                d,
895                e
896            );
897        }
898    }
899}