Skip to main content

ftui_text/
tier_budget.rs

1//! Frame-time, memory, and queue budgets per quality tier.
2//!
3//! This module defines measurable performance envelopes for each
4//! [`LayoutTier`]. The adaptive quality controller (see the runtime control
5//! layer) reads these budgets to decide when to degrade or promote tiers.
6//!
7//! # Budget model
8//!
9//! Each tier has three budget axes:
10//!
11//! - **Frame budget** — maximum wall-clock time (µs) for a single
12//!   render frame (layout + shaping + diff + present).
13//! - **Memory budget** — peak transient allocation ceiling (bytes) for
14//!   per-frame working memory (caches, scratch buffers, glyph tables).
15//! - **Queue budget** — maximum depth of the deferred work queue
16//!   (re-shape, re-wrap, incremental reflow jobs).
17//!
18//! # Feature toggles
19//!
20//! [`TierFeatures`] defines which subsystem features are active at each
21//! tier. Features are monotonically enabled as the tier increases:
22//! everything active at Emergency is also active at Fast, and so on.
23//!
24//! # Safety constraints
25//!
26//! [`SafetyInvariant`] lists properties that must hold regardless of the
27//! current tier. These are never disabled by the adaptive controller.
28
29use std::fmt;
30use std::time::Duration;
31
32use crate::layout_policy::LayoutTier;
33
34// =========================================================================
35// FrameBudget
36// =========================================================================
37
38/// Wall-clock time budget for a single render frame.
39///
40/// All values are in microseconds for sub-millisecond precision without
41/// floating point.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct FrameBudget {
44    /// Total frame budget (layout + shaping + diff + present).
45    pub total_us: u64,
46    /// Maximum time for the layout solver pass.
47    pub layout_us: u64,
48    /// Maximum time for text shaping (shaped or terminal path).
49    pub shaping_us: u64,
50    /// Maximum time for buffer-diff computation.
51    pub diff_us: u64,
52    /// Maximum time for the presenter (ANSI emit).
53    pub present_us: u64,
54    /// Headroom reserved for widget render + event dispatch + IO.
55    pub headroom_us: u64,
56}
57
58impl FrameBudget {
59    /// Budget for a target frame rate.
60    ///
61    /// Returns the per-frame total in microseconds.
62    #[must_use]
63    pub const fn from_fps(fps: u32) -> u64 {
64        1_000_000 / fps as u64
65    }
66
67    /// Convert the total budget to a [`Duration`].
68    #[must_use]
69    pub const fn as_duration(&self) -> Duration {
70        Duration::from_micros(self.total_us)
71    }
72
73    /// Sum of allocated sub-budgets (should equal total).
74    #[must_use]
75    pub const fn allocated(&self) -> u64 {
76        self.layout_us + self.shaping_us + self.diff_us + self.present_us + self.headroom_us
77    }
78
79    /// Whether the sub-budgets are consistent with total.
80    #[must_use]
81    pub const fn is_consistent(&self) -> bool {
82        self.allocated() == self.total_us
83    }
84}
85
86impl fmt::Display for FrameBudget {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        write!(
89            f,
90            "{}µs (layout={}µs shaping={}µs diff={}µs present={}µs headroom={}µs)",
91            self.total_us,
92            self.layout_us,
93            self.shaping_us,
94            self.diff_us,
95            self.present_us,
96            self.headroom_us
97        )
98    }
99}
100
101// =========================================================================
102// MemoryBudget
103// =========================================================================
104
105/// Transient per-frame memory budget.
106///
107/// These are ceilings for scratch allocations that are live during a
108/// single frame. Persistent caches (width cache, shaping cache) are
109/// accounted separately in their own capacity configs.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub struct MemoryBudget {
112    /// Maximum bytes for shaping scratch (glyph buffers, cluster maps).
113    pub shaping_bytes: usize,
114    /// Maximum bytes for layout scratch (constraint vectors, flex splits).
115    pub layout_bytes: usize,
116    /// Maximum bytes for diff scratch (dirty bitmaps, change lists).
117    pub diff_bytes: usize,
118    /// Maximum entries in the width cache.
119    pub width_cache_entries: usize,
120    /// Maximum entries in the shaping cache.
121    pub shaping_cache_entries: usize,
122}
123
124impl MemoryBudget {
125    /// Total transient ceiling (shaping + layout + diff).
126    #[must_use]
127    pub const fn transient_total(&self) -> usize {
128        self.shaping_bytes + self.layout_bytes + self.diff_bytes
129    }
130}
131
132impl fmt::Display for MemoryBudget {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        write!(
135            f,
136            "transient={}B (shaping={}B layout={}B diff={}B) caches: width={} shaping={}",
137            self.transient_total(),
138            self.shaping_bytes,
139            self.layout_bytes,
140            self.diff_bytes,
141            self.width_cache_entries,
142            self.shaping_cache_entries
143        )
144    }
145}
146
147// =========================================================================
148// QueueBudget
149// =========================================================================
150
151/// Work-queue depth limits for deferred layout/shaping jobs.
152///
153/// When the queue exceeds these limits, the adaptive controller should
154/// degrade to a lower tier to reduce incoming work.
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub struct QueueBudget {
157    /// Maximum pending re-shape jobs (text runs awaiting shaping).
158    pub max_reshape_pending: usize,
159    /// Maximum pending re-wrap jobs (paragraphs awaiting line-breaking).
160    pub max_rewrap_pending: usize,
161    /// Maximum pending incremental reflow jobs.
162    pub max_reflow_pending: usize,
163}
164
165impl QueueBudget {
166    /// Total maximum pending jobs across all queues.
167    #[must_use]
168    pub const fn total_max(&self) -> usize {
169        self.max_reshape_pending + self.max_rewrap_pending + self.max_reflow_pending
170    }
171}
172
173impl fmt::Display for QueueBudget {
174    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175        write!(
176            f,
177            "reshape={} rewrap={} reflow={}",
178            self.max_reshape_pending, self.max_rewrap_pending, self.max_reflow_pending
179        )
180    }
181}
182
183// =========================================================================
184// TierBudget
185// =========================================================================
186
187/// Combined budget for a single quality tier.
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub struct TierBudget {
190    /// Which tier this budget applies to.
191    pub tier: LayoutTier,
192    /// Frame-time budget.
193    pub frame: FrameBudget,
194    /// Memory budget.
195    pub memory: MemoryBudget,
196    /// Queue-depth budget.
197    pub queue: QueueBudget,
198}
199
200impl fmt::Display for TierBudget {
201    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202        write!(
203            f,
204            "[{}] frame: {} | mem: {} | queue: {}",
205            self.tier, self.frame, self.memory, self.queue
206        )
207    }
208}
209
210// =========================================================================
211// TierFeatures
212// =========================================================================
213
214/// Feature toggles for a quality tier.
215///
216/// Each flag indicates whether a subsystem feature is active at this tier.
217/// Features are monotonically enabled: if a feature is on at tier T, it is
218/// also on at every tier above T.
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220pub struct TierFeatures {
221    /// Which tier these features apply to.
222    pub tier: LayoutTier,
223
224    // ── Text shaping ────────────────────────────────────────────────
225    /// Use the shaped text path (HarfBuzz/rustybuzz) when available.
226    pub shaped_text: bool,
227    /// Use the terminal (ClusterMap) fallback path.
228    pub terminal_fallback: bool,
229
230    // ── Line breaking ───────────────────────────────────────────────
231    /// Use Knuth-Plass optimal line breaking.
232    pub optimal_breaking: bool,
233    /// Use hyphenation for line breaking.
234    pub hyphenation: bool,
235
236    // ── Spacing / justification ─────────────────────────────────────
237    /// Enable full justification (stretch/shrink word spaces).
238    pub justification: bool,
239    /// Enable inter-character tracking.
240    pub tracking: bool,
241
242    // ── Vertical metrics ────────────────────────────────────────────
243    /// Activate baseline grid snapping.
244    pub baseline_grid: bool,
245    /// Apply paragraph spacing.
246    pub paragraph_spacing: bool,
247    /// Apply first-line indent.
248    pub first_line_indent: bool,
249
250    // ── Caching ─────────────────────────────────────────────────────
251    /// Use the width cache.
252    pub width_cache: bool,
253    /// Use the shaping cache.
254    pub shaping_cache: bool,
255
256    // ── Rendering ───────────────────────────────────────────────────
257    /// Use incremental (dirty-region) diff.
258    pub incremental_diff: bool,
259    /// Use sub-cell spacing (1/256 cell precision).
260    pub subcell_spacing: bool,
261}
262
263impl TierFeatures {
264    /// Human-readable list of active features.
265    #[must_use]
266    pub fn active_list(&self) -> Vec<&'static str> {
267        let mut out = Vec::new();
268        if self.shaped_text {
269            out.push("shaped-text");
270        }
271        if self.terminal_fallback {
272            out.push("terminal-fallback");
273        }
274        if self.optimal_breaking {
275            out.push("optimal-breaking");
276        }
277        if self.hyphenation {
278            out.push("hyphenation");
279        }
280        if self.justification {
281            out.push("justification");
282        }
283        if self.tracking {
284            out.push("tracking");
285        }
286        if self.baseline_grid {
287            out.push("baseline-grid");
288        }
289        if self.paragraph_spacing {
290            out.push("paragraph-spacing");
291        }
292        if self.first_line_indent {
293            out.push("first-line-indent");
294        }
295        if self.width_cache {
296            out.push("width-cache");
297        }
298        if self.shaping_cache {
299            out.push("shaping-cache");
300        }
301        if self.incremental_diff {
302            out.push("incremental-diff");
303        }
304        if self.subcell_spacing {
305            out.push("subcell-spacing");
306        }
307        out
308    }
309}
310
311impl fmt::Display for TierFeatures {
312    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
313        write!(f, "[{}] {}", self.tier, self.active_list().join(", "))
314    }
315}
316
317// =========================================================================
318// SafetyInvariant
319// =========================================================================
320
321/// Properties that must hold regardless of the current quality tier.
322///
323/// The adaptive controller must never violate these invariants, even
324/// under extreme compute pressure. They define the semantic floor
325/// below which output is no longer meaningful.
326#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
327pub enum SafetyInvariant {
328    /// Every input character must appear in the output buffer.
329    /// No content may be silently dropped.
330    NoContentLoss,
331    /// Wide characters (CJK, emoji) must occupy 2 cells.
332    /// Displaying a wide char in 1 cell corrupts layout.
333    WideCharWidth,
334    /// Buffer dimensions must match the terminal size.
335    /// Mismatched buffers cause garbled output.
336    BufferSizeMatch,
337    /// Cursor position must be within buffer bounds.
338    CursorInBounds,
339    /// Style resets must be emitted at line boundaries.
340    /// Leaking styles across lines corrupts subsequent output.
341    StyleBoundary,
342    /// Diff output must be idempotent: applying the same diff twice
343    /// produces the same result as applying it once.
344    DiffIdempotence,
345    /// Greedy wrapping must always be available as a fallback.
346    /// If optimal breaking fails, greedy wrapping takes over.
347    GreedyWrapFallback,
348    /// Width measurement must be deterministic for the same input.
349    WidthDeterminism,
350}
351
352impl SafetyInvariant {
353    /// All safety invariants.
354    pub const ALL: &'static [Self] = &[
355        Self::NoContentLoss,
356        Self::WideCharWidth,
357        Self::BufferSizeMatch,
358        Self::CursorInBounds,
359        Self::StyleBoundary,
360        Self::DiffIdempotence,
361        Self::GreedyWrapFallback,
362        Self::WidthDeterminism,
363    ];
364}
365
366impl fmt::Display for SafetyInvariant {
367    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
368        match self {
369            Self::NoContentLoss => write!(f, "no-content-loss"),
370            Self::WideCharWidth => write!(f, "wide-char-width"),
371            Self::BufferSizeMatch => write!(f, "buffer-size-match"),
372            Self::CursorInBounds => write!(f, "cursor-in-bounds"),
373            Self::StyleBoundary => write!(f, "style-boundary"),
374            Self::DiffIdempotence => write!(f, "diff-idempotence"),
375            Self::GreedyWrapFallback => write!(f, "greedy-wrap-fallback"),
376            Self::WidthDeterminism => write!(f, "width-determinism"),
377        }
378    }
379}
380
381// =========================================================================
382// TierLadder — the canonical budget/feature table
383// =========================================================================
384
385/// The canonical tier ladder: budgets, features, and safety constraints
386/// for every quality tier.
387///
388/// This is the single source of truth that the adaptive controller reads
389/// to decide degradation thresholds and feature availability.
390#[derive(Debug, Clone)]
391pub struct TierLadder {
392    /// Budget for each tier, ordered Emergency → Fast → Balanced → Quality.
393    pub budgets: [TierBudget; 4],
394    /// Feature toggles for each tier, same order.
395    pub features: [TierFeatures; 4],
396}
397
398impl TierLadder {
399    /// Look up the budget for a specific tier.
400    #[must_use]
401    pub fn budget(&self, tier: LayoutTier) -> &TierBudget {
402        &self.budgets[tier as usize]
403    }
404
405    /// Look up the feature toggles for a specific tier.
406    #[must_use]
407    pub fn features_for(&self, tier: LayoutTier) -> &TierFeatures {
408        &self.features[tier as usize]
409    }
410
411    /// The default tier ladder calibrated from baseline profiles.
412    ///
413    /// Frame budgets target 60fps (16,667µs) with progressive headroom
414    /// allocation. Memory budgets are sized for typical terminal
415    /// workloads (80x24 to 200x60). Queue budgets prevent unbounded
416    /// accumulation of deferred work.
417    #[must_use]
418    pub fn default_60fps() -> Self {
419        Self {
420            budgets: [
421                // Emergency: 2ms total — survival mode
422                TierBudget {
423                    tier: LayoutTier::Emergency,
424                    frame: FrameBudget {
425                        total_us: 2_000,
426                        layout_us: 100,
427                        shaping_us: 200,
428                        diff_us: 200,
429                        present_us: 500,
430                        headroom_us: 1_000,
431                    },
432                    memory: MemoryBudget {
433                        shaping_bytes: 64 * 1024, // 64 KiB
434                        layout_bytes: 16 * 1024,  // 16 KiB
435                        diff_bytes: 32 * 1024,    // 32 KiB
436                        width_cache_entries: 256,
437                        shaping_cache_entries: 0, // disabled
438                    },
439                    queue: QueueBudget {
440                        max_reshape_pending: 0, // no shaping
441                        max_rewrap_pending: 4,  // minimal
442                        max_reflow_pending: 1,
443                    },
444                },
445                // Fast: 4ms total — terminal-optimized
446                TierBudget {
447                    tier: LayoutTier::Fast,
448                    frame: FrameBudget {
449                        total_us: 4_000,
450                        layout_us: 200,
451                        shaping_us: 800,
452                        diff_us: 500,
453                        present_us: 1_000,
454                        headroom_us: 1_500,
455                    },
456                    memory: MemoryBudget {
457                        shaping_bytes: 256 * 1024, // 256 KiB
458                        layout_bytes: 64 * 1024,   // 64 KiB
459                        diff_bytes: 128 * 1024,    // 128 KiB
460                        width_cache_entries: 1_000,
461                        shaping_cache_entries: 0, // no shaping at fast
462                    },
463                    queue: QueueBudget {
464                        max_reshape_pending: 0, // no shaping
465                        max_rewrap_pending: 16,
466                        max_reflow_pending: 4,
467                    },
468                },
469                // Balanced: 8ms total — good default
470                TierBudget {
471                    tier: LayoutTier::Balanced,
472                    frame: FrameBudget {
473                        total_us: 8_000,
474                        layout_us: 500,
475                        shaping_us: 2_500,
476                        diff_us: 1_000,
477                        present_us: 1_500,
478                        headroom_us: 2_500,
479                    },
480                    memory: MemoryBudget {
481                        shaping_bytes: 1024 * 1024, // 1 MiB
482                        layout_bytes: 256 * 1024,   // 256 KiB
483                        diff_bytes: 512 * 1024,     // 512 KiB
484                        width_cache_entries: 4_000,
485                        shaping_cache_entries: 512,
486                    },
487                    queue: QueueBudget {
488                        max_reshape_pending: 32,
489                        max_rewrap_pending: 64,
490                        max_reflow_pending: 16,
491                    },
492                },
493                // Quality: 16ms total — near full frame budget
494                TierBudget {
495                    tier: LayoutTier::Quality,
496                    frame: FrameBudget {
497                        total_us: 16_000,
498                        layout_us: 1_000,
499                        shaping_us: 5_000,
500                        diff_us: 2_000,
501                        present_us: 3_000,
502                        headroom_us: 5_000,
503                    },
504                    memory: MemoryBudget {
505                        shaping_bytes: 4 * 1024 * 1024, // 4 MiB
506                        layout_bytes: 1024 * 1024,      // 1 MiB
507                        diff_bytes: 2 * 1024 * 1024,    // 2 MiB
508                        width_cache_entries: 16_000,
509                        shaping_cache_entries: 2_048,
510                    },
511                    queue: QueueBudget {
512                        max_reshape_pending: 128,
513                        max_rewrap_pending: 256,
514                        max_reflow_pending: 64,
515                    },
516                },
517            ],
518            features: [
519                // Emergency
520                TierFeatures {
521                    tier: LayoutTier::Emergency,
522                    shaped_text: false,
523                    terminal_fallback: true,
524                    optimal_breaking: false,
525                    hyphenation: false,
526                    justification: false,
527                    tracking: false,
528                    baseline_grid: false,
529                    paragraph_spacing: false,
530                    first_line_indent: false,
531                    width_cache: true, // always on — cheap
532                    shaping_cache: false,
533                    incremental_diff: true, // always on — correctness aid
534                    subcell_spacing: false,
535                },
536                // Fast
537                TierFeatures {
538                    tier: LayoutTier::Fast,
539                    shaped_text: false,
540                    terminal_fallback: true,
541                    optimal_breaking: false,
542                    hyphenation: false,
543                    justification: false,
544                    tracking: false,
545                    baseline_grid: false,
546                    paragraph_spacing: false,
547                    first_line_indent: false,
548                    width_cache: true,
549                    shaping_cache: false,
550                    incremental_diff: true,
551                    subcell_spacing: false,
552                },
553                // Balanced
554                TierFeatures {
555                    tier: LayoutTier::Balanced,
556                    shaped_text: true,
557                    terminal_fallback: true,
558                    optimal_breaking: true,
559                    hyphenation: false,
560                    justification: false,
561                    tracking: false,
562                    baseline_grid: false,
563                    paragraph_spacing: true,
564                    first_line_indent: false,
565                    width_cache: true,
566                    shaping_cache: true,
567                    incremental_diff: true,
568                    subcell_spacing: true,
569                },
570                // Quality
571                TierFeatures {
572                    tier: LayoutTier::Quality,
573                    shaped_text: true,
574                    terminal_fallback: true,
575                    optimal_breaking: true,
576                    hyphenation: true,
577                    justification: true,
578                    tracking: true,
579                    baseline_grid: true,
580                    paragraph_spacing: true,
581                    first_line_indent: true,
582                    width_cache: true,
583                    shaping_cache: true,
584                    incremental_diff: true,
585                    subcell_spacing: true,
586                },
587            ],
588        }
589    }
590
591    /// Verify that feature toggles are monotonically enabled up the ladder.
592    ///
593    /// Returns a list of violations where a higher tier disables a feature
594    /// that a lower tier enables.
595    #[must_use]
596    pub fn check_monotonicity(&self) -> Vec<String> {
597        let mut violations = Vec::new();
598
599        for i in 0..self.features.len() - 1 {
600            let lower = &self.features[i];
601            let higher = &self.features[i + 1];
602
603            let check = |name: &str, lo: bool, hi: bool| {
604                if lo && !hi {
605                    Some(format!(
606                        "{name} is enabled at {} but disabled at {}",
607                        lower.tier, higher.tier
608                    ))
609                } else {
610                    None
611                }
612            };
613
614            violations.extend(check(
615                "terminal_fallback",
616                lower.terminal_fallback,
617                higher.terminal_fallback,
618            ));
619            violations.extend(check("width_cache", lower.width_cache, higher.width_cache));
620            violations.extend(check(
621                "incremental_diff",
622                lower.incremental_diff,
623                higher.incremental_diff,
624            ));
625            violations.extend(check("shaped_text", lower.shaped_text, higher.shaped_text));
626            violations.extend(check(
627                "optimal_breaking",
628                lower.optimal_breaking,
629                higher.optimal_breaking,
630            ));
631            violations.extend(check("hyphenation", lower.hyphenation, higher.hyphenation));
632            violations.extend(check(
633                "justification",
634                lower.justification,
635                higher.justification,
636            ));
637            violations.extend(check("tracking", lower.tracking, higher.tracking));
638            violations.extend(check(
639                "baseline_grid",
640                lower.baseline_grid,
641                higher.baseline_grid,
642            ));
643            violations.extend(check(
644                "paragraph_spacing",
645                lower.paragraph_spacing,
646                higher.paragraph_spacing,
647            ));
648            violations.extend(check(
649                "first_line_indent",
650                lower.first_line_indent,
651                higher.first_line_indent,
652            ));
653            violations.extend(check(
654                "shaping_cache",
655                lower.shaping_cache,
656                higher.shaping_cache,
657            ));
658            violations.extend(check(
659                "subcell_spacing",
660                lower.subcell_spacing,
661                higher.subcell_spacing,
662            ));
663        }
664        violations
665    }
666
667    /// Verify that all budgets are consistent (sub-budgets sum to total).
668    #[must_use]
669    pub fn check_budget_consistency(&self) -> Vec<String> {
670        let mut issues = Vec::new();
671        for b in &self.budgets {
672            if !b.frame.is_consistent() {
673                issues.push(format!(
674                    "[{}] frame sub-budgets sum to {}µs but total is {}µs",
675                    b.tier,
676                    b.frame.allocated(),
677                    b.frame.total_us
678                ));
679            }
680        }
681        issues
682    }
683
684    /// Verify that budgets increase monotonically up the tier ladder.
685    #[must_use]
686    pub fn check_budget_ordering(&self) -> Vec<String> {
687        let mut issues = Vec::new();
688        for i in 0..self.budgets.len() - 1 {
689            let lower = &self.budgets[i];
690            let higher = &self.budgets[i + 1];
691            if lower.frame.total_us >= higher.frame.total_us {
692                issues.push(format!(
693                    "frame budget {} ({}µs) >= {} ({}µs)",
694                    lower.tier, lower.frame.total_us, higher.tier, higher.frame.total_us
695                ));
696            }
697        }
698        issues
699    }
700}
701
702impl Default for TierLadder {
703    fn default() -> Self {
704        Self::default_60fps()
705    }
706}
707
708impl fmt::Display for TierLadder {
709    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
710        for b in &self.budgets {
711            writeln!(f, "{b}")?;
712        }
713        writeln!(f)?;
714        for feat in &self.features {
715            writeln!(f, "{feat}")?;
716        }
717        Ok(())
718    }
719}
720
721// =========================================================================
722// Tests
723// =========================================================================
724
725#[cfg(test)]
726mod tests {
727    use super::*;
728
729    // ── FrameBudget ─────────────────────────────────────────────────
730
731    #[test]
732    fn fps_60_is_16667us() {
733        assert_eq!(FrameBudget::from_fps(60), 16_666);
734    }
735
736    #[test]
737    fn fps_30_is_33333us() {
738        assert_eq!(FrameBudget::from_fps(30), 33_333);
739    }
740
741    #[test]
742    fn frame_budget_as_duration() {
743        let fb = FrameBudget {
744            total_us: 16_000,
745            layout_us: 1_000,
746            shaping_us: 5_000,
747            diff_us: 2_000,
748            present_us: 3_000,
749            headroom_us: 5_000,
750        };
751        assert_eq!(fb.as_duration(), Duration::from_micros(16_000));
752    }
753
754    #[test]
755    fn frame_budget_consistency() {
756        let fb = FrameBudget {
757            total_us: 10_000,
758            layout_us: 1_000,
759            shaping_us: 2_000,
760            diff_us: 2_000,
761            present_us: 2_000,
762            headroom_us: 3_000,
763        };
764        assert!(fb.is_consistent());
765    }
766
767    #[test]
768    fn frame_budget_inconsistency() {
769        let fb = FrameBudget {
770            total_us: 10_000,
771            layout_us: 1_000,
772            shaping_us: 2_000,
773            diff_us: 2_000,
774            present_us: 2_000,
775            headroom_us: 999, // wrong
776        };
777        assert!(!fb.is_consistent());
778    }
779
780    #[test]
781    fn frame_budget_display() {
782        let fb = FrameBudget {
783            total_us: 4_000,
784            layout_us: 200,
785            shaping_us: 800,
786            diff_us: 500,
787            present_us: 1_000,
788            headroom_us: 1_500,
789        };
790        let s = format!("{fb}");
791        assert!(s.contains("4000µs"));
792        assert!(s.contains("layout=200µs"));
793    }
794
795    // ── MemoryBudget ────────────────────────────────────────────────
796
797    #[test]
798    fn memory_transient_total() {
799        let mb = MemoryBudget {
800            shaping_bytes: 1024,
801            layout_bytes: 512,
802            diff_bytes: 256,
803            width_cache_entries: 100,
804            shaping_cache_entries: 50,
805        };
806        assert_eq!(mb.transient_total(), 1792);
807    }
808
809    #[test]
810    fn memory_budget_display() {
811        let mb = MemoryBudget {
812            shaping_bytes: 1024,
813            layout_bytes: 512,
814            diff_bytes: 256,
815            width_cache_entries: 100,
816            shaping_cache_entries: 50,
817        };
818        let s = format!("{mb}");
819        assert!(s.contains("transient=1792B"));
820    }
821
822    // ── QueueBudget ─────────────────────────────────────────────────
823
824    #[test]
825    fn queue_total_max() {
826        let qb = QueueBudget {
827            max_reshape_pending: 10,
828            max_rewrap_pending: 20,
829            max_reflow_pending: 5,
830        };
831        assert_eq!(qb.total_max(), 35);
832    }
833
834    #[test]
835    fn queue_budget_display() {
836        let qb = QueueBudget {
837            max_reshape_pending: 10,
838            max_rewrap_pending: 20,
839            max_reflow_pending: 5,
840        };
841        let s = format!("{qb}");
842        assert!(s.contains("reshape=10"));
843    }
844
845    // ── TierBudget ──────────────────────────────────────────────────
846
847    #[test]
848    fn tier_budget_display() {
849        let ladder = TierLadder::default_60fps();
850        let s = format!("{}", ladder.budget(LayoutTier::Fast));
851        assert!(s.contains("[fast]"));
852        assert!(s.contains("4000µs"));
853    }
854
855    // ── TierFeatures ────────────────────────────────────────────────
856
857    #[test]
858    fn emergency_features_minimal() {
859        let ladder = TierLadder::default_60fps();
860        let f = ladder.features_for(LayoutTier::Emergency);
861        assert!(!f.shaped_text);
862        assert!(!f.optimal_breaking);
863        assert!(!f.justification);
864        assert!(!f.hyphenation);
865        assert!(f.terminal_fallback);
866        assert!(f.width_cache);
867        assert!(f.incremental_diff);
868    }
869
870    #[test]
871    fn fast_features() {
872        let ladder = TierLadder::default_60fps();
873        let f = ladder.features_for(LayoutTier::Fast);
874        assert!(!f.shaped_text);
875        assert!(!f.optimal_breaking);
876        assert!(f.terminal_fallback);
877        assert!(f.width_cache);
878    }
879
880    #[test]
881    fn balanced_features() {
882        let ladder = TierLadder::default_60fps();
883        let f = ladder.features_for(LayoutTier::Balanced);
884        assert!(f.shaped_text);
885        assert!(f.optimal_breaking);
886        assert!(f.shaping_cache);
887        assert!(f.subcell_spacing);
888        assert!(!f.hyphenation);
889        assert!(!f.justification);
890    }
891
892    #[test]
893    fn quality_features_all_on() {
894        let ladder = TierLadder::default_60fps();
895        let f = ladder.features_for(LayoutTier::Quality);
896        assert!(f.shaped_text);
897        assert!(f.optimal_breaking);
898        assert!(f.hyphenation);
899        assert!(f.justification);
900        assert!(f.tracking);
901        assert!(f.baseline_grid);
902        assert!(f.first_line_indent);
903    }
904
905    #[test]
906    fn feature_active_list() {
907        let ladder = TierLadder::default_60fps();
908        let list = ladder.features_for(LayoutTier::Emergency).active_list();
909        assert!(list.contains(&"terminal-fallback"));
910        assert!(list.contains(&"width-cache"));
911        assert!(list.contains(&"incremental-diff"));
912        assert_eq!(list.len(), 3);
913    }
914
915    #[test]
916    fn features_display() {
917        let ladder = TierLadder::default_60fps();
918        let s = format!("{}", ladder.features_for(LayoutTier::Quality));
919        assert!(s.contains("[quality]"));
920        assert!(s.contains("justification"));
921    }
922
923    // ── TierLadder ──────────────────────────────────────────────────
924
925    #[test]
926    fn default_ladder_budgets_are_consistent() {
927        let ladder = TierLadder::default_60fps();
928        let issues = ladder.check_budget_consistency();
929        assert!(issues.is_empty(), "Budget inconsistencies: {issues:?}");
930    }
931
932    #[test]
933    fn default_ladder_budgets_monotonically_increase() {
934        let ladder = TierLadder::default_60fps();
935        let issues = ladder.check_budget_ordering();
936        assert!(issues.is_empty(), "Budget ordering violations: {issues:?}");
937    }
938
939    #[test]
940    fn default_ladder_features_are_monotonic() {
941        let ladder = TierLadder::default_60fps();
942        let violations = ladder.check_monotonicity();
943        assert!(
944            violations.is_empty(),
945            "Feature monotonicity violations: {violations:?}"
946        );
947    }
948
949    #[test]
950    fn ladder_budget_lookup() {
951        let ladder = TierLadder::default_60fps();
952        assert_eq!(ladder.budget(LayoutTier::Emergency).frame.total_us, 2_000);
953        assert_eq!(ladder.budget(LayoutTier::Fast).frame.total_us, 4_000);
954        assert_eq!(ladder.budget(LayoutTier::Balanced).frame.total_us, 8_000);
955        assert_eq!(ladder.budget(LayoutTier::Quality).frame.total_us, 16_000);
956    }
957
958    #[test]
959    fn ladder_display() {
960        let ladder = TierLadder::default_60fps();
961        let s = format!("{ladder}");
962        assert!(s.contains("[emergency]"));
963        assert!(s.contains("[fast]"));
964        assert!(s.contains("[balanced]"));
965        assert!(s.contains("[quality]"));
966    }
967
968    #[test]
969    fn default_trait() {
970        let ladder = TierLadder::default();
971        assert_eq!(ladder.budget(LayoutTier::Fast).frame.total_us, 4_000);
972    }
973
974    // ── SafetyInvariant ─────────────────────────────────────────────
975
976    #[test]
977    fn all_invariants_listed() {
978        assert_eq!(SafetyInvariant::ALL.len(), 8);
979    }
980
981    #[test]
982    fn invariant_display() {
983        assert_eq!(
984            format!("{}", SafetyInvariant::NoContentLoss),
985            "no-content-loss"
986        );
987        assert_eq!(
988            format!("{}", SafetyInvariant::WideCharWidth),
989            "wide-char-width"
990        );
991        assert_eq!(
992            format!("{}", SafetyInvariant::GreedyWrapFallback),
993            "greedy-wrap-fallback"
994        );
995    }
996
997    #[test]
998    fn invariants_cover_key_concerns() {
999        let all = SafetyInvariant::ALL;
1000        assert!(all.contains(&SafetyInvariant::NoContentLoss));
1001        assert!(all.contains(&SafetyInvariant::WideCharWidth));
1002        assert!(all.contains(&SafetyInvariant::BufferSizeMatch));
1003        assert!(all.contains(&SafetyInvariant::DiffIdempotence));
1004        assert!(all.contains(&SafetyInvariant::WidthDeterminism));
1005    }
1006
1007    // ── Integration: budget fits within 60fps ───────────────────────
1008
1009    #[test]
1010    fn all_budgets_within_60fps() {
1011        let frame_budget_60fps = FrameBudget::from_fps(60);
1012        let ladder = TierLadder::default_60fps();
1013        for b in &ladder.budgets {
1014            assert!(
1015                b.frame.total_us <= frame_budget_60fps,
1016                "{} budget {}µs exceeds 60fps frame ({}µs)",
1017                b.tier,
1018                b.frame.total_us,
1019                frame_budget_60fps
1020            );
1021        }
1022    }
1023
1024    #[test]
1025    fn emergency_queue_disables_reshape() {
1026        let ladder = TierLadder::default_60fps();
1027        assert_eq!(
1028            ladder
1029                .budget(LayoutTier::Emergency)
1030                .queue
1031                .max_reshape_pending,
1032            0
1033        );
1034    }
1035
1036    #[test]
1037    fn quality_has_largest_caches() {
1038        let ladder = TierLadder::default_60fps();
1039        let e = &ladder.budget(LayoutTier::Emergency).memory;
1040        let q = &ladder.budget(LayoutTier::Quality).memory;
1041        assert!(q.width_cache_entries > e.width_cache_entries);
1042        assert!(q.shaping_cache_entries > e.shaping_cache_entries);
1043    }
1044}