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