Skip to main content

ftui_core/
s3_fifo.rs

1#![forbid(unsafe_code)]
2
3//! S3-FIFO cache (bd-l6yba.1): a scan-resistant, FIFO-based eviction policy.
4//!
5//! S3-FIFO uses three queues (small, main, ghost) to achieve scan resistance
6//! with lower overhead than LRU. It was shown to match or outperform W-TinyLFU
7//! and ARC on most workloads while being simpler to implement.
8//!
9//! # Algorithm
10//!
11//! - **Small queue** (10% of capacity): New entries go here. On eviction,
12//!   entries accessed at least once are promoted to main; others are evicted
13//!   (key goes to ghost).
14//! - **Main queue** (90% of capacity): Promoted entries. Eviction uses FIFO
15//!   with a frequency counter (max 3). If freq > 0, decrement and re-insert.
16//! - **Ghost queue** (same size as small): Stores keys only (no values).
17//!   If a key in ghost is accessed, it's admitted directly to main.
18//!
19//! # Usage
20//!
21//! ```
22//! use ftui_core::s3_fifo::S3Fifo;
23//!
24//! let mut cache = S3Fifo::new(100);
25//! cache.insert("hello", 42);
26//! assert_eq!(cache.get(&"hello"), Some(&42));
27//! ```
28
29use ahash::AHashMap;
30use std::collections::VecDeque;
31use std::hash::Hash;
32
33/// A cache entry stored in the slab.
34struct Entry<K, V> {
35    key: K,
36    value: V,
37    freq: u8,
38}
39
40/// S3-FIFO cache with scan-resistant eviction.
41pub struct S3Fifo<K, V> {
42    /// Index from key to location (and slab index).
43    index: AHashMap<K, Location>,
44    /// Slab storage for entries.
45    entries: Vec<Option<Entry<K, V>>>,
46    /// Free slots in the slab.
47    free_indices: Vec<usize>,
48    /// Small FIFO queue (indices into slab) (~10% of capacity).
49    small: VecDeque<usize>,
50    /// Main FIFO queue (indices into slab) (~90% of capacity).
51    main: VecDeque<usize>,
52    /// Ghost queue (keys only, same size as small).
53    ghost: VecDeque<K>,
54    /// Capacity of the small queue.
55    small_cap: usize,
56    /// Capacity of the main queue.
57    main_cap: usize,
58    /// Capacity of the ghost queue.
59    ghost_cap: usize,
60    /// Statistics.
61    hits: u64,
62    misses: u64,
63}
64
65/// Where an entry lives, including its index in the slab.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67enum Location {
68    Small(usize),
69    Main(usize),
70}
71
72impl Location {
73    fn idx(&self) -> usize {
74        match self {
75            Self::Small(i) | Self::Main(i) => *i,
76        }
77    }
78}
79
80/// Cache statistics.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
82pub struct S3FifoStats {
83    /// Number of cache hits.
84    pub hits: u64,
85    /// Number of cache misses, counted when [`S3Fifo::insert`] sees a new
86    /// key. A [`S3Fifo::get`] on a missing key does NOT increment this
87    /// (callers pairing get-miss with insert would otherwise double-count);
88    /// track lookup misses at the call site if you need them.
89    pub misses: u64,
90    /// Current entries in the small queue.
91    pub small_size: usize,
92    /// Current entries in the main queue.
93    pub main_size: usize,
94    /// Current entries in the ghost queue.
95    pub ghost_size: usize,
96    /// Total capacity.
97    pub capacity: usize,
98}
99
100impl<K, V> S3Fifo<K, V>
101where
102    K: Hash + Eq + Clone,
103{
104    /// Create a new S3-FIFO cache with the given total capacity.
105    ///
106    /// The capacity is split: 10% small, 90% main. Ghost capacity
107    /// matches small capacity. Minimum total capacity is 2.
108    pub fn new(capacity: usize) -> Self {
109        let capacity = capacity.max(2);
110        let small_cap = (capacity / 10).max(1);
111        let main_cap = capacity - small_cap;
112        let ghost_cap = small_cap;
113
114        Self {
115            index: AHashMap::with_capacity(capacity),
116            entries: Vec::with_capacity(capacity),
117            free_indices: Vec::new(),
118            small: VecDeque::with_capacity(small_cap),
119            main: VecDeque::with_capacity(main_cap),
120            ghost: VecDeque::with_capacity(ghost_cap),
121            small_cap,
122            main_cap,
123            ghost_cap,
124            hits: 0,
125            misses: 0,
126        }
127    }
128
129    /// Look up a value by key, incrementing the frequency counter on hit.
130    ///
131    /// A miss returns `None` without touching the miss counter — misses are
132    /// counted by [`insert`](Self::insert) (see [`S3FifoStats::misses`]).
133    pub fn get(&mut self, key: &K) -> Option<&V> {
134        if let Some(loc) = self.index.get(key) {
135            self.hits += 1;
136            let idx = loc.idx();
137            // SAFETY: indices in `index` are guaranteed to be valid and occupied.
138            let entry = self.entries[idx]
139                .as_mut()
140                .expect("S3Fifo invariant violated: valid index required");
141            entry.freq = entry.freq.saturating_add(1).min(3);
142            Some(&entry.value)
143        } else {
144            None
145        }
146    }
147
148    /// Insert a key-value pair. Returns the evicted value if an existing
149    /// entry with the same key was replaced.
150    pub fn insert(&mut self, key: K, value: V) -> Option<V> {
151        // If key already exists, update in place.
152        if let Some(loc) = self.index.get(&key) {
153            let idx = loc.idx();
154            let entry = self.entries[idx]
155                .as_mut()
156                .expect("S3Fifo invariant violated: valid index required");
157            let old = std::mem::replace(&mut entry.value, value);
158            entry.freq = entry.freq.saturating_add(1).min(3);
159            return Some(old);
160        }
161
162        self.misses += 1;
163
164        // Check ghost: if key was recently evicted, promote to main.
165        let in_ghost = self.remove_from_ghost(&key);
166
167        if in_ghost {
168            // Admit directly to main.
169            self.evict_main_if_full();
170            let idx = self.alloc_entry(key.clone(), value);
171            self.main.push_back(idx);
172            self.index.insert(key, Location::Main(idx));
173        } else {
174            // Insert into small queue.
175            self.evict_small_if_full();
176            let idx = self.alloc_entry(key.clone(), value);
177            self.small.push_back(idx);
178            self.index.insert(key, Location::Small(idx));
179        }
180
181        None
182    }
183
184    /// Remove a key from the cache.
185    pub fn remove(&mut self, key: &K) -> Option<V> {
186        let loc = self.index.remove(key)?;
187        let idx = loc.idx();
188
189        // Remove from the queue. This is O(N) for the queue, but necessary
190        // to maintain consistency. Usually cache removal is rare compared to
191        // get/insert.
192        match loc {
193            Location::Small(_) => {
194                if let Some(pos) = self.small.iter().position(|&i| i == idx) {
195                    self.small.remove(pos);
196                }
197            }
198            Location::Main(_) => {
199                if let Some(pos) = self.main.iter().position(|&i| i == idx) {
200                    self.main.remove(pos);
201                }
202            }
203        }
204
205        self.free_entry(idx)
206    }
207
208    /// Number of entries in the cache.
209    pub fn len(&self) -> usize {
210        self.index.len()
211    }
212
213    /// Whether the cache is empty.
214    pub fn is_empty(&self) -> bool {
215        self.index.is_empty()
216    }
217
218    /// Total capacity.
219    pub fn capacity(&self) -> usize {
220        self.small_cap + self.main_cap
221    }
222
223    /// Cache statistics.
224    pub fn stats(&self) -> S3FifoStats {
225        S3FifoStats {
226            hits: self.hits,
227            misses: self.misses,
228            small_size: self.small.len(),
229            main_size: self.main.len(),
230            ghost_size: self.ghost.len(),
231            capacity: self.small_cap + self.main_cap,
232        }
233    }
234
235    /// Clear all entries and reset statistics.
236    pub fn clear(&mut self) {
237        self.index.clear();
238        self.entries.clear();
239        self.free_indices.clear();
240        self.small.clear();
241        self.main.clear();
242        self.ghost.clear();
243        self.hits = 0;
244        self.misses = 0;
245    }
246
247    /// Check if the cache contains a key (without incrementing freq).
248    pub fn contains_key(&self, key: &K) -> bool {
249        self.index.contains_key(key)
250    }
251
252    // ── Internal helpers ──────────────────────────────────────────
253
254    /// Allocate a slot in the slab.
255    fn alloc_entry(&mut self, key: K, value: V) -> usize {
256        let entry = Some(Entry {
257            key,
258            value,
259            freq: 0,
260        });
261
262        if let Some(idx) = self.free_indices.pop() {
263            self.entries[idx] = entry;
264            idx
265        } else {
266            let idx = self.entries.len();
267            self.entries.push(entry);
268            idx
269        }
270    }
271
272    /// Free a slot in the slab and return the value.
273    fn free_entry(&mut self, idx: usize) -> Option<V> {
274        let entry = self.entries[idx].take()?;
275        self.free_indices.push(idx);
276        Some(entry.value)
277    }
278
279    /// Remove a key from the ghost queue if present.
280    fn remove_from_ghost(&mut self, key: &K) -> bool {
281        if let Some(pos) = self.ghost.iter().position(|k| k == key) {
282            self.ghost.remove(pos);
283            true
284        } else {
285            false
286        }
287    }
288
289    /// Evict from the small queue if it's at capacity.
290    fn evict_small_if_full(&mut self) {
291        while self.small.len() >= self.small_cap {
292            if let Some(idx) = self.small.pop_front() {
293                // Check freq - minimize borrow scope
294                let freq = self.entries[idx]
295                    .as_ref()
296                    .expect("S3Fifo invariant violated: valid index required")
297                    .freq;
298
299                if freq > 0 {
300                    // Promote to main.
301                    self.entries[idx]
302                        .as_mut()
303                        .expect("S3Fifo invariant violated: valid index required")
304                        .freq = 0;
305
306                    // Clone key for index update (must do before evict_main which borrows self)
307                    let key = self.entries[idx]
308                        .as_ref()
309                        .expect("S3Fifo invariant violated: valid index required")
310                        .key
311                        .clone();
312
313                    self.evict_main_if_full();
314
315                    self.index.insert(key, Location::Main(idx));
316                    self.main.push_back(idx);
317                } else {
318                    // Evict to ghost.
319                    // Extract entry to reuse key and avoid clone
320                    let entry = self.entries[idx]
321                        .take()
322                        .expect("S3Fifo invariant violated: valid index required");
323                    self.free_indices.push(idx);
324
325                    self.index.remove(&entry.key);
326
327                    if self.ghost.len() >= self.ghost_cap {
328                        self.ghost.pop_front();
329                    }
330                    self.ghost.push_back(entry.key);
331                }
332            }
333        }
334    }
335
336    /// Evict from the main queue if it's at capacity.
337    fn evict_main_if_full(&mut self) {
338        while self.main.len() >= self.main_cap {
339            if let Some(idx) = self.main.pop_front() {
340                let freq = self.entries[idx]
341                    .as_ref()
342                    .expect("S3Fifo invariant violated: valid index required")
343                    .freq;
344
345                if freq > 0 {
346                    // Give second chance
347                    self.entries[idx]
348                        .as_mut()
349                        .expect("S3Fifo invariant violated: valid index required")
350                        .freq -= 1;
351                    self.main.push_back(idx);
352                } else {
353                    // Evict
354                    let entry = self.entries[idx]
355                        .take()
356                        .expect("S3Fifo invariant violated: valid index required");
357                    self.free_indices.push(idx);
358                    self.index.remove(&entry.key);
359                }
360            }
361        }
362    }
363}
364
365impl<K, V> std::fmt::Debug for S3Fifo<K, V> {
366    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
367        f.debug_struct("S3Fifo")
368            .field("small", &self.small.len())
369            .field("main", &self.main.len())
370            .field("ghost", &self.ghost.len())
371            .field("hits", &self.hits)
372            .field("misses", &self.misses)
373            .finish()
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn empty_cache() {
383        let cache: S3Fifo<&str, i32> = S3Fifo::new(10);
384        assert!(cache.is_empty());
385        assert_eq!(cache.len(), 0);
386    }
387
388    #[test]
389    fn insert_and_get() {
390        let mut cache = S3Fifo::new(10);
391        cache.insert("key1", 42);
392        assert_eq!(cache.get(&"key1"), Some(&42));
393        assert_eq!(cache.len(), 1);
394    }
395
396    #[test]
397    fn miss_returns_none() {
398        let mut cache: S3Fifo<&str, i32> = S3Fifo::new(10);
399        assert_eq!(cache.get(&"missing"), None);
400    }
401
402    #[test]
403    fn update_existing_key() {
404        let mut cache = S3Fifo::new(10);
405        cache.insert("key1", 1);
406        let old = cache.insert("key1", 2);
407        assert_eq!(old, Some(1));
408        assert_eq!(cache.get(&"key1"), Some(&2));
409        assert_eq!(cache.len(), 1);
410    }
411
412    #[test]
413    fn remove_key() {
414        let mut cache = S3Fifo::new(10);
415        cache.insert("key1", 42);
416        let removed = cache.remove(&"key1");
417        assert_eq!(removed, Some(42));
418        assert!(cache.is_empty());
419        assert_eq!(cache.get(&"key1"), None);
420    }
421
422    #[test]
423    fn remove_nonexistent() {
424        let mut cache: S3Fifo<&str, i32> = S3Fifo::new(10);
425        assert_eq!(cache.remove(&"missing"), None);
426    }
427
428    #[test]
429    fn eviction_at_capacity() {
430        let mut cache = S3Fifo::new(5);
431        for i in 0..10 {
432            cache.insert(i, i * 10);
433        }
434        // Should have at most capacity entries
435        assert!(cache.len() <= cache.capacity());
436    }
437
438    #[test]
439    fn small_to_main_promotion() {
440        // Items accessed in small queue should be promoted to main on eviction
441        let mut cache = S3Fifo::new(10); // small_cap=1, main_cap=9
442
443        // Insert key and access it (sets freq > 0)
444        cache.insert("keep", 1);
445        cache.get(&"keep"); // freq = 1
446
447        // Fill small to trigger eviction of "keep" from small -> main
448        cache.insert("new", 2);
449
450        // "keep" should still be accessible (promoted to main)
451        assert_eq!(cache.get(&"keep"), Some(&1));
452    }
453
454    #[test]
455    fn ghost_readmission() {
456        // Keys evicted from small without access go to ghost.
457        // Re-inserting a ghost key should go directly to main.
458        let mut cache = S3Fifo::new(10); // small_cap=1
459
460        // Insert and evict without access
461        cache.insert("ghost_key", 1);
462        cache.insert("displacer", 2); // evicts "ghost_key" to ghost
463
464        // ghost_key should be gone from cache but in ghost
465        assert_eq!(cache.get(&"ghost_key"), None);
466
467        // Re-insert ghost_key -> should go to main
468        cache.insert("ghost_key", 3);
469        assert_eq!(cache.get(&"ghost_key"), Some(&3));
470    }
471
472    #[test]
473    fn stats_tracking() {
474        // Use capacity 20 so small_cap=2 and both "a" and "b" fit in small.
475        let mut cache = S3Fifo::new(20);
476        cache.insert("a", 1);
477        cache.insert("b", 2);
478        cache.get(&"a"); // hit
479        cache.get(&"a"); // hit
480        cache.get(&"c"); // miss (not found, but get doesn't track misses)
481
482        let stats = cache.stats();
483        assert_eq!(stats.hits, 2);
484        // misses are only counted on insert (new keys)
485        assert_eq!(stats.misses, 2); // 2 inserts
486    }
487
488    #[test]
489    fn clear_resets() {
490        let mut cache = S3Fifo::new(10);
491        cache.insert("a", 1);
492        cache.insert("b", 2);
493        cache.get(&"a");
494        cache.clear();
495
496        assert!(cache.is_empty());
497        assert_eq!(cache.len(), 0);
498        let stats = cache.stats();
499        assert_eq!(stats.hits, 0);
500        assert_eq!(stats.misses, 0);
501        assert_eq!(stats.ghost_size, 0);
502    }
503
504    #[test]
505    fn contains_key() {
506        let mut cache = S3Fifo::new(10);
507        cache.insert("a", 1);
508        assert!(cache.contains_key(&"a"));
509        assert!(!cache.contains_key(&"b"));
510    }
511
512    #[test]
513    fn capacity_split() {
514        let cache: S3Fifo<i32, i32> = S3Fifo::new(100);
515        assert_eq!(cache.capacity(), 100);
516        assert_eq!(cache.small_cap, 10);
517        assert_eq!(cache.main_cap, 90);
518        assert_eq!(cache.ghost_cap, 10);
519    }
520
521    #[test]
522    fn minimum_capacity() {
523        let cache: S3Fifo<i32, i32> = S3Fifo::new(0);
524        assert!(cache.capacity() >= 2);
525    }
526
527    #[test]
528    fn freq_capped_at_3() {
529        let mut cache = S3Fifo::new(10);
530        cache.insert("a", 1);
531        for _ in 0..10 {
532            cache.get(&"a");
533        }
534        // freq should be capped at 3 (internal, verified by eviction behavior)
535        assert_eq!(cache.get(&"a"), Some(&1));
536    }
537
538    #[test]
539    fn main_eviction_gives_second_chance() {
540        // Items with freq > 0 in main get re-inserted with freq-1
541        let mut cache = S3Fifo::new(5); // small=1, main=4
542
543        // Fill main with accessed items
544        for i in 0..4 {
545            cache.insert(i, i);
546            // Access once to move to small (freq=1) then to main
547            cache.get(&i);
548        }
549
550        // Insert more to trigger main eviction
551        for i in 10..20 {
552            cache.insert(i, i);
553        }
554
555        // Cache should still function correctly
556        assert!(cache.len() <= cache.capacity());
557    }
558
559    #[test]
560    fn debug_format() {
561        let cache: S3Fifo<&str, i32> = S3Fifo::new(10);
562        let debug = format!("{cache:?}");
563        assert!(debug.contains("S3Fifo"));
564        assert!(debug.contains("small"));
565        assert!(debug.contains("main"));
566    }
567
568    #[test]
569    fn large_workload() {
570        let mut cache = S3Fifo::new(100);
571
572        // Insert a working set and access items as they are inserted,
573        // so frequently-accessed ones have freq > 0 before eviction.
574        for i in 0..200 {
575            cache.insert(i, i * 10);
576            // Access items 50..100 repeatedly to build frequency
577            if i >= 50 {
578                for hot in 50..std::cmp::min(i, 100) {
579                    cache.get(&hot);
580                }
581            }
582        }
583
584        // Hot set (50..100) should have survived due to frequency protection
585        let mut hot_hits = 0;
586        for i in 50..100 {
587            if cache.get(&i).is_some() {
588                hot_hits += 1;
589            }
590        }
591
592        // Frequently-accessed items should persist
593        assert!(hot_hits > 20, "hot set retention: {hot_hits}/50");
594    }
595
596    #[test]
597    fn scan_resistance() {
598        let mut cache = S3Fifo::new(100);
599
600        // Insert a working set and access frequently
601        for i in 0..50 {
602            cache.insert(i, i);
603            cache.get(&i);
604            cache.get(&i);
605        }
606
607        // Scan through a large number of unique keys (scan pattern)
608        for i in 1000..2000 {
609            cache.insert(i, i);
610        }
611
612        // Some of the original working set should survive the scan
613        let mut survivors = 0;
614        for i in 0..50 {
615            if cache.get(&i).is_some() {
616                survivors += 1;
617            }
618        }
619
620        // S3-FIFO should protect frequently-accessed items from scan eviction
621        assert!(
622            survivors > 10,
623            "scan resistance: {survivors}/50 working set items survived"
624        );
625    }
626
627    #[test]
628    fn ghost_size_bounded() {
629        let mut cache = S3Fifo::new(10);
630
631        // Insert many items to fill ghost
632        for i in 0..100 {
633            cache.insert(i, i);
634        }
635
636        let stats = cache.stats();
637        assert!(
638            stats.ghost_size <= cache.ghost_cap,
639            "ghost should be bounded: {} <= {}",
640            stats.ghost_size,
641            cache.ghost_cap
642        );
643    }
644}