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::*;
#[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");
}
}