use std::fmt::Debug;
#[derive(Clone, Debug)]
pub struct Correction<State> {
pub seen: State,
pub settled: State,
}
#[derive(Clone, Debug)]
pub struct CorrectionMonitor {
mean: f32,
var: f32,
alpha: f32,
sigma: f32,
floor: f32,
warmup: u64,
samples: u64,
outliers: u64,
peak: f32,
}
impl Default for CorrectionMonitor {
fn default() -> Self {
Self::new()
}
}
impl CorrectionMonitor {
pub fn new() -> Self {
Self {
mean: 0.0,
var: 0.0,
alpha: 0.03,
sigma: 4.0,
floor: 0.0,
warmup: 32,
samples: 0,
outliers: 0,
peak: 0.0,
}
}
pub fn with_warmup(mut self, samples: u64) -> Self {
self.warmup = samples;
self
}
pub fn is_warming_up(&self) -> bool {
self.samples < self.warmup
}
pub fn with_smoothing(mut self, alpha: f32) -> Self {
self.alpha = alpha.clamp(0.0, 1.0);
self
}
pub fn with_sigma(mut self, sigma: f32) -> Self {
self.sigma = sigma.max(0.0);
self
}
pub fn with_floor(mut self, floor: f32) -> Self {
self.floor = floor.max(0.0);
self
}
pub fn record(&mut self, magnitude: f32) -> bool {
let magnitude = if magnitude.is_finite() { magnitude.max(0.0) } else { return false };
let warming = self.samples < self.warmup;
let abnormal = !warming && magnitude > self.threshold();
let alpha = if warming { self.alpha.max(1.0 / (self.samples as f32 + 1.0)) } else { self.alpha };
let sample = if warming { magnitude } else { magnitude.min(self.threshold()) };
let delta = sample - self.mean;
self.mean += alpha * delta;
self.var += alpha * (delta * delta - self.var);
self.samples += 1;
self.peak = self.peak.max(magnitude);
if abnormal {
self.outliers += 1;
}
abnormal
}
pub fn is_abnormal(&self, magnitude: f32) -> bool {
!self.is_warming_up() && magnitude > self.threshold()
}
pub fn threshold(&self) -> f32 {
self.mean + self.band()
}
pub fn band(&self) -> f32 {
(self.sigma * self.var.max(0.0).sqrt()).max(self.floor)
}
pub fn norm(&self) -> f32 {
self.mean
}
pub fn peak(&self) -> f32 {
self.peak
}
pub fn counts(&self) -> (u64, u64) {
(self.samples, self.outliers)
}
pub fn reset(&mut self) {
*self = Self {
alpha: self.alpha,
sigma: self.sigma,
floor: self.floor,
warmup: self.warmup,
..Self::new()
};
}
}
#[cfg(test)]
mod tests {
use super::*;
mod the_knobs {
use super::*;
fn warmed(mut m: CorrectionMonitor, level: f32) -> CorrectionMonitor {
for i in 0..200 {
m.record(level + (i % 5) as f32 * 0.01);
}
m
}
#[test]
fn a_wider_sigma_flags_less() {
let tight = warmed(CorrectionMonitor::new().with_floor(0.1).with_sigma(1.0), 10.0);
let loose = warmed(CorrectionMonitor::new().with_floor(0.1).with_sigma(6.0), 10.0);
assert!(
loose.threshold() > tight.threshold(),
"six sigma is a higher bar than one: {} against {}",
loose.threshold(),
tight.threshold()
);
}
#[test]
fn a_negative_sigma_is_clamped_rather_than_inverted() {
let m = warmed(CorrectionMonitor::new().with_floor(1.0).with_sigma(-3.0), 10.0);
assert!(m.threshold() >= m.norm(), "the band never goes below the baseline");
}
#[test]
fn smoothing_decides_how_fast_the_baseline_follows_a_step() {
let mut slow = CorrectionMonitor::new().with_floor(0.1).with_smoothing(0.01);
let mut fast = CorrectionMonitor::new().with_floor(0.1).with_smoothing(0.5);
for _ in 0..200 {
slow.record(1.0);
fast.record(1.0);
}
for _ in 0..20 {
slow.record(50.0);
fast.record(50.0);
}
assert!(
fast.norm() > slow.norm(),
"the fast baseline has followed the step further: {} against {}",
fast.norm(),
slow.norm()
);
}
#[test]
fn smoothing_outside_zero_to_one_is_clamped() {
let m = CorrectionMonitor::new().with_smoothing(9.0);
let n = CorrectionMonitor::new().with_smoothing(-9.0);
for mut each in [m, n] {
for _ in 0..50 {
each.record(5.0);
}
assert!(each.norm().is_finite(), "a clamped alpha keeps the baseline finite");
}
}
#[test]
fn asking_whether_something_is_abnormal_does_not_record_it() {
let mut m = warmed(CorrectionMonitor::new().with_floor(1.0), 10.0);
let before = (m.norm(), m.threshold(), m.counts().1);
assert!(m.is_abnormal(10_000.0), "plainly out of band");
assert_eq!(
(m.norm(), m.threshold(), m.counts().1),
before,
"and asking changed nothing"
);
m.record(10_000.0);
assert!(m.counts().1 > before.2, "where recording it does");
}
#[test]
fn nothing_is_abnormal_while_the_monitor_is_still_warming_up() {
let m = CorrectionMonitor::new().with_floor(0.1);
assert!(m.is_warming_up());
assert!(!m.is_abnormal(10_000.0), "no verdict before there is a baseline");
}
}
#[test]
fn a_steady_stream_of_similar_corrections_is_never_abnormal() {
let mut m = CorrectionMonitor::new().with_floor(1.0);
let mut flagged = 0;
for i in 0..500 {
let magnitude = 30.0 + (i % 7) as f32 * 0.5;
if m.record(magnitude) {
flagged += 1;
}
}
assert!(flagged <= 2, "a steady level should settle and stop flagging, got {flagged}");
assert!(m.norm() > 25.0, "the baseline should have followed the level, got {}", m.norm());
}
#[test]
fn a_genuine_outlier_is_flagged_against_a_settled_baseline() {
let mut m = CorrectionMonitor::new().with_floor(1.0);
for _ in 0..300 {
m.record(10.0);
}
assert!(!m.record(11.0), "ordinary variation is not an outlier");
assert!(m.record(400.0), "a correction far above the norm is an outlier");
let (samples, outliers) = m.counts();
assert_eq!(outliers, 1);
assert_eq!(samples, 302);
assert_eq!(m.peak(), 400.0);
}
#[test]
fn one_huge_correction_does_not_blind_the_monitor_afterwards() {
let mut m = CorrectionMonitor::new().with_floor(1.0);
for _ in 0..300 {
m.record(10.0);
}
m.record(5000.0);
let norm_after = m.norm();
assert!(norm_after < 20.0, "a single spike moved the norm to {norm_after}");
assert!(m.record(500.0), "the monitor should still notice the next real outlier");
}
#[test]
fn a_sustained_shift_recentres_rather_than_alarming_forever() {
let mut m = CorrectionMonitor::new().with_floor(1.0);
for _ in 0..300 {
m.record(5.0);
}
for _ in 0..500 {
m.record(40.0);
}
let mut tail_flags = 0;
for _ in 0..200 {
if m.record(40.0) {
tail_flags += 1;
}
}
assert_eq!(tail_flags, 0, "a settled new normal should be silent, got {tail_flags} flags");
assert!(m.norm() > 30.0, "the baseline should have moved to the new level, got {}", m.norm());
}
#[test]
fn a_floor_keeps_perfect_prediction_from_flagging_noise() {
let mut m = CorrectionMonitor::new().with_floor(8.0);
for _ in 0..300 {
m.record(0.0);
}
assert!(!m.record(3.0), "sub-floor jitter must not flag");
assert!(m.record(50.0), "something well past the floor still should");
}
}