Skip to main content

rill_ml/drift/
decay.rs

1//! Decay-aware learning utilities.
2//!
3//! This module provides three utilities for adapting to non-stationary
4//! streams:
5//!
6//! - [`TimeDecayedMean`]: exponentially decays the weight of older
7//!   observations, giving more influence to recent data.
8//! - [`LearningRateScheduler`]: adjusts the learning rate based on the
9//!   current drift level reported by a detector.
10//! - [`FixedWindowBuffer`]: a bounded ring buffer that stores the most
11//!   recent `N` observations for window-based training or replay.
12//!
13//! All three components use bounded memory and are independent of any
14//! specific model or detector.
15
16use crate::drift::detector::DriftLevel;
17use crate::error::{RillError, ensure_finite};
18
19// ---------------------------------------------------------------------------
20// TimeDecayedMean
21// ---------------------------------------------------------------------------
22
23/// An exponentially time-decayed weighted mean.
24///
25/// Each observation `(t_i, v_i)` contributes `v_i · exp(-decay · (t_now - t_i))`
26/// to the weighted sum. The mean is `Σ(v_i · w_i) / Σ(w_i)`. Older
27/// observations receive exponentially smaller weights, making the statistic
28/// responsive to recent changes.
29///
30/// Time complexity per update: `O(1)`. Space complexity: `O(1)`.
31///
32/// # Examples
33///
34/// ```
35/// use rill_ml::drift::TimeDecayedMean;
36///
37/// let mut m = TimeDecayedMean::new(0.1).unwrap();
38/// m.update(0.0, 10.0).unwrap();
39/// m.update(1.0, 20.0).unwrap();
40/// m.update(2.0, 30.0).unwrap();
41/// // The mean should be closer to 30 than to the simple average (20)
42/// // because recent observations are weighted higher.
43/// let v = m.value().unwrap();
44/// assert!(v > 20.0, "recent data should dominate: {}", v);
45/// ```
46#[derive(Debug, Clone)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48pub struct TimeDecayedMean {
49    decay: f64,
50    weighted_sum: f64,
51    weight_total: f64,
52    last_time: Option<f64>,
53}
54
55impl TimeDecayedMean {
56    /// Create a new time-decayed mean with the given decay rate.
57    ///
58    /// `decay` must be finite and strictly positive. Larger values cause
59    /// faster forgetting.
60    pub fn new(decay: f64) -> Result<Self, RillError> {
61        ensure_finite("decay", decay)?;
62        if decay <= 0.0 {
63            return Err(RillError::InvalidParameter {
64                name: "decay",
65                value: decay,
66            });
67        }
68        Ok(Self {
69            decay,
70            weighted_sum: 0.0,
71            weight_total: 0.0,
72            last_time: None,
73        })
74    }
75
76    /// The configured decay rate.
77    pub const fn decay(&self) -> f64 {
78        self.decay
79    }
80
81    /// Update with a new observation at time `t` with value `v`.
82    ///
83    /// `t` must be finite and must not decrease (i.e., `t >= last_time`).
84    /// `v` must be finite.
85    pub fn update(&mut self, time: f64, value: f64) -> Result<(), RillError> {
86        ensure_finite("time", time)?;
87        ensure_finite("value", value)?;
88        match self.last_time {
89            None => {
90                // First sample: seed directly.
91                self.weighted_sum = value;
92                self.weight_total = 1.0;
93            }
94            Some(prev) => {
95                if time < prev {
96                    return Err(RillError::InvalidParameter {
97                        name: "time",
98                        value: time,
99                    });
100                }
101                let dt = time - prev;
102                let factor = (-self.decay * dt).exp();
103                self.weighted_sum = factor * self.weighted_sum + value;
104                self.weight_total = factor * self.weight_total + 1.0;
105            }
106        }
107        self.last_time = Some(time);
108        Ok(())
109    }
110
111    /// The current decayed mean, or `None` if no observations have been seen.
112    pub fn value(&self) -> Option<f64> {
113        if self.weight_total > 0.0 {
114            Some(self.weighted_sum / self.weight_total)
115        } else {
116            None
117        }
118    }
119
120    /// The total weight accumulated so far.
121    pub const fn weight_total(&self) -> f64 {
122        self.weight_total
123    }
124
125    /// The last observation's timestamp, or `None` if no observations yet.
126    pub const fn last_time(&self) -> Option<f64> {
127        self.last_time
128    }
129
130    /// Reset to the initial (no-data) state.
131    pub fn reset(&mut self) {
132        self.weighted_sum = 0.0;
133        self.weight_total = 0.0;
134        self.last_time = None;
135    }
136}
137
138// ---------------------------------------------------------------------------
139// LearningRateScheduler
140// ---------------------------------------------------------------------------
141
142/// A learning-rate scheduler that adjusts the rate based on drift state.
143///
144/// - [`DriftLevel::None`]: use `base_lr`.
145/// - [`DriftLevel::Warning`]: use `base_lr * warning_multiplier`.
146/// - [`DriftLevel::Drift`]: use `base_lr * drift_multiplier`.
147///
148/// Space complexity: `O(1)`.
149///
150/// # Examples
151///
152/// ```
153/// use rill_ml::drift::{DriftLevel, LearningRateScheduler};
154///
155/// let mut sched = LearningRateScheduler::new(0.01, 2.0, 5.0).unwrap();
156/// assert!((sched.current_lr() - 0.01).abs() < 1e-12);
157///
158/// sched.on_drift_level(DriftLevel::Warning);
159/// assert!((sched.current_lr() - 0.02).abs() < 1e-12);
160///
161/// sched.on_drift_level(DriftLevel::Drift);
162/// assert!((sched.current_lr() - 0.05).abs() < 1e-12);
163/// ```
164#[derive(Debug, Clone)]
165#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
166pub struct LearningRateScheduler {
167    base_lr: f64,
168    warning_multiplier: f64,
169    drift_multiplier: f64,
170    current_state: DriftLevel,
171}
172
173impl LearningRateScheduler {
174    /// Create a new scheduler.
175    ///
176    /// Returns an error if:
177    /// - `base_lr` is not finite or not strictly positive.
178    /// - `warning_multiplier` is not finite or less than 1.
179    /// - `drift_multiplier` is not finite or less than `warning_multiplier`.
180    pub fn new(
181        base_lr: f64,
182        warning_multiplier: f64,
183        drift_multiplier: f64,
184    ) -> Result<Self, RillError> {
185        ensure_finite("base_lr", base_lr)?;
186        ensure_finite("warning_multiplier", warning_multiplier)?;
187        ensure_finite("drift_multiplier", drift_multiplier)?;
188        if base_lr <= 0.0 {
189            return Err(RillError::InvalidLearningRate(base_lr));
190        }
191        if warning_multiplier < 1.0 {
192            return Err(RillError::InvalidParameter {
193                name: "warning_multiplier",
194                value: warning_multiplier,
195            });
196        }
197        if drift_multiplier < warning_multiplier {
198            return Err(RillError::InvalidParameter {
199                name: "drift_multiplier",
200                value: drift_multiplier,
201            });
202        }
203        Ok(Self {
204            base_lr,
205            warning_multiplier,
206            drift_multiplier,
207            current_state: DriftLevel::None,
208        })
209    }
210
211    /// The configured base learning rate.
212    pub const fn base_lr(&self) -> f64 {
213        self.base_lr
214    }
215
216    /// The configured warning multiplier.
217    pub const fn warning_multiplier(&self) -> f64 {
218        self.warning_multiplier
219    }
220
221    /// The configured drift multiplier.
222    pub const fn drift_multiplier(&self) -> f64 {
223        self.drift_multiplier
224    }
225
226    /// The current drift state.
227    pub const fn current_state(&self) -> DriftLevel {
228        self.current_state
229    }
230
231    /// Update the scheduler with the latest drift level from a detector.
232    pub fn on_drift_level(&mut self, level: DriftLevel) {
233        self.current_state = level;
234    }
235
236    /// The current learning rate, adjusted for the drift state.
237    pub fn current_lr(&self) -> f64 {
238        match self.current_state {
239            DriftLevel::None => self.base_lr,
240            DriftLevel::Warning => self.base_lr * self.warning_multiplier,
241            DriftLevel::Drift => self.base_lr * self.drift_multiplier,
242        }
243    }
244
245    /// Reset to the `None` drift state (use `base_lr`).
246    pub fn reset(&mut self) {
247        self.current_state = DriftLevel::None;
248    }
249}
250
251impl Default for LearningRateScheduler {
252    fn default() -> Self {
253        Self::new(0.01, 2.0, 5.0).expect("default config is valid")
254    }
255}
256
257// ---------------------------------------------------------------------------
258// FixedWindowBuffer
259// ---------------------------------------------------------------------------
260
261/// A bounded ring buffer storing the most recent `capacity` observations.
262///
263/// When the buffer is full, pushing a new value overwrites the oldest entry.
264/// Useful for fixed-window training or replay where only recent data matters.
265///
266/// Space complexity: `O(capacity)`.
267///
268/// # Examples
269///
270/// ```
271/// use rill_ml::drift::FixedWindowBuffer;
272///
273/// let mut buf = FixedWindowBuffer::new(3).unwrap();
274/// buf.push(1.0).unwrap();
275/// buf.push(2.0).unwrap();
276/// buf.push(3.0).unwrap();
277/// assert_eq!(buf.mean(), Some(2.0));
278///
279/// // Overwrites the oldest entry (1.0).
280/// buf.push(4.0).unwrap();
281/// assert_eq!(buf.mean(), Some(3.0)); // (2 + 3 + 4) / 3
282/// ```
283#[derive(Debug, Clone)]
284#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
285pub struct FixedWindowBuffer {
286    buffer: Vec<f64>,
287    capacity: usize,
288    head: usize,
289    len: usize,
290}
291
292impl FixedWindowBuffer {
293    /// Create a new buffer with the given capacity.
294    ///
295    /// `capacity` must be greater than zero.
296    pub fn new(capacity: usize) -> Result<Self, RillError> {
297        if capacity == 0 {
298            return Err(RillError::InvalidCapacity(capacity));
299        }
300        Ok(Self {
301            buffer: vec![0.0; capacity],
302            capacity,
303            head: 0,
304            len: 0,
305        })
306    }
307
308    /// The maximum number of elements the buffer can hold.
309    pub const fn capacity(&self) -> usize {
310        self.capacity
311    }
312
313    /// The current number of elements stored.
314    pub const fn len(&self) -> usize {
315        self.len
316    }
317
318    /// Whether the buffer is empty.
319    pub const fn is_empty(&self) -> bool {
320        self.len == 0
321    }
322
323    /// Whether the buffer is at capacity.
324    pub const fn is_full(&self) -> bool {
325        self.len == self.capacity
326    }
327
328    /// Push a new value, overwriting the oldest entry if full.
329    ///
330    /// `value` must be finite.
331    pub fn push(&mut self, value: f64) -> Result<(), RillError> {
332        ensure_finite("value", value)?;
333        self.buffer[self.head] = value;
334        self.head = (self.head + 1) % self.capacity;
335        if self.len < self.capacity {
336            self.len += 1;
337        }
338        Ok(())
339    }
340
341    /// The mean of the stored values, or `None` if empty.
342    pub fn mean(&self) -> Option<f64> {
343        if self.len == 0 {
344            return None;
345        }
346        let sum: f64 = self.iter().sum();
347        Some(sum / self.len as f64)
348    }
349
350    /// Iterate over the stored values in insertion order (oldest to newest).
351    pub fn iter(&self) -> impl Iterator<Item = &f64> {
352        let start = if self.is_full() { self.head } else { 0 };
353        let len = self.len;
354        let cap = self.capacity;
355        (0..len).map(move |i| &self.buffer[(start + i) % cap])
356    }
357
358    /// Reset to the empty state.
359    pub fn reset(&mut self) {
360        self.head = 0;
361        self.len = 0;
362    }
363}
364
365// ---------------------------------------------------------------------------
366// Tests
367// ---------------------------------------------------------------------------
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    // --- TimeDecayedMean tests ---
374
375    #[test]
376    fn tdm_first_sample_seeds_mean() {
377        let mut m = TimeDecayedMean::new(0.1).unwrap();
378        m.update(0.0, 10.0).unwrap();
379        assert!((m.value().unwrap() - 10.0).abs() < 1e-12);
380    }
381
382    #[test]
383    fn tdm_decay_weights_old_samples() {
384        let mut m = TimeDecayedMean::new(1.0).unwrap();
385        m.update(0.0, 100.0).unwrap();
386        m.update(10.0, 1.0).unwrap();
387        // With decay=1.0 and dt=10, the old sample's weight is exp(-10) ≈ 4.5e-5.
388        // The mean should be very close to 1.0 (the recent sample).
389        let v = m.value().unwrap();
390        assert!(
391            (v - 1.0).abs() < 0.01,
392            "recent sample should dominate, got {}",
393            v
394        );
395    }
396
397    #[test]
398    fn tdm_value_correct() {
399        let mut m = TimeDecayedMean::new(0.5).unwrap();
400        m.update(0.0, 10.0).unwrap();
401        m.update(1.0, 20.0).unwrap();
402        // factor = exp(-0.5 * 1) ≈ 0.6065
403        // weighted_sum = 0.6065 * 10 + 20 = 26.065
404        // weight_total = 0.6065 + 1 = 1.6065
405        // mean = 26.065 / 1.6065 ≈ 16.22
406        let v = m.value().unwrap();
407        assert!((v - 16.22).abs() < 0.1, "expected ~16.22, got {}", v);
408    }
409
410    #[test]
411    fn tdm_reset_clears_state() {
412        let mut m = TimeDecayedMean::new(0.1).unwrap();
413        m.update(0.0, 10.0).unwrap();
414        m.update(1.0, 20.0).unwrap();
415        assert!(m.value().is_some());
416        m.reset();
417        assert!(m.value().is_none());
418        assert_eq!(m.weight_total(), 0.0);
419        assert_eq!(m.last_time(), None);
420    }
421
422    #[test]
423    fn tdm_rejects_invalid_decay() {
424        assert!(TimeDecayedMean::new(0.0).is_err());
425        assert!(TimeDecayedMean::new(-1.0).is_err());
426        assert!(TimeDecayedMean::new(f64::NAN).is_err());
427        assert!(TimeDecayedMean::new(f64::INFINITY).is_err());
428    }
429
430    #[test]
431    fn tdm_rejects_non_finite() {
432        let mut m = TimeDecayedMean::new(0.1).unwrap();
433        assert!(m.update(f64::NAN, 1.0).is_err());
434        assert!(m.update(1.0, f64::NAN).is_err());
435        assert!(m.update(f64::INFINITY, 1.0).is_err());
436        assert!(m.update(1.0, f64::INFINITY).is_err());
437    }
438
439    #[test]
440    fn tdm_rejects_negative_dt() {
441        let mut m = TimeDecayedMean::new(0.1).unwrap();
442        m.update(5.0, 10.0).unwrap();
443        assert!(m.update(3.0, 20.0).is_err());
444    }
445
446    #[test]
447    fn tdm_equal_time_no_decay() {
448        let mut m = TimeDecayedMean::new(1.0).unwrap();
449        m.update(0.0, 10.0).unwrap();
450        m.update(0.0, 20.0).unwrap();
451        // dt = 0, factor = exp(0) = 1, so this is a simple mean.
452        assert!((m.value().unwrap() - 15.0).abs() < 1e-12);
453    }
454
455    #[cfg(feature = "serde")]
456    #[test]
457    fn tdm_serde_roundtrip() {
458        let mut m = TimeDecayedMean::new(0.5).unwrap();
459        m.update(0.0, 10.0).unwrap();
460        m.update(1.0, 20.0).unwrap();
461        let json = serde_json::to_string(&m).unwrap();
462        let restored: TimeDecayedMean = serde_json::from_str(&json).unwrap();
463        assert!((restored.decay() - 0.5).abs() < 1e-12);
464        assert!((restored.value().unwrap() - m.value().unwrap()).abs() < 1e-12);
465    }
466
467    // --- LearningRateScheduler tests ---
468
469    #[test]
470    fn lrs_default_lr() {
471        let sched = LearningRateScheduler::default();
472        assert!((sched.current_lr() - 0.01).abs() < 1e-12);
473        assert_eq!(sched.current_state(), DriftLevel::None);
474    }
475
476    #[test]
477    fn lrs_warning_increases_lr() {
478        let mut sched = LearningRateScheduler::new(0.05, 2.0, 5.0).unwrap();
479        sched.on_drift_level(DriftLevel::Warning);
480        assert!((sched.current_lr() - 0.10).abs() < 1e-12);
481    }
482
483    #[test]
484    fn lrs_drift_increases_more() {
485        let mut sched = LearningRateScheduler::new(0.05, 2.0, 5.0).unwrap();
486        sched.on_drift_level(DriftLevel::Drift);
487        assert!((sched.current_lr() - 0.25).abs() < 1e-12);
488    }
489
490    #[test]
491    fn lrs_reset_to_base() {
492        let mut sched = LearningRateScheduler::new(0.05, 2.0, 5.0).unwrap();
493        sched.on_drift_level(DriftLevel::Drift);
494        sched.reset();
495        assert_eq!(sched.current_state(), DriftLevel::None);
496        assert!((sched.current_lr() - 0.05).abs() < 1e-12);
497    }
498
499    #[test]
500    fn lrs_rejects_invalid_config() {
501        // base_lr <= 0
502        assert!(LearningRateScheduler::new(0.0, 2.0, 5.0).is_err());
503        assert!(LearningRateScheduler::new(-1.0, 2.0, 5.0).is_err());
504        // warning_multiplier < 1
505        assert!(LearningRateScheduler::new(0.01, 0.5, 5.0).is_err());
506        // drift_multiplier < warning_multiplier
507        assert!(LearningRateScheduler::new(0.01, 3.0, 2.0).is_err());
508        // NaN
509        assert!(LearningRateScheduler::new(f64::NAN, 2.0, 5.0).is_err());
510    }
511
512    #[cfg(feature = "serde")]
513    #[test]
514    fn lrs_serde_roundtrip() {
515        let mut sched = LearningRateScheduler::new(0.02, 3.0, 7.0).unwrap();
516        sched.on_drift_level(DriftLevel::Warning);
517        let json = serde_json::to_string(&sched).unwrap();
518        let restored: LearningRateScheduler = serde_json::from_str(&json).unwrap();
519        assert!((restored.base_lr() - 0.02).abs() < 1e-12);
520        assert!((restored.warning_multiplier() - 3.0).abs() < 1e-12);
521        assert!((restored.drift_multiplier() - 7.0).abs() < 1e-12);
522        assert_eq!(restored.current_state(), DriftLevel::Warning);
523        assert!((restored.current_lr() - 0.06).abs() < 1e-12);
524    }
525
526    // --- FixedWindowBuffer tests ---
527
528    #[test]
529    fn fwb_push_below_capacity() {
530        let mut buf = FixedWindowBuffer::new(5).unwrap();
531        buf.push(1.0).unwrap();
532        buf.push(2.0).unwrap();
533        buf.push(3.0).unwrap();
534        assert_eq!(buf.len(), 3);
535        assert!(!buf.is_full());
536        assert!(!buf.is_empty());
537        let collected: Vec<f64> = buf.iter().copied().collect();
538        assert_eq!(collected, vec![1.0, 2.0, 3.0]);
539    }
540
541    #[test]
542    fn fwb_push_overwrites_oldest() {
543        let mut buf = FixedWindowBuffer::new(3).unwrap();
544        buf.push(1.0).unwrap();
545        buf.push(2.0).unwrap();
546        buf.push(3.0).unwrap();
547        assert!(buf.is_full());
548        buf.push(4.0).unwrap();
549        // After push, the oldest (1.0) is gone; order is [2, 3, 4].
550        let collected: Vec<f64> = buf.iter().copied().collect();
551        assert_eq!(collected, vec![2.0, 3.0, 4.0]);
552        assert_eq!(buf.len(), 3);
553    }
554
555    #[test]
556    fn fwb_mean_correct() {
557        let mut buf = FixedWindowBuffer::new(4).unwrap();
558        buf.push(1.0).unwrap();
559        buf.push(2.0).unwrap();
560        buf.push(3.0).unwrap();
561        buf.push(4.0).unwrap();
562        assert_eq!(buf.mean(), Some(2.5));
563        buf.push(10.0).unwrap(); // overwrites 1.0
564        assert_eq!(buf.mean(), Some((2.0 + 3.0 + 4.0 + 10.0) / 4.0));
565    }
566
567    #[test]
568    fn fwb_iter_returns_in_order() {
569        let mut buf = FixedWindowBuffer::new(3).unwrap();
570        for v in &[10.0, 20.0, 30.0, 40.0, 50.0] {
571            buf.push(*v).unwrap();
572        }
573        // After 5 pushes into capacity 3: the last 3 values are [30, 40, 50].
574        let collected: Vec<f64> = buf.iter().copied().collect();
575        assert_eq!(collected, vec![30.0, 40.0, 50.0]);
576    }
577
578    #[test]
579    fn fwb_empty_buffer_mean_none() {
580        let buf = FixedWindowBuffer::new(3).unwrap();
581        assert_eq!(buf.mean(), None);
582        assert!(buf.is_empty());
583        assert!(!buf.is_full());
584    }
585
586    #[test]
587    fn fwb_rejects_zero_capacity() {
588        assert!(FixedWindowBuffer::new(0).is_err());
589    }
590
591    #[test]
592    fn fwb_rejects_non_finite() {
593        let mut buf = FixedWindowBuffer::new(3).unwrap();
594        assert!(buf.push(f64::NAN).is_err());
595        assert!(buf.push(f64::INFINITY).is_err());
596        assert!(buf.push(f64::NEG_INFINITY).is_err());
597        assert_eq!(buf.len(), 0);
598    }
599
600    #[test]
601    fn fwb_reset_clears() {
602        let mut buf = FixedWindowBuffer::new(3).unwrap();
603        buf.push(1.0).unwrap();
604        buf.push(2.0).unwrap();
605        buf.reset();
606        assert_eq!(buf.len(), 0);
607        assert!(buf.is_empty());
608        assert_eq!(buf.mean(), None);
609    }
610
611    #[test]
612    fn fwb_wrap_around_multiple_times() {
613        let mut buf = FixedWindowBuffer::new(2).unwrap();
614        for i in 1..=10 {
615            buf.push(i as f64).unwrap();
616        }
617        assert_eq!(buf.len(), 2);
618        assert!(buf.is_full());
619        let collected: Vec<f64> = buf.iter().copied().collect();
620        assert_eq!(collected, vec![9.0, 10.0]);
621    }
622
623    #[cfg(feature = "serde")]
624    #[test]
625    fn fwb_serde_roundtrip() {
626        let mut buf = FixedWindowBuffer::new(3).unwrap();
627        buf.push(1.0).unwrap();
628        buf.push(2.0).unwrap();
629        buf.push(3.0).unwrap();
630        buf.push(4.0).unwrap(); // overwrites 1.0
631        let json = serde_json::to_string(&buf).unwrap();
632        let restored: FixedWindowBuffer = serde_json::from_str(&json).unwrap();
633        assert_eq!(restored.capacity(), 3);
634        assert_eq!(restored.len(), 3);
635        assert!(restored.is_full());
636        let collected: Vec<f64> = restored.iter().copied().collect();
637        assert_eq!(collected, vec![2.0, 3.0, 4.0]);
638    }
639}