use crate::config::{SpotConfig, SpotEstimator, SpotExcessUpdate, SpotInitialThreshold};
use crate::error::{SpotError, SpotResult};
use crate::p2::p2_quantile;
use crate::status::SpotStatus;
use crate::tail::Tail;
#[derive(Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SpotDetector {
q: f64,
level: f64,
discard_anomalies: bool,
low: bool,
up_down: f64,
#[cfg_attr(feature = "serde", serde(with = "crate::ser::nan_safe_f64"))]
anomaly_threshold: f64,
#[cfg_attr(feature = "serde", serde(with = "crate::ser::nan_safe_f64"))]
excess_threshold: f64,
nt: usize,
n: usize,
#[cfg_attr(feature = "serde", serde(default))]
estimator: SpotEstimator,
#[cfg_attr(feature = "serde", serde(default))]
initial_threshold: SpotInitialThreshold,
#[cfg_attr(feature = "serde", serde(default))]
excess_update: SpotExcessUpdate,
tail: Tail,
}
impl SpotDetector {
pub fn new(config: SpotConfig) -> SpotResult<Self> {
Self::new_with_estimator(config, SpotEstimator::default())
}
pub fn new_with_estimator(config: SpotConfig, estimator: SpotEstimator) -> SpotResult<Self> {
Self::new_with_options(config, estimator, SpotInitialThreshold::default())
}
pub fn new_with_options(
config: SpotConfig,
estimator: SpotEstimator,
initial_threshold: SpotInitialThreshold,
) -> SpotResult<Self> {
Self::new_with_full_options(
config,
estimator,
initial_threshold,
SpotExcessUpdate::default(),
)
}
pub fn new_with_full_options(
config: SpotConfig,
estimator: SpotEstimator,
initial_threshold: SpotInitialThreshold,
excess_update: SpotExcessUpdate,
) -> SpotResult<Self> {
if config.level < 0.0 || config.level >= 1.0 {
return Err(SpotError::LevelOutOfBounds);
}
if config.q >= (1.0 - config.level) || config.q <= 0.0 {
return Err(SpotError::QOutOfBounds);
}
let up_down = if config.low_tail { -1.0 } else { 1.0 };
Ok(Self {
q: config.q,
level: config.level,
discard_anomalies: config.discard_anomalies,
low: config.low_tail,
up_down,
anomaly_threshold: f64::NAN,
excess_threshold: f64::NAN,
nt: 0,
n: 0,
estimator,
initial_threshold,
excess_update,
tail: Tail::new(config.max_excess)?,
})
}
pub fn fit(&mut self, data: &[f64]) -> SpotResult<()> {
self.nt = 0;
self.n = data.len();
self.tail.reset();
let et = if self.low {
self.initial_quantile(1.0 - self.level, data)
} else {
self.initial_quantile(self.level, data)
};
if et.is_nan() {
return Err(SpotError::ExcessThresholdIsNaN);
}
self.excess_threshold = et;
for &value in data {
let excess = self.up_down * (value - et);
if excess > 0.0 {
self.nt += 1;
self.tail.push(excess);
}
}
self.tail.fit_with(self.estimator);
self.anomaly_threshold = self.quantile(self.q);
if self.anomaly_threshold.is_nan() {
return Err(SpotError::AnomalyThresholdIsNaN);
}
Ok(())
}
pub fn step(&mut self, value: f64) -> SpotResult<SpotStatus> {
if value.is_nan() {
return Err(SpotError::DataIsNaN);
}
if self.discard_anomalies && (self.up_down * (value - self.anomaly_threshold) > 0.0) {
return Ok(SpotStatus::Anomaly);
}
self.n += 1;
let ex = self.up_down * (value - self.excess_threshold);
if self.should_update_excess(ex) {
self.nt += 1;
self.tail.push(ex);
self.tail.fit_with(self.estimator);
self.anomaly_threshold = self.quantile(self.q);
return Ok(SpotStatus::Excess);
}
Ok(SpotStatus::Normal)
}
pub fn observe_normal(&mut self) {
self.n += 1;
}
pub fn quantile(&self, q: f64) -> f64 {
if self.n == 0 {
return f64::NAN;
}
let s = (self.nt as f64) / (self.n as f64);
self.excess_threshold + self.up_down * self.tail.quantile(s, q)
}
pub fn probability(&self, z: f64) -> f64 {
if self.n == 0 {
return f64::NAN;
}
let s = (self.nt as f64) / (self.n as f64);
self.tail
.probability(s, self.up_down * (z - self.excess_threshold))
}
pub fn anomaly_score(&self, value: f64) -> f64 {
if value.is_nan() || self.n == 0 || self.excess_threshold.is_nan() {
return f64::NAN;
}
let excess = self.up_down * (value - self.excess_threshold);
self.tail.cdf(excess)
}
pub fn anomaly_threshold(&self) -> f64 {
self.anomaly_threshold
}
pub fn excess_threshold(&self) -> f64 {
self.excess_threshold
}
pub fn config(&self) -> Option<SpotConfig> {
Some(SpotConfig {
q: self.q,
low_tail: self.low,
discard_anomalies: self.discard_anomalies,
level: self.level,
max_excess: self.tail.peaks().container().capacity(),
})
}
pub fn n(&self) -> usize {
self.n
}
pub fn nt(&self) -> usize {
self.nt
}
pub fn tail_parameters(&self) -> (f64, f64) {
(self.tail.gamma(), self.tail.sigma())
}
pub fn estimator(&self) -> SpotEstimator {
self.estimator
}
pub fn initial_threshold(&self) -> SpotInitialThreshold {
self.initial_threshold
}
pub fn excess_update(&self) -> SpotExcessUpdate {
self.excess_update
}
pub fn reset(&mut self) {
self.anomaly_threshold = f64::NAN;
self.excess_threshold = f64::NAN;
self.nt = 0;
self.n = 0;
self.tail.reset();
}
pub fn tail_size(&self) -> usize {
self.tail.size()
}
pub fn peaks_min(&self) -> f64 {
self.tail.peaks().min()
}
pub fn peaks_max(&self) -> f64 {
self.tail.peaks().max()
}
pub fn peaks_mean(&self) -> f64 {
self.tail.peaks().mean()
}
pub fn peaks_variance(&self) -> f64 {
self.tail.peaks().variance()
}
pub fn peaks_data(&self) -> Vec<f64> {
self.tail.peaks().container().data()
}
fn initial_quantile(&self, level: f64, data: &[f64]) -> f64 {
match self.initial_threshold {
SpotInitialThreshold::P2 => p2_quantile(level, data),
SpotInitialThreshold::Empirical => empirical_quantile(level, data),
}
}
fn should_update_excess(&self, excess: f64) -> bool {
match self.excess_update {
SpotExcessUpdate::GreaterOrEqual => excess >= 0.0,
SpotExcessUpdate::Greater => excess > 0.0,
}
}
}
fn empirical_quantile(level: f64, data: &[f64]) -> f64 {
let mut sorted: Vec<f64> = data
.iter()
.copied()
.filter(|value| value.is_finite())
.collect();
if sorted.is_empty() {
return f64::NAN;
}
sorted.sort_by(|a, b| a.total_cmp(b));
let index = ((level - level.floor()) * sorted.len() as f64) as usize;
sorted[index.min(sorted.len() - 1)]
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn test_spot_creation_valid_config() {
let config = SpotConfig::default();
let spot = SpotDetector::new(config).unwrap();
assert_relative_eq!(spot.q, 0.0001);
assert!(!spot.low);
assert!(spot.discard_anomalies);
assert_relative_eq!(spot.level, 0.998);
assert!(spot.anomaly_threshold().is_nan());
assert!(spot.excess_threshold().is_nan());
assert_eq!(spot.n(), 0);
assert_eq!(spot.nt(), 0);
assert_eq!(spot.estimator(), SpotEstimator::Best);
assert_eq!(spot.initial_threshold(), SpotInitialThreshold::P2);
assert_eq!(spot.excess_update(), SpotExcessUpdate::GreaterOrEqual);
}
#[test]
fn test_spot_creation_with_mom_estimator() {
let config = SpotConfig {
q: 0.001,
level: 0.98,
max_excess: 10_000,
..SpotConfig::default()
};
let mut spot = SpotDetector::new_with_full_options(
config,
SpotEstimator::Mom,
SpotInitialThreshold::Empirical,
SpotExcessUpdate::Greater,
)
.unwrap();
let data: Vec<f64> = (0..1000)
.map(|i| {
let x = i as f64 * 0.1;
x.sin() + (x * 0.37).cos() * 0.1 + 5.0
})
.collect();
spot.fit(&data).unwrap();
assert_eq!(spot.estimator(), SpotEstimator::Mom);
assert_eq!(spot.initial_threshold(), SpotInitialThreshold::Empirical);
assert_eq!(spot.excess_update(), SpotExcessUpdate::Greater);
assert!(!spot.anomaly_threshold().is_nan());
assert_eq!(spot.step(5.5).unwrap(), SpotStatus::Normal);
assert_eq!(spot.step(100.0).unwrap(), SpotStatus::Anomaly);
}
#[test]
fn test_empirical_quantile_matches_sorted_index() {
let data = [5.0, 1.0, 3.0, 2.0, 4.0];
assert_relative_eq!(empirical_quantile(0.0, &data), 1.0);
assert_relative_eq!(empirical_quantile(0.5, &data), 3.0);
assert_relative_eq!(empirical_quantile(0.98, &data), 5.0);
}
#[test]
fn test_spot_invalid_level() {
let config = SpotConfig {
level: 1.5, ..SpotConfig::default()
};
let result = SpotDetector::new(config);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), SpotError::LevelOutOfBounds);
}
#[test]
fn test_spot_invalid_q() {
let config = SpotConfig {
q: 0.5, ..SpotConfig::default()
};
let result = SpotDetector::new(config);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), SpotError::QOutOfBounds);
}
#[test]
fn test_spot_fit_basic() {
let config = SpotConfig::default();
let mut spot = SpotDetector::new(config).unwrap();
let data: Vec<f64> = (0..1000).map(|i| (i as f64 / 1000.0) * 2.0 - 1.0).collect();
let result = spot.fit(&data);
assert!(result.is_ok());
assert!(!spot.anomaly_threshold().is_nan());
assert!(!spot.excess_threshold().is_nan());
assert!(spot.anomaly_threshold().is_finite());
assert!(spot.excess_threshold().is_finite());
assert_eq!(spot.n(), 1000);
assert!(spot.nt() > 0); }
#[test]
fn test_spot_step_normal() {
let config = SpotConfig::default();
let mut spot = SpotDetector::new(config).unwrap();
let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
spot.fit(&data).unwrap();
let result = spot.step(50.0);
assert!(result.is_ok());
}
#[test]
fn test_spot_step_nan() {
let config = SpotConfig::default();
let mut spot = SpotDetector::new(config).unwrap();
let result = spot.step(f64::NAN);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), SpotError::DataIsNaN);
}
#[test]
fn test_spot_observe_normal_advances_count_only() {
let config = SpotConfig::default();
let mut spot = SpotDetector::new(config).unwrap();
let data: Vec<f64> = (0..1000).map(|i| (i as f64 / 1000.0) * 2.0 - 1.0).collect();
spot.fit(&data).unwrap();
let n = spot.n();
let nt = spot.nt();
let anomaly_threshold = spot.anomaly_threshold();
let excess_threshold = spot.excess_threshold();
spot.observe_normal();
assert_eq!(spot.n(), n + 1);
assert_eq!(spot.nt(), nt);
assert_relative_eq!(spot.anomaly_threshold(), anomaly_threshold);
assert_relative_eq!(spot.excess_threshold(), excess_threshold);
}
#[test]
fn test_spot_reset_returns_to_pristine_state() {
let config = SpotConfig::default();
let mut spot = SpotDetector::new(config.clone()).unwrap();
let data: Vec<f64> = (0..1000).map(|i| (i as f64 / 1000.0) * 2.0 - 1.0).collect();
spot.fit(&data).unwrap();
for v in &data {
let _ = spot.step(*v).unwrap();
}
assert!(spot.n() > 0);
assert!(!spot.anomaly_threshold().is_nan());
spot.reset();
assert!(spot.anomaly_threshold().is_nan());
assert!(spot.excess_threshold().is_nan());
assert_eq!(spot.n(), 0);
assert_eq!(spot.nt(), 0);
assert_eq!(spot.tail_size(), 0);
assert_eq!(spot.config(), Some(config.clone()));
let mut fresh = SpotDetector::new(config).unwrap();
spot.fit(&data).unwrap();
fresh.fit(&data).unwrap();
assert_relative_eq!(spot.anomaly_threshold(), fresh.anomaly_threshold());
assert_relative_eq!(spot.excess_threshold(), fresh.excess_threshold());
assert_eq!(spot.nt(), fresh.nt());
assert_eq!(spot.n(), fresh.n());
}
#[test]
fn test_spot_reset_before_fit_is_noop_safe() {
let mut spot = SpotDetector::new(SpotConfig::default()).unwrap();
spot.reset();
assert!(spot.anomaly_threshold().is_nan());
assert!(spot.excess_threshold().is_nan());
assert_eq!(spot.n(), 0);
assert_eq!(spot.nt(), 0);
assert_eq!(spot.tail_size(), 0);
let data: Vec<f64> = (0..500).map(|i| (i as f64 / 500.0) * 2.0 - 1.0).collect();
spot.fit(&data).unwrap();
assert!(!spot.anomaly_threshold().is_nan());
}
#[test]
fn test_spot_reset_is_idempotent() {
let mut spot = SpotDetector::new(SpotConfig::default()).unwrap();
let data: Vec<f64> = (0..500).map(|i| (i as f64 / 500.0) * 2.0 - 1.0).collect();
spot.fit(&data).unwrap();
for v in &data {
let _ = spot.step(*v).unwrap();
}
spot.reset();
let after_first_n = spot.n();
let after_first_nt = spot.nt();
let after_first_size = spot.tail_size();
spot.reset();
assert_eq!(spot.n(), after_first_n);
assert_eq!(spot.nt(), after_first_nt);
assert_eq!(spot.tail_size(), after_first_size);
assert!(spot.anomaly_threshold().is_nan());
assert!(spot.excess_threshold().is_nan());
}
#[test]
fn test_spot_reset_then_fit_then_step_full_cycle() {
let config = SpotConfig::default();
let train: Vec<f64> = (0..1000).map(|i| (i as f64 / 1000.0) * 2.0 - 1.0).collect();
let probe: Vec<f64> = (0..200).map(|i| (i as f64 / 100.0) - 1.0).collect();
let mut reused = SpotDetector::new(config.clone()).unwrap();
reused.fit(&train).unwrap();
for v in &probe {
let _ = reused.step(*v).unwrap();
}
reused.reset();
reused.fit(&train).unwrap();
let reused_classifications: Vec<SpotStatus> =
probe.iter().map(|&v| reused.step(v).unwrap()).collect();
let mut fresh = SpotDetector::new(config).unwrap();
fresh.fit(&train).unwrap();
let fresh_classifications: Vec<SpotStatus> =
probe.iter().map(|&v| fresh.step(v).unwrap()).collect();
assert_eq!(reused_classifications, fresh_classifications);
assert_relative_eq!(reused.anomaly_threshold(), fresh.anomaly_threshold());
assert_relative_eq!(reused.excess_threshold(), fresh.excess_threshold());
assert_eq!(reused.nt(), fresh.nt());
assert_eq!(reused.n(), fresh.n());
}
#[test]
fn test_spot_repeated_fit_replaces_previous_tail() {
let config = SpotConfig {
level: 0.9,
..SpotConfig::default()
};
let train_a: Vec<f64> = (0..100).map(|i| i as f64).collect();
let train_b: Vec<f64> = (0..50).map(|i| i as f64 * 0.5).collect();
let mut reused = SpotDetector::new_with_options(
config.clone(),
SpotEstimator::Best,
SpotInitialThreshold::Empirical,
)
.unwrap();
reused.fit(&train_a).unwrap();
reused.fit(&train_b).unwrap();
let mut fresh = SpotDetector::new_with_options(
config,
SpotEstimator::Best,
SpotInitialThreshold::Empirical,
)
.unwrap();
fresh.fit(&train_b).unwrap();
assert_eq!(reused.tail_size(), reused.nt());
assert_eq!(reused.tail_size(), fresh.tail_size());
assert_eq!(reused.nt(), fresh.nt());
assert_eq!(reused.n(), fresh.n());
assert_relative_eq!(reused.anomaly_threshold(), fresh.anomaly_threshold());
assert_relative_eq!(reused.excess_threshold(), fresh.excess_threshold());
}
#[test]
fn test_spot_low_tail() {
let config = SpotConfig {
low_tail: true,
..SpotConfig::default()
};
let spot = SpotDetector::new(config).unwrap();
assert!(spot.low);
assert_relative_eq!(spot.up_down, -1.0);
}
#[test]
fn test_spot_config_roundtrip() {
let original_config = SpotConfig {
q: 0.001,
low_tail: true,
discard_anomalies: false,
level: 0.99,
max_excess: 100,
};
let spot = SpotDetector::new(original_config.clone()).unwrap();
let retrieved_config = spot.config().unwrap();
assert_relative_eq!(retrieved_config.q, original_config.q);
assert_eq!(retrieved_config.low_tail, original_config.low_tail);
assert_eq!(
retrieved_config.discard_anomalies,
original_config.discard_anomalies
);
assert_relative_eq!(retrieved_config.level, original_config.level);
assert_eq!(retrieved_config.max_excess, original_config.max_excess);
}
#[test]
fn test_spot_quantile_probability_consistency() {
let config = SpotConfig::default();
let mut spot = SpotDetector::new(config).unwrap();
let data: Vec<f64> = (1..=100).map(|i| i as f64).collect();
spot.fit(&data).unwrap();
let q = spot.quantile(0.01);
assert!(!q.is_nan());
assert!(q.is_finite());
let p = spot.probability(q);
assert!(!p.is_nan());
assert!(p >= 0.0);
}
fn score_test_detector(low_tail: bool) -> SpotDetector {
let config = SpotConfig {
q: 0.001,
low_tail,
discard_anomalies: true,
level: 0.9,
max_excess: 200,
};
let mut spot = SpotDetector::new_with_options(
config,
SpotEstimator::Best,
SpotInitialThreshold::Empirical,
)
.unwrap();
let data: Vec<f64> = (0..1000)
.map(|i| {
let x = i as f64 * 0.031;
x.sin() + 0.2 * (x * 0.37).cos()
})
.collect();
spot.fit(&data).unwrap();
spot
}
#[test]
fn test_anomaly_score_requires_fitted_detector_and_valid_value() {
let spot = SpotDetector::new(SpotConfig::default()).unwrap();
assert!(spot.anomaly_score(1.0).is_nan());
assert!(spot.anomaly_score(f64::NAN).is_nan());
}
#[test]
fn test_anomaly_score_range_and_monotonicity_for_both_tails() {
for low_tail in [false, true] {
let spot = score_test_detector(low_tail);
let direction = if low_tail { -1.0 } else { 1.0 };
let threshold = spot.excess_threshold();
let anomaly_distance = direction * (spot.anomaly_threshold() - threshold);
assert_eq!(spot.anomaly_score(threshold - direction), 0.0);
let mut previous = 0.0;
for scale in [0.0, 0.25, 0.5, 1.0, 2.0] {
let value = threshold + direction * anomaly_distance * scale;
let score = spot.anomaly_score(value);
assert!((0.0..=1.0).contains(&score));
assert!(score >= previous, "low_tail={low_tail}, value={value}");
previous = score;
}
}
}
#[test]
fn test_anomaly_score_matches_probability_conditioned_on_tail() {
for low_tail in [false, true] {
let spot = score_test_detector(low_tail);
let t = spot.excess_threshold();
let z = spot.anomaly_threshold();
let value = (t + z) / 2.0;
let tail_fraction = spot.nt() as f64 / spot.n() as f64;
assert_relative_eq!(
spot.anomaly_score(value),
1.0 - spot.probability(value) / tail_fraction,
epsilon = 1e-8
);
}
}
#[test]
fn test_anomaly_score_is_read_only() {
let spot = score_test_detector(false);
let before = (
spot.n(),
spot.nt(),
spot.anomaly_threshold(),
spot.excess_threshold(),
spot.tail_parameters(),
spot.peaks_data(),
);
let _ = spot.anomaly_score(spot.anomaly_threshold() * 2.0);
assert_eq!(spot.n(), before.0);
assert_eq!(spot.nt(), before.1);
assert_eq!(spot.anomaly_threshold(), before.2);
assert_eq!(spot.excess_threshold(), before.3);
assert_eq!(spot.tail_parameters(), before.4);
assert_eq!(spot.peaks_data(), before.5);
}
#[test]
fn test_upper_and_lower_anomaly_scores_are_mirror_symmetric() {
let upper = score_test_detector(false);
let config = SpotConfig {
q: 0.001,
low_tail: true,
discard_anomalies: true,
level: 0.9,
max_excess: 200,
};
let mut lower = SpotDetector::new_with_options(
config,
SpotEstimator::Best,
SpotInitialThreshold::Empirical,
)
.unwrap();
let mirrored_data: Vec<f64> = (0..1000)
.map(|i| {
let x = i as f64 * 0.031;
-(x.sin() + 0.2 * (x * 0.37).cos())
})
.collect();
lower.fit(&mirrored_data).unwrap();
for value in [
upper.excess_threshold(),
(upper.excess_threshold() + upper.anomaly_threshold()) / 2.0,
upper.anomaly_threshold(),
upper.anomaly_threshold() * 1.5,
] {
assert_relative_eq!(
upper.anomaly_score(value),
lower.anomaly_score(-value),
epsilon = 1e-12
);
}
}
#[test]
fn test_spot_excess_detection() {
let config = SpotConfig {
level: 0.9, ..SpotConfig::default()
};
let mut spot = SpotDetector::new(config).unwrap();
let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
spot.fit(&data).unwrap();
let _initial_nt = spot.nt();
let result = spot.step(95.0);
assert!(result.is_ok());
match result.unwrap() {
SpotStatus::Normal | SpotStatus::Excess | SpotStatus::Anomaly => {
}
}
}
}