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}