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    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    /// Estimate the frequency of a key (minimum across all rows).
869    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    /// Total number of increments since creation or last reset.
879    pub fn total_increments(&self) -> u64 {
880        self.total_increments
881    }
882
883    /// Halve all counters (aging). Resets the increment counter.
884    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    /// Clear all counters to zero.
894    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    /// Compute the column index for a given hash and row.
902    #[inline]
903    fn index(&self, hash: u64, row: usize) -> usize {
904        // Mix the hash with the row index for independent hash functions.
905        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/// 1-bit Bloom filter used as a doorkeeper to filter one-hit wonders.
914///
915/// On first access within an epoch, the doorkeeper sets a bit. Only on
916/// the second access does the item get promoted to the Count-Min Sketch.
917/// The doorkeeper is cleared whenever the CMS resets (halves).
918#[derive(Debug, Clone)]
919pub struct Doorkeeper {
920    bits: Vec<u64>,
921    num_bits: usize,
922}
923
924/// Default doorkeeper size in bits.
925const DOORKEEPER_DEFAULT_BITS: usize = 2048;
926
927impl Doorkeeper {
928    /// Create a new doorkeeper with the specified number of bits.
929    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    /// Create a doorkeeper with the default size (2048 bits).
939    pub fn with_defaults() -> Self {
940        Self::new(DOORKEEPER_DEFAULT_BITS)
941    }
942
943    /// Check if a key has been seen. Returns true if the bit was already set.
944    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    /// Check if a key has been seen without setting.
954    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    /// Clear all bits.
962    pub fn clear(&mut self) {
963        self.bits.fill(0);
964    }
965}
966
967/// Compute a secondary fingerprint hash for collision guard.
968///
969/// Uses a different multiplicative constant than FxHash to produce
970/// an independent 64-bit fingerprint.
971#[inline]
972pub fn fingerprint_hash(text: &str) -> u64 {
973    // Simple but effective: fold bytes with a different constant than FxHash.
974    let mut h: u64 = 0xcbf2_9ce4_8422_2325; // FNV offset basis
975    for &b in text.as_bytes() {
976        h ^= b as u64;
977        h = h.wrapping_mul(0x0100_0000_01b3); // FNV prime
978    }
979    h
980}
981
982/// Evaluate the TinyLFU admission rule.
983///
984/// Returns `true` if the candidate should be admitted (replacing the victim).
985///
986/// # Rule
987/// Admit if `freq(candidate) > freq(victim)`. On tie, reject (keep victim).
988#[inline]
989pub fn tinylfu_admit(candidate_freq: u8, victim_freq: u8) -> bool {
990    candidate_freq > victim_freq
991}
992
993// ---------------------------------------------------------------------------
994// W-TinyLFU Width Cache (bd-4kq0.6.2)
995// ---------------------------------------------------------------------------
996
997/// Entry in the TinyLFU cache, storing value and fingerprint for collision guard.
998#[derive(Debug, Clone)]
999struct TinyLfuEntry {
1000    width: usize,
1001    fingerprint: u64,
1002}
1003
1004/// Width cache using W-TinyLFU admission policy.
1005///
1006/// Architecture:
1007/// - **Window cache** (small LRU, ~1% of capacity): captures recent items.
1008/// - **Main cache** (larger LRU, ~99% of capacity): for frequently accessed items.
1009/// - **Count-Min Sketch + Doorkeeper**: frequency estimation for admission decisions.
1010/// - **Fingerprint guard**: secondary hash per entry to detect hash collisions.
1011///
1012/// On every access:
1013/// 1. Check main cache → hit? Return value (verify fingerprint).
1014/// 2. Check window cache → hit? Return value (verify fingerprint).
1015/// 3. Miss: compute width, insert into window cache.
1016///
1017/// On window cache eviction:
1018/// 1. The evicted item becomes a candidate.
1019/// 2. The LRU victim of the main cache is identified.
1020/// 3. If `freq(candidate) > freq(victim)`, candidate enters main cache
1021///    (victim is evicted). Otherwise, candidate is discarded.
1022///
1023/// Frequency tracking uses Doorkeeper → CMS pipeline:
1024/// - First access: doorkeeper records.
1025/// - Second+ access: CMS is incremented.
1026#[derive(Debug)]
1027pub struct TinyLfuWidthCache {
1028    /// Small window cache (recency).
1029    window: LruCache<u64, TinyLfuEntry>,
1030    /// Large main cache (frequency-filtered).
1031    main: LruCache<u64, TinyLfuEntry>,
1032    /// Approximate frequency counter.
1033    sketch: CountMinSketch,
1034    /// One-hit-wonder filter.
1035    doorkeeper: Doorkeeper,
1036    /// Total capacity (window + main).
1037    total_capacity: usize,
1038    /// Hit/miss stats.
1039    hits: u64,
1040    misses: u64,
1041}
1042
1043impl TinyLfuWidthCache {
1044    /// Create a new TinyLFU cache with the given total capacity.
1045    ///
1046    /// The window gets ~1% of capacity (minimum 1), main gets the rest.
1047    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    /// Get cached width or compute and cache it.
1064    pub fn get_or_compute(&mut self, text: &str) -> usize {
1065        self.get_or_compute_with(text, crate::display_width)
1066    }
1067
1068    /// Get cached width or compute using a custom function.
1069    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        // Record frequency via doorkeeper → CMS pipeline.
1077        let seen = self.doorkeeper.check_and_set(hash);
1078        if seen {
1079            self.sketch.increment(hash);
1080        }
1081
1082        // Check main cache first (larger, higher value).
1083        if let Some(entry) = self.main.get(&hash) {
1084            if entry.fingerprint == fp {
1085                self.hits += 1;
1086                return entry.width;
1087            }
1088            // Fingerprint mismatch: collision. Evict stale entry.
1089            self.main.pop(&hash);
1090        }
1091
1092        // Check window cache.
1093        if let Some(entry) = self.window.get(&hash) {
1094            if entry.fingerprint == fp {
1095                self.hits += 1;
1096                return entry.width;
1097            }
1098            // Collision in window cache.
1099            self.window.pop(&hash);
1100        }
1101
1102        // Cache miss: compute width.
1103        self.misses += 1;
1104        let width = compute(text);
1105        let new_entry = TinyLfuEntry {
1106            width,
1107            fingerprint: fp,
1108        };
1109
1110        // Insert into window cache. If window is full, the evicted item
1111        // goes through admission filter for main cache.
1112        if self.window.len() >= self.window.cap().get() {
1113            // Get the LRU item from window before it's evicted.
1114            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    /// Try to admit a candidate (evicted from window) into the main cache.
1124    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            // Main has room — admit unconditionally.
1129            self.main.put(candidate_hash, candidate_entry);
1130            return;
1131        }
1132
1133        // Main is full. Compare candidate frequency with the LRU victim.
1134        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            // Otherwise, candidate is discarded.
1141        }
1142    }
1143
1144    /// Check if a key is in the cache (window or main).
1145    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    /// Get cache statistics.
1162    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    /// Clear all caches and reset sketch/doorkeeper.
1172    pub fn clear(&mut self) {
1173        self.window.clear();
1174        self.main.clear();
1175        self.sketch.clear();
1176        self.doorkeeper.clear();
1177    }
1178
1179    /// Reset statistics.
1180    pub fn reset_stats(&mut self) {
1181        self.hits = 0;
1182        self.misses = 0;
1183    }
1184
1185    /// Current number of cached entries.
1186    pub fn len(&self) -> usize {
1187        self.window.len() + self.main.len()
1188    }
1189
1190    /// Check if cache is empty.
1191    pub fn is_empty(&self) -> bool {
1192        self.window.is_empty() && self.main.is_empty()
1193    }
1194
1195    /// Total capacity (window + main).
1196    pub fn capacity(&self) -> usize {
1197        self.total_capacity
1198    }
1199
1200    /// Number of entries in the main cache.
1201    pub fn main_len(&self) -> usize {
1202        self.main.len()
1203    }
1204
1205    /// Number of entries in the window cache.
1206    pub fn window_len(&self) -> usize {
1207        self.window.len()
1208    }
1209}
1210
1211// ── S3-FIFO Width Cache ────────────────────────────────────────────────
1212
1213/// Width cache backed by S3-FIFO eviction (bd-l6yba.2).
1214///
1215/// Drop-in replacement for [`WidthCache`] that uses the scan-resistant
1216/// S3-FIFO eviction policy instead of LRU. This protects frequently-used
1217/// width entries from being evicted by one-time scan patterns (e.g. when a
1218/// large block of new text scrolls past).
1219///
1220/// Uses the same 64-bit FxHash keying as [`WidthCache`] with a secondary
1221/// FNV fingerprint for collision detection.
1222#[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/// Entry stored in the S3-FIFO width cache.
1231#[derive(Debug, Clone, Copy)]
1232struct S3FifoEntry {
1233    width: usize,
1234    fingerprint: u64,
1235}
1236
1237impl S3FifoWidthCache {
1238    /// Create a new S3-FIFO width cache with the given capacity.
1239    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    /// Create a new cache with the default capacity (4096 entries).
1250    #[must_use]
1251    pub fn with_default_capacity() -> Self {
1252        Self::new(DEFAULT_CACHE_CAPACITY)
1253    }
1254
1255    /// Get cached width or compute and cache it.
1256    pub fn get_or_compute(&mut self, text: &str) -> usize {
1257        self.get_or_compute_with(text, crate::display_width)
1258    }
1259
1260    /// Get cached width or compute using a custom function.
1261    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            // Fingerprint mismatch: collision. Remove stale entry.
1274            self.cache.remove(&hash);
1275        }
1276
1277        // Cache miss: compute width.
1278        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    /// Check if a key is in the cache.
1291    pub fn contains(&self, text: &str) -> bool {
1292        let hash = hash_text(text);
1293        self.cache.contains_key(&hash)
1294    }
1295
1296    /// Get cache statistics.
1297    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    /// Clear the cache.
1307    pub fn clear(&mut self) {
1308        self.cache.clear();
1309        self.hits = 0;
1310        self.misses = 0;
1311    }
1312
1313    /// Reset statistics.
1314    pub fn reset_stats(&mut self) {
1315        self.hits = 0;
1316        self.misses = 0;
1317    }
1318
1319    /// Current number of cached entries.
1320    pub fn len(&self) -> usize {
1321        self.cache.len()
1322    }
1323
1324    /// Check if cache is empty.
1325    pub fn is_empty(&self) -> bool {
1326        self.cache.is_empty()
1327    }
1328
1329    /// Total capacity.
1330    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        // Build a hot set
1433        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); // access twice to set freq
1437        }
1438
1439        // Scan through a large one-time set
1440        for i in 0..200 {
1441            cache.get_or_compute(&format!("scan_{i}"));
1442        }
1443
1444        // Some hot items should survive
1445        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            // Should be a hit (preloaded)
1527            prop_assert_eq!(stats_after.hits, stats_before.hits + 1);
1528        }
1529    }
1530}
1531
1532// ---------------------------------------------------------------------------
1533// TinyLFU Spec Tests (bd-4kq0.6.1)
1534// ---------------------------------------------------------------------------
1535
1536#[cfg(test)]
1537mod tinylfu_tests {
1538    use super::*;
1539
1540    // --- Count-Min Sketch ---
1541
1542    #[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        // Error bound: estimate(x) <= true_count(x) + epsilon * N.
1573        // With w=1024, epsilon = e/1024 ~ 0.00266.
1574        let mut cms = CountMinSketch::new(1024, 4, u64::MAX); // no reset
1575        let n: u64 = 1000;
1576
1577        // Insert 1000 unique keys
1578        for i in 0..n {
1579            cms.increment(i.wrapping_mul(0x9E37_79B9_7F4A_7C15).wrapping_add(1));
1580        }
1581
1582        // Check a target key inserted exactly 5 times
1583        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        // delta = (1/2)^4 = 0.0625; allow generous threshold
1638        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        // Trigger reset by reaching reset_interval
1657        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    // --- Doorkeeper ---
1680
1681    #[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        // k=1, m=2048, n=100: FP rate ~ 1 - e^{-100/2048} ~ 0.048
1732        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    // --- Admission Rule ---
1741
1742    #[test]
1743    fn unit_admission_rule() {
1744        assert!(tinylfu_admit(5, 3)); // candidate > victim -> admit
1745        assert!(!tinylfu_admit(3, 5)); // candidate < victim -> reject
1746        assert!(!tinylfu_admit(3, 3)); // tie -> reject (keep victim)
1747    }
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    // --- Fingerprint Guard ---
1759
1760    #[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    // --- Integration: Doorkeeper + CMS pipeline ---
1804
1805    #[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        // First access: doorkeeper records
1812        assert!(!dk.check_and_set(h));
1813        assert_eq!(cms.estimate(h), 0);
1814
1815        // Second access: doorkeeper confirms, CMS incremented
1816        assert!(dk.check_and_set(h));
1817        cms.increment(h);
1818        assert_eq!(cms.estimate(h), 1);
1819
1820        // Third access
1821        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        // 100 one-hit items
1832        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        // Access one again -> passes doorkeeper
1843        let h = 1; // i=0 from the loop above
1844        assert!(dk.check_and_set(h));
1845        cms.increment(h);
1846        assert_eq!(cms.total_increments(), 1);
1847    }
1848}
1849
1850// ---------------------------------------------------------------------------
1851// TinyLFU Implementation Tests (bd-4kq0.6.2)
1852// ---------------------------------------------------------------------------
1853
1854#[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        // With capacity=100, window=1, main=99.
1875        // Fill window, then force eviction into main via new inserts.
1876        let mut cache = TinyLfuWidthCache::new(100);
1877
1878        // Access "frequent" many times to build CMS frequency.
1879        for _ in 0..10 {
1880            cache.get_or_compute("frequent");
1881        }
1882
1883        // Insert enough items to fill window and force eviction.
1884        for i in 0..5 {
1885            cache.get_or_compute(&format!("item_{}", i));
1886        }
1887
1888        // "frequent" should have been promoted to main cache via admission.
1889        assert!(cache.contains("frequent"));
1890        assert!(cache.main_len() > 0 || cache.window_len() > 0);
1891    }
1892
1893    #[test]
1894    fn unit_window_promotion() {
1895        // Frequent items should end up in main cache.
1896        let mut cache = TinyLfuWidthCache::new(50);
1897
1898        // Access "hot" repeatedly to build frequency.
1899        for _ in 0..20 {
1900            cache.get_or_compute("hot");
1901        }
1902
1903        // Now push enough items through window to force "hot" out of window.
1904        for i in 0..10 {
1905            cache.get_or_compute(&format!("filler_{}", i));
1906        }
1907
1908        // "hot" should still be accessible (promoted to main via admission).
1909        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        // Compute "hello" with custom function.
1917        let w = cache.get_or_compute_with("hello", |_| 42);
1918        assert_eq!(w, 42);
1919
1920        // Verify it's cached.
1921        assert!(cache.contains("hello"));
1922    }
1923
1924    #[test]
1925    fn admission_rejects_infrequent() {
1926        // Fill main cache with frequently-accessed items.
1927        // Then try to insert a cold item — it should be rejected.
1928        let mut cache = TinyLfuWidthCache::new(10); // window=1, main=9
1929
1930        // Fill main with items accessed multiple times.
1931        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        // Now insert cold items. They should go through window but not
1939        // necessarily get into main.
1940        for i in 0..20 {
1941            cache.get_or_compute(&format!("cold_{}", i));
1942        }
1943
1944        // Hot items should mostly survive (they have high frequency).
1945        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        // Simulate a Zipfian-like workload: some items accessed frequently,
2007        // many accessed rarely. TinyLFU should achieve decent hit rate.
2008        let mut cache = TinyLfuWidthCache::new(50);
2009
2010        // 10 hot items accessed 20 times each.
2011        for _ in 0..20 {
2012            for i in 0..10 {
2013                cache.get_or_compute(&format!("hot_{}", i));
2014            }
2015        }
2016
2017        // 100 cold items accessed once each.
2018        for i in 0..100 {
2019            cache.get_or_compute(&format!("cold_{}", i));
2020        }
2021
2022        // Re-access hot items — these should mostly be hits.
2023        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        // Hot items should have high hit rate after being frequently accessed.
2030        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); // hit
2043        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// ---------------------------------------------------------------------------
2060// bd-4kq0.6.3: WidthCache Tests + Perf Gates
2061// ---------------------------------------------------------------------------
2062
2063/// Deterministic LCG for test reproducibility (no external rand dependency).
2064#[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// ---------------------------------------------------------------------------
2085// 1. Property: cache equivalence (cached width == computed width)
2086// ---------------------------------------------------------------------------
2087
2088#[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(); // byte length as custom metric
2129            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        // Same workload with same seed → same results every time.
2138        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        // WidthCache (plain LRU) and TinyLfuWidthCache must return the same
2166        // widths for any input.
2167        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// ---------------------------------------------------------------------------
2196// 2. E2E cache replay: deterministic workload, log hit rate + latency (JSONL)
2197// ---------------------------------------------------------------------------
2198
2199#[cfg(test)]
2200mod e2e_cache_replay {
2201    use super::*;
2202    use std::time::Instant;
2203
2204    /// A single replay record, serialisable to JSONL.
2205    #[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        // Approximate Zipfian: lower indices are much more frequent.
2225        (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        // Validate JSONL serialisation is parseable.
2260        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        // Compute aggregate stats.
2270        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        // Zipfian workload with 50-entry cache over 200-item universe
2275        // should get a meaningful hit rate (> 10%).
2276        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        // Both must have correct total operations.
2302        assert_eq!(tlfu_stats.hits + tlfu_stats.misses, 2000);
2303        assert_eq!(lru_stats.hits + lru_stats.misses, 2000);
2304
2305        // TinyLFU should be at least competitive with plain LRU on Zipfian.
2306        // (In practice TinyLFU often wins, but we just check both work.)
2307        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        // Two identical replays must produce identical hit/miss sequences.
2318        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        // Uniform access pattern: every key equally likely. Hit rate should be
2339        // roughly cache_size / universe_size for large N.
2340        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        // Warm up: access each key once.
2348        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        // Theoretical: ~25/100 = 25%. Allow range 10%–60%.
2361        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// ---------------------------------------------------------------------------
2370// 3. Perf gates: cache operations < 1us p95
2371// ---------------------------------------------------------------------------
2372
2373#[cfg(test)]
2374mod perf_cache_overhead {
2375    use super::*;
2376    use std::time::Instant;
2377
2378    /// Collect latencies for N operations, return sorted Vec<u128> in nanoseconds.
2379    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        // Warm up.
2419        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        // Budget: < 1us (1000ns) p95 for cache hits.
2430        // Use generous 5us to account for CI variability (10x under coverage).
2431        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        // Warm up: access each key multiple times to ensure promotion to main.
2446        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        // Budget: < 1us p95 for hits (5us CI-safe threshold).
2459        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        // Misses include unicode width computation. Budget: < 5us p95.
2481        // Use 20us CI-safe threshold (10x under coverage; computation dominates).
2482        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        // CMS increment should be very fast: < 500ns p95.
2504        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        // Doorkeeper bit ops: < 200ns p95.
2525        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        // FNV hash: < 200ns p95.
2545        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}