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                    let excess = current_depth - 1; // keep only oldest
361                    self.total_drops = self.total_drops.saturating_add(excess as u64);
362                    QueueAction::DropNewest(excess)
363                }
364                QueueDropPolicy::Backpressure => {
365                    self.total_backpressure_events =
366                        self.total_backpressure_events.saturating_add(1);
367                    QueueAction::Backpressure
368                }
369            };
370            (
371                Some(GuardrailAlert {
372                    kind: GuardrailKind::QueueDepth,
373                    severity: AlertSeverity::Emergency,
374                    recommended_level: DegradationLevel::SkipFrame,
375                }),
376                action,
377            )
378        } else if current_depth >= self.config.max_depth {
379            let action = match self.config.drop_policy {
380                QueueDropPolicy::DropOldest => {
381                    let excess = current_depth.saturating_sub(self.config.warn_depth);
382                    self.total_drops = self.total_drops.saturating_add(excess as u64);
383                    QueueAction::DropOldest(excess)
384                }
385                QueueDropPolicy::DropNewest => {
386                    let excess = current_depth.saturating_sub(self.config.warn_depth);
387                    self.total_drops = self.total_drops.saturating_add(excess as u64);
388                    QueueAction::DropNewest(excess)
389                }
390                QueueDropPolicy::Backpressure => {
391                    self.total_backpressure_events =
392                        self.total_backpressure_events.saturating_add(1);
393                    QueueAction::Backpressure
394                }
395            };
396            (
397                Some(GuardrailAlert {
398                    kind: GuardrailKind::QueueDepth,
399                    severity: AlertSeverity::Critical,
400                    recommended_level: DegradationLevel::EssentialOnly,
401                }),
402                action,
403            )
404        } else if current_depth >= self.config.warn_depth {
405            (
406                Some(GuardrailAlert {
407                    kind: GuardrailKind::QueueDepth,
408                    severity: AlertSeverity::Warning,
409                    recommended_level: DegradationLevel::SimpleBorders,
410                }),
411                QueueAction::None,
412            )
413        } else {
414            (None, QueueAction::None)
415        }
416    }
417
418    /// Current queue depth.
419    #[inline]
420    #[must_use]
421    pub fn current_depth(&self) -> u32 {
422        self.current_depth
423    }
424
425    /// Peak queue depth observed.
426    #[inline]
427    #[must_use]
428    pub fn peak_depth(&self) -> u32 {
429        self.peak_depth
430    }
431
432    /// Total frames dropped due to queue overflow.
433    #[inline]
434    #[must_use]
435    pub fn total_drops(&self) -> u64 {
436        self.total_drops
437    }
438
439    /// Total backpressure events.
440    #[inline]
441    #[must_use]
442    pub fn total_backpressure_events(&self) -> u64 {
443        self.total_backpressure_events
444    }
445
446    /// Get a reference to the configuration.
447    #[inline]
448    #[must_use]
449    pub fn config(&self) -> &QueueConfig {
450        &self.config
451    }
452
453    /// Reset tracking state (preserves config).
454    pub fn reset(&mut self) {
455        self.peak_depth = 0;
456        self.current_depth = 0;
457        self.total_drops = 0;
458        self.total_backpressure_events = 0;
459    }
460}
461
462/// Action the runtime should take in response to queue depth.
463#[derive(Debug, Clone, Copy, PartialEq, Eq)]
464pub enum QueueAction {
465    /// No action needed.
466    None,
467    /// Drop the N oldest pending frames.
468    DropOldest(u32),
469    /// Drop the N newest pending frames.
470    DropNewest(u32),
471    /// Signal backpressure to the input source.
472    Backpressure,
473}
474
475impl QueueAction {
476    /// Whether this action requires dropping any frames.
477    #[inline]
478    #[must_use]
479    pub fn drops_frames(self) -> bool {
480        matches!(self, Self::DropOldest(_) | Self::DropNewest(_))
481    }
482}
483
484// =========================================================================
485// Unified guardrails
486// =========================================================================
487
488/// Configuration for the unified frame guardrails.
489#[derive(Debug, Clone, Default)]
490pub struct GuardrailsConfig {
491    /// Memory budget configuration.
492    pub memory: MemoryBudgetConfig,
493    /// Queue depth configuration.
494    pub queue: QueueConfig,
495}
496
497/// Verdict from a guardrail check, combining all subsystem results.
498#[derive(Debug, Clone)]
499pub struct GuardrailVerdict {
500    /// Alerts from all guardrails that fired (may be empty).
501    pub alerts: Vec<GuardrailAlert>,
502    /// Queue action recommended by queue guardrails.
503    pub queue_action: QueueAction,
504    /// The most aggressive degradation level recommended across all alerts.
505    pub recommended_level: DegradationLevel,
506}
507
508impl GuardrailVerdict {
509    /// Whether any guardrail recommends dropping the current frame.
510    #[inline]
511    #[must_use]
512    pub fn should_drop_frame(&self) -> bool {
513        self.recommended_level >= DegradationLevel::SkipFrame
514    }
515
516    /// Whether any guardrail recommends degradation (but not frame skip).
517    #[inline]
518    #[must_use]
519    pub fn should_degrade(&self) -> bool {
520        self.recommended_level > DegradationLevel::Full
521            && self.recommended_level < DegradationLevel::SkipFrame
522    }
523
524    /// Whether all guardrails are satisfied (no alerts).
525    #[inline]
526    #[must_use]
527    pub fn is_clear(&self) -> bool {
528        self.alerts.is_empty()
529    }
530
531    /// The highest severity among all alerts, or `None` if clear.
532    #[must_use]
533    pub fn max_severity(&self) -> Option<AlertSeverity> {
534        self.alerts.iter().map(|a| a.severity).max()
535    }
536}
537
538/// Unified frame guardrails combining memory budget and queue depth limits.
539///
540/// Call [`check_frame`](Self::check_frame) once per frame with current resource
541/// usage. The returned [`GuardrailVerdict`] tells you what (if anything) to do.
542#[derive(Debug, Clone)]
543pub struct FrameGuardrails {
544    memory: MemoryBudget,
545    queue: QueueGuardrails,
546    /// Total frames checked.
547    frames_checked: u64,
548    /// Total frames where at least one alert fired.
549    frames_with_alerts: u64,
550}
551
552impl FrameGuardrails {
553    /// Create a new unified guardrails instance.
554    #[must_use]
555    pub fn new(config: GuardrailsConfig) -> Self {
556        Self {
557            memory: MemoryBudget::new(config.memory),
558            queue: QueueGuardrails::new(config.queue),
559            frames_checked: 0,
560            frames_with_alerts: 0,
561        }
562    }
563
564    /// Check all guardrails for the current frame.
565    ///
566    /// `memory_bytes`: total rendering memory in use (buffer + pools).
567    /// `queue_depth`: number of pending frames waiting to be rendered.
568    pub fn check_frame(&mut self, memory_bytes: usize, queue_depth: u32) -> GuardrailVerdict {
569        self.frames_checked = self.frames_checked.saturating_add(1);
570
571        let mut alerts = Vec::new();
572        let mut max_level = DegradationLevel::Full;
573
574        // Memory check
575        if let Some(alert) = self.memory.check(memory_bytes) {
576            if alert.recommended_level > max_level {
577                max_level = alert.recommended_level;
578            }
579            alerts.push(alert);
580        }
581
582        // Queue check
583        let (queue_alert, queue_action) = self.queue.check(queue_depth);
584        if let Some(alert) = queue_alert {
585            if alert.recommended_level > max_level {
586                max_level = alert.recommended_level;
587            }
588            alerts.push(alert);
589        }
590
591        if !alerts.is_empty() {
592            self.frames_with_alerts = self.frames_with_alerts.saturating_add(1);
593        }
594
595        GuardrailVerdict {
596            alerts,
597            queue_action,
598            recommended_level: max_level,
599        }
600    }
601
602    /// Access the memory budget subsystem.
603    #[inline]
604    #[must_use]
605    pub fn memory(&self) -> &MemoryBudget {
606        &self.memory
607    }
608
609    /// Access the queue guardrails subsystem.
610    #[inline]
611    #[must_use]
612    pub fn queue(&self) -> &QueueGuardrails {
613        &self.queue
614    }
615
616    /// Total frames checked.
617    #[inline]
618    #[must_use]
619    pub fn frames_checked(&self) -> u64 {
620        self.frames_checked
621    }
622
623    /// Total frames where at least one alert fired.
624    #[inline]
625    #[must_use]
626    pub fn frames_with_alerts(&self) -> u64 {
627        self.frames_with_alerts
628    }
629
630    /// Fraction of frames that triggered alerts (0.0–1.0).
631    #[inline]
632    #[must_use]
633    pub fn alert_rate(&self) -> f64 {
634        if self.frames_checked == 0 {
635            return 0.0;
636        }
637        self.frames_with_alerts as f64 / self.frames_checked as f64
638    }
639
640    /// Capture a diagnostic snapshot.
641    #[must_use]
642    pub fn snapshot(&self) -> GuardrailSnapshot {
643        GuardrailSnapshot {
644            memory_bytes: self.memory.current_bytes(),
645            memory_peak_bytes: self.memory.peak_bytes(),
646            memory_usage_fraction: self.memory.usage_fraction(),
647            memory_soft_violations: self.memory.soft_violations(),
648            memory_hard_violations: self.memory.hard_violations(),
649            queue_depth: self.queue.current_depth(),
650            queue_peak_depth: self.queue.peak_depth(),
651            queue_total_drops: self.queue.total_drops(),
652            queue_total_backpressure: self.queue.total_backpressure_events(),
653            frames_checked: self.frames_checked,
654            frames_with_alerts: self.frames_with_alerts,
655        }
656    }
657
658    /// Reset all tracking state (preserves configs).
659    pub fn reset(&mut self) {
660        self.memory.reset();
661        self.queue.reset();
662        self.frames_checked = 0;
663        self.frames_with_alerts = 0;
664    }
665}
666
667/// Diagnostic snapshot of guardrail state.
668///
669/// All fields are `Copy` — no allocations. Suitable for structured logging
670/// or debug overlay.
671#[derive(Debug, Clone, Copy, PartialEq)]
672pub struct GuardrailSnapshot {
673    /// Current memory usage in bytes.
674    pub memory_bytes: usize,
675    /// Peak memory usage in bytes.
676    pub memory_peak_bytes: usize,
677    /// Fraction of soft memory limit used.
678    pub memory_usage_fraction: f64,
679    /// Frames exceeding soft memory limit.
680    pub memory_soft_violations: u32,
681    /// Frames exceeding hard memory limit.
682    pub memory_hard_violations: u32,
683    /// Current queue depth.
684    pub queue_depth: u32,
685    /// Peak queue depth.
686    pub queue_peak_depth: u32,
687    /// Total frames dropped from queue.
688    pub queue_total_drops: u64,
689    /// Total backpressure events.
690    pub queue_total_backpressure: u64,
691    /// Total frames checked.
692    pub frames_checked: u64,
693    /// Total frames with alerts.
694    pub frames_with_alerts: u64,
695}
696
697impl GuardrailSnapshot {
698    /// Serialize to a JSONL-compatible string.
699    pub fn to_jsonl(&self) -> String {
700        format!(
701            concat!(
702                r#"{{"memory_bytes":{},"memory_peak":{},"memory_frac":{:.4},"#,
703                r#""mem_soft_violations":{},"mem_hard_violations":{},"#,
704                r#""queue_depth":{},"queue_peak":{},"queue_drops":{},"#,
705                r#""queue_backpressure":{},"frames_checked":{},"frames_alerted":{}}}"#,
706            ),
707            self.memory_bytes,
708            self.memory_peak_bytes,
709            self.memory_usage_fraction,
710            self.memory_soft_violations,
711            self.memory_hard_violations,
712            self.queue_depth,
713            self.queue_peak_depth,
714            self.queue_total_drops,
715            self.queue_total_backpressure,
716            self.frames_checked,
717            self.frames_with_alerts,
718        )
719    }
720}
721
722// =========================================================================
723// Utility: compute buffer memory
724// =========================================================================
725
726/// Size of a single Cell in bytes (compile-time constant).
727pub const CELL_SIZE_BYTES: usize = 16;
728
729/// Compute the memory footprint of a buffer with the given dimensions.
730///
731/// This accounts for the cell array only (not dirty tracking or stack metadata).
732#[inline]
733#[must_use]
734pub fn buffer_memory_bytes(width: u16, height: u16) -> usize {
735    width as usize * height as usize * CELL_SIZE_BYTES
736}
737
738// =========================================================================
739// Tests
740// =========================================================================
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745
746    // ---- MemoryBudget ----
747
748    #[test]
749    fn memory_below_soft_no_alert() {
750        let mut mb = MemoryBudget::new(MemoryBudgetConfig::default());
751        assert!(mb.check(1024).is_none());
752        assert_eq!(mb.current_bytes(), 1024);
753    }
754
755    #[test]
756    fn memory_at_soft_limit_warns() {
757        let mut mb = MemoryBudget::new(MemoryBudgetConfig::default());
758        let alert = mb.check(8 * 1024 * 1024).unwrap();
759        assert_eq!(alert.kind, GuardrailKind::Memory);
760        assert_eq!(alert.severity, AlertSeverity::Warning);
761        assert_eq!(alert.recommended_level, DegradationLevel::SimpleBorders);
762    }
763
764    #[test]
765    fn memory_at_hard_limit_critical() {
766        let mut mb = MemoryBudget::new(MemoryBudgetConfig::default());
767        let alert = mb.check(16 * 1024 * 1024).unwrap();
768        assert_eq!(alert.severity, AlertSeverity::Critical);
769        assert_eq!(alert.recommended_level, DegradationLevel::Skeleton);
770    }
771
772    #[test]
773    fn memory_at_emergency_limit() {
774        let mut mb = MemoryBudget::new(MemoryBudgetConfig::default());
775        let alert = mb.check(32 * 1024 * 1024).unwrap();
776        assert_eq!(alert.severity, AlertSeverity::Emergency);
777        assert_eq!(alert.recommended_level, DegradationLevel::SkipFrame);
778    }
779
780    #[test]
781    fn memory_peak_tracking() {
782        let mut mb = MemoryBudget::new(MemoryBudgetConfig::default());
783        mb.check(1000);
784        mb.check(5000);
785        mb.check(3000);
786        assert_eq!(mb.peak_bytes(), 5000);
787        assert_eq!(mb.current_bytes(), 3000);
788    }
789
790    #[test]
791    fn memory_violation_counts() {
792        let config = MemoryBudgetConfig {
793            soft_limit_bytes: 100,
794            hard_limit_bytes: 200,
795            emergency_limit_bytes: 300,
796        };
797        let mut mb = MemoryBudget::new(config);
798        mb.check(50); // no violation
799        mb.check(150); // soft
800        mb.check(150); // soft again
801        mb.check(250); // hard
802        assert_eq!(mb.soft_violations(), 2);
803        assert_eq!(mb.hard_violations(), 1);
804    }
805
806    #[test]
807    fn memory_usage_fraction() {
808        let config = MemoryBudgetConfig {
809            soft_limit_bytes: 1000,
810            hard_limit_bytes: 2000,
811            emergency_limit_bytes: 3000,
812        };
813        let mut mb = MemoryBudget::new(config);
814        mb.check(500);
815        assert!((mb.usage_fraction() - 0.5).abs() < f64::EPSILON);
816    }
817
818    #[test]
819    fn memory_usage_fraction_zero_limit() {
820        let config = MemoryBudgetConfig {
821            soft_limit_bytes: 0,
822            hard_limit_bytes: 0,
823            emergency_limit_bytes: 0,
824        };
825        let mut mb = MemoryBudget::new(config);
826        mb.check(100);
827        assert!((mb.usage_fraction() - 1.0).abs() < f64::EPSILON);
828    }
829
830    #[test]
831    fn memory_reset_clears_state() {
832        let mut mb = MemoryBudget::new(MemoryBudgetConfig::default());
833        mb.check(10 * 1024 * 1024); // soft violation
834        assert!(mb.soft_violations() > 0);
835        mb.reset();
836        assert_eq!(mb.peak_bytes(), 0);
837        assert_eq!(mb.current_bytes(), 0);
838        assert_eq!(mb.soft_violations(), 0);
839        assert_eq!(mb.hard_violations(), 0);
840    }
841
842    #[test]
843    fn memory_config_accessors() {
844        let config = MemoryBudgetConfig::small();
845        let mb = MemoryBudget::new(config);
846        assert_eq!(mb.config().soft_limit_bytes, 2 * 1024 * 1024);
847    }
848
849    // ---- QueueGuardrails ----
850
851    #[test]
852    fn queue_below_warn_no_alert() {
853        let mut qg = QueueGuardrails::new(QueueConfig::default());
854        let (alert, action) = qg.check(1);
855        assert!(alert.is_none());
856        assert_eq!(action, QueueAction::None);
857    }
858
859    #[test]
860    fn queue_at_warn_depth() {
861        let mut qg = QueueGuardrails::new(QueueConfig::default());
862        let (alert, action) = qg.check(3);
863        assert_eq!(alert.unwrap().severity, AlertSeverity::Warning);
864        assert_eq!(action, QueueAction::None); // warning only, no action
865    }
866
867    #[test]
868    fn queue_at_max_depth_drop_oldest() {
869        let config = QueueConfig {
870            drop_policy: QueueDropPolicy::DropOldest,
871            ..QueueConfig::default()
872        };
873        let mut qg = QueueGuardrails::new(config);
874        let (alert, action) = qg.check(8);
875        assert_eq!(alert.unwrap().severity, AlertSeverity::Critical);
876        assert!(action.drops_frames());
877    }
878
879    #[test]
880    fn queue_at_max_depth_drop_newest() {
881        let config = QueueConfig {
882            drop_policy: QueueDropPolicy::DropNewest,
883            ..QueueConfig::default()
884        };
885        let mut qg = QueueGuardrails::new(config);
886        let (alert, action) = qg.check(8);
887        assert_eq!(alert.unwrap().severity, AlertSeverity::Critical);
888        assert_eq!(action, QueueAction::DropNewest(5));
889    }
890
891    #[test]
892    fn queue_at_max_depth_backpressure() {
893        let config = QueueConfig {
894            drop_policy: QueueDropPolicy::Backpressure,
895            ..QueueConfig::default()
896        };
897        let mut qg = QueueGuardrails::new(config);
898        let (alert, action) = qg.check(8);
899        assert_eq!(alert.unwrap().severity, AlertSeverity::Critical);
900        assert_eq!(action, QueueAction::Backpressure);
901    }
902
903    #[test]
904    fn queue_emergency_drops_to_latest() {
905        let mut qg = QueueGuardrails::new(QueueConfig::default());
906        let (alert, action) = qg.check(16);
907        assert_eq!(alert.unwrap().severity, AlertSeverity::Emergency);
908        // DropOldest at emergency should keep only 1 frame
909        assert_eq!(action, QueueAction::DropOldest(15));
910    }
911
912    #[test]
913    fn queue_peak_tracking() {
914        let mut qg = QueueGuardrails::new(QueueConfig::default());
915        qg.check(2);
916        qg.check(5);
917        qg.check(1);
918        assert_eq!(qg.peak_depth(), 5);
919        assert_eq!(qg.current_depth(), 1);
920    }
921
922    #[test]
923    fn queue_drop_counting() {
924        let mut qg = QueueGuardrails::new(QueueConfig::default());
925        qg.check(8); // triggers drop
926        assert!(qg.total_drops() > 0);
927    }
928
929    #[test]
930    fn queue_backpressure_counting() {
931        let config = QueueConfig::strict();
932        let mut qg = QueueGuardrails::new(config);
933        qg.check(4); // max_depth for strict
934        assert!(qg.total_backpressure_events() > 0);
935    }
936
937    #[test]
938    fn queue_reset_clears_state() {
939        let mut qg = QueueGuardrails::new(QueueConfig::default());
940        qg.check(10);
941        qg.reset();
942        assert_eq!(qg.peak_depth(), 0);
943        assert_eq!(qg.current_depth(), 0);
944        assert_eq!(qg.total_drops(), 0);
945    }
946
947    #[test]
948    fn queue_config_accessors() {
949        let config = QueueConfig::relaxed();
950        let qg = QueueGuardrails::new(config);
951        assert_eq!(qg.config().max_depth, 16);
952    }
953
954    // ---- QueueAction ----
955
956    #[test]
957    fn queue_action_drops_frames() {
958        assert!(!QueueAction::None.drops_frames());
959        assert!(QueueAction::DropOldest(3).drops_frames());
960        assert!(QueueAction::DropNewest(1).drops_frames());
961        assert!(!QueueAction::Backpressure.drops_frames());
962    }
963
964    // ---- FrameGuardrails ----
965
966    #[test]
967    fn guardrails_clear_when_healthy() {
968        let mut g = FrameGuardrails::new(GuardrailsConfig::default());
969        let v = g.check_frame(1024, 0);
970        assert!(v.is_clear());
971        assert_eq!(v.recommended_level, DegradationLevel::Full);
972        assert_eq!(v.queue_action, QueueAction::None);
973    }
974
975    #[test]
976    fn guardrails_memory_alert_propagates() {
977        let mut g = FrameGuardrails::new(GuardrailsConfig::default());
978        let v = g.check_frame(8 * 1024 * 1024, 0);
979        assert!(!v.is_clear());
980        assert_eq!(v.alerts.len(), 1);
981        assert_eq!(v.alerts[0].kind, GuardrailKind::Memory);
982        assert!(v.should_degrade());
983        assert!(!v.should_drop_frame());
984    }
985
986    #[test]
987    fn guardrails_queue_alert_propagates() {
988        let mut g = FrameGuardrails::new(GuardrailsConfig::default());
989        let v = g.check_frame(0, 8);
990        assert!(!v.is_clear());
991        assert!(v.alerts.iter().any(|a| a.kind == GuardrailKind::QueueDepth));
992    }
993
994    #[test]
995    fn guardrails_both_alerts_combine() {
996        let config = GuardrailsConfig {
997            memory: MemoryBudgetConfig {
998                soft_limit_bytes: 100,
999                hard_limit_bytes: 200,
1000                emergency_limit_bytes: 300,
1001            },
1002            queue: QueueConfig {
1003                warn_depth: 1,
1004                max_depth: 2,
1005                emergency_depth: 3,
1006                drop_policy: QueueDropPolicy::DropOldest,
1007            },
1008        };
1009        let mut g = FrameGuardrails::new(config);
1010        let v = g.check_frame(150, 2);
1011        assert_eq!(v.alerts.len(), 2);
1012        // Should use the most aggressive recommendation
1013        assert!(v.recommended_level >= DegradationLevel::SimpleBorders);
1014    }
1015
1016    #[test]
1017    fn guardrails_emergency_recommends_skip() {
1018        let config = GuardrailsConfig {
1019            memory: MemoryBudgetConfig {
1020                soft_limit_bytes: 100,
1021                hard_limit_bytes: 200,
1022                emergency_limit_bytes: 300,
1023            },
1024            queue: QueueConfig::default(),
1025        };
1026        let mut g = FrameGuardrails::new(config);
1027        let v = g.check_frame(300, 0);
1028        assert!(v.should_drop_frame());
1029    }
1030
1031    #[test]
1032    fn guardrails_frame_counting() {
1033        let mut g = FrameGuardrails::new(GuardrailsConfig::default());
1034        g.check_frame(0, 0);
1035        g.check_frame(0, 0);
1036        g.check_frame(8 * 1024 * 1024, 0); // triggers alert
1037        assert_eq!(g.frames_checked(), 3);
1038        assert_eq!(g.frames_with_alerts(), 1);
1039    }
1040
1041    #[test]
1042    fn guardrails_alert_rate() {
1043        let config = GuardrailsConfig {
1044            memory: MemoryBudgetConfig {
1045                soft_limit_bytes: 100,
1046                hard_limit_bytes: 200,
1047                emergency_limit_bytes: 300,
1048            },
1049            queue: QueueConfig::default(),
1050        };
1051        let mut g = FrameGuardrails::new(config);
1052        g.check_frame(50, 0); // clear
1053        g.check_frame(150, 0); // alert
1054        g.check_frame(50, 0); // clear
1055        g.check_frame(150, 0); // alert
1056        assert!((g.alert_rate() - 0.5).abs() < f64::EPSILON);
1057    }
1058
1059    #[test]
1060    fn guardrails_alert_rate_zero_frames() {
1061        let g = FrameGuardrails::new(GuardrailsConfig::default());
1062        assert!((g.alert_rate() - 0.0).abs() < f64::EPSILON);
1063    }
1064
1065    #[test]
1066    fn guardrails_snapshot_jsonl() {
1067        let mut g = FrameGuardrails::new(GuardrailsConfig::default());
1068        g.check_frame(1024, 1);
1069        let snap = g.snapshot();
1070        let line = snap.to_jsonl();
1071        assert!(line.starts_with('{'));
1072        assert!(line.ends_with('}'));
1073        assert!(line.contains("\"memory_bytes\":1024"));
1074        assert!(line.contains("\"queue_depth\":1"));
1075    }
1076
1077    #[test]
1078    fn guardrails_reset_clears_all() {
1079        let mut g = FrameGuardrails::new(GuardrailsConfig::default());
1080        g.check_frame(8 * 1024 * 1024, 5);
1081        g.reset();
1082        assert_eq!(g.frames_checked(), 0);
1083        assert_eq!(g.frames_with_alerts(), 0);
1084        assert_eq!(g.memory().peak_bytes(), 0);
1085        assert_eq!(g.queue().peak_depth(), 0);
1086    }
1087
1088    #[test]
1089    fn guardrails_subsystem_access() {
1090        let g = FrameGuardrails::new(GuardrailsConfig::default());
1091        let _ = g.memory().config();
1092        let _ = g.queue().config();
1093    }
1094
1095    // ---- GuardrailVerdict ----
1096
1097    #[test]
1098    fn verdict_max_severity_none_when_clear() {
1099        let v = GuardrailVerdict {
1100            alerts: vec![],
1101            queue_action: QueueAction::None,
1102            recommended_level: DegradationLevel::Full,
1103        };
1104        assert!(v.max_severity().is_none());
1105        assert!(v.is_clear());
1106    }
1107
1108    #[test]
1109    fn verdict_max_severity_picks_highest() {
1110        let v = GuardrailVerdict {
1111            alerts: vec![
1112                GuardrailAlert {
1113                    kind: GuardrailKind::Memory,
1114                    severity: AlertSeverity::Warning,
1115                    recommended_level: DegradationLevel::SimpleBorders,
1116                },
1117                GuardrailAlert {
1118                    kind: GuardrailKind::QueueDepth,
1119                    severity: AlertSeverity::Critical,
1120                    recommended_level: DegradationLevel::EssentialOnly,
1121                },
1122            ],
1123            queue_action: QueueAction::None,
1124            recommended_level: DegradationLevel::EssentialOnly,
1125        };
1126        assert_eq!(v.max_severity(), Some(AlertSeverity::Critical));
1127    }
1128
1129    // ---- AlertSeverity ordering ----
1130
1131    #[test]
1132    fn severity_ordering() {
1133        assert!(AlertSeverity::Warning < AlertSeverity::Critical);
1134        assert!(AlertSeverity::Critical < AlertSeverity::Emergency);
1135    }
1136
1137    // ---- Config presets ----
1138
1139    #[test]
1140    fn memory_config_small_preset() {
1141        let c = MemoryBudgetConfig::small();
1142        assert!(c.soft_limit_bytes < MemoryBudgetConfig::default().soft_limit_bytes);
1143    }
1144
1145    #[test]
1146    fn memory_config_large_preset() {
1147        let c = MemoryBudgetConfig::large();
1148        assert!(c.soft_limit_bytes > MemoryBudgetConfig::default().soft_limit_bytes);
1149    }
1150
1151    #[test]
1152    fn queue_config_strict_preset() {
1153        let c = QueueConfig::strict();
1154        assert_eq!(c.drop_policy, QueueDropPolicy::Backpressure);
1155        assert!(c.max_depth < QueueConfig::default().max_depth);
1156    }
1157
1158    #[test]
1159    fn queue_config_relaxed_preset() {
1160        let c = QueueConfig::relaxed();
1161        assert!(c.max_depth > QueueConfig::default().max_depth);
1162    }
1163
1164    // ---- buffer_memory_bytes ----
1165
1166    #[test]
1167    fn buffer_memory_typical_terminal() {
1168        // 80×24 terminal
1169        assert_eq!(buffer_memory_bytes(80, 24), 80 * 24 * 16);
1170    }
1171
1172    #[test]
1173    fn buffer_memory_zero_dimension() {
1174        assert_eq!(buffer_memory_bytes(0, 24), 0);
1175        assert_eq!(buffer_memory_bytes(80, 0), 0);
1176        assert_eq!(buffer_memory_bytes(0, 0), 0);
1177    }
1178
1179    #[test]
1180    fn buffer_memory_large_terminal() {
1181        // 300×100 terminal
1182        let bytes = buffer_memory_bytes(300, 100);
1183        assert_eq!(bytes, 300 * 100 * 16);
1184        assert_eq!(bytes, 480_000);
1185    }
1186
1187    // ---- QueueDropPolicy Default ----
1188
1189    #[test]
1190    fn queue_drop_policy_default_is_drop_oldest() {
1191        assert_eq!(QueueDropPolicy::default(), QueueDropPolicy::DropOldest);
1192    }
1193
1194    // ---- Determinism ----
1195
1196    #[test]
1197    fn guardrails_deterministic_for_same_inputs() {
1198        let config = GuardrailsConfig::default();
1199        let mut g1 = FrameGuardrails::new(config.clone());
1200        let mut g2 = FrameGuardrails::new(config);
1201
1202        let inputs = [(1024, 0), (8 * 1024 * 1024, 3), (20 * 1024 * 1024, 10)];
1203        for (mem, queue) in inputs {
1204            let v1 = g1.check_frame(mem, queue);
1205            let v2 = g2.check_frame(mem, queue);
1206            assert_eq!(v1.recommended_level, v2.recommended_level);
1207            assert_eq!(v1.alerts.len(), v2.alerts.len());
1208            assert_eq!(v1.queue_action, v2.queue_action);
1209        }
1210    }
1211}