Skip to main content

monitrs_core/diagnostics/
hysteresis.rs

1//! Explicit hysteresis state: the reason the radar does not flap (§11.3).
2//!
3//! # The rule
4//!
5//! A signal escalates only once the higher state has been observed in
6//! `sustained_samples` of the last `sustained_window` observations, and it
7//! de-escalates only once the state it currently holds has **completely cleared**
8//! from that window. Both halves are needed: the first stops one noisy tick from
9//! raising an alarm, the second stops one quiet tick from clearing it. An input
10//! that alternates between quiet and loud therefore produces no transition at all,
11//! which is the property [`Hysteresis`]'s tests pin down.
12//!
13//! # Resetting
14//!
15//! §11.3 also requires the engine to reset cleanly after a counter reset or a
16//! sleep/wake cycle. [`Hysteresis::reset`] is that reset: it clears the
17//! observation window, so the samples from before the gap can never be stitched
18//! together with the samples after it to manufacture a sustained condition. After
19//! a reset the signal is warming up again — a reset must never look like an event.
20
21use core::time::Duration;
22use std::collections::VecDeque;
23
24use crate::model::{MetricState, PressureState};
25
26use super::Thresholds;
27
28/// The hysteresis state of one pressure signal.
29///
30/// Deliberately a concrete, inspectable struct rather than a closure or a counter
31/// hidden inside the engine: §11.3 calls for hysteresis as behaviour that can be
32/// tested, and the tests in this file are what guarantee the radar is stable.
33#[derive(Clone, Debug)]
34pub struct Hysteresis {
35    /// The most recent candidate states, oldest first, bounded by `window`.
36    observations: VecDeque<PressureState>,
37    /// How many observations are retained.
38    window: usize,
39    /// How many observations at or above a state are needed to reach it.
40    required: usize,
41    /// The state currently held.
42    state: PressureState,
43    /// How long `state` has been held, accumulated from measured intervals.
44    held_for: Duration,
45    /// Whether enough observations have accumulated for `state` to mean anything.
46    settled: bool,
47}
48
49impl Hysteresis {
50    /// Builds tracker state from sanitized thresholds.
51    ///
52    /// `thresholds` must already be [`Thresholds::sanitized`]; the engine does that
53    /// once at construction so every tracker inherits a window at least as wide as
54    /// the sample count it needs.
55    #[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    /// Feeds one candidate state derived from the current sample.
69    ///
70    /// `elapsed` is the *measured* interval since the previous sample (§8.1); it is
71    /// only used to accumulate [`Self::held_for`], never to decide a state.
72    ///
73    /// Returns [`MetricState::WarmingUp`] until `sustained_samples` observations
74    /// exist, because below that no sustained claim is possible (§11.3).
75    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            // Escalation: the higher state has been observed often enough.
94            self.state = target;
95            true
96        } else if target < self.state && self.count_at_least(self.state) == 0 {
97            // De-escalation: only once the held state has left the window
98            // entirely. This is what stops a single quiet sample from clearing a
99            // real problem (§11.3).
100            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    /// Discards every observation (§11.3: counter reset, sleep/wake).
115    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    /// How long the current state has been held, once it means anything.
123    ///
124    /// `None` while warming up: a duration for a state that has not been
125    /// established would be a fabricated number.
126    #[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    /// The state currently held, or `None` while warming up.
136    #[must_use]
137    pub const fn state(&self) -> Option<PressureState> {
138        if self.settled { Some(self.state) } else { None }
139    }
140
141    /// How many observations are retained.
142    #[must_use]
143    pub fn observations(&self) -> usize {
144        self.observations.len()
145    }
146
147    /// How many observations are still needed before a state can be derived.
148    #[must_use]
149    pub fn remaining_samples(&self) -> usize {
150        self.required.saturating_sub(self.observations.len())
151    }
152
153    /// The highest state observed often enough to be reached.
154    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    /// How many retained observations are at or above `state`.
165    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        // The flapping case §11.3 exists to prevent: a metric sitting exactly on
215        // its threshold must not raise and clear an alarm once per second.
216        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        // Now alternate. The watch state must hold, because it has not cleared.
242        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        // Nine critical observations inside a fifteen-sample window are not ten.
260        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        // Fourteen quiet samples still leave one critical observation in a
276        // fifteen-sample window, so the signal must not clear yet.
277        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        // Critical leaves the window but watch remains sustained.
291        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        // One critical observation after the reset must not join the nine from
329        // before it.
330        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}