Skip to main content

ftui_text/
width_cache.rs

1#![forbid(unsafe_code)]
2
3//! LRU width cache for efficient text measurement.
4//!
5//! Text width calculation is a hot path (called every render frame).
6//! This cache stores computed widths to avoid redundant Unicode width
7//! calculations for repeated strings.
8//!
9//! ## VS16 Policy and Caching
10//!
11//! Cache keys are the grapheme string itself. The VS16 width policy
12//! (controlled by `FTUI_EMOJI_VS16_WIDTH`) is global and read once at
13//! startup via `OnceLock` in `ftui_core::text_width`. Changing the env
14//! var mid-process has no effect on cached or future lookups.
15//!
16//! # Example
17//! ```
18//! use ftui_text::WidthCache;
19//!
20//! let mut cache = WidthCache::new(1000);
21//!
22//! // First call computes width
23//! let width = cache.get_or_compute("Hello, world!");
24//! assert_eq!(width, 13);
25//!
26//! // Second call hits cache
27//! let width2 = cache.get_or_compute("Hello, world!");
28//! assert_eq!(width2, 13);
29//!
30//! // Check stats
31//! let stats = cache.stats();
32//! assert_eq!(stats.hits, 1);
33//! assert_eq!(stats.misses, 1);
34//! ```
35
36use lru::LruCache;
37use rustc_hash::FxHasher;
38use std::hash::{Hash, Hasher};
39use std::num::NonZeroUsize;
40
41/// Default cache capacity.
42pub const DEFAULT_CACHE_CAPACITY: usize = 4096;
43
44/// Statistics about cache performance.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46pub struct CacheStats {
47    /// Number of cache hits.
48    pub hits: u64,
49    /// Number of cache misses.
50    pub misses: u64,
51    /// Current number of entries.
52    pub size: usize,
53    /// Maximum capacity.
54    pub capacity: usize,
55}
56
57impl CacheStats {
58    /// Calculate hit rate (0.0 to 1.0).
59    #[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/// LRU cache for text width measurements.
71///
72/// This cache stores the computed display width (in terminal cells) for
73/// text strings, using an LRU eviction policy when capacity is reached.
74///
75/// # Performance
76/// - Uses FxHash for fast hashing
77/// - O(1) lookup and insertion
78/// - Automatic LRU eviction
79/// - Keys are stored as 64-bit hashes (not full strings) to minimize memory
80///
81/// # Hash Collisions
82/// The cache uses a 64-bit hash as the lookup key rather than storing the
83/// full string. This trades theoretical correctness for memory efficiency.
84/// With FxHash, collision probability is ~1 in 2^64, making this safe for
85/// practical use.
86///
87/// Note that `contains()`, `get()`, and `peek()` all key on this same hash, so
88/// none of them can distinguish a collision — on the (astronomically unlikely)
89/// event of two distinct strings hashing equal, a lookup for one returns the
90/// other's cached width. If you require collision-proof correctness, key a cache
91/// on the full string instead of using `WidthCache`.
92///
93/// # Thread Safety
94/// `WidthCache` is not thread-safe. For concurrent use, wrap in a mutex
95/// or use thread-local caches.
96#[derive(Debug)]
97pub struct WidthCache {
98    cache: LruCache<u64, usize>,
99    hits: u64,
100    misses: u64,
101}
102
103impl WidthCache {
104    /// Create a new cache with the specified capacity.
105    ///
106    /// If capacity is zero, defaults to 1.
107    #[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    /// Create a new cache with the default capacity (4096 entries).
118    #[must_use]
119    pub fn with_default_capacity() -> Self {
120        Self::new(DEFAULT_CACHE_CAPACITY)
121    }
122
123    /// Get cached width or compute and cache it.
124    ///
125    /// If the text is in the cache, returns the cached width.
126    /// Otherwise, computes the width using `display_width` and caches it.
127    #[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    /// Get cached width or compute using a custom function.
133    ///
134    /// This allows using custom width calculation functions for testing
135    /// or specialized terminal behavior.
136    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    /// Check if a text string is in the cache.
154    #[must_use]
155    pub fn contains(&self, text: &str) -> bool {
156        let hash = hash_text(text);
157        self.cache.contains(&hash)
158    }
159
160    /// Get the cached width for a text string without computing.
161    ///
162    /// Returns `None` if the text is not in the cache.
163    /// Note: This does update the LRU order.
164    #[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    /// Peek at the cached width without updating LRU order.
171    #[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    /// Pre-populate the cache with a text string.
178    ///
179    /// This is useful for warming up the cache with known strings.
180    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    /// Pre-populate the cache with multiple strings.
189    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    /// Clear the cache.
196    pub fn clear(&mut self) {
197        self.cache.clear();
198    }
199
200    /// Reset statistics.
201    pub fn reset_stats(&mut self) {
202        self.hits = 0;
203        self.misses = 0;
204    }
205
206    /// Get cache statistics.
207    #[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    /// Get the current number of cached entries.
219    #[inline]
220    #[must_use]
221    pub fn len(&self) -> usize {
222        self.cache.len()
223    }
224
225    /// Check if the cache is empty.
226    #[inline]
227    #[must_use]
228    pub fn is_empty(&self) -> bool {
229        self.cache.is_empty()
230    }
231
232    /// Get the cache capacity.
233    #[inline]
234    #[must_use]
235    pub fn capacity(&self) -> usize {
236        self.cache.cap().get()
237    }
238
239    /// Resize the cache capacity.
240    ///
241    /// If the new capacity is smaller than the current size,
242    /// entries will be evicted (LRU order).
243    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/// Hash a text string using FxHash for fast hashing.
256#[inline]
257fn hash_text(text: &str) -> u64 {
258    let mut hasher = FxHasher::default();
259    text.hash(&mut hasher);
260    hasher.finish()
261}
262
263// Thread-local width cache for convenience.
264//
265// This provides a global cache that is thread-local, avoiding the need
266// to pass a cache around explicitly.
267#[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/// Get or compute width using the thread-local cache.
274#[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/// Clear the thread-local cache.
280#[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); // Same entry
314
315        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); // 2 chars * 2 cells each
340    }
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        // Peek at "a" - should not update LRU order
374        let _ = cache.peek("a");
375
376        // Add "c" - should evict "a" (oldest)
377        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"); // Should evict "a"
391
392        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"); // Refresh "a" to most recent
404        cache.get_or_compute("c"); // Should evict "b"
405
406        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); // Preload doesn't count as miss
421        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        // Use a custom width function (always returns 42)
501        let width = cache.get_or_compute_with("hello", |_| 42);
502        assert_eq!(width, 42);
503
504        // Cached value is 42
505        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        // Even with hash collisions, the LRU should handle them
518        // (this is just a stress test with many entries)
519        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        // Various Unicode strings
533        assert_eq!(cache.get_or_compute("café"), 4);
534        assert_eq!(cache.get_or_compute("日本語"), 6);
535        assert_eq!(cache.get_or_compute("🎉"), 2); // Emoji typically 2 cells
536
537        assert_eq!(cache.len(), 3);
538    }
539
540    #[test]
541    fn combining_characters() {
542        let mut cache = WidthCache::new(100);
543
544        // e + combining acute accent
545        let width = cache.get_or_compute("e\u{0301}");
546        // Should be 1 cell (the combining char doesn't add width)
547        assert_eq!(width, 1);
548    }
549
550    // ==========================================================================
551    // Additional coverage tests
552    // ==========================================================================
553
554    #[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")); // hits
572    }
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; // Copy
592        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"); // First access
610        let len_before = cache.len();
611
612        cache.preload("hello"); // Already exists
613        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        // Family emoji (ZWJ sequence)
633        let width = cache.get_or_compute("👨‍👩‍👧");
634        // Width varies by implementation, just ensure it doesn't panic
635        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        // US flag emoji (regional indicators)
649        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        // Mixed ASCII and CJK
657        let width = cache.get_or_compute("Hello你好World");
658        assert_eq!(width, 14); // 10 ASCII + 4 CJK
659    }
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        // Access "a" via get() - should update LRU order
695        let _ = cache.get("a");
696
697        // Add "c" - should evict "b" (now oldest)
698        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 // This shouldn't be called
738        });
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); // 3 spaces
748        assert_eq!(cache.get_or_compute("\t"), 1); // Tab is 1 cell typically
749        assert_eq!(cache.get_or_compute("\n"), 1); // Newline
750    }
751}
752
753// ---------------------------------------------------------------------------
754// W-TinyLFU Admission Components (bd-4kq0.6.1)
755// ---------------------------------------------------------------------------
756//
757// # Design
758//
759// W-TinyLFU augments LRU eviction with a frequency-based admission filter:
760//
761// 1. **Count-Min Sketch (CMS)**: Approximate frequency counter.
762//    - Parameters: width `w`, depth `d`.
763//    - Error bound: estimated count <= true count + epsilon * N
764//      with probability >= 1 - delta, where:
765//        epsilon = e / w  (e = Euler's number ≈ 2.718)
766//        delta   = (1/2)^d
767//    - Chosen defaults: w=1024 (epsilon ≈ 0.0027), d=4 (delta ≈ 0.0625).
768//    - Counter width: 4 bits (saturating at 15). Periodic halving (aging)
769//      every `reset_interval` increments to prevent staleness.
770//
771// 2. **Doorkeeper**: 1-bit Bloom filter (single hash, `doorkeeper_bits` entries).
772//    - Filters one-hit wonders before they reach the CMS.
773//    - On first access: set doorkeeper bit. On second access in the same
774//      epoch: increment CMS. Cleared on CMS reset.
775//    - Default: 2048 bits (256 bytes).
776//
777// 3. **Admission rule**: When evicting, compare frequencies:
778//    - `freq(candidate) > freq(victim)` → admit candidate, evict victim.
779//    - `freq(candidate) <= freq(victim)` → reject candidate, keep victim.
780//
781// 4. **Fingerprint guard**: The CMS stores 64-bit hashes. Since the main
782//    cache also keys by 64-bit hash, a collision means two distinct strings
783//    share the same key. The fingerprint guard adds a secondary hash
784//    (different seed) stored alongside the value. On lookup, if the
785//    secondary hash mismatches, the entry is treated as a miss and evicted.
786//
787// # Failure Modes
788// - CMS overcounting: bounded by epsilon * N; aging limits staleness.
789// - Doorkeeper false positives: one-hit items may leak to CMS. Bounded
790//   by Bloom FP rate ≈ (1 - e^{-k*n/m})^k with k=1.
791// - Fingerprint collision (secondary hash): probability ~2^{-64}; negligible.
792// - Reset storm: halving all counters is O(w*d). With w=1024, d=4 this is
793//   4096 operations — negligible vs. rendering cost.
794
795/// Count-Min Sketch for approximate frequency estimation.
796///
797/// Uses `depth` independent hash functions (derived from a single hash via
798/// mixing) and `width` counters per row. Each counter is a `u8` saturating
799/// at `CountMinSketch::MAX_COUNT` (15 by default, representing 4-bit counters).
800///
801/// # Error Bounds
802///
803/// For a sketch with width `w` and depth `d`, after `N` total increments:
804/// - `estimate(x) <= true_count(x) + epsilon * N` with probability `>= 1 - delta`
805/// - where `epsilon = e / w` and `delta = (1/2)^d`
806#[derive(Debug, Clone)]
807pub struct CountMinSketch {
808    /// Counter matrix: `depth` rows of `width` counters each.
809    counters: Vec<Vec<u8>>,
810    /// Number of hash functions (rows).
811    depth: usize,
812    /// Number of counters per row.
813    width: usize,
814    /// Total number of increments since last reset.
815    total_increments: u64,
816    /// Increment count at which to halve all counters.
817    reset_interval: u64,
818}
819
820/// Maximum counter value (4-bit saturation).
821const CMS_MAX_COUNT: u8 = 15;
822
823/// Default CMS width. epsilon = e/1024 ≈ 0.0027.
824const CMS_DEFAULT_WIDTH: usize = 1024;
825
826/// Default CMS depth. delta = (1/2)^4 = 0.0625.
827const CMS_DEFAULT_DEPTH: usize = 4;
828
829/// Default reset interval (halve counters after this many increments).
830const CMS_DEFAULT_RESET_INTERVAL: u64 = 8192;
831
832impl CountMinSketch {
833    /// Create a new Count-Min Sketch with the given dimensions.
834    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    /// Create a sketch with default parameters (w=1024, d=4, reset=8192).
847    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    /// Increment the count for a key.
856    ///
857    /// Returns `true` if this increment triggered the periodic aging reset
858    /// (all counters halved). Callers pairing the sketch with a
859    /// [`Doorkeeper`] must clear the doorkeeper when this returns `true`,
860    /// so the one-hit-wonder filter restarts with the new epoch.
861    pub fn increment(&mut self, hash: u64) -> bool {
862        for row in 0..self.depth {
863            let idx = self.index(hash, row);
864            self.counters[row][idx] = self.counters[row][idx].saturating_add(1).min(CMS_MAX_COUNT);
865        }
866        self.total_increments += 1;
867
868        if self.total_increments >= self.reset_interval {
869            self.halve();
870            return true;
871        }
872        false
873    }
874
875    /// Estimate the frequency of a key (minimum across all rows).
876    pub fn estimate(&self, hash: u64) -> u8 {
877        let mut min = u8::MAX;
878        for row in 0..self.depth {
879            let idx = self.index(hash, row);
880            min = min.min(self.counters[row][idx]);
881        }
882        min
883    }
884
885    /// Total number of increments since creation or last reset.
886    pub fn total_increments(&self) -> u64 {
887        self.total_increments
888    }
889
890    /// Halve all counters (aging). Resets the increment counter.
891    fn halve(&mut self) {
892        for row in &mut self.counters {
893            for c in row.iter_mut() {
894                *c /= 2;
895            }
896        }
897        self.total_increments = 0;
898    }
899
900    /// Clear all counters to zero.
901    pub fn clear(&mut self) {
902        for row in &mut self.counters {
903            row.fill(0);
904        }
905        self.total_increments = 0;
906    }
907
908    /// Compute the column index for a given hash and row.
909    #[inline]
910    fn index(&self, hash: u64, row: usize) -> usize {
911        // Mix the hash with the row index for independent hash functions.
912        let mixed = hash
913            .wrapping_mul(0x517c_c1b7_2722_0a95)
914            .wrapping_add(row as u64);
915        let mixed = mixed ^ (mixed >> 32);
916        (mixed as usize) % self.width
917    }
918}
919
920/// 1-bit Bloom filter used as a doorkeeper to filter one-hit wonders.
921///
922/// On first access within an epoch, the doorkeeper sets a bit. Only on
923/// the second access does the item get promoted to the Count-Min Sketch.
924/// The doorkeeper is cleared whenever the CMS resets (halves).
925#[derive(Debug, Clone)]
926pub struct Doorkeeper {
927    bits: Vec<u64>,
928    num_bits: usize,
929}
930
931/// Default doorkeeper size in bits.
932const DOORKEEPER_DEFAULT_BITS: usize = 2048;
933
934impl Doorkeeper {
935    /// Create a new doorkeeper with the specified number of bits.
936    pub fn new(num_bits: usize) -> Self {
937        let num_bits = num_bits.max(64);
938        let num_words = num_bits.div_ceil(64);
939        Self {
940            bits: vec![0u64; num_words],
941            num_bits,
942        }
943    }
944
945    /// Create a doorkeeper with the default size (2048 bits).
946    pub fn with_defaults() -> Self {
947        Self::new(DOORKEEPER_DEFAULT_BITS)
948    }
949
950    /// Check if a key has been seen. Returns true if the bit was already set.
951    pub fn check_and_set(&mut self, hash: u64) -> bool {
952        let idx = (hash as usize) % self.num_bits;
953        let word = idx / 64;
954        let bit = idx % 64;
955        let was_set = (self.bits[word] >> bit) & 1 == 1;
956        self.bits[word] |= 1 << bit;
957        was_set
958    }
959
960    /// Check if a key has been seen without setting.
961    pub fn contains(&self, hash: u64) -> bool {
962        let idx = (hash as usize) % self.num_bits;
963        let word = idx / 64;
964        let bit = idx % 64;
965        (self.bits[word] >> bit) & 1 == 1
966    }
967
968    /// Clear all bits.
969    pub fn clear(&mut self) {
970        self.bits.fill(0);
971    }
972}
973
974/// Compute a secondary fingerprint hash for collision guard.
975///
976/// Uses a different multiplicative constant than FxHash to produce
977/// an independent 64-bit fingerprint.
978#[inline]
979pub fn fingerprint_hash(text: &str) -> u64 {
980    // Simple but effective: fold bytes with a different constant than FxHash.
981    let mut h: u64 = 0xcbf2_9ce4_8422_2325; // FNV offset basis
982    for &b in text.as_bytes() {
983        h ^= b as u64;
984        h = h.wrapping_mul(0x0100_0000_01b3); // FNV prime
985    }
986    h
987}
988
989/// Evaluate the TinyLFU admission rule.
990///
991/// Returns `true` if the candidate should be admitted (replacing the victim).
992///
993/// # Rule
994/// Admit if `freq(candidate) > freq(victim)`. On tie, reject (keep victim).
995#[inline]
996pub fn tinylfu_admit(candidate_freq: u8, victim_freq: u8) -> bool {
997    candidate_freq > victim_freq
998}
999
1000// ---------------------------------------------------------------------------
1001// W-TinyLFU Width Cache (bd-4kq0.6.2)
1002// ---------------------------------------------------------------------------
1003
1004/// Entry in the TinyLFU cache, storing value and fingerprint for collision guard.
1005#[derive(Debug, Clone)]
1006struct TinyLfuEntry {
1007    width: usize,
1008    fingerprint: u64,
1009}
1010
1011/// Width cache using W-TinyLFU admission policy.
1012///
1013/// Architecture:
1014/// - **Window cache** (small LRU, ~1% of capacity): captures recent items.
1015/// - **Main cache** (larger LRU, ~99% of capacity): for frequently accessed items.
1016/// - **Count-Min Sketch + Doorkeeper**: frequency estimation for admission decisions.
1017/// - **Fingerprint guard**: secondary hash per entry to detect hash collisions.
1018///
1019/// On every access:
1020/// 1. Check main cache → hit? Return value (verify fingerprint).
1021/// 2. Check window cache → hit? Return value (verify fingerprint).
1022/// 3. Miss: compute width, insert into window cache.
1023///
1024/// On window cache eviction:
1025/// 1. The evicted item becomes a candidate.
1026/// 2. The LRU victim of the main cache is identified.
1027/// 3. If `freq(candidate) > freq(victim)`, candidate enters main cache
1028///    (victim is evicted). Otherwise, candidate is discarded.
1029///
1030/// Frequency tracking uses Doorkeeper → CMS pipeline:
1031/// - First access: doorkeeper records.
1032/// - Second+ access: CMS is incremented.
1033#[derive(Debug)]
1034pub struct TinyLfuWidthCache {
1035    /// Small window cache (recency).
1036    window: LruCache<u64, TinyLfuEntry>,
1037    /// Large main cache (frequency-filtered).
1038    main: LruCache<u64, TinyLfuEntry>,
1039    /// Approximate frequency counter.
1040    sketch: CountMinSketch,
1041    /// One-hit-wonder filter.
1042    doorkeeper: Doorkeeper,
1043    /// Total capacity (window + main).
1044    total_capacity: usize,
1045    /// Hit/miss stats.
1046    hits: u64,
1047    misses: u64,
1048}
1049
1050impl TinyLfuWidthCache {
1051    /// Create a new TinyLFU cache with the given total capacity.
1052    ///
1053    /// The window gets ~1% of capacity (minimum 1), main gets the rest.
1054    pub fn new(total_capacity: usize) -> Self {
1055        let total_capacity = total_capacity.max(2);
1056        let window_cap = (total_capacity / 100).max(1);
1057        let main_cap = total_capacity - window_cap;
1058
1059        Self {
1060            window: LruCache::new(NonZeroUsize::new(window_cap).unwrap()),
1061            main: LruCache::new(NonZeroUsize::new(main_cap.max(1)).unwrap()),
1062            sketch: CountMinSketch::with_defaults(),
1063            doorkeeper: Doorkeeper::with_defaults(),
1064            total_capacity,
1065            hits: 0,
1066            misses: 0,
1067        }
1068    }
1069
1070    /// Get cached width or compute and cache it.
1071    pub fn get_or_compute(&mut self, text: &str) -> usize {
1072        self.get_or_compute_with(text, crate::display_width)
1073    }
1074
1075    /// Get cached width or compute using a custom function.
1076    pub fn get_or_compute_with<F>(&mut self, text: &str, compute: F) -> usize
1077    where
1078        F: FnOnce(&str) -> usize,
1079    {
1080        let hash = hash_text(text);
1081        let fp = fingerprint_hash(text);
1082
1083        // Record frequency via doorkeeper → CMS pipeline. When the CMS ages
1084        // (halves), the doorkeeper epoch ends with it: clear the filter so
1085        // one-hit wonders are filtered afresh instead of the bitset
1086        // saturating permanently.
1087        let seen = self.doorkeeper.check_and_set(hash);
1088        if seen && self.sketch.increment(hash) {
1089            self.doorkeeper.clear();
1090        }
1091
1092        // Check main cache first (larger, higher value).
1093        if let Some(entry) = self.main.get(&hash) {
1094            if entry.fingerprint == fp {
1095                self.hits += 1;
1096                return entry.width;
1097            }
1098            // Fingerprint mismatch: collision. Evict stale entry.
1099            self.main.pop(&hash);
1100        }
1101
1102        // Check window cache.
1103        if let Some(entry) = self.window.get(&hash) {
1104            if entry.fingerprint == fp {
1105                self.hits += 1;
1106                return entry.width;
1107            }
1108            // Collision in window cache.
1109            self.window.pop(&hash);
1110        }
1111
1112        // Cache miss: compute width.
1113        self.misses += 1;
1114        let width = compute(text);
1115        let new_entry = TinyLfuEntry {
1116            width,
1117            fingerprint: fp,
1118        };
1119
1120        // Insert into window cache. If window is full, the evicted item
1121        // goes through admission filter for main cache.
1122        if self.window.len() >= self.window.cap().get() {
1123            // Get the LRU item from window before it's evicted.
1124            if let Some((evicted_hash, evicted_entry)) = self.window.pop_lru() {
1125                self.try_admit_to_main(evicted_hash, evicted_entry);
1126            }
1127        }
1128        self.window.put(hash, new_entry);
1129
1130        width
1131    }
1132
1133    /// Try to admit a candidate (evicted from window) into the main cache.
1134    fn try_admit_to_main(&mut self, candidate_hash: u64, candidate_entry: TinyLfuEntry) {
1135        let candidate_freq = self.sketch.estimate(candidate_hash);
1136
1137        if self.main.len() < self.main.cap().get() {
1138            // Main has room — admit unconditionally.
1139            self.main.put(candidate_hash, candidate_entry);
1140            return;
1141        }
1142
1143        // Main is full. Compare candidate frequency with the LRU victim.
1144        if let Some((&victim_hash, _)) = self.main.peek_lru() {
1145            let victim_freq = self.sketch.estimate(victim_hash);
1146            if tinylfu_admit(candidate_freq, victim_freq) {
1147                self.main.pop_lru();
1148                self.main.put(candidate_hash, candidate_entry);
1149            }
1150            // Otherwise, candidate is discarded.
1151        }
1152    }
1153
1154    /// Check if a key is in the cache (window or main).
1155    pub fn contains(&self, text: &str) -> bool {
1156        let hash = hash_text(text);
1157        let fp = fingerprint_hash(text);
1158        if let Some(e) = self.main.peek(&hash)
1159            && e.fingerprint == fp
1160        {
1161            return true;
1162        }
1163        if let Some(e) = self.window.peek(&hash)
1164            && e.fingerprint == fp
1165        {
1166            return true;
1167        }
1168        false
1169    }
1170
1171    /// Get cache statistics.
1172    pub fn stats(&self) -> CacheStats {
1173        CacheStats {
1174            hits: self.hits,
1175            misses: self.misses,
1176            size: self.window.len() + self.main.len(),
1177            capacity: self.total_capacity,
1178        }
1179    }
1180
1181    /// Clear all caches and reset sketch/doorkeeper.
1182    pub fn clear(&mut self) {
1183        self.window.clear();
1184        self.main.clear();
1185        self.sketch.clear();
1186        self.doorkeeper.clear();
1187    }
1188
1189    /// Reset statistics.
1190    pub fn reset_stats(&mut self) {
1191        self.hits = 0;
1192        self.misses = 0;
1193    }
1194
1195    /// Current number of cached entries.
1196    pub fn len(&self) -> usize {
1197        self.window.len() + self.main.len()
1198    }
1199
1200    /// Check if cache is empty.
1201    pub fn is_empty(&self) -> bool {
1202        self.window.is_empty() && self.main.is_empty()
1203    }
1204
1205    /// Total capacity (window + main).
1206    pub fn capacity(&self) -> usize {
1207        self.total_capacity
1208    }
1209
1210    /// Number of entries in the main cache.
1211    pub fn main_len(&self) -> usize {
1212        self.main.len()
1213    }
1214
1215    /// Number of entries in the window cache.
1216    pub fn window_len(&self) -> usize {
1217        self.window.len()
1218    }
1219}
1220
1221// ── S3-FIFO Width Cache ────────────────────────────────────────────────
1222
1223/// Width cache backed by S3-FIFO eviction (bd-l6yba.2).
1224///
1225/// Drop-in replacement for [`WidthCache`] that uses the scan-resistant
1226/// S3-FIFO eviction policy instead of LRU. This protects frequently-used
1227/// width entries from being evicted by one-time scan patterns (e.g. when a
1228/// large block of new text scrolls past).
1229///
1230/// Uses the same 64-bit FxHash keying as [`WidthCache`] with a secondary
1231/// FNV fingerprint for collision detection.
1232#[derive(Debug)]
1233pub struct S3FifoWidthCache {
1234    cache: ftui_core::s3_fifo::S3Fifo<u64, S3FifoEntry>,
1235    hits: u64,
1236    misses: u64,
1237    total_capacity: usize,
1238}
1239
1240/// Entry stored in the S3-FIFO width cache.
1241#[derive(Debug, Clone, Copy)]
1242struct S3FifoEntry {
1243    width: usize,
1244    fingerprint: u64,
1245}
1246
1247impl S3FifoWidthCache {
1248    /// Create a new S3-FIFO width cache with the given capacity.
1249    pub fn new(capacity: usize) -> Self {
1250        let capacity = capacity.max(2);
1251        Self {
1252            cache: ftui_core::s3_fifo::S3Fifo::new(capacity),
1253            hits: 0,
1254            misses: 0,
1255            total_capacity: capacity,
1256        }
1257    }
1258
1259    /// Create a new cache with the default capacity (4096 entries).
1260    #[must_use]
1261    pub fn with_default_capacity() -> Self {
1262        Self::new(DEFAULT_CACHE_CAPACITY)
1263    }
1264
1265    /// Get cached width or compute and cache it.
1266    pub fn get_or_compute(&mut self, text: &str) -> usize {
1267        self.get_or_compute_with(text, crate::display_width)
1268    }
1269
1270    /// Get cached width or compute using a custom function.
1271    pub fn get_or_compute_with<F>(&mut self, text: &str, compute: F) -> usize
1272    where
1273        F: FnOnce(&str) -> usize,
1274    {
1275        let hash = hash_text(text);
1276        let fp = fingerprint_hash(text);
1277
1278        if let Some(entry) = self.cache.get(&hash) {
1279            if entry.fingerprint == fp {
1280                self.hits += 1;
1281                return entry.width;
1282            }
1283            // Fingerprint mismatch: collision. Remove stale entry.
1284            self.cache.remove(&hash);
1285        }
1286
1287        // Cache miss: compute width.
1288        self.misses += 1;
1289        let width = compute(text);
1290        self.cache.insert(
1291            hash,
1292            S3FifoEntry {
1293                width,
1294                fingerprint: fp,
1295            },
1296        );
1297        width
1298    }
1299
1300    /// Check if a key is in the cache.
1301    ///
1302    /// Keys on the 64-bit hash only (like [`WidthCache::contains`]): the
1303    /// fingerprint is not verified here, so a colliding string reports
1304    /// `true` even though `get_or_compute` would treat it as a miss.
1305    pub fn contains(&self, text: &str) -> bool {
1306        let hash = hash_text(text);
1307        self.cache.contains_key(&hash)
1308    }
1309
1310    /// Get cache statistics.
1311    pub fn stats(&self) -> CacheStats {
1312        CacheStats {
1313            hits: self.hits,
1314            misses: self.misses,
1315            size: self.cache.len(),
1316            capacity: self.total_capacity,
1317        }
1318    }
1319
1320    /// Clear the cache.
1321    pub fn clear(&mut self) {
1322        self.cache.clear();
1323        self.hits = 0;
1324        self.misses = 0;
1325    }
1326
1327    /// Reset statistics.
1328    pub fn reset_stats(&mut self) {
1329        self.hits = 0;
1330        self.misses = 0;
1331    }
1332
1333    /// Current number of cached entries.
1334    pub fn len(&self) -> usize {
1335        self.cache.len()
1336    }
1337
1338    /// Check if cache is empty.
1339    pub fn is_empty(&self) -> bool {
1340        self.cache.is_empty()
1341    }
1342
1343    /// Total capacity.
1344    pub fn capacity(&self) -> usize {
1345        self.total_capacity
1346    }
1347}
1348
1349impl Default for S3FifoWidthCache {
1350    fn default() -> Self {
1351        Self::with_default_capacity()
1352    }
1353}
1354
1355#[cfg(test)]
1356mod s3_fifo_width_tests {
1357    use super::*;
1358
1359    #[test]
1360    fn s3fifo_new_cache_is_empty() {
1361        let cache = S3FifoWidthCache::new(100);
1362        assert!(cache.is_empty());
1363        assert_eq!(cache.len(), 0);
1364    }
1365
1366    #[test]
1367    fn s3fifo_get_or_compute_caches_value() {
1368        let mut cache = S3FifoWidthCache::new(100);
1369        let w1 = cache.get_or_compute("hello");
1370        assert_eq!(w1, 5);
1371        assert_eq!(cache.len(), 1);
1372
1373        let w2 = cache.get_or_compute("hello");
1374        assert_eq!(w2, 5);
1375        assert_eq!(cache.len(), 1);
1376
1377        let stats = cache.stats();
1378        assert_eq!(stats.hits, 1);
1379        assert_eq!(stats.misses, 1);
1380    }
1381
1382    #[test]
1383    fn s3fifo_different_strings() {
1384        let mut cache = S3FifoWidthCache::new(100);
1385        cache.get_or_compute("hello");
1386        cache.get_or_compute("world");
1387        cache.get_or_compute("foo");
1388        assert_eq!(cache.len(), 3);
1389    }
1390
1391    #[test]
1392    fn s3fifo_cjk_width() {
1393        let mut cache = S3FifoWidthCache::new(100);
1394        let w = cache.get_or_compute("你好");
1395        assert_eq!(w, 4);
1396    }
1397
1398    #[test]
1399    fn s3fifo_contains() {
1400        let mut cache = S3FifoWidthCache::new(100);
1401        assert!(!cache.contains("hello"));
1402        cache.get_or_compute("hello");
1403        assert!(cache.contains("hello"));
1404    }
1405
1406    #[test]
1407    fn s3fifo_clear_resets() {
1408        let mut cache = S3FifoWidthCache::new(100);
1409        cache.get_or_compute("hello");
1410        cache.get_or_compute("world");
1411        cache.clear();
1412        assert!(cache.is_empty());
1413        assert!(!cache.contains("hello"));
1414        let stats = cache.stats();
1415        assert_eq!(stats.hits, 0);
1416        assert_eq!(stats.misses, 0);
1417    }
1418
1419    #[test]
1420    fn s3fifo_produces_same_widths_as_lru() {
1421        let mut lru = WidthCache::new(100);
1422        let mut s3 = S3FifoWidthCache::new(100);
1423
1424        let texts = [
1425            "hello",
1426            "你好世界",
1427            "abc",
1428            "🎉🎉",
1429            "",
1430            " ",
1431            "a\tb",
1432            "mixed中english文",
1433        ];
1434
1435        for text in &texts {
1436            let lru_w = lru.get_or_compute(text);
1437            let s3_w = s3.get_or_compute(text);
1438            assert_eq!(lru_w, s3_w, "width mismatch for {:?}", text);
1439        }
1440    }
1441
1442    #[test]
1443    fn s3fifo_scan_resistance_preserves_hot_set() {
1444        let mut cache = S3FifoWidthCache::new(50);
1445
1446        // Build a hot set
1447        let hot: Vec<String> = (0..20).map(|i| format!("hot_{i}")).collect();
1448        for text in &hot {
1449            cache.get_or_compute(text);
1450            cache.get_or_compute(text); // access twice to set freq
1451        }
1452
1453        // Scan through a large one-time set
1454        for i in 0..200 {
1455            cache.get_or_compute(&format!("scan_{i}"));
1456        }
1457
1458        // Some hot items should survive
1459        let mut survivors = 0;
1460        for text in &hot {
1461            if cache.contains(text) {
1462                survivors += 1;
1463            }
1464        }
1465        assert!(
1466            survivors > 5,
1467            "scan resistance: only {survivors}/20 hot items survived"
1468        );
1469    }
1470
1471    #[test]
1472    fn s3fifo_default_capacity() {
1473        let cache = S3FifoWidthCache::with_default_capacity();
1474        assert_eq!(cache.capacity(), DEFAULT_CACHE_CAPACITY);
1475    }
1476
1477    #[test]
1478    fn s3fifo_reset_stats() {
1479        let mut cache = S3FifoWidthCache::new(100);
1480        cache.get_or_compute("a");
1481        cache.get_or_compute("a");
1482        cache.reset_stats();
1483        let stats = cache.stats();
1484        assert_eq!(stats.hits, 0);
1485        assert_eq!(stats.misses, 0);
1486    }
1487}
1488
1489#[cfg(test)]
1490mod proptests {
1491    use super::*;
1492    use proptest::prelude::*;
1493
1494    proptest! {
1495        #[test]
1496        fn cached_width_matches_direct(s in "[a-zA-Z0-9 ]{1,50}") {
1497            let mut cache = WidthCache::new(100);
1498            let cached = cache.get_or_compute(&s);
1499            let direct = crate::display_width(&s);
1500            prop_assert_eq!(cached, direct);
1501        }
1502
1503        #[test]
1504        fn second_access_is_hit(s in "[a-zA-Z0-9]{1,20}") {
1505            let mut cache = WidthCache::new(100);
1506
1507            cache.get_or_compute(&s);
1508            let stats_before = cache.stats();
1509
1510            cache.get_or_compute(&s);
1511            let stats_after = cache.stats();
1512
1513            prop_assert_eq!(stats_after.hits, stats_before.hits + 1);
1514            prop_assert_eq!(stats_after.misses, stats_before.misses);
1515        }
1516
1517        #[test]
1518        fn lru_never_exceeds_capacity(
1519            strings in prop::collection::vec("[a-z]{1,5}", 10..100),
1520            capacity in 5usize..20
1521        ) {
1522            let mut cache = WidthCache::new(capacity);
1523
1524            for s in &strings {
1525                cache.get_or_compute(s);
1526                prop_assert!(cache.len() <= capacity);
1527            }
1528        }
1529
1530        #[test]
1531        fn preload_then_access_is_hit(s in "[a-zA-Z]{1,20}") {
1532            let mut cache = WidthCache::new(100);
1533
1534            cache.preload(&s);
1535            let stats_before = cache.stats();
1536
1537            cache.get_or_compute(&s);
1538            let stats_after = cache.stats();
1539
1540            // Should be a hit (preloaded)
1541            prop_assert_eq!(stats_after.hits, stats_before.hits + 1);
1542        }
1543    }
1544}
1545
1546// ---------------------------------------------------------------------------
1547// TinyLFU Spec Tests (bd-4kq0.6.1)
1548// ---------------------------------------------------------------------------
1549
1550#[cfg(test)]
1551mod tinylfu_tests {
1552    use super::*;
1553
1554    // --- Count-Min Sketch ---
1555
1556    #[test]
1557    fn unit_cms_single_key_count() {
1558        let mut cms = CountMinSketch::with_defaults();
1559        let h = hash_text("hello");
1560
1561        for _ in 0..5 {
1562            cms.increment(h);
1563        }
1564        assert_eq!(cms.estimate(h), 5);
1565    }
1566
1567    #[test]
1568    fn unit_cms_unseen_key_is_zero() {
1569        let cms = CountMinSketch::with_defaults();
1570        assert_eq!(cms.estimate(hash_text("never_seen")), 0);
1571    }
1572
1573    #[test]
1574    fn unit_cms_saturates_at_max() {
1575        let mut cms = CountMinSketch::with_defaults();
1576        let h = hash_text("hot");
1577
1578        for _ in 0..100 {
1579            cms.increment(h);
1580        }
1581        assert_eq!(cms.estimate(h), CMS_MAX_COUNT);
1582    }
1583
1584    #[test]
1585    fn unit_cms_bounds() {
1586        // Error bound: estimate(x) <= true_count(x) + epsilon * N.
1587        // With w=1024, epsilon = e/1024 ~ 0.00266.
1588        let mut cms = CountMinSketch::new(1024, 4, u64::MAX); // no reset
1589        let n: u64 = 1000;
1590
1591        // Insert 1000 unique keys
1592        for i in 0..n {
1593            cms.increment(i.wrapping_mul(0x9E37_79B9_7F4A_7C15).wrapping_add(1));
1594        }
1595
1596        // Check a target key inserted exactly 5 times
1597        let target = 0xDEAD_BEEF_u64;
1598        for _ in 0..5 {
1599            cms.increment(target);
1600        }
1601
1602        let est = cms.estimate(target);
1603        let epsilon = std::f64::consts::E / 1024.0;
1604        let upper_bound = 5.0 + epsilon * (n + 5) as f64;
1605
1606        assert!(
1607            (est as f64) <= upper_bound,
1608            "estimate {} exceeds bound {:.1} (epsilon={:.5}, N={})",
1609            est,
1610            upper_bound,
1611            epsilon,
1612            n + 5,
1613        );
1614        assert!(est >= 5, "estimate {} should be >= true count 5", est);
1615    }
1616
1617    #[test]
1618    fn unit_cms_bounds_mass_test() {
1619        let mut cms = CountMinSketch::new(1024, 4, u64::MAX);
1620        let n = 2000u64;
1621
1622        let mut true_counts = vec![0u8; n as usize];
1623        for i in 0..n {
1624            let count = (i % 10 + 1) as u8;
1625            true_counts[i as usize] = count;
1626            for _ in 0..count {
1627                cms.increment(i);
1628            }
1629        }
1630
1631        let total = cms.total_increments();
1632        let epsilon = std::f64::consts::E / 1024.0;
1633        let mut violations = 0u32;
1634
1635        for i in 0..n {
1636            let est = cms.estimate(i);
1637            let true_c = true_counts[i as usize];
1638            let upper = true_c as f64 + epsilon * total as f64;
1639            if est as f64 > upper + 0.5 {
1640                violations += 1;
1641            }
1642            assert!(
1643                est >= true_c,
1644                "key {}: estimate {} < true count {}",
1645                i,
1646                est,
1647                true_c
1648            );
1649        }
1650
1651        // delta = (1/2)^4 = 0.0625; allow generous threshold
1652        let violation_rate = violations as f64 / n as f64;
1653        assert!(
1654            violation_rate <= 0.10,
1655            "violation rate {:.3} exceeds delta threshold",
1656            violation_rate,
1657        );
1658    }
1659
1660    #[test]
1661    fn unit_cms_halving_ages_counts() {
1662        let mut cms = CountMinSketch::new(64, 2, 100);
1663
1664        let h = hash_text("test");
1665        for _ in 0..10 {
1666            cms.increment(h);
1667        }
1668        assert_eq!(cms.estimate(h), 10);
1669
1670        // Trigger reset by reaching reset_interval
1671        for _ in 10..100 {
1672            cms.increment(hash_text("noise"));
1673        }
1674
1675        let est = cms.estimate(h);
1676        assert!(est <= 5, "After halving, estimate {} should be <= 5", est);
1677    }
1678
1679    #[test]
1680    fn unit_cms_increment_reports_halving() {
1681        let mut cms = CountMinSketch::new(64, 2, 4);
1682        let h = hash_text("k");
1683        assert!(!cms.increment(h));
1684        assert!(!cms.increment(h));
1685        assert!(!cms.increment(h));
1686        assert!(cms.increment(h), "4th increment hits reset_interval=4");
1687        assert!(!cms.increment(h), "increment count restarts after halving");
1688    }
1689
1690    #[test]
1691    fn unit_cms_monotone() {
1692        let mut cms = CountMinSketch::with_defaults();
1693        let h = hash_text("key");
1694
1695        let mut prev_est = 0u8;
1696        for _ in 0..CMS_MAX_COUNT {
1697            cms.increment(h);
1698            let est = cms.estimate(h);
1699            assert!(est >= prev_est, "estimate should be monotone");
1700            prev_est = est;
1701        }
1702    }
1703
1704    // --- Doorkeeper ---
1705
1706    #[test]
1707    fn unit_doorkeeper_first_access_returns_false() {
1708        let mut dk = Doorkeeper::with_defaults();
1709        assert!(!dk.check_and_set(hash_text("new")));
1710    }
1711
1712    #[test]
1713    fn unit_doorkeeper_second_access_returns_true() {
1714        let mut dk = Doorkeeper::with_defaults();
1715        let h = hash_text("key");
1716        dk.check_and_set(h);
1717        assert!(dk.check_and_set(h));
1718    }
1719
1720    #[test]
1721    fn unit_doorkeeper_contains() {
1722        let mut dk = Doorkeeper::with_defaults();
1723        let h = hash_text("key");
1724        assert!(!dk.contains(h));
1725        dk.check_and_set(h);
1726        assert!(dk.contains(h));
1727    }
1728
1729    #[test]
1730    fn unit_doorkeeper_clear_resets() {
1731        let mut dk = Doorkeeper::with_defaults();
1732        let h = hash_text("key");
1733        dk.check_and_set(h);
1734        dk.clear();
1735        assert!(!dk.contains(h));
1736        assert!(!dk.check_and_set(h));
1737    }
1738
1739    #[test]
1740    fn unit_doorkeeper_false_positive_rate() {
1741        let mut dk = Doorkeeper::new(2048);
1742        let n = 100u64;
1743
1744        for i in 0..n {
1745            dk.check_and_set(i * 0x9E37_79B9 + 1);
1746        }
1747
1748        let mut false_positives = 0u32;
1749        for i in 0..1000 {
1750            let h = (i + 100_000) * 0x6A09_E667 + 7;
1751            if dk.contains(h) {
1752                false_positives += 1;
1753            }
1754        }
1755
1756        // k=1, m=2048, n=100: FP rate ~ 1 - e^{-100/2048} ~ 0.048
1757        let fp_rate = false_positives as f64 / 1000.0;
1758        assert!(
1759            fp_rate < 0.15,
1760            "FP rate {:.3} too high (expected < 0.15)",
1761            fp_rate,
1762        );
1763    }
1764
1765    // --- Admission Rule ---
1766
1767    #[test]
1768    fn unit_admission_rule() {
1769        assert!(tinylfu_admit(5, 3)); // candidate > victim -> admit
1770        assert!(!tinylfu_admit(3, 5)); // candidate < victim -> reject
1771        assert!(!tinylfu_admit(3, 3)); // tie -> reject (keep victim)
1772    }
1773
1774    #[test]
1775    fn unit_admission_rule_extremes() {
1776        assert!(tinylfu_admit(1, 0));
1777        assert!(!tinylfu_admit(0, 0));
1778        assert!(!tinylfu_admit(0, 1));
1779        assert!(tinylfu_admit(CMS_MAX_COUNT, CMS_MAX_COUNT - 1));
1780        assert!(!tinylfu_admit(CMS_MAX_COUNT, CMS_MAX_COUNT));
1781    }
1782
1783    // --- Fingerprint Guard ---
1784
1785    #[test]
1786    fn unit_fingerprint_guard() {
1787        let fp1 = fingerprint_hash("hello");
1788        let fp2 = fingerprint_hash("world");
1789        let fp3 = fingerprint_hash("hello");
1790
1791        assert_ne!(
1792            fp1, fp2,
1793            "Different strings should have different fingerprints"
1794        );
1795        assert_eq!(fp1, fp3, "Same string should have same fingerprint");
1796    }
1797
1798    #[test]
1799    fn unit_fingerprint_guard_collision_rate() {
1800        let mut fps = std::collections::HashSet::new();
1801        let n = 10_000;
1802
1803        for i in 0..n {
1804            let s = format!("string_{}", i);
1805            fps.insert(fingerprint_hash(&s));
1806        }
1807
1808        let collisions = n - fps.len();
1809        assert!(
1810            collisions == 0,
1811            "Expected 0 collisions in 10k items, got {}",
1812            collisions,
1813        );
1814    }
1815
1816    #[test]
1817    fn unit_fingerprint_independent_of_primary_hash() {
1818        let text = "test_string";
1819        let primary = hash_text(text);
1820        let secondary = fingerprint_hash(text);
1821
1822        assert_ne!(
1823            primary, secondary,
1824            "Fingerprint and primary hash should differ"
1825        );
1826    }
1827
1828    // --- Integration: Doorkeeper + CMS pipeline ---
1829
1830    #[test]
1831    fn unit_doorkeeper_cms_pipeline() {
1832        let mut dk = Doorkeeper::with_defaults();
1833        let mut cms = CountMinSketch::with_defaults();
1834        let h = hash_text("item");
1835
1836        // First access: doorkeeper records
1837        assert!(!dk.check_and_set(h));
1838        assert_eq!(cms.estimate(h), 0);
1839
1840        // Second access: doorkeeper confirms, CMS incremented
1841        assert!(dk.check_and_set(h));
1842        cms.increment(h);
1843        assert_eq!(cms.estimate(h), 1);
1844
1845        // Third access
1846        assert!(dk.check_and_set(h));
1847        cms.increment(h);
1848        assert_eq!(cms.estimate(h), 2);
1849    }
1850
1851    #[test]
1852    fn unit_doorkeeper_filters_one_hit_wonders() {
1853        let mut dk = Doorkeeper::with_defaults();
1854        let mut cms = CountMinSketch::with_defaults();
1855
1856        // 100 one-hit items
1857        for i in 0u64..100 {
1858            let h = i * 0x9E37_79B9 + 1;
1859            let seen = dk.check_and_set(h);
1860            if seen {
1861                cms.increment(h);
1862            }
1863        }
1864
1865        assert_eq!(cms.total_increments(), 0);
1866
1867        // Access one again -> passes doorkeeper
1868        let h = 1; // i=0 from the loop above
1869        assert!(dk.check_and_set(h));
1870        cms.increment(h);
1871        assert_eq!(cms.total_increments(), 1);
1872    }
1873}
1874
1875// ---------------------------------------------------------------------------
1876// TinyLFU Implementation Tests (bd-4kq0.6.2)
1877// ---------------------------------------------------------------------------
1878
1879#[cfg(test)]
1880mod tinylfu_impl_tests {
1881    use super::*;
1882
1883    #[test]
1884    fn basic_get_or_compute() {
1885        let mut cache = TinyLfuWidthCache::new(100);
1886        let w = cache.get_or_compute("hello");
1887        assert_eq!(w, 5);
1888        assert_eq!(cache.len(), 1);
1889
1890        let w2 = cache.get_or_compute("hello");
1891        assert_eq!(w2, 5);
1892        let stats = cache.stats();
1893        assert_eq!(stats.misses, 1);
1894        assert_eq!(stats.hits, 1);
1895    }
1896
1897    #[test]
1898    fn window_to_main_promotion() {
1899        // With capacity=100, window=1, main=99.
1900        // Fill window, then force eviction into main via new inserts.
1901        let mut cache = TinyLfuWidthCache::new(100);
1902
1903        // Access "frequent" many times to build CMS frequency.
1904        for _ in 0..10 {
1905            cache.get_or_compute("frequent");
1906        }
1907
1908        // Insert enough items to fill window and force eviction.
1909        for i in 0..5 {
1910            cache.get_or_compute(&format!("item_{}", i));
1911        }
1912
1913        // "frequent" should have been promoted to main cache via admission.
1914        assert!(cache.contains("frequent"));
1915        assert!(cache.main_len() > 0 || cache.window_len() > 0);
1916    }
1917
1918    #[test]
1919    fn unit_window_promotion() {
1920        // Frequent items should end up in main cache.
1921        let mut cache = TinyLfuWidthCache::new(50);
1922
1923        // Access "hot" repeatedly to build frequency.
1924        for _ in 0..20 {
1925            cache.get_or_compute("hot");
1926        }
1927
1928        // Now push enough items through window to force "hot" out of window.
1929        for i in 0..10 {
1930            cache.get_or_compute(&format!("filler_{}", i));
1931        }
1932
1933        // "hot" should still be accessible (promoted to main via admission).
1934        assert!(cache.contains("hot"), "Frequent item should be retained");
1935    }
1936
1937    #[test]
1938    fn fingerprint_guard_detects_collision() {
1939        let mut cache = TinyLfuWidthCache::new(100);
1940
1941        // Compute "hello" with custom function.
1942        let w = cache.get_or_compute_with("hello", |_| 42);
1943        assert_eq!(w, 42);
1944
1945        // Verify it's cached.
1946        assert!(cache.contains("hello"));
1947    }
1948
1949    #[test]
1950    fn admission_rejects_infrequent() {
1951        // Fill main cache with frequently-accessed items.
1952        // Then try to insert a cold item — it should be rejected.
1953        let mut cache = TinyLfuWidthCache::new(10); // window=1, main=9
1954
1955        // Fill main with items accessed multiple times.
1956        for i in 0..9 {
1957            let s = format!("hot_{}", i);
1958            for _ in 0..5 {
1959                cache.get_or_compute(&s);
1960            }
1961        }
1962
1963        // Now insert cold items. They should go through window but not
1964        // necessarily get into main.
1965        for i in 0..20 {
1966            cache.get_or_compute(&format!("cold_{}", i));
1967        }
1968
1969        // Hot items should mostly survive (they have high frequency).
1970        let hot_survivors: usize = (0..9)
1971            .filter(|i| cache.contains(&format!("hot_{}", i)))
1972            .count();
1973        assert!(
1974            hot_survivors >= 5,
1975            "Expected most hot items to survive, got {}/9",
1976            hot_survivors
1977        );
1978    }
1979
1980    #[test]
1981    fn doorkeeper_clears_when_cms_ages() {
1982        // The doorkeeper epoch is tied to CMS aging: when the sketch halves,
1983        // the doorkeeper must be cleared so one-hit-wonder filtering restarts
1984        // instead of the bitset saturating permanently.
1985        let mut cache = TinyLfuWidthCache::new(100);
1986        let h = hash_text("hot");
1987
1988        // First access seeds the doorkeeper without touching the CMS.
1989        cache.get_or_compute("hot");
1990        assert!(cache.doorkeeper.contains(h));
1991        assert_eq!(cache.sketch.total_increments(), 0);
1992
1993        // Every subsequent access increments the CMS once. Drive exactly to
1994        // the reset interval: the aging access must also clear the doorkeeper.
1995        for _ in 0..CMS_DEFAULT_RESET_INTERVAL {
1996            cache.get_or_compute("hot");
1997        }
1998        assert_eq!(
1999            cache.sketch.total_increments(),
2000            0,
2001            "CMS should have aged (halved) at the reset interval"
2002        );
2003        assert!(
2004            !cache.doorkeeper.contains(h),
2005            "doorkeeper must be cleared when the CMS epoch rolls over"
2006        );
2007    }
2008
2009    #[test]
2010    fn clear_empties_everything() {
2011        let mut cache = TinyLfuWidthCache::new(100);
2012        cache.get_or_compute("a");
2013        cache.get_or_compute("b");
2014        cache.clear();
2015        assert!(cache.is_empty());
2016        assert_eq!(cache.len(), 0);
2017    }
2018
2019    #[test]
2020    fn stats_reflect_usage() {
2021        let mut cache = TinyLfuWidthCache::new(100);
2022        cache.get_or_compute("a");
2023        cache.get_or_compute("a");
2024        cache.get_or_compute("b");
2025
2026        let stats = cache.stats();
2027        assert_eq!(stats.misses, 2);
2028        assert_eq!(stats.hits, 1);
2029        assert_eq!(stats.size, 2);
2030    }
2031
2032    #[test]
2033    fn capacity_is_respected() {
2034        let mut cache = TinyLfuWidthCache::new(20);
2035
2036        for i in 0..100 {
2037            cache.get_or_compute(&format!("item_{}", i));
2038        }
2039
2040        assert!(
2041            cache.len() <= 20,
2042            "Cache size {} exceeds capacity 20",
2043            cache.len()
2044        );
2045    }
2046
2047    #[test]
2048    fn reset_stats_works() {
2049        let mut cache = TinyLfuWidthCache::new(100);
2050        cache.get_or_compute("x");
2051        cache.get_or_compute("x");
2052        cache.reset_stats();
2053        let stats = cache.stats();
2054        assert_eq!(stats.hits, 0);
2055        assert_eq!(stats.misses, 0);
2056    }
2057
2058    #[test]
2059    fn perf_cache_hit_rate() {
2060        // Simulate a Zipfian-like workload: some items accessed frequently,
2061        // many accessed rarely. TinyLFU should achieve decent hit rate.
2062        let mut cache = TinyLfuWidthCache::new(50);
2063
2064        // 10 hot items accessed 20 times each.
2065        for _ in 0..20 {
2066            for i in 0..10 {
2067                cache.get_or_compute(&format!("hot_{}", i));
2068            }
2069        }
2070
2071        // 100 cold items accessed once each.
2072        for i in 0..100 {
2073            cache.get_or_compute(&format!("cold_{}", i));
2074        }
2075
2076        // Re-access hot items — these should mostly be hits.
2077        cache.reset_stats();
2078        for i in 0..10 {
2079            cache.get_or_compute(&format!("hot_{}", i));
2080        }
2081
2082        let stats = cache.stats();
2083        // Hot items should have high hit rate after being frequently accessed.
2084        assert!(
2085            stats.hits >= 5,
2086            "Expected at least 5/10 hot items to hit, got {}",
2087            stats.hits
2088        );
2089    }
2090
2091    #[test]
2092    fn unicode_strings_work() {
2093        let mut cache = TinyLfuWidthCache::new(100);
2094        assert_eq!(cache.get_or_compute("日本語"), 6);
2095        assert_eq!(cache.get_or_compute("café"), 4);
2096        assert_eq!(cache.get_or_compute("日本語"), 6); // hit
2097        assert_eq!(cache.stats().hits, 1);
2098    }
2099
2100    #[test]
2101    fn empty_string() {
2102        let mut cache = TinyLfuWidthCache::new(100);
2103        assert_eq!(cache.get_or_compute(""), 0);
2104    }
2105
2106    #[test]
2107    fn minimum_capacity() {
2108        let cache = TinyLfuWidthCache::new(0);
2109        assert!(cache.capacity() >= 2);
2110    }
2111}
2112
2113// ---------------------------------------------------------------------------
2114// bd-4kq0.6.3: WidthCache Tests + Perf Gates
2115// ---------------------------------------------------------------------------
2116
2117/// Deterministic LCG for test reproducibility (no external rand dependency).
2118#[cfg(test)]
2119struct Lcg(u64);
2120
2121#[cfg(test)]
2122impl Lcg {
2123    fn new(seed: u64) -> Self {
2124        Self(seed)
2125    }
2126    fn next_u64(&mut self) -> u64 {
2127        self.0 = self
2128            .0
2129            .wrapping_mul(6_364_136_223_846_793_005)
2130            .wrapping_add(1);
2131        self.0
2132    }
2133    fn next_usize(&mut self, max: usize) -> usize {
2134        (self.next_u64() % (max as u64)) as usize
2135    }
2136}
2137
2138// ---------------------------------------------------------------------------
2139// 1. Property: cache equivalence (cached width == computed width)
2140// ---------------------------------------------------------------------------
2141
2142#[cfg(test)]
2143mod property_cache_equivalence {
2144    use super::*;
2145    use proptest::prelude::*;
2146
2147    proptest! {
2148        #[test]
2149        fn tinylfu_cached_equals_computed(s in "[a-zA-Z0-9 ]{1,80}") {
2150            let mut cache = TinyLfuWidthCache::new(200);
2151            let cached = cache.get_or_compute(&s);
2152            let direct = crate::display_width(&s);
2153            prop_assert_eq!(cached, direct,
2154                "TinyLFU returned {} but display_width says {} for {:?}", cached, direct, s);
2155        }
2156
2157        #[test]
2158        fn tinylfu_second_access_same_value(s in "[a-zA-Z0-9]{1,40}") {
2159            let mut cache = TinyLfuWidthCache::new(200);
2160            let first = cache.get_or_compute(&s);
2161            let second = cache.get_or_compute(&s);
2162            prop_assert_eq!(first, second,
2163                "First access returned {} but second returned {} for {:?}", first, second, s);
2164        }
2165
2166        #[test]
2167        fn tinylfu_never_exceeds_capacity(
2168            strings in prop::collection::vec("[a-z]{1,5}", 10..200),
2169            capacity in 10usize..50
2170        ) {
2171            let mut cache = TinyLfuWidthCache::new(capacity);
2172            for s in &strings {
2173                cache.get_or_compute(s);
2174                prop_assert!(cache.len() <= capacity,
2175                    "Cache size {} exceeded capacity {}", cache.len(), capacity);
2176            }
2177        }
2178
2179        #[test]
2180        fn tinylfu_custom_fn_matches(s in "[a-z]{1,20}") {
2181            let mut cache = TinyLfuWidthCache::new(100);
2182            let custom_fn = |text: &str| text.len(); // byte length as custom metric
2183            let cached = cache.get_or_compute_with(&s, custom_fn);
2184            prop_assert_eq!(cached, s.len(),
2185                "Custom fn: cached {} != expected {} for {:?}", cached, s.len(), s);
2186        }
2187    }
2188
2189    #[test]
2190    fn deterministic_seed_equivalence() {
2191        // Same workload with same seed → same results every time.
2192        let mut rng = super::Lcg::new(0xDEAD_BEEF);
2193
2194        let mut cache1 = TinyLfuWidthCache::new(50);
2195        let mut results1 = Vec::new();
2196        for _ in 0..500 {
2197            let idx = rng.next_usize(100);
2198            let s = format!("key_{}", idx);
2199            results1.push(cache1.get_or_compute(&s));
2200        }
2201
2202        let mut rng2 = super::Lcg::new(0xDEAD_BEEF);
2203        let mut cache2 = TinyLfuWidthCache::new(50);
2204        let mut results2 = Vec::new();
2205        for _ in 0..500 {
2206            let idx = rng2.next_usize(100);
2207            let s = format!("key_{}", idx);
2208            results2.push(cache2.get_or_compute(&s));
2209        }
2210
2211        assert_eq!(
2212            results1, results2,
2213            "Deterministic seed must produce identical results"
2214        );
2215    }
2216
2217    #[test]
2218    fn both_caches_agree_on_widths() {
2219        // WidthCache (plain LRU) and TinyLfuWidthCache must return the same
2220        // widths for any input.
2221        let mut lru = WidthCache::new(200);
2222        let mut tlfu = TinyLfuWidthCache::new(200);
2223
2224        let inputs = [
2225            "",
2226            "hello",
2227            "日本語テスト",
2228            "café résumé",
2229            "a\tb",
2230            "🎉🎊🎈",
2231            "mixed日本eng",
2232            "    spaces    ",
2233            "AAAAAAAAAAAAAAAAAAAAAAAAA",
2234            "x",
2235        ];
2236
2237        for &s in &inputs {
2238            let w1 = lru.get_or_compute(s);
2239            let w2 = tlfu.get_or_compute(s);
2240            assert_eq!(
2241                w1, w2,
2242                "Width mismatch for {:?}: LRU={}, TinyLFU={}",
2243                s, w1, w2
2244            );
2245        }
2246    }
2247}
2248
2249// ---------------------------------------------------------------------------
2250// 2. E2E cache replay: deterministic workload, log hit rate + latency (JSONL)
2251// ---------------------------------------------------------------------------
2252
2253#[cfg(test)]
2254mod e2e_cache_replay {
2255    use super::*;
2256    use std::time::Instant;
2257
2258    /// A single replay record, serialisable to JSONL.
2259    #[derive(Debug)]
2260    struct ReplayRecord {
2261        step: usize,
2262        key: String,
2263        width: usize,
2264        hit: bool,
2265        latency_ns: u128,
2266    }
2267
2268    impl ReplayRecord {
2269        fn to_jsonl(&self) -> String {
2270            format!(
2271                r#"{{"step":{},"key":"{}","width":{},"hit":{},"latency_ns":{}}}"#,
2272                self.step, self.key, self.width, self.hit, self.latency_ns,
2273            )
2274        }
2275    }
2276
2277    fn zipfian_workload(rng: &mut super::Lcg, n: usize, universe: usize) -> Vec<String> {
2278        // Approximate Zipfian: lower indices are much more frequent.
2279        (0..n)
2280            .map(|_| {
2281                let raw = rng.next_usize(universe * universe);
2282                let idx = (raw as f64).sqrt() as usize % universe;
2283                format!("item_{}", idx)
2284            })
2285            .collect()
2286    }
2287
2288    #[test]
2289    fn replay_zipfian_tinylfu() {
2290        let mut rng = super::Lcg::new(0x1234_5678);
2291        let workload = zipfian_workload(&mut rng, 2000, 200);
2292
2293        let mut cache = TinyLfuWidthCache::new(50);
2294        let mut records = Vec::with_capacity(workload.len());
2295
2296        for (i, key) in workload.iter().enumerate() {
2297            let stats_before = cache.stats();
2298            let t0 = Instant::now();
2299            let width = cache.get_or_compute(key);
2300            let elapsed = t0.elapsed().as_nanos();
2301            let stats_after = cache.stats();
2302            let hit = stats_after.hits > stats_before.hits;
2303
2304            records.push(ReplayRecord {
2305                step: i,
2306                key: key.clone(),
2307                width,
2308                hit,
2309                latency_ns: elapsed,
2310            });
2311        }
2312
2313        // Validate JSONL serialisation is parseable.
2314        for r in &records[..5] {
2315            let line = r.to_jsonl();
2316            assert!(
2317                line.starts_with('{') && line.ends_with('}'),
2318                "Bad JSONL: {}",
2319                line
2320            );
2321        }
2322
2323        // Compute aggregate stats.
2324        let total = records.len();
2325        let hits = records.iter().filter(|r| r.hit).count();
2326        let hit_rate = hits as f64 / total as f64;
2327
2328        // Zipfian workload with 50-entry cache over 200-item universe
2329        // should get a meaningful hit rate (> 10%).
2330        assert!(
2331            hit_rate > 0.10,
2332            "Zipfian hit rate too low: {:.2}% ({}/{})",
2333            hit_rate * 100.0,
2334            hits,
2335            total
2336        );
2337    }
2338
2339    #[test]
2340    fn replay_zipfian_lru_comparison() {
2341        let mut rng = super::Lcg::new(0x1234_5678);
2342        let workload = zipfian_workload(&mut rng, 2000, 200);
2343
2344        let mut tlfu = TinyLfuWidthCache::new(50);
2345        let mut lru = WidthCache::new(50);
2346
2347        for key in &workload {
2348            tlfu.get_or_compute(key);
2349            lru.get_or_compute(key);
2350        }
2351
2352        let tlfu_stats = tlfu.stats();
2353        let lru_stats = lru.stats();
2354
2355        // Both must have correct total operations.
2356        assert_eq!(tlfu_stats.hits + tlfu_stats.misses, 2000);
2357        assert_eq!(lru_stats.hits + lru_stats.misses, 2000);
2358
2359        // TinyLFU should be at least competitive with plain LRU on Zipfian.
2360        // (In practice TinyLFU often wins, but we just check both work.)
2361        assert!(
2362            tlfu_stats.hit_rate() >= lru_stats.hit_rate() * 0.8,
2363            "TinyLFU hit rate {:.2}% much worse than LRU {:.2}%",
2364            tlfu_stats.hit_rate() * 100.0,
2365            lru_stats.hit_rate() * 100.0,
2366        );
2367    }
2368
2369    #[test]
2370    fn replay_deterministic_reproduction() {
2371        // Two identical replays must produce identical hit/miss sequences.
2372        let run = |seed: u64| -> Vec<bool> {
2373            let mut rng = super::Lcg::new(seed);
2374            let workload = zipfian_workload(&mut rng, 500, 100);
2375            let mut cache = TinyLfuWidthCache::new(30);
2376            let mut hits = Vec::with_capacity(500);
2377            for key in &workload {
2378                let before = cache.stats().hits;
2379                cache.get_or_compute(key);
2380                hits.push(cache.stats().hits > before);
2381            }
2382            hits
2383        };
2384
2385        let run1 = run(0xABCD_EF01);
2386        let run2 = run(0xABCD_EF01);
2387        assert_eq!(run1, run2, "Deterministic replay diverged");
2388    }
2389
2390    #[test]
2391    fn replay_uniform_workload() {
2392        // Uniform access pattern: every key equally likely. Hit rate should be
2393        // roughly cache_size / universe_size for large N.
2394        let mut rng = super::Lcg::new(0x9999);
2395        let universe = 100;
2396        let cache_size = 25;
2397        let n = 5000;
2398
2399        let mut cache = TinyLfuWidthCache::new(cache_size);
2400
2401        // Warm up: access each key once.
2402        for i in 0..universe {
2403            cache.get_or_compute(&format!("u_{}", i));
2404        }
2405
2406        cache.reset_stats();
2407        for _ in 0..n {
2408            let idx = rng.next_usize(universe);
2409            cache.get_or_compute(&format!("u_{}", idx));
2410        }
2411
2412        let stats = cache.stats();
2413        let hit_rate = stats.hit_rate();
2414        // Theoretical: ~25/100 = 25%. Allow range 10%–60%.
2415        assert!(
2416            hit_rate > 0.10 && hit_rate < 0.60,
2417            "Uniform hit rate {:.2}% outside expected range",
2418            hit_rate * 100.0,
2419        );
2420    }
2421}
2422
2423// ---------------------------------------------------------------------------
2424// 3. Perf gates: cache operations < 1us p95
2425// ---------------------------------------------------------------------------
2426
2427#[cfg(test)]
2428mod perf_cache_overhead {
2429    use super::*;
2430    use std::time::Instant;
2431
2432    /// Collect latencies for N operations, return sorted Vec<u128> in nanoseconds.
2433    fn measure_latencies<F: FnMut(usize)>(n: usize, mut op: F) -> Vec<u128> {
2434        let mut latencies = Vec::with_capacity(n);
2435        for i in 0..n {
2436            let t0 = Instant::now();
2437            op(i);
2438            latencies.push(t0.elapsed().as_nanos());
2439        }
2440        latencies.sort_unstable();
2441        latencies
2442    }
2443
2444    fn p95(sorted: &[u128]) -> u128 {
2445        let len = sorted.len();
2446        let idx = ((len as f64 * 0.95) as usize).min(len.saturating_sub(1));
2447        sorted[idx]
2448    }
2449
2450    fn p99(sorted: &[u128]) -> u128 {
2451        let len = sorted.len();
2452        let idx = ((len as f64 * 0.99) as usize).min(len.saturating_sub(1));
2453        sorted[idx]
2454    }
2455
2456    fn median(sorted: &[u128]) -> u128 {
2457        sorted[sorted.len() / 2]
2458    }
2459
2460    #[allow(unexpected_cfgs)]
2461    fn perf_budget_ns(base_ns: u128) -> u128 {
2462        if cfg!(coverage) || cfg!(coverage_nightly) {
2463            base_ns.saturating_mul(10)
2464        } else {
2465            base_ns
2466        }
2467    }
2468
2469    #[test]
2470    fn perf_lru_hit_latency() {
2471        let mut cache = WidthCache::new(1000);
2472        // Warm up.
2473        for i in 0..100 {
2474            cache.get_or_compute(&format!("warm_{}", i));
2475        }
2476
2477        let keys: Vec<String> = (0..100).map(|i| format!("warm_{}", i)).collect();
2478        let latencies = measure_latencies(10_000, |i| {
2479            let _ = cache.get_or_compute(&keys[i % 100]);
2480        });
2481
2482        let p95_ns = p95(&latencies);
2483        // Budget: < 1us (1000ns) p95 for cache hits.
2484        // Use generous 5us to account for CI variability (10x under coverage).
2485        let budget_ns = perf_budget_ns(5_000);
2486        assert!(
2487            p95_ns < budget_ns,
2488            "LRU hit p95 = {}ns exceeds {}ns budget (median={}ns, p99={}ns)",
2489            p95_ns,
2490            budget_ns,
2491            median(&latencies),
2492            p99(&latencies),
2493        );
2494    }
2495
2496    #[test]
2497    fn perf_tinylfu_hit_latency() {
2498        let mut cache = TinyLfuWidthCache::new(1000);
2499        // Warm up: access each key multiple times to ensure promotion to main.
2500        for _ in 0..5 {
2501            for i in 0..100 {
2502                cache.get_or_compute(&format!("warm_{}", i));
2503            }
2504        }
2505
2506        let keys: Vec<String> = (0..100).map(|i| format!("warm_{}", i)).collect();
2507        let latencies = measure_latencies(10_000, |i| {
2508            let _ = cache.get_or_compute(&keys[i % 100]);
2509        });
2510
2511        let p95_ns = p95(&latencies);
2512        // Budget: < 1us p95 for hits (5us CI-safe threshold).
2513        let budget_ns = perf_budget_ns(5_000);
2514        assert!(
2515            p95_ns < budget_ns,
2516            "TinyLFU hit p95 = {}ns exceeds {}ns budget (median={}ns, p99={}ns)",
2517            p95_ns,
2518            budget_ns,
2519            median(&latencies),
2520            p99(&latencies),
2521        );
2522    }
2523
2524    #[test]
2525    fn perf_tinylfu_miss_latency() {
2526        let mut cache = TinyLfuWidthCache::new(100);
2527        let keys: Vec<String> = (0..10_000).map(|i| format!("miss_{}", i)).collect();
2528
2529        let latencies = measure_latencies(10_000, |i| {
2530            let _ = cache.get_or_compute(&keys[i]);
2531        });
2532
2533        let p95_ns = p95(&latencies);
2534        // Misses include unicode width computation. Budget: < 5us p95.
2535        // Use 20us CI-safe threshold (10x under coverage; computation dominates).
2536        let budget_ns = perf_budget_ns(20_000);
2537        assert!(
2538            p95_ns < budget_ns,
2539            "TinyLFU miss p95 = {}ns exceeds {}ns budget (median={}ns, p99={}ns)",
2540            p95_ns,
2541            budget_ns,
2542            median(&latencies),
2543            p99(&latencies),
2544        );
2545    }
2546
2547    #[test]
2548    fn perf_cms_increment_latency() {
2549        let mut cms = CountMinSketch::with_defaults();
2550        let hashes: Vec<u64> = (0..10_000).map(|i| hash_text(&format!("k{}", i))).collect();
2551
2552        let latencies = measure_latencies(10_000, |i| {
2553            cms.increment(hashes[i]);
2554        });
2555
2556        let p95_ns = p95(&latencies);
2557        // CMS increment should be very fast: < 500ns p95.
2558        let budget_ns = perf_budget_ns(2_000);
2559        assert!(
2560            p95_ns < budget_ns,
2561            "CMS increment p95 = {}ns exceeds {}ns budget (median={}ns)",
2562            p95_ns,
2563            budget_ns,
2564            median(&latencies),
2565        );
2566    }
2567
2568    #[test]
2569    fn perf_doorkeeper_latency() {
2570        let mut dk = Doorkeeper::with_defaults();
2571        let hashes: Vec<u64> = (0..10_000).map(|i| hash_text(&format!("d{}", i))).collect();
2572
2573        let latencies = measure_latencies(10_000, |i| {
2574            let _ = dk.check_and_set(hashes[i]);
2575        });
2576
2577        let p95_ns = p95(&latencies);
2578        // Doorkeeper bit ops: < 200ns p95.
2579        let budget_ns = perf_budget_ns(1_000);
2580        assert!(
2581            p95_ns < budget_ns,
2582            "Doorkeeper p95 = {}ns exceeds {}ns budget (median={}ns)",
2583            p95_ns,
2584            budget_ns,
2585            median(&latencies),
2586        );
2587    }
2588
2589    #[test]
2590    fn perf_fingerprint_hash_latency() {
2591        let keys: Vec<String> = (0..10_000).map(|i| format!("fp_{}", i)).collect();
2592
2593        let latencies = measure_latencies(10_000, |i| {
2594            let _ = fingerprint_hash(&keys[i]);
2595        });
2596
2597        let p95_ns = p95(&latencies);
2598        // FNV hash: < 200ns p95.
2599        let budget_ns = perf_budget_ns(1_000);
2600        assert!(
2601            p95_ns < budget_ns,
2602            "fingerprint_hash p95 = {}ns exceeds {}ns budget (median={}ns)",
2603            p95_ns,
2604            budget_ns,
2605            median(&latencies),
2606        );
2607    }
2608}