cache_mod/tinylfu.rs
1//! TinyLFU cache — Count-Min Sketch frequency estimator + admission filter
2//! on top of an LRU-ordered main cache.
3
4use core::hash::{Hash, Hasher};
5use std::collections::hash_map::DefaultHasher;
6use std::collections::HashMap;
7use std::num::NonZeroUsize;
8use std::sync::Mutex;
9
10use crate::cache::Cache;
11use crate::error::CacheError;
12use crate::util::MutexExt;
13
14/// Count-Min Sketch depth (number of independent hash rows).
15const SKETCH_DEPTH: usize = 4;
16
17/// Width floor — the sketch is never narrower than this, regardless of
18/// configured capacity. Guards small-capacity caches from degenerate
19/// collision rates.
20const MIN_SKETCH_WIDTH: usize = 64;
21
22/// A bounded, thread-safe cache with **admission control**.
23///
24/// `TinyLfuCache` tracks the access frequency of *every* key it observes —
25/// including keys that aren't (yet) in the cache — using a fixed-size
26/// [Count-Min Sketch][cms]. On capacity overflow, an incoming key is
27/// **admitted only if its estimated frequency exceeds the LRU victim's**.
28/// One-hit-wonders are rejected at the door instead of evicting hot entries.
29///
30/// This is a deliberate semantic deviation from `LruCache` / `LfuCache` /
31/// `TtlCache`: a successful [`insert`](Cache::insert) call **does not
32/// guarantee** that the value is in the cache. The admission filter may
33/// have rejected it. Callers that need strict insertion guarantees should
34/// use `LruCache` or `LfuCache` instead.
35///
36/// Reference implementation details:
37///
38/// - depth-4 Count-Min Sketch with `u8` saturating counters
39/// - width = `max(MIN_SKETCH_WIDTH, 2 × capacity)`, rounded to the next power of two
40/// - periodic frequency decay: every `10 × capacity` increments, every counter
41/// is right-shifted by 1 (this is the "aging" step from the W-TinyLFU paper,
42/// which keeps the sketch responsive to shifting workloads)
43/// - main cache uses LRU ordering; eviction victim = least-recently-accessed
44/// - lock-minimized via `&self` + `Mutex<Inner>`; a true sharded / lock-free
45/// variant lands in a later minor without changing this public surface
46///
47/// # Example
48///
49/// ```
50/// use cache_mod::{Cache, TinyLfuCache};
51///
52/// let cache: TinyLfuCache<&'static str, u32> =
53/// TinyLfuCache::new(4).expect("capacity > 0");
54///
55/// // Build up the frequency signal for "hot".
56/// for _ in 0..16 {
57/// let _ = cache.get(&"hot");
58/// let _ = cache.insert("hot", 1);
59/// }
60///
61/// // A subsequent insert will see "hot" as warm in the sketch.
62/// assert_eq!(cache.get(&"hot"), Some(1));
63/// ```
64///
65/// [cms]: https://en.wikipedia.org/wiki/Count%E2%80%93min_sketch
66pub struct TinyLfuCache<K, V> {
67 capacity: NonZeroUsize,
68 inner: Mutex<Inner<K, V>>,
69}
70
71struct Entry<V> {
72 value: V,
73 last_access: u64,
74}
75
76struct Inner<K, V> {
77 map: HashMap<K, Entry<V>>,
78 sketch: CountMinSketch,
79 clock: u64,
80}
81
82impl<K, V> TinyLfuCache<K, V>
83where
84 K: Eq + Hash + Clone,
85 V: Clone,
86{
87 /// Creates a cache with the given entry-count capacity.
88 ///
89 /// Returns [`CacheError::InvalidCapacity`] if `capacity == 0`.
90 ///
91 /// # Example
92 ///
93 /// ```
94 /// use cache_mod::TinyLfuCache;
95 ///
96 /// let cache: TinyLfuCache<String, u32> =
97 /// TinyLfuCache::new(256).expect("capacity > 0");
98 /// ```
99 pub fn new(capacity: usize) -> Result<Self, CacheError> {
100 let cap = NonZeroUsize::new(capacity).ok_or(CacheError::InvalidCapacity)?;
101 Ok(Self::with_capacity(cap))
102 }
103
104 /// Creates a cache with the given non-zero capacity. Infallible.
105 ///
106 /// # Example
107 ///
108 /// ```
109 /// use std::num::NonZeroUsize;
110 /// use cache_mod::TinyLfuCache;
111 ///
112 /// let cap = NonZeroUsize::new(256).expect("256 != 0");
113 /// let cache: TinyLfuCache<String, u32> = TinyLfuCache::with_capacity(cap);
114 /// ```
115 pub fn with_capacity(capacity: NonZeroUsize) -> Self {
116 let cap = capacity.get();
117 Self {
118 capacity,
119 inner: Mutex::new(Inner {
120 map: HashMap::with_capacity(cap),
121 sketch: CountMinSketch::new(cap),
122 clock: 0,
123 }),
124 }
125 }
126}
127
128impl<K, V> Cache<K, V> for TinyLfuCache<K, V>
129where
130 K: Eq + Hash + Clone,
131 V: Clone,
132{
133 fn get(&self, key: &K) -> Option<V> {
134 let mut inner = self.inner.lock_recover();
135 // Every observation feeds the sketch, even on cache miss — that's
136 // how the cache learns which keys are "hot" before they are admitted.
137 inner.sketch.increment(key);
138 inner.clock = inner.clock.wrapping_add(1);
139 let now = inner.clock;
140 let entry = inner.map.get_mut(key)?;
141 entry.last_access = now;
142 Some(entry.value.clone())
143 }
144
145 fn insert(&self, key: K, value: V) -> Option<V> {
146 let mut inner = self.inner.lock_recover();
147 inner.sketch.increment(&key);
148 inner.clock = inner.clock.wrapping_add(1);
149 let now = inner.clock;
150
151 // Live update: existing key always succeeds (no admission check).
152 if let Some(existing) = inner.map.get_mut(&key) {
153 let old = core::mem::replace(&mut existing.value, value);
154 existing.last_access = now;
155 return Some(old);
156 }
157
158 // New key. If at capacity, run the admission filter.
159 if inner.map.len() >= self.capacity.get() {
160 let candidate_freq = inner.sketch.estimate(&key);
161 let victim = find_lru_victim(&inner.map);
162 if let Some(victim_key) = victim {
163 let victim_freq = inner.sketch.estimate(&victim_key);
164 if candidate_freq <= victim_freq {
165 // Reject the admission. The value is dropped at the
166 // end of the function. Returning `None` matches the
167 // "no prior entry was present" semantic.
168 return None;
169 }
170 let _ = inner.map.remove(&victim_key);
171 }
172 }
173
174 let _ = inner.map.insert(
175 key,
176 Entry {
177 value,
178 last_access: now,
179 },
180 );
181 None
182 }
183
184 fn remove(&self, key: &K) -> Option<V> {
185 let mut inner = self.inner.lock_recover();
186 inner.map.remove(key).map(|e| e.value)
187 }
188
189 fn contains_key(&self, key: &K) -> bool {
190 self.inner.lock_recover().map.contains_key(key)
191 }
192
193 fn len(&self) -> usize {
194 self.inner.lock_recover().map.len()
195 }
196
197 fn clear(&self) {
198 let mut inner = self.inner.lock_recover();
199 inner.map.clear();
200 inner.sketch.reset();
201 inner.clock = 0;
202 }
203
204 fn capacity(&self) -> usize {
205 self.capacity.get()
206 }
207}
208
209fn find_lru_victim<K, V>(map: &HashMap<K, Entry<V>>) -> Option<K>
210where
211 K: Clone,
212{
213 map.iter()
214 .min_by_key(|(_, e)| e.last_access)
215 .map(|(k, _)| k.clone())
216}
217
218// -----------------------------------------------------------------------------
219// Count-Min Sketch
220// -----------------------------------------------------------------------------
221
222/// A small Count-Min Sketch with `u8` saturating counters and periodic
223/// halving. Used as the frequency estimator behind `TinyLfuCache`'s
224/// admission filter.
225struct CountMinSketch {
226 counters: Vec<u8>,
227 width: usize,
228 /// `width` as `u64` — pre-cached because every probe modulos by it.
229 width_u64: u64,
230 samples: u64,
231 /// Number of increments between sketch halvings (the "aging" trigger).
232 sample_window: u64,
233}
234
235impl CountMinSketch {
236 fn new(capacity: usize) -> Self {
237 let mut width = capacity.saturating_mul(2).max(MIN_SKETCH_WIDTH);
238 // Round up to the next power of two so the modulus could later be
239 // swapped for a mask without changing semantics.
240 width = width.next_power_of_two();
241 let sample_window = (capacity as u64).saturating_mul(10).max(64);
242 Self {
243 counters: vec![0; width.saturating_mul(SKETCH_DEPTH)],
244 width,
245 width_u64: width as u64,
246 samples: 0,
247 sample_window,
248 }
249 }
250
251 fn estimate<K: Hash>(&self, key: &K) -> u8 {
252 let mut min = u8::MAX;
253 for d in 0..SKETCH_DEPTH {
254 let idx = self.cell(d, key);
255 let observed = *self.counters.get(idx).unwrap_or(&0);
256 if observed < min {
257 min = observed;
258 }
259 }
260 min
261 }
262
263 fn increment<K: Hash>(&mut self, key: &K) {
264 for d in 0..SKETCH_DEPTH {
265 let idx = self.cell(d, key);
266 if let Some(slot) = self.counters.get_mut(idx) {
267 *slot = slot.saturating_add(1);
268 }
269 }
270 self.samples = self.samples.saturating_add(1);
271 if self.samples >= self.sample_window {
272 self.age();
273 self.samples = 0;
274 }
275 }
276
277 fn reset(&mut self) {
278 for c in self.counters.iter_mut() {
279 *c = 0;
280 }
281 self.samples = 0;
282 }
283
284 /// Halve every counter — the W-TinyLFU "aging" step. Recent activity
285 /// dominates over time-bygone activity, which lets the cache follow
286 /// workload shifts instead of being locked in to the first hot set.
287 fn age(&mut self) {
288 for c in self.counters.iter_mut() {
289 *c >>= 1;
290 }
291 }
292
293 /// Compute the absolute counter index for the `d`-th sketch row.
294 fn cell<K: Hash>(&self, d: usize, key: &K) -> usize {
295 let h = hash_with_seed(key, d as u64);
296 let col = (h % self.width_u64) as usize;
297 d.saturating_mul(self.width).saturating_add(col)
298 }
299}
300
301fn hash_with_seed<K: Hash>(key: &K, seed: u64) -> u64 {
302 let mut hasher = DefaultHasher::new();
303 seed.hash(&mut hasher);
304 key.hash(&mut hasher);
305 hasher.finish()
306}