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