use ax_core::envelope::Absence;
use ax_core::{AnomalyClass, Finding, RecordSet};
pub mod cadence;
pub mod coll;
pub mod config;
pub mod ctx;
pub mod dist;
pub mod linalg;
pub mod mv;
pub mod point;
pub mod robustz;
pub mod structural;
pub use cadence::CadenceDetector;
pub use coll::CusumDetector;
pub use config::DetectConfig;
pub use ctx::SeasonalDetector;
pub use dist::{Chi2Detector, KsDetector, PsiDetector};
pub use mv::MahalanobisDetector;
pub use point::PointDetector;
pub use structural::SchemaDetector;
#[derive(Debug, Clone, Copy)]
pub struct ScanContext<'a> {
pub current: &'a RecordSet,
pub baseline: Option<&'a RecordSet>,
}
impl<'a> ScanContext<'a> {
pub fn single(current: &'a RecordSet) -> Self {
ScanContext {
current,
baseline: None,
}
}
pub fn compared(baseline: &'a RecordSet, current: &'a RecordSet) -> Self {
ScanContext {
current,
baseline: Some(baseline),
}
}
}
#[derive(Debug, Default)]
pub struct Report {
pub findings: Vec<Finding>,
pub absent: Vec<Absence>,
}
impl Report {
pub fn new() -> Self {
Report::default()
}
pub fn push(&mut self, f: Finding) {
self.findings.push(f);
}
pub fn mark_absent(&mut self, detector: &str, reason: impl Into<String>) {
self.absent.push(Absence {
detector: detector.to_string(),
reason: reason.into(),
});
}
pub fn is_clean(&self) -> bool {
self.findings.is_empty()
}
}
pub trait Detector {
fn id(&self) -> &'static str;
fn class(&self) -> AnomalyClass;
fn detect(&self, ctx: &ScanContext, cfg: &DetectConfig, out: &mut Report);
}
pub struct Registry {
detectors: Vec<Box<dyn Detector>>,
}
impl Registry {
pub fn new() -> Self {
Registry {
detectors: Vec::new(),
}
}
pub fn default_set() -> Self {
let mut r = Registry::new();
r.register(Box::new(PointDetector));
r.register(Box::new(SchemaDetector));
r.register(Box::new(KsDetector));
r.register(Box::new(PsiDetector));
r.register(Box::new(Chi2Detector));
r.register(Box::new(MahalanobisDetector));
r.register(Box::new(SeasonalDetector));
r.register(Box::new(CusumDetector));
r.register(Box::new(CadenceDetector));
r
}
pub fn register(&mut self, d: Box<dyn Detector>) -> &mut Self {
self.detectors.push(d);
self
}
pub fn ids(&self) -> Vec<&'static str> {
self.detectors.iter().map(|d| d.id()).collect()
}
pub fn run(&self, ctx: &ScanContext, cfg: &DetectConfig) -> Report {
let mut out = Report::new();
for d in &self.detectors {
d.detect(ctx, cfg, &mut out);
}
out
}
}
impl Default for Registry {
fn default() -> Self {
Registry::default_set()
}
}
#[cfg(test)]
mod tests {
use super::*;
use ax_core::{Column, Value};
#[test]
fn report_is_clean_only_without_findings() {
let mut r = Report::new();
assert!(r.is_clean());
r.push(Finding::new(
"d",
AnomalyClass::Point,
ax_core::Handle::Column { name: "x".into() },
0.9,
1.0,
"r",
));
assert!(!r.is_clean());
}
#[test]
fn registry_registers_the_default_detector_set() {
let reg = Registry::default_set();
assert_eq!(
reg.ids(),
vec![
"point.modz",
"struct.schema",
"dist.ks",
"dist.psi",
"dist.chi2",
"mv.mahalanobis",
"ctx.seasonal",
"coll.cusum",
"cad.regularity"
]
);
}
#[test]
fn single_corpus_clean_numeric_has_no_point_findings() {
let rs = RecordSet::new(
"-",
"test",
vec![Column::new(
"x",
(0..12).map(|i| Value::Int(10 + i % 3)).collect(),
)],
);
let report =
Registry::default_set().run(&ScanContext::single(&rs), &DetectConfig::default());
assert!(report.findings.is_empty());
assert!(report.absent.iter().any(|a| a.detector == "dist.ks"));
}
#[test]
fn registry_run_surfaces_point_finding() {
let mut cells: Vec<Value> = (0..12).map(|i| Value::Int(10 + i % 3)).collect();
cells.push(Value::Int(100_000)); let rs = RecordSet::new("-", "test", vec![Column::new("x", cells)]);
let report =
Registry::default_set().run(&ScanContext::single(&rs), &DetectConfig::default());
assert!(report.findings.iter().any(|f| f.detector == "point.modz"));
}
}