1#![forbid(unsafe_code)]
2
3use lru::LruCache;
37use rustc_hash::FxHasher;
38use std::hash::{Hash, Hasher};
39use std::num::NonZeroUsize;
40
41pub const DEFAULT_CACHE_CAPACITY: usize = 4096;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46pub struct CacheStats {
47 pub hits: u64,
49 pub misses: u64,
51 pub size: usize,
53 pub capacity: usize,
55}
56
57impl CacheStats {
58 #[must_use]
60 pub fn hit_rate(&self) -> f64 {
61 let total = self.hits + self.misses;
62 if total == 0 {
63 0.0
64 } else {
65 self.hits as f64 / total as f64
66 }
67 }
68}
69
70#[derive(Debug)]
97pub struct WidthCache {
98 cache: LruCache<u64, usize>,
99 hits: u64,
100 misses: u64,
101}
102
103impl WidthCache {
104 #[must_use]
108 pub fn new(capacity: usize) -> Self {
109 let capacity = NonZeroUsize::new(capacity.max(1)).expect("capacity must be > 0");
110 Self {
111 cache: LruCache::new(capacity),
112 hits: 0,
113 misses: 0,
114 }
115 }
116
117 #[must_use]
119 pub fn with_default_capacity() -> Self {
120 Self::new(DEFAULT_CACHE_CAPACITY)
121 }
122
123 #[inline]
128 pub fn get_or_compute(&mut self, text: &str) -> usize {
129 self.get_or_compute_with(text, crate::display_width)
130 }
131
132 pub fn get_or_compute_with<F>(&mut self, text: &str, compute: F) -> usize
137 where
138 F: FnOnce(&str) -> usize,
139 {
140 let hash = hash_text(text);
141
142 if let Some(&width) = self.cache.get(&hash) {
143 self.hits += 1;
144 return width;
145 }
146
147 self.misses += 1;
148 let width = compute(text);
149 self.cache.put(hash, width);
150 width
151 }
152
153 #[must_use]
155 pub fn contains(&self, text: &str) -> bool {
156 let hash = hash_text(text);
157 self.cache.contains(&hash)
158 }
159
160 #[must_use]
165 pub fn get(&mut self, text: &str) -> Option<usize> {
166 let hash = hash_text(text);
167 self.cache.get(&hash).copied()
168 }
169
170 #[must_use]
172 pub fn peek(&self, text: &str) -> Option<usize> {
173 let hash = hash_text(text);
174 self.cache.peek(&hash).copied()
175 }
176
177 pub fn preload(&mut self, text: &str) {
181 let hash = hash_text(text);
182 if !self.cache.contains(&hash) {
183 let width = crate::display_width(text);
184 self.cache.put(hash, width);
185 }
186 }
187
188 pub fn preload_many<'a>(&mut self, texts: impl IntoIterator<Item = &'a str>) {
190 for text in texts {
191 self.preload(text);
192 }
193 }
194
195 pub fn clear(&mut self) {
197 self.cache.clear();
198 }
199
200 pub fn reset_stats(&mut self) {
202 self.hits = 0;
203 self.misses = 0;
204 }
205
206 #[inline]
208 #[must_use]
209 pub fn stats(&self) -> CacheStats {
210 CacheStats {
211 hits: self.hits,
212 misses: self.misses,
213 size: self.cache.len(),
214 capacity: self.cache.cap().get(),
215 }
216 }
217
218 #[inline]
220 #[must_use]
221 pub fn len(&self) -> usize {
222 self.cache.len()
223 }
224
225 #[inline]
227 #[must_use]
228 pub fn is_empty(&self) -> bool {
229 self.cache.is_empty()
230 }
231
232 #[inline]
234 #[must_use]
235 pub fn capacity(&self) -> usize {
236 self.cache.cap().get()
237 }
238
239 pub fn resize(&mut self, new_capacity: usize) {
244 let new_capacity = NonZeroUsize::new(new_capacity.max(1)).expect("capacity must be > 0");
245 self.cache.resize(new_capacity);
246 }
247}
248
249impl Default for WidthCache {
250 fn default() -> Self {
251 Self::with_default_capacity()
252 }
253}
254
255#[inline]
257fn hash_text(text: &str) -> u64 {
258 let mut hasher = FxHasher::default();
259 text.hash(&mut hasher);
260 hasher.finish()
261}
262
263#[cfg(feature = "thread_local_cache")]
268thread_local! {
269 static THREAD_CACHE: std::cell::RefCell<WidthCache> =
270 std::cell::RefCell::new(WidthCache::with_default_capacity());
271}
272
273#[cfg(feature = "thread_local_cache")]
275pub fn cached_width(text: &str) -> usize {
276 THREAD_CACHE.with(|cache| cache.borrow_mut().get_or_compute(text))
277}
278
279#[cfg(feature = "thread_local_cache")]
281pub fn clear_thread_cache() {
282 THREAD_CACHE.with(|cache| cache.borrow_mut().clear());
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 #[test]
290 fn new_cache_is_empty() {
291 let cache = WidthCache::new(100);
292 assert!(cache.is_empty());
293 assert_eq!(cache.len(), 0);
294 assert_eq!(cache.capacity(), 100);
295 }
296
297 #[test]
298 fn default_capacity() {
299 let cache = WidthCache::with_default_capacity();
300 assert_eq!(cache.capacity(), DEFAULT_CACHE_CAPACITY);
301 }
302
303 #[test]
304 fn get_or_compute_caches_value() {
305 let mut cache = WidthCache::new(100);
306
307 let width1 = cache.get_or_compute("hello");
308 assert_eq!(width1, 5);
309 assert_eq!(cache.len(), 1);
310
311 let width2 = cache.get_or_compute("hello");
312 assert_eq!(width2, 5);
313 assert_eq!(cache.len(), 1); let stats = cache.stats();
316 assert_eq!(stats.hits, 1);
317 assert_eq!(stats.misses, 1);
318 }
319
320 #[test]
321 fn get_or_compute_different_strings() {
322 let mut cache = WidthCache::new(100);
323
324 cache.get_or_compute("hello");
325 cache.get_or_compute("world");
326 cache.get_or_compute("foo");
327
328 assert_eq!(cache.len(), 3);
329 let stats = cache.stats();
330 assert_eq!(stats.misses, 3);
331 assert_eq!(stats.hits, 0);
332 }
333
334 #[test]
335 fn get_or_compute_cjk() {
336 let mut cache = WidthCache::new(100);
337
338 let width = cache.get_or_compute("你好");
339 assert_eq!(width, 4); }
341
342 #[test]
343 fn contains() {
344 let mut cache = WidthCache::new(100);
345
346 assert!(!cache.contains("hello"));
347 cache.get_or_compute("hello");
348 assert!(cache.contains("hello"));
349 }
350
351 #[test]
352 fn get_returns_none_for_missing() {
353 let mut cache = WidthCache::new(100);
354 assert!(cache.get("missing").is_none());
355 }
356
357 #[test]
358 fn get_returns_cached_value() {
359 let mut cache = WidthCache::new(100);
360 cache.get_or_compute("hello");
361
362 let width = cache.get("hello");
363 assert_eq!(width, Some(5));
364 }
365
366 #[test]
367 fn peek_does_not_update_lru() {
368 let mut cache = WidthCache::new(2);
369
370 cache.get_or_compute("a");
371 cache.get_or_compute("b");
372
373 let _ = cache.peek("a");
375
376 cache.get_or_compute("c");
378
379 assert!(!cache.contains("a"));
380 assert!(cache.contains("b"));
381 assert!(cache.contains("c"));
382 }
383
384 #[test]
385 fn lru_eviction() {
386 let mut cache = WidthCache::new(2);
387
388 cache.get_or_compute("a");
389 cache.get_or_compute("b");
390 cache.get_or_compute("c"); assert!(!cache.contains("a"));
393 assert!(cache.contains("b"));
394 assert!(cache.contains("c"));
395 }
396
397 #[test]
398 fn lru_refresh_on_access() {
399 let mut cache = WidthCache::new(2);
400
401 cache.get_or_compute("a");
402 cache.get_or_compute("b");
403 cache.get_or_compute("a"); cache.get_or_compute("c"); assert!(cache.contains("a"));
407 assert!(!cache.contains("b"));
408 assert!(cache.contains("c"));
409 }
410
411 #[test]
412 fn preload() {
413 let mut cache = WidthCache::new(100);
414
415 cache.preload("hello");
416 assert!(cache.contains("hello"));
417 assert_eq!(cache.peek("hello"), Some(5));
418
419 let stats = cache.stats();
420 assert_eq!(stats.misses, 0); assert_eq!(stats.hits, 0);
422 }
423
424 #[test]
425 fn preload_many() {
426 let mut cache = WidthCache::new(100);
427
428 cache.preload_many(["hello", "world", "foo"]);
429 assert_eq!(cache.len(), 3);
430 }
431
432 #[test]
433 fn clear() {
434 let mut cache = WidthCache::new(100);
435 cache.get_or_compute("hello");
436 cache.get_or_compute("world");
437
438 cache.clear();
439 assert!(cache.is_empty());
440 assert!(!cache.contains("hello"));
441 }
442
443 #[test]
444 fn reset_stats() {
445 let mut cache = WidthCache::new(100);
446 cache.get_or_compute("hello");
447 cache.get_or_compute("hello");
448
449 let stats = cache.stats();
450 assert_eq!(stats.hits, 1);
451 assert_eq!(stats.misses, 1);
452
453 cache.reset_stats();
454 let stats = cache.stats();
455 assert_eq!(stats.hits, 0);
456 assert_eq!(stats.misses, 0);
457 }
458
459 #[test]
460 fn hit_rate() {
461 let stats = CacheStats {
462 hits: 75,
463 misses: 25,
464 size: 100,
465 capacity: 1000,
466 };
467 assert!((stats.hit_rate() - 0.75).abs() < 0.001);
468 }
469
470 #[test]
471 fn hit_rate_no_requests() {
472 let stats = CacheStats::default();
473 assert_eq!(stats.hit_rate(), 0.0);
474 }
475
476 #[test]
477 fn resize_smaller() {
478 let mut cache = WidthCache::new(100);
479 for i in 0..50 {
480 cache.get_or_compute(&format!("text{i}"));
481 }
482 assert_eq!(cache.len(), 50);
483
484 cache.resize(10);
485 assert!(cache.len() <= 10);
486 assert_eq!(cache.capacity(), 10);
487 }
488
489 #[test]
490 fn resize_larger() {
491 let mut cache = WidthCache::new(10);
492 cache.resize(100);
493 assert_eq!(cache.capacity(), 100);
494 }
495
496 #[test]
497 fn custom_compute_function() {
498 let mut cache = WidthCache::new(100);
499
500 let width = cache.get_or_compute_with("hello", |_| 42);
502 assert_eq!(width, 42);
503
504 assert_eq!(cache.peek("hello"), Some(42));
506 }
507
508 #[test]
509 fn empty_string() {
510 let mut cache = WidthCache::new(100);
511 let width = cache.get_or_compute("");
512 assert_eq!(width, 0);
513 }
514
515 #[test]
516 fn hash_collision_handling() {
517 let mut cache = WidthCache::new(1000);
520
521 for i in 0..500 {
522 cache.get_or_compute(&format!("string{i}"));
523 }
524
525 assert_eq!(cache.len(), 500);
526 }
527
528 #[test]
529 fn unicode_strings() {
530 let mut cache = WidthCache::new(100);
531
532 assert_eq!(cache.get_or_compute("café"), 4);
534 assert_eq!(cache.get_or_compute("日本語"), 6);
535 assert_eq!(cache.get_or_compute("🎉"), 2); assert_eq!(cache.len(), 3);
538 }
539
540 #[test]
541 fn combining_characters() {
542 let mut cache = WidthCache::new(100);
543
544 let width = cache.get_or_compute("e\u{0301}");
546 assert_eq!(width, 1);
548 }
549
550 #[test]
555 fn default_cache() {
556 let cache = WidthCache::default();
557 assert!(cache.is_empty());
558 assert_eq!(cache.capacity(), DEFAULT_CACHE_CAPACITY);
559 }
560
561 #[test]
562 fn cache_stats_debug() {
563 let stats = CacheStats {
564 hits: 10,
565 misses: 5,
566 size: 15,
567 capacity: 100,
568 };
569 let debug = format!("{:?}", stats);
570 assert!(debug.contains("CacheStats"));
571 assert!(debug.contains("10")); }
573
574 #[test]
575 fn cache_stats_default() {
576 let stats = CacheStats::default();
577 assert_eq!(stats.hits, 0);
578 assert_eq!(stats.misses, 0);
579 assert_eq!(stats.size, 0);
580 assert_eq!(stats.capacity, 0);
581 }
582
583 #[test]
584 fn cache_stats_equality() {
585 let stats1 = CacheStats {
586 hits: 10,
587 misses: 5,
588 size: 15,
589 capacity: 100,
590 };
591 let stats2 = stats1; assert_eq!(stats1, stats2);
593 }
594
595 #[test]
596 fn clear_after_preload() {
597 let mut cache = WidthCache::new(100);
598 cache.preload_many(["hello", "world", "test"]);
599 assert_eq!(cache.len(), 3);
600
601 cache.clear();
602 assert!(cache.is_empty());
603 assert!(!cache.contains("hello"));
604 }
605
606 #[test]
607 fn preload_existing_is_noop() {
608 let mut cache = WidthCache::new(100);
609 cache.get_or_compute("hello"); let len_before = cache.len();
611
612 cache.preload("hello"); assert_eq!(cache.len(), len_before);
614 }
615
616 #[test]
617 fn minimum_capacity_is_one() {
618 let cache = WidthCache::new(0);
619 assert_eq!(cache.capacity(), 1);
620 }
621
622 #[test]
623 fn width_cache_debug() {
624 let cache = WidthCache::new(10);
625 let debug = format!("{:?}", cache);
626 assert!(debug.contains("WidthCache"));
627 }
628
629 #[test]
630 fn emoji_zwj_sequence() {
631 let mut cache = WidthCache::new(100);
632 let width = cache.get_or_compute("👨👩👧");
634 assert!(width >= 1);
636 }
637
638 #[test]
639 fn emoji_with_skin_tone() {
640 let mut cache = WidthCache::new(100);
641 let width = cache.get_or_compute("👍🏻");
642 assert!(width >= 1);
643 }
644
645 #[test]
646 fn flag_emoji() {
647 let mut cache = WidthCache::new(100);
648 let width = cache.get_or_compute("🇺🇸");
650 assert!(width >= 1);
651 }
652
653 #[test]
654 fn mixed_width_strings() {
655 let mut cache = WidthCache::new(100);
656 let width = cache.get_or_compute("Hello你好World");
658 assert_eq!(width, 14); }
660
661 #[test]
662 fn stats_size_reflects_cache_len() {
663 let mut cache = WidthCache::new(100);
664 cache.get_or_compute("a");
665 cache.get_or_compute("b");
666 cache.get_or_compute("c");
667
668 let stats = cache.stats();
669 assert_eq!(stats.size, cache.len());
670 assert_eq!(stats.size, 3);
671 }
672
673 #[test]
674 fn stats_capacity_matches() {
675 let cache = WidthCache::new(42);
676 let stats = cache.stats();
677 assert_eq!(stats.capacity, 42);
678 }
679
680 #[test]
681 fn resize_to_zero_becomes_one() {
682 let mut cache = WidthCache::new(100);
683 cache.resize(0);
684 assert_eq!(cache.capacity(), 1);
685 }
686
687 #[test]
688 fn get_updates_lru_order() {
689 let mut cache = WidthCache::new(2);
690
691 cache.get_or_compute("a");
692 cache.get_or_compute("b");
693
694 let _ = cache.get("a");
696
697 cache.get_or_compute("c");
699
700 assert!(cache.contains("a"));
701 assert!(!cache.contains("b"));
702 assert!(cache.contains("c"));
703 }
704
705 #[test]
706 fn contains_does_not_modify_stats() {
707 let mut cache = WidthCache::new(100);
708 cache.get_or_compute("hello");
709
710 let stats_before = cache.stats();
711 let _ = cache.contains("hello");
712 let _ = cache.contains("missing");
713 let stats_after = cache.stats();
714
715 assert_eq!(stats_before.hits, stats_after.hits);
716 assert_eq!(stats_before.misses, stats_after.misses);
717 }
718
719 #[test]
720 fn peek_returns_none_for_missing() {
721 let cache = WidthCache::new(100);
722 assert!(cache.peek("missing").is_none());
723 }
724
725 #[test]
726 fn custom_compute_called_once() {
727 let mut cache = WidthCache::new(100);
728 let mut call_count = 0;
729
730 cache.get_or_compute_with("test", |_| {
731 call_count += 1;
732 10
733 });
734
735 cache.get_or_compute_with("test", |_| {
736 call_count += 1;
737 20 });
739
740 assert_eq!(call_count, 1);
741 assert_eq!(cache.peek("test"), Some(10));
742 }
743
744 #[test]
745 fn whitespace_strings() {
746 let mut cache = WidthCache::new(100);
747 assert_eq!(cache.get_or_compute(" "), 3); assert_eq!(cache.get_or_compute("\t"), 1); assert_eq!(cache.get_or_compute("\n"), 1); }
751}
752
753#[derive(Debug, Clone)]
807pub struct CountMinSketch {
808 counters: Vec<Vec<u8>>,
810 depth: usize,
812 width: usize,
814 total_increments: u64,
816 reset_interval: u64,
818}
819
820const CMS_MAX_COUNT: u8 = 15;
822
823const CMS_DEFAULT_WIDTH: usize = 1024;
825
826const CMS_DEFAULT_DEPTH: usize = 4;
828
829const CMS_DEFAULT_RESET_INTERVAL: u64 = 8192;
831
832impl CountMinSketch {
833 pub fn new(width: usize, depth: usize, reset_interval: u64) -> Self {
835 let width = width.max(1);
836 let depth = depth.max(1);
837 Self {
838 counters: vec![vec![0u8; width]; depth],
839 depth,
840 width,
841 total_increments: 0,
842 reset_interval: reset_interval.max(1),
843 }
844 }
845
846 pub fn with_defaults() -> Self {
848 Self::new(
849 CMS_DEFAULT_WIDTH,
850 CMS_DEFAULT_DEPTH,
851 CMS_DEFAULT_RESET_INTERVAL,
852 )
853 }
854
855 pub fn increment(&mut self, hash: u64) {
857 for row in 0..self.depth {
858 let idx = self.index(hash, row);
859 self.counters[row][idx] = self.counters[row][idx].saturating_add(1).min(CMS_MAX_COUNT);
860 }
861 self.total_increments += 1;
862
863 if self.total_increments >= self.reset_interval {
864 self.halve();
865 }
866 }
867
868 pub fn estimate(&self, hash: u64) -> u8 {
870 let mut min = u8::MAX;
871 for row in 0..self.depth {
872 let idx = self.index(hash, row);
873 min = min.min(self.counters[row][idx]);
874 }
875 min
876 }
877
878 pub fn total_increments(&self) -> u64 {
880 self.total_increments
881 }
882
883 fn halve(&mut self) {
885 for row in &mut self.counters {
886 for c in row.iter_mut() {
887 *c /= 2;
888 }
889 }
890 self.total_increments = 0;
891 }
892
893 pub fn clear(&mut self) {
895 for row in &mut self.counters {
896 row.fill(0);
897 }
898 self.total_increments = 0;
899 }
900
901 #[inline]
903 fn index(&self, hash: u64, row: usize) -> usize {
904 let mixed = hash
906 .wrapping_mul(0x517c_c1b7_2722_0a95)
907 .wrapping_add(row as u64);
908 let mixed = mixed ^ (mixed >> 32);
909 (mixed as usize) % self.width
910 }
911}
912
913#[derive(Debug, Clone)]
919pub struct Doorkeeper {
920 bits: Vec<u64>,
921 num_bits: usize,
922}
923
924const DOORKEEPER_DEFAULT_BITS: usize = 2048;
926
927impl Doorkeeper {
928 pub fn new(num_bits: usize) -> Self {
930 let num_bits = num_bits.max(64);
931 let num_words = num_bits.div_ceil(64);
932 Self {
933 bits: vec![0u64; num_words],
934 num_bits,
935 }
936 }
937
938 pub fn with_defaults() -> Self {
940 Self::new(DOORKEEPER_DEFAULT_BITS)
941 }
942
943 pub fn check_and_set(&mut self, hash: u64) -> bool {
945 let idx = (hash as usize) % self.num_bits;
946 let word = idx / 64;
947 let bit = idx % 64;
948 let was_set = (self.bits[word] >> bit) & 1 == 1;
949 self.bits[word] |= 1 << bit;
950 was_set
951 }
952
953 pub fn contains(&self, hash: u64) -> bool {
955 let idx = (hash as usize) % self.num_bits;
956 let word = idx / 64;
957 let bit = idx % 64;
958 (self.bits[word] >> bit) & 1 == 1
959 }
960
961 pub fn clear(&mut self) {
963 self.bits.fill(0);
964 }
965}
966
967#[inline]
972pub fn fingerprint_hash(text: &str) -> u64 {
973 let mut h: u64 = 0xcbf2_9ce4_8422_2325; for &b in text.as_bytes() {
976 h ^= b as u64;
977 h = h.wrapping_mul(0x0100_0000_01b3); }
979 h
980}
981
982#[inline]
989pub fn tinylfu_admit(candidate_freq: u8, victim_freq: u8) -> bool {
990 candidate_freq > victim_freq
991}
992
993#[derive(Debug, Clone)]
999struct TinyLfuEntry {
1000 width: usize,
1001 fingerprint: u64,
1002}
1003
1004#[derive(Debug)]
1027pub struct TinyLfuWidthCache {
1028 window: LruCache<u64, TinyLfuEntry>,
1030 main: LruCache<u64, TinyLfuEntry>,
1032 sketch: CountMinSketch,
1034 doorkeeper: Doorkeeper,
1036 total_capacity: usize,
1038 hits: u64,
1040 misses: u64,
1041}
1042
1043impl TinyLfuWidthCache {
1044 pub fn new(total_capacity: usize) -> Self {
1048 let total_capacity = total_capacity.max(2);
1049 let window_cap = (total_capacity / 100).max(1);
1050 let main_cap = total_capacity - window_cap;
1051
1052 Self {
1053 window: LruCache::new(NonZeroUsize::new(window_cap).unwrap()),
1054 main: LruCache::new(NonZeroUsize::new(main_cap.max(1)).unwrap()),
1055 sketch: CountMinSketch::with_defaults(),
1056 doorkeeper: Doorkeeper::with_defaults(),
1057 total_capacity,
1058 hits: 0,
1059 misses: 0,
1060 }
1061 }
1062
1063 pub fn get_or_compute(&mut self, text: &str) -> usize {
1065 self.get_or_compute_with(text, crate::display_width)
1066 }
1067
1068 pub fn get_or_compute_with<F>(&mut self, text: &str, compute: F) -> usize
1070 where
1071 F: FnOnce(&str) -> usize,
1072 {
1073 let hash = hash_text(text);
1074 let fp = fingerprint_hash(text);
1075
1076 let seen = self.doorkeeper.check_and_set(hash);
1078 if seen {
1079 self.sketch.increment(hash);
1080 }
1081
1082 if let Some(entry) = self.main.get(&hash) {
1084 if entry.fingerprint == fp {
1085 self.hits += 1;
1086 return entry.width;
1087 }
1088 self.main.pop(&hash);
1090 }
1091
1092 if let Some(entry) = self.window.get(&hash) {
1094 if entry.fingerprint == fp {
1095 self.hits += 1;
1096 return entry.width;
1097 }
1098 self.window.pop(&hash);
1100 }
1101
1102 self.misses += 1;
1104 let width = compute(text);
1105 let new_entry = TinyLfuEntry {
1106 width,
1107 fingerprint: fp,
1108 };
1109
1110 if self.window.len() >= self.window.cap().get() {
1113 if let Some((evicted_hash, evicted_entry)) = self.window.pop_lru() {
1115 self.try_admit_to_main(evicted_hash, evicted_entry);
1116 }
1117 }
1118 self.window.put(hash, new_entry);
1119
1120 width
1121 }
1122
1123 fn try_admit_to_main(&mut self, candidate_hash: u64, candidate_entry: TinyLfuEntry) {
1125 let candidate_freq = self.sketch.estimate(candidate_hash);
1126
1127 if self.main.len() < self.main.cap().get() {
1128 self.main.put(candidate_hash, candidate_entry);
1130 return;
1131 }
1132
1133 if let Some((&victim_hash, _)) = self.main.peek_lru() {
1135 let victim_freq = self.sketch.estimate(victim_hash);
1136 if tinylfu_admit(candidate_freq, victim_freq) {
1137 self.main.pop_lru();
1138 self.main.put(candidate_hash, candidate_entry);
1139 }
1140 }
1142 }
1143
1144 pub fn contains(&self, text: &str) -> bool {
1146 let hash = hash_text(text);
1147 let fp = fingerprint_hash(text);
1148 if let Some(e) = self.main.peek(&hash)
1149 && e.fingerprint == fp
1150 {
1151 return true;
1152 }
1153 if let Some(e) = self.window.peek(&hash)
1154 && e.fingerprint == fp
1155 {
1156 return true;
1157 }
1158 false
1159 }
1160
1161 pub fn stats(&self) -> CacheStats {
1163 CacheStats {
1164 hits: self.hits,
1165 misses: self.misses,
1166 size: self.window.len() + self.main.len(),
1167 capacity: self.total_capacity,
1168 }
1169 }
1170
1171 pub fn clear(&mut self) {
1173 self.window.clear();
1174 self.main.clear();
1175 self.sketch.clear();
1176 self.doorkeeper.clear();
1177 }
1178
1179 pub fn reset_stats(&mut self) {
1181 self.hits = 0;
1182 self.misses = 0;
1183 }
1184
1185 pub fn len(&self) -> usize {
1187 self.window.len() + self.main.len()
1188 }
1189
1190 pub fn is_empty(&self) -> bool {
1192 self.window.is_empty() && self.main.is_empty()
1193 }
1194
1195 pub fn capacity(&self) -> usize {
1197 self.total_capacity
1198 }
1199
1200 pub fn main_len(&self) -> usize {
1202 self.main.len()
1203 }
1204
1205 pub fn window_len(&self) -> usize {
1207 self.window.len()
1208 }
1209}
1210
1211#[derive(Debug)]
1223pub struct S3FifoWidthCache {
1224 cache: ftui_core::s3_fifo::S3Fifo<u64, S3FifoEntry>,
1225 hits: u64,
1226 misses: u64,
1227 total_capacity: usize,
1228}
1229
1230#[derive(Debug, Clone, Copy)]
1232struct S3FifoEntry {
1233 width: usize,
1234 fingerprint: u64,
1235}
1236
1237impl S3FifoWidthCache {
1238 pub fn new(capacity: usize) -> Self {
1240 let capacity = capacity.max(2);
1241 Self {
1242 cache: ftui_core::s3_fifo::S3Fifo::new(capacity),
1243 hits: 0,
1244 misses: 0,
1245 total_capacity: capacity,
1246 }
1247 }
1248
1249 #[must_use]
1251 pub fn with_default_capacity() -> Self {
1252 Self::new(DEFAULT_CACHE_CAPACITY)
1253 }
1254
1255 pub fn get_or_compute(&mut self, text: &str) -> usize {
1257 self.get_or_compute_with(text, crate::display_width)
1258 }
1259
1260 pub fn get_or_compute_with<F>(&mut self, text: &str, compute: F) -> usize
1262 where
1263 F: FnOnce(&str) -> usize,
1264 {
1265 let hash = hash_text(text);
1266 let fp = fingerprint_hash(text);
1267
1268 if let Some(entry) = self.cache.get(&hash) {
1269 if entry.fingerprint == fp {
1270 self.hits += 1;
1271 return entry.width;
1272 }
1273 self.cache.remove(&hash);
1275 }
1276
1277 self.misses += 1;
1279 let width = compute(text);
1280 self.cache.insert(
1281 hash,
1282 S3FifoEntry {
1283 width,
1284 fingerprint: fp,
1285 },
1286 );
1287 width
1288 }
1289
1290 pub fn contains(&self, text: &str) -> bool {
1292 let hash = hash_text(text);
1293 self.cache.contains_key(&hash)
1294 }
1295
1296 pub fn stats(&self) -> CacheStats {
1298 CacheStats {
1299 hits: self.hits,
1300 misses: self.misses,
1301 size: self.cache.len(),
1302 capacity: self.total_capacity,
1303 }
1304 }
1305
1306 pub fn clear(&mut self) {
1308 self.cache.clear();
1309 self.hits = 0;
1310 self.misses = 0;
1311 }
1312
1313 pub fn reset_stats(&mut self) {
1315 self.hits = 0;
1316 self.misses = 0;
1317 }
1318
1319 pub fn len(&self) -> usize {
1321 self.cache.len()
1322 }
1323
1324 pub fn is_empty(&self) -> bool {
1326 self.cache.is_empty()
1327 }
1328
1329 pub fn capacity(&self) -> usize {
1331 self.total_capacity
1332 }
1333}
1334
1335impl Default for S3FifoWidthCache {
1336 fn default() -> Self {
1337 Self::with_default_capacity()
1338 }
1339}
1340
1341#[cfg(test)]
1342mod s3_fifo_width_tests {
1343 use super::*;
1344
1345 #[test]
1346 fn s3fifo_new_cache_is_empty() {
1347 let cache = S3FifoWidthCache::new(100);
1348 assert!(cache.is_empty());
1349 assert_eq!(cache.len(), 0);
1350 }
1351
1352 #[test]
1353 fn s3fifo_get_or_compute_caches_value() {
1354 let mut cache = S3FifoWidthCache::new(100);
1355 let w1 = cache.get_or_compute("hello");
1356 assert_eq!(w1, 5);
1357 assert_eq!(cache.len(), 1);
1358
1359 let w2 = cache.get_or_compute("hello");
1360 assert_eq!(w2, 5);
1361 assert_eq!(cache.len(), 1);
1362
1363 let stats = cache.stats();
1364 assert_eq!(stats.hits, 1);
1365 assert_eq!(stats.misses, 1);
1366 }
1367
1368 #[test]
1369 fn s3fifo_different_strings() {
1370 let mut cache = S3FifoWidthCache::new(100);
1371 cache.get_or_compute("hello");
1372 cache.get_or_compute("world");
1373 cache.get_or_compute("foo");
1374 assert_eq!(cache.len(), 3);
1375 }
1376
1377 #[test]
1378 fn s3fifo_cjk_width() {
1379 let mut cache = S3FifoWidthCache::new(100);
1380 let w = cache.get_or_compute("你好");
1381 assert_eq!(w, 4);
1382 }
1383
1384 #[test]
1385 fn s3fifo_contains() {
1386 let mut cache = S3FifoWidthCache::new(100);
1387 assert!(!cache.contains("hello"));
1388 cache.get_or_compute("hello");
1389 assert!(cache.contains("hello"));
1390 }
1391
1392 #[test]
1393 fn s3fifo_clear_resets() {
1394 let mut cache = S3FifoWidthCache::new(100);
1395 cache.get_or_compute("hello");
1396 cache.get_or_compute("world");
1397 cache.clear();
1398 assert!(cache.is_empty());
1399 assert!(!cache.contains("hello"));
1400 let stats = cache.stats();
1401 assert_eq!(stats.hits, 0);
1402 assert_eq!(stats.misses, 0);
1403 }
1404
1405 #[test]
1406 fn s3fifo_produces_same_widths_as_lru() {
1407 let mut lru = WidthCache::new(100);
1408 let mut s3 = S3FifoWidthCache::new(100);
1409
1410 let texts = [
1411 "hello",
1412 "你好世界",
1413 "abc",
1414 "🎉🎉",
1415 "",
1416 " ",
1417 "a\tb",
1418 "mixed中english文",
1419 ];
1420
1421 for text in &texts {
1422 let lru_w = lru.get_or_compute(text);
1423 let s3_w = s3.get_or_compute(text);
1424 assert_eq!(lru_w, s3_w, "width mismatch for {:?}", text);
1425 }
1426 }
1427
1428 #[test]
1429 fn s3fifo_scan_resistance_preserves_hot_set() {
1430 let mut cache = S3FifoWidthCache::new(50);
1431
1432 let hot: Vec<String> = (0..20).map(|i| format!("hot_{i}")).collect();
1434 for text in &hot {
1435 cache.get_or_compute(text);
1436 cache.get_or_compute(text); }
1438
1439 for i in 0..200 {
1441 cache.get_or_compute(&format!("scan_{i}"));
1442 }
1443
1444 let mut survivors = 0;
1446 for text in &hot {
1447 if cache.contains(text) {
1448 survivors += 1;
1449 }
1450 }
1451 assert!(
1452 survivors > 5,
1453 "scan resistance: only {survivors}/20 hot items survived"
1454 );
1455 }
1456
1457 #[test]
1458 fn s3fifo_default_capacity() {
1459 let cache = S3FifoWidthCache::with_default_capacity();
1460 assert_eq!(cache.capacity(), DEFAULT_CACHE_CAPACITY);
1461 }
1462
1463 #[test]
1464 fn s3fifo_reset_stats() {
1465 let mut cache = S3FifoWidthCache::new(100);
1466 cache.get_or_compute("a");
1467 cache.get_or_compute("a");
1468 cache.reset_stats();
1469 let stats = cache.stats();
1470 assert_eq!(stats.hits, 0);
1471 assert_eq!(stats.misses, 0);
1472 }
1473}
1474
1475#[cfg(test)]
1476mod proptests {
1477 use super::*;
1478 use proptest::prelude::*;
1479
1480 proptest! {
1481 #[test]
1482 fn cached_width_matches_direct(s in "[a-zA-Z0-9 ]{1,50}") {
1483 let mut cache = WidthCache::new(100);
1484 let cached = cache.get_or_compute(&s);
1485 let direct = crate::display_width(&s);
1486 prop_assert_eq!(cached, direct);
1487 }
1488
1489 #[test]
1490 fn second_access_is_hit(s in "[a-zA-Z0-9]{1,20}") {
1491 let mut cache = WidthCache::new(100);
1492
1493 cache.get_or_compute(&s);
1494 let stats_before = cache.stats();
1495
1496 cache.get_or_compute(&s);
1497 let stats_after = cache.stats();
1498
1499 prop_assert_eq!(stats_after.hits, stats_before.hits + 1);
1500 prop_assert_eq!(stats_after.misses, stats_before.misses);
1501 }
1502
1503 #[test]
1504 fn lru_never_exceeds_capacity(
1505 strings in prop::collection::vec("[a-z]{1,5}", 10..100),
1506 capacity in 5usize..20
1507 ) {
1508 let mut cache = WidthCache::new(capacity);
1509
1510 for s in &strings {
1511 cache.get_or_compute(s);
1512 prop_assert!(cache.len() <= capacity);
1513 }
1514 }
1515
1516 #[test]
1517 fn preload_then_access_is_hit(s in "[a-zA-Z]{1,20}") {
1518 let mut cache = WidthCache::new(100);
1519
1520 cache.preload(&s);
1521 let stats_before = cache.stats();
1522
1523 cache.get_or_compute(&s);
1524 let stats_after = cache.stats();
1525
1526 prop_assert_eq!(stats_after.hits, stats_before.hits + 1);
1528 }
1529 }
1530}
1531
1532#[cfg(test)]
1537mod tinylfu_tests {
1538 use super::*;
1539
1540 #[test]
1543 fn unit_cms_single_key_count() {
1544 let mut cms = CountMinSketch::with_defaults();
1545 let h = hash_text("hello");
1546
1547 for _ in 0..5 {
1548 cms.increment(h);
1549 }
1550 assert_eq!(cms.estimate(h), 5);
1551 }
1552
1553 #[test]
1554 fn unit_cms_unseen_key_is_zero() {
1555 let cms = CountMinSketch::with_defaults();
1556 assert_eq!(cms.estimate(hash_text("never_seen")), 0);
1557 }
1558
1559 #[test]
1560 fn unit_cms_saturates_at_max() {
1561 let mut cms = CountMinSketch::with_defaults();
1562 let h = hash_text("hot");
1563
1564 for _ in 0..100 {
1565 cms.increment(h);
1566 }
1567 assert_eq!(cms.estimate(h), CMS_MAX_COUNT);
1568 }
1569
1570 #[test]
1571 fn unit_cms_bounds() {
1572 let mut cms = CountMinSketch::new(1024, 4, u64::MAX); let n: u64 = 1000;
1576
1577 for i in 0..n {
1579 cms.increment(i.wrapping_mul(0x9E37_79B9_7F4A_7C15).wrapping_add(1));
1580 }
1581
1582 let target = 0xDEAD_BEEF_u64;
1584 for _ in 0..5 {
1585 cms.increment(target);
1586 }
1587
1588 let est = cms.estimate(target);
1589 let epsilon = std::f64::consts::E / 1024.0;
1590 let upper_bound = 5.0 + epsilon * (n + 5) as f64;
1591
1592 assert!(
1593 (est as f64) <= upper_bound,
1594 "estimate {} exceeds bound {:.1} (epsilon={:.5}, N={})",
1595 est,
1596 upper_bound,
1597 epsilon,
1598 n + 5,
1599 );
1600 assert!(est >= 5, "estimate {} should be >= true count 5", est);
1601 }
1602
1603 #[test]
1604 fn unit_cms_bounds_mass_test() {
1605 let mut cms = CountMinSketch::new(1024, 4, u64::MAX);
1606 let n = 2000u64;
1607
1608 let mut true_counts = vec![0u8; n as usize];
1609 for i in 0..n {
1610 let count = (i % 10 + 1) as u8;
1611 true_counts[i as usize] = count;
1612 for _ in 0..count {
1613 cms.increment(i);
1614 }
1615 }
1616
1617 let total = cms.total_increments();
1618 let epsilon = std::f64::consts::E / 1024.0;
1619 let mut violations = 0u32;
1620
1621 for i in 0..n {
1622 let est = cms.estimate(i);
1623 let true_c = true_counts[i as usize];
1624 let upper = true_c as f64 + epsilon * total as f64;
1625 if est as f64 > upper + 0.5 {
1626 violations += 1;
1627 }
1628 assert!(
1629 est >= true_c,
1630 "key {}: estimate {} < true count {}",
1631 i,
1632 est,
1633 true_c
1634 );
1635 }
1636
1637 let violation_rate = violations as f64 / n as f64;
1639 assert!(
1640 violation_rate <= 0.10,
1641 "violation rate {:.3} exceeds delta threshold",
1642 violation_rate,
1643 );
1644 }
1645
1646 #[test]
1647 fn unit_cms_halving_ages_counts() {
1648 let mut cms = CountMinSketch::new(64, 2, 100);
1649
1650 let h = hash_text("test");
1651 for _ in 0..10 {
1652 cms.increment(h);
1653 }
1654 assert_eq!(cms.estimate(h), 10);
1655
1656 for _ in 10..100 {
1658 cms.increment(hash_text("noise"));
1659 }
1660
1661 let est = cms.estimate(h);
1662 assert!(est <= 5, "After halving, estimate {} should be <= 5", est);
1663 }
1664
1665 #[test]
1666 fn unit_cms_monotone() {
1667 let mut cms = CountMinSketch::with_defaults();
1668 let h = hash_text("key");
1669
1670 let mut prev_est = 0u8;
1671 for _ in 0..CMS_MAX_COUNT {
1672 cms.increment(h);
1673 let est = cms.estimate(h);
1674 assert!(est >= prev_est, "estimate should be monotone");
1675 prev_est = est;
1676 }
1677 }
1678
1679 #[test]
1682 fn unit_doorkeeper_first_access_returns_false() {
1683 let mut dk = Doorkeeper::with_defaults();
1684 assert!(!dk.check_and_set(hash_text("new")));
1685 }
1686
1687 #[test]
1688 fn unit_doorkeeper_second_access_returns_true() {
1689 let mut dk = Doorkeeper::with_defaults();
1690 let h = hash_text("key");
1691 dk.check_and_set(h);
1692 assert!(dk.check_and_set(h));
1693 }
1694
1695 #[test]
1696 fn unit_doorkeeper_contains() {
1697 let mut dk = Doorkeeper::with_defaults();
1698 let h = hash_text("key");
1699 assert!(!dk.contains(h));
1700 dk.check_and_set(h);
1701 assert!(dk.contains(h));
1702 }
1703
1704 #[test]
1705 fn unit_doorkeeper_clear_resets() {
1706 let mut dk = Doorkeeper::with_defaults();
1707 let h = hash_text("key");
1708 dk.check_and_set(h);
1709 dk.clear();
1710 assert!(!dk.contains(h));
1711 assert!(!dk.check_and_set(h));
1712 }
1713
1714 #[test]
1715 fn unit_doorkeeper_false_positive_rate() {
1716 let mut dk = Doorkeeper::new(2048);
1717 let n = 100u64;
1718
1719 for i in 0..n {
1720 dk.check_and_set(i * 0x9E37_79B9 + 1);
1721 }
1722
1723 let mut false_positives = 0u32;
1724 for i in 0..1000 {
1725 let h = (i + 100_000) * 0x6A09_E667 + 7;
1726 if dk.contains(h) {
1727 false_positives += 1;
1728 }
1729 }
1730
1731 let fp_rate = false_positives as f64 / 1000.0;
1733 assert!(
1734 fp_rate < 0.15,
1735 "FP rate {:.3} too high (expected < 0.15)",
1736 fp_rate,
1737 );
1738 }
1739
1740 #[test]
1743 fn unit_admission_rule() {
1744 assert!(tinylfu_admit(5, 3)); assert!(!tinylfu_admit(3, 5)); assert!(!tinylfu_admit(3, 3)); }
1748
1749 #[test]
1750 fn unit_admission_rule_extremes() {
1751 assert!(tinylfu_admit(1, 0));
1752 assert!(!tinylfu_admit(0, 0));
1753 assert!(!tinylfu_admit(0, 1));
1754 assert!(tinylfu_admit(CMS_MAX_COUNT, CMS_MAX_COUNT - 1));
1755 assert!(!tinylfu_admit(CMS_MAX_COUNT, CMS_MAX_COUNT));
1756 }
1757
1758 #[test]
1761 fn unit_fingerprint_guard() {
1762 let fp1 = fingerprint_hash("hello");
1763 let fp2 = fingerprint_hash("world");
1764 let fp3 = fingerprint_hash("hello");
1765
1766 assert_ne!(
1767 fp1, fp2,
1768 "Different strings should have different fingerprints"
1769 );
1770 assert_eq!(fp1, fp3, "Same string should have same fingerprint");
1771 }
1772
1773 #[test]
1774 fn unit_fingerprint_guard_collision_rate() {
1775 let mut fps = std::collections::HashSet::new();
1776 let n = 10_000;
1777
1778 for i in 0..n {
1779 let s = format!("string_{}", i);
1780 fps.insert(fingerprint_hash(&s));
1781 }
1782
1783 let collisions = n - fps.len();
1784 assert!(
1785 collisions == 0,
1786 "Expected 0 collisions in 10k items, got {}",
1787 collisions,
1788 );
1789 }
1790
1791 #[test]
1792 fn unit_fingerprint_independent_of_primary_hash() {
1793 let text = "test_string";
1794 let primary = hash_text(text);
1795 let secondary = fingerprint_hash(text);
1796
1797 assert_ne!(
1798 primary, secondary,
1799 "Fingerprint and primary hash should differ"
1800 );
1801 }
1802
1803 #[test]
1806 fn unit_doorkeeper_cms_pipeline() {
1807 let mut dk = Doorkeeper::with_defaults();
1808 let mut cms = CountMinSketch::with_defaults();
1809 let h = hash_text("item");
1810
1811 assert!(!dk.check_and_set(h));
1813 assert_eq!(cms.estimate(h), 0);
1814
1815 assert!(dk.check_and_set(h));
1817 cms.increment(h);
1818 assert_eq!(cms.estimate(h), 1);
1819
1820 assert!(dk.check_and_set(h));
1822 cms.increment(h);
1823 assert_eq!(cms.estimate(h), 2);
1824 }
1825
1826 #[test]
1827 fn unit_doorkeeper_filters_one_hit_wonders() {
1828 let mut dk = Doorkeeper::with_defaults();
1829 let mut cms = CountMinSketch::with_defaults();
1830
1831 for i in 0u64..100 {
1833 let h = i * 0x9E37_79B9 + 1;
1834 let seen = dk.check_and_set(h);
1835 if seen {
1836 cms.increment(h);
1837 }
1838 }
1839
1840 assert_eq!(cms.total_increments(), 0);
1841
1842 let h = 1; assert!(dk.check_and_set(h));
1845 cms.increment(h);
1846 assert_eq!(cms.total_increments(), 1);
1847 }
1848}
1849
1850#[cfg(test)]
1855mod tinylfu_impl_tests {
1856 use super::*;
1857
1858 #[test]
1859 fn basic_get_or_compute() {
1860 let mut cache = TinyLfuWidthCache::new(100);
1861 let w = cache.get_or_compute("hello");
1862 assert_eq!(w, 5);
1863 assert_eq!(cache.len(), 1);
1864
1865 let w2 = cache.get_or_compute("hello");
1866 assert_eq!(w2, 5);
1867 let stats = cache.stats();
1868 assert_eq!(stats.misses, 1);
1869 assert_eq!(stats.hits, 1);
1870 }
1871
1872 #[test]
1873 fn window_to_main_promotion() {
1874 let mut cache = TinyLfuWidthCache::new(100);
1877
1878 for _ in 0..10 {
1880 cache.get_or_compute("frequent");
1881 }
1882
1883 for i in 0..5 {
1885 cache.get_or_compute(&format!("item_{}", i));
1886 }
1887
1888 assert!(cache.contains("frequent"));
1890 assert!(cache.main_len() > 0 || cache.window_len() > 0);
1891 }
1892
1893 #[test]
1894 fn unit_window_promotion() {
1895 let mut cache = TinyLfuWidthCache::new(50);
1897
1898 for _ in 0..20 {
1900 cache.get_or_compute("hot");
1901 }
1902
1903 for i in 0..10 {
1905 cache.get_or_compute(&format!("filler_{}", i));
1906 }
1907
1908 assert!(cache.contains("hot"), "Frequent item should be retained");
1910 }
1911
1912 #[test]
1913 fn fingerprint_guard_detects_collision() {
1914 let mut cache = TinyLfuWidthCache::new(100);
1915
1916 let w = cache.get_or_compute_with("hello", |_| 42);
1918 assert_eq!(w, 42);
1919
1920 assert!(cache.contains("hello"));
1922 }
1923
1924 #[test]
1925 fn admission_rejects_infrequent() {
1926 let mut cache = TinyLfuWidthCache::new(10); for i in 0..9 {
1932 let s = format!("hot_{}", i);
1933 for _ in 0..5 {
1934 cache.get_or_compute(&s);
1935 }
1936 }
1937
1938 for i in 0..20 {
1941 cache.get_or_compute(&format!("cold_{}", i));
1942 }
1943
1944 let hot_survivors: usize = (0..9)
1946 .filter(|i| cache.contains(&format!("hot_{}", i)))
1947 .count();
1948 assert!(
1949 hot_survivors >= 5,
1950 "Expected most hot items to survive, got {}/9",
1951 hot_survivors
1952 );
1953 }
1954
1955 #[test]
1956 fn clear_empties_everything() {
1957 let mut cache = TinyLfuWidthCache::new(100);
1958 cache.get_or_compute("a");
1959 cache.get_or_compute("b");
1960 cache.clear();
1961 assert!(cache.is_empty());
1962 assert_eq!(cache.len(), 0);
1963 }
1964
1965 #[test]
1966 fn stats_reflect_usage() {
1967 let mut cache = TinyLfuWidthCache::new(100);
1968 cache.get_or_compute("a");
1969 cache.get_or_compute("a");
1970 cache.get_or_compute("b");
1971
1972 let stats = cache.stats();
1973 assert_eq!(stats.misses, 2);
1974 assert_eq!(stats.hits, 1);
1975 assert_eq!(stats.size, 2);
1976 }
1977
1978 #[test]
1979 fn capacity_is_respected() {
1980 let mut cache = TinyLfuWidthCache::new(20);
1981
1982 for i in 0..100 {
1983 cache.get_or_compute(&format!("item_{}", i));
1984 }
1985
1986 assert!(
1987 cache.len() <= 20,
1988 "Cache size {} exceeds capacity 20",
1989 cache.len()
1990 );
1991 }
1992
1993 #[test]
1994 fn reset_stats_works() {
1995 let mut cache = TinyLfuWidthCache::new(100);
1996 cache.get_or_compute("x");
1997 cache.get_or_compute("x");
1998 cache.reset_stats();
1999 let stats = cache.stats();
2000 assert_eq!(stats.hits, 0);
2001 assert_eq!(stats.misses, 0);
2002 }
2003
2004 #[test]
2005 fn perf_cache_hit_rate() {
2006 let mut cache = TinyLfuWidthCache::new(50);
2009
2010 for _ in 0..20 {
2012 for i in 0..10 {
2013 cache.get_or_compute(&format!("hot_{}", i));
2014 }
2015 }
2016
2017 for i in 0..100 {
2019 cache.get_or_compute(&format!("cold_{}", i));
2020 }
2021
2022 cache.reset_stats();
2024 for i in 0..10 {
2025 cache.get_or_compute(&format!("hot_{}", i));
2026 }
2027
2028 let stats = cache.stats();
2029 assert!(
2031 stats.hits >= 5,
2032 "Expected at least 5/10 hot items to hit, got {}",
2033 stats.hits
2034 );
2035 }
2036
2037 #[test]
2038 fn unicode_strings_work() {
2039 let mut cache = TinyLfuWidthCache::new(100);
2040 assert_eq!(cache.get_or_compute("日本語"), 6);
2041 assert_eq!(cache.get_or_compute("café"), 4);
2042 assert_eq!(cache.get_or_compute("日本語"), 6); assert_eq!(cache.stats().hits, 1);
2044 }
2045
2046 #[test]
2047 fn empty_string() {
2048 let mut cache = TinyLfuWidthCache::new(100);
2049 assert_eq!(cache.get_or_compute(""), 0);
2050 }
2051
2052 #[test]
2053 fn minimum_capacity() {
2054 let cache = TinyLfuWidthCache::new(0);
2055 assert!(cache.capacity() >= 2);
2056 }
2057}
2058
2059#[cfg(test)]
2065struct Lcg(u64);
2066
2067#[cfg(test)]
2068impl Lcg {
2069 fn new(seed: u64) -> Self {
2070 Self(seed)
2071 }
2072 fn next_u64(&mut self) -> u64 {
2073 self.0 = self
2074 .0
2075 .wrapping_mul(6_364_136_223_846_793_005)
2076 .wrapping_add(1);
2077 self.0
2078 }
2079 fn next_usize(&mut self, max: usize) -> usize {
2080 (self.next_u64() % (max as u64)) as usize
2081 }
2082}
2083
2084#[cfg(test)]
2089mod property_cache_equivalence {
2090 use super::*;
2091 use proptest::prelude::*;
2092
2093 proptest! {
2094 #[test]
2095 fn tinylfu_cached_equals_computed(s in "[a-zA-Z0-9 ]{1,80}") {
2096 let mut cache = TinyLfuWidthCache::new(200);
2097 let cached = cache.get_or_compute(&s);
2098 let direct = crate::display_width(&s);
2099 prop_assert_eq!(cached, direct,
2100 "TinyLFU returned {} but display_width says {} for {:?}", cached, direct, s);
2101 }
2102
2103 #[test]
2104 fn tinylfu_second_access_same_value(s in "[a-zA-Z0-9]{1,40}") {
2105 let mut cache = TinyLfuWidthCache::new(200);
2106 let first = cache.get_or_compute(&s);
2107 let second = cache.get_or_compute(&s);
2108 prop_assert_eq!(first, second,
2109 "First access returned {} but second returned {} for {:?}", first, second, s);
2110 }
2111
2112 #[test]
2113 fn tinylfu_never_exceeds_capacity(
2114 strings in prop::collection::vec("[a-z]{1,5}", 10..200),
2115 capacity in 10usize..50
2116 ) {
2117 let mut cache = TinyLfuWidthCache::new(capacity);
2118 for s in &strings {
2119 cache.get_or_compute(s);
2120 prop_assert!(cache.len() <= capacity,
2121 "Cache size {} exceeded capacity {}", cache.len(), capacity);
2122 }
2123 }
2124
2125 #[test]
2126 fn tinylfu_custom_fn_matches(s in "[a-z]{1,20}") {
2127 let mut cache = TinyLfuWidthCache::new(100);
2128 let custom_fn = |text: &str| text.len(); let cached = cache.get_or_compute_with(&s, custom_fn);
2130 prop_assert_eq!(cached, s.len(),
2131 "Custom fn: cached {} != expected {} for {:?}", cached, s.len(), s);
2132 }
2133 }
2134
2135 #[test]
2136 fn deterministic_seed_equivalence() {
2137 let mut rng = super::Lcg::new(0xDEAD_BEEF);
2139
2140 let mut cache1 = TinyLfuWidthCache::new(50);
2141 let mut results1 = Vec::new();
2142 for _ in 0..500 {
2143 let idx = rng.next_usize(100);
2144 let s = format!("key_{}", idx);
2145 results1.push(cache1.get_or_compute(&s));
2146 }
2147
2148 let mut rng2 = super::Lcg::new(0xDEAD_BEEF);
2149 let mut cache2 = TinyLfuWidthCache::new(50);
2150 let mut results2 = Vec::new();
2151 for _ in 0..500 {
2152 let idx = rng2.next_usize(100);
2153 let s = format!("key_{}", idx);
2154 results2.push(cache2.get_or_compute(&s));
2155 }
2156
2157 assert_eq!(
2158 results1, results2,
2159 "Deterministic seed must produce identical results"
2160 );
2161 }
2162
2163 #[test]
2164 fn both_caches_agree_on_widths() {
2165 let mut lru = WidthCache::new(200);
2168 let mut tlfu = TinyLfuWidthCache::new(200);
2169
2170 let inputs = [
2171 "",
2172 "hello",
2173 "日本語テスト",
2174 "café résumé",
2175 "a\tb",
2176 "🎉🎊🎈",
2177 "mixed日本eng",
2178 " spaces ",
2179 "AAAAAAAAAAAAAAAAAAAAAAAAA",
2180 "x",
2181 ];
2182
2183 for &s in &inputs {
2184 let w1 = lru.get_or_compute(s);
2185 let w2 = tlfu.get_or_compute(s);
2186 assert_eq!(
2187 w1, w2,
2188 "Width mismatch for {:?}: LRU={}, TinyLFU={}",
2189 s, w1, w2
2190 );
2191 }
2192 }
2193}
2194
2195#[cfg(test)]
2200mod e2e_cache_replay {
2201 use super::*;
2202 use std::time::Instant;
2203
2204 #[derive(Debug)]
2206 struct ReplayRecord {
2207 step: usize,
2208 key: String,
2209 width: usize,
2210 hit: bool,
2211 latency_ns: u128,
2212 }
2213
2214 impl ReplayRecord {
2215 fn to_jsonl(&self) -> String {
2216 format!(
2217 r#"{{"step":{},"key":"{}","width":{},"hit":{},"latency_ns":{}}}"#,
2218 self.step, self.key, self.width, self.hit, self.latency_ns,
2219 )
2220 }
2221 }
2222
2223 fn zipfian_workload(rng: &mut super::Lcg, n: usize, universe: usize) -> Vec<String> {
2224 (0..n)
2226 .map(|_| {
2227 let raw = rng.next_usize(universe * universe);
2228 let idx = (raw as f64).sqrt() as usize % universe;
2229 format!("item_{}", idx)
2230 })
2231 .collect()
2232 }
2233
2234 #[test]
2235 fn replay_zipfian_tinylfu() {
2236 let mut rng = super::Lcg::new(0x1234_5678);
2237 let workload = zipfian_workload(&mut rng, 2000, 200);
2238
2239 let mut cache = TinyLfuWidthCache::new(50);
2240 let mut records = Vec::with_capacity(workload.len());
2241
2242 for (i, key) in workload.iter().enumerate() {
2243 let stats_before = cache.stats();
2244 let t0 = Instant::now();
2245 let width = cache.get_or_compute(key);
2246 let elapsed = t0.elapsed().as_nanos();
2247 let stats_after = cache.stats();
2248 let hit = stats_after.hits > stats_before.hits;
2249
2250 records.push(ReplayRecord {
2251 step: i,
2252 key: key.clone(),
2253 width,
2254 hit,
2255 latency_ns: elapsed,
2256 });
2257 }
2258
2259 for r in &records[..5] {
2261 let line = r.to_jsonl();
2262 assert!(
2263 line.starts_with('{') && line.ends_with('}'),
2264 "Bad JSONL: {}",
2265 line
2266 );
2267 }
2268
2269 let total = records.len();
2271 let hits = records.iter().filter(|r| r.hit).count();
2272 let hit_rate = hits as f64 / total as f64;
2273
2274 assert!(
2277 hit_rate > 0.10,
2278 "Zipfian hit rate too low: {:.2}% ({}/{})",
2279 hit_rate * 100.0,
2280 hits,
2281 total
2282 );
2283 }
2284
2285 #[test]
2286 fn replay_zipfian_lru_comparison() {
2287 let mut rng = super::Lcg::new(0x1234_5678);
2288 let workload = zipfian_workload(&mut rng, 2000, 200);
2289
2290 let mut tlfu = TinyLfuWidthCache::new(50);
2291 let mut lru = WidthCache::new(50);
2292
2293 for key in &workload {
2294 tlfu.get_or_compute(key);
2295 lru.get_or_compute(key);
2296 }
2297
2298 let tlfu_stats = tlfu.stats();
2299 let lru_stats = lru.stats();
2300
2301 assert_eq!(tlfu_stats.hits + tlfu_stats.misses, 2000);
2303 assert_eq!(lru_stats.hits + lru_stats.misses, 2000);
2304
2305 assert!(
2308 tlfu_stats.hit_rate() >= lru_stats.hit_rate() * 0.8,
2309 "TinyLFU hit rate {:.2}% much worse than LRU {:.2}%",
2310 tlfu_stats.hit_rate() * 100.0,
2311 lru_stats.hit_rate() * 100.0,
2312 );
2313 }
2314
2315 #[test]
2316 fn replay_deterministic_reproduction() {
2317 let run = |seed: u64| -> Vec<bool> {
2319 let mut rng = super::Lcg::new(seed);
2320 let workload = zipfian_workload(&mut rng, 500, 100);
2321 let mut cache = TinyLfuWidthCache::new(30);
2322 let mut hits = Vec::with_capacity(500);
2323 for key in &workload {
2324 let before = cache.stats().hits;
2325 cache.get_or_compute(key);
2326 hits.push(cache.stats().hits > before);
2327 }
2328 hits
2329 };
2330
2331 let run1 = run(0xABCD_EF01);
2332 let run2 = run(0xABCD_EF01);
2333 assert_eq!(run1, run2, "Deterministic replay diverged");
2334 }
2335
2336 #[test]
2337 fn replay_uniform_workload() {
2338 let mut rng = super::Lcg::new(0x9999);
2341 let universe = 100;
2342 let cache_size = 25;
2343 let n = 5000;
2344
2345 let mut cache = TinyLfuWidthCache::new(cache_size);
2346
2347 for i in 0..universe {
2349 cache.get_or_compute(&format!("u_{}", i));
2350 }
2351
2352 cache.reset_stats();
2353 for _ in 0..n {
2354 let idx = rng.next_usize(universe);
2355 cache.get_or_compute(&format!("u_{}", idx));
2356 }
2357
2358 let stats = cache.stats();
2359 let hit_rate = stats.hit_rate();
2360 assert!(
2362 hit_rate > 0.10 && hit_rate < 0.60,
2363 "Uniform hit rate {:.2}% outside expected range",
2364 hit_rate * 100.0,
2365 );
2366 }
2367}
2368
2369#[cfg(test)]
2374mod perf_cache_overhead {
2375 use super::*;
2376 use std::time::Instant;
2377
2378 fn measure_latencies<F: FnMut(usize)>(n: usize, mut op: F) -> Vec<u128> {
2380 let mut latencies = Vec::with_capacity(n);
2381 for i in 0..n {
2382 let t0 = Instant::now();
2383 op(i);
2384 latencies.push(t0.elapsed().as_nanos());
2385 }
2386 latencies.sort_unstable();
2387 latencies
2388 }
2389
2390 fn p95(sorted: &[u128]) -> u128 {
2391 let len = sorted.len();
2392 let idx = ((len as f64 * 0.95) as usize).min(len.saturating_sub(1));
2393 sorted[idx]
2394 }
2395
2396 fn p99(sorted: &[u128]) -> u128 {
2397 let len = sorted.len();
2398 let idx = ((len as f64 * 0.99) as usize).min(len.saturating_sub(1));
2399 sorted[idx]
2400 }
2401
2402 fn median(sorted: &[u128]) -> u128 {
2403 sorted[sorted.len() / 2]
2404 }
2405
2406 #[allow(unexpected_cfgs)]
2407 fn perf_budget_ns(base_ns: u128) -> u128 {
2408 if cfg!(coverage) || cfg!(coverage_nightly) {
2409 base_ns.saturating_mul(10)
2410 } else {
2411 base_ns
2412 }
2413 }
2414
2415 #[test]
2416 fn perf_lru_hit_latency() {
2417 let mut cache = WidthCache::new(1000);
2418 for i in 0..100 {
2420 cache.get_or_compute(&format!("warm_{}", i));
2421 }
2422
2423 let keys: Vec<String> = (0..100).map(|i| format!("warm_{}", i)).collect();
2424 let latencies = measure_latencies(10_000, |i| {
2425 let _ = cache.get_or_compute(&keys[i % 100]);
2426 });
2427
2428 let p95_ns = p95(&latencies);
2429 let budget_ns = perf_budget_ns(5_000);
2432 assert!(
2433 p95_ns < budget_ns,
2434 "LRU hit p95 = {}ns exceeds {}ns budget (median={}ns, p99={}ns)",
2435 p95_ns,
2436 budget_ns,
2437 median(&latencies),
2438 p99(&latencies),
2439 );
2440 }
2441
2442 #[test]
2443 fn perf_tinylfu_hit_latency() {
2444 let mut cache = TinyLfuWidthCache::new(1000);
2445 for _ in 0..5 {
2447 for i in 0..100 {
2448 cache.get_or_compute(&format!("warm_{}", i));
2449 }
2450 }
2451
2452 let keys: Vec<String> = (0..100).map(|i| format!("warm_{}", i)).collect();
2453 let latencies = measure_latencies(10_000, |i| {
2454 let _ = cache.get_or_compute(&keys[i % 100]);
2455 });
2456
2457 let p95_ns = p95(&latencies);
2458 let budget_ns = perf_budget_ns(5_000);
2460 assert!(
2461 p95_ns < budget_ns,
2462 "TinyLFU hit p95 = {}ns exceeds {}ns budget (median={}ns, p99={}ns)",
2463 p95_ns,
2464 budget_ns,
2465 median(&latencies),
2466 p99(&latencies),
2467 );
2468 }
2469
2470 #[test]
2471 fn perf_tinylfu_miss_latency() {
2472 let mut cache = TinyLfuWidthCache::new(100);
2473 let keys: Vec<String> = (0..10_000).map(|i| format!("miss_{}", i)).collect();
2474
2475 let latencies = measure_latencies(10_000, |i| {
2476 let _ = cache.get_or_compute(&keys[i]);
2477 });
2478
2479 let p95_ns = p95(&latencies);
2480 let budget_ns = perf_budget_ns(20_000);
2483 assert!(
2484 p95_ns < budget_ns,
2485 "TinyLFU miss p95 = {}ns exceeds {}ns budget (median={}ns, p99={}ns)",
2486 p95_ns,
2487 budget_ns,
2488 median(&latencies),
2489 p99(&latencies),
2490 );
2491 }
2492
2493 #[test]
2494 fn perf_cms_increment_latency() {
2495 let mut cms = CountMinSketch::with_defaults();
2496 let hashes: Vec<u64> = (0..10_000).map(|i| hash_text(&format!("k{}", i))).collect();
2497
2498 let latencies = measure_latencies(10_000, |i| {
2499 cms.increment(hashes[i]);
2500 });
2501
2502 let p95_ns = p95(&latencies);
2503 let budget_ns = perf_budget_ns(2_000);
2505 assert!(
2506 p95_ns < budget_ns,
2507 "CMS increment p95 = {}ns exceeds {}ns budget (median={}ns)",
2508 p95_ns,
2509 budget_ns,
2510 median(&latencies),
2511 );
2512 }
2513
2514 #[test]
2515 fn perf_doorkeeper_latency() {
2516 let mut dk = Doorkeeper::with_defaults();
2517 let hashes: Vec<u64> = (0..10_000).map(|i| hash_text(&format!("d{}", i))).collect();
2518
2519 let latencies = measure_latencies(10_000, |i| {
2520 let _ = dk.check_and_set(hashes[i]);
2521 });
2522
2523 let p95_ns = p95(&latencies);
2524 let budget_ns = perf_budget_ns(1_000);
2526 assert!(
2527 p95_ns < budget_ns,
2528 "Doorkeeper p95 = {}ns exceeds {}ns budget (median={}ns)",
2529 p95_ns,
2530 budget_ns,
2531 median(&latencies),
2532 );
2533 }
2534
2535 #[test]
2536 fn perf_fingerprint_hash_latency() {
2537 let keys: Vec<String> = (0..10_000).map(|i| format!("fp_{}", i)).collect();
2538
2539 let latencies = measure_latencies(10_000, |i| {
2540 let _ = fingerprint_hash(&keys[i]);
2541 });
2542
2543 let p95_ns = p95(&latencies);
2544 let budget_ns = perf_budget_ns(1_000);
2546 assert!(
2547 p95_ns < budget_ns,
2548 "fingerprint_hash p95 = {}ns exceeds {}ns budget (median={}ns)",
2549 p95_ns,
2550 budget_ns,
2551 median(&latencies),
2552 );
2553 }
2554}