use crate::config::DetectConfig;
use crate::{calibrate, robustz, Detector, Report, ScanContext};
use ax_core::finding::Handle;
use ax_core::{AnomalyClass, Column, Finding, RecordSet};
#[derive(Debug, Default, Clone)]
pub struct SeasonalDetector;
impl SeasonalDetector {
fn scan_column(&self, col: &Column, cfg: &DetectConfig, out: &mut Report) -> bool {
let p = cfg.ctx_period;
let mut assessed = false;
for phase in 0..p {
let members: Vec<(usize, f64)> = col
.cells
.iter()
.enumerate()
.skip(phase)
.step_by(p)
.filter_map(|(row, cell)| cell.as_f64().filter(|v| v.is_finite()).map(|v| (row, v)))
.collect();
if members.len() < cfg.ctx_min_per_phase {
continue;
}
let values: Vec<f64> = members.iter().map(|(_, v)| *v).collect();
let Some((center, scale, k)) = robustz::center_scale(&values) else {
continue;
};
assessed = true;
for (row, v) in members {
let modz = robustz::score(v, center, scale, k);
if modz <= cfg.ctx_threshold {
continue;
}
out.push(Finding::new(
self.id(),
AnomalyClass::Contextual,
Handle::Cell {
column: col.name.clone(),
row,
},
calibrate::from_exceedance(modz, cfg.ctx_threshold),
modz,
format!(
"{} = {v:.6} at row {row} (phase {phase}/{p}): modified z-score {modz:.3} within its seasonal subseries exceeds {:.3}",
col.name, cfg.ctx_threshold
),
));
}
}
assessed
}
}
impl Detector for SeasonalDetector {
fn id(&self) -> &'static str {
"ctx.seasonal"
}
fn class(&self) -> AnomalyClass {
AnomalyClass::Contextual
}
fn detect(&self, ctx: &ScanContext, cfg: &DetectConfig, out: &mut Report) {
if cfg.ctx_period < 2 {
out.mark_absent(
self.id(),
"contextual detection needs a declared period ≥ 2 (pass --period N)",
);
return;
}
let assessed = scan_all(self, ctx.current, cfg, out);
if !assessed {
out.mark_absent(
self.id(),
format!(
"no numeric column has ≥ {} values in any phase of period {}",
cfg.ctx_min_per_phase, cfg.ctx_period
),
);
}
}
}
fn scan_all(det: &SeasonalDetector, rs: &RecordSet, cfg: &DetectConfig, out: &mut Report) -> bool {
let mut assessed = false;
for col in &rs.columns {
if col.ty.is_numeric() {
assessed |= det.scan_column(col, cfg, out);
}
}
assessed
}
#[cfg(test)]
mod tests {
use super::*;
use ax_core::Value;
fn weekly(cfg_period: usize) -> DetectConfig {
DetectConfig {
ctx_period: cfg_period,
..DetectConfig::default()
}
}
fn col_corpus(values: &[f64]) -> RecordSet {
RecordSet::new(
"-",
"t",
vec![Column::new(
"v",
values.iter().map(|&x| Value::Float(x)).collect(),
)],
)
}
fn seasonal_series() -> Vec<f64> {
(0..35)
.map(|i| (i % 7) as f64 * 10.0 + (i / 7) as f64 * 0.3)
.collect()
}
fn run(rs: &RecordSet, cfg: &DetectConfig) -> Report {
let mut out = Report::new();
SeasonalDetector.detect(&ScanContext::single(rs), cfg, &mut out);
out
}
#[test]
fn absent_without_a_period() {
let report = run(&col_corpus(&seasonal_series()), &DetectConfig::default());
assert!(report.findings.is_empty());
assert_eq!(report.absent.len(), 1);
assert_eq!(report.absent[0].detector, "ctx.seasonal");
}
#[test]
fn clean_seasonal_series_has_no_findings() {
let report = run(&col_corpus(&seasonal_series()), &weekly(7));
assert!(
report.findings.is_empty(),
"tidy seasonal data has no contextual outlier"
);
assert!(report.absent.is_empty(), "it ran; not absent");
}
#[test]
fn value_anomalous_only_in_context_is_flagged() {
let mut s = seasonal_series();
s[14] = 50.0;
let report = run(&col_corpus(&s), &weekly(7));
assert_eq!(report.findings.len(), 1);
assert!(matches!(
report.findings[0].handle,
Handle::Cell { row: 14, .. }
));
assert_eq!(report.findings[0].class, AnomalyClass::Contextual);
}
#[test]
fn absent_when_no_phase_has_enough_data() {
let report = run(&col_corpus(&seasonal_series()), &weekly(50));
assert!(report.findings.is_empty());
assert_eq!(report.absent.len(), 1);
assert!(report.absent[0].reason.contains("phase"));
}
#[test]
fn period_two_is_assessed_not_absent() {
let v: Vec<f64> = (0..12)
.map(|i| if i % 2 == 0 { 10.0 } else { 20.0 } + (i / 2) as f64 * 0.1)
.collect();
let report = run(&col_corpus(&v), &weekly(2));
assert!(report.absent.is_empty(), "period 2 must be assessed");
}
#[test]
fn phase_with_exactly_min_members_is_assessed() {
let mut v: Vec<f64> = (0..28)
.map(|i| (i % 7) as f64 * 10.0 + (i / 7) as f64 * 0.3)
.collect();
v[7] = 55.0; let report = run(&col_corpus(&v), &weekly(7));
assert_eq!(report.findings.len(), 1);
assert!(matches!(
report.findings[0].handle,
Handle::Cell { row: 7, .. }
));
}
#[test]
fn deterministic_across_runs() {
let mut s = seasonal_series();
s[14] = 50.0;
let rs = col_corpus(&s);
let a = run(&rs, &weekly(7));
let b = run(&rs, &weekly(7));
assert_eq!(
serde_json::to_string(&a.findings).unwrap(),
serde_json::to_string(&b.findings).unwrap()
);
}
}