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