//! AZ-682 — frame-rate floor monitor. //! //! Per `description.md §5/§6/§8`: when the sustained FPS drops below //! the configured floor (default 10 fps), the FSM suppresses //! `ZoomedOut → ZoomedIn` transitions and surfaces yellow health. //! //! Implementation: ring-buffer of recent frame-arrival timestamps. //! Computing average FPS over the window is cheap (O(1) per observe) //! and avoids spurious flapping that a single-frame rate would //! produce. A hysteresis margin (`floor_clear_fps`) gates the //! transition back to "active" to prevent oscillation around the //! threshold. use std::collections::VecDeque; use std::time::Duration; use serde::{Deserialize, Serialize}; /// Minimum window size before the monitor produces a verdict. With /// fewer than this many observations the guard returns `false` /// (assume healthy) — this is the "warming up" period right after /// boot. const WARMUP_SAMPLES: usize = 4; #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub struct FrameRateGuardConfig { pub window: Duration, pub fps_floor: f32, pub fps_clear: f32, } impl Default for FrameRateGuardConfig { fn default() -> Self { Self { // 1 s window matches the description's "sustained" intent // — single-frame jitter cannot trip it but a 1-second // dip will. window: Duration::from_secs(1), fps_floor: 10.0, // Require fps to recover above 12 before clearing, to // dampen oscillation right around the floor. fps_clear: 12.0, } } } /// Monotonic ring buffer of frame arrival times. `observe` is /// O(amortised 1); `is_floor_active` is O(1). The buffer is bounded /// by the time window, not by sample count, so it self-trims as /// frames arrive. #[derive(Debug)] pub struct FrameRateGuard { config: FrameRateGuardConfig, arrivals_ns: VecDeque, active: bool, } impl FrameRateGuard { pub fn new(config: FrameRateGuardConfig) -> Self { Self { config, arrivals_ns: VecDeque::new(), active: false, } } pub fn observe(&mut self, now_ns: u64) { self.arrivals_ns.push_back(now_ns); let window_ns = self.config.window.as_nanos() as u64; // Trim arrivals outside the rolling window. while let Some(&front) = self.arrivals_ns.front() { if now_ns.saturating_sub(front) > window_ns { self.arrivals_ns.pop_front(); } else { break; } } self.recompute(); } /// Treat a long silence (no `observe` calls) as zero fps. Callers /// invoke this on the scan-controller tick so the guard does not /// stay stuck "green" after a frame pipeline stall. pub fn tick(&mut self, now_ns: u64) { let window_ns = self.config.window.as_nanos() as u64; while let Some(&front) = self.arrivals_ns.front() { if now_ns.saturating_sub(front) > window_ns { self.arrivals_ns.pop_front(); } else { break; } } self.recompute(); } pub fn is_floor_active(&self) -> bool { self.active } pub fn fps(&self) -> f32 { let n = self.arrivals_ns.len(); if n < 2 { return 0.0; } // Avoid div-by-zero on burst arrivals at the same ns. let first = self.arrivals_ns.front().copied().unwrap_or(0); let last = self.arrivals_ns.back().copied().unwrap_or(0); let span_ns = last.saturating_sub(first); if span_ns == 0 { return 0.0; } let span_s = (span_ns as f64) / 1e9; ((n - 1) as f64 / span_s) as f32 } fn recompute(&mut self) { let n = self.arrivals_ns.len(); if n < WARMUP_SAMPLES { self.active = false; return; } let fps = self.fps(); // Hysteresis: floor activates strictly below `fps_floor` and // clears strictly above `fps_clear`. Within the band the // existing state holds. if self.active { if fps >= self.config.fps_clear { self.active = false; } } else if fps < self.config.fps_floor { self.active = true; } } } #[cfg(test)] mod tests { use super::*; fn cfg() -> FrameRateGuardConfig { FrameRateGuardConfig::default() } fn observe_at_fps(g: &mut FrameRateGuard, fps: f32, count: u32, start_ns: u64) -> u64 { let step_ns = (1e9 / fps as f64) as u64; let mut t = start_ns; for _ in 0..count { g.observe(t); t += step_ns; } t } #[test] fn warming_up_window_returns_inactive() { // Arrange let mut g = FrameRateGuard::new(cfg()); // Act g.observe(0); g.observe(100_000_000); // Assert assert!(!g.is_floor_active(), "must be inactive during warmup"); } #[test] fn healthy_fps_keeps_floor_clear() { // Arrange let mut g = FrameRateGuard::new(cfg()); // Act — observe 30 frames at 30 fps over 1 s observe_at_fps(&mut g, 30.0, 30, 0); // Assert assert!(!g.is_floor_active()); assert!(g.fps() > 25.0, "expected ~30 fps, got {}", g.fps()); } #[test] fn sustained_low_fps_activates_floor() { // Arrange let mut g = FrameRateGuard::new(cfg()); // Act — 5 frames at 5 fps over 1 s observe_at_fps(&mut g, 5.0, 5, 0); // Assert assert!(g.is_floor_active()); assert!(g.fps() < 10.0); } #[test] fn fps_recovery_clears_floor_with_hysteresis() { // Arrange — drop below floor first let mut g = FrameRateGuard::new(cfg()); let end = observe_at_fps(&mut g, 5.0, 5, 0); assert!(g.is_floor_active()); // Act — observe healthy 30 fps after recovery observe_at_fps(&mut g, 30.0, 60, end + 200_000_000); // Assert assert!( !g.is_floor_active(), "floor must clear once fps > clear threshold; current fps = {}", g.fps() ); } #[test] fn hysteresis_band_does_not_oscillate() { // Arrange — in the band [10, 12) fps. Start active, stay // active; start inactive, stay inactive. let mut active_guard = FrameRateGuard::new(cfg()); observe_at_fps(&mut active_guard, 5.0, 5, 0); assert!(active_guard.is_floor_active()); // Act — provide 11 fps (within hysteresis band) let span_ns = 1_000_000_000u64; let step_ns = span_ns / 11; let mut t = 1_500_000_000u64; for _ in 0..11 { active_guard.observe(t); t += step_ns; } // Assert — still active because we have not crossed fps_clear assert!( active_guard.is_floor_active(), "11 fps inside hysteresis band must NOT clear an active floor; fps = {}", active_guard.fps() ); } #[test] fn tick_without_observe_re_evaluates_silence() { // Arrange — healthy fps then long silence let mut g = FrameRateGuard::new(cfg()); observe_at_fps(&mut g, 30.0, 30, 0); assert!(!g.is_floor_active()); // Act — 5 s of silence — tick must re-evaluate. g.tick(6_000_000_000); // Assert — buffer is empty so fps falls below floor; the // guard treats this as active (we have