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    /// Return a normalized copy with sane, monotone thresholds.
128    ///
129    /// User-supplied configs (via `ProgramConfig`) can be zero-initialized
130    /// or inverted; the classifier uses `>=` comparisons, so an all-zero
131    /// config would classify EVERY frame as Emergency (blank screen from
132    /// startup) and inverted watermarks silently misclassify. Zero limits
133    /// fall back to the defaults; hard/emergency are raised to keep
134    /// soft <= hard <= emergency.
135    #[must_use]
136    pub fn normalized(self) -> Self {
137        let defaults = Self::default();
138        let soft = if self.soft_limit_bytes == 0 {
139            defaults.soft_limit_bytes
140        } else {
141            self.soft_limit_bytes
142        };
143        let hard = if self.hard_limit_bytes == 0 {
144            defaults.hard_limit_bytes
145        } else {
146            self.hard_limit_bytes
147        }
148        .max(soft);
149        let emergency = if self.emergency_limit_bytes == 0 {
150            defaults.emergency_limit_bytes
151        } else {
152            self.emergency_limit_bytes
153        }
154        .max(hard);
155        Self {
156            soft_limit_bytes: soft,
157            hard_limit_bytes: hard,
158            emergency_limit_bytes: emergency,
159        }
160    }
161}
162
163/// Memory budget tracker.
164///
165/// Checks reported memory usage against configured thresholds and produces
166/// alerts with recommended degradation levels.
167#[derive(Debug, Clone)]
168pub struct MemoryBudget {
169    config: MemoryBudgetConfig,
170    /// Peak memory observed (bytes).
171    peak_bytes: usize,
172    /// Last reported memory (bytes).
173    current_bytes: usize,
174    /// Number of frames where soft limit was exceeded.
175    soft_violations: u32,
176    /// Number of frames where hard limit was exceeded.
177    hard_violations: u32,
178    /// Number of frames where the emergency limit was exceeded.
179    ///
180    /// Counted separately from [`Self::hard_violations`]: emergency is a
181    /// distinct tier (frame shed + arena rebuild), and conflating it with
182    /// hard-limit hits made the snapshot unable to distinguish "hard limit
183    /// pressure" from "emergency shedding" (bd-1za0z F5).
184    emergency_violations: u32,
185}
186
187impl MemoryBudget {
188    /// Create a new memory budget with the given configuration.
189    #[must_use]
190    pub fn new(config: MemoryBudgetConfig) -> Self {
191        Self {
192            // Normalize at the boundary: the classifier's >= comparisons
193            // must never see zero or inverted thresholds (see
194            // MemoryBudgetConfig::normalized).
195            config: config.normalized(),
196            peak_bytes: 0,
197            current_bytes: 0,
198            soft_violations: 0,
199            hard_violations: 0,
200            emergency_violations: 0,
201        }
202    }
203
204    /// Report current memory usage and get an alert if thresholds are exceeded.
205    pub fn check(&mut self, current_bytes: usize) -> Option<GuardrailAlert> {
206        self.current_bytes = current_bytes;
207        if current_bytes > self.peak_bytes {
208            self.peak_bytes = current_bytes;
209        }
210
211        if current_bytes >= self.config.emergency_limit_bytes {
212            // Emergency is its OWN tier: counting it as a hard violation
213            // conflated "hard limit pressure" with "emergency shedding" in
214            // the snapshot (bd-1za0z F5).
215            self.emergency_violations = self.emergency_violations.saturating_add(1);
216            Some(GuardrailAlert {
217                kind: GuardrailKind::Memory,
218                severity: AlertSeverity::Emergency,
219                recommended_level: DegradationLevel::SkipFrame,
220            })
221        } else if current_bytes >= self.config.hard_limit_bytes {
222            self.hard_violations = self.hard_violations.saturating_add(1);
223            Some(GuardrailAlert {
224                kind: GuardrailKind::Memory,
225                severity: AlertSeverity::Critical,
226                recommended_level: DegradationLevel::Skeleton,
227            })
228        } else if current_bytes >= self.config.soft_limit_bytes {
229            self.soft_violations = self.soft_violations.saturating_add(1);
230            Some(GuardrailAlert {
231                kind: GuardrailKind::Memory,
232                severity: AlertSeverity::Warning,
233                recommended_level: DegradationLevel::SimpleBorders,
234            })
235        } else {
236            None
237        }
238    }
239
240    /// Current memory usage in bytes.
241    #[inline]
242    #[must_use]
243    pub fn current_bytes(&self) -> usize {
244        self.current_bytes
245    }
246
247    /// Peak memory usage observed since creation or last reset.
248    #[inline]
249    #[must_use]
250    pub fn peak_bytes(&self) -> usize {
251        self.peak_bytes
252    }
253
254    /// Fraction of soft limit currently used (0.0 = empty, 1.0 = at limit).
255    #[inline]
256    #[must_use]
257    pub fn usage_fraction(&self) -> f64 {
258        if self.config.soft_limit_bytes == 0 {
259            return 1.0;
260        }
261        self.current_bytes as f64 / self.config.soft_limit_bytes as f64
262    }
263
264    /// Number of frames where the soft limit was exceeded.
265    #[inline]
266    #[must_use]
267    pub fn soft_violations(&self) -> u32 {
268        self.soft_violations
269    }
270
271    /// Number of frames where the hard limit was exceeded.
272    #[inline]
273    #[must_use]
274    pub fn hard_violations(&self) -> u32 {
275        self.hard_violations
276    }
277
278    /// Number of frames where the emergency limit was exceeded.
279    #[inline]
280    #[must_use]
281    pub fn emergency_violations(&self) -> u32 {
282        self.emergency_violations
283    }
284
285    /// Get a reference to the configuration.
286    #[inline]
287    #[must_use]
288    pub fn config(&self) -> &MemoryBudgetConfig {
289        &self.config
290    }
291
292    /// Reset tracking state (preserves config).
293    pub fn reset(&mut self) {
294        self.peak_bytes = 0;
295        self.current_bytes = 0;
296        self.soft_violations = 0;
297        self.hard_violations = 0;
298        self.emergency_violations = 0;
299    }
300}
301
302// =========================================================================
303// Queue depth guardrails
304// =========================================================================
305
306/// Policy for handling frames when queue is full.
307#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
308pub enum QueueDropPolicy {
309    /// Drop the oldest pending frame (display freshest content).
310    #[default]
311    DropOldest,
312    /// Drop the newest frame (preserve sequential ordering).
313    DropNewest,
314    /// Signal backpressure to the producer (don't drop, slow input).
315    Backpressure,
316}
317
318/// Configuration for frame queue depth limits.
319#[derive(Debug, Clone, Copy, PartialEq, Eq)]
320pub struct QueueConfig {
321    /// Maximum pending frames before warning.
322    /// Default: 3.
323    pub warn_depth: u32,
324    /// Maximum pending frames before critical action.
325    /// Default: 8.
326    pub max_depth: u32,
327    /// Emergency depth — at this depth the queue is drained hard:
328    /// `DropOldest` keeps only the latest frame, `DropNewest` keeps only the
329    /// oldest, `Backpressure` signals the producer (bd-1za0z: the old
330    /// "drop all but latest" wording described only the default policy).
331    /// Default: 16.
332    pub emergency_depth: u32,
333    pub drop_policy: QueueDropPolicy,
334}
335
336impl Default for QueueConfig {
337    fn default() -> Self {
338        Self {
339            warn_depth: 3,
340            max_depth: 8,
341            emergency_depth: 16,
342            drop_policy: QueueDropPolicy::DropOldest,
343        }
344    }
345}
346
347impl QueueConfig {
348    /// Strict config: small queue, backpressure policy.
349    #[must_use]
350    pub fn strict() -> Self {
351        Self {
352            warn_depth: 2,
353            max_depth: 4,
354            emergency_depth: 8,
355            drop_policy: QueueDropPolicy::Backpressure,
356        }
357    }
358
359    /// Relaxed config: larger queue, drop oldest.
360    #[must_use]
361    pub fn relaxed() -> Self {
362        Self {
363            warn_depth: 8,
364            max_depth: 16,
365            emergency_depth: 32,
366            drop_policy: QueueDropPolicy::DropOldest,
367        }
368    }
369}
370
371/// Queue depth tracker.
372///
373/// Monitors the number of pending frames and produces alerts when
374/// configured thresholds are exceeded.
375#[derive(Debug, Clone)]
376pub struct QueueGuardrails {
377    config: QueueConfig,
378    /// Peak queue depth observed.
379    peak_depth: u32,
380    /// Current queue depth.
381    current_depth: u32,
382    /// Total frames dropped due to queue overflow.
383    total_drops: u64,
384    /// Total backpressure events.
385    total_backpressure_events: u64,
386}
387
388impl QueueGuardrails {
389    /// Create a new queue guardrail with the given configuration.
390    ///
391    /// Thresholds are normalized at construction (same policy as
392    /// `MemoryBudgetConfig`): `warn_depth` is clamped to `max_depth` and
393    /// `max_depth` to `emergency_depth`. A malformed config like
394    /// `warn_depth > max_depth` would otherwise produce zero-excess
395    /// `DropOldest(0)` / `DropNewest(0)` actions at the critical tier —
396    /// alerts that look actionable but do nothing (bd-1za0z).
397    #[must_use]
398    pub fn new(config: QueueConfig) -> Self {
399        // Floor warn at 1: warn_depth 0 would flag an idle queue. Ordering
400        // is then enforced upward so every threshold tier is reachable.
401        let warn_depth = config.warn_depth.max(1);
402        let max_depth = config.max_depth.max(warn_depth);
403        let emergency_depth = config.emergency_depth.max(max_depth);
404        Self {
405            config: QueueConfig {
406                warn_depth,
407                max_depth,
408                emergency_depth,
409                drop_policy: config.drop_policy,
410            },
411            peak_depth: 0,
412            current_depth: 0,
413            total_drops: 0,
414            total_backpressure_events: 0,
415        }
416    }
417
418    /// Report current queue depth and get an alert if thresholds are exceeded.
419    ///
420    /// Returns `(alert, action)` where action indicates what the runtime should
421    /// do about queued frames (if anything).
422    pub fn check(&mut self, current_depth: u32) -> (Option<GuardrailAlert>, QueueAction) {
423        self.current_depth = current_depth;
424        if current_depth > self.peak_depth {
425            self.peak_depth = current_depth;
426        }
427
428        if current_depth >= self.config.emergency_depth {
429            let action = match self.config.drop_policy {
430                QueueDropPolicy::DropOldest => {
431                    let excess = current_depth - 1; // DropOldest: keep only the latest
432                    self.total_drops = self.total_drops.saturating_add(excess as u64);
433                    QueueAction::DropOldest(excess)
434                }
435                QueueDropPolicy::DropNewest => {
436                    let excess = current_depth - 1; // DropNewest: keep only the oldest
437                    self.total_drops = self.total_drops.saturating_add(excess as u64);
438                    QueueAction::DropNewest(excess)
439                }
440                QueueDropPolicy::Backpressure => {
441                    self.total_backpressure_events =
442                        self.total_backpressure_events.saturating_add(1);
443                    QueueAction::Backpressure
444                }
445            };
446            (
447                Some(GuardrailAlert {
448                    kind: GuardrailKind::QueueDepth,
449                    severity: AlertSeverity::Emergency,
450                    recommended_level: DegradationLevel::SkipFrame,
451                }),
452                action,
453            )
454        } else if current_depth >= self.config.max_depth {
455            let action = match self.config.drop_policy {
456                QueueDropPolicy::DropOldest => {
457                    let excess = current_depth.saturating_sub(self.config.warn_depth);
458                    if excess == 0 {
459                        // Thresholds collapsed (e.g. warn == max): the
460                        // critical alert still fires, but there is nothing
461                        // to drop — never emit a no-op Drop(excess=0).
462                        QueueAction::None
463                    } else {
464                        self.total_drops = self.total_drops.saturating_add(u64::from(excess));
465                        QueueAction::DropOldest(excess)
466                    }
467                }
468                QueueDropPolicy::DropNewest => {
469                    let excess = current_depth.saturating_sub(self.config.warn_depth);
470                    if excess == 0 {
471                        QueueAction::None
472                    } else {
473                        self.total_drops = self.total_drops.saturating_add(u64::from(excess));
474                        QueueAction::DropNewest(excess)
475                    }
476                }
477                QueueDropPolicy::Backpressure => {
478                    self.total_backpressure_events =
479                        self.total_backpressure_events.saturating_add(1);
480                    QueueAction::Backpressure
481                }
482            };
483            (
484                Some(GuardrailAlert {
485                    kind: GuardrailKind::QueueDepth,
486                    severity: AlertSeverity::Critical,
487                    recommended_level: DegradationLevel::EssentialOnly,
488                }),
489                action,
490            )
491        } else if current_depth >= self.config.warn_depth {
492            (
493                Some(GuardrailAlert {
494                    kind: GuardrailKind::QueueDepth,
495                    severity: AlertSeverity::Warning,
496                    recommended_level: DegradationLevel::SimpleBorders,
497                }),
498                QueueAction::None,
499            )
500        } else {
501            (None, QueueAction::None)
502        }
503    }
504
505    /// Current queue depth.
506    #[inline]
507    #[must_use]
508    pub fn current_depth(&self) -> u32 {
509        self.current_depth
510    }
511
512    /// Peak queue depth observed.
513    #[inline]
514    #[must_use]
515    pub fn peak_depth(&self) -> u32 {
516        self.peak_depth
517    }
518
519    /// Total frames dropped due to queue overflow.
520    #[inline]
521    #[must_use]
522    pub fn total_drops(&self) -> u64 {
523        self.total_drops
524    }
525
526    /// Total backpressure events.
527    #[inline]
528    #[must_use]
529    pub fn total_backpressure_events(&self) -> u64 {
530        self.total_backpressure_events
531    }
532
533    /// Get a reference to the configuration.
534    #[inline]
535    #[must_use]
536    pub fn config(&self) -> &QueueConfig {
537        &self.config
538    }
539
540    /// Reset tracking state (preserves config).
541    pub fn reset(&mut self) {
542        self.peak_depth = 0;
543        self.current_depth = 0;
544        self.total_drops = 0;
545        self.total_backpressure_events = 0;
546    }
547}
548
549/// Action the runtime should take in response to queue depth.
550#[derive(Debug, Clone, Copy, PartialEq, Eq)]
551pub enum QueueAction {
552    /// No action needed.
553    None,
554    /// Drop the N oldest pending frames.
555    DropOldest(u32),
556    /// Drop the N newest pending frames.
557    DropNewest(u32),
558    /// Signal backpressure to the input source.
559    Backpressure,
560}
561
562impl QueueAction {
563    /// Whether this action requires dropping any frames.
564    #[inline]
565    #[must_use]
566    pub fn drops_frames(self) -> bool {
567        matches!(self, Self::DropOldest(_) | Self::DropNewest(_))
568    }
569}
570
571// =========================================================================
572// Unified guardrails
573// =========================================================================
574
575/// Configuration for the unified frame guardrails.
576#[derive(Debug, Clone, Default)]
577pub struct GuardrailsConfig {
578    /// Memory budget configuration.
579    pub memory: MemoryBudgetConfig,
580    /// Queue depth configuration.
581    pub queue: QueueConfig,
582}
583
584/// Verdict from a guardrail check, combining all subsystem results.
585#[derive(Debug, Clone)]
586pub struct GuardrailVerdict {
587    /// Alerts from all guardrails that fired (may be empty).
588    pub alerts: Vec<GuardrailAlert>,
589    /// Queue action recommended by queue guardrails.
590    pub queue_action: QueueAction,
591    /// The most aggressive degradation level recommended across all alerts.
592    pub recommended_level: DegradationLevel,
593}
594
595impl GuardrailVerdict {
596    /// Whether any guardrail recommends dropping the current frame.
597    #[inline]
598    #[must_use]
599    pub fn should_drop_frame(&self) -> bool {
600        self.recommended_level >= DegradationLevel::SkipFrame
601    }
602
603    /// Whether any guardrail recommends degradation (but not frame skip).
604    #[inline]
605    #[must_use]
606    pub fn should_degrade(&self) -> bool {
607        self.recommended_level > DegradationLevel::Full
608            && self.recommended_level < DegradationLevel::SkipFrame
609    }
610
611    /// Whether all guardrails are satisfied (no alerts).
612    #[inline]
613    #[must_use]
614    pub fn is_clear(&self) -> bool {
615        self.alerts.is_empty()
616    }
617
618    /// The highest severity among all alerts, or `None` if clear.
619    #[must_use]
620    pub fn max_severity(&self) -> Option<AlertSeverity> {
621        self.alerts.iter().map(|a| a.severity).max()
622    }
623}
624
625/// Unified frame guardrails combining memory budget and queue depth limits.
626///
627/// Call [`check_frame`](Self::check_frame) once per frame with current resource
628/// usage. The returned [`GuardrailVerdict`] tells you what (if anything) to do.
629#[derive(Debug, Clone)]
630pub struct FrameGuardrails {
631    memory: MemoryBudget,
632    queue: QueueGuardrails,
633    /// Total frames checked.
634    frames_checked: u64,
635    /// Total frames where at least one alert fired.
636    frames_with_alerts: u64,
637}
638
639impl FrameGuardrails {
640    /// Create a new unified guardrails instance.
641    #[must_use]
642    pub fn new(config: GuardrailsConfig) -> Self {
643        Self {
644            memory: MemoryBudget::new(config.memory),
645            queue: QueueGuardrails::new(config.queue),
646            frames_checked: 0,
647            frames_with_alerts: 0,
648        }
649    }
650
651    /// Check all guardrails for the current frame.
652    ///
653    /// `memory_bytes`: total rendering memory in use (buffer + pools).
654    /// `queue_depth`: number of pending frames waiting to be rendered.
655    pub fn check_frame(&mut self, memory_bytes: usize, queue_depth: u32) -> GuardrailVerdict {
656        self.frames_checked = self.frames_checked.saturating_add(1);
657
658        let mut alerts = Vec::new();
659        let mut max_level = DegradationLevel::Full;
660
661        // Memory check
662        if let Some(alert) = self.memory.check(memory_bytes) {
663            if alert.recommended_level > max_level {
664                max_level = alert.recommended_level;
665            }
666            alerts.push(alert);
667        }
668
669        // Queue check
670        let (queue_alert, queue_action) = self.queue.check(queue_depth);
671        if let Some(alert) = queue_alert {
672            if alert.recommended_level > max_level {
673                max_level = alert.recommended_level;
674            }
675            alerts.push(alert);
676        }
677
678        if !alerts.is_empty() {
679            self.frames_with_alerts = self.frames_with_alerts.saturating_add(1);
680        }
681
682        GuardrailVerdict {
683            alerts,
684            queue_action,
685            recommended_level: max_level,
686        }
687    }
688
689    /// Access the memory budget subsystem.
690    #[inline]
691    #[must_use]
692    pub fn memory(&self) -> &MemoryBudget {
693        &self.memory
694    }
695
696    /// Access the queue guardrails subsystem.
697    #[inline]
698    #[must_use]
699    pub fn queue(&self) -> &QueueGuardrails {
700        &self.queue
701    }
702
703    /// Total frames checked.
704    #[inline]
705    #[must_use]
706    pub fn frames_checked(&self) -> u64 {
707        self.frames_checked
708    }
709
710    /// Total frames where at least one alert fired.
711    #[inline]
712    #[must_use]
713    pub fn frames_with_alerts(&self) -> u64 {
714        self.frames_with_alerts
715    }
716
717    /// Fraction of frames that triggered alerts (0.0–1.0).
718    #[inline]
719    #[must_use]
720    pub fn alert_rate(&self) -> f64 {
721        if self.frames_checked == 0 {
722            return 0.0;
723        }
724        self.frames_with_alerts as f64 / self.frames_checked as f64
725    }
726
727    /// Capture a diagnostic snapshot.
728    #[must_use]
729    pub fn snapshot(&self) -> GuardrailSnapshot {
730        GuardrailSnapshot {
731            memory_bytes: self.memory.current_bytes(),
732            memory_peak_bytes: self.memory.peak_bytes(),
733            memory_usage_fraction: self.memory.usage_fraction(),
734            memory_soft_violations: self.memory.soft_violations(),
735            memory_hard_violations: self.memory.hard_violations(),
736            memory_emergency_violations: self.memory.emergency_violations(),
737            queue_depth: self.queue.current_depth(),
738            queue_peak_depth: self.queue.peak_depth(),
739            queue_total_drops: self.queue.total_drops(),
740            queue_total_backpressure: self.queue.total_backpressure_events(),
741            frames_checked: self.frames_checked,
742            frames_with_alerts: self.frames_with_alerts,
743        }
744    }
745
746    /// Reset all tracking state (preserves configs).
747    pub fn reset(&mut self) {
748        self.memory.reset();
749        self.queue.reset();
750        self.frames_checked = 0;
751        self.frames_with_alerts = 0;
752    }
753}
754
755/// Diagnostic snapshot of guardrail state.
756///
757/// All fields are `Copy` — no allocations. Suitable for structured logging
758/// or debug overlay.
759#[derive(Debug, Clone, Copy, PartialEq)]
760pub struct GuardrailSnapshot {
761    /// Current memory usage in bytes.
762    pub memory_bytes: usize,
763    /// Peak memory usage in bytes.
764    pub memory_peak_bytes: usize,
765    /// Fraction of soft memory limit used.
766    pub memory_usage_fraction: f64,
767    /// Frames exceeding soft memory limit.
768    pub memory_soft_violations: u32,
769    /// Frames exceeding hard memory limit.
770    pub memory_hard_violations: u32,
771    /// Frames exceeding the emergency memory limit (frame shed + arena
772    /// rebuild). Kept distinct from [`Self::memory_hard_violations`]
773    /// (bd-1za0z F5).
774    pub memory_emergency_violations: u32,
775    /// Current queue depth.
776    pub queue_depth: u32,
777    /// Peak queue depth.
778    pub queue_peak_depth: u32,
779    /// Total frames dropped from queue.
780    pub queue_total_drops: u64,
781    /// Total backpressure events.
782    pub queue_total_backpressure: u64,
783    /// Total frames checked.
784    pub frames_checked: u64,
785    /// Total frames with alerts.
786    pub frames_with_alerts: u64,
787}
788
789impl GuardrailSnapshot {
790    /// Serialize to a JSONL-compatible string.
791    pub fn to_jsonl(&self) -> String {
792        format!(
793            concat!(
794                r#"{{"memory_bytes":{},"memory_peak":{},"memory_frac":{:.4},"#,
795                r#""mem_soft_violations":{},"mem_hard_violations":{},"mem_emergency_violations":{},"#,
796                r#""queue_depth":{},"queue_peak":{},"queue_drops":{},"#,
797                r#""queue_backpressure":{},"frames_checked":{},"frames_alerted":{}}}"#,
798            ),
799            self.memory_bytes,
800            self.memory_peak_bytes,
801            self.memory_usage_fraction,
802            self.memory_soft_violations,
803            self.memory_hard_violations,
804            self.memory_emergency_violations,
805            self.queue_depth,
806            self.queue_peak_depth,
807            self.queue_total_drops,
808            self.queue_total_backpressure,
809            self.frames_checked,
810            self.frames_with_alerts,
811        )
812    }
813}
814
815// =========================================================================
816// Utility: compute buffer memory
817// =========================================================================
818
819/// Size of a single Cell in bytes (compile-time constant).
820pub const CELL_SIZE_BYTES: usize = 16;
821
822/// Compute the memory footprint of a buffer with the given dimensions.
823///
824/// This accounts for the cell array only (not dirty tracking or stack metadata).
825#[inline]
826#[must_use]
827pub fn buffer_memory_bytes(width: u16, height: u16) -> usize {
828    width as usize * height as usize * CELL_SIZE_BYTES
829}
830
831// =========================================================================
832// Tests
833// =========================================================================
834
835#[cfg(test)]
836mod tests {
837    use super::*;
838
839    #[test]
840    fn zero_and_inverted_configs_are_normalized() {
841        // Regression: an all-zero config classified EVERY frame Emergency
842        // (>= comparisons) — blank screen from startup; inverted watermarks
843        // silently misclassified. new() now normalizes.
844        let zero = MemoryBudget::new(MemoryBudgetConfig {
845            soft_limit_bytes: 0,
846            hard_limit_bytes: 0,
847            emergency_limit_bytes: 0,
848        });
849        assert!(zero.config.soft_limit_bytes > 0);
850        assert!(zero.config.soft_limit_bytes <= zero.config.hard_limit_bytes);
851        assert!(zero.config.hard_limit_bytes <= zero.config.emergency_limit_bytes);
852
853        let inverted = MemoryBudget::new(MemoryBudgetConfig {
854            soft_limit_bytes: 32 * 1024 * 1024,
855            hard_limit_bytes: 8 * 1024 * 1024,
856            emergency_limit_bytes: 16 * 1024 * 1024,
857        });
858        assert!(inverted.config.soft_limit_bytes <= inverted.config.hard_limit_bytes);
859        assert!(inverted.config.hard_limit_bytes <= inverted.config.emergency_limit_bytes);
860    }
861
862    // ---- MemoryBudget ----
863
864    #[test]
865    fn memory_below_soft_no_alert() {
866        let mut mb = MemoryBudget::new(MemoryBudgetConfig::default());
867        assert!(mb.check(1024).is_none());
868        assert_eq!(mb.current_bytes(), 1024);
869    }
870
871    #[test]
872    fn memory_at_soft_limit_warns() {
873        let mut mb = MemoryBudget::new(MemoryBudgetConfig::default());
874        let alert = mb.check(8 * 1024 * 1024).unwrap();
875        assert_eq!(alert.kind, GuardrailKind::Memory);
876        assert_eq!(alert.severity, AlertSeverity::Warning);
877        assert_eq!(alert.recommended_level, DegradationLevel::SimpleBorders);
878    }
879
880    #[test]
881    fn memory_at_hard_limit_critical() {
882        let mut mb = MemoryBudget::new(MemoryBudgetConfig::default());
883        let alert = mb.check(16 * 1024 * 1024).unwrap();
884        assert_eq!(alert.severity, AlertSeverity::Critical);
885        assert_eq!(alert.recommended_level, DegradationLevel::Skeleton);
886    }
887
888    #[test]
889    fn memory_at_emergency_limit() {
890        let mut mb = MemoryBudget::new(MemoryBudgetConfig::default());
891        let alert = mb.check(32 * 1024 * 1024).unwrap();
892        assert_eq!(alert.severity, AlertSeverity::Emergency);
893        assert_eq!(alert.recommended_level, DegradationLevel::SkipFrame);
894    }
895
896    #[test]
897    fn memory_peak_tracking() {
898        let mut mb = MemoryBudget::new(MemoryBudgetConfig::default());
899        mb.check(1000);
900        mb.check(5000);
901        mb.check(3000);
902        assert_eq!(mb.peak_bytes(), 5000);
903        assert_eq!(mb.current_bytes(), 3000);
904    }
905
906    #[test]
907    fn memory_violation_counts() {
908        let config = MemoryBudgetConfig {
909            soft_limit_bytes: 100,
910            hard_limit_bytes: 200,
911            emergency_limit_bytes: 300,
912        };
913        let mut mb = MemoryBudget::new(config);
914        mb.check(50); // no violation
915        mb.check(150); // soft
916        mb.check(150); // soft again
917        mb.check(250); // hard
918        mb.check(350); // emergency — its own tier, NOT a hard violation
919        assert_eq!(mb.soft_violations(), 2);
920        assert_eq!(mb.hard_violations(), 1);
921        assert_eq!(
922            mb.emergency_violations(),
923            1,
924            "emergency shedding must be counted separately from hard hits (bd-1za0z F5)"
925        );
926    }
927
928    #[test]
929    fn memory_usage_fraction() {
930        let config = MemoryBudgetConfig {
931            soft_limit_bytes: 1000,
932            hard_limit_bytes: 2000,
933            emergency_limit_bytes: 3000,
934        };
935        let mut mb = MemoryBudget::new(config);
936        mb.check(500);
937        assert!((mb.usage_fraction() - 0.5).abs() < f64::EPSILON);
938    }
939
940    #[test]
941    fn memory_usage_fraction_zero_limit() {
942        // The constructor normalizes zero limits to defaults (an all-zero
943        // config used to classify every frame Emergency), so a zero-limit
944        // budget is no longer constructible via new(); 100 bytes against
945        // the default 8 MiB soft limit is a tiny fraction.
946        let config = MemoryBudgetConfig {
947            soft_limit_bytes: 0,
948            hard_limit_bytes: 0,
949            emergency_limit_bytes: 0,
950        };
951        let mut mb = MemoryBudget::new(config);
952        mb.check(100);
953        assert!(mb.usage_fraction() > 0.0);
954        assert!(mb.usage_fraction() < 0.001);
955    }
956
957    #[test]
958    fn memory_reset_clears_state() {
959        let mut mb = MemoryBudget::new(MemoryBudgetConfig::default());
960        mb.check(10 * 1024 * 1024); // soft violation
961        assert!(mb.soft_violations() > 0);
962        mb.reset();
963        assert_eq!(mb.peak_bytes(), 0);
964        assert_eq!(mb.current_bytes(), 0);
965        assert_eq!(mb.soft_violations(), 0);
966        assert_eq!(mb.hard_violations(), 0);
967    }
968
969    #[test]
970    fn memory_config_accessors() {
971        let config = MemoryBudgetConfig::small();
972        let mb = MemoryBudget::new(config);
973        assert_eq!(mb.config().soft_limit_bytes, 2 * 1024 * 1024);
974    }
975
976    // ---- QueueGuardrails ----
977
978    #[test]
979    fn queue_below_warn_no_alert() {
980        let mut qg = QueueGuardrails::new(QueueConfig::default());
981        let (alert, action) = qg.check(1);
982        assert!(alert.is_none());
983        assert_eq!(action, QueueAction::None);
984    }
985
986    #[test]
987    fn queue_at_warn_depth() {
988        let mut qg = QueueGuardrails::new(QueueConfig::default());
989        let (alert, action) = qg.check(3);
990        assert_eq!(alert.unwrap().severity, AlertSeverity::Warning);
991        assert_eq!(action, QueueAction::None); // warning only, no action
992    }
993
994    #[test]
995    fn queue_at_max_depth_drop_oldest() {
996        let config = QueueConfig {
997            drop_policy: QueueDropPolicy::DropOldest,
998            ..QueueConfig::default()
999        };
1000        let mut qg = QueueGuardrails::new(config);
1001        let (alert, action) = qg.check(8);
1002        assert_eq!(alert.unwrap().severity, AlertSeverity::Critical);
1003        assert!(action.drops_frames());
1004    }
1005
1006    #[test]
1007    fn queue_at_max_depth_drop_newest() {
1008        let config = QueueConfig {
1009            drop_policy: QueueDropPolicy::DropNewest,
1010            ..QueueConfig::default()
1011        };
1012        let mut qg = QueueGuardrails::new(config);
1013        let (alert, action) = qg.check(8);
1014        assert_eq!(alert.unwrap().severity, AlertSeverity::Critical);
1015        assert_eq!(action, QueueAction::DropNewest(5));
1016    }
1017
1018    #[test]
1019    fn queue_at_max_depth_backpressure() {
1020        let config = QueueConfig {
1021            drop_policy: QueueDropPolicy::Backpressure,
1022            ..QueueConfig::default()
1023        };
1024        let mut qg = QueueGuardrails::new(config);
1025        let (alert, action) = qg.check(8);
1026        assert_eq!(alert.unwrap().severity, AlertSeverity::Critical);
1027        assert_eq!(action, QueueAction::Backpressure);
1028    }
1029
1030    #[test]
1031    fn queue_emergency_drops_to_latest() {
1032        let mut qg = QueueGuardrails::new(QueueConfig::default());
1033        let (alert, action) = qg.check(16);
1034        assert_eq!(alert.unwrap().severity, AlertSeverity::Emergency);
1035        // DropOldest at emergency should keep only 1 frame
1036        assert_eq!(action, QueueAction::DropOldest(15));
1037    }
1038
1039    #[test]
1040    fn queue_peak_tracking() {
1041        let mut qg = QueueGuardrails::new(QueueConfig::default());
1042        qg.check(2);
1043        qg.check(5);
1044        qg.check(1);
1045        assert_eq!(qg.peak_depth(), 5);
1046        assert_eq!(qg.current_depth(), 1);
1047    }
1048
1049    #[test]
1050    fn queue_drop_counting() {
1051        let mut qg = QueueGuardrails::new(QueueConfig::default());
1052        qg.check(8); // triggers drop
1053        assert!(qg.total_drops() > 0);
1054    }
1055
1056    #[test]
1057    fn queue_backpressure_counting() {
1058        let config = QueueConfig::strict();
1059        let mut qg = QueueGuardrails::new(config);
1060        qg.check(4); // max_depth for strict
1061        assert!(qg.total_backpressure_events() > 0);
1062    }
1063
1064    #[test]
1065    fn queue_reset_clears_state() {
1066        let mut qg = QueueGuardrails::new(QueueConfig::default());
1067        qg.check(10);
1068        qg.reset();
1069        assert_eq!(qg.peak_depth(), 0);
1070        assert_eq!(qg.current_depth(), 0);
1071        assert_eq!(qg.total_drops(), 0);
1072    }
1073
1074    #[test]
1075    fn queue_config_accessors() {
1076        let config = QueueConfig::relaxed();
1077        let qg = QueueGuardrails::new(config);
1078        assert_eq!(qg.config().max_depth, 16);
1079    }
1080
1081    /// CONTRACT (bd-1za0z): malformed threshold ordering is normalized at
1082    /// construction — warn <= max <= emergency, warn floored at 1.
1083    #[test]
1084    fn queue_config_normalized_at_construction() {
1085        let config = QueueConfig {
1086            warn_depth: 10,
1087            max_depth: 8,
1088            emergency_depth: 6,
1089            drop_policy: QueueDropPolicy::DropOldest,
1090        };
1091        let qg = QueueGuardrails::new(config);
1092        let cfg = qg.config();
1093        assert_eq!(cfg.warn_depth, 10);
1094        assert_eq!(cfg.max_depth, 10, "max must be raised to warn");
1095        assert_eq!(cfg.emergency_depth, 10, "emergency must be raised to max");
1096
1097        let zeroed = QueueGuardrails::new(QueueConfig {
1098            warn_depth: 0,
1099            ..QueueConfig::default()
1100        });
1101        assert_eq!(
1102            zeroed.config().warn_depth,
1103            1,
1104            "warn_depth 0 would flag an idle queue"
1105        );
1106    }
1107
1108    /// CONTRACT (bd-1za0z): collapsed thresholds (warn == max) must never
1109    /// produce a zero-excess Drop action; the critical alert still fires.
1110    #[test]
1111    fn queue_collapsed_thresholds_yield_noop_action_not_zero_drop() {
1112        let config = QueueConfig {
1113            warn_depth: 8,
1114            max_depth: 8,
1115            emergency_depth: 16,
1116            drop_policy: QueueDropPolicy::DropOldest,
1117        };
1118        let mut qg = QueueGuardrails::new(config);
1119        let (alert, action) = qg.check(8);
1120        assert_eq!(alert.unwrap().severity, AlertSeverity::Critical);
1121        assert_eq!(
1122            action,
1123            QueueAction::None,
1124            "excess 0 must not be reported as a real drop action"
1125        );
1126        assert_eq!(qg.total_drops(), 0, "no-op action must not count drops");
1127    }
1128
1129    /// CONTRACT (bd-1za0z): DropNewest at emergency keeps the oldest frame
1130    /// (drops the newest excess) — policy-relative semantics, now documented
1131    /// on the config field.
1132    #[test]
1133    fn queue_emergency_drop_newest_keeps_oldest() {
1134        let config = QueueConfig {
1135            drop_policy: QueueDropPolicy::DropNewest,
1136            ..QueueConfig::default()
1137        };
1138        let mut qg = QueueGuardrails::new(config);
1139        let (alert, action) = qg.check(16);
1140        assert_eq!(alert.unwrap().severity, AlertSeverity::Emergency);
1141        assert_eq!(action, QueueAction::DropNewest(15));
1142    }
1143
1144    // ---- QueueAction ----
1145
1146    #[test]
1147    fn queue_action_drops_frames() {
1148        assert!(!QueueAction::None.drops_frames());
1149        assert!(QueueAction::DropOldest(3).drops_frames());
1150        assert!(QueueAction::DropNewest(1).drops_frames());
1151        assert!(!QueueAction::Backpressure.drops_frames());
1152    }
1153
1154    // ---- FrameGuardrails ----
1155
1156    #[test]
1157    fn guardrails_clear_when_healthy() {
1158        let mut g = FrameGuardrails::new(GuardrailsConfig::default());
1159        let v = g.check_frame(1024, 0);
1160        assert!(v.is_clear());
1161        assert_eq!(v.recommended_level, DegradationLevel::Full);
1162        assert_eq!(v.queue_action, QueueAction::None);
1163    }
1164
1165    #[test]
1166    fn guardrails_memory_alert_propagates() {
1167        let mut g = FrameGuardrails::new(GuardrailsConfig::default());
1168        let v = g.check_frame(8 * 1024 * 1024, 0);
1169        assert!(!v.is_clear());
1170        assert_eq!(v.alerts.len(), 1);
1171        assert_eq!(v.alerts[0].kind, GuardrailKind::Memory);
1172        assert!(v.should_degrade());
1173        assert!(!v.should_drop_frame());
1174    }
1175
1176    #[test]
1177    fn guardrails_queue_alert_propagates() {
1178        let mut g = FrameGuardrails::new(GuardrailsConfig::default());
1179        let v = g.check_frame(0, 8);
1180        assert!(!v.is_clear());
1181        assert!(v.alerts.iter().any(|a| a.kind == GuardrailKind::QueueDepth));
1182    }
1183
1184    #[test]
1185    fn guardrails_both_alerts_combine() {
1186        let config = GuardrailsConfig {
1187            memory: MemoryBudgetConfig {
1188                soft_limit_bytes: 100,
1189                hard_limit_bytes: 200,
1190                emergency_limit_bytes: 300,
1191            },
1192            queue: QueueConfig {
1193                warn_depth: 1,
1194                max_depth: 2,
1195                emergency_depth: 3,
1196                drop_policy: QueueDropPolicy::DropOldest,
1197            },
1198        };
1199        let mut g = FrameGuardrails::new(config);
1200        let v = g.check_frame(150, 2);
1201        assert_eq!(v.alerts.len(), 2);
1202        // Should use the most aggressive recommendation
1203        assert!(v.recommended_level >= DegradationLevel::SimpleBorders);
1204    }
1205
1206    #[test]
1207    fn guardrails_emergency_recommends_skip() {
1208        let config = GuardrailsConfig {
1209            memory: MemoryBudgetConfig {
1210                soft_limit_bytes: 100,
1211                hard_limit_bytes: 200,
1212                emergency_limit_bytes: 300,
1213            },
1214            queue: QueueConfig::default(),
1215        };
1216        let mut g = FrameGuardrails::new(config);
1217        let v = g.check_frame(300, 0);
1218        assert!(v.should_drop_frame());
1219    }
1220
1221    #[test]
1222    fn guardrails_frame_counting() {
1223        let mut g = FrameGuardrails::new(GuardrailsConfig::default());
1224        g.check_frame(0, 0);
1225        g.check_frame(0, 0);
1226        g.check_frame(8 * 1024 * 1024, 0); // triggers alert
1227        assert_eq!(g.frames_checked(), 3);
1228        assert_eq!(g.frames_with_alerts(), 1);
1229    }
1230
1231    #[test]
1232    fn guardrails_alert_rate() {
1233        let config = GuardrailsConfig {
1234            memory: MemoryBudgetConfig {
1235                soft_limit_bytes: 100,
1236                hard_limit_bytes: 200,
1237                emergency_limit_bytes: 300,
1238            },
1239            queue: QueueConfig::default(),
1240        };
1241        let mut g = FrameGuardrails::new(config);
1242        g.check_frame(50, 0); // clear
1243        g.check_frame(150, 0); // alert
1244        g.check_frame(50, 0); // clear
1245        g.check_frame(150, 0); // alert
1246        assert!((g.alert_rate() - 0.5).abs() < f64::EPSILON);
1247    }
1248
1249    #[test]
1250    fn guardrails_alert_rate_zero_frames() {
1251        let g = FrameGuardrails::new(GuardrailsConfig::default());
1252        assert!((g.alert_rate() - 0.0).abs() < f64::EPSILON);
1253    }
1254
1255    #[test]
1256    fn guardrails_snapshot_jsonl() {
1257        let mut g = FrameGuardrails::new(GuardrailsConfig::default());
1258        g.check_frame(1024, 1);
1259        let snap = g.snapshot();
1260        let line = snap.to_jsonl();
1261        assert!(line.starts_with('{'));
1262        assert!(line.ends_with('}'));
1263        assert!(line.contains("\"memory_bytes\":1024"));
1264        assert!(line.contains("\"queue_depth\":1"));
1265    }
1266
1267    #[test]
1268    fn guardrails_reset_clears_all() {
1269        let mut g = FrameGuardrails::new(GuardrailsConfig::default());
1270        g.check_frame(8 * 1024 * 1024, 5);
1271        g.reset();
1272        assert_eq!(g.frames_checked(), 0);
1273        assert_eq!(g.frames_with_alerts(), 0);
1274        assert_eq!(g.memory().peak_bytes(), 0);
1275        assert_eq!(g.queue().peak_depth(), 0);
1276    }
1277
1278    #[test]
1279    fn guardrails_subsystem_access() {
1280        let g = FrameGuardrails::new(GuardrailsConfig::default());
1281        let _ = g.memory().config();
1282        let _ = g.queue().config();
1283    }
1284
1285    // ---- GuardrailVerdict ----
1286
1287    #[test]
1288    fn verdict_max_severity_none_when_clear() {
1289        let v = GuardrailVerdict {
1290            alerts: vec![],
1291            queue_action: QueueAction::None,
1292            recommended_level: DegradationLevel::Full,
1293        };
1294        assert!(v.max_severity().is_none());
1295        assert!(v.is_clear());
1296    }
1297
1298    #[test]
1299    fn verdict_max_severity_picks_highest() {
1300        let v = GuardrailVerdict {
1301            alerts: vec![
1302                GuardrailAlert {
1303                    kind: GuardrailKind::Memory,
1304                    severity: AlertSeverity::Warning,
1305                    recommended_level: DegradationLevel::SimpleBorders,
1306                },
1307                GuardrailAlert {
1308                    kind: GuardrailKind::QueueDepth,
1309                    severity: AlertSeverity::Critical,
1310                    recommended_level: DegradationLevel::EssentialOnly,
1311                },
1312            ],
1313            queue_action: QueueAction::None,
1314            recommended_level: DegradationLevel::EssentialOnly,
1315        };
1316        assert_eq!(v.max_severity(), Some(AlertSeverity::Critical));
1317    }
1318
1319    // ---- AlertSeverity ordering ----
1320
1321    #[test]
1322    fn severity_ordering() {
1323        assert!(AlertSeverity::Warning < AlertSeverity::Critical);
1324        assert!(AlertSeverity::Critical < AlertSeverity::Emergency);
1325    }
1326
1327    // ---- Config presets ----
1328
1329    #[test]
1330    fn memory_config_small_preset() {
1331        let c = MemoryBudgetConfig::small();
1332        assert!(c.soft_limit_bytes < MemoryBudgetConfig::default().soft_limit_bytes);
1333    }
1334
1335    #[test]
1336    fn memory_config_large_preset() {
1337        let c = MemoryBudgetConfig::large();
1338        assert!(c.soft_limit_bytes > MemoryBudgetConfig::default().soft_limit_bytes);
1339    }
1340
1341    #[test]
1342    fn queue_config_strict_preset() {
1343        let c = QueueConfig::strict();
1344        assert_eq!(c.drop_policy, QueueDropPolicy::Backpressure);
1345        assert!(c.max_depth < QueueConfig::default().max_depth);
1346    }
1347
1348    #[test]
1349    fn queue_config_relaxed_preset() {
1350        let c = QueueConfig::relaxed();
1351        assert!(c.max_depth > QueueConfig::default().max_depth);
1352    }
1353
1354    // ---- buffer_memory_bytes ----
1355
1356    #[test]
1357    fn buffer_memory_typical_terminal() {
1358        // 80×24 terminal
1359        assert_eq!(buffer_memory_bytes(80, 24), 80 * 24 * 16);
1360    }
1361
1362    #[test]
1363    fn buffer_memory_zero_dimension() {
1364        assert_eq!(buffer_memory_bytes(0, 24), 0);
1365        assert_eq!(buffer_memory_bytes(80, 0), 0);
1366        assert_eq!(buffer_memory_bytes(0, 0), 0);
1367    }
1368
1369    #[test]
1370    fn buffer_memory_large_terminal() {
1371        // 300×100 terminal
1372        let bytes = buffer_memory_bytes(300, 100);
1373        assert_eq!(bytes, 300 * 100 * 16);
1374        assert_eq!(bytes, 480_000);
1375    }
1376
1377    // ---- QueueDropPolicy Default ----
1378
1379    #[test]
1380    fn queue_drop_policy_default_is_drop_oldest() {
1381        assert_eq!(QueueDropPolicy::default(), QueueDropPolicy::DropOldest);
1382    }
1383
1384    // ---- Determinism ----
1385
1386    #[test]
1387    fn guardrails_deterministic_for_same_inputs() {
1388        let config = GuardrailsConfig::default();
1389        let mut g1 = FrameGuardrails::new(config.clone());
1390        let mut g2 = FrameGuardrails::new(config);
1391
1392        let inputs = [(1024, 0), (8 * 1024 * 1024, 3), (20 * 1024 * 1024, 10)];
1393        for (mem, queue) in inputs {
1394            let v1 = g1.check_frame(mem, queue);
1395            let v2 = g2.check_frame(mem, queue);
1396            assert_eq!(v1.recommended_level, v2.recommended_level);
1397            assert_eq!(v1.alerts.len(), v2.alerts.len());
1398            assert_eq!(v1.queue_action, v2.queue_action);
1399        }
1400    }
1401}