Skip to main content

ftui_layout/
cache.rs

1//! Layout cache for memoizing layout computation results.
2//!
3//! This module provides [`LayoutCache`] which caches the `Vec<Rect>` results from
4//! layout computations to avoid redundant constraint solving during rendering.
5//!
6//! # Overview
7//!
8//! Layout computation (constraint solving, flex distribution) can be expensive for
9//! complex nested layouts. During a single frame, the same layout may be queried
10//! multiple times with identical parameters. The cache eliminates this redundancy.
11//!
12//! # Usage
13//!
14//! ```ignore
15//! use ftui_layout::{Flex, Constraint, LayoutCache, LayoutCacheKey, Direction};
16//! use ftui_core::geometry::Rect;
17//!
18//! let mut cache = LayoutCache::new(64);
19//!
20//! let flex = Flex::horizontal()
21//!     .constraints([Constraint::Percentage(50.0), Constraint::Fill]);
22//!
23//! let area = Rect::new(0, 0, 80, 24);
24//!
25//! // First call computes and caches
26//! let rects = flex.split_cached(area, &mut cache);
27//!
28//! // Second call returns cached result
29//! let cached = flex.split_cached(area, &mut cache);
30//! ```
31//!
32//! # Invalidation
33//!
34//! ## Generation-Based (Primary)
35//!
36//! Call [`LayoutCache::invalidate_all()`] after any state change affecting layouts:
37//!
38//! ```ignore
39//! match msg {
40//!     Msg::DataChanged(_) => {
41//!         self.layout_cache.invalidate_all();
42//!     }
43//!     Msg::Resize(_) => {
44//!         // Area is part of cache key, no invalidation needed!
45//!     }
46//! }
47//! ```
48//!
49//! # Cache Eviction
50//!
51//! The cache uses LRU (Least Recently Used) eviction when at capacity.
52
53use std::hash::{Hash, Hasher};
54
55use ftui_core::geometry::Rect;
56use rustc_hash::{FxHashMap, FxHasher};
57
58use crate::{Constraint, Direction, LayoutSizeHint};
59
60/// Key for layout cache lookups.
61///
62/// Includes all parameters that affect layout computation:
63/// - The available area (stored as components for Hash)
64/// - A fingerprint of all constraints
65/// - The layout direction
66/// - Optionally, a fingerprint of intrinsic size hints
67#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
68pub struct LayoutCacheKey {
69    /// Area x-coordinate.
70    pub area_x: u16,
71    /// Area y-coordinate.
72    pub area_y: u16,
73    /// Area width.
74    pub area_width: u16,
75    /// Area height.
76    pub area_height: u16,
77    /// Hash fingerprint of constraints.
78    pub constraints_hash: u64,
79    /// Layout direction.
80    pub direction: Direction,
81    /// Hash fingerprint of intrinsic sizes (if using FitContent).
82    pub intrinsics_hash: Option<u64>,
83}
84
85impl LayoutCacheKey {
86    /// Create a new cache key from layout parameters.
87    ///
88    /// # Arguments
89    ///
90    /// * `area` - The available rectangle for layout
91    /// * `constraints` - The constraint list
92    /// * `direction` - Horizontal or Vertical layout
93    /// * `intrinsics` - Optional size hints for FitContent constraints
94    pub fn new(
95        area: Rect,
96        constraints: &[Constraint],
97        direction: Direction,
98        intrinsics: Option<&[LayoutSizeHint]>,
99    ) -> Self {
100        Self {
101            area_x: area.x,
102            area_y: area.y,
103            area_width: area.width,
104            area_height: area.height,
105            constraints_hash: Self::hash_constraints(constraints),
106            direction,
107            intrinsics_hash: intrinsics.map(Self::hash_intrinsics),
108        }
109    }
110
111    /// Reconstruct the area Rect from cached components.
112    #[inline]
113    pub fn area(&self) -> Rect {
114        Rect::new(self.area_x, self.area_y, self.area_width, self.area_height)
115    }
116
117    /// Hash a slice of constraints.
118    fn hash_constraints(constraints: &[Constraint]) -> u64 {
119        let mut hasher = FxHasher::default();
120        for c in constraints {
121            // Hash each constraint's discriminant and value
122            std::mem::discriminant(c).hash(&mut hasher);
123            match c {
124                Constraint::Fixed(v) => v.hash(&mut hasher),
125                Constraint::Percentage(p) => p.to_bits().hash(&mut hasher),
126                Constraint::Min(v) => v.hash(&mut hasher),
127                Constraint::Max(v) => v.hash(&mut hasher),
128                Constraint::Ratio(n, d) => {
129                    fn gcd(mut a: u32, mut b: u32) -> u32 {
130                        while b != 0 {
131                            let t = b;
132                            b = a % b;
133                            a = t;
134                        }
135                        a
136                    }
137                    let divisor = gcd(*n, *d);
138                    if let (Some(n_div), Some(d_div)) =
139                        (n.checked_div(divisor), d.checked_div(divisor))
140                    {
141                        n_div.hash(&mut hasher);
142                        d_div.hash(&mut hasher);
143                    } else {
144                        n.hash(&mut hasher);
145                        d.hash(&mut hasher);
146                    }
147                }
148                Constraint::Fill => {}
149                Constraint::FitContent => {}
150                Constraint::FitContentBounded { min, max } => {
151                    min.hash(&mut hasher);
152                    max.hash(&mut hasher);
153                }
154                Constraint::FitMin => {}
155            }
156        }
157        hasher.finish()
158    }
159
160    /// Hash a slice of intrinsic size hints.
161    fn hash_intrinsics(intrinsics: &[LayoutSizeHint]) -> u64 {
162        let mut hasher = FxHasher::default();
163        for hint in intrinsics {
164            hint.min.hash(&mut hasher);
165            hint.preferred.hash(&mut hasher);
166            hint.max.hash(&mut hasher);
167        }
168        hasher.finish()
169    }
170}
171
172/// Cached layout result with metadata for eviction.
173#[derive(Clone, Debug)]
174struct CachedLayoutEntry {
175    /// The cached layout rectangles.
176    chunks: Vec<Rect>,
177    /// Generation when this entry was created/updated.
178    generation: u64,
179    /// Access count for LRU eviction.
180    access_count: u32,
181}
182
183/// Statistics about layout cache performance.
184#[derive(Debug, Clone, Default)]
185pub struct LayoutCacheStats {
186    /// Number of entries currently in the cache.
187    pub entries: usize,
188    /// Total cache hits since creation or last reset.
189    pub hits: u64,
190    /// Total cache misses since creation or last reset.
191    pub misses: u64,
192    /// Hit rate as a fraction (0.0 to 1.0).
193    pub hit_rate: f64,
194}
195
196/// Cache for layout computation results.
197///
198/// Stores `Vec<Rect>` results keyed by [`LayoutCacheKey`] to avoid redundant
199/// constraint solving during rendering.
200///
201/// # Capacity
202///
203/// The cache has a fixed maximum capacity. When full, the least recently used
204/// entries are evicted to make room for new ones.
205///
206/// # Generation-Based Invalidation
207///
208/// Each entry is tagged with a generation number. Calling [`invalidate_all()`]
209/// bumps the generation, making all existing entries stale.
210///
211/// [`invalidate_all()`]: LayoutCache::invalidate_all
212#[derive(Debug)]
213pub struct LayoutCache {
214    entries: FxHashMap<LayoutCacheKey, CachedLayoutEntry>,
215    generation: u64,
216    max_entries: usize,
217    hits: u64,
218    misses: u64,
219}
220
221impl LayoutCache {
222    /// Create a new cache with the specified maximum capacity.
223    ///
224    /// # Arguments
225    ///
226    /// * `max_entries` - Maximum number of entries before LRU eviction occurs.
227    ///   A typical value is 64-256 for most UIs.
228    ///
229    /// # Example
230    ///
231    /// ```
232    /// use ftui_layout::LayoutCache;
233    /// let cache = LayoutCache::new(64);
234    /// ```
235    #[inline]
236    pub fn new(max_entries: usize) -> Self {
237        Self {
238            entries: FxHashMap::with_capacity_and_hasher(max_entries, Default::default()),
239            generation: 0,
240            max_entries,
241            hits: 0,
242            misses: 0,
243        }
244    }
245
246    /// Get cached layout or compute and cache a new one.
247    ///
248    /// If a valid (same generation) cache entry exists for the given key,
249    /// returns a clone of it. Otherwise, calls the `compute` closure,
250    /// caches the result, and returns it.
251    ///
252    /// # Arguments
253    ///
254    /// * `key` - The cache key identifying this layout computation
255    /// * `compute` - Closure to compute the layout if not cached
256    ///
257    /// # Example
258    ///
259    /// ```ignore
260    /// let key = LayoutCacheKey::new(area, &constraints, Direction::Horizontal, None);
261    /// let rects = cache.get_or_compute(key, || flex.split(area));
262    /// ```
263    pub fn get_or_compute<F>(&mut self, key: LayoutCacheKey, compute: F) -> Vec<Rect>
264    where
265        F: FnOnce() -> Vec<Rect>,
266    {
267        // Check for existing valid entry
268        if let Some(entry) = self.entries.get_mut(&key)
269            && entry.generation == self.generation
270        {
271            self.hits += 1;
272            entry.access_count = entry.access_count.saturating_add(1);
273            return entry.chunks.clone();
274        }
275
276        // Cache miss - compute the value
277        self.misses += 1;
278        let chunks = compute();
279
280        // Evict if at capacity
281        if self.entries.len() >= self.max_entries {
282            self.evict_lru();
283        }
284
285        // Insert new entry
286        self.entries.insert(
287            key,
288            CachedLayoutEntry {
289                chunks: chunks.clone(),
290                generation: self.generation,
291                access_count: 1,
292            },
293        );
294
295        chunks
296    }
297
298    /// Invalidate all entries by bumping the generation.
299    ///
300    /// Existing entries become stale and will be recomputed on next access.
301    /// This is an O(1) operation - entries are not immediately removed.
302    ///
303    /// # When to Call
304    ///
305    /// Call this after any state change that affects layout:
306    /// - Model data changes that affect widget content
307    /// - Theme/font changes that affect sizing
308    ///
309    /// # Note
310    ///
311    /// Resize events don't require invalidation because the area
312    /// is part of the cache key.
313    #[inline]
314    pub fn invalidate_all(&mut self) {
315        self.generation = self.generation.wrapping_add(1);
316    }
317
318    /// Get current cache statistics.
319    ///
320    /// Returns hit/miss counts and the current hit rate.
321    pub fn stats(&self) -> LayoutCacheStats {
322        let total = self.hits + self.misses;
323        LayoutCacheStats {
324            entries: self.entries.len(),
325            hits: self.hits,
326            misses: self.misses,
327            hit_rate: if total > 0 {
328                self.hits as f64 / total as f64
329            } else {
330                0.0
331            },
332        }
333    }
334
335    /// Reset statistics counters to zero.
336    ///
337    /// Useful for measuring hit rate over a specific period (e.g., per frame).
338    #[inline]
339    pub fn reset_stats(&mut self) {
340        self.hits = 0;
341        self.misses = 0;
342    }
343
344    /// Clear all entries from the cache.
345    ///
346    /// Unlike [`invalidate_all()`], this immediately frees memory.
347    ///
348    /// [`invalidate_all()`]: LayoutCache::invalidate_all
349    #[inline]
350    pub fn clear(&mut self) {
351        self.entries.clear();
352        self.generation = self.generation.wrapping_add(1);
353    }
354
355    /// Returns the current number of entries in the cache.
356    #[inline]
357    pub fn len(&self) -> usize {
358        self.entries.len()
359    }
360
361    /// Returns true if the cache is empty.
362    #[inline]
363    pub fn is_empty(&self) -> bool {
364        self.entries.is_empty()
365    }
366
367    /// Returns the maximum capacity of the cache.
368    #[inline]
369    pub fn capacity(&self) -> usize {
370        self.max_entries
371    }
372
373    /// Evict the least recently used entry.
374    fn evict_lru(&mut self) {
375        if let Some(key) = self
376            .entries
377            .iter()
378            .min_by_key(|(_, e)| e.access_count)
379            .map(|(k, _)| *k)
380        {
381            self.entries.remove(&key);
382        }
383    }
384}
385
386impl Default for LayoutCache {
387    /// Creates a cache with default capacity of 64 entries.
388    fn default() -> Self {
389        Self::new(64)
390    }
391}
392
393// ---------------------------------------------------------------------------
394// S3-FIFO Layout Cache (bd-l6yba.3)
395// ---------------------------------------------------------------------------
396
397/// Layout cache backed by S3-FIFO eviction.
398///
399/// Drop-in replacement for [`LayoutCache`] that uses the scan-resistant
400/// S3-FIFO eviction policy instead of the HashMap-based LRU. This protects
401/// frequently-accessed layout computations from being evicted by transient
402/// layouts (e.g. popup menus or tooltips that appear once).
403///
404/// Supports the same generation-based invalidation as [`LayoutCache`].
405#[derive(Debug)]
406pub struct S3FifoLayoutCache {
407    cache: ftui_core::s3_fifo::S3Fifo<LayoutCacheKey, CachedLayoutEntry>,
408    generation: u64,
409    max_entries: usize,
410    hits: u64,
411    misses: u64,
412}
413
414impl S3FifoLayoutCache {
415    /// Create a new S3-FIFO layout cache with the given capacity.
416    #[inline]
417    pub fn new(max_entries: usize) -> Self {
418        Self {
419            cache: ftui_core::s3_fifo::S3Fifo::new(max_entries.max(2)),
420            generation: 0,
421            max_entries: max_entries.max(2),
422            hits: 0,
423            misses: 0,
424        }
425    }
426
427    /// Get cached layout or compute and cache a new one.
428    ///
429    /// Same semantics as [`LayoutCache::get_or_compute`]: entries from
430    /// a previous generation are treated as misses.
431    pub fn get_or_compute<F>(&mut self, key: LayoutCacheKey, compute: F) -> Vec<Rect>
432    where
433        F: FnOnce() -> Vec<Rect>,
434    {
435        if let Some(entry) = self.cache.get(&key) {
436            if entry.generation == self.generation {
437                self.hits += 1;
438                return entry.chunks.clone();
439            }
440            // Stale entry (old generation) — remove and recompute.
441            self.cache.remove(&key);
442        }
443
444        self.misses += 1;
445        let chunks = compute();
446
447        self.cache.insert(
448            key,
449            CachedLayoutEntry {
450                chunks: chunks.clone(),
451                generation: self.generation,
452                access_count: 1,
453            },
454        );
455
456        chunks
457    }
458
459    /// Invalidate all entries by bumping the generation (O(1)).
460    #[inline]
461    pub fn invalidate_all(&mut self) {
462        self.generation = self.generation.wrapping_add(1);
463    }
464
465    /// Get current cache statistics.
466    pub fn stats(&self) -> LayoutCacheStats {
467        let total = self.hits + self.misses;
468        LayoutCacheStats {
469            entries: self.cache.len(),
470            hits: self.hits,
471            misses: self.misses,
472            hit_rate: if total > 0 {
473                self.hits as f64 / total as f64
474            } else {
475                0.0
476            },
477        }
478    }
479
480    /// Reset statistics counters.
481    #[inline]
482    pub fn reset_stats(&mut self) {
483        self.hits = 0;
484        self.misses = 0;
485    }
486
487    /// Clear all entries.
488    #[inline]
489    pub fn clear(&mut self) {
490        self.cache.clear();
491        self.generation = self.generation.wrapping_add(1);
492    }
493
494    /// Current number of entries.
495    #[inline]
496    pub fn len(&self) -> usize {
497        self.cache.len()
498    }
499
500    /// Whether the cache is empty.
501    #[inline]
502    pub fn is_empty(&self) -> bool {
503        self.cache.is_empty()
504    }
505
506    /// Maximum capacity.
507    #[inline]
508    pub fn capacity(&self) -> usize {
509        self.max_entries
510    }
511}
512
513impl Default for S3FifoLayoutCache {
514    fn default() -> Self {
515        Self::new(64)
516    }
517}
518
519// ---------------------------------------------------------------------------
520// Coherence Cache: Temporal Stability for Layout Rounding
521// ---------------------------------------------------------------------------
522
523/// Identity for a layout computation, used as key in the coherence cache.
524///
525/// This is a subset of [`LayoutCacheKey`] that identifies a *layout slot*
526/// independently of the available area. Two computations with the same
527/// `CoherenceId` but different area sizes represent the same logical layout
528/// being re-rendered at a different terminal size.
529#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
530pub struct CoherenceId {
531    /// Hash fingerprint of constraints.
532    pub constraints_hash: u64,
533    /// Layout direction.
534    pub direction: Direction,
535}
536
537impl CoherenceId {
538    /// Create a coherence ID from layout parameters.
539    pub fn new(constraints: &[Constraint], direction: Direction) -> Self {
540        Self {
541            constraints_hash: LayoutCacheKey::hash_constraints(constraints),
542            direction,
543        }
544    }
545
546    /// Create a coherence ID from an existing cache key.
547    pub fn from_cache_key(key: &LayoutCacheKey) -> Self {
548        Self {
549            constraints_hash: key.constraints_hash,
550            direction: key.direction,
551        }
552    }
553}
554
555/// Stores previous layout allocations for temporal coherence.
556///
557/// When terminal size changes, the layout solver re-computes positions from
558/// scratch. Without coherence, rounding tie-breaks can cause widgets to
559/// "jump" between frames even when the total size change is small.
560///
561/// The `CoherenceCache` feeds previous allocations to
562/// [`round_layout_stable`](crate::round_layout_stable) so that tie-breaking
563/// favours keeping elements where they were.
564///
565/// # Usage
566///
567/// ```ignore
568/// use ftui_layout::{CoherenceCache, CoherenceId, round_layout_stable, Constraint, Direction};
569///
570/// let mut coherence = CoherenceCache::new(64);
571///
572/// let id = CoherenceId::new(&constraints, Direction::Horizontal);
573/// let prev = coherence.get(&id);
574/// let alloc = round_layout_stable(&targets, total, prev);
575/// coherence.store(id, alloc.clone());
576/// ```
577///
578/// # Eviction
579///
580/// Entries are evicted on a least-recently-stored basis when the cache
581/// reaches capacity. The cache does not grow unboundedly.
582#[derive(Debug)]
583pub struct CoherenceCache {
584    entries: FxHashMap<CoherenceId, CoherenceEntry>,
585    max_entries: usize,
586    /// Monotonic counter for LRU eviction.
587    tick: u64,
588}
589
590#[derive(Debug, Clone)]
591struct CoherenceEntry {
592    /// Previous allocation sizes.
593    allocation: Vec<u16>,
594    /// Tick when this entry was last stored.
595    last_stored: u64,
596}
597
598impl CoherenceCache {
599    /// Create a new coherence cache with the specified capacity.
600    pub fn new(max_entries: usize) -> Self {
601        Self {
602            entries: FxHashMap::with_capacity_and_hasher(max_entries.min(256), Default::default()),
603            max_entries,
604            tick: 0,
605        }
606    }
607
608    /// Retrieve the previous allocation for a layout, if available.
609    ///
610    /// Returns `Some(allocation)` suitable for passing directly to
611    /// [`round_layout_stable`](crate::round_layout_stable).
612    #[inline]
613    pub fn get(&self, id: &CoherenceId) -> Option<Vec<u16>> {
614        self.entries.get(id).map(|e| e.allocation.clone())
615    }
616
617    /// Store a layout allocation for future coherence lookups.
618    ///
619    /// If the cache is at capacity, evicts the oldest entry.
620    pub fn store(&mut self, id: CoherenceId, allocation: Vec<u16>) {
621        self.tick = self.tick.wrapping_add(1);
622
623        if self.entries.len() >= self.max_entries && !self.entries.contains_key(&id) {
624            self.evict_oldest();
625        }
626
627        self.entries.insert(
628            id,
629            CoherenceEntry {
630                allocation,
631                last_stored: self.tick,
632            },
633        );
634    }
635
636    /// Clear all stored allocations.
637    #[inline]
638    pub fn clear(&mut self) {
639        self.entries.clear();
640    }
641
642    /// Number of entries in the cache.
643    #[inline]
644    pub fn len(&self) -> usize {
645        self.entries.len()
646    }
647
648    /// Returns true if the cache is empty.
649    #[inline]
650    pub fn is_empty(&self) -> bool {
651        self.entries.is_empty()
652    }
653
654    /// Compute displacement between a new allocation and the previously stored one.
655    ///
656    /// Returns `(sum_displacement, max_displacement)` in cells.
657    /// If no previous allocation exists for the ID, returns `(0, 0)`.
658    pub fn displacement(&self, id: &CoherenceId, new_alloc: &[u16]) -> (u64, u32) {
659        match self.entries.get(id) {
660            Some(entry) => {
661                let prev = &entry.allocation;
662                let len = prev.len().min(new_alloc.len());
663                let mut sum: u64 = 0;
664                let mut max: u32 = 0;
665                for i in 0..len {
666                    let d = (new_alloc[i] as i32 - prev[i] as i32).unsigned_abs();
667                    sum += u64::from(d);
668                    max = max.max(d);
669                }
670                // If lengths differ, count the extra elements as full displacement
671                for &v in &prev[len..] {
672                    sum += u64::from(v);
673                    max = max.max(u32::from(v));
674                }
675                for &v in &new_alloc[len..] {
676                    sum += u64::from(v);
677                    max = max.max(u32::from(v));
678                }
679                (sum, max)
680            }
681            None => (0, 0),
682        }
683    }
684
685    fn evict_oldest(&mut self) {
686        if let Some(key) = self
687            .entries
688            .iter()
689            .min_by_key(|(_, e)| e.last_stored)
690            .map(|(k, _)| *k)
691        {
692            self.entries.remove(&key);
693        }
694    }
695}
696
697impl Default for CoherenceCache {
698    fn default() -> Self {
699        Self::new(64)
700    }
701}
702
703#[cfg(test)]
704mod tests {
705    use super::*;
706
707    fn make_key(width: u16, height: u16) -> LayoutCacheKey {
708        LayoutCacheKey::new(
709            Rect::new(0, 0, width, height),
710            &[Constraint::Percentage(50.0), Constraint::Fill],
711            Direction::Horizontal,
712            None,
713        )
714    }
715
716    fn should_not_call(label: &str) -> Vec<Rect> {
717        unreachable!("{label}");
718    }
719
720    // --- LayoutCacheKey tests ---
721
722    #[test]
723    fn same_params_produce_same_key() {
724        let k1 = make_key(80, 24);
725        let k2 = make_key(80, 24);
726        assert_eq!(k1, k2);
727    }
728
729    #[test]
730    fn different_area_different_key() {
731        let k1 = make_key(80, 24);
732        let k2 = make_key(120, 40);
733        assert_ne!(k1, k2);
734    }
735
736    #[test]
737    fn different_constraints_different_key() {
738        let k1 = LayoutCacheKey::new(
739            Rect::new(0, 0, 80, 24),
740            &[Constraint::Fixed(20)],
741            Direction::Horizontal,
742            None,
743        );
744        let k2 = LayoutCacheKey::new(
745            Rect::new(0, 0, 80, 24),
746            &[Constraint::Fixed(30)],
747            Direction::Horizontal,
748            None,
749        );
750        assert_ne!(k1, k2);
751    }
752
753    #[test]
754    fn different_direction_different_key() {
755        let k1 = LayoutCacheKey::new(
756            Rect::new(0, 0, 80, 24),
757            &[Constraint::Fill],
758            Direction::Horizontal,
759            None,
760        );
761        let k2 = LayoutCacheKey::new(
762            Rect::new(0, 0, 80, 24),
763            &[Constraint::Fill],
764            Direction::Vertical,
765            None,
766        );
767        assert_ne!(k1, k2);
768    }
769
770    #[test]
771    fn different_intrinsics_different_key() {
772        let hints1 = [LayoutSizeHint {
773            min: 10,
774            preferred: 20,
775            max: None,
776        }];
777        let hints2 = [LayoutSizeHint {
778            min: 10,
779            preferred: 30,
780            max: None,
781        }];
782
783        let k1 = LayoutCacheKey::new(
784            Rect::new(0, 0, 80, 24),
785            &[Constraint::FitContent],
786            Direction::Horizontal,
787            Some(&hints1),
788        );
789        let k2 = LayoutCacheKey::new(
790            Rect::new(0, 0, 80, 24),
791            &[Constraint::FitContent],
792            Direction::Horizontal,
793            Some(&hints2),
794        );
795        assert_ne!(k1, k2);
796    }
797
798    // --- LayoutCache tests ---
799
800    #[test]
801    fn cache_returns_same_result() {
802        let mut cache = LayoutCache::new(100);
803        let key = make_key(80, 24);
804
805        let mut compute_count = 0;
806        let compute = || {
807            compute_count += 1;
808            vec![Rect::new(0, 0, 40, 24), Rect::new(40, 0, 40, 24)]
809        };
810
811        let r1 = cache.get_or_compute(key, compute);
812        let r2 = cache.get_or_compute(key, || should_not_call("should not call"));
813
814        assert_eq!(r1, r2);
815        assert_eq!(compute_count, 1);
816    }
817
818    #[test]
819    fn different_area_is_cache_miss() {
820        let mut cache = LayoutCache::new(100);
821
822        let mut compute_count = 0;
823        let mut compute = || {
824            compute_count += 1;
825            vec![Rect::default()]
826        };
827
828        let k1 = make_key(80, 24);
829        let k2 = make_key(120, 40);
830
831        cache.get_or_compute(k1, &mut compute);
832        cache.get_or_compute(k2, &mut compute);
833
834        assert_eq!(compute_count, 2);
835    }
836
837    #[test]
838    fn invalidation_clears_cache() {
839        let mut cache = LayoutCache::new(100);
840        let key = make_key(80, 24);
841
842        let mut compute_count = 0;
843        let mut compute = || {
844            compute_count += 1;
845            vec![]
846        };
847
848        cache.get_or_compute(key, &mut compute);
849        cache.invalidate_all();
850        cache.get_or_compute(key, &mut compute);
851
852        assert_eq!(compute_count, 2);
853    }
854
855    #[test]
856    fn lru_eviction_works() {
857        let mut cache = LayoutCache::new(2);
858
859        let k1 = make_key(10, 10);
860        let k2 = make_key(20, 20);
861        let k3 = make_key(30, 30);
862
863        // Insert two entries
864        cache.get_or_compute(k1, || vec![Rect::new(0, 0, 10, 10)]);
865        cache.get_or_compute(k2, || vec![Rect::new(0, 0, 20, 20)]);
866
867        // Access k1 again (increases access count)
868        cache.get_or_compute(k1, || should_not_call("k1 should hit"));
869
870        // Insert k3, should evict k2 (least accessed)
871        cache.get_or_compute(k3, || vec![Rect::new(0, 0, 30, 30)]);
872
873        assert_eq!(cache.len(), 2);
874
875        // k2 should be evicted
876        let mut was_called = false;
877        cache.get_or_compute(k2, || {
878            was_called = true;
879            vec![]
880        });
881        assert!(was_called, "k2 should have been evicted");
882
883        // k1 should still be cached
884        cache.get_or_compute(k1, || should_not_call("k1 should still be cached"));
885    }
886
887    #[test]
888    fn stats_track_hits_and_misses() {
889        let mut cache = LayoutCache::new(100);
890
891        let k1 = make_key(80, 24);
892        let k2 = make_key(120, 40);
893
894        cache.get_or_compute(k1, Vec::new); // miss
895        cache.get_or_compute(k1, || should_not_call("hit")); // hit
896        cache.get_or_compute(k2, Vec::new); // miss
897
898        let stats = cache.stats();
899        assert_eq!(stats.hits, 1);
900        assert_eq!(stats.misses, 2);
901        assert!((stats.hit_rate - 1.0 / 3.0).abs() < 0.01);
902    }
903
904    #[test]
905    fn reset_stats_clears_counters() {
906        let mut cache = LayoutCache::new(100);
907        let key = make_key(80, 24);
908
909        cache.get_or_compute(key, Vec::new);
910        cache.get_or_compute(key, || should_not_call("hit"));
911
912        let stats = cache.stats();
913        assert_eq!(stats.hits, 1);
914        assert_eq!(stats.misses, 1);
915
916        cache.reset_stats();
917
918        let stats = cache.stats();
919        assert_eq!(stats.hits, 0);
920        assert_eq!(stats.misses, 0);
921        assert_eq!(stats.hit_rate, 0.0);
922    }
923
924    #[test]
925    fn clear_removes_all_entries() {
926        let mut cache = LayoutCache::new(100);
927
928        cache.get_or_compute(make_key(80, 24), Vec::new);
929        cache.get_or_compute(make_key(120, 40), Vec::new);
930
931        assert_eq!(cache.len(), 2);
932
933        cache.clear();
934
935        assert_eq!(cache.len(), 0);
936        assert!(cache.is_empty());
937
938        // All entries should miss now
939        let mut was_called = false;
940        cache.get_or_compute(make_key(80, 24), || {
941            was_called = true;
942            vec![]
943        });
944        assert!(was_called);
945    }
946
947    #[test]
948    fn default_capacity_is_64() {
949        let cache = LayoutCache::default();
950        assert_eq!(cache.capacity(), 64);
951    }
952
953    #[test]
954    fn generation_wraps_around() {
955        let mut cache = LayoutCache::new(100);
956        cache.generation = u64::MAX;
957        cache.invalidate_all();
958        assert_eq!(cache.generation, 0);
959    }
960
961    // --- Constraint hashing tests ---
962
963    #[test]
964    fn constraint_hash_is_stable() {
965        let constraints = [
966            Constraint::Fixed(20),
967            Constraint::Percentage(50.0),
968            Constraint::Min(10),
969        ];
970
971        let h1 = LayoutCacheKey::hash_constraints(&constraints);
972        let h2 = LayoutCacheKey::hash_constraints(&constraints);
973
974        assert_eq!(h1, h2);
975    }
976
977    #[test]
978    fn different_constraint_values_different_hash() {
979        let c1 = [Constraint::Fixed(20)];
980        let c2 = [Constraint::Fixed(30)];
981
982        let h1 = LayoutCacheKey::hash_constraints(&c1);
983        let h2 = LayoutCacheKey::hash_constraints(&c2);
984
985        assert_ne!(h1, h2);
986    }
987
988    #[test]
989    fn different_constraint_types_different_hash() {
990        let c1 = [Constraint::Fixed(20)];
991        let c2 = [Constraint::Min(20)];
992
993        let h1 = LayoutCacheKey::hash_constraints(&c1);
994        let h2 = LayoutCacheKey::hash_constraints(&c2);
995
996        assert_ne!(h1, h2);
997    }
998
999    #[test]
1000    fn fit_content_bounded_values_in_hash() {
1001        let c1 = [Constraint::FitContentBounded { min: 10, max: 50 }];
1002        let c2 = [Constraint::FitContentBounded { min: 10, max: 60 }];
1003
1004        let h1 = LayoutCacheKey::hash_constraints(&c1);
1005        let h2 = LayoutCacheKey::hash_constraints(&c2);
1006
1007        assert_ne!(h1, h2);
1008    }
1009
1010    // --- Intrinsics hashing tests ---
1011
1012    #[test]
1013    fn intrinsics_hash_is_stable() {
1014        let hints = [
1015            LayoutSizeHint {
1016                min: 10,
1017                preferred: 20,
1018                max: Some(30),
1019            },
1020            LayoutSizeHint {
1021                min: 5,
1022                preferred: 15,
1023                max: None,
1024            },
1025        ];
1026
1027        let h1 = LayoutCacheKey::hash_intrinsics(&hints);
1028        let h2 = LayoutCacheKey::hash_intrinsics(&hints);
1029
1030        assert_eq!(h1, h2);
1031    }
1032
1033    #[test]
1034    fn different_intrinsics_different_hash() {
1035        let h1 = [LayoutSizeHint {
1036            min: 10,
1037            preferred: 20,
1038            max: None,
1039        }];
1040        let h2 = [LayoutSizeHint {
1041            min: 10,
1042            preferred: 25,
1043            max: None,
1044        }];
1045
1046        let hash1 = LayoutCacheKey::hash_intrinsics(&h1);
1047        let hash2 = LayoutCacheKey::hash_intrinsics(&h2);
1048
1049        assert_ne!(hash1, hash2);
1050    }
1051
1052    // --- Property-like tests ---
1053
1054    #[test]
1055    fn cache_is_deterministic() {
1056        let mut cache1 = LayoutCache::new(100);
1057        let mut cache2 = LayoutCache::new(100);
1058
1059        for i in 0..10u16 {
1060            let key = make_key(i * 10, i * 5);
1061            let result = vec![Rect::new(0, 0, i, i)];
1062
1063            cache1.get_or_compute(key, || result.clone());
1064            cache2.get_or_compute(key, || result);
1065        }
1066
1067        assert_eq!(cache1.stats().entries, cache2.stats().entries);
1068        assert_eq!(cache1.stats().misses, cache2.stats().misses);
1069    }
1070
1071    #[test]
1072    fn hit_count_increments_on_each_access() {
1073        let mut cache = LayoutCache::new(100);
1074        let key = make_key(80, 24);
1075
1076        // First access is a miss
1077        cache.get_or_compute(key, Vec::new);
1078
1079        // Subsequent accesses are hits
1080        for _ in 0..5 {
1081            cache.get_or_compute(key, || should_not_call("should hit"));
1082        }
1083
1084        let stats = cache.stats();
1085        assert_eq!(stats.misses, 1);
1086        assert_eq!(stats.hits, 5);
1087    }
1088
1089    // -----------------------------------------------------------------------
1090    // Coherence Cache Tests (bd-4kq0.4.2)
1091    // -----------------------------------------------------------------------
1092
1093    fn make_coherence_id(n: u16) -> CoherenceId {
1094        CoherenceId::new(
1095            &[Constraint::Fixed(n), Constraint::Fill],
1096            Direction::Horizontal,
1097        )
1098    }
1099
1100    #[test]
1101    fn coherence_store_and_get() {
1102        let mut cc = CoherenceCache::new(64);
1103        let id = make_coherence_id(1);
1104
1105        assert!(cc.get(&id).is_none());
1106
1107        cc.store(id, vec![30, 50]);
1108        assert_eq!(cc.get(&id), Some(vec![30, 50]));
1109    }
1110
1111    #[test]
1112    fn coherence_update_replaces_allocation() {
1113        let mut cc = CoherenceCache::new(64);
1114        let id = make_coherence_id(1);
1115
1116        cc.store(id, vec![30, 50]);
1117        cc.store(id, vec![31, 49]);
1118
1119        assert_eq!(cc.get(&id), Some(vec![31, 49]));
1120        assert_eq!(cc.len(), 1);
1121    }
1122
1123    #[test]
1124    fn coherence_different_ids_are_separate() {
1125        let mut cc = CoherenceCache::new(64);
1126        let id1 = make_coherence_id(1);
1127        let id2 = make_coherence_id(2);
1128
1129        cc.store(id1, vec![40, 40]);
1130        cc.store(id2, vec![30, 50]);
1131
1132        assert_eq!(cc.get(&id1), Some(vec![40, 40]));
1133        assert_eq!(cc.get(&id2), Some(vec![30, 50]));
1134    }
1135
1136    #[test]
1137    fn coherence_eviction_at_capacity() {
1138        let mut cc = CoherenceCache::new(2);
1139
1140        let id1 = make_coherence_id(1);
1141        let id2 = make_coherence_id(2);
1142        let id3 = make_coherence_id(3);
1143
1144        cc.store(id1, vec![10]);
1145        cc.store(id2, vec![20]);
1146        cc.store(id3, vec![30]);
1147
1148        assert_eq!(cc.len(), 2);
1149        // id1 should be evicted (oldest)
1150        assert!(cc.get(&id1).is_none());
1151        assert_eq!(cc.get(&id2), Some(vec![20]));
1152        assert_eq!(cc.get(&id3), Some(vec![30]));
1153    }
1154
1155    #[test]
1156    fn coherence_clear() {
1157        let mut cc = CoherenceCache::new(64);
1158        let id = make_coherence_id(1);
1159
1160        cc.store(id, vec![10, 20]);
1161        assert_eq!(cc.len(), 1);
1162
1163        cc.clear();
1164        assert!(cc.is_empty());
1165        assert!(cc.get(&id).is_none());
1166    }
1167
1168    #[test]
1169    fn coherence_displacement_with_previous() {
1170        let mut cc = CoherenceCache::new(64);
1171        let id = make_coherence_id(1);
1172
1173        cc.store(id, vec![30, 50]);
1174
1175        // New allocation differs by 2 cells in each slot
1176        let (sum, max) = cc.displacement(&id, &[32, 48]);
1177        assert_eq!(sum, 4); // |32-30| + |48-50| = 2 + 2
1178        assert_eq!(max, 2);
1179    }
1180
1181    #[test]
1182    fn coherence_displacement_without_previous() {
1183        let cc = CoherenceCache::new(64);
1184        let id = make_coherence_id(1);
1185
1186        let (sum, max) = cc.displacement(&id, &[30, 50]);
1187        assert_eq!(sum, 0);
1188        assert_eq!(max, 0);
1189    }
1190
1191    #[test]
1192    fn coherence_displacement_different_lengths() {
1193        let mut cc = CoherenceCache::new(64);
1194        let id = make_coherence_id(1);
1195
1196        cc.store(id, vec![30, 50]);
1197
1198        // New allocation has 3 elements (extra element counted as displacement)
1199        let (sum, max) = cc.displacement(&id, &[30, 50, 10]);
1200        assert_eq!(sum, 10);
1201        assert_eq!(max, 10);
1202    }
1203
1204    #[test]
1205    fn coherence_from_cache_key() {
1206        let key = make_key(80, 24);
1207        let id = CoherenceId::from_cache_key(&key);
1208
1209        // Same constraints + direction should produce same ID regardless of area
1210        let key2 = make_key(120, 40);
1211        let id2 = CoherenceId::from_cache_key(&key2);
1212
1213        assert_eq!(id, id2);
1214    }
1215
1216    // --- unit_cache_reuse ---
1217
1218    #[test]
1219    fn unit_cache_reuse_unchanged_constraints_yield_identical_layout() {
1220        use crate::round_layout_stable;
1221
1222        let mut cc = CoherenceCache::new(64);
1223        let id = make_coherence_id(1);
1224
1225        // First layout at width 80
1226        let targets = [26.67, 26.67, 26.66];
1227        let total = 80;
1228        let alloc1 = round_layout_stable(&targets, total, cc.get(&id));
1229        cc.store(id, alloc1.clone());
1230
1231        // Same constraints, same width → should produce identical result
1232        let alloc2 = round_layout_stable(&targets, total, cc.get(&id));
1233        assert_eq!(alloc1, alloc2, "Same inputs should yield identical layout");
1234    }
1235
1236    // --- e2e_resize_sweep ---
1237
1238    #[test]
1239    fn e2e_resize_sweep_bounded_displacement() {
1240        use crate::round_layout_stable;
1241
1242        let mut cc = CoherenceCache::new(64);
1243        let id = make_coherence_id(1);
1244
1245        // Equal three-way split: targets are total/3 each
1246        // Sweep terminal width from 60 to 120 in steps of 1
1247        let mut max_displacement_ever: u32 = 0;
1248        let mut total_displacement_sum: u64 = 0;
1249        let steps = 61; // 60..=120
1250
1251        for width in 60u16..=120 {
1252            let third = f64::from(width) / 3.0;
1253            let targets = [third, third, third];
1254
1255            let prev = cc.get(&id);
1256            let alloc = round_layout_stable(&targets, width, prev);
1257
1258            let (d_sum, d_max) = cc.displacement(&id, &alloc);
1259            total_displacement_sum += d_sum;
1260            max_displacement_ever = max_displacement_ever.max(d_max);
1261
1262            cc.store(id, alloc);
1263        }
1264
1265        // Each 1-cell width change should cause at most 1 cell of displacement
1266        // in each slot (ideal: only 1 slot changes by 1).
1267        assert!(
1268            max_displacement_ever <= 2,
1269            "Max single-step displacement should be <=2 cells, got {}",
1270            max_displacement_ever
1271        );
1272
1273        // Average displacement per step should be ~1 (one extra cell redistributed)
1274        let avg = total_displacement_sum as f64 / steps as f64;
1275        assert!(
1276            avg < 3.0,
1277            "Average displacement per step should be <3 cells, got {:.2}",
1278            avg
1279        );
1280    }
1281
1282    #[test]
1283    fn e2e_resize_sweep_deterministic() {
1284        use crate::round_layout_stable;
1285
1286        // Two identical sweeps should produce identical displacement logs
1287        let sweep = |seed: u16| -> Vec<(u16, Vec<u16>, u64, u32)> {
1288            let mut cc = CoherenceCache::new(64);
1289            let id = CoherenceId::new(
1290                &[Constraint::Percentage(30.0), Constraint::Fill],
1291                Direction::Horizontal,
1292            );
1293
1294            let mut log = Vec::new();
1295            for width in (40 + seed)..(100 + seed) {
1296                let targets = [f64::from(width) * 0.3, f64::from(width) * 0.7];
1297                let prev = cc.get(&id);
1298                let alloc = round_layout_stable(&targets, width, prev);
1299                let (d_sum, d_max) = cc.displacement(&id, &alloc);
1300                cc.store(id, alloc.clone());
1301                log.push((width, alloc, d_sum, d_max));
1302            }
1303            log
1304        };
1305
1306        let log1 = sweep(0);
1307        let log2 = sweep(0);
1308        assert_eq!(log1, log2, "Identical sweeps should produce identical logs");
1309    }
1310
1311    #[test]
1312    fn default_coherence_cache_capacity_is_64() {
1313        let cc = CoherenceCache::default();
1314        assert_eq!(cc.max_entries, 64);
1315    }
1316
1317    // ── S3-FIFO Layout Cache ────────────────────────────────────────
1318
1319    fn s3_fifo_test_key(x: u16, w: u16) -> LayoutCacheKey {
1320        LayoutCacheKey {
1321            area_x: x,
1322            area_y: 0,
1323            area_width: w,
1324            area_height: 24,
1325            constraints_hash: 42,
1326            direction: Direction::Horizontal,
1327            intrinsics_hash: None,
1328        }
1329    }
1330
1331    #[test]
1332    fn s3fifo_layout_new_is_empty() {
1333        let cache = S3FifoLayoutCache::new(64);
1334        assert!(cache.is_empty());
1335        assert_eq!(cache.len(), 0);
1336        assert_eq!(cache.capacity(), 64);
1337    }
1338
1339    #[test]
1340    fn s3fifo_layout_default_capacity() {
1341        let cache = S3FifoLayoutCache::default();
1342        assert_eq!(cache.capacity(), 64);
1343    }
1344
1345    #[test]
1346    fn s3fifo_layout_get_or_compute_caches() {
1347        let mut cache = S3FifoLayoutCache::new(64);
1348        let key = s3_fifo_test_key(0, 80);
1349        let rects1 = cache.get_or_compute(key, || vec![Rect::new(0, 0, 40, 24)]);
1350        let rects2 = cache.get_or_compute(key, || panic!("should not recompute"));
1351        assert_eq!(rects1, rects2);
1352        let stats = cache.stats();
1353        assert_eq!(stats.hits, 1);
1354        assert_eq!(stats.misses, 1);
1355    }
1356
1357    #[test]
1358    fn s3fifo_layout_generation_invalidation() {
1359        let mut cache = S3FifoLayoutCache::new(64);
1360        let key = s3_fifo_test_key(0, 80);
1361        cache.get_or_compute(key, || vec![Rect::new(0, 0, 40, 24)]);
1362
1363        cache.invalidate_all();
1364
1365        // After invalidation, should recompute
1366        let rects = cache.get_or_compute(key, || vec![Rect::new(0, 0, 80, 24)]);
1367        assert_eq!(rects, vec![Rect::new(0, 0, 80, 24)]);
1368        let stats = cache.stats();
1369        assert_eq!(stats.misses, 2);
1370    }
1371
1372    #[test]
1373    fn s3fifo_layout_clear() {
1374        let mut cache = S3FifoLayoutCache::new(64);
1375        let key = s3_fifo_test_key(0, 80);
1376        cache.get_or_compute(key, || vec![Rect::new(0, 0, 40, 24)]);
1377        cache.clear();
1378        assert!(cache.is_empty());
1379    }
1380
1381    #[test]
1382    fn s3fifo_layout_different_keys() {
1383        let mut cache = S3FifoLayoutCache::new(64);
1384        let k1 = s3_fifo_test_key(0, 80);
1385        let k2 = s3_fifo_test_key(0, 120);
1386        cache.get_or_compute(k1, || vec![Rect::new(0, 0, 40, 24)]);
1387        cache.get_or_compute(k2, || vec![Rect::new(0, 0, 60, 24)]);
1388        assert_eq!(cache.len(), 2);
1389    }
1390
1391    #[test]
1392    fn s3fifo_layout_reset_stats() {
1393        let mut cache = S3FifoLayoutCache::new(64);
1394        let key = s3_fifo_test_key(0, 80);
1395        cache.get_or_compute(key, Vec::new);
1396        cache.get_or_compute(key, Vec::new);
1397        cache.reset_stats();
1398        let stats = cache.stats();
1399        assert_eq!(stats.hits, 0);
1400        assert_eq!(stats.misses, 0);
1401    }
1402
1403    #[test]
1404    fn s3fifo_layout_produces_same_results_as_lru() {
1405        let mut lru = LayoutCache::new(64);
1406        let mut s3 = S3FifoLayoutCache::new(64);
1407
1408        for w in [80, 100, 120, 160, 200] {
1409            let key = s3_fifo_test_key(0, w);
1410            let expected = vec![Rect::new(0, 0, w / 2, 24)];
1411            let lru_r = lru.get_or_compute(key, || expected.clone());
1412            let s3_r = s3.get_or_compute(key, || expected.clone());
1413            assert_eq!(lru_r, s3_r, "mismatch for width={w}");
1414        }
1415    }
1416}