use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
pub const ENTROPY_SCORE_VERSION: u32 = 1;
pub const ENTROPY_WINDOW_TURNS: usize = 8;
const ROLLBACK_SATURATION: f64 = 3.0;
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct EntropyWatchConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_threshold")]
pub threshold: f64,
#[serde(default = "default_hysteresis")]
pub hysteresis: f64,
#[serde(default = "default_cooldown_turns")]
pub cooldown_turns: u32,
#[serde(default)]
pub notify_model: bool,
}
fn default_threshold() -> f64 { 0.65 }
fn default_hysteresis() -> f64 { 0.1 }
fn default_cooldown_turns() -> u32 { 4 }
impl Default for EntropyWatchConfig {
fn default() -> Self {
Self {
enabled: false,
threshold: default_threshold(),
hysteresis: default_hysteresis(),
cooldown_turns: default_cooldown_turns(),
notify_model: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EntropySample {
pub turn: u32,
pub score: f64,
pub rho: f64,
pub repeat_pressure: f64,
pub failure_rate: f64,
pub rollbacks_in_window: u32,
pub window_turns: u32,
}
#[derive(Debug, Default)]
pub struct EntropyTracker {
turn_stats: VecDeque<(u32, u32)>,
rollback_stats: VecDeque<u32>,
rollbacks_pending: u32,
disarmed: bool,
last_alert_turn: Option<u32>,
}
impl EntropyTracker {
pub fn note_rollback(&mut self) {
self.rollbacks_pending += 1;
}
pub fn sample(
&mut self,
turn: u32,
rho: f64,
repeat_streak: u32,
repeat_deny_after: u32,
errored_results: u32,
total_results: u32,
) -> EntropySample {
self.turn_stats.push_back((errored_results, total_results));
self.rollback_stats.push_back(std::mem::take(&mut self.rollbacks_pending));
while self.turn_stats.len() > ENTROPY_WINDOW_TURNS {
self.turn_stats.pop_front();
}
while self.rollback_stats.len() > ENTROPY_WINDOW_TURNS {
self.rollback_stats.pop_front();
}
let (errors, totals) = self
.turn_stats
.iter()
.fold((0u32, 0u32), |(e, t), (te, tt)| (e + te, t + tt));
let rollbacks_in_window: u32 = self.rollback_stats.iter().sum();
let rho = rho.clamp(0.0, 1.0);
let repeat_pressure = (f64::from(repeat_streak.saturating_sub(1))
/ f64::from(repeat_deny_after.max(1)))
.clamp(0.0, 1.0);
let failure_rate = if totals == 0 { 0.0 } else { f64::from(errors) / f64::from(totals) };
let rollback_term = (f64::from(rollbacks_in_window) / ROLLBACK_SATURATION).clamp(0.0, 1.0);
let score = 0.35 * repeat_pressure + 0.30 * failure_rate + 0.20 * rho + 0.15 * rollback_term;
EntropySample {
turn,
score,
rho,
repeat_pressure,
failure_rate,
rollbacks_in_window,
window_turns: self.turn_stats.len() as u32,
}
}
pub fn should_alert(&mut self, config: &EntropyWatchConfig, sample: &EntropySample) -> bool {
if !config.enabled {
return false;
}
if sample.score < config.threshold {
if sample.score < config.threshold - config.hysteresis {
self.disarmed = false;
}
return false;
}
if self.disarmed {
return false;
}
let cooled_down = self
.last_alert_turn
.is_none_or(|t| sample.turn.saturating_sub(t) >= config.cooldown_turns);
if !cooled_down {
return false;
}
self.disarmed = true;
self.last_alert_turn = Some(sample.turn);
true
}
}
#[cfg(test)]
mod tests {
use super::*;
fn quiet_sample(tracker: &mut EntropyTracker, turn: u32) -> EntropySample {
tracker.sample(turn, 0.1, 1, 5, 0, 2)
}
#[test]
fn healthy_turn_scores_near_zero() {
let mut t = EntropyTracker::default();
let s = quiet_sample(&mut t, 1);
assert!(s.score < 0.05, "healthy turn score {} should be ~0", s.score);
assert_eq!(s.repeat_pressure, 0.0);
assert_eq!(s.failure_rate, 0.0);
assert_eq!(s.rollbacks_in_window, 0);
}
#[test]
fn repetition_and_failures_raise_the_score() {
let mut t = EntropyTracker::default();
let s = t.sample(3, 0.9, 4, 5, 2, 2);
assert!(s.score > 0.6, "disordered turn score {} should be high", s.score);
assert!((s.repeat_pressure - 0.6).abs() < 1e-9);
assert!((s.failure_rate - 1.0).abs() < 1e-9);
}
#[test]
fn failure_rate_windows_out_old_turns() {
let mut t = EntropyTracker::default();
t.sample(1, 0.1, 1, 5, 3, 3); for turn in 2..=(ENTROPY_WINDOW_TURNS as u32 + 1) {
let s = quiet_sample(&mut t, turn);
if turn <= ENTROPY_WINDOW_TURNS as u32 {
assert!(s.failure_rate > 0.0, "turn {turn} still inside the window");
} else {
assert_eq!(s.failure_rate, 0.0, "turn {turn} should have evicted the errors");
}
}
}
#[test]
fn rollbacks_accrue_until_a_boundary_completes() {
let mut t = EntropyTracker::default();
t.note_rollback();
t.note_rollback();
let s = quiet_sample(&mut t, 2);
assert_eq!(s.rollbacks_in_window, 2);
let s = quiet_sample(&mut t, 3);
assert_eq!(s.rollbacks_in_window, 2);
}
#[test]
fn watch_fires_once_then_rearms_below_hysteresis() {
let cfg = EntropyWatchConfig { enabled: true, threshold: 0.5, hysteresis: 0.1, cooldown_turns: 0, notify_model: false };
let mut t = EntropyTracker::default();
let hot = EntropySample { turn: 1, score: 0.7, rho: 0.0, repeat_pressure: 0.0, failure_rate: 0.0, rollbacks_in_window: 0, window_turns: 1 };
assert!(t.should_alert(&cfg, &hot));
assert!(!t.should_alert(&cfg, &EntropySample { turn: 2, ..hot }));
assert!(!t.should_alert(&cfg, &EntropySample { turn: 3, score: 0.45, ..hot }));
assert!(!t.should_alert(&cfg, &EntropySample { turn: 4, ..hot }));
assert!(!t.should_alert(&cfg, &EntropySample { turn: 5, score: 0.3, ..hot }));
assert!(t.should_alert(&cfg, &EntropySample { turn: 6, ..hot }));
}
#[test]
fn watch_cooldown_gates_refire_even_after_rearm() {
let cfg = EntropyWatchConfig { enabled: true, threshold: 0.5, hysteresis: 0.1, cooldown_turns: 5, notify_model: false };
let mut t = EntropyTracker::default();
let hot = EntropySample { turn: 1, score: 0.9, rho: 0.0, repeat_pressure: 0.0, failure_rate: 0.0, rollbacks_in_window: 0, window_turns: 1 };
assert!(t.should_alert(&cfg, &hot));
assert!(!t.should_alert(&cfg, &EntropySample { turn: 2, score: 0.2, ..hot })); assert!(!t.should_alert(&cfg, &EntropySample { turn: 3, ..hot })); assert!(t.should_alert(&cfg, &EntropySample { turn: 6, ..hot })); }
#[test]
fn watch_disabled_never_alerts() {
let cfg = EntropyWatchConfig::default();
assert!(!cfg.enabled);
let mut t = EntropyTracker::default();
let hot = EntropySample { turn: 1, score: 1.0, rho: 1.0, repeat_pressure: 1.0, failure_rate: 1.0, rollbacks_in_window: 9, window_turns: 8 };
assert!(!t.should_alert(&cfg, &hot));
}
}