use std::sync::Mutex;
use std::time::{Duration, Instant};
#[derive(Debug)]
pub struct Ewma {
half_life_secs: f64,
state: Mutex<EwmaState>,
}
#[derive(Debug)]
struct EwmaState {
current: Option<f64>,
last: Option<Instant>,
}
impl Ewma {
pub fn new(half_life: Duration) -> Self {
let secs = half_life.as_secs_f64();
assert!(secs > 0.0, "Ewma half-life must be > 0");
Self {
half_life_secs: secs,
state: Mutex::new(EwmaState {
current: None,
last: None,
}),
}
}
pub fn half_life(&self) -> Duration {
Duration::from_secs_f64(self.half_life_secs)
}
pub fn record(&self, value: f64, now: Instant) {
let mut g = self.state.lock().unwrap_or_else(|e| e.into_inner());
match (g.current, g.last) {
(None, _) | (_, None) => {
g.current = Some(value);
g.last = Some(now);
}
(Some(curr), Some(last)) => {
let dt_secs = now.saturating_duration_since(last).as_secs_f64();
let alpha = 1.0 - 2f64.powf(-dt_secs / self.half_life_secs);
let blended = alpha * value + (1.0 - alpha) * curr;
g.current = Some(blended);
g.last = Some(now);
}
}
}
pub fn record_now(&self, value: f64) {
self.record(value, Instant::now());
}
pub fn peek(&self) -> Option<f64> {
self.state.lock().unwrap_or_else(|e| e.into_inner()).current
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_sample_initializes_without_decay() {
let e = Ewma::new(Duration::from_secs(1));
assert!(e.peek().is_none());
let t0 = Instant::now();
e.record(42.0, t0);
assert_eq!(e.peek(), Some(42.0));
}
#[test]
fn zero_dt_updates_take_full_weight() {
let e = Ewma::new(Duration::from_secs(1));
let t0 = Instant::now();
e.record(10.0, t0);
e.record(20.0, t0);
assert_eq!(e.peek(), Some(10.0));
}
#[test]
fn half_life_behavior() {
let e = Ewma::new(Duration::from_secs(1));
let t0 = Instant::now();
e.record(0.0, t0);
e.record(10.0, t0 + Duration::from_secs(1));
let v = e.peek().unwrap();
assert!((v - 5.0).abs() < 1e-9, "expected 5.0, got {v}");
}
#[test]
fn converges_to_steady_value() {
let e = Ewma::new(Duration::from_millis(100));
let mut t = Instant::now();
for _ in 0..200 {
e.record(100.0, t);
t += Duration::from_millis(10);
}
let v = e.peek().unwrap();
assert!((v - 100.0).abs() < 1e-6, "expected ~100, got {v}");
}
#[test]
fn clock_skew_treated_as_zero_dt() {
let e = Ewma::new(Duration::from_secs(1));
let t0 = Instant::now();
e.record(5.0, t0 + Duration::from_secs(10));
e.record(999.0, t0);
assert_eq!(e.peek(), Some(5.0));
}
#[test]
#[should_panic(expected = "half-life must be > 0")]
fn zero_half_life_rejected() {
let _ = Ewma::new(Duration::ZERO);
}
}