Skip to main content

fdars_core/tolerance/
conformal_anomaly.rs

1//! Inductive conformal anomaly detection for functional data using elastic distances.
2//!
3//! This module implements an inductive (split) conformal anomaly detector that scores
4//! functional curves against a reference template using elastic distances. Curves whose
5//! conformal p-value falls at or below a significance level `alpha` are flagged as
6//! anomalies.
7//!
8//! # Overview
9//!
10//! 1. **Calibration**: compute elastic nonconformity scores for each calibration curve
11//!    against a shared template (default: Karcher mean of the calibration set).
12//! 2. **Threshold**: derive the `(1-alpha)` empirical quantile of calibration scores.
13//! 3. **Scoring**: for each test curve, compute its nonconformity score and the
14//!    conformal p-value `(1 + #{calib_score >= test_score}) / (n_calib + 1)`.
15//! 4. **Flagging**: flag a curve as anomalous when `p_value <= alpha`.
16//!
17//! # Variants
18//!
19//! - [`NonConformityScore::AmplitudeElastic`]: flags magnitude (amplitude) outliers
20//! - [`NonConformityScore::PhaseElastic`]: flags shape (timing/phase) outliers
21//! - [`NonConformityScore::CombinedElastic`]: flags both (default)
22//!
23//! # Example
24//!
25//! ```
26//! use fdars_core::simulation::{sim_fundata, EFunType, EValType};
27//! use fdars_core::tolerance::{
28//!     elastic_conformal_anomaly, ConformalAnomalyConfig, NonConformityScore,
29//! };
30//!
31//! let t: Vec<f64> = (0..50).map(|i| i as f64 / 49.0).collect();
32//!
33//! // Build a calibration set of clean curves
34//! let calibration = sim_fundata(30, &t, 3, EFunType::Fourier, EValType::Exponential, Some(42));
35//! // Build a test set of clean curves (exchangeable with calibration)
36//! let test = sim_fundata(20, &t, 3, EFunType::Fourier, EValType::Exponential, Some(99));
37//!
38//! let mut config = ConformalAnomalyConfig::default();
39//! config.variant = NonConformityScore::CombinedElastic;
40//! config.alpha = 0.1;
41//!
42//! let result = elastic_conformal_anomaly(&calibration, &test, &t, &config).unwrap();
43//! assert_eq!(result.p_values.len(), 20);
44//! assert_eq!(result.flags.len(), 20);
45//! assert!(result.threshold >= 0.0);
46//! // On clean exchangeable data, flag rate should be approximately alpha
47//! let flag_rate = result.flags.iter().filter(|&&f| f).count() as f64 / 20.0;
48//! assert!(flag_rate <= 0.4, "Flag rate {} too high for alpha=0.1", flag_rate);
49//! ```
50
51use super::NonConformityScore;
52use crate::error::FdarError;
53use crate::matrix::FdMatrix;
54
55// ─── Configuration ────────────────────────────────────────────────────────────
56
57/// Configuration for [`elastic_conformal_anomaly`].
58///
59/// Controls the elastic distance variant, significance level, warp penalty,
60/// Karcher mean computation parameters, and an optional pre-computed template.
61#[derive(Debug, Clone, PartialEq)]
62#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
63#[non_exhaustive]
64pub struct ConformalAnomalyConfig {
65    /// Non-conformity score variant. Must be an elastic variant
66    /// ([`AmplitudeElastic`], [`PhaseElastic`], or [`CombinedElastic`]).
67    ///
68    /// [`AmplitudeElastic`]: NonConformityScore::AmplitudeElastic
69    /// [`PhaseElastic`]: NonConformityScore::PhaseElastic
70    /// [`CombinedElastic`]: NonConformityScore::CombinedElastic
71    pub variant: NonConformityScore,
72    /// Miscoverage level — flag a test curve when `p_value <= alpha` (default: 0.1).
73    pub alpha: f64,
74    /// Warp penalty passed to the elastic distance functions (default: 0.0 = no penalty).
75    pub lambda: f64,
76    /// Maximum iterations for Karcher mean convergence (used when `template` is `None`; default: 20).
77    pub max_iter: usize,
78    /// Convergence tolerance for Karcher mean (default: 1e-4).
79    pub tol: f64,
80    /// Optional pre-computed template curve of length `m`.
81    ///
82    /// When `None`, the Karcher mean of the calibration set is computed and used as the template.
83    pub template: Option<Vec<f64>>,
84}
85
86impl Default for ConformalAnomalyConfig {
87    fn default() -> Self {
88        Self {
89            variant: NonConformityScore::CombinedElastic,
90            alpha: 0.1,
91            lambda: 0.0,
92            max_iter: 20,
93            tol: 1e-4,
94            template: None,
95        }
96    }
97}
98
99// ─── Result ───────────────────────────────────────────────────────────────────
100
101/// Result of [`elastic_conformal_anomaly`].
102///
103/// Contains per-test-curve conformal p-values, nonconformity scores, boolean anomaly
104/// flags, and the calibrated threshold.
105#[derive(Debug, Clone, PartialEq)]
106#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
107#[non_exhaustive]
108pub struct ConformalAnomalyResult {
109    /// Conformal p-value for each test curve (length `n_test`).
110    ///
111    /// Computed as `(1 + #{calib_score >= test_score}) / (n_calib + 1)`.
112    pub p_values: Vec<f64>,
113    /// Elastic nonconformity score for each test curve (length `n_test`).
114    pub scores: Vec<f64>,
115    /// Boolean anomaly flag for each test curve (length `n_test`).
116    ///
117    /// `true` when `p_value <= alpha` (the curve is anomalous at level `alpha`).
118    pub flags: Vec<bool>,
119    /// Calibrated threshold: `(1-alpha)` quantile of calibration nonconformity scores.
120    ///
121    /// A test curve is flagged iff its score exceeds this threshold.
122    pub threshold: f64,
123}
124
125// ─── Public API ───────────────────────────────────────────────────────────────
126
127/// Compute elastic nonconformity score for a single curve against a template.
128///
129/// Scores the curve against the template using the specified elastic distance variant.
130/// Returns a non-negative value; near zero when the curve is identical to the template.
131///
132/// # Argument Order (load-bearing)
133///
134/// `curve` is the query curve (`f1`) and `template` is the reference (`f2`). The
135/// elastic alignment optimally warps `f2` (the template) onto `f1` (the curve).
136/// Keeping the test/calibration curve as `f1` ensures all scores are on the same
137/// scale relative to the fixed template. The zero-for-identical gate catches any
138/// argument-order flip.
139///
140/// # Arguments
141///
142/// * `curve`    — Curve to score (length `m`)
143/// * `template` — Reference/template curve (length `m`)
144/// * `argvals`  — Evaluation points (length `m`)
145/// * `lambda`   — Warp penalty (0.0 = no penalty)
146/// * `variant`  — Must be [`AmplitudeElastic`], [`PhaseElastic`], or [`CombinedElastic`]
147///
148/// # Errors
149///
150/// Returns [`FdarError::InvalidParameter`] if `variant` is [`SupNorm`] or [`L2`].
151///
152/// [`AmplitudeElastic`]: NonConformityScore::AmplitudeElastic
153/// [`PhaseElastic`]: NonConformityScore::PhaseElastic
154/// [`CombinedElastic`]: NonConformityScore::CombinedElastic
155/// [`SupNorm`]: NonConformityScore::SupNorm
156/// [`L2`]: NonConformityScore::L2
157#[must_use = "expensive computation: elastic_nonconformity runs an elastic alignment; use the score"]
158pub fn elastic_nonconformity(
159    curve: &[f64],
160    template: &[f64],
161    argvals: &[f64],
162    lambda: f64,
163    variant: NonConformityScore,
164) -> Result<f64, FdarError> {
165    match variant {
166        NonConformityScore::AmplitudeElastic => Ok(crate::alignment::amplitude_distance(
167            curve, template, argvals, lambda,
168        )),
169        NonConformityScore::PhaseElastic => Ok(crate::alignment::phase_distance_pair(
170            curve, template, argvals, lambda,
171        )),
172        NonConformityScore::CombinedElastic => {
173            // Genuine combination of amplitude and phase distances.
174            // amplitude_distance == elastic_distance (it delegates exactly), so
175            // CombinedElastic must NOT be a bare alias. Formula: sqrt(amp² + phase²).
176            let amp = crate::alignment::amplitude_distance(curve, template, argvals, lambda);
177            let ph = crate::alignment::phase_distance_pair(curve, template, argvals, lambda);
178            Ok((amp.powi(2) + ph.powi(2)).sqrt())
179        }
180        _ => Err(FdarError::InvalidParameter {
181            parameter: "variant",
182            message: "elastic_nonconformity requires an elastic NonConformityScore variant \
183                      (AmplitudeElastic, PhaseElastic, or CombinedElastic)"
184                .to_string(),
185        }),
186    }
187}
188
189/// Inductive conformal anomaly detection for functional data using elastic distances.
190///
191/// Calibrates elastic nonconformity scores on `calibration`, then for each test
192/// curve in `test` computes a conformal p-value and an anomaly flag at level `alpha`.
193///
194/// # Algorithm
195///
196/// 1. Resolve the template: use `config.template` if supplied; otherwise compute the
197///    Karcher mean of `calibration`.
198/// 2. Score each calibration curve against the template via [`elastic_nonconformity`].
199/// 3. Compute the calibrated threshold as the `(1-alpha)` empirical quantile of the
200///    calibration scores (using `ceil((n_calib+1)*(1-alpha))` order statistic).
201/// 4. For each test curve, compute score `a*` and p-value
202///    `(1 + #{calib_score >= a*}) / (n_calib + 1)`. Flag when `p_value <= alpha`.
203///
204/// # Arguments
205///
206/// * `calibration` — Reference/calibration functional data (`n_calib × m`)
207/// * `test`        — Test functional data to score (`n_test × m`)
208/// * `argvals`     — Evaluation points (length `m`)
209/// * `config`      — [`ConformalAnomalyConfig`]
210///
211/// # Returns
212///
213/// [`ConformalAnomalyResult`] with per-curve p-values, scores, flags, and the
214/// calibrated threshold.
215///
216/// # Errors
217///
218/// Returns [`FdarError::InvalidDimension`] if column counts do not match `argvals.len()`.
219/// Returns [`FdarError::InvalidParameter`] if:
220/// - `n_calib < 1`
221/// - `alpha` is not in `(0.0, 1.0)` exclusive
222/// - `config.variant` is not an elastic variant
223#[must_use = "expensive computation whose result should not be discarded"]
224pub fn elastic_conformal_anomaly(
225    calibration: &FdMatrix,
226    test: &FdMatrix,
227    argvals: &[f64],
228    config: &ConformalAnomalyConfig,
229) -> Result<ConformalAnomalyResult, FdarError> {
230    let m = argvals.len();
231    let (n_calib, m_calib) = calibration.shape();
232    let (n_test, m_test) = test.shape();
233
234    // ── Input validation ─────────────────────────────────────────────────────
235    if m_calib != m {
236        return Err(FdarError::InvalidDimension {
237            parameter: "calibration",
238            expected: format!("{m} columns (argvals.len())"),
239            actual: format!("{m_calib}"),
240        });
241    }
242    if m_test != m {
243        return Err(FdarError::InvalidDimension {
244            parameter: "test",
245            expected: format!("{m} columns (argvals.len())"),
246            actual: format!("{m_test}"),
247        });
248    }
249    if n_calib < 1 {
250        return Err(FdarError::InvalidParameter {
251            parameter: "calibration",
252            message: "calibration set must have at least one curve (n_calib >= 1)".to_string(),
253        });
254    }
255    if !(config.alpha > 0.0 && config.alpha < 1.0) {
256        return Err(FdarError::InvalidParameter {
257            parameter: "alpha",
258            message: format!(
259                "alpha must be in (0.0, 1.0) exclusive, got {}",
260                config.alpha
261            ),
262        });
263    }
264    // Validate variant is elastic
265    match config.variant {
266        NonConformityScore::AmplitudeElastic
267        | NonConformityScore::PhaseElastic
268        | NonConformityScore::CombinedElastic => {}
269        _ => {
270            return Err(FdarError::InvalidParameter {
271                parameter: "config.variant",
272                message: "elastic_conformal_anomaly requires an elastic NonConformityScore \
273                          variant (AmplitudeElastic, PhaseElastic, or CombinedElastic)"
274                    .to_string(),
275            });
276        }
277    }
278
279    // ── Template resolution ───────────────────────────────────────────────────
280    let template: Vec<f64> = match config.template.clone() {
281        Some(t) => {
282            // A caller-supplied template MUST match the evaluation grid — otherwise
283            // srsf_transform silently returns a zero matrix and every score is wrong.
284            if t.len() != m {
285                return Err(FdarError::InvalidDimension {
286                    parameter: "config.template",
287                    expected: format!("{m} elements (argvals.len())"),
288                    actual: format!("{}", t.len()),
289                });
290            }
291            t
292        }
293        None => {
294            let km = crate::alignment::karcher_mean(
295                calibration,
296                argvals,
297                config.max_iter,
298                config.tol,
299                config.lambda,
300            );
301            km.mean
302        }
303    };
304
305    // ── Calibration scoring ───────────────────────────────────────────────────
306    // Propagate any scoring error explicitly — a silent NaN fallback would poison
307    // the threshold (NaN) and suppress every anomaly flag with no error signal.
308    let calib_scores: Vec<f64> = (0..n_calib)
309        .map(|i| {
310            let curve = calibration.row(i);
311            elastic_nonconformity(&curve, &template, argvals, config.lambda, config.variant)
312        })
313        .collect::<Result<Vec<f64>, FdarError>>()?;
314
315    // ── Threshold ─────────────────────────────────────────────────────────────
316    let mut sorted_calib = calib_scores.clone();
317    crate::helpers::sort_nan_safe(&mut sorted_calib);
318    let threshold = calibrated_threshold(&sorted_calib, config.alpha);
319
320    // ── Test scoring ──────────────────────────────────────────────────────────
321    let mut p_values = Vec::with_capacity(n_test);
322    let mut scores = Vec::with_capacity(n_test);
323    let mut flags = Vec::with_capacity(n_test);
324
325    for j in 0..n_test {
326        let curve = test.row(j);
327        let a_star =
328            elastic_nonconformity(&curve, &template, argvals, config.lambda, config.variant)?;
329
330        // p-value: (1 + #{calib_score >= a_star}) / (n_calib + 1)
331        let count = calib_scores.iter().filter(|&&a| a >= a_star).count();
332        let p_value = (1 + count) as f64 / (n_calib + 1) as f64;
333        let flag = p_value <= config.alpha;
334
335        scores.push(a_star);
336        p_values.push(p_value);
337        flags.push(flag);
338    }
339
340    Ok(ConformalAnomalyResult {
341        p_values,
342        scores,
343        flags,
344        threshold,
345    })
346}
347
348// ─── Private Helpers ──────────────────────────────────────────────────────────
349
350/// Compute the calibrated threshold from sorted calibration scores.
351///
352/// Returns the `(1-alpha)` order-statistic: the `k`-th smallest value where
353/// `k = ceil((n+1) * (1-alpha))`. Returns `f64::INFINITY` when `k > n`
354/// (insufficient calibration data to form a threshold at level `alpha`).
355fn calibrated_threshold(sorted_scores: &[f64], alpha: f64) -> f64 {
356    let n = sorted_scores.len();
357    let k = ((n + 1) as f64 * (1.0 - alpha)).ceil() as usize;
358    if k > n {
359        f64::INFINITY
360    } else {
361        sorted_scores[k.saturating_sub(1)]
362    }
363}
364
365// ─── Tests ────────────────────────────────────────────────────────────────────
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use crate::simulation::{sim_fundata, EFunType, EValType};
371
372    /// Build a uniform grid on [0, 1] of length `n`.
373    fn uniform_grid(n: usize) -> Vec<f64> {
374        (0..n).map(|i| i as f64 / (n - 1) as f64).collect()
375    }
376
377    /// Build a smooth sinusoid curve on the given grid.
378    fn sinusoid(argvals: &[f64]) -> Vec<f64> {
379        argvals
380            .iter()
381            .map(|&t| (t * 6.0 * std::f64::consts::PI).sin())
382            .collect()
383    }
384
385    // ── ECA-01: elastic_nonconformity ─────────────────────────────────────────
386
387    #[test]
388    fn test_elastic_nonconformity_self_near_zero() {
389        let t = uniform_grid(50);
390        let curve = sinusoid(&t);
391        for variant in [
392            NonConformityScore::AmplitudeElastic,
393            NonConformityScore::PhaseElastic,
394            NonConformityScore::CombinedElastic,
395        ] {
396            let score = elastic_nonconformity(&curve, &curve, &t, 0.0, variant).unwrap();
397            assert!(
398                score < 1e-4,
399                "Self-score for {:?} should be near zero, got {score}",
400                variant
401            );
402        }
403    }
404
405    #[test]
406    fn test_elastic_nonconformity_nonneg() {
407        let t = uniform_grid(50);
408        let curve = sinusoid(&t);
409        // A distinct curve (cosine)
410        let other: Vec<f64> = t
411            .iter()
412            .map(|&x| (x * 6.0 * std::f64::consts::PI).cos())
413            .collect();
414        for variant in [
415            NonConformityScore::AmplitudeElastic,
416            NonConformityScore::PhaseElastic,
417            NonConformityScore::CombinedElastic,
418        ] {
419            let score = elastic_nonconformity(&curve, &other, &t, 0.0, variant).unwrap();
420            assert!(
421                score >= 0.0,
422                "Non-negativity violated for {:?}: got {score}",
423                variant
424            );
425        }
426    }
427
428    #[test]
429    fn test_elastic_nonconformity_invalid_variant() {
430        let t = uniform_grid(50);
431        let curve = sinusoid(&t);
432        for variant in [NonConformityScore::SupNorm, NonConformityScore::L2] {
433            let result = elastic_nonconformity(&curve, &curve, &t, 0.0, variant);
434            assert!(
435                matches!(result, Err(FdarError::InvalidParameter { .. })),
436                "Expected InvalidParameter for {:?}, got {:?}",
437                variant,
438                result
439            );
440        }
441    }
442
443    #[test]
444    fn test_conformal_anomaly_rejects_mismatched_template() {
445        // A caller-supplied template of the wrong length must be rejected up front,
446        // not silently zero-scored (CR-01 regression guard).
447        let t = uniform_grid(50);
448        let calibration = sim_fundata(20, &t, 3, EFunType::Fourier, EValType::Exponential, Some(1));
449        let test_data = sim_fundata(5, &t, 3, EFunType::Fourier, EValType::Exponential, Some(2));
450        let config = ConformalAnomalyConfig {
451            variant: NonConformityScore::CombinedElastic,
452            alpha: 0.1,
453            template: Some(uniform_grid(40)), // wrong length (40 != 50)
454            ..Default::default()
455        };
456        let result = elastic_conformal_anomaly(&calibration, &test_data, &t, &config);
457        assert!(
458            matches!(result, Err(FdarError::InvalidDimension { .. })),
459            "Expected InvalidDimension for mismatched template, got {result:?}"
460        );
461    }
462
463    #[test]
464    fn test_elastic_nonconformity_combined_not_alias() {
465        // Scaled curve differs in amplitude only; for a pure amplitude outlier,
466        // AmplitudeElastic > 0, and CombinedElastic >= AmplitudeElastic (not an alias of amplitude).
467        let t = uniform_grid(50);
468        let template = sinusoid(&t);
469        // Scale by 5x to create a large amplitude outlier
470        let scaled: Vec<f64> = template.iter().map(|&v| v * 5.0).collect();
471
472        let amp = elastic_nonconformity(
473            &scaled,
474            &template,
475            &t,
476            0.0,
477            NonConformityScore::AmplitudeElastic,
478        )
479        .unwrap();
480        let combined = elastic_nonconformity(
481            &scaled,
482            &template,
483            &t,
484            0.0,
485            NonConformityScore::CombinedElastic,
486        )
487        .unwrap();
488
489        assert!(
490            amp > 0.0,
491            "AmplitudeElastic should be positive for scaled curve, got {amp}"
492        );
493        // CombinedElastic = sqrt(amp^2 + phase^2) >= amp (since phase^2 >= 0)
494        assert!(
495            combined >= amp - 1e-12,
496            "CombinedElastic ({combined}) should be >= AmplitudeElastic ({amp})"
497        );
498    }
499
500    // ── ECA-02 / ECA-03: elastic_conformal_anomaly ────────────────────────────
501
502    #[test]
503    fn test_elastic_conformal_marginal_validity() {
504        // On exchangeable clean data, flag rate should be approximately alpha.
505        let t = uniform_grid(50);
506        let calibration = sim_fundata(
507            100,
508            &t,
509            3,
510            EFunType::Fourier,
511            EValType::Exponential,
512            Some(1),
513        );
514        let test_data = sim_fundata(
515            100,
516            &t,
517            3,
518            EFunType::Fourier,
519            EValType::Exponential,
520            Some(2),
521        );
522
523        let config = ConformalAnomalyConfig {
524            variant: NonConformityScore::CombinedElastic,
525            alpha: 0.1,
526            ..Default::default()
527        };
528
529        let result = elastic_conformal_anomaly(&calibration, &test_data, &t, &config).unwrap();
530        let flag_rate = result.flags.iter().filter(|&&f| f).count() as f64 / 100.0;
531        // Generous tolerance: finite-sample marginal validity guarantees <= alpha + correction
532        assert!(
533            flag_rate <= 0.25,
534            "Flag rate {flag_rate} too high for alpha=0.1 on clean data"
535        );
536    }
537
538    #[test]
539    fn test_elastic_conformal_magnitude_outlier() {
540        // An amplitude-scaled curve should be flagged by AmplitudeElastic.
541        //
542        // Strategy: use a sinusoid as the fixed template. Build a calibration set of
543        // curves that are identical to the template (self-score ≈ 0), so the calibration
544        // distribution is tightly concentrated near zero. Then inject a 10x-scaled
545        // sinusoid as the magnitude outlier — its AmplitudeElastic score will be large,
546        // clearly exceeding the near-zero threshold.
547        let t = uniform_grid(50);
548        let template = sinusoid(&t);
549
550        // Calibration: 20 copies of the template (self-scores ≈ 0)
551        let n_calib = 20;
552        let mut calibration = FdMatrix::zeros(n_calib, t.len());
553        for i in 0..n_calib {
554            for j in 0..t.len() {
555                calibration[(i, j)] = template[j];
556            }
557        }
558
559        // Test set: 5 clean (identical to template) + 1 magnitude outlier (10x scaled)
560        let n_clean = 5;
561        let outlier: Vec<f64> = template.iter().map(|&v| v * 10.0).collect();
562        let mut test_mat = FdMatrix::zeros(n_clean + 1, t.len());
563        for i in 0..n_clean {
564            for j in 0..t.len() {
565                test_mat[(i, j)] = template[j];
566            }
567        }
568        for j in 0..t.len() {
569            test_mat[(n_clean, j)] = outlier[j];
570        }
571
572        let config = ConformalAnomalyConfig {
573            variant: NonConformityScore::AmplitudeElastic,
574            alpha: 0.1,
575            template: Some(template),
576            ..Default::default()
577        };
578
579        let result = elastic_conformal_anomaly(&calibration, &test_mat, &t, &config).unwrap();
580        assert!(
581            result.flags[n_clean],
582            "Magnitude outlier (10x scaled) should be flagged by AmplitudeElastic; \
583             outlier score={}, threshold={}",
584            result.scores[n_clean], result.threshold
585        );
586        assert!(
587            result.scores[n_clean] > result.threshold,
588            "Outlier score {} should exceed threshold {}",
589            result.scores[n_clean],
590            result.threshold
591        );
592    }
593
594    #[test]
595    fn test_elastic_conformal_shape_outlier() {
596        // A phase-distorted curve should be flagged by PhaseElastic.
597        //
598        // Strategy: calibrate on sinusoids (phase distance ≈ 0 against template).
599        // Inject a "reversed" curve (t → 1-t phase map) — this creates a large
600        // phase distortion from the sinusoid template.
601        let t = uniform_grid(50);
602        let template = sinusoid(&t);
603
604        // Calibration: 20 copies of the template (phase-score ≈ 0)
605        let n_calib = 20;
606        let mut calibration = FdMatrix::zeros(n_calib, t.len());
607        for i in 0..n_calib {
608            for j in 0..t.len() {
609                calibration[(i, j)] = template[j];
610            }
611        }
612
613        // Shape/phase outlier: a cosine (quarter-period phase shift relative to sinusoid).
614        // The cosine and sine are of the same shape class but have a large phase offset.
615        let phase_outlier: Vec<f64> = t
616            .iter()
617            .map(|&x| (x * 6.0 * std::f64::consts::PI).cos())
618            .collect();
619
620        let n_clean = 5;
621        let mut test_mat = FdMatrix::zeros(n_clean + 1, t.len());
622        for i in 0..n_clean {
623            for j in 0..t.len() {
624                test_mat[(i, j)] = template[j];
625            }
626        }
627        for j in 0..t.len() {
628            test_mat[(n_clean, j)] = phase_outlier[j];
629        }
630
631        let config = ConformalAnomalyConfig {
632            variant: NonConformityScore::PhaseElastic,
633            alpha: 0.1,
634            template: Some(template),
635            ..Default::default()
636        };
637
638        let result = elastic_conformal_anomaly(&calibration, &test_mat, &t, &config).unwrap();
639        assert!(
640            result.flags[n_clean],
641            "Phase-distorted outlier (cosine vs sine template) should be flagged by PhaseElastic; \
642             outlier score={}, threshold={}",
643            result.scores[n_clean], result.threshold
644        );
645    }
646
647    #[test]
648    fn test_elastic_conformal_combined_catches_both() {
649        // CombinedElastic should flag both a magnitude and a phase outlier.
650        //
651        // Strategy: calibrate on sinusoids (calib scores ≈ 0). Inject:
652        // - a 10x-scaled sinusoid (magnitude outlier)
653        // - a cosine (phase outlier relative to sinusoid template)
654        let t = uniform_grid(50);
655        let template = sinusoid(&t);
656
657        // Calibration: 20 copies of the template
658        let n_calib = 20;
659        let mut calibration = FdMatrix::zeros(n_calib, t.len());
660        for i in 0..n_calib {
661            for j in 0..t.len() {
662                calibration[(i, j)] = template[j];
663            }
664        }
665
666        let magnitude_outlier: Vec<f64> = template.iter().map(|&v| v * 10.0).collect();
667        let phase_outlier: Vec<f64> = t
668            .iter()
669            .map(|&x| (x * 6.0 * std::f64::consts::PI).cos())
670            .collect();
671
672        let n_clean = 3;
673        let mut test_mat = FdMatrix::zeros(n_clean + 2, t.len());
674        for i in 0..n_clean {
675            for j in 0..t.len() {
676                test_mat[(i, j)] = template[j];
677            }
678        }
679        for j in 0..t.len() {
680            test_mat[(n_clean, j)] = magnitude_outlier[j];
681            test_mat[(n_clean + 1, j)] = phase_outlier[j];
682        }
683
684        let config = ConformalAnomalyConfig {
685            variant: NonConformityScore::CombinedElastic,
686            alpha: 0.1,
687            template: Some(template),
688            ..Default::default()
689        };
690
691        let result = elastic_conformal_anomaly(&calibration, &test_mat, &t, &config).unwrap();
692        assert!(
693            result.flags[n_clean],
694            "Magnitude outlier (10x scaled) should be flagged by CombinedElastic; \
695             outlier score={}, threshold={}",
696            result.scores[n_clean], result.threshold
697        );
698        assert!(
699            result.flags[n_clean + 1],
700            "Phase outlier (cosine vs sine template) should be flagged by CombinedElastic; \
701             outlier score={}, threshold={}",
702            result.scores[n_clean + 1],
703            result.threshold
704        );
705    }
706
707    #[test]
708    fn test_elastic_conformal_pvalue_threshold_correctness() {
709        // Hand-verifiable small example: verify p-value formula and threshold.
710        // Calibration: 5 identical sinusoids (self-score = ~0) against a template.
711        let t = uniform_grid(20);
712        let template = sinusoid(&t);
713
714        // 5 calibration curves: identical to template → calib_scores ≈ 0
715        let mut calib = FdMatrix::zeros(5, t.len());
716        for i in 0..5 {
717            for j in 0..t.len() {
718                calib[(i, j)] = template[j];
719            }
720        }
721
722        // 1 test curve that is a scaled version (high score)
723        let scaled: Vec<f64> = template.iter().map(|&v| v * 5.0).collect();
724        let mut test_mat = FdMatrix::zeros(1, t.len());
725        for j in 0..t.len() {
726            test_mat[(0, j)] = scaled[j];
727        }
728
729        let config = ConformalAnomalyConfig {
730            variant: NonConformityScore::AmplitudeElastic,
731            alpha: 0.1,
732            template: Some(template.clone()),
733            ..Default::default()
734        };
735
736        let result = elastic_conformal_anomaly(&calib, &test_mat, &t, &config).unwrap();
737
738        // Expected: calib_scores all near 0, test score > 0
739        // p_value = (1 + 5) / (5+1) = 1.0 (all 5 calib scores >= test score is false,
740        // but actually if calib ≈ 0 and test > 0, count of (calib >= test) = 0)
741        // → p_value = (1 + 0) / 6 = 1/6 ≈ 0.1667
742        // flag = p_value <= 0.1 → false (0.1667 > 0.1)
743        // But let's verify the formula directly:
744        let a_star = result.scores[0];
745
746        // All calib scores should be near zero, test score should be large
747        // Recompute p_value independently
748        let calib_scores: Vec<f64> = (0..5)
749            .map(|i| {
750                let row: Vec<f64> = (0..t.len()).map(|j| calib[(i, j)]).collect();
751                elastic_nonconformity(
752                    &row,
753                    &template,
754                    &t,
755                    0.0,
756                    NonConformityScore::AmplitudeElastic,
757                )
758                .unwrap()
759            })
760            .collect();
761
762        let count = calib_scores.iter().filter(|&&a| a >= a_star).count();
763        let expected_p = (1 + count) as f64 / 6.0;
764        assert!(
765            (result.p_values[0] - expected_p).abs() < 1e-12,
766            "p_value mismatch: got {}, expected {}",
767            result.p_values[0],
768            expected_p
769        );
770        assert_eq!(
771            result.flags[0],
772            result.p_values[0] <= 0.1,
773            "Flag should equal (p_value <= alpha)"
774        );
775
776        // Threshold: order-statistic at k = ceil(6 * 0.9) = ceil(5.4) = 6 → k > n → INFINITY
777        let mut sorted_calib = calib_scores.clone();
778        crate::helpers::sort_nan_safe(&mut sorted_calib);
779        let expected_threshold = {
780            let n = sorted_calib.len();
781            let k = ((n + 1) as f64 * 0.9).ceil() as usize;
782            if k > n {
783                f64::INFINITY
784            } else {
785                sorted_calib[k.saturating_sub(1)]
786            }
787        };
788        assert!(
789            (result.threshold - expected_threshold).abs() < 1e-12
790                || (result.threshold.is_infinite() && expected_threshold.is_infinite()),
791            "Threshold mismatch: got {}, expected {}",
792            result.threshold,
793            expected_threshold
794        );
795    }
796
797    #[test]
798    fn test_conformal_anomaly_result_shape() {
799        let t = uniform_grid(50);
800        let calibration = sim_fundata(
801            40,
802            &t,
803            3,
804            EFunType::Fourier,
805            EValType::Exponential,
806            Some(100),
807        );
808        let test_data = sim_fundata(
809            15,
810            &t,
811            3,
812            EFunType::Fourier,
813            EValType::Exponential,
814            Some(101),
815        );
816
817        let config = ConformalAnomalyConfig::default();
818        let result = elastic_conformal_anomaly(&calibration, &test_data, &t, &config).unwrap();
819
820        assert_eq!(
821            result.p_values.len(),
822            15,
823            "p_values length should equal n_test"
824        );
825        assert_eq!(result.scores.len(), 15, "scores length should equal n_test");
826        assert_eq!(result.flags.len(), 15, "flags length should equal n_test");
827        assert!(result.threshold.is_finite() || result.threshold.is_infinite());
828        assert!(result.threshold >= 0.0 || result.threshold.is_infinite());
829    }
830
831    #[test]
832    fn test_conformal_band_rejects_elastic_variants() {
833        use crate::tolerance::conformal_prediction_band;
834
835        let t = uniform_grid(50);
836        let data = sim_fundata(
837            40,
838            &t,
839            3,
840            EFunType::Fourier,
841            EValType::Exponential,
842            Some(42),
843        );
844
845        // Elastic variants must return None
846        for variant in [
847            NonConformityScore::AmplitudeElastic,
848            NonConformityScore::PhaseElastic,
849            NonConformityScore::CombinedElastic,
850        ] {
851            let result = conformal_prediction_band(&data, 0.2, 0.95, variant, 42);
852            assert!(
853                result.is_none(),
854                "conformal_prediction_band should return None for {:?}",
855                variant
856            );
857        }
858
859        // SupNorm and L2 must still return Some on valid data
860        for variant in [NonConformityScore::SupNorm, NonConformityScore::L2] {
861            let result = conformal_prediction_band(&data, 0.2, 0.95, variant, 42);
862            assert!(
863                result.is_some(),
864                "conformal_prediction_band should return Some for {:?}",
865                variant
866            );
867        }
868    }
869}