Skip to main content

ipfrs_storage/
eviction_simulator.rs

1//! Cache eviction policy simulator.
2//!
3//! Simulates LRU, LFU, and ARC-approximation eviction policies against an
4//! access trace and compares their hit rates.
5
6use std::collections::HashMap;
7
8// ---------------------------------------------------------------------------
9// Public types
10// ---------------------------------------------------------------------------
11
12/// Eviction policy to simulate.
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum EvictionPolicy {
15    /// Least-recently used.
16    Lru,
17    /// Least-frequently used (tie-break: LRU among equal freq).
18    Lfu,
19    /// Simplified ARC: T1 (recency) + T2 (frequency).
20    ArcApprox,
21}
22
23/// A single cache access event in the trace.
24#[derive(Clone, Debug)]
25pub struct AccessEvent {
26    /// Content identifier (opaque string key).
27    pub cid: String,
28    /// Logical clock tick for ordering.
29    pub timestamp_tick: u64,
30}
31
32/// Result of simulating one eviction policy against a trace.
33#[derive(Clone, Debug)]
34pub struct SimulationResult {
35    pub policy: EvictionPolicy,
36    pub hits: u64,
37    pub misses: u64,
38    pub evictions: u64,
39}
40
41impl SimulationResult {
42    /// Fraction of accesses that were cache hits (0.0 – 1.0).
43    pub fn hit_rate(&self) -> f64 {
44        let total = self.hits + self.misses;
45        if total == 0 {
46            0.0
47        } else {
48            self.hits as f64 / total as f64
49        }
50    }
51
52    /// Fraction of accesses that were cache misses (0.0 – 1.0).
53    pub fn miss_rate(&self) -> f64 {
54        1.0 - self.hit_rate()
55    }
56}
57
58// ---------------------------------------------------------------------------
59// Internal simulation state
60// ---------------------------------------------------------------------------
61
62/// Internal state threaded through each step of the simulation.
63struct CacheState {
64    capacity: usize,
65    /// Ordered eviction queue for LRU/LFU: index 0 is evicted first.
66    items: Vec<String>,
67    /// Per-item access frequency (used by LFU).
68    freq: HashMap<String, u64>,
69    /// ARC T1 list (recency queue).
70    t1: Vec<String>,
71    /// ARC T2 list (frequency queue).
72    t2: Vec<String>,
73}
74
75impl CacheState {
76    fn new(capacity: usize) -> Self {
77        Self {
78            capacity,
79            items: Vec::new(),
80            freq: HashMap::new(),
81            t1: Vec::new(),
82            t2: Vec::new(),
83        }
84    }
85
86    // -----------------------------------------------------------------------
87    // LRU helpers
88    // -----------------------------------------------------------------------
89
90    #[cfg(test)]
91    #[allow(dead_code)]
92    fn lru_contains(&self, cid: &str) -> bool {
93        self.items.iter().any(|x| x == cid)
94    }
95
96    /// Record an LRU access.  Returns `true` when it was a hit.
97    fn lru_access(&mut self, cid: &str) -> (bool, u64) {
98        let mut evictions = 0u64;
99        if let Some(pos) = self.items.iter().position(|x| x == cid) {
100            // Hit — move to back (most-recently used).
101            self.items.remove(pos);
102            self.items.push(cid.to_owned());
103            (true, evictions)
104        } else {
105            // Miss — evict if full.
106            if self.items.len() >= self.capacity {
107                self.items.remove(0);
108                evictions += 1;
109            }
110            self.items.push(cid.to_owned());
111            (false, evictions)
112        }
113    }
114
115    // -----------------------------------------------------------------------
116    // LFU helpers
117    // -----------------------------------------------------------------------
118
119    fn lfu_contains(&self, cid: &str) -> bool {
120        self.items.iter().any(|x| x == cid)
121    }
122
123    /// Record an LFU access.  Returns `(hit, evictions)`.
124    fn lfu_access(&mut self, cid: &str) -> (bool, u64) {
125        let mut evictions = 0u64;
126        if self.lfu_contains(cid) {
127            // Hit — increment frequency, keep position (tie-break by insertion order).
128            *self.freq.entry(cid.to_owned()).or_insert(0) += 1;
129            (true, evictions)
130        } else {
131            // Miss.
132            if self.items.len() >= self.capacity {
133                // Evict item with minimum frequency; among ties use earliest
134                // position in `items` (index 0 wins).
135                let evict_pos = self.lfu_evict_pos();
136                let evicted = self.items.remove(evict_pos);
137                self.freq.remove(&evicted);
138                evictions += 1;
139            }
140            self.freq.insert(cid.to_owned(), 1);
141            self.items.push(cid.to_owned());
142            (false, evictions)
143        }
144    }
145
146    /// Index of the item to evict under LFU policy.
147    fn lfu_evict_pos(&self) -> usize {
148        let mut best_pos = 0usize;
149        let mut best_freq = u64::MAX;
150        for (pos, item) in self.items.iter().enumerate() {
151            let f = *self.freq.get(item).unwrap_or(&0);
152            if f < best_freq {
153                best_freq = f;
154                best_pos = pos;
155            }
156            // Equal-frequency ties are broken by earliest position, which
157            // is already captured by iterating front-to-back and using
158            // strict `<` for replacement.
159        }
160        best_pos
161    }
162
163    // -----------------------------------------------------------------------
164    // ARC-approximation helpers
165    // -----------------------------------------------------------------------
166
167    #[cfg(test)]
168    #[allow(dead_code)]
169    fn arc_contains(&self, cid: &str) -> bool {
170        self.t1.iter().any(|x| x == cid) || self.t2.iter().any(|x| x == cid)
171    }
172
173    /// Record an ARC-approx access.  Returns `(hit, evictions)`.
174    fn arc_access(&mut self, cid: &str) -> (bool, u64) {
175        let mut evictions = 0u64;
176
177        // Check T2 first (promoted / frequent items).
178        if let Some(pos) = self.t2.iter().position(|x| x == cid) {
179            // Hit in T2 — refresh to back of T2.
180            self.t2.remove(pos);
181            self.t2.push(cid.to_owned());
182            return (true, evictions);
183        }
184
185        // Check T1 (first-time / recent items).
186        if let Some(pos) = self.t1.iter().position(|x| x == cid) {
187            // Hit in T1 — promote to T2.
188            self.t1.remove(pos);
189            // Make room in T2 if total capacity is exceeded.
190            let total = self.t1.len() + self.t2.len();
191            if total >= self.capacity {
192                evictions += self.arc_evict();
193            }
194            self.t2.push(cid.to_owned());
195            return (true, evictions);
196        }
197
198        // Miss — insert into T1.
199        let total = self.t1.len() + self.t2.len();
200        if total >= self.capacity {
201            evictions += self.arc_evict();
202        }
203        self.t1.push(cid.to_owned());
204        (false, evictions)
205    }
206
207    /// Evict one item following the simplified ARC rule.
208    /// Evict from T1 if `T1.len() > capacity / 2`, else from T2.
209    fn arc_evict(&mut self) -> u64 {
210        let half = self.capacity / 2;
211        if self.t1.len() > half && !self.t1.is_empty() {
212            self.t1.remove(0);
213            return 1;
214        }
215        if !self.t2.is_empty() {
216            self.t2.remove(0);
217            return 1;
218        }
219        // Fallback: evict from T1 even if not exceeding half.
220        if !self.t1.is_empty() {
221            self.t1.remove(0);
222            return 1;
223        }
224        0
225    }
226}
227
228// ---------------------------------------------------------------------------
229// Simulator
230// ---------------------------------------------------------------------------
231
232/// Simulates cache eviction policies against access traces.
233pub struct CacheEvictionSimulator {
234    pub capacity: usize,
235}
236
237impl CacheEvictionSimulator {
238    /// Create a new simulator with the given cache capacity.
239    pub fn new(capacity: usize) -> Self {
240        Self { capacity }
241    }
242
243    /// Replay `trace` against a fresh `CacheState` using `policy`.
244    pub fn simulate(&self, policy: EvictionPolicy, trace: &[AccessEvent]) -> SimulationResult {
245        let mut state = CacheState::new(self.capacity);
246        let mut hits = 0u64;
247        let mut misses = 0u64;
248        let mut evictions = 0u64;
249
250        for event in trace {
251            let (hit, ev) = match policy {
252                EvictionPolicy::Lru => state.lru_access(&event.cid),
253                EvictionPolicy::Lfu => state.lfu_access(&event.cid),
254                EvictionPolicy::ArcApprox => state.arc_access(&event.cid),
255            };
256            if hit {
257                hits += 1;
258            } else {
259                misses += 1;
260            }
261            evictions += ev;
262        }
263
264        SimulationResult {
265            policy,
266            hits,
267            misses,
268            evictions,
269        }
270    }
271
272    /// Simulate all three policies and return results sorted by hit rate (descending).
273    pub fn compare_policies(&self, trace: &[AccessEvent]) -> Vec<SimulationResult> {
274        let mut results = vec![
275            self.simulate(EvictionPolicy::Lru, trace),
276            self.simulate(EvictionPolicy::Lfu, trace),
277            self.simulate(EvictionPolicy::ArcApprox, trace),
278        ];
279        results.sort_by(|a, b| {
280            b.hit_rate()
281                .partial_cmp(&a.hit_rate())
282                .unwrap_or(std::cmp::Ordering::Equal)
283        });
284        results
285    }
286}
287
288// ---------------------------------------------------------------------------
289// Tests
290// ---------------------------------------------------------------------------
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    // Helper: build a trace from a slice of (&str, u64) pairs.
297    fn make_trace(pairs: &[(&str, u64)]) -> Vec<AccessEvent> {
298        pairs
299            .iter()
300            .map(|(cid, tick)| AccessEvent {
301                cid: cid.to_string(),
302                timestamp_tick: *tick,
303            })
304            .collect()
305    }
306
307    // -----------------------------------------------------------------------
308    // LRU tests
309    // -----------------------------------------------------------------------
310
311    #[test]
312    fn lru_hit_basic() {
313        let sim = CacheEvictionSimulator::new(3);
314        // a, b, c load; then a is a hit.
315        let trace = make_trace(&[("a", 1), ("b", 2), ("c", 3), ("a", 4)]);
316        let res = sim.simulate(EvictionPolicy::Lru, &trace);
317        assert_eq!(res.hits, 1);
318        assert_eq!(res.misses, 3);
319    }
320
321    #[test]
322    fn lru_miss_and_eviction() {
323        let sim = CacheEvictionSimulator::new(2);
324        // Load a, b (cap full). Access c → evicts a (LRU). Then access a → miss again.
325        let trace = make_trace(&[("a", 1), ("b", 2), ("c", 3), ("a", 4)]);
326        let res = sim.simulate(EvictionPolicy::Lru, &trace);
327        assert_eq!(res.hits, 0);
328        assert_eq!(res.evictions, 2); // evicts a then b
329        assert_eq!(res.misses, 4);
330    }
331
332    #[test]
333    fn lru_order_after_access() {
334        // Capacity 2: load a, b. Access a (hit → a is now MRU). Load c → evicts b.
335        let sim = CacheEvictionSimulator::new(2);
336        let trace = make_trace(&[("a", 1), ("b", 2), ("a", 3), ("c", 4), ("b", 5)]);
337        let res = sim.simulate(EvictionPolicy::Lru, &trace);
338        // hits: a@3, (b should be evicted at c@4), so b@5 is miss
339        assert_eq!(res.hits, 1); // only a@3
340        assert_eq!(res.evictions, 2); // b evicted when c loaded; a evicted when b reloaded
341    }
342
343    #[test]
344    fn lru_hit_rate_and_miss_rate() {
345        let sim = CacheEvictionSimulator::new(3);
346        let trace = make_trace(&[("a", 1), ("b", 2), ("c", 3), ("a", 4), ("b", 5)]);
347        let res = sim.simulate(EvictionPolicy::Lru, &trace);
348        assert_eq!(res.hits, 2);
349        assert_eq!(res.misses, 3);
350        let hr = res.hit_rate();
351        let mr = res.miss_rate();
352        assert!((hr - 0.4).abs() < 1e-9);
353        assert!((mr - 0.6).abs() < 1e-9);
354        assert!((hr + mr - 1.0).abs() < 1e-9);
355    }
356
357    #[test]
358    fn lru_evictions_count() {
359        let sim = CacheEvictionSimulator::new(2);
360        // Each new item after capacity is full causes an eviction.
361        let trace = make_trace(&[("a", 1), ("b", 2), ("c", 3), ("d", 4), ("e", 5)]);
362        let res = sim.simulate(EvictionPolicy::Lru, &trace);
363        assert_eq!(res.evictions, 3); // c, d, e each evict one item
364    }
365
366    #[test]
367    fn lru_capacity_one() {
368        let sim = CacheEvictionSimulator::new(1);
369        let trace = make_trace(&[("a", 1), ("a", 2), ("b", 3), ("b", 4)]);
370        let res = sim.simulate(EvictionPolicy::Lru, &trace);
371        // a@1 miss, a@2 hit, b@3 miss+evict-a, b@4 hit
372        assert_eq!(res.hits, 2);
373        assert_eq!(res.misses, 2);
374        assert_eq!(res.evictions, 1);
375    }
376
377    // -----------------------------------------------------------------------
378    // LFU tests
379    // -----------------------------------------------------------------------
380
381    #[test]
382    fn lfu_selects_min_freq() {
383        let sim = CacheEvictionSimulator::new(2);
384        // a@1 miss(freq=1), b@2 miss(freq=1). a@3 hit(freq=2), a@4 hit(freq=3).
385        // c@5 miss: evicts lowest-freq item = b(freq=1). cache=[a(3),c(1)].
386        // b@6 miss: evicts lowest-freq item = c(freq=1). cache=[a(3),b(1)].
387        let trace = make_trace(&[("a", 1), ("b", 2), ("a", 3), ("a", 4), ("c", 5), ("b", 6)]);
388        let res = sim.simulate(EvictionPolicy::Lfu, &trace);
389        assert_eq!(res.hits, 2); // a@3, a@4
390        assert_eq!(res.misses, 4); // a@1, b@2, c@5, b@6
391        assert_eq!(res.evictions, 2); // b evicted at c@5; c evicted at b@6
392    }
393
394    #[test]
395    fn lfu_tie_break_lru_order() {
396        // Capacity 2: a and b both freq=1. Load c → must evict one.
397        // Tie broken by earliest insertion: a was inserted first → evict a.
398        let sim = CacheEvictionSimulator::new(2);
399        let trace = make_trace(&[("a", 1), ("b", 2), ("c", 3), ("b", 4)]);
400        let res = sim.simulate(EvictionPolicy::Lfu, &trace);
401        // a@1 miss, b@2 miss, c@3 miss (evicts a, freq tie), b@4 hit
402        assert_eq!(res.hits, 1); // b@4
403        assert_eq!(res.evictions, 1);
404    }
405
406    #[test]
407    fn lfu_hit_rate() {
408        let sim = CacheEvictionSimulator::new(3);
409        let trace = make_trace(&[("x", 1), ("y", 2), ("z", 3), ("x", 4), ("x", 5), ("y", 6)]);
410        let res = sim.simulate(EvictionPolicy::Lfu, &trace);
411        assert_eq!(res.hits, 3);
412        assert_eq!(res.misses, 3);
413        assert!((res.hit_rate() - 0.5).abs() < 1e-9);
414    }
415
416    #[test]
417    fn lfu_capacity_one() {
418        let sim = CacheEvictionSimulator::new(1);
419        let trace = make_trace(&[("a", 1), ("a", 2), ("b", 3), ("b", 4)]);
420        let res = sim.simulate(EvictionPolicy::Lfu, &trace);
421        // a@1 miss; a@2 hit (freq=2); b@3 miss — a has freq 2, b freq 1; b evicted immediately? No:
422        // At b@3: cache has [a freq=2]. Evict a (only item). Insert b freq=1.
423        // b@4 hit.
424        assert_eq!(res.hits, 2); // a@2 and b@4
425        assert_eq!(res.evictions, 1);
426    }
427
428    // -----------------------------------------------------------------------
429    // ARC-approx tests
430    // -----------------------------------------------------------------------
431
432    #[test]
433    fn arc_t1_to_t2_promotion() {
434        // First access → T1. Second access → T2.
435        let sim = CacheEvictionSimulator::new(4);
436        let trace = make_trace(&[("a", 1), ("b", 2), ("a", 3)]);
437        let res = sim.simulate(EvictionPolicy::ArcApprox, &trace);
438        // a@1 miss (T1), b@2 miss (T1), a@3 hit (promote to T2)
439        assert_eq!(res.hits, 1);
440        assert_eq!(res.misses, 2);
441        assert_eq!(res.evictions, 0);
442    }
443
444    #[test]
445    fn arc_evicts_t1_when_over_half() {
446        // Capacity 3 → half = 1.
447        // Promote one item from T1→T2 so T1.len remains 1 and T2.len=1, then fill T1 to 2 > half.
448        // Trace: x@1 miss(T1=[x]), z@2 miss(T1=[x,z]), z@3 hit T1→promote T2(T1=[x],T2=[z]),
449        //        y@4 miss total=2<3 no evict(T1=[x,y],T2=[z]),
450        //        w@5 miss total=3>=3 → evict: T1.len=2>half=1 → evict x → T1=[y], push w → T1=[y,w].
451        let sim = CacheEvictionSimulator::new(3);
452        let trace = make_trace(&[("x", 1), ("z", 2), ("z", 3), ("y", 4), ("w", 5)]);
453        let res = sim.simulate(EvictionPolicy::ArcApprox, &trace);
454        assert_eq!(res.hits, 1); // z@3
455        assert_eq!(res.misses, 4); // x@1, z@2, y@4, w@5
456        assert_eq!(res.evictions, 1); // x evicted when w@5 is inserted
457    }
458
459    #[test]
460    fn arc_t2_hit_refreshes() {
461        let sim = CacheEvictionSimulator::new(4);
462        // a promoted to T2, then hit again in T2.
463        let trace = make_trace(&[("a", 1), ("a", 2), ("a", 3)]);
464        let res = sim.simulate(EvictionPolicy::ArcApprox, &trace);
465        assert_eq!(res.hits, 2); // a@2, a@3
466        assert_eq!(res.misses, 1);
467        assert_eq!(res.evictions, 0);
468    }
469
470    #[test]
471    fn arc_capacity_one() {
472        let sim = CacheEvictionSimulator::new(1);
473        let trace = make_trace(&[("a", 1), ("a", 2), ("b", 3), ("b", 4)]);
474        let res = sim.simulate(EvictionPolicy::ArcApprox, &trace);
475        // a@1 miss, a@2 hit (T2), b@3 miss+evict, b@4 hit
476        assert_eq!(res.hits, 2);
477        assert_eq!(res.misses, 2);
478        assert_eq!(res.evictions, 1);
479    }
480
481    // -----------------------------------------------------------------------
482    // compare_policies tests
483    // -----------------------------------------------------------------------
484
485    #[test]
486    fn compare_policies_returns_three_results() {
487        let sim = CacheEvictionSimulator::new(3);
488        let trace = make_trace(&[("a", 1), ("b", 2), ("a", 3)]);
489        let results = sim.compare_policies(&trace);
490        assert_eq!(results.len(), 3);
491    }
492
493    #[test]
494    fn compare_policies_sorted_by_hit_rate_desc() {
495        let sim = CacheEvictionSimulator::new(3);
496        let trace = make_trace(&[("a", 1), ("b", 2), ("c", 3), ("a", 4), ("b", 5), ("c", 6)]);
497        let results = sim.compare_policies(&trace);
498        for pair in results.windows(2) {
499            assert!(pair[0].hit_rate() >= pair[1].hit_rate());
500        }
501    }
502
503    #[test]
504    fn compare_policies_all_three_policies_present() {
505        let sim = CacheEvictionSimulator::new(2);
506        let trace = make_trace(&[("a", 1), ("b", 2), ("a", 3)]);
507        let results = sim.compare_policies(&trace);
508        let policies: Vec<EvictionPolicy> = results.iter().map(|r| r.policy).collect();
509        assert!(policies.contains(&EvictionPolicy::Lru));
510        assert!(policies.contains(&EvictionPolicy::Lfu));
511        assert!(policies.contains(&EvictionPolicy::ArcApprox));
512    }
513
514    // -----------------------------------------------------------------------
515    // Edge case tests
516    // -----------------------------------------------------------------------
517
518    #[test]
519    fn empty_trace_all_policies() {
520        let sim = CacheEvictionSimulator::new(4);
521        for policy in [
522            EvictionPolicy::Lru,
523            EvictionPolicy::Lfu,
524            EvictionPolicy::ArcApprox,
525        ] {
526            let res = sim.simulate(policy, &[]);
527            assert_eq!(res.hits, 0);
528            assert_eq!(res.misses, 0);
529            assert_eq!(res.evictions, 0);
530            assert!((res.hit_rate() - 0.0).abs() < 1e-9);
531            assert!((res.miss_rate() - 1.0).abs() < 1e-9);
532        }
533    }
534
535    #[test]
536    fn hit_rate_miss_rate_sum_to_one() {
537        let sim = CacheEvictionSimulator::new(2);
538        let trace = make_trace(&[("a", 1), ("b", 2), ("c", 3), ("a", 4)]);
539        for policy in [
540            EvictionPolicy::Lru,
541            EvictionPolicy::Lfu,
542            EvictionPolicy::ArcApprox,
543        ] {
544            let res = sim.simulate(policy, &trace);
545            assert!((res.hit_rate() + res.miss_rate() - 1.0).abs() < 1e-9);
546        }
547    }
548
549    #[test]
550    fn eviction_policy_derive_traits() {
551        let p = EvictionPolicy::Lru;
552        let q = p; // Copy
553        let r = p; // Clone
554        assert_eq!(p, q);
555        assert_eq!(p, r);
556        let _ = format!("{p:?}"); // Debug
557    }
558
559    #[test]
560    fn large_trace_lru_no_panic() {
561        let sim = CacheEvictionSimulator::new(10);
562        let trace: Vec<AccessEvent> = (0u64..200)
563            .map(|i| AccessEvent {
564                cid: format!("item-{}", i % 15),
565                timestamp_tick: i,
566            })
567            .collect();
568        let res = sim.simulate(EvictionPolicy::Lru, &trace);
569        assert!(res.hits + res.misses == 200);
570    }
571
572    #[test]
573    fn lru_no_eviction_under_capacity() {
574        let sim = CacheEvictionSimulator::new(10);
575        let trace = make_trace(&[("a", 1), ("b", 2), ("c", 3), ("a", 4), ("b", 5), ("c", 6)]);
576        let res = sim.simulate(EvictionPolicy::Lru, &trace);
577        assert_eq!(res.evictions, 0);
578        assert_eq!(res.hits, 3);
579    }
580}