Skip to main content

ftui_render/
frame_guardrails.rs

1#![forbid(unsafe_code)]
2
3//! Frame guardrails: memory budget, queue depth limits, and unified enforcement.
4//!
5//! This module complements the time-based [`RenderBudget`](crate::budget::RenderBudget)
6//! and allocation-tracking [`AllocLeakDetector`](crate::alloc_budget::AllocLeakDetector)
7//! with two additional guardrails:
8//!
9//! 1. **Memory budget** — enforces hard/soft limits on total rendering memory
10//!    (buffer cells, grapheme pool, arena).
11//! 2. **Queue depth** — prevents unbounded frame queuing under sustained load
12//!    with configurable drop policies.
13//!
14//! A unified [`FrameGuardrails`] facade combines all four guardrails into a
15//! single per-frame checkpoint that returns an actionable [`GuardrailVerdict`].
16//!
17//! # Usage
18//!
19//! ```
20//! use ftui_render::frame_guardrails::{
21//!     FrameGuardrails, GuardrailsConfig, MemoryBudgetConfig, QueueConfig,
22//! };
23//! use ftui_render::budget::FrameBudgetConfig;
24//!
25//! let config = GuardrailsConfig::default();
26//! let mut guardrails = FrameGuardrails::new(config);
27//!
28//! // Each frame: report current resource usage
29//! let verdict = guardrails.check_frame(
30//!     1_048_576,  // current memory bytes
31//!     2,          // pending frames in queue
32//! );
33//!
34//! if verdict.should_drop_frame() {
35//!     // Skip this frame entirely
36//! } else if verdict.should_degrade() {
37//!     // Render at reduced fidelity
38//! }
39//! ```
40
41use crate::budget::DegradationLevel;
42
43// =========================================================================
44// Alerts
45// =========================================================================
46
47/// Category of guardrail that triggered.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
49pub enum GuardrailKind {
50    /// Memory usage exceeded a threshold.
51    Memory,
52    /// Queue depth exceeded a threshold.
53    QueueDepth,
54}
55
56/// Severity of a guardrail alert.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
58pub enum AlertSeverity {
59    /// Approaching limit — consider reducing work.
60    Warning,
61    /// At or near limit — degrade immediately.
62    Critical,
63    /// Past hard limit — drop frames or backpressure.
64    Emergency,
65}
66
67/// A single guardrail alert.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct GuardrailAlert {
70    /// Which guardrail triggered.
71    pub kind: GuardrailKind,
72    /// How severe the overage is.
73    pub severity: AlertSeverity,
74    /// Recommended minimum degradation level.
75    pub recommended_level: DegradationLevel,
76}
77
78// =========================================================================
79// Memory budget
80// =========================================================================
81
82/// Configuration for memory budget enforcement.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub struct MemoryBudgetConfig {
85    /// Soft limit in bytes — triggers `Warning` alert and suggests degradation.
86    /// Default: 8 MiB (enough for ~524K cells at 16 bytes each, i.e. ~540×970).
87    pub soft_limit_bytes: usize,
88    /// Hard limit in bytes — triggers `Critical` alert with aggressive degradation.
89    /// Default: 16 MiB.
90    pub hard_limit_bytes: usize,
91    /// Emergency limit in bytes — triggers `Emergency` alert, drop frames.
92    /// Default: 32 MiB.
93    pub emergency_limit_bytes: usize,
94}
95
96impl Default for MemoryBudgetConfig {
97    fn default() -> Self {
98        Self {
99            soft_limit_bytes: 8 * 1024 * 1024,
100            hard_limit_bytes: 16 * 1024 * 1024,
101            emergency_limit_bytes: 32 * 1024 * 1024,
102        }
103    }
104}
105
106impl MemoryBudgetConfig {
107    /// Create a config scaled for small terminals (e.g. 80×24).
108    #[must_use]
109    pub fn small() -> Self {
110        Self {
111            soft_limit_bytes: 2 * 1024 * 1024,
112            hard_limit_bytes: 4 * 1024 * 1024,
113            emergency_limit_bytes: 8 * 1024 * 1024,
114        }
115    }
116
117    /// Create a config scaled for large terminals (e.g. 300×100).
118    #[must_use]
119    pub fn large() -> Self {
120        Self {
121            soft_limit_bytes: 32 * 1024 * 1024,
122            hard_limit_bytes: 64 * 1024 * 1024,
123            emergency_limit_bytes: 128 * 1024 * 1024,
124        }
125    }
126}
127
128/// Memory budget tracker.
129///
130/// Checks reported memory usage against configured thresholds and produces
131/// alerts with recommended degradation levels.
132#[derive(Debug, Clone)]
133pub struct MemoryBudget {
134    config: MemoryBudgetConfig,
135    /// Peak memory observed (bytes).
136    peak_bytes: usize,
137    /// Last reported memory (bytes).
138    current_bytes: usize,
139    /// Number of frames where soft limit was exceeded.
140    soft_violations: u32,
141    /// Number of frames where hard limit was exceeded.
142    hard_violations: u32,
143}
144
145impl MemoryBudget {
146    /// Create a new memory budget with the given configuration.
147    #[must_use]
148    pub fn new(config: MemoryBudgetConfig) -> Self {
149        Self {
150            config,
151            peak_bytes: 0,
152            current_bytes: 0,
153            soft_violations: 0,
154            hard_violations: 0,
155        }
156    }
157
158    /// Report current memory usage and get an alert if thresholds are exceeded.
159    pub fn check(&mut self, current_bytes: usize) -> Option<GuardrailAlert> {
160        self.current_bytes = current_bytes;
161        if current_bytes > self.peak_bytes {
162            self.peak_bytes = current_bytes;
163        }
164
165        if current_bytes >= self.config.emergency_limit_bytes {
166            self.hard_violations = self.hard_violations.saturating_add(1);
167            Some(GuardrailAlert {
168                kind: GuardrailKind::Memory,
169                severity: AlertSeverity::Emergency,
170                recommended_level: DegradationLevel::SkipFrame,
171            })
172        } else if current_bytes >= self.config.hard_limit_bytes {
173            self.hard_violations = self.hard_violations.saturating_add(1);
174            Some(GuardrailAlert {
175                kind: GuardrailKind::Memory,
176                severity: AlertSeverity::Critical,
177                recommended_level: DegradationLevel::Skeleton,
178            })
179        } else if current_bytes >= self.config.soft_limit_bytes {
180            self.soft_violations = self.soft_violations.saturating_add(1);
181            Some(GuardrailAlert {
182                kind: GuardrailKind::Memory,
183                severity: AlertSeverity::Warning,
184                recommended_level: DegradationLevel::SimpleBorders,
185            })
186        } else {
187            None
188        }
189    }
190
191    /// Current memory usage in bytes.
192    #[inline]
193    #[must_use]
194    pub fn current_bytes(&self) -> usize {
195        self.current_bytes
196    }
197
198    /// Peak memory usage observed since creation or last reset.
199    #[inline]
200    #[must_use]
201    pub fn peak_bytes(&self) -> usize {
202        self.peak_bytes
203    }
204
205    /// Fraction of soft limit currently used (0.0 = empty, 1.0 = at limit).
206    #[inline]
207    #[must_use]
208    pub fn usage_fraction(&self) -> f64 {
209        if self.config.soft_limit_bytes == 0 {
210            return 1.0;
211        }
212        self.current_bytes as f64 / self.config.soft_limit_bytes as f64
213    }
214
215    /// Number of frames where the soft limit was exceeded.
216    #[inline]
217    #[must_use]
218    pub fn soft_violations(&self) -> u32 {
219        self.soft_violations
220    }
221
222    /// Number of frames where the hard limit was exceeded.
223    #[inline]
224    #[must_use]
225    pub fn hard_violations(&self) -> u32 {
226        self.hard_violations
227    }
228
229    /// Get a reference to the configuration.
230    #[inline]
231    #[must_use]
232    pub fn config(&self) -> &MemoryBudgetConfig {
233        &self.config
234    }
235
236    /// Reset tracking state (preserves config).
237    pub fn reset(&mut self) {
238        self.peak_bytes = 0;
239        self.current_bytes = 0;
240        self.soft_violations = 0;
241        self.hard_violations = 0;
242    }
243}
244
245// =========================================================================
246// Queue depth guardrails
247// =========================================================================
248
249/// Policy for handling frames when queue is full.
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
251pub enum QueueDropPolicy {
252    /// Drop the oldest pending frame (display freshest content).
253    #[default]
254    DropOldest,
255    /// Drop the newest frame (preserve sequential ordering).
256    DropNewest,
257    /// Signal backpressure to the producer (don't drop, slow input).
258    Backpressure,
259}
260
261/// Configuration for frame queue depth limits.
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub struct QueueConfig {
264    /// Maximum pending frames before warning.
265    /// Default: 3.
266    pub warn_depth: u32,
267    /// Maximum pending frames before critical action.
268    /// Default: 8.
269    pub max_depth: u32,
270    /// Emergency depth — drop all but latest.
271    /// Default: 16.
272    pub emergency_depth: u32,
273    /// What to do when max_depth is reached.
274    pub drop_policy: QueueDropPolicy,
275}
276
277impl Default for QueueConfig {
278    fn default() -> Self {
279        Self {
280            warn_depth: 3,
281            max_depth: 8,
282            emergency_depth: 16,
283            drop_policy: QueueDropPolicy::DropOldest,
284        }
285    }
286}
287
288impl QueueConfig {
289    /// Strict config: small queue, backpressure policy.
290    #[must_use]
291    pub fn strict() -> Self {
292        Self {
293            warn_depth: 2,
294            max_depth: 4,
295            emergency_depth: 8,
296            drop_policy: QueueDropPolicy::Backpressure,
297        }
298    }
299
300    /// Relaxed config: larger queue, drop oldest.
301    #[must_use]
302    pub fn relaxed() -> Self {
303        Self {
304            warn_depth: 8,
305            max_depth: 16,
306            emergency_depth: 32,
307            drop_policy: QueueDropPolicy::DropOldest,
308        }
309    }
310}
311
312/// Queue depth tracker.
313///
314/// Monitors the number of pending frames and produces alerts when
315/// configured thresholds are exceeded.
316#[derive(Debug, Clone)]
317pub struct QueueGuardrails {
318    config: QueueConfig,
319    /// Peak queue depth observed.
320    peak_depth: u32,
321    /// Current queue depth.
322    current_depth: u32,
323    /// Total frames dropped due to queue overflow.
324    total_drops: u64,
325    /// Total backpressure events.
326    total_backpressure_events: u64,
327}
328
329impl QueueGuardrails {
330    /// Create a new queue guardrail with the given configuration.
331    #[must_use]
332    pub fn new(config: QueueConfig) -> Self {
333        Self {
334            config,
335            peak_depth: 0,
336            current_depth: 0,
337            total_drops: 0,
338            total_backpressure_events: 0,
339        }
340    }
341
342    /// Report current queue depth and get an alert if thresholds are exceeded.
343    ///
344    /// Returns `(alert, action)` where action indicates what the runtime should
345    /// do about queued frames (if anything).
346    pub fn check(&mut self, current_depth: u32) -> (Option<GuardrailAlert>, QueueAction) {
347        self.current_depth = current_depth;
348        if current_depth > self.peak_depth {
349            self.peak_depth = current_depth;
350        }
351
352        if current_depth >= self.config.emergency_depth {
353            let action = match self.config.drop_policy {
354                QueueDropPolicy::DropOldest => {
355                    let excess = current_depth - 1; // keep only latest
356                    self.total_drops = self.total_drops.saturating_add(excess as u64);
357                    QueueAction::DropOldest(excess)
358                }
359                QueueDropPolicy::DropNewest => {
360                    self.total_drops = self.total_drops.saturating_add(1);
361                    QueueAction::DropNewest(1)
362                }
363                QueueDropPolicy::Backpressure => {
364                    self.total_backpressure_events =
365                        self.total_backpressure_events.saturating_add(1);
366                    QueueAction::Backpressure
367                }
368            };
369            (
370                Some(GuardrailAlert {
371                    kind: GuardrailKind::QueueDepth,
372                    severity: AlertSeverity::Emergency,
373                    recommended_level: DegradationLevel::SkipFrame,
374                }),
375                action,
376            )
377        } else if current_depth >= self.config.max_depth {
378            let action = match self.config.drop_policy {
379                QueueDropPolicy::DropOldest => {
380                    let excess = current_depth.saturating_sub(self.config.warn_depth);
381                    self.total_drops = self.total_drops.saturating_add(excess as u64);
382                    QueueAction::DropOldest(excess)
383                }
384                QueueDropPolicy::DropNewest => {
385                    self.total_drops = self.total_drops.saturating_add(1);
386                    QueueAction::DropNewest(1)
387                }
388                QueueDropPolicy::Backpressure => {
389                    self.total_backpressure_events =
390                        self.total_backpressure_events.saturating_add(1);
391                    QueueAction::Backpressure
392                }
393            };
394            (
395                Some(GuardrailAlert {
396                    kind: GuardrailKind::QueueDepth,
397                    severity: AlertSeverity::Critical,
398                    recommended_level: DegradationLevel::EssentialOnly,
399                }),
400                action,
401            )
402        } else if current_depth >= self.config.warn_depth {
403            (
404                Some(GuardrailAlert {
405                    kind: GuardrailKind::QueueDepth,
406                    severity: AlertSeverity::Warning,
407                    recommended_level: DegradationLevel::SimpleBorders,
408                }),
409                QueueAction::None,
410            )
411        } else {
412            (None, QueueAction::None)
413        }
414    }
415
416    /// Current queue depth.
417    #[inline]
418    #[must_use]
419    pub fn current_depth(&self) -> u32 {
420        self.current_depth
421    }
422
423    /// Peak queue depth observed.
424    #[inline]
425    #[must_use]
426    pub fn peak_depth(&self) -> u32 {
427        self.peak_depth
428    }
429
430    /// Total frames dropped due to queue overflow.
431    #[inline]
432    #[must_use]
433    pub fn total_drops(&self) -> u64 {
434        self.total_drops
435    }
436
437    /// Total backpressure events.
438    #[inline]
439    #[must_use]
440    pub fn total_backpressure_events(&self) -> u64 {
441        self.total_backpressure_events
442    }
443
444    /// Get a reference to the configuration.
445    #[inline]
446    #[must_use]
447    pub fn config(&self) -> &QueueConfig {
448        &self.config
449    }
450
451    /// Reset tracking state (preserves config).
452    pub fn reset(&mut self) {
453        self.peak_depth = 0;
454        self.current_depth = 0;
455        self.total_drops = 0;
456        self.total_backpressure_events = 0;
457    }
458}
459
460/// Action the runtime should take in response to queue depth.
461#[derive(Debug, Clone, Copy, PartialEq, Eq)]
462pub enum QueueAction {
463    /// No action needed.
464    None,
465    /// Drop the N oldest pending frames.
466    DropOldest(u32),
467    /// Drop the N newest pending frames.
468    DropNewest(u32),
469    /// Signal backpressure to the input source.
470    Backpressure,
471}
472
473impl QueueAction {
474    /// Whether this action requires dropping any frames.
475    #[inline]
476    #[must_use]
477    pub fn drops_frames(self) -> bool {
478        matches!(self, Self::DropOldest(_) | Self::DropNewest(_))
479    }
480}
481
482// =========================================================================
483// Unified guardrails
484// =========================================================================
485
486/// Configuration for the unified frame guardrails.
487#[derive(Debug, Clone, Default)]
488pub struct GuardrailsConfig {
489    /// Memory budget configuration.
490    pub memory: MemoryBudgetConfig,
491    /// Queue depth configuration.
492    pub queue: QueueConfig,
493}
494
495/// Verdict from a guardrail check, combining all subsystem results.
496#[derive(Debug, Clone)]
497pub struct GuardrailVerdict {
498    /// Alerts from all guardrails that fired (may be empty).
499    pub alerts: Vec<GuardrailAlert>,
500    /// Queue action recommended by queue guardrails.
501    pub queue_action: QueueAction,
502    /// The most aggressive degradation level recommended across all alerts.
503    pub recommended_level: DegradationLevel,
504}
505
506impl GuardrailVerdict {
507    /// Whether any guardrail recommends dropping the current frame.
508    #[inline]
509    #[must_use]
510    pub fn should_drop_frame(&self) -> bool {
511        self.recommended_level >= DegradationLevel::SkipFrame
512    }
513
514    /// Whether any guardrail recommends degradation (but not frame skip).
515    #[inline]
516    #[must_use]
517    pub fn should_degrade(&self) -> bool {
518        self.recommended_level > DegradationLevel::Full
519            && self.recommended_level < DegradationLevel::SkipFrame
520    }
521
522    /// Whether all guardrails are satisfied (no alerts).
523    #[inline]
524    #[must_use]
525    pub fn is_clear(&self) -> bool {
526        self.alerts.is_empty()
527    }
528
529    /// The highest severity among all alerts, or `None` if clear.
530    #[must_use]
531    pub fn max_severity(&self) -> Option<AlertSeverity> {
532        self.alerts.iter().map(|a| a.severity).max()
533    }
534}
535
536/// Unified frame guardrails combining memory budget and queue depth limits.
537///
538/// Call [`check_frame`](Self::check_frame) once per frame with current resource
539/// usage. The returned [`GuardrailVerdict`] tells you what (if anything) to do.
540#[derive(Debug, Clone)]
541pub struct FrameGuardrails {
542    memory: MemoryBudget,
543    queue: QueueGuardrails,
544    /// Total frames checked.
545    frames_checked: u64,
546    /// Total frames where at least one alert fired.
547    frames_with_alerts: u64,
548}
549
550impl FrameGuardrails {
551    /// Create a new unified guardrails instance.
552    #[must_use]
553    pub fn new(config: GuardrailsConfig) -> Self {
554        Self {
555            memory: MemoryBudget::new(config.memory),
556            queue: QueueGuardrails::new(config.queue),
557            frames_checked: 0,
558            frames_with_alerts: 0,
559        }
560    }
561
562    /// Check all guardrails for the current frame.
563    ///
564    /// `memory_bytes`: total rendering memory in use (buffer + pools).
565    /// `queue_depth`: number of pending frames waiting to be rendered.
566    pub fn check_frame(&mut self, memory_bytes: usize, queue_depth: u32) -> GuardrailVerdict {
567        self.frames_checked = self.frames_checked.saturating_add(1);
568
569        let mut alerts = Vec::new();
570        let mut max_level = DegradationLevel::Full;
571
572        // Memory check
573        if let Some(alert) = self.memory.check(memory_bytes) {
574            if alert.recommended_level > max_level {
575                max_level = alert.recommended_level;
576            }
577            alerts.push(alert);
578        }
579
580        // Queue check
581        let (queue_alert, queue_action) = self.queue.check(queue_depth);
582        if let Some(alert) = queue_alert {
583            if alert.recommended_level > max_level {
584                max_level = alert.recommended_level;
585            }
586            alerts.push(alert);
587        }
588
589        if !alerts.is_empty() {
590            self.frames_with_alerts = self.frames_with_alerts.saturating_add(1);
591        }
592
593        GuardrailVerdict {
594            alerts,
595            queue_action,
596            recommended_level: max_level,
597        }
598    }
599
600    /// Access the memory budget subsystem.
601    #[inline]
602    #[must_use]
603    pub fn memory(&self) -> &MemoryBudget {
604        &self.memory
605    }
606
607    /// Access the queue guardrails subsystem.
608    #[inline]
609    #[must_use]
610    pub fn queue(&self) -> &QueueGuardrails {
611        &self.queue
612    }
613
614    /// Total frames checked.
615    #[inline]
616    #[must_use]
617    pub fn frames_checked(&self) -> u64 {
618        self.frames_checked
619    }
620
621    /// Total frames where at least one alert fired.
622    #[inline]
623    #[must_use]
624    pub fn frames_with_alerts(&self) -> u64 {
625        self.frames_with_alerts
626    }
627
628    /// Fraction of frames that triggered alerts (0.0–1.0).
629    #[inline]
630    #[must_use]
631    pub fn alert_rate(&self) -> f64 {
632        if self.frames_checked == 0 {
633            return 0.0;
634        }
635        self.frames_with_alerts as f64 / self.frames_checked as f64
636    }
637
638    /// Capture a diagnostic snapshot.
639    #[must_use]
640    pub fn snapshot(&self) -> GuardrailSnapshot {
641        GuardrailSnapshot {
642            memory_bytes: self.memory.current_bytes(),
643            memory_peak_bytes: self.memory.peak_bytes(),
644            memory_usage_fraction: self.memory.usage_fraction(),
645            memory_soft_violations: self.memory.soft_violations(),
646            memory_hard_violations: self.memory.hard_violations(),
647            queue_depth: self.queue.current_depth(),
648            queue_peak_depth: self.queue.peak_depth(),
649            queue_total_drops: self.queue.total_drops(),
650            queue_total_backpressure: self.queue.total_backpressure_events(),
651            frames_checked: self.frames_checked,
652            frames_with_alerts: self.frames_with_alerts,
653        }
654    }
655
656    /// Reset all tracking state (preserves configs).
657    pub fn reset(&mut self) {
658        self.memory.reset();
659        self.queue.reset();
660        self.frames_checked = 0;
661        self.frames_with_alerts = 0;
662    }
663}
664
665/// Diagnostic snapshot of guardrail state.
666///
667/// All fields are `Copy` — no allocations. Suitable for structured logging
668/// or debug overlay.
669#[derive(Debug, Clone, Copy, PartialEq)]
670pub struct GuardrailSnapshot {
671    /// Current memory usage in bytes.
672    pub memory_bytes: usize,
673    /// Peak memory usage in bytes.
674    pub memory_peak_bytes: usize,
675    /// Fraction of soft memory limit used.
676    pub memory_usage_fraction: f64,
677    /// Frames exceeding soft memory limit.
678    pub memory_soft_violations: u32,
679    /// Frames exceeding hard memory limit.
680    pub memory_hard_violations: u32,
681    /// Current queue depth.
682    pub queue_depth: u32,
683    /// Peak queue depth.
684    pub queue_peak_depth: u32,
685    /// Total frames dropped from queue.
686    pub queue_total_drops: u64,
687    /// Total backpressure events.
688    pub queue_total_backpressure: u64,
689    /// Total frames checked.
690    pub frames_checked: u64,
691    /// Total frames with alerts.
692    pub frames_with_alerts: u64,
693}
694
695impl GuardrailSnapshot {
696    /// Serialize to a JSONL-compatible string.
697    pub fn to_jsonl(&self) -> String {
698        format!(
699            concat!(
700                r#"{{"memory_bytes":{},"memory_peak":{},"memory_frac":{:.4},"#,
701                r#""mem_soft_violations":{},"mem_hard_violations":{},"#,
702                r#""queue_depth":{},"queue_peak":{},"queue_drops":{},"#,
703                r#""queue_backpressure":{},"frames_checked":{},"frames_alerted":{}}}"#,
704            ),
705            self.memory_bytes,
706            self.memory_peak_bytes,
707            self.memory_usage_fraction,
708            self.memory_soft_violations,
709            self.memory_hard_violations,
710            self.queue_depth,
711            self.queue_peak_depth,
712            self.queue_total_drops,
713            self.queue_total_backpressure,
714            self.frames_checked,
715            self.frames_with_alerts,
716        )
717    }
718}
719
720// =========================================================================
721// Utility: compute buffer memory
722// =========================================================================
723
724/// Size of a single Cell in bytes (compile-time constant).
725pub const CELL_SIZE_BYTES: usize = 16;
726
727/// Compute the memory footprint of a buffer with the given dimensions.
728///
729/// This accounts for the cell array only (not dirty tracking or stack metadata).
730#[inline]
731#[must_use]
732pub fn buffer_memory_bytes(width: u16, height: u16) -> usize {
733    width as usize * height as usize * CELL_SIZE_BYTES
734}
735
736// =========================================================================
737// Tests
738// =========================================================================
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743
744    // ---- MemoryBudget ----
745
746    #[test]
747    fn memory_below_soft_no_alert() {
748        let mut mb = MemoryBudget::new(MemoryBudgetConfig::default());
749        assert!(mb.check(1024).is_none());
750        assert_eq!(mb.current_bytes(), 1024);
751    }
752
753    #[test]
754    fn memory_at_soft_limit_warns() {
755        let mut mb = MemoryBudget::new(MemoryBudgetConfig::default());
756        let alert = mb.check(8 * 1024 * 1024).unwrap();
757        assert_eq!(alert.kind, GuardrailKind::Memory);
758        assert_eq!(alert.severity, AlertSeverity::Warning);
759        assert_eq!(alert.recommended_level, DegradationLevel::SimpleBorders);
760    }
761
762    #[test]
763    fn memory_at_hard_limit_critical() {
764        let mut mb = MemoryBudget::new(MemoryBudgetConfig::default());
765        let alert = mb.check(16 * 1024 * 1024).unwrap();
766        assert_eq!(alert.severity, AlertSeverity::Critical);
767        assert_eq!(alert.recommended_level, DegradationLevel::Skeleton);
768    }
769
770    #[test]
771    fn memory_at_emergency_limit() {
772        let mut mb = MemoryBudget::new(MemoryBudgetConfig::default());
773        let alert = mb.check(32 * 1024 * 1024).unwrap();
774        assert_eq!(alert.severity, AlertSeverity::Emergency);
775        assert_eq!(alert.recommended_level, DegradationLevel::SkipFrame);
776    }
777
778    #[test]
779    fn memory_peak_tracking() {
780        let mut mb = MemoryBudget::new(MemoryBudgetConfig::default());
781        mb.check(1000);
782        mb.check(5000);
783        mb.check(3000);
784        assert_eq!(mb.peak_bytes(), 5000);
785        assert_eq!(mb.current_bytes(), 3000);
786    }
787
788    #[test]
789    fn memory_violation_counts() {
790        let config = MemoryBudgetConfig {
791            soft_limit_bytes: 100,
792            hard_limit_bytes: 200,
793            emergency_limit_bytes: 300,
794        };
795        let mut mb = MemoryBudget::new(config);
796        mb.check(50); // no violation
797        mb.check(150); // soft
798        mb.check(150); // soft again
799        mb.check(250); // hard
800        assert_eq!(mb.soft_violations(), 2);
801        assert_eq!(mb.hard_violations(), 1);
802    }
803
804    #[test]
805    fn memory_usage_fraction() {
806        let config = MemoryBudgetConfig {
807            soft_limit_bytes: 1000,
808            hard_limit_bytes: 2000,
809            emergency_limit_bytes: 3000,
810        };
811        let mut mb = MemoryBudget::new(config);
812        mb.check(500);
813        assert!((mb.usage_fraction() - 0.5).abs() < f64::EPSILON);
814    }
815
816    #[test]
817    fn memory_usage_fraction_zero_limit() {
818        let config = MemoryBudgetConfig {
819            soft_limit_bytes: 0,
820            hard_limit_bytes: 0,
821            emergency_limit_bytes: 0,
822        };
823        let mut mb = MemoryBudget::new(config);
824        mb.check(100);
825        assert!((mb.usage_fraction() - 1.0).abs() < f64::EPSILON);
826    }
827
828    #[test]
829    fn memory_reset_clears_state() {
830        let mut mb = MemoryBudget::new(MemoryBudgetConfig::default());
831        mb.check(10 * 1024 * 1024); // soft violation
832        assert!(mb.soft_violations() > 0);
833        mb.reset();
834        assert_eq!(mb.peak_bytes(), 0);
835        assert_eq!(mb.current_bytes(), 0);
836        assert_eq!(mb.soft_violations(), 0);
837        assert_eq!(mb.hard_violations(), 0);
838    }
839
840    #[test]
841    fn memory_config_accessors() {
842        let config = MemoryBudgetConfig::small();
843        let mb = MemoryBudget::new(config);
844        assert_eq!(mb.config().soft_limit_bytes, 2 * 1024 * 1024);
845    }
846
847    // ---- QueueGuardrails ----
848
849    #[test]
850    fn queue_below_warn_no_alert() {
851        let mut qg = QueueGuardrails::new(QueueConfig::default());
852        let (alert, action) = qg.check(1);
853        assert!(alert.is_none());
854        assert_eq!(action, QueueAction::None);
855    }
856
857    #[test]
858    fn queue_at_warn_depth() {
859        let mut qg = QueueGuardrails::new(QueueConfig::default());
860        let (alert, action) = qg.check(3);
861        assert_eq!(alert.unwrap().severity, AlertSeverity::Warning);
862        assert_eq!(action, QueueAction::None); // warning only, no action
863    }
864
865    #[test]
866    fn queue_at_max_depth_drop_oldest() {
867        let config = QueueConfig {
868            drop_policy: QueueDropPolicy::DropOldest,
869            ..QueueConfig::default()
870        };
871        let mut qg = QueueGuardrails::new(config);
872        let (alert, action) = qg.check(8);
873        assert_eq!(alert.unwrap().severity, AlertSeverity::Critical);
874        assert!(action.drops_frames());
875    }
876
877    #[test]
878    fn queue_at_max_depth_drop_newest() {
879        let config = QueueConfig {
880            drop_policy: QueueDropPolicy::DropNewest,
881            ..QueueConfig::default()
882        };
883        let mut qg = QueueGuardrails::new(config);
884        let (alert, action) = qg.check(8);
885        assert_eq!(alert.unwrap().severity, AlertSeverity::Critical);
886        assert_eq!(action, QueueAction::DropNewest(1));
887    }
888
889    #[test]
890    fn queue_at_max_depth_backpressure() {
891        let config = QueueConfig {
892            drop_policy: QueueDropPolicy::Backpressure,
893            ..QueueConfig::default()
894        };
895        let mut qg = QueueGuardrails::new(config);
896        let (alert, action) = qg.check(8);
897        assert_eq!(alert.unwrap().severity, AlertSeverity::Critical);
898        assert_eq!(action, QueueAction::Backpressure);
899    }
900
901    #[test]
902    fn queue_emergency_drops_to_latest() {
903        let mut qg = QueueGuardrails::new(QueueConfig::default());
904        let (alert, action) = qg.check(16);
905        assert_eq!(alert.unwrap().severity, AlertSeverity::Emergency);
906        // DropOldest at emergency should keep only 1 frame
907        assert_eq!(action, QueueAction::DropOldest(15));
908    }
909
910    #[test]
911    fn queue_peak_tracking() {
912        let mut qg = QueueGuardrails::new(QueueConfig::default());
913        qg.check(2);
914        qg.check(5);
915        qg.check(1);
916        assert_eq!(qg.peak_depth(), 5);
917        assert_eq!(qg.current_depth(), 1);
918    }
919
920    #[test]
921    fn queue_drop_counting() {
922        let mut qg = QueueGuardrails::new(QueueConfig::default());
923        qg.check(8); // triggers drop
924        assert!(qg.total_drops() > 0);
925    }
926
927    #[test]
928    fn queue_backpressure_counting() {
929        let config = QueueConfig::strict();
930        let mut qg = QueueGuardrails::new(config);
931        qg.check(4); // max_depth for strict
932        assert!(qg.total_backpressure_events() > 0);
933    }
934
935    #[test]
936    fn queue_reset_clears_state() {
937        let mut qg = QueueGuardrails::new(QueueConfig::default());
938        qg.check(10);
939        qg.reset();
940        assert_eq!(qg.peak_depth(), 0);
941        assert_eq!(qg.current_depth(), 0);
942        assert_eq!(qg.total_drops(), 0);
943    }
944
945    #[test]
946    fn queue_config_accessors() {
947        let config = QueueConfig::relaxed();
948        let qg = QueueGuardrails::new(config);
949        assert_eq!(qg.config().max_depth, 16);
950    }
951
952    // ---- QueueAction ----
953
954    #[test]
955    fn queue_action_drops_frames() {
956        assert!(!QueueAction::None.drops_frames());
957        assert!(QueueAction::DropOldest(3).drops_frames());
958        assert!(QueueAction::DropNewest(1).drops_frames());
959        assert!(!QueueAction::Backpressure.drops_frames());
960    }
961
962    // ---- FrameGuardrails ----
963
964    #[test]
965    fn guardrails_clear_when_healthy() {
966        let mut g = FrameGuardrails::new(GuardrailsConfig::default());
967        let v = g.check_frame(1024, 0);
968        assert!(v.is_clear());
969        assert_eq!(v.recommended_level, DegradationLevel::Full);
970        assert_eq!(v.queue_action, QueueAction::None);
971    }
972
973    #[test]
974    fn guardrails_memory_alert_propagates() {
975        let mut g = FrameGuardrails::new(GuardrailsConfig::default());
976        let v = g.check_frame(8 * 1024 * 1024, 0);
977        assert!(!v.is_clear());
978        assert_eq!(v.alerts.len(), 1);
979        assert_eq!(v.alerts[0].kind, GuardrailKind::Memory);
980        assert!(v.should_degrade());
981        assert!(!v.should_drop_frame());
982    }
983
984    #[test]
985    fn guardrails_queue_alert_propagates() {
986        let mut g = FrameGuardrails::new(GuardrailsConfig::default());
987        let v = g.check_frame(0, 8);
988        assert!(!v.is_clear());
989        assert!(v.alerts.iter().any(|a| a.kind == GuardrailKind::QueueDepth));
990    }
991
992    #[test]
993    fn guardrails_both_alerts_combine() {
994        let config = GuardrailsConfig {
995            memory: MemoryBudgetConfig {
996                soft_limit_bytes: 100,
997                hard_limit_bytes: 200,
998                emergency_limit_bytes: 300,
999            },
1000            queue: QueueConfig {
1001                warn_depth: 1,
1002                max_depth: 2,
1003                emergency_depth: 3,
1004                drop_policy: QueueDropPolicy::DropOldest,
1005            },
1006        };
1007        let mut g = FrameGuardrails::new(config);
1008        let v = g.check_frame(150, 2);
1009        assert_eq!(v.alerts.len(), 2);
1010        // Should use the most aggressive recommendation
1011        assert!(v.recommended_level >= DegradationLevel::SimpleBorders);
1012    }
1013
1014    #[test]
1015    fn guardrails_emergency_recommends_skip() {
1016        let config = GuardrailsConfig {
1017            memory: MemoryBudgetConfig {
1018                soft_limit_bytes: 100,
1019                hard_limit_bytes: 200,
1020                emergency_limit_bytes: 300,
1021            },
1022            queue: QueueConfig::default(),
1023        };
1024        let mut g = FrameGuardrails::new(config);
1025        let v = g.check_frame(300, 0);
1026        assert!(v.should_drop_frame());
1027    }
1028
1029    #[test]
1030    fn guardrails_frame_counting() {
1031        let mut g = FrameGuardrails::new(GuardrailsConfig::default());
1032        g.check_frame(0, 0);
1033        g.check_frame(0, 0);
1034        g.check_frame(8 * 1024 * 1024, 0); // triggers alert
1035        assert_eq!(g.frames_checked(), 3);
1036        assert_eq!(g.frames_with_alerts(), 1);
1037    }
1038
1039    #[test]
1040    fn guardrails_alert_rate() {
1041        let config = GuardrailsConfig {
1042            memory: MemoryBudgetConfig {
1043                soft_limit_bytes: 100,
1044                hard_limit_bytes: 200,
1045                emergency_limit_bytes: 300,
1046            },
1047            queue: QueueConfig::default(),
1048        };
1049        let mut g = FrameGuardrails::new(config);
1050        g.check_frame(50, 0); // clear
1051        g.check_frame(150, 0); // alert
1052        g.check_frame(50, 0); // clear
1053        g.check_frame(150, 0); // alert
1054        assert!((g.alert_rate() - 0.5).abs() < f64::EPSILON);
1055    }
1056
1057    #[test]
1058    fn guardrails_alert_rate_zero_frames() {
1059        let g = FrameGuardrails::new(GuardrailsConfig::default());
1060        assert!((g.alert_rate() - 0.0).abs() < f64::EPSILON);
1061    }
1062
1063    #[test]
1064    fn guardrails_snapshot_jsonl() {
1065        let mut g = FrameGuardrails::new(GuardrailsConfig::default());
1066        g.check_frame(1024, 1);
1067        let snap = g.snapshot();
1068        let line = snap.to_jsonl();
1069        assert!(line.starts_with('{'));
1070        assert!(line.ends_with('}'));
1071        assert!(line.contains("\"memory_bytes\":1024"));
1072        assert!(line.contains("\"queue_depth\":1"));
1073    }
1074
1075    #[test]
1076    fn guardrails_reset_clears_all() {
1077        let mut g = FrameGuardrails::new(GuardrailsConfig::default());
1078        g.check_frame(8 * 1024 * 1024, 5);
1079        g.reset();
1080        assert_eq!(g.frames_checked(), 0);
1081        assert_eq!(g.frames_with_alerts(), 0);
1082        assert_eq!(g.memory().peak_bytes(), 0);
1083        assert_eq!(g.queue().peak_depth(), 0);
1084    }
1085
1086    #[test]
1087    fn guardrails_subsystem_access() {
1088        let g = FrameGuardrails::new(GuardrailsConfig::default());
1089        let _ = g.memory().config();
1090        let _ = g.queue().config();
1091    }
1092
1093    // ---- GuardrailVerdict ----
1094
1095    #[test]
1096    fn verdict_max_severity_none_when_clear() {
1097        let v = GuardrailVerdict {
1098            alerts: vec![],
1099            queue_action: QueueAction::None,
1100            recommended_level: DegradationLevel::Full,
1101        };
1102        assert!(v.max_severity().is_none());
1103        assert!(v.is_clear());
1104    }
1105
1106    #[test]
1107    fn verdict_max_severity_picks_highest() {
1108        let v = GuardrailVerdict {
1109            alerts: vec![
1110                GuardrailAlert {
1111                    kind: GuardrailKind::Memory,
1112                    severity: AlertSeverity::Warning,
1113                    recommended_level: DegradationLevel::SimpleBorders,
1114                },
1115                GuardrailAlert {
1116                    kind: GuardrailKind::QueueDepth,
1117                    severity: AlertSeverity::Critical,
1118                    recommended_level: DegradationLevel::EssentialOnly,
1119                },
1120            ],
1121            queue_action: QueueAction::None,
1122            recommended_level: DegradationLevel::EssentialOnly,
1123        };
1124        assert_eq!(v.max_severity(), Some(AlertSeverity::Critical));
1125    }
1126
1127    // ---- AlertSeverity ordering ----
1128
1129    #[test]
1130    fn severity_ordering() {
1131        assert!(AlertSeverity::Warning < AlertSeverity::Critical);
1132        assert!(AlertSeverity::Critical < AlertSeverity::Emergency);
1133    }
1134
1135    // ---- Config presets ----
1136
1137    #[test]
1138    fn memory_config_small_preset() {
1139        let c = MemoryBudgetConfig::small();
1140        assert!(c.soft_limit_bytes < MemoryBudgetConfig::default().soft_limit_bytes);
1141    }
1142
1143    #[test]
1144    fn memory_config_large_preset() {
1145        let c = MemoryBudgetConfig::large();
1146        assert!(c.soft_limit_bytes > MemoryBudgetConfig::default().soft_limit_bytes);
1147    }
1148
1149    #[test]
1150    fn queue_config_strict_preset() {
1151        let c = QueueConfig::strict();
1152        assert_eq!(c.drop_policy, QueueDropPolicy::Backpressure);
1153        assert!(c.max_depth < QueueConfig::default().max_depth);
1154    }
1155
1156    #[test]
1157    fn queue_config_relaxed_preset() {
1158        let c = QueueConfig::relaxed();
1159        assert!(c.max_depth > QueueConfig::default().max_depth);
1160    }
1161
1162    // ---- buffer_memory_bytes ----
1163
1164    #[test]
1165    fn buffer_memory_typical_terminal() {
1166        // 80×24 terminal
1167        assert_eq!(buffer_memory_bytes(80, 24), 80 * 24 * 16);
1168    }
1169
1170    #[test]
1171    fn buffer_memory_zero_dimension() {
1172        assert_eq!(buffer_memory_bytes(0, 24), 0);
1173        assert_eq!(buffer_memory_bytes(80, 0), 0);
1174        assert_eq!(buffer_memory_bytes(0, 0), 0);
1175    }
1176
1177    #[test]
1178    fn buffer_memory_large_terminal() {
1179        // 300×100 terminal
1180        let bytes = buffer_memory_bytes(300, 100);
1181        assert_eq!(bytes, 300 * 100 * 16);
1182        assert_eq!(bytes, 480_000);
1183    }
1184
1185    // ---- QueueDropPolicy Default ----
1186
1187    #[test]
1188    fn queue_drop_policy_default_is_drop_oldest() {
1189        assert_eq!(QueueDropPolicy::default(), QueueDropPolicy::DropOldest);
1190    }
1191
1192    // ---- Determinism ----
1193
1194    #[test]
1195    fn guardrails_deterministic_for_same_inputs() {
1196        let config = GuardrailsConfig::default();
1197        let mut g1 = FrameGuardrails::new(config.clone());
1198        let mut g2 = FrameGuardrails::new(config);
1199
1200        let inputs = [(1024, 0), (8 * 1024 * 1024, 3), (20 * 1024 * 1024, 10)];
1201        for (mem, queue) in inputs {
1202            let v1 = g1.check_frame(mem, queue);
1203            let v2 = g2.check_frame(mem, queue);
1204            assert_eq!(v1.recommended_level, v2.recommended_level);
1205            assert_eq!(v1.alerts.len(), v2.alerts.len());
1206            assert_eq!(v1.queue_action, v2.queue_action);
1207        }
1208    }
1209}