use core::time::Duration;
use std::collections::VecDeque;
use crate::model::{MetricState, PressureState};
use super::Thresholds;
#[derive(Clone, Debug)]
pub struct Hysteresis {
observations: VecDeque<PressureState>,
window: usize,
required: usize,
state: PressureState,
held_for: Duration,
settled: bool,
}
impl Hysteresis {
#[must_use]
pub fn new(thresholds: &Thresholds) -> Self {
let window = thresholds.sustained_window.max(1);
Self {
observations: VecDeque::with_capacity(window),
window,
required: thresholds.sustained_samples.clamp(1, window),
state: PressureState::Normal,
held_for: Duration::ZERO,
settled: false,
}
}
pub fn observe(
&mut self,
candidate: PressureState,
elapsed: Duration,
) -> MetricState<PressureState> {
if self.observations.len() >= self.window {
self.observations.pop_front();
}
self.observations.push_back(candidate);
if self.observations.len() < self.required {
self.held_for = Duration::ZERO;
self.settled = false;
return MetricState::WarmingUp;
}
let target = self.escalation_target();
let changed = if target > self.state {
self.state = target;
true
} else if target < self.state && self.count_at_least(self.state) == 0 {
self.state = target;
true
} else {
false
};
if changed || !self.settled {
self.held_for = Duration::ZERO;
}
self.settled = true;
self.held_for = self.held_for.saturating_add(elapsed);
MetricState::Available(self.state)
}
pub fn reset(&mut self) {
self.observations.clear();
self.state = PressureState::Normal;
self.held_for = Duration::ZERO;
self.settled = false;
}
#[must_use]
pub const fn held_for(&self) -> Option<Duration> {
if self.settled {
Some(self.held_for)
} else {
None
}
}
#[must_use]
pub const fn state(&self) -> Option<PressureState> {
if self.settled { Some(self.state) } else { None }
}
#[must_use]
pub fn observations(&self) -> usize {
self.observations.len()
}
#[must_use]
pub fn remaining_samples(&self) -> usize {
self.required.saturating_sub(self.observations.len())
}
fn escalation_target(&self) -> PressureState {
if self.count_at_least(PressureState::Critical) >= self.required {
PressureState::Critical
} else if self.count_at_least(PressureState::Watch) >= self.required {
PressureState::Watch
} else {
PressureState::Normal
}
}
fn count_at_least(&self, state: PressureState) -> usize {
self.observations
.iter()
.filter(|observed| **observed >= state)
.count()
}
}
#[cfg(test)]
mod tests {
use super::*;
const TICK: Duration = Duration::from_secs(1);
fn tracker() -> Hysteresis {
Hysteresis::new(&Thresholds::default())
}
fn feed(tracker: &mut Hysteresis, states: &[PressureState]) -> MetricState<PressureState> {
let mut last = MetricState::WarmingUp;
for state in states {
last = tracker.observe(*state, TICK);
}
last
}
#[test]
fn a_signal_warms_up_until_it_has_the_minimum_number_of_samples() {
let mut tracker = tracker();
for index in 1..10 {
assert!(
tracker
.observe(PressureState::Critical, TICK)
.is_warming_up(),
"observation {index} must not support a sustained claim"
);
assert!(tracker.state().is_none());
assert!(tracker.held_for().is_none());
}
assert_eq!(tracker.remaining_samples(), 1);
assert_eq!(
tracker.observe(PressureState::Critical, TICK),
MetricState::Available(PressureState::Critical)
);
assert_eq!(tracker.remaining_samples(), 0);
}
#[test]
fn an_alternating_input_never_escalates() {
let mut tracker = tracker();
let mut states = Vec::new();
for index in 0..100 {
let candidate = if index % 2 == 0 {
PressureState::Watch
} else {
PressureState::Normal
};
states.push(tracker.observe(candidate, TICK));
}
let derived: Vec<PressureState> =
states.iter().filter_map(|s| s.fresh().copied()).collect();
assert!(
derived.iter().all(|state| *state == PressureState::Normal),
"alternating input produced {derived:?}"
);
}
#[test]
fn an_alternating_input_does_not_flap_once_a_state_is_established() {
let mut tracker = tracker();
feed(&mut tracker, &[PressureState::Watch; 10]);
assert_eq!(tracker.state(), Some(PressureState::Watch));
for index in 0..40 {
let candidate = if index % 2 == 0 {
PressureState::Normal
} else {
PressureState::Watch
};
assert_eq!(
tracker.observe(candidate, TICK),
MetricState::Available(PressureState::Watch),
"flapped on observation {index}"
);
}
}
#[test]
fn escalation_needs_the_required_count_inside_the_window() {
let mut tracker = tracker();
feed(&mut tracker, &[PressureState::Critical; 9]);
feed(&mut tracker, &[PressureState::Watch; 6]);
assert_eq!(
tracker.state(),
Some(PressureState::Watch),
"watch is sustained (15 of 15 at or above watch), critical is not"
);
}
#[test]
fn a_state_clears_only_once_it_has_left_the_window_entirely() {
let mut tracker = tracker();
feed(&mut tracker, &[PressureState::Critical; 10]);
assert_eq!(tracker.state(), Some(PressureState::Critical));
feed(&mut tracker, &[PressureState::Normal; 14]);
assert_eq!(tracker.state(), Some(PressureState::Critical));
feed(&mut tracker, &[PressureState::Normal]);
assert_eq!(tracker.state(), Some(PressureState::Normal));
}
#[test]
fn de_escalation_stops_at_the_state_that_is_still_sustained() {
let mut tracker = tracker();
feed(&mut tracker, &[PressureState::Critical; 15]);
assert_eq!(tracker.state(), Some(PressureState::Critical));
feed(&mut tracker, &[PressureState::Watch; 15]);
assert_eq!(tracker.state(), Some(PressureState::Watch));
}
#[test]
fn held_for_accumulates_measured_intervals_and_restarts_on_a_transition() {
let mut tracker = tracker();
feed(&mut tracker, &[PressureState::Normal; 10]);
assert_eq!(tracker.held_for(), Some(TICK));
for _ in 0..4 {
tracker.observe(PressureState::Normal, Duration::from_millis(500));
}
assert_eq!(
tracker.held_for(),
Some(TICK + Duration::from_millis(2_000)),
"held_for must use the measured interval, not an assumed second"
);
feed(&mut tracker, &[PressureState::Watch; 10]);
assert_eq!(
tracker.held_for(),
Some(TICK),
"a transition restarts the held duration"
);
}
#[test]
fn a_reset_discards_the_window_so_a_gap_cannot_become_a_sustained_condition() {
let mut tracker = tracker();
feed(&mut tracker, &[PressureState::Critical; 9]);
tracker.reset();
assert_eq!(tracker.observations(), 0);
assert!(tracker.state().is_none());
assert!(tracker.held_for().is_none());
assert!(
tracker
.observe(PressureState::Critical, TICK)
.is_warming_up(),
"§11.3: a reset must not be readable as an event"
);
}
#[test]
fn the_window_is_bounded_however_long_the_engine_runs() {
let mut tracker = tracker();
for _ in 0..10_000 {
tracker.observe(PressureState::Watch, TICK);
}
assert_eq!(
tracker.observations(),
Thresholds::default().sustained_window
);
}
#[test]
fn a_single_sample_configuration_still_applies_hysteresis_downwards() {
let thresholds = Thresholds {
sustained_samples: 1,
sustained_window: 1,
..Thresholds::default()
}
.sanitized();
let mut tracker = Hysteresis::new(&thresholds);
assert_eq!(
tracker.observe(PressureState::Critical, TICK),
MetricState::Available(PressureState::Critical)
);
assert_eq!(
tracker.observe(PressureState::Normal, TICK),
MetricState::Available(PressureState::Normal),
"with a one-sample window the previous state has left it"
);
}
}