chie_core/
cache_admission.rs

1//! Cache admission policies for intelligent caching decisions.
2//!
3//! This module implements various cache admission policies that determine
4//! whether new items should be admitted to the cache based on their predicted
5//! value. This helps prevent cache pollution from items that are unlikely
6//! to be accessed again.
7//!
8//! # Implemented Policies
9//!
10//! - **TinyLFU**: Tiny Least Frequently Used with Count-Min Sketch
11//! - **SLRU**: Segmented LRU with probationary and protected segments
12//!
13//! # Example
14//!
15//! ```rust
16//! use chie_core::cache_admission::{TinyLFU, AdmissionPolicy};
17//!
18//! let mut policy = TinyLFU::new(1000, 4);
19//!
20//! // Record accesses
21//! policy.record_access("item1");
22//! policy.record_access("item1");
23//! policy.record_access("item2");
24//!
25//! // Check if item should be admitted (comparing with victim)
26//! if policy.should_admit("new_item", "victim_item") {
27//!     println!("Admit new_item to cache");
28//! } else {
29//!     println!("Keep victim_item in cache");
30//! }
31//! ```
32
33use std::collections::HashMap;
34use std::hash::{Hash, Hasher};
35
36/// Admission policy trait.
37pub trait AdmissionPolicy<K> {
38    /// Record an access to a key.
39    fn record_access(&mut self, key: &K);
40
41    /// Check if a new key should be admitted, potentially evicting a victim.
42    ///
43    /// Returns true if new_key should replace victim_key.
44    fn should_admit(&self, new_key: &K, victim_key: &K) -> bool;
45
46    /// Reset the policy state.
47    fn reset(&mut self);
48}
49
50/// TinyLFU admission policy using Count-Min Sketch.
51///
52/// TinyLFU uses a compact frequency sketch to estimate item access frequencies
53/// with minimal memory overhead. It's particularly effective for preventing
54/// cache pollution from one-hit wonders.
55pub struct TinyLFU {
56    /// Count-Min Sketch for frequency estimation.
57    sketch: CountMinSketch,
58    /// Doorkeeper Bloom filter for very recent items.
59    doorkeeper: DoorKeeper,
60    /// Sample size for reset.
61    sample_size: usize,
62    /// Current sample count.
63    samples: usize,
64}
65
66impl TinyLFU {
67    /// Create a new TinyLFU policy.
68    ///
69    /// # Arguments
70    /// * `capacity` - Expected number of unique items
71    /// * `hash_functions` - Number of hash functions for sketch (typically 4)
72    #[must_use]
73    #[inline]
74    pub fn new(capacity: usize, hash_functions: usize) -> Self {
75        Self {
76            sketch: CountMinSketch::new(capacity, hash_functions),
77            doorkeeper: DoorKeeper::new(capacity),
78            sample_size: capacity * 10,
79            samples: 0,
80        }
81    }
82
83    /// Get estimated frequency for a key.
84    #[must_use]
85    #[inline]
86    pub fn estimate_frequency<K: Hash>(&self, key: &K) -> u32 {
87        self.sketch.estimate(key)
88    }
89}
90
91impl<K: Hash> AdmissionPolicy<K> for TinyLFU {
92    fn record_access(&mut self, key: &K) {
93        // Add to doorkeeper first
94        self.doorkeeper.insert(key);
95
96        // Increment in sketch
97        self.sketch.increment(key);
98
99        // Check if we need to reset (aging)
100        self.samples += 1;
101        if self.samples >= self.sample_size {
102            self.sketch.halve();
103            self.samples = 0;
104        }
105    }
106
107    fn should_admit(&self, new_key: &K, victim_key: &K) -> bool {
108        let new_in_doorkeeper = self.doorkeeper.might_contain(new_key);
109        let victim_in_doorkeeper = self.doorkeeper.might_contain(victim_key);
110
111        // If only new_key is recent, admit it
112        if new_in_doorkeeper && !victim_in_doorkeeper {
113            return true;
114        }
115
116        // If only victim is recent, keep it
117        if !new_in_doorkeeper && victim_in_doorkeeper {
118            return false;
119        }
120
121        // Both recent or both not recent: compare frequencies
122        let new_freq = self.sketch.estimate(new_key);
123        let victim_freq = self.sketch.estimate(victim_key);
124
125        new_freq > victim_freq
126    }
127
128    fn reset(&mut self) {
129        self.sketch.reset();
130        self.doorkeeper.reset();
131        self.samples = 0;
132    }
133}
134
135/// Count-Min Sketch for frequency estimation.
136struct CountMinSketch {
137    /// Sketch table (rows × columns).
138    table: Vec<Vec<u32>>,
139    /// Number of hash functions (rows).
140    depth: usize,
141    /// Width of each row.
142    width: usize,
143}
144
145impl CountMinSketch {
146    /// Create a new Count-Min Sketch.
147    fn new(capacity: usize, depth: usize) -> Self {
148        let width = capacity.next_power_of_two();
149        let table = vec![vec![0; width]; depth];
150
151        Self {
152            table,
153            depth,
154            width,
155        }
156    }
157
158    /// Increment count for a key.
159    fn increment<K: Hash>(&mut self, key: &K) {
160        for i in 0..self.depth {
161            let hash = self.hash(key, i);
162            let index = (hash as usize) % self.width;
163            self.table[i][index] = self.table[i][index].saturating_add(1);
164        }
165    }
166
167    /// Estimate frequency for a key.
168    fn estimate<K: Hash>(&self, key: &K) -> u32 {
169        (0..self.depth)
170            .map(|i| {
171                let hash = self.hash(key, i);
172                let index = (hash as usize) % self.width;
173                self.table[i][index]
174            })
175            .min()
176            .unwrap_or(0)
177    }
178
179    /// Halve all counts (aging).
180    fn halve(&mut self) {
181        for row in &mut self.table {
182            for count in row {
183                *count /= 2;
184            }
185        }
186    }
187
188    /// Reset all counts.
189    fn reset(&mut self) {
190        for row in &mut self.table {
191            row.fill(0);
192        }
193    }
194
195    /// Hash function with seed.
196    fn hash<K: Hash>(&self, key: &K, seed: usize) -> u64 {
197        let mut hasher = std::collections::hash_map::DefaultHasher::new();
198        key.hash(&mut hasher);
199        seed.hash(&mut hasher);
200        hasher.finish()
201    }
202}
203
204/// Simple Bloom filter for doorkeeper.
205struct DoorKeeper {
206    bits: Vec<bool>,
207    size: usize,
208}
209
210impl DoorKeeper {
211    fn new(capacity: usize) -> Self {
212        let size = capacity.next_power_of_two();
213        Self {
214            bits: vec![false; size],
215            size,
216        }
217    }
218
219    fn insert<K: Hash>(&mut self, key: &K) {
220        let hash = self.hash(key);
221        let index = (hash as usize) % self.size;
222        self.bits[index] = true;
223    }
224
225    fn might_contain<K: Hash>(&self, key: &K) -> bool {
226        let hash = self.hash(key);
227        let index = (hash as usize) % self.size;
228        self.bits[index]
229    }
230
231    fn reset(&mut self) {
232        self.bits.fill(false);
233    }
234
235    fn hash<K: Hash>(&self, key: &K) -> u64 {
236        let mut hasher = std::collections::hash_map::DefaultHasher::new();
237        key.hash(&mut hasher);
238        hasher.finish()
239    }
240}
241
242/// SLRU (Segmented LRU) admission policy.
243///
244/// SLRU divides the cache into two segments:
245/// - Probationary segment: New items start here
246/// - Protected segment: Frequently accessed items are promoted here
247pub struct SLRU<K: Eq + Hash + Clone> {
248    /// Probationary segment.
249    probationary: HashMap<K, u64>,
250    /// Protected segment.
251    protected: HashMap<K, u64>,
252    /// Protected segment size ratio (0.0 to 1.0).
253    #[allow(dead_code)]
254    protected_ratio: f64,
255    /// Access counter.
256    counter: u64,
257}
258
259impl<K: Eq + Hash + Clone> SLRU<K> {
260    /// Create a new SLRU policy.
261    ///
262    /// # Arguments
263    /// * `protected_ratio` - Ratio of protected segment (e.g., 0.8 = 80% protected)
264    #[must_use]
265    #[inline]
266    pub fn new(protected_ratio: f64) -> Self {
267        Self {
268            probationary: HashMap::new(),
269            protected: HashMap::new(),
270            protected_ratio: protected_ratio.clamp(0.0, 1.0),
271            counter: 0,
272        }
273    }
274
275    /// Check if key is in protected segment.
276    #[must_use]
277    #[inline]
278    pub fn is_protected(&self, key: &K) -> bool {
279        self.protected.contains_key(key)
280    }
281
282    /// Get segment for a key.
283    #[must_use]
284    #[inline]
285    pub fn get_segment(&self, key: &K) -> Option<Segment> {
286        if self.protected.contains_key(key) {
287            Some(Segment::Protected)
288        } else if self.probationary.contains_key(key) {
289            Some(Segment::Probationary)
290        } else {
291            None
292        }
293    }
294}
295
296/// Cache segment in SLRU.
297#[derive(Debug, Clone, Copy, PartialEq, Eq)]
298pub enum Segment {
299    /// Probationary segment (new items).
300    Probationary,
301    /// Protected segment (frequently accessed).
302    Protected,
303}
304
305impl<K: Eq + Hash + Clone> AdmissionPolicy<K> for SLRU<K> {
306    fn record_access(&mut self, key: &K) {
307        self.counter += 1;
308
309        // Check if in probationary
310        if self.probationary.contains_key(key) {
311            // Promote to protected
312            self.probationary.remove(key);
313            self.protected.insert(key.clone(), self.counter);
314        } else if self.protected.contains_key(key) {
315            // Update access time
316            self.protected.insert(key.clone(), self.counter);
317        } else {
318            // New item, add to probationary
319            self.probationary.insert(key.clone(), self.counter);
320        }
321    }
322
323    fn should_admit(&self, new_key: &K, victim_key: &K) -> bool {
324        match (self.get_segment(new_key), self.get_segment(victim_key)) {
325            (_, Some(Segment::Probationary)) => true, // Always replace probationary
326            (Some(Segment::Protected), Some(Segment::Protected)) => {
327                // Compare access times
328                let new_time = self.protected.get(new_key).copied().unwrap_or(0);
329                let victim_time = self.protected.get(victim_key).copied().unwrap_or(0);
330                new_time > victim_time
331            }
332            (Some(Segment::Probationary), Some(Segment::Protected)) => false,
333            _ => true, // Default to admit
334        }
335    }
336
337    fn reset(&mut self) {
338        self.probationary.clear();
339        self.protected.clear();
340        self.counter = 0;
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn test_tinylfu_basic() {
350        let mut policy = TinyLFU::new(100, 4);
351
352        // Record multiple accesses to "hot"
353        for _ in 0..10 {
354            policy.record_access(&"hot");
355        }
356
357        // Record single access to "cold"
358        policy.record_access(&"cold");
359
360        // "hot" should be admitted over "cold"
361        assert!(policy.should_admit(&"hot", &"cold"));
362        assert!(!policy.should_admit(&"cold", &"hot"));
363    }
364
365    #[test]
366    fn test_tinylfu_doorkeeper() {
367        let mut policy = TinyLFU::new(100, 4);
368
369        // Recently accessed item should be admitted
370        policy.record_access(&"recent");
371        assert!(policy.should_admit(&"recent", &"victim"));
372    }
373
374    #[test]
375    fn test_tinylfu_frequency_estimation() {
376        let mut policy = TinyLFU::new(100, 4);
377
378        for _ in 0..5 {
379            policy.record_access(&"item");
380        }
381
382        let freq = policy.estimate_frequency(&"item");
383        assert!(freq >= 5);
384    }
385
386    #[test]
387    fn test_slru_promotion() {
388        let mut policy: SLRU<&str> = SLRU::new(0.8);
389
390        // Add item
391        policy.record_access(&"item");
392        assert_eq!(policy.get_segment(&"item"), Some(Segment::Probationary));
393
394        // Access again - should promote
395        policy.record_access(&"item");
396        assert_eq!(policy.get_segment(&"item"), Some(Segment::Protected));
397    }
398
399    #[test]
400    fn test_slru_admission() {
401        let mut policy: SLRU<&str> = SLRU::new(0.8);
402
403        // Create protected item
404        policy.record_access(&"protected");
405        policy.record_access(&"protected");
406
407        // Create probationary item
408        policy.record_access(&"probationary");
409
410        // Should always replace probationary
411        assert!(policy.should_admit(&"new", &"probationary"));
412
413        // Should not easily replace protected
414        assert!(!policy.should_admit(&"probationary", &"protected"));
415    }
416
417    #[test]
418    fn test_count_min_sketch() {
419        let mut sketch = CountMinSketch::new(100, 4);
420
421        sketch.increment(&"key1");
422        sketch.increment(&"key1");
423        sketch.increment(&"key1");
424
425        assert_eq!(sketch.estimate(&"key1"), 3);
426        assert_eq!(sketch.estimate(&"key2"), 0);
427    }
428
429    #[test]
430    fn test_count_min_sketch_halving() {
431        let mut sketch = CountMinSketch::new(100, 4);
432
433        for _ in 0..10 {
434            sketch.increment(&"key");
435        }
436
437        assert_eq!(sketch.estimate(&"key"), 10);
438
439        sketch.halve();
440        assert_eq!(sketch.estimate(&"key"), 5);
441    }
442
443    #[test]
444    fn test_doorkeeper() {
445        let mut doorkeeper = DoorKeeper::new(100);
446
447        doorkeeper.insert(&"item");
448        assert!(doorkeeper.might_contain(&"item"));
449
450        doorkeeper.reset();
451        assert!(!doorkeeper.might_contain(&"item"));
452    }
453}