monitrs_core/diagnostics/
hysteresis.rs1use core::time::Duration;
22use std::collections::VecDeque;
23
24use crate::model::{MetricState, PressureState};
25
26use super::Thresholds;
27
28#[derive(Clone, Debug)]
34pub struct Hysteresis {
35 observations: VecDeque<PressureState>,
37 window: usize,
39 required: usize,
41 state: PressureState,
43 held_for: Duration,
45 settled: bool,
47}
48
49impl Hysteresis {
50 #[must_use]
56 pub fn new(thresholds: &Thresholds) -> Self {
57 let window = thresholds.sustained_window.max(1);
58 Self {
59 observations: VecDeque::with_capacity(window),
60 window,
61 required: thresholds.sustained_samples.clamp(1, window),
62 state: PressureState::Normal,
63 held_for: Duration::ZERO,
64 settled: false,
65 }
66 }
67
68 pub fn observe(
76 &mut self,
77 candidate: PressureState,
78 elapsed: Duration,
79 ) -> MetricState<PressureState> {
80 if self.observations.len() >= self.window {
81 self.observations.pop_front();
82 }
83 self.observations.push_back(candidate);
84
85 if self.observations.len() < self.required {
86 self.held_for = Duration::ZERO;
87 self.settled = false;
88 return MetricState::WarmingUp;
89 }
90
91 let target = self.escalation_target();
92 let changed = if target > self.state {
93 self.state = target;
95 true
96 } else if target < self.state && self.count_at_least(self.state) == 0 {
97 self.state = target;
101 true
102 } else {
103 false
104 };
105
106 if changed || !self.settled {
107 self.held_for = Duration::ZERO;
108 }
109 self.settled = true;
110 self.held_for = self.held_for.saturating_add(elapsed);
111 MetricState::Available(self.state)
112 }
113
114 pub fn reset(&mut self) {
116 self.observations.clear();
117 self.state = PressureState::Normal;
118 self.held_for = Duration::ZERO;
119 self.settled = false;
120 }
121
122 #[must_use]
127 pub const fn held_for(&self) -> Option<Duration> {
128 if self.settled {
129 Some(self.held_for)
130 } else {
131 None
132 }
133 }
134
135 #[must_use]
137 pub const fn state(&self) -> Option<PressureState> {
138 if self.settled { Some(self.state) } else { None }
139 }
140
141 #[must_use]
143 pub fn observations(&self) -> usize {
144 self.observations.len()
145 }
146
147 #[must_use]
149 pub fn remaining_samples(&self) -> usize {
150 self.required.saturating_sub(self.observations.len())
151 }
152
153 fn escalation_target(&self) -> PressureState {
155 if self.count_at_least(PressureState::Critical) >= self.required {
156 PressureState::Critical
157 } else if self.count_at_least(PressureState::Watch) >= self.required {
158 PressureState::Watch
159 } else {
160 PressureState::Normal
161 }
162 }
163
164 fn count_at_least(&self, state: PressureState) -> usize {
166 self.observations
167 .iter()
168 .filter(|observed| **observed >= state)
169 .count()
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 const TICK: Duration = Duration::from_secs(1);
178
179 fn tracker() -> Hysteresis {
180 Hysteresis::new(&Thresholds::default())
181 }
182
183 fn feed(tracker: &mut Hysteresis, states: &[PressureState]) -> MetricState<PressureState> {
184 let mut last = MetricState::WarmingUp;
185 for state in states {
186 last = tracker.observe(*state, TICK);
187 }
188 last
189 }
190
191 #[test]
192 fn a_signal_warms_up_until_it_has_the_minimum_number_of_samples() {
193 let mut tracker = tracker();
194 for index in 1..10 {
195 assert!(
196 tracker
197 .observe(PressureState::Critical, TICK)
198 .is_warming_up(),
199 "observation {index} must not support a sustained claim"
200 );
201 assert!(tracker.state().is_none());
202 assert!(tracker.held_for().is_none());
203 }
204 assert_eq!(tracker.remaining_samples(), 1);
205 assert_eq!(
206 tracker.observe(PressureState::Critical, TICK),
207 MetricState::Available(PressureState::Critical)
208 );
209 assert_eq!(tracker.remaining_samples(), 0);
210 }
211
212 #[test]
213 fn an_alternating_input_never_escalates() {
214 let mut tracker = tracker();
217 let mut states = Vec::new();
218 for index in 0..100 {
219 let candidate = if index % 2 == 0 {
220 PressureState::Watch
221 } else {
222 PressureState::Normal
223 };
224 states.push(tracker.observe(candidate, TICK));
225 }
226
227 let derived: Vec<PressureState> =
228 states.iter().filter_map(|s| s.fresh().copied()).collect();
229 assert!(
230 derived.iter().all(|state| *state == PressureState::Normal),
231 "alternating input produced {derived:?}"
232 );
233 }
234
235 #[test]
236 fn an_alternating_input_does_not_flap_once_a_state_is_established() {
237 let mut tracker = tracker();
238 feed(&mut tracker, &[PressureState::Watch; 10]);
239 assert_eq!(tracker.state(), Some(PressureState::Watch));
240
241 for index in 0..40 {
243 let candidate = if index % 2 == 0 {
244 PressureState::Normal
245 } else {
246 PressureState::Watch
247 };
248 assert_eq!(
249 tracker.observe(candidate, TICK),
250 MetricState::Available(PressureState::Watch),
251 "flapped on observation {index}"
252 );
253 }
254 }
255
256 #[test]
257 fn escalation_needs_the_required_count_inside_the_window() {
258 let mut tracker = tracker();
259 feed(&mut tracker, &[PressureState::Critical; 9]);
261 feed(&mut tracker, &[PressureState::Watch; 6]);
262 assert_eq!(
263 tracker.state(),
264 Some(PressureState::Watch),
265 "watch is sustained (15 of 15 at or above watch), critical is not"
266 );
267 }
268
269 #[test]
270 fn a_state_clears_only_once_it_has_left_the_window_entirely() {
271 let mut tracker = tracker();
272 feed(&mut tracker, &[PressureState::Critical; 10]);
273 assert_eq!(tracker.state(), Some(PressureState::Critical));
274
275 feed(&mut tracker, &[PressureState::Normal; 14]);
278 assert_eq!(tracker.state(), Some(PressureState::Critical));
279
280 feed(&mut tracker, &[PressureState::Normal]);
281 assert_eq!(tracker.state(), Some(PressureState::Normal));
282 }
283
284 #[test]
285 fn de_escalation_stops_at_the_state_that_is_still_sustained() {
286 let mut tracker = tracker();
287 feed(&mut tracker, &[PressureState::Critical; 15]);
288 assert_eq!(tracker.state(), Some(PressureState::Critical));
289
290 feed(&mut tracker, &[PressureState::Watch; 15]);
292 assert_eq!(tracker.state(), Some(PressureState::Watch));
293 }
294
295 #[test]
296 fn held_for_accumulates_measured_intervals_and_restarts_on_a_transition() {
297 let mut tracker = tracker();
298 feed(&mut tracker, &[PressureState::Normal; 10]);
299 assert_eq!(tracker.held_for(), Some(TICK));
300
301 for _ in 0..4 {
302 tracker.observe(PressureState::Normal, Duration::from_millis(500));
303 }
304 assert_eq!(
305 tracker.held_for(),
306 Some(TICK + Duration::from_millis(2_000)),
307 "held_for must use the measured interval, not an assumed second"
308 );
309
310 feed(&mut tracker, &[PressureState::Watch; 10]);
311 assert_eq!(
312 tracker.held_for(),
313 Some(TICK),
314 "a transition restarts the held duration"
315 );
316 }
317
318 #[test]
319 fn a_reset_discards_the_window_so_a_gap_cannot_become_a_sustained_condition() {
320 let mut tracker = tracker();
321 feed(&mut tracker, &[PressureState::Critical; 9]);
322 tracker.reset();
323
324 assert_eq!(tracker.observations(), 0);
325 assert!(tracker.state().is_none());
326 assert!(tracker.held_for().is_none());
327
328 assert!(
331 tracker
332 .observe(PressureState::Critical, TICK)
333 .is_warming_up(),
334 "§11.3: a reset must not be readable as an event"
335 );
336 }
337
338 #[test]
339 fn the_window_is_bounded_however_long_the_engine_runs() {
340 let mut tracker = tracker();
341 for _ in 0..10_000 {
342 tracker.observe(PressureState::Watch, TICK);
343 }
344 assert_eq!(
345 tracker.observations(),
346 Thresholds::default().sustained_window
347 );
348 }
349
350 #[test]
351 fn a_single_sample_configuration_still_applies_hysteresis_downwards() {
352 let thresholds = Thresholds {
353 sustained_samples: 1,
354 sustained_window: 1,
355 ..Thresholds::default()
356 }
357 .sanitized();
358 let mut tracker = Hysteresis::new(&thresholds);
359 assert_eq!(
360 tracker.observe(PressureState::Critical, TICK),
361 MetricState::Available(PressureState::Critical)
362 );
363 assert_eq!(
364 tracker.observe(PressureState::Normal, TICK),
365 MetricState::Available(PressureState::Normal),
366 "with a one-sample window the previous state has left it"
367 );
368 }
369}