1use std::collections::HashMap;
54use std::hash::{DefaultHasher, Hash, Hasher};
55
56use ftui_core::geometry::Rect;
57
58use crate::{Constraint, Direction, LayoutSizeHint};
59
60#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
68pub struct LayoutCacheKey {
69 pub area_x: u16,
71 pub area_y: u16,
73 pub area_width: u16,
75 pub area_height: u16,
77 pub constraints_hash: u64,
79 pub direction: Direction,
81 pub intrinsics_hash: Option<u64>,
83}
84
85impl LayoutCacheKey {
86 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 #[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 fn hash_constraints(constraints: &[Constraint]) -> u64 {
119 let mut hasher = DefaultHasher::new();
120 for c in constraints {
121 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 fn hash_intrinsics(intrinsics: &[LayoutSizeHint]) -> u64 {
162 let mut hasher = DefaultHasher::new();
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#[derive(Clone, Debug)]
174struct CachedLayoutEntry {
175 chunks: Vec<Rect>,
177 generation: u64,
179 access_count: u32,
181}
182
183#[derive(Debug, Clone, Default)]
185pub struct LayoutCacheStats {
186 pub entries: usize,
188 pub hits: u64,
190 pub misses: u64,
192 pub hit_rate: f64,
194}
195
196#[derive(Debug)]
213pub struct LayoutCache {
214 entries: HashMap<LayoutCacheKey, CachedLayoutEntry>,
215 generation: u64,
216 max_entries: usize,
217 hits: u64,
218 misses: u64,
219}
220
221impl LayoutCache {
222 #[inline]
236 pub fn new(max_entries: usize) -> Self {
237 Self {
238 entries: HashMap::with_capacity(max_entries),
239 generation: 0,
240 max_entries,
241 hits: 0,
242 misses: 0,
243 }
244 }
245
246 pub fn get_or_compute<F>(&mut self, key: LayoutCacheKey, compute: F) -> Vec<Rect>
264 where
265 F: FnOnce() -> Vec<Rect>,
266 {
267 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 self.misses += 1;
278 let chunks = compute();
279
280 if self.entries.len() >= self.max_entries {
282 self.evict_lru();
283 }
284
285 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 #[inline]
314 pub fn invalidate_all(&mut self) {
315 self.generation = self.generation.wrapping_add(1);
316 }
317
318 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 #[inline]
339 pub fn reset_stats(&mut self) {
340 self.hits = 0;
341 self.misses = 0;
342 }
343
344 #[inline]
350 pub fn clear(&mut self) {
351 self.entries.clear();
352 self.generation = self.generation.wrapping_add(1);
353 }
354
355 #[inline]
357 pub fn len(&self) -> usize {
358 self.entries.len()
359 }
360
361 #[inline]
363 pub fn is_empty(&self) -> bool {
364 self.entries.is_empty()
365 }
366
367 #[inline]
369 pub fn capacity(&self) -> usize {
370 self.max_entries
371 }
372
373 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 fn default() -> Self {
389 Self::new(64)
390 }
391}
392
393#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
404pub struct CoherenceId {
405 pub constraints_hash: u64,
407 pub direction: Direction,
409}
410
411impl CoherenceId {
412 pub fn new(constraints: &[Constraint], direction: Direction) -> Self {
414 Self {
415 constraints_hash: LayoutCacheKey::hash_constraints(constraints),
416 direction,
417 }
418 }
419
420 pub fn from_cache_key(key: &LayoutCacheKey) -> Self {
422 Self {
423 constraints_hash: key.constraints_hash,
424 direction: key.direction,
425 }
426 }
427}
428
429#[derive(Debug)]
457pub struct CoherenceCache {
458 entries: HashMap<CoherenceId, CoherenceEntry>,
459 max_entries: usize,
460 tick: u64,
462}
463
464#[derive(Debug, Clone)]
465struct CoherenceEntry {
466 allocation: Vec<u16>,
468 last_stored: u64,
470}
471
472impl CoherenceCache {
473 pub fn new(max_entries: usize) -> Self {
475 Self {
476 entries: HashMap::with_capacity(max_entries.min(256)),
477 max_entries,
478 tick: 0,
479 }
480 }
481
482 #[inline]
487 pub fn get(&self, id: &CoherenceId) -> Option<Vec<u16>> {
488 self.entries.get(id).map(|e| e.allocation.clone())
489 }
490
491 pub fn store(&mut self, id: CoherenceId, allocation: Vec<u16>) {
495 self.tick = self.tick.wrapping_add(1);
496
497 if self.entries.len() >= self.max_entries && !self.entries.contains_key(&id) {
498 self.evict_oldest();
499 }
500
501 self.entries.insert(
502 id,
503 CoherenceEntry {
504 allocation,
505 last_stored: self.tick,
506 },
507 );
508 }
509
510 #[inline]
512 pub fn clear(&mut self) {
513 self.entries.clear();
514 }
515
516 #[inline]
518 pub fn len(&self) -> usize {
519 self.entries.len()
520 }
521
522 #[inline]
524 pub fn is_empty(&self) -> bool {
525 self.entries.is_empty()
526 }
527
528 pub fn displacement(&self, id: &CoherenceId, new_alloc: &[u16]) -> (u64, u32) {
533 match self.entries.get(id) {
534 Some(entry) => {
535 let prev = &entry.allocation;
536 let len = prev.len().min(new_alloc.len());
537 let mut sum: u64 = 0;
538 let mut max: u32 = 0;
539 for i in 0..len {
540 let d = (new_alloc[i] as i32 - prev[i] as i32).unsigned_abs();
541 sum += u64::from(d);
542 max = max.max(d);
543 }
544 for &v in &prev[len..] {
546 sum += u64::from(v);
547 max = max.max(u32::from(v));
548 }
549 for &v in &new_alloc[len..] {
550 sum += u64::from(v);
551 max = max.max(u32::from(v));
552 }
553 (sum, max)
554 }
555 None => (0, 0),
556 }
557 }
558
559 fn evict_oldest(&mut self) {
560 if let Some(key) = self
561 .entries
562 .iter()
563 .min_by_key(|(_, e)| e.last_stored)
564 .map(|(k, _)| *k)
565 {
566 self.entries.remove(&key);
567 }
568 }
569}
570
571impl Default for CoherenceCache {
572 fn default() -> Self {
573 Self::new(64)
574 }
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580
581 fn make_key(width: u16, height: u16) -> LayoutCacheKey {
582 LayoutCacheKey::new(
583 Rect::new(0, 0, width, height),
584 &[Constraint::Percentage(50.0), Constraint::Fill],
585 Direction::Horizontal,
586 None,
587 )
588 }
589
590 fn should_not_call(label: &str) -> Vec<Rect> {
591 unreachable!("{label}");
592 }
593
594 #[test]
597 fn same_params_produce_same_key() {
598 let k1 = make_key(80, 24);
599 let k2 = make_key(80, 24);
600 assert_eq!(k1, k2);
601 }
602
603 #[test]
604 fn different_area_different_key() {
605 let k1 = make_key(80, 24);
606 let k2 = make_key(120, 40);
607 assert_ne!(k1, k2);
608 }
609
610 #[test]
611 fn different_constraints_different_key() {
612 let k1 = LayoutCacheKey::new(
613 Rect::new(0, 0, 80, 24),
614 &[Constraint::Fixed(20)],
615 Direction::Horizontal,
616 None,
617 );
618 let k2 = LayoutCacheKey::new(
619 Rect::new(0, 0, 80, 24),
620 &[Constraint::Fixed(30)],
621 Direction::Horizontal,
622 None,
623 );
624 assert_ne!(k1, k2);
625 }
626
627 #[test]
628 fn different_direction_different_key() {
629 let k1 = LayoutCacheKey::new(
630 Rect::new(0, 0, 80, 24),
631 &[Constraint::Fill],
632 Direction::Horizontal,
633 None,
634 );
635 let k2 = LayoutCacheKey::new(
636 Rect::new(0, 0, 80, 24),
637 &[Constraint::Fill],
638 Direction::Vertical,
639 None,
640 );
641 assert_ne!(k1, k2);
642 }
643
644 #[test]
645 fn different_intrinsics_different_key() {
646 let hints1 = [LayoutSizeHint {
647 min: 10,
648 preferred: 20,
649 max: None,
650 }];
651 let hints2 = [LayoutSizeHint {
652 min: 10,
653 preferred: 30,
654 max: None,
655 }];
656
657 let k1 = LayoutCacheKey::new(
658 Rect::new(0, 0, 80, 24),
659 &[Constraint::FitContent],
660 Direction::Horizontal,
661 Some(&hints1),
662 );
663 let k2 = LayoutCacheKey::new(
664 Rect::new(0, 0, 80, 24),
665 &[Constraint::FitContent],
666 Direction::Horizontal,
667 Some(&hints2),
668 );
669 assert_ne!(k1, k2);
670 }
671
672 #[test]
675 fn cache_returns_same_result() {
676 let mut cache = LayoutCache::new(100);
677 let key = make_key(80, 24);
678
679 let mut compute_count = 0;
680 let compute = || {
681 compute_count += 1;
682 vec![Rect::new(0, 0, 40, 24), Rect::new(40, 0, 40, 24)]
683 };
684
685 let r1 = cache.get_or_compute(key, compute);
686 let r2 = cache.get_or_compute(key, || should_not_call("should not call"));
687
688 assert_eq!(r1, r2);
689 assert_eq!(compute_count, 1);
690 }
691
692 #[test]
693 fn different_area_is_cache_miss() {
694 let mut cache = LayoutCache::new(100);
695
696 let mut compute_count = 0;
697 let mut compute = || {
698 compute_count += 1;
699 vec![Rect::default()]
700 };
701
702 let k1 = make_key(80, 24);
703 let k2 = make_key(120, 40);
704
705 cache.get_or_compute(k1, &mut compute);
706 cache.get_or_compute(k2, &mut compute);
707
708 assert_eq!(compute_count, 2);
709 }
710
711 #[test]
712 fn invalidation_clears_cache() {
713 let mut cache = LayoutCache::new(100);
714 let key = make_key(80, 24);
715
716 let mut compute_count = 0;
717 let mut compute = || {
718 compute_count += 1;
719 vec![]
720 };
721
722 cache.get_or_compute(key, &mut compute);
723 cache.invalidate_all();
724 cache.get_or_compute(key, &mut compute);
725
726 assert_eq!(compute_count, 2);
727 }
728
729 #[test]
730 fn lru_eviction_works() {
731 let mut cache = LayoutCache::new(2);
732
733 let k1 = make_key(10, 10);
734 let k2 = make_key(20, 20);
735 let k3 = make_key(30, 30);
736
737 cache.get_or_compute(k1, || vec![Rect::new(0, 0, 10, 10)]);
739 cache.get_or_compute(k2, || vec![Rect::new(0, 0, 20, 20)]);
740
741 cache.get_or_compute(k1, || should_not_call("k1 should hit"));
743
744 cache.get_or_compute(k3, || vec![Rect::new(0, 0, 30, 30)]);
746
747 assert_eq!(cache.len(), 2);
748
749 let mut was_called = false;
751 cache.get_or_compute(k2, || {
752 was_called = true;
753 vec![]
754 });
755 assert!(was_called, "k2 should have been evicted");
756
757 cache.get_or_compute(k1, || should_not_call("k1 should still be cached"));
759 }
760
761 #[test]
762 fn stats_track_hits_and_misses() {
763 let mut cache = LayoutCache::new(100);
764
765 let k1 = make_key(80, 24);
766 let k2 = make_key(120, 40);
767
768 cache.get_or_compute(k1, Vec::new); cache.get_or_compute(k1, || should_not_call("hit")); cache.get_or_compute(k2, Vec::new); let stats = cache.stats();
773 assert_eq!(stats.hits, 1);
774 assert_eq!(stats.misses, 2);
775 assert!((stats.hit_rate - 1.0 / 3.0).abs() < 0.01);
776 }
777
778 #[test]
779 fn reset_stats_clears_counters() {
780 let mut cache = LayoutCache::new(100);
781 let key = make_key(80, 24);
782
783 cache.get_or_compute(key, Vec::new);
784 cache.get_or_compute(key, || should_not_call("hit"));
785
786 let stats = cache.stats();
787 assert_eq!(stats.hits, 1);
788 assert_eq!(stats.misses, 1);
789
790 cache.reset_stats();
791
792 let stats = cache.stats();
793 assert_eq!(stats.hits, 0);
794 assert_eq!(stats.misses, 0);
795 assert_eq!(stats.hit_rate, 0.0);
796 }
797
798 #[test]
799 fn clear_removes_all_entries() {
800 let mut cache = LayoutCache::new(100);
801
802 cache.get_or_compute(make_key(80, 24), Vec::new);
803 cache.get_or_compute(make_key(120, 40), Vec::new);
804
805 assert_eq!(cache.len(), 2);
806
807 cache.clear();
808
809 assert_eq!(cache.len(), 0);
810 assert!(cache.is_empty());
811
812 let mut was_called = false;
814 cache.get_or_compute(make_key(80, 24), || {
815 was_called = true;
816 vec![]
817 });
818 assert!(was_called);
819 }
820
821 #[test]
822 fn default_capacity_is_64() {
823 let cache = LayoutCache::default();
824 assert_eq!(cache.capacity(), 64);
825 }
826
827 #[test]
828 fn generation_wraps_around() {
829 let mut cache = LayoutCache::new(100);
830 cache.generation = u64::MAX;
831 cache.invalidate_all();
832 assert_eq!(cache.generation, 0);
833 }
834
835 #[test]
838 fn constraint_hash_is_stable() {
839 let constraints = [
840 Constraint::Fixed(20),
841 Constraint::Percentage(50.0),
842 Constraint::Min(10),
843 ];
844
845 let h1 = LayoutCacheKey::hash_constraints(&constraints);
846 let h2 = LayoutCacheKey::hash_constraints(&constraints);
847
848 assert_eq!(h1, h2);
849 }
850
851 #[test]
852 fn different_constraint_values_different_hash() {
853 let c1 = [Constraint::Fixed(20)];
854 let c2 = [Constraint::Fixed(30)];
855
856 let h1 = LayoutCacheKey::hash_constraints(&c1);
857 let h2 = LayoutCacheKey::hash_constraints(&c2);
858
859 assert_ne!(h1, h2);
860 }
861
862 #[test]
863 fn different_constraint_types_different_hash() {
864 let c1 = [Constraint::Fixed(20)];
865 let c2 = [Constraint::Min(20)];
866
867 let h1 = LayoutCacheKey::hash_constraints(&c1);
868 let h2 = LayoutCacheKey::hash_constraints(&c2);
869
870 assert_ne!(h1, h2);
871 }
872
873 #[test]
874 fn fit_content_bounded_values_in_hash() {
875 let c1 = [Constraint::FitContentBounded { min: 10, max: 50 }];
876 let c2 = [Constraint::FitContentBounded { min: 10, max: 60 }];
877
878 let h1 = LayoutCacheKey::hash_constraints(&c1);
879 let h2 = LayoutCacheKey::hash_constraints(&c2);
880
881 assert_ne!(h1, h2);
882 }
883
884 #[test]
887 fn intrinsics_hash_is_stable() {
888 let hints = [
889 LayoutSizeHint {
890 min: 10,
891 preferred: 20,
892 max: Some(30),
893 },
894 LayoutSizeHint {
895 min: 5,
896 preferred: 15,
897 max: None,
898 },
899 ];
900
901 let h1 = LayoutCacheKey::hash_intrinsics(&hints);
902 let h2 = LayoutCacheKey::hash_intrinsics(&hints);
903
904 assert_eq!(h1, h2);
905 }
906
907 #[test]
908 fn different_intrinsics_different_hash() {
909 let h1 = [LayoutSizeHint {
910 min: 10,
911 preferred: 20,
912 max: None,
913 }];
914 let h2 = [LayoutSizeHint {
915 min: 10,
916 preferred: 25,
917 max: None,
918 }];
919
920 let hash1 = LayoutCacheKey::hash_intrinsics(&h1);
921 let hash2 = LayoutCacheKey::hash_intrinsics(&h2);
922
923 assert_ne!(hash1, hash2);
924 }
925
926 #[test]
929 fn cache_is_deterministic() {
930 let mut cache1 = LayoutCache::new(100);
931 let mut cache2 = LayoutCache::new(100);
932
933 for i in 0..10u16 {
934 let key = make_key(i * 10, i * 5);
935 let result = vec![Rect::new(0, 0, i, i)];
936
937 cache1.get_or_compute(key, || result.clone());
938 cache2.get_or_compute(key, || result);
939 }
940
941 assert_eq!(cache1.stats().entries, cache2.stats().entries);
942 assert_eq!(cache1.stats().misses, cache2.stats().misses);
943 }
944
945 #[test]
946 fn hit_count_increments_on_each_access() {
947 let mut cache = LayoutCache::new(100);
948 let key = make_key(80, 24);
949
950 cache.get_or_compute(key, Vec::new);
952
953 for _ in 0..5 {
955 cache.get_or_compute(key, || should_not_call("should hit"));
956 }
957
958 let stats = cache.stats();
959 assert_eq!(stats.misses, 1);
960 assert_eq!(stats.hits, 5);
961 }
962
963 fn make_coherence_id(n: u16) -> CoherenceId {
968 CoherenceId::new(
969 &[Constraint::Fixed(n), Constraint::Fill],
970 Direction::Horizontal,
971 )
972 }
973
974 #[test]
975 fn coherence_store_and_get() {
976 let mut cc = CoherenceCache::new(64);
977 let id = make_coherence_id(1);
978
979 assert!(cc.get(&id).is_none());
980
981 cc.store(id, vec![30, 50]);
982 assert_eq!(cc.get(&id), Some(vec![30, 50]));
983 }
984
985 #[test]
986 fn coherence_update_replaces_allocation() {
987 let mut cc = CoherenceCache::new(64);
988 let id = make_coherence_id(1);
989
990 cc.store(id, vec![30, 50]);
991 cc.store(id, vec![31, 49]);
992
993 assert_eq!(cc.get(&id), Some(vec![31, 49]));
994 assert_eq!(cc.len(), 1);
995 }
996
997 #[test]
998 fn coherence_different_ids_are_separate() {
999 let mut cc = CoherenceCache::new(64);
1000 let id1 = make_coherence_id(1);
1001 let id2 = make_coherence_id(2);
1002
1003 cc.store(id1, vec![40, 40]);
1004 cc.store(id2, vec![30, 50]);
1005
1006 assert_eq!(cc.get(&id1), Some(vec![40, 40]));
1007 assert_eq!(cc.get(&id2), Some(vec![30, 50]));
1008 }
1009
1010 #[test]
1011 fn coherence_eviction_at_capacity() {
1012 let mut cc = CoherenceCache::new(2);
1013
1014 let id1 = make_coherence_id(1);
1015 let id2 = make_coherence_id(2);
1016 let id3 = make_coherence_id(3);
1017
1018 cc.store(id1, vec![10]);
1019 cc.store(id2, vec![20]);
1020 cc.store(id3, vec![30]);
1021
1022 assert_eq!(cc.len(), 2);
1023 assert!(cc.get(&id1).is_none());
1025 assert_eq!(cc.get(&id2), Some(vec![20]));
1026 assert_eq!(cc.get(&id3), Some(vec![30]));
1027 }
1028
1029 #[test]
1030 fn coherence_clear() {
1031 let mut cc = CoherenceCache::new(64);
1032 let id = make_coherence_id(1);
1033
1034 cc.store(id, vec![10, 20]);
1035 assert_eq!(cc.len(), 1);
1036
1037 cc.clear();
1038 assert!(cc.is_empty());
1039 assert!(cc.get(&id).is_none());
1040 }
1041
1042 #[test]
1043 fn coherence_displacement_with_previous() {
1044 let mut cc = CoherenceCache::new(64);
1045 let id = make_coherence_id(1);
1046
1047 cc.store(id, vec![30, 50]);
1048
1049 let (sum, max) = cc.displacement(&id, &[32, 48]);
1051 assert_eq!(sum, 4); assert_eq!(max, 2);
1053 }
1054
1055 #[test]
1056 fn coherence_displacement_without_previous() {
1057 let cc = CoherenceCache::new(64);
1058 let id = make_coherence_id(1);
1059
1060 let (sum, max) = cc.displacement(&id, &[30, 50]);
1061 assert_eq!(sum, 0);
1062 assert_eq!(max, 0);
1063 }
1064
1065 #[test]
1066 fn coherence_displacement_different_lengths() {
1067 let mut cc = CoherenceCache::new(64);
1068 let id = make_coherence_id(1);
1069
1070 cc.store(id, vec![30, 50]);
1071
1072 let (sum, max) = cc.displacement(&id, &[30, 50, 10]);
1074 assert_eq!(sum, 10);
1075 assert_eq!(max, 10);
1076 }
1077
1078 #[test]
1079 fn coherence_from_cache_key() {
1080 let key = make_key(80, 24);
1081 let id = CoherenceId::from_cache_key(&key);
1082
1083 let key2 = make_key(120, 40);
1085 let id2 = CoherenceId::from_cache_key(&key2);
1086
1087 assert_eq!(id, id2);
1088 }
1089
1090 #[test]
1093 fn unit_cache_reuse_unchanged_constraints_yield_identical_layout() {
1094 use crate::round_layout_stable;
1095
1096 let mut cc = CoherenceCache::new(64);
1097 let id = make_coherence_id(1);
1098
1099 let targets = [26.67, 26.67, 26.66];
1101 let total = 80;
1102 let alloc1 = round_layout_stable(&targets, total, cc.get(&id));
1103 cc.store(id, alloc1.clone());
1104
1105 let alloc2 = round_layout_stable(&targets, total, cc.get(&id));
1107 assert_eq!(alloc1, alloc2, "Same inputs should yield identical layout");
1108 }
1109
1110 #[test]
1113 fn e2e_resize_sweep_bounded_displacement() {
1114 use crate::round_layout_stable;
1115
1116 let mut cc = CoherenceCache::new(64);
1117 let id = make_coherence_id(1);
1118
1119 let mut max_displacement_ever: u32 = 0;
1122 let mut total_displacement_sum: u64 = 0;
1123 let steps = 61; for width in 60u16..=120 {
1126 let third = f64::from(width) / 3.0;
1127 let targets = [third, third, third];
1128
1129 let prev = cc.get(&id);
1130 let alloc = round_layout_stable(&targets, width, prev);
1131
1132 let (d_sum, d_max) = cc.displacement(&id, &alloc);
1133 total_displacement_sum += d_sum;
1134 max_displacement_ever = max_displacement_ever.max(d_max);
1135
1136 cc.store(id, alloc);
1137 }
1138
1139 assert!(
1142 max_displacement_ever <= 2,
1143 "Max single-step displacement should be <=2 cells, got {}",
1144 max_displacement_ever
1145 );
1146
1147 let avg = total_displacement_sum as f64 / steps as f64;
1149 assert!(
1150 avg < 3.0,
1151 "Average displacement per step should be <3 cells, got {:.2}",
1152 avg
1153 );
1154 }
1155
1156 #[test]
1157 fn e2e_resize_sweep_deterministic() {
1158 use crate::round_layout_stable;
1159
1160 let sweep = |seed: u16| -> Vec<(u16, Vec<u16>, u64, u32)> {
1162 let mut cc = CoherenceCache::new(64);
1163 let id = CoherenceId::new(
1164 &[Constraint::Percentage(30.0), Constraint::Fill],
1165 Direction::Horizontal,
1166 );
1167
1168 let mut log = Vec::new();
1169 for width in (40 + seed)..(100 + seed) {
1170 let targets = [f64::from(width) * 0.3, f64::from(width) * 0.7];
1171 let prev = cc.get(&id);
1172 let alloc = round_layout_stable(&targets, width, prev);
1173 let (d_sum, d_max) = cc.displacement(&id, &alloc);
1174 cc.store(id, alloc.clone());
1175 log.push((width, alloc, d_sum, d_max));
1176 }
1177 log
1178 };
1179
1180 let log1 = sweep(0);
1181 let log2 = sweep(0);
1182 assert_eq!(log1, log2, "Identical sweeps should produce identical logs");
1183 }
1184
1185 #[test]
1186 fn default_coherence_cache_capacity_is_64() {
1187 let cc = CoherenceCache::default();
1188 assert_eq!(cc.max_entries, 64);
1189 }
1190}