use std::collections::VecDeque;
use std::rc::Rc;
use augurs::outlier::{DbscanDetector, MADDetector, OutlierDetector, OutlierOutput};
use super::AugursOutliers;
use crate::types::*;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum AugursOutlierDetector {
#[default]
Mad,
Dbscan,
}
#[derive(Debug, Clone)]
pub struct AugursOutlierConfig {
pub window: usize,
pub sensitivity: f64,
pub detector: AugursOutlierDetector,
}
impl AugursOutlierConfig {
#[must_use]
pub fn new(window: usize, sensitivity: f64) -> Self {
Self {
window,
sensitivity,
detector: AugursOutlierDetector::Mad,
}
}
#[must_use]
pub fn mad(window: usize, sensitivity: f64) -> Self {
Self::new(window, sensitivity)
}
#[must_use]
pub fn dbscan(window: usize, sensitivity: f64) -> Self {
Self {
window,
sensitivity,
detector: AugursOutlierDetector::Dbscan,
}
}
}
impl From<(usize, f64)> for AugursOutlierConfig {
fn from((window, sensitivity): (usize, f64)) -> Self {
Self::new(window, sensitivity)
}
}
enum Detector {
Mad(MADDetector),
Dbscan(DbscanDetector),
}
impl Detector {
fn run(&self, series: &[&[f64]]) -> anyhow::Result<OutlierOutput> {
match self {
Detector::Mad(d) => {
let pre = d
.preprocess(series)
.map_err(|e| anyhow::anyhow!("augurs_outlier: MAD preprocess failed: {e}"))?;
d.detect(&pre)
.map_err(|e| anyhow::anyhow!("augurs_outlier: MAD detect failed: {e}"))
}
Detector::Dbscan(d) => {
let pre = d.preprocess(series).map_err(|e| {
anyhow::anyhow!("augurs_outlier: DBSCAN preprocess failed: {e}")
})?;
d.detect(&pre)
.map_err(|e| anyhow::anyhow!("augurs_outlier: DBSCAN detect failed: {e}"))
}
}
}
}
pub(crate) struct AugursOutlierNode {
upstream: Rc<dyn Stream<Vec<f64>>>,
detector: Detector,
window: usize,
buffer: VecDeque<Vec<f64>>,
value: AugursOutliers,
}
impl AugursOutlierNode {
fn new(upstream: Rc<dyn Stream<Vec<f64>>>, detector: Detector, window: usize) -> Self {
Self {
upstream,
detector,
window: window.max(2),
buffer: VecDeque::with_capacity(window.max(2)),
value: AugursOutliers::default(),
}
}
}
#[node(active = [upstream], output = value: AugursOutliers)]
impl MutableNode for AugursOutlierNode {
fn cycle(&mut self, _state: &mut GraphState) -> anyhow::Result<bool> {
super::push_windowed(&mut self.buffer, self.upstream.peek_value(), self.window);
if self.buffer.len() < 2 {
return Ok(false);
}
let series = super::transpose_window(&self.buffer);
if series.is_empty() {
return Ok(false);
}
let refs: Vec<&[f64]> = series.iter().map(Vec::as_slice).collect();
let output = self.detector.run(&refs)?;
self.value = AugursOutliers {
outlying: output.outlying_series.iter().copied().collect(),
scores: output
.series_results
.iter()
.map(|s| s.scores.last().copied().unwrap_or(0.0))
.collect(),
};
Ok(true)
}
}
pub trait AugursOutlierOperators {
#[must_use]
fn augurs_outlier(
self: &Rc<Self>,
config: impl Into<AugursOutlierConfig>,
) -> Rc<dyn Stream<AugursOutliers>>;
}
impl AugursOutlierOperators for dyn Stream<Vec<f64>> {
fn augurs_outlier(
self: &Rc<Self>,
config: impl Into<AugursOutlierConfig>,
) -> Rc<dyn Stream<AugursOutliers>> {
let config = config.into();
let detector = match config.detector {
AugursOutlierDetector::Mad => Detector::Mad(
MADDetector::with_sensitivity(config.sensitivity).unwrap_or_else(|e| {
panic!(
"augurs_outlier: invalid sensitivity {}: {e}",
config.sensitivity
)
}),
),
AugursOutlierDetector::Dbscan => Detector::Dbscan(
DbscanDetector::with_sensitivity(config.sensitivity).unwrap_or_else(|e| {
panic!(
"augurs_outlier: invalid sensitivity {}: {e}",
config.sensitivity
)
}),
),
};
AugursOutlierNode::new(self.clone(), detector, config.window).into_stream()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::*;
use crate::nodes::*;
use std::time::Duration;
#[test]
fn outlier_mad_flags_diverging_series() {
let readings = ticker(Duration::from_secs(1)).count().map(|n| {
let base = 100.0 + (n as f64 * 0.4).sin();
let series2 = if n > 20 { base + 80.0 } else { base + 0.2 };
vec![base, base + 0.1, series2]
});
let outliers = readings.augurs_outlier(AugursOutlierConfig::new(40, 0.5));
let captured = outliers.clone().collect();
captured
.run(RunMode::HistoricalFrom(NanoTime::ZERO), RunFor::Cycles(40))
.unwrap();
let last = outliers.peek_value();
assert_eq!(last.scores.len(), 3, "one score per series");
assert!(
last.is_outlier(2),
"series 2 diverged and should be flagged, got {last:?}"
);
assert!(!last.is_outlier(0));
assert!(!last.is_outlier(1));
}
#[test]
fn outlier_dbscan_flags_diverging_series() {
let readings = ticker(Duration::from_secs(1)).count().map(|n| {
let base = 100.0 + (n as f64 * 0.4).sin();
let series3 = if n > 15 { base + 90.0 } else { base + 0.3 };
vec![base, base + 0.1, base - 0.1, series3]
});
let outliers = readings.augurs_outlier(AugursOutlierConfig::dbscan(40, 0.5));
let captured = outliers.clone().collect();
captured
.run(RunMode::HistoricalFrom(NanoTime::ZERO), RunFor::Cycles(40))
.unwrap();
let last = outliers.peek_value();
assert_eq!(last.scores.len(), 4);
assert!(
last.is_outlier(3),
"series 3 diverged and should be flagged by DBSCAN, got {last:?}"
);
}
#[test]
fn outlier_quiet_when_aligned() {
let readings = ticker(Duration::from_secs(1)).count().map(|n| {
let base = 50.0 + (n as f64 * 0.3).sin();
vec![base, base + 0.05, base - 0.05]
});
let outliers = readings.augurs_outlier((30, 0.5));
let captured = outliers.clone().collect();
captured
.run(RunMode::HistoricalFrom(NanoTime::ZERO), RunFor::Cycles(30))
.unwrap();
assert!(
outliers.peek_value().outlying.is_empty(),
"aligned series should produce no outliers, got {:?}",
outliers.peek_value()
);
}
#[test]
fn outlier_waits_for_two_samples() {
let readings = ticker(Duration::from_secs(1))
.count()
.map(|n| vec![n as f64, n as f64 + 1.0]);
let outliers = readings.augurs_outlier((8, 0.5));
let captured = outliers.clone().collect();
captured
.run(RunMode::HistoricalFrom(NanoTime::ZERO), RunFor::Cycles(1))
.unwrap();
assert!(captured.peek_value().is_empty());
}
}