use std::{
collections::HashMap,
hash::{BuildHasher, Hash, RandomState},
time::Duration,
};
use crate::Timestamp;
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct EwmaVarValue {
pub mean: f64,
pub variance: f64,
}
impl EwmaVarValue {
pub fn std(&self) -> f64 {
self.variance.max(0.0).sqrt()
}
}
#[derive(Debug)]
pub struct EwmaVar<K: Hash + Eq, S: BuildHasher = RandomState> {
alpha: f64,
entries: HashMap<K, Entry, S>,
}
#[derive(Debug, Clone, Copy)]
struct Entry {
mean: f64,
var: f64,
last_ts: Timestamp,
}
impl<K: Hash + Eq + Clone> EwmaVar<K> {
pub fn new(alpha: f64) -> Self {
Self::with_hasher(alpha, RandomState::new())
}
}
impl<K: Hash + Eq + Clone, S: BuildHasher> EwmaVar<K, S> {
pub fn with_hasher(alpha: f64, hasher_builder: S) -> Self {
assert!(alpha > 0.0 && alpha <= 1.0, "alpha must be in (0, 1]");
Self {
alpha,
entries: HashMap::with_hasher(hasher_builder),
}
}
pub fn record(&mut self, key: K, sample: f64) -> EwmaVarValue {
self.record_at(key, sample, Timestamp::default())
}
pub fn record_at(&mut self, key: K, sample: f64, now: Timestamp) -> EwmaVarValue {
match self.entries.get_mut(&key) {
Some(entry) => {
entry.last_ts = now;
if entry.mean.is_nan() {
entry.mean = sample;
entry.var = 0.0;
} else {
let diff = sample - entry.mean;
let incr = self.alpha * diff;
entry.mean += incr;
entry.var = (1.0 - self.alpha) * (entry.var + diff * incr);
}
EwmaVarValue {
mean: entry.mean,
variance: entry.var,
}
}
None => {
self.entries.insert(
key,
Entry {
mean: sample,
var: 0.0,
last_ts: now,
},
);
EwmaVarValue {
mean: sample,
variance: 0.0,
}
}
}
}
pub fn get(&self, key: &K) -> Option<EwmaVarValue> {
self.entries.get(key).map(|e| EwmaVarValue {
mean: e.mean,
variance: e.var,
})
}
pub fn zscore(&self, key: &K, sample: f64) -> Option<f64> {
let v = self.get(key)?;
let std = v.std();
if std <= f64::EPSILON {
return Some(0.0);
}
Some((sample - v.mean) / std)
}
pub fn iter(&self) -> impl Iterator<Item = (&K, EwmaVarValue)> {
self.entries.iter().map(|(k, e)| {
(
k,
EwmaVarValue {
mean: e.mean,
variance: e.var,
},
)
})
}
pub fn evict_stale(&mut self, now: Timestamp, ttl: Duration) {
let now_dur = now.to_duration();
self.entries
.retain(|_, e| now_dur.saturating_sub(e.last_ts.to_duration()) <= ttl);
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
impl<K: Hash + Eq + Clone, S: BuildHasher> crate::correlate::Mergeable for EwmaVar<K, S> {
fn merge(&mut self, other: Self) {
assert_eq!(
self.alpha, other.alpha,
"EwmaVar::merge requires matching alpha",
);
for (k, e_other) in other.entries {
match self.entries.get_mut(&k) {
Some(e_self) => {
let mean_diff = e_self.mean - e_other.mean;
e_self.var = 0.5 * (e_self.var + e_other.var) + 0.25 * mean_diff * mean_diff;
e_self.mean = 0.5 * (e_self.mean + e_other.mean);
if e_other.last_ts > e_self.last_ts {
e_self.last_ts = e_other.last_ts;
}
}
None => {
self.entries.insert(k, e_other);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn alpha_one_tracks_last_sample_with_zero_variance() {
let mut e: EwmaVar<u32> = EwmaVar::new(1.0);
e.record(1, 10.0);
let v = e.record(1, 5.0);
assert!((v.mean - 5.0).abs() < 1e-9);
assert!(v.variance.abs() < 1e-9, "alpha=1 has no memory → var 0");
}
#[test]
fn constant_stream_variance_decays_to_zero() {
let mut e: EwmaVar<u32> = EwmaVar::new(0.3);
for _ in 0..100 {
e.record(1, 42.0);
}
let v = e.get(&1).unwrap();
assert!((v.mean - 42.0).abs() < 1e-9);
assert!(v.variance < 1e-12);
}
#[test]
fn two_sample_hand_computation() {
let mut e: EwmaVar<u32> = EwmaVar::new(0.5);
e.record(1, 10.0);
let v = e.record(1, 20.0);
assert!((v.mean - 15.0).abs() < 1e-9);
assert!((v.variance - 25.0).abs() < 1e-9);
assert!((v.std() - 5.0).abs() < 1e-9);
}
#[test]
fn zscore_warmup_returns_zero() {
let mut e: EwmaVar<u32> = EwmaVar::new(0.5);
assert_eq!(e.zscore(&1, 100.0), None, "no baseline yet");
e.record(1, 10.0);
assert_eq!(e.zscore(&1, 1_000_000.0), Some(0.0));
}
#[test]
fn zscore_flags_outlier_after_stable_baseline() {
let mut e: EwmaVar<u32> = EwmaVar::new(0.1);
for i in 0..50 {
let jitter = if i % 2 == 0 { 2.0 } else { -2.0 };
e.record(1, 100.0 + jitter);
}
let z_normal = e.zscore(&1, 101.0).unwrap();
let z_outlier = e.zscore(&1, 200.0).unwrap();
assert!(z_normal.abs() < 3.0, "in-band sample: z={z_normal}");
assert!(z_outlier > 3.0, "20x spike must exceed 3σ: z={z_outlier}");
}
#[test]
fn per_key_isolation() {
let mut e: EwmaVar<u32> = EwmaVar::new(1.0);
e.record(1, 10.0);
e.record(2, 20.0);
assert_eq!(e.get(&1).unwrap().mean, 10.0);
assert_eq!(e.get(&2).unwrap().mean, 20.0);
assert_eq!(e.len(), 2);
}
#[test]
fn evict_stale_drops_old_entries() {
let mut e: EwmaVar<u32> = EwmaVar::new(0.5);
e.record_at(1, 1.0, Timestamp::new(0, 0));
e.record_at(2, 2.0, Timestamp::new(100, 0));
e.evict_stale(Timestamp::new(100, 0), Duration::from_secs(10));
assert!(e.get(&1).is_none());
assert!(e.get(&2).is_some());
}
#[test]
#[should_panic(expected = "alpha must be in (0, 1]")]
fn zero_alpha_panics() {
let _: EwmaVar<u32> = EwmaVar::new(0.0);
}
#[test]
#[should_panic(expected = "alpha must be in (0, 1]")]
fn alpha_above_one_panics() {
let _: EwmaVar<u32> = EwmaVar::new(1.5);
}
}