use serde::{Deserialize, Serialize};
use tracing::{Span, instrument};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HysteresisGate {
pub threshold_enter: f64,
pub threshold_exit: f64,
pub min_hold_ms: u64,
pub active: bool,
pub last_transition_ms: u64,
}
impl HysteresisGate {
pub fn new(threshold_enter: f64, threshold_exit: f64, min_hold_ms: u64) -> Self {
Self {
threshold_enter,
threshold_exit,
min_hold_ms,
active: false,
last_transition_ms: 0,
}
}
#[instrument(skip(self), fields(autonomic.hysteresis.metric = metric, autonomic.hysteresis.active))]
pub fn evaluate(&mut self, metric: f64, now_ms: u64) -> bool {
let was_active = self.active;
let held_long_enough = now_ms.saturating_sub(self.last_transition_ms) >= self.min_hold_ms;
if !self.active && metric >= self.threshold_enter && held_long_enough {
self.active = true;
self.last_transition_ms = now_ms;
} else if self.active && metric <= self.threshold_exit && held_long_enough {
self.active = false;
self.last_transition_ms = now_ms;
}
let span = Span::current();
span.record("autonomic.hysteresis.active", self.active);
if was_active != self.active {
tracing::debug!(
from = was_active,
to = self.active,
"hysteresis gate state changed"
);
}
self.active
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gate_activates_at_enter_threshold() {
let mut gate = HysteresisGate::new(0.8, 0.6, 0);
assert!(!gate.active);
assert!(!gate.evaluate(0.7, 100));
assert!(!gate.active);
assert!(gate.evaluate(0.8, 200));
assert!(gate.active);
}
#[test]
fn gate_deactivates_at_exit_threshold() {
let mut gate = HysteresisGate::new(0.8, 0.6, 0);
gate.active = true;
gate.last_transition_ms = 0;
assert!(gate.evaluate(0.7, 100));
assert!(!gate.evaluate(0.6, 200));
assert!(!gate.active);
}
#[test]
fn gate_respects_min_hold_duration() {
let mut gate = HysteresisGate::new(0.8, 0.6, 1000);
assert!(gate.evaluate(0.9, 1000));
assert!(gate.active);
assert!(gate.evaluate(0.5, 1500));
assert!(gate.active);
assert!(!gate.evaluate(0.5, 2000));
assert!(!gate.active);
}
#[test]
fn gate_hysteresis_prevents_flapping() {
let mut gate = HysteresisGate::new(0.8, 0.6, 0);
gate.evaluate(0.9, 100);
assert!(gate.active);
gate.evaluate(0.7, 200);
assert!(gate.active);
gate.evaluate(0.6, 300);
assert!(!gate.active);
gate.evaluate(0.7, 400);
assert!(!gate.active);
}
#[test]
fn gate_serde_roundtrip() {
let gate = HysteresisGate::new(0.8, 0.6, 5000);
let json = serde_json::to_string(&gate).unwrap();
let back: HysteresisGate = serde_json::from_str(&json).unwrap();
assert!((back.threshold_enter - 0.8).abs() < f64::EPSILON);
assert!((back.threshold_exit - 0.6).abs() < f64::EPSILON);
assert_eq!(back.min_hold_ms, 5000);
}
}