1use crate::error::{RillError, checked_increment, ensure_finite};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14#[non_exhaustive]
15pub enum WarmupState {
16 NoData,
18 WarmingUp,
20 Usable,
22 Stable,
24 Degraded,
26}
27
28impl WarmupState {
29 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 pub fn is_ready(&self) -> bool {
47 matches!(self, WarmupState::Usable | WarmupState::Stable)
48 }
49}
50
51#[derive(Debug, Clone)]
53#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
54#[non_exhaustive]
55pub struct WarmupConfig {
56 pub warming_up_threshold: u64,
58 pub usable_threshold: u64,
61 pub stable_threshold: u64,
64 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#[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 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 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 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 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 pub const fn samples(&self) -> u64 {
194 self.samples
195 }
196
197 pub const fn recent_error(&self) -> Option<f64> {
199 self.recent_error
200 }
201
202 pub const fn baseline_error(&self) -> Option<f64> {
204 self.baseline_error
205 }
206
207 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 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 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 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 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 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 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 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 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}