1use crate::error::{RillError, ensure_finite};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14pub enum WarmupState {
15 NoData,
17 WarmingUp,
19 Usable,
21 Stable,
23 Degraded,
25}
26
27impl WarmupState {
28 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 pub fn is_ready(&self) -> bool {
46 matches!(self, WarmupState::Usable | WarmupState::Stable)
47 }
48}
49
50#[derive(Debug, Clone)]
52#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
53pub struct WarmupConfig {
54 pub warming_up_threshold: u64,
56 pub usable_threshold: u64,
59 pub stable_threshold: u64,
62 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#[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 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 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 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 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 pub const fn samples(&self) -> u64 {
192 self.samples
193 }
194
195 pub const fn recent_error(&self) -> Option<f64> {
197 self.recent_error
198 }
199
200 pub const fn baseline_error(&self) -> Option<f64> {
202 self.baseline_error
203 }
204
205 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 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 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 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 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 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 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 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 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}