Skip to main content

rill_ml/diagnostics/
warmup.rs

1//! Warmup state tracking.
2//!
3//! Tracks the warmup state of an online model based on sample count and
4//! error comparison against a baseline. Helps callers decide when a model
5//! is ready for production use or when it has degraded.
6//!
7//! Space complexity: `O(1)`.
8
9use crate::error::{RillError, checked_increment, ensure_finite};
10
11/// Lifecycle state of a model during warmup.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14#[non_exhaustive]
15pub enum WarmupState {
16    /// No samples have been observed yet.
17    NoData,
18    /// Not enough samples have been seen to make any decision.
19    WarmingUp,
20    /// The model can be used but has not yet stabilized.
21    Usable,
22    /// The model is stable and performing at least as well as the baseline.
23    Stable,
24    /// The recent error exceeds the baseline by too large a margin.
25    Degraded,
26}
27
28impl WarmupState {
29    /// Returns a short, stable string identifier for the state.
30    ///
31    /// Possible return values: `"no_data"`, `"warming_up"`, `"usable"`,
32    /// `"stable"`, `"degraded"`.
33    pub fn as_str(&self) -> &'static str {
34        match self {
35            WarmupState::NoData => "no_data",
36            WarmupState::WarmingUp => "warming_up",
37            WarmupState::Usable => "usable",
38            WarmupState::Stable => "stable",
39            WarmupState::Degraded => "degraded",
40        }
41    }
42
43    /// Returns `true` when the model may be used for decisions.
44    ///
45    /// Both [`WarmupState::Usable`] and [`WarmupState::Stable`] are considered ready.
46    pub fn is_ready(&self) -> bool {
47        matches!(self, WarmupState::Usable | WarmupState::Stable)
48    }
49}
50
51/// Configuration for [`WarmupTracker`].
52#[derive(Debug, Clone)]
53#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
54#[non_exhaustive]
55pub struct WarmupConfig {
56    /// Number of samples below which the model is considered [`WarmupState::WarmingUp`].
57    pub warming_up_threshold: u64,
58    /// Number of samples at which the model transitions from warming up to
59    /// [`WarmupState::Usable`] (when no error comparison applies).
60    pub usable_threshold: u64,
61    /// Number of samples required (along with beating the baseline) for
62    /// [`WarmupState::Stable`].
63    pub stable_threshold: u64,
64    /// Ratio by which the recent error may exceed the baseline before the
65    /// model is considered [`WarmupState::Degraded`].
66    pub degraded_error_ratio: f64,
67}
68
69impl Default for WarmupConfig {
70    fn default() -> Self {
71        Self {
72            warming_up_threshold: 5,
73            usable_threshold: 30,
74            stable_threshold: 100,
75            degraded_error_ratio: 2.0,
76        }
77    }
78}
79
80/// Bounded-memory warmup state tracker.
81///
82/// Tracks the number of observed samples, the most recent absolute error,
83/// and a baseline error. From these it derives a [`WarmupState`].
84///
85/// # Examples
86///
87/// ```
88/// use rill_ml::diagnostics::WarmupTracker;
89///
90/// let mut tracker = WarmupTracker::default();
91/// assert_eq!(tracker.state().as_str(), "no_data");
92///
93/// for _ in 0..5 {
94///     tracker.observe_sample(None).unwrap();
95/// }
96/// assert!(tracker.state().is_ready());
97/// ```
98#[derive(Debug, Clone)]
99#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
100pub struct WarmupTracker {
101    config: WarmupConfig,
102    samples: u64,
103    recent_error: Option<f64>,
104    baseline_error: Option<f64>,
105}
106
107impl WarmupTracker {
108    /// Create a new tracker with the given configuration.
109    ///
110    /// Returns [`RillError::InvalidParameter`] if the thresholds are not ordered
111    /// `warming_up_threshold < usable_threshold <= stable_threshold` or if
112    /// `degraded_error_ratio` is not greater than `1.0`.
113    pub fn new(config: WarmupConfig) -> Result<Self, RillError> {
114        if config.warming_up_threshold >= config.usable_threshold {
115            return Err(RillError::InvalidParameter {
116                name: "warming_up_threshold",
117                value: config.warming_up_threshold as f64,
118            });
119        }
120        if config.usable_threshold > config.stable_threshold {
121            return Err(RillError::InvalidParameter {
122                name: "usable_threshold",
123                value: config.usable_threshold as f64,
124            });
125        }
126        if config.degraded_error_ratio.partial_cmp(&1.0) != Some(core::cmp::Ordering::Greater) {
127            return Err(RillError::InvalidParameter {
128                name: "degraded_error_ratio",
129                value: config.degraded_error_ratio,
130            });
131        }
132        Ok(Self {
133            config,
134            samples: 0,
135            recent_error: None,
136            baseline_error: None,
137        })
138    }
139
140    /// Observe a sample, optionally with an error value.
141    ///
142    /// When `error` is `Some`, the value must be finite; its absolute value
143    /// replaces the stored recent error. When `error` is `None`, only the
144    /// sample counter is incremented.
145    ///
146    /// Returns [`RillError::NonFiniteValue`] if `error` is `Some` but not finite.
147    /// In that case the tracker state is left unchanged.
148    pub fn observe_sample(&mut self, error: Option<f64>) -> Result<(), RillError> {
149        if let Some(e) = error {
150            ensure_finite("error", e)?;
151            self.recent_error = Some(e.abs());
152        }
153        self.samples = checked_increment(self.samples, "samples")?;
154        Ok(())
155    }
156
157    /// Set the baseline error for comparison.
158    ///
159    /// The absolute value is stored, so signed errors are accepted.
160    pub fn set_baseline(&mut self, baseline: f64) -> Result<(), RillError> {
161        ensure_finite("baseline", baseline)?;
162        self.baseline_error = Some(baseline.abs());
163        Ok(())
164    }
165
166    /// Compute the current warmup state.
167    ///
168    /// The decision is made in priority order:
169    ///
170    /// 1. No samples seen → [`WarmupState::NoData`].
171    /// 2. Samples below `warming_up_threshold` → [`WarmupState::WarmingUp`].
172    /// 3. If both recent and baseline errors are available:
173    ///    - recent > baseline × `degraded_error_ratio` → [`WarmupState::Degraded`].
174    ///    - samples ≥ `stable_threshold` and recent ≤ baseline → [`WarmupState::Stable`].
175    /// 4. Otherwise → [`WarmupState::Usable`].
176    pub fn state(&self) -> WarmupState {
177        if self.samples == 0 {
178            return WarmupState::NoData;
179        }
180        if self.samples < self.config.warming_up_threshold {
181            return WarmupState::WarmingUp;
182        }
183        match (self.recent_error, self.baseline_error) {
184            (Some(r), Some(b)) if r > b * self.config.degraded_error_ratio => WarmupState::Degraded,
185            (Some(r), Some(b)) if self.samples >= self.config.stable_threshold && r <= b => {
186                WarmupState::Stable
187            }
188            _ => WarmupState::Usable,
189        }
190    }
191
192    /// Number of samples observed so far.
193    pub const fn samples(&self) -> u64 {
194        self.samples
195    }
196
197    /// The most recent absolute error, or `None` if none was recorded.
198    pub const fn recent_error(&self) -> Option<f64> {
199        self.recent_error
200    }
201
202    /// The baseline error, or `None` if not set.
203    pub const fn baseline_error(&self) -> Option<f64> {
204        self.baseline_error
205    }
206
207    /// Reset the tracker to its initial (no-data) state.
208    pub fn reset(&mut self) {
209        self.samples = 0;
210        self.recent_error = None;
211        self.baseline_error = None;
212    }
213}
214
215impl Default for WarmupTracker {
216    fn default() -> Self {
217        Self::new(WarmupConfig::default()).expect("default config is valid")
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn no_data_initially() {
227        let t = WarmupTracker::default();
228        assert_eq!(t.state(), WarmupState::NoData);
229        assert_eq!(t.samples(), 0);
230        assert_eq!(t.recent_error(), None);
231        assert_eq!(t.baseline_error(), None);
232    }
233
234    #[test]
235    fn warming_up_below_threshold() {
236        let mut t = WarmupTracker::default();
237        for _ in 0..4 {
238            t.observe_sample(None).unwrap();
239        }
240        assert_eq!(t.state(), WarmupState::WarmingUp);
241    }
242
243    #[test]
244    fn usable_after_warming_up() {
245        let mut t = WarmupTracker::default();
246        for _ in 0..5 {
247            t.observe_sample(None).unwrap();
248        }
249        // samples >= warming_up (5) but < usable (30), no baseline -> Usable
250        assert_eq!(t.state(), WarmupState::Usable);
251    }
252
253    #[test]
254    fn stable_when_meets_threshold_and_beats_baseline() {
255        let mut t = WarmupTracker::default();
256        t.set_baseline(0.4).unwrap();
257        for _ in 0..100 {
258            t.observe_sample(Some(0.3)).unwrap();
259        }
260        // samples >= stable (100), recent (0.3) <= baseline (0.4) -> Stable
261        assert_eq!(t.state(), WarmupState::Stable);
262    }
263
264    #[test]
265    fn degraded_when_error_exceeds_ratio() {
266        let mut t = WarmupTracker::default();
267        t.set_baseline(0.4).unwrap();
268        for _ in 0..5 {
269            t.observe_sample(Some(1.0)).unwrap();
270        }
271        // 1.0 > 0.4 * 2.0 = 0.8 -> Degraded
272        assert_eq!(t.state(), WarmupState::Degraded);
273    }
274
275    #[test]
276    fn degraded_takes_precedence_over_stable() {
277        let mut t = WarmupTracker::default();
278        t.set_baseline(0.4).unwrap();
279        for _ in 0..100 {
280            t.observe_sample(Some(1.0)).unwrap();
281        }
282        // samples >= stable (100), but error (1.0) > baseline (0.4) * ratio (2.0)
283        // Degraded is checked before Stable
284        assert_eq!(t.state(), WarmupState::Degraded);
285    }
286
287    #[test]
288    fn no_baseline_means_usable() {
289        let mut t = WarmupTracker::default();
290        for _ in 0..100 {
291            t.observe_sample(Some(0.3)).unwrap();
292        }
293        // No baseline set, cannot be Stable or Degraded
294        assert_eq!(t.state(), WarmupState::Usable);
295    }
296
297    #[test]
298    fn set_baseline_stores_absolute() {
299        let mut t = WarmupTracker::default();
300        t.set_baseline(-3.0).unwrap();
301        assert_eq!(t.baseline_error(), Some(3.0));
302    }
303
304    #[test]
305    fn observe_sample_with_error() {
306        let mut t = WarmupTracker::default();
307        t.observe_sample(Some(0.5)).unwrap();
308        assert_eq!(t.samples(), 1);
309        assert_eq!(t.recent_error(), Some(0.5));
310    }
311
312    #[test]
313    fn observe_sample_without_error() {
314        let mut t = WarmupTracker::default();
315        t.observe_sample(None).unwrap();
316        assert_eq!(t.samples(), 1);
317        assert_eq!(t.recent_error(), None);
318    }
319
320    #[test]
321    fn reset_clears_state() {
322        let mut t = WarmupTracker::default();
323        t.observe_sample(Some(0.5)).unwrap();
324        t.set_baseline(0.4).unwrap();
325        t.reset();
326        assert_eq!(t.samples(), 0);
327        assert_eq!(t.recent_error(), None);
328        assert_eq!(t.baseline_error(), None);
329        assert_eq!(t.state(), WarmupState::NoData);
330    }
331
332    #[test]
333    fn invalid_config_rejected() {
334        // warming_up >= usable
335        let config = WarmupConfig {
336            warming_up_threshold: 30,
337            usable_threshold: 30,
338            stable_threshold: 100,
339            degraded_error_ratio: 2.0,
340        };
341        assert!(WarmupTracker::new(config).is_err());
342
343        // usable > stable
344        let config = WarmupConfig {
345            warming_up_threshold: 5,
346            usable_threshold: 101,
347            stable_threshold: 100,
348            degraded_error_ratio: 2.0,
349        };
350        assert!(WarmupTracker::new(config).is_err());
351
352        // ratio <= 1.0
353        let config = WarmupConfig {
354            warming_up_threshold: 5,
355            usable_threshold: 30,
356            stable_threshold: 100,
357            degraded_error_ratio: 1.0,
358        };
359        assert!(WarmupTracker::new(config).is_err());
360    }
361
362    #[test]
363    fn non_finite_error_rejected() {
364        let mut t = WarmupTracker::default();
365        assert!(t.observe_sample(Some(f64::NAN)).is_err());
366        assert_eq!(t.samples(), 0);
367        assert_eq!(t.recent_error(), None);
368        assert!(t.observe_sample(Some(f64::INFINITY)).is_err());
369        assert_eq!(t.samples(), 0);
370        assert!(t.observe_sample(Some(f64::NEG_INFINITY)).is_err());
371        assert_eq!(t.samples(), 0);
372    }
373
374    #[test]
375    fn state_as_str() {
376        assert_eq!(WarmupState::NoData.as_str(), "no_data");
377        assert_eq!(WarmupState::WarmingUp.as_str(), "warming_up");
378        assert_eq!(WarmupState::Usable.as_str(), "usable");
379        assert_eq!(WarmupState::Stable.as_str(), "stable");
380        assert_eq!(WarmupState::Degraded.as_str(), "degraded");
381    }
382
383    #[test]
384    fn state_is_ready() {
385        assert!(!WarmupState::NoData.is_ready());
386        assert!(!WarmupState::WarmingUp.is_ready());
387        assert!(WarmupState::Usable.is_ready());
388        assert!(WarmupState::Stable.is_ready());
389        assert!(!WarmupState::Degraded.is_ready());
390    }
391
392    #[cfg(feature = "serde")]
393    #[test]
394    fn serde_roundtrip() {
395        let config = WarmupConfig {
396            warming_up_threshold: 1,
397            usable_threshold: 2,
398            stable_threshold: 3,
399            degraded_error_ratio: 2.0,
400        };
401        let mut t = WarmupTracker::new(config).unwrap();
402        t.observe_sample(Some(0.3)).unwrap();
403        t.observe_sample(Some(0.3)).unwrap();
404        t.observe_sample(Some(0.3)).unwrap();
405        t.set_baseline(0.4).unwrap();
406
407        let json = serde_json::to_string(&t).unwrap();
408        let restored: WarmupTracker = serde_json::from_str(&json).unwrap();
409        assert_eq!(restored.samples(), 3);
410        assert_eq!(restored.recent_error(), Some(0.3));
411        assert_eq!(restored.baseline_error(), Some(0.4));
412        assert_eq!(restored.state(), WarmupState::Stable);
413    }
414}