use super::NonConformityScore;
use crate::error::FdarError;
use crate::matrix::FdMatrix;
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct ConformalAnomalyConfig {
pub variant: NonConformityScore,
pub alpha: f64,
pub lambda: f64,
pub max_iter: usize,
pub tol: f64,
pub template: Option<Vec<f64>>,
}
impl Default for ConformalAnomalyConfig {
fn default() -> Self {
Self {
variant: NonConformityScore::CombinedElastic,
alpha: 0.1,
lambda: 0.0,
max_iter: 20,
tol: 1e-4,
template: None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct ConformalAnomalyResult {
pub p_values: Vec<f64>,
pub scores: Vec<f64>,
pub flags: Vec<bool>,
pub threshold: f64,
}
#[must_use = "expensive computation: elastic_nonconformity runs an elastic alignment; use the score"]
pub fn elastic_nonconformity(
curve: &[f64],
template: &[f64],
argvals: &[f64],
lambda: f64,
variant: NonConformityScore,
) -> Result<f64, FdarError> {
match variant {
NonConformityScore::AmplitudeElastic => Ok(crate::alignment::amplitude_distance(
curve, template, argvals, lambda,
)),
NonConformityScore::PhaseElastic => Ok(crate::alignment::phase_distance_pair(
curve, template, argvals, lambda,
)),
NonConformityScore::CombinedElastic => {
let amp = crate::alignment::amplitude_distance(curve, template, argvals, lambda);
let ph = crate::alignment::phase_distance_pair(curve, template, argvals, lambda);
Ok((amp.powi(2) + ph.powi(2)).sqrt())
}
_ => Err(FdarError::InvalidParameter {
parameter: "variant",
message: "elastic_nonconformity requires an elastic NonConformityScore variant \
(AmplitudeElastic, PhaseElastic, or CombinedElastic)"
.to_string(),
}),
}
}
#[must_use = "expensive computation whose result should not be discarded"]
pub fn elastic_conformal_anomaly(
calibration: &FdMatrix,
test: &FdMatrix,
argvals: &[f64],
config: &ConformalAnomalyConfig,
) -> Result<ConformalAnomalyResult, FdarError> {
let m = argvals.len();
let (n_calib, m_calib) = calibration.shape();
let (n_test, m_test) = test.shape();
if m_calib != m {
return Err(FdarError::InvalidDimension {
parameter: "calibration",
expected: format!("{m} columns (argvals.len())"),
actual: format!("{m_calib}"),
});
}
if m_test != m {
return Err(FdarError::InvalidDimension {
parameter: "test",
expected: format!("{m} columns (argvals.len())"),
actual: format!("{m_test}"),
});
}
if n_calib < 1 {
return Err(FdarError::InvalidParameter {
parameter: "calibration",
message: "calibration set must have at least one curve (n_calib >= 1)".to_string(),
});
}
if !(config.alpha > 0.0 && config.alpha < 1.0) {
return Err(FdarError::InvalidParameter {
parameter: "alpha",
message: format!(
"alpha must be in (0.0, 1.0) exclusive, got {}",
config.alpha
),
});
}
match config.variant {
NonConformityScore::AmplitudeElastic
| NonConformityScore::PhaseElastic
| NonConformityScore::CombinedElastic => {}
_ => {
return Err(FdarError::InvalidParameter {
parameter: "config.variant",
message: "elastic_conformal_anomaly requires an elastic NonConformityScore \
variant (AmplitudeElastic, PhaseElastic, or CombinedElastic)"
.to_string(),
});
}
}
let template: Vec<f64> = match config.template.clone() {
Some(t) => {
if t.len() != m {
return Err(FdarError::InvalidDimension {
parameter: "config.template",
expected: format!("{m} elements (argvals.len())"),
actual: format!("{}", t.len()),
});
}
t
}
None => {
let km = crate::alignment::karcher_mean(
calibration,
argvals,
config.max_iter,
config.tol,
config.lambda,
);
km.mean
}
};
let calib_scores: Vec<f64> = (0..n_calib)
.map(|i| {
let curve = calibration.row(i);
elastic_nonconformity(&curve, &template, argvals, config.lambda, config.variant)
})
.collect::<Result<Vec<f64>, FdarError>>()?;
let mut sorted_calib = calib_scores.clone();
crate::helpers::sort_nan_safe(&mut sorted_calib);
let threshold = calibrated_threshold(&sorted_calib, config.alpha);
let mut p_values = Vec::with_capacity(n_test);
let mut scores = Vec::with_capacity(n_test);
let mut flags = Vec::with_capacity(n_test);
for j in 0..n_test {
let curve = test.row(j);
let a_star =
elastic_nonconformity(&curve, &template, argvals, config.lambda, config.variant)?;
let count = calib_scores.iter().filter(|&&a| a >= a_star).count();
let p_value = (1 + count) as f64 / (n_calib + 1) as f64;
let flag = p_value <= config.alpha;
scores.push(a_star);
p_values.push(p_value);
flags.push(flag);
}
Ok(ConformalAnomalyResult {
p_values,
scores,
flags,
threshold,
})
}
fn calibrated_threshold(sorted_scores: &[f64], alpha: f64) -> f64 {
let n = sorted_scores.len();
let k = ((n + 1) as f64 * (1.0 - alpha)).ceil() as usize;
if k > n {
f64::INFINITY
} else {
sorted_scores[k.saturating_sub(1)]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::simulation::{sim_fundata, EFunType, EValType};
fn uniform_grid(n: usize) -> Vec<f64> {
(0..n).map(|i| i as f64 / (n - 1) as f64).collect()
}
fn sinusoid(argvals: &[f64]) -> Vec<f64> {
argvals
.iter()
.map(|&t| (t * 6.0 * std::f64::consts::PI).sin())
.collect()
}
#[test]
fn test_elastic_nonconformity_self_near_zero() {
let t = uniform_grid(50);
let curve = sinusoid(&t);
for variant in [
NonConformityScore::AmplitudeElastic,
NonConformityScore::PhaseElastic,
NonConformityScore::CombinedElastic,
] {
let score = elastic_nonconformity(&curve, &curve, &t, 0.0, variant).unwrap();
assert!(
score < 1e-4,
"Self-score for {:?} should be near zero, got {score}",
variant
);
}
}
#[test]
fn test_elastic_nonconformity_nonneg() {
let t = uniform_grid(50);
let curve = sinusoid(&t);
let other: Vec<f64> = t
.iter()
.map(|&x| (x * 6.0 * std::f64::consts::PI).cos())
.collect();
for variant in [
NonConformityScore::AmplitudeElastic,
NonConformityScore::PhaseElastic,
NonConformityScore::CombinedElastic,
] {
let score = elastic_nonconformity(&curve, &other, &t, 0.0, variant).unwrap();
assert!(
score >= 0.0,
"Non-negativity violated for {:?}: got {score}",
variant
);
}
}
#[test]
fn test_elastic_nonconformity_invalid_variant() {
let t = uniform_grid(50);
let curve = sinusoid(&t);
for variant in [NonConformityScore::SupNorm, NonConformityScore::L2] {
let result = elastic_nonconformity(&curve, &curve, &t, 0.0, variant);
assert!(
matches!(result, Err(FdarError::InvalidParameter { .. })),
"Expected InvalidParameter for {:?}, got {:?}",
variant,
result
);
}
}
#[test]
fn test_conformal_anomaly_rejects_mismatched_template() {
let t = uniform_grid(50);
let calibration = sim_fundata(20, &t, 3, EFunType::Fourier, EValType::Exponential, Some(1));
let test_data = sim_fundata(5, &t, 3, EFunType::Fourier, EValType::Exponential, Some(2));
let config = ConformalAnomalyConfig {
variant: NonConformityScore::CombinedElastic,
alpha: 0.1,
template: Some(uniform_grid(40)), ..Default::default()
};
let result = elastic_conformal_anomaly(&calibration, &test_data, &t, &config);
assert!(
matches!(result, Err(FdarError::InvalidDimension { .. })),
"Expected InvalidDimension for mismatched template, got {result:?}"
);
}
#[test]
fn test_elastic_nonconformity_combined_not_alias() {
let t = uniform_grid(50);
let template = sinusoid(&t);
let scaled: Vec<f64> = template.iter().map(|&v| v * 5.0).collect();
let amp = elastic_nonconformity(
&scaled,
&template,
&t,
0.0,
NonConformityScore::AmplitudeElastic,
)
.unwrap();
let combined = elastic_nonconformity(
&scaled,
&template,
&t,
0.0,
NonConformityScore::CombinedElastic,
)
.unwrap();
assert!(
amp > 0.0,
"AmplitudeElastic should be positive for scaled curve, got {amp}"
);
assert!(
combined >= amp - 1e-12,
"CombinedElastic ({combined}) should be >= AmplitudeElastic ({amp})"
);
}
#[test]
fn test_elastic_conformal_marginal_validity() {
let t = uniform_grid(50);
let calibration = sim_fundata(
100,
&t,
3,
EFunType::Fourier,
EValType::Exponential,
Some(1),
);
let test_data = sim_fundata(
100,
&t,
3,
EFunType::Fourier,
EValType::Exponential,
Some(2),
);
let config = ConformalAnomalyConfig {
variant: NonConformityScore::CombinedElastic,
alpha: 0.1,
..Default::default()
};
let result = elastic_conformal_anomaly(&calibration, &test_data, &t, &config).unwrap();
let flag_rate = result.flags.iter().filter(|&&f| f).count() as f64 / 100.0;
assert!(
flag_rate <= 0.25,
"Flag rate {flag_rate} too high for alpha=0.1 on clean data"
);
}
#[test]
fn test_elastic_conformal_magnitude_outlier() {
let t = uniform_grid(50);
let template = sinusoid(&t);
let n_calib = 20;
let mut calibration = FdMatrix::zeros(n_calib, t.len());
for i in 0..n_calib {
for j in 0..t.len() {
calibration[(i, j)] = template[j];
}
}
let n_clean = 5;
let outlier: Vec<f64> = template.iter().map(|&v| v * 10.0).collect();
let mut test_mat = FdMatrix::zeros(n_clean + 1, t.len());
for i in 0..n_clean {
for j in 0..t.len() {
test_mat[(i, j)] = template[j];
}
}
for j in 0..t.len() {
test_mat[(n_clean, j)] = outlier[j];
}
let config = ConformalAnomalyConfig {
variant: NonConformityScore::AmplitudeElastic,
alpha: 0.1,
template: Some(template),
..Default::default()
};
let result = elastic_conformal_anomaly(&calibration, &test_mat, &t, &config).unwrap();
assert!(
result.flags[n_clean],
"Magnitude outlier (10x scaled) should be flagged by AmplitudeElastic; \
outlier score={}, threshold={}",
result.scores[n_clean], result.threshold
);
assert!(
result.scores[n_clean] > result.threshold,
"Outlier score {} should exceed threshold {}",
result.scores[n_clean],
result.threshold
);
}
#[test]
fn test_elastic_conformal_shape_outlier() {
let t = uniform_grid(50);
let template = sinusoid(&t);
let n_calib = 20;
let mut calibration = FdMatrix::zeros(n_calib, t.len());
for i in 0..n_calib {
for j in 0..t.len() {
calibration[(i, j)] = template[j];
}
}
let phase_outlier: Vec<f64> = t
.iter()
.map(|&x| (x * 6.0 * std::f64::consts::PI).cos())
.collect();
let n_clean = 5;
let mut test_mat = FdMatrix::zeros(n_clean + 1, t.len());
for i in 0..n_clean {
for j in 0..t.len() {
test_mat[(i, j)] = template[j];
}
}
for j in 0..t.len() {
test_mat[(n_clean, j)] = phase_outlier[j];
}
let config = ConformalAnomalyConfig {
variant: NonConformityScore::PhaseElastic,
alpha: 0.1,
template: Some(template),
..Default::default()
};
let result = elastic_conformal_anomaly(&calibration, &test_mat, &t, &config).unwrap();
assert!(
result.flags[n_clean],
"Phase-distorted outlier (cosine vs sine template) should be flagged by PhaseElastic; \
outlier score={}, threshold={}",
result.scores[n_clean], result.threshold
);
}
#[test]
fn test_elastic_conformal_combined_catches_both() {
let t = uniform_grid(50);
let template = sinusoid(&t);
let n_calib = 20;
let mut calibration = FdMatrix::zeros(n_calib, t.len());
for i in 0..n_calib {
for j in 0..t.len() {
calibration[(i, j)] = template[j];
}
}
let magnitude_outlier: Vec<f64> = template.iter().map(|&v| v * 10.0).collect();
let phase_outlier: Vec<f64> = t
.iter()
.map(|&x| (x * 6.0 * std::f64::consts::PI).cos())
.collect();
let n_clean = 3;
let mut test_mat = FdMatrix::zeros(n_clean + 2, t.len());
for i in 0..n_clean {
for j in 0..t.len() {
test_mat[(i, j)] = template[j];
}
}
for j in 0..t.len() {
test_mat[(n_clean, j)] = magnitude_outlier[j];
test_mat[(n_clean + 1, j)] = phase_outlier[j];
}
let config = ConformalAnomalyConfig {
variant: NonConformityScore::CombinedElastic,
alpha: 0.1,
template: Some(template),
..Default::default()
};
let result = elastic_conformal_anomaly(&calibration, &test_mat, &t, &config).unwrap();
assert!(
result.flags[n_clean],
"Magnitude outlier (10x scaled) should be flagged by CombinedElastic; \
outlier score={}, threshold={}",
result.scores[n_clean], result.threshold
);
assert!(
result.flags[n_clean + 1],
"Phase outlier (cosine vs sine template) should be flagged by CombinedElastic; \
outlier score={}, threshold={}",
result.scores[n_clean + 1],
result.threshold
);
}
#[test]
fn test_elastic_conformal_pvalue_threshold_correctness() {
let t = uniform_grid(20);
let template = sinusoid(&t);
let mut calib = FdMatrix::zeros(5, t.len());
for i in 0..5 {
for j in 0..t.len() {
calib[(i, j)] = template[j];
}
}
let scaled: Vec<f64> = template.iter().map(|&v| v * 5.0).collect();
let mut test_mat = FdMatrix::zeros(1, t.len());
for j in 0..t.len() {
test_mat[(0, j)] = scaled[j];
}
let config = ConformalAnomalyConfig {
variant: NonConformityScore::AmplitudeElastic,
alpha: 0.1,
template: Some(template.clone()),
..Default::default()
};
let result = elastic_conformal_anomaly(&calib, &test_mat, &t, &config).unwrap();
let a_star = result.scores[0];
let calib_scores: Vec<f64> = (0..5)
.map(|i| {
let row: Vec<f64> = (0..t.len()).map(|j| calib[(i, j)]).collect();
elastic_nonconformity(
&row,
&template,
&t,
0.0,
NonConformityScore::AmplitudeElastic,
)
.unwrap()
})
.collect();
let count = calib_scores.iter().filter(|&&a| a >= a_star).count();
let expected_p = (1 + count) as f64 / 6.0;
assert!(
(result.p_values[0] - expected_p).abs() < 1e-12,
"p_value mismatch: got {}, expected {}",
result.p_values[0],
expected_p
);
assert_eq!(
result.flags[0],
result.p_values[0] <= 0.1,
"Flag should equal (p_value <= alpha)"
);
let mut sorted_calib = calib_scores.clone();
crate::helpers::sort_nan_safe(&mut sorted_calib);
let expected_threshold = {
let n = sorted_calib.len();
let k = ((n + 1) as f64 * 0.9).ceil() as usize;
if k > n {
f64::INFINITY
} else {
sorted_calib[k.saturating_sub(1)]
}
};
assert!(
(result.threshold - expected_threshold).abs() < 1e-12
|| (result.threshold.is_infinite() && expected_threshold.is_infinite()),
"Threshold mismatch: got {}, expected {}",
result.threshold,
expected_threshold
);
}
#[test]
fn test_conformal_anomaly_result_shape() {
let t = uniform_grid(50);
let calibration = sim_fundata(
40,
&t,
3,
EFunType::Fourier,
EValType::Exponential,
Some(100),
);
let test_data = sim_fundata(
15,
&t,
3,
EFunType::Fourier,
EValType::Exponential,
Some(101),
);
let config = ConformalAnomalyConfig::default();
let result = elastic_conformal_anomaly(&calibration, &test_data, &t, &config).unwrap();
assert_eq!(
result.p_values.len(),
15,
"p_values length should equal n_test"
);
assert_eq!(result.scores.len(), 15, "scores length should equal n_test");
assert_eq!(result.flags.len(), 15, "flags length should equal n_test");
assert!(result.threshold.is_finite() || result.threshold.is_infinite());
assert!(result.threshold >= 0.0 || result.threshold.is_infinite());
}
#[test]
fn test_conformal_band_rejects_elastic_variants() {
use crate::tolerance::conformal_prediction_band;
let t = uniform_grid(50);
let data = sim_fundata(
40,
&t,
3,
EFunType::Fourier,
EValType::Exponential,
Some(42),
);
for variant in [
NonConformityScore::AmplitudeElastic,
NonConformityScore::PhaseElastic,
NonConformityScore::CombinedElastic,
] {
let result = conformal_prediction_band(&data, 0.2, 0.95, variant, 42);
assert!(
result.is_none(),
"conformal_prediction_band should return None for {:?}",
variant
);
}
for variant in [NonConformityScore::SupNorm, NonConformityScore::L2] {
let result = conformal_prediction_band(&data, 0.2, 0.95, variant, 42);
assert!(
result.is_some(),
"conformal_prediction_band should return Some for {:?}",
variant
);
}
}
}