Skip to main content

wm_memory/
predictive_cache.rs

1//! Predictive LRU Cache with Markov Chain Pre-warming.
2//!
3//! Ported from v2's optimization/predictive_cache.py.
4//! Tracks access patterns to predict future accesses, maintaining a
5//! rolling window of transitions. When a key is accessed, likely-next
6//! keys are pre-warmed for faster retrieval.
7//!
8//! Target: 60–70% faster access for frequently used memories.
9
10use std::collections::{HashMap, VecDeque};
11
12/// Cache performance statistics.
13#[derive(Debug, Clone, Default)]
14pub struct CacheStats {
15    pub hits: u64,
16    pub misses: u64,
17    pub predictions: u64,
18    pub prediction_hits: u64,
19    pub evictions: u64,
20}
21
22impl CacheStats {
23    /// Hit rate (0.0–1.0).
24    #[must_use]
25    pub fn hit_rate(&self) -> f64 {
26        let total = self.hits + self.misses;
27        if total == 0 {
28            0.0
29        } else {
30            self.hits as f64 / total as f64
31        }
32    }
33
34    /// Prediction accuracy (0.0–1.0).
35    #[must_use]
36    pub fn prediction_accuracy(&self) -> f64 {
37        if self.predictions == 0 {
38            0.0
39        } else {
40            self.prediction_hits as f64 / self.predictions as f64
41        }
42    }
43}
44
45/// LRU cache entry.
46struct Entry<V> {
47    value: V,
48    /// Position in the LRU order (higher = more recently used)
49    order: u64,
50}
51
52/// Predictive LRU cache with Markov chain pre-warming.
53///
54/// Generic over the value type `V`. Keys are strings.
55pub struct PredictiveCache<V> {
56    max_size: usize,
57    prediction_depth: usize,
58    max_history: usize,
59
60    cache: HashMap<String, Entry<V>>,
61    lru_counter: u64,
62
63    access_history: VecDeque<String>,
64    /// Markov transition counts: access_patterns[a][b] = count of a→b transitions
65    access_patterns: HashMap<String, HashMap<String, u64>>,
66
67    /// Keys predicted to be accessed soon
68    prewarmed: HashMap<String, bool>,
69
70    pub stats: CacheStats,
71}
72
73impl<V: Clone> PredictiveCache<V> {
74    /// Create a new predictive cache.
75    ///
76    /// # Arguments
77    /// * `max_size` - Maximum number of cached entries
78    /// * `prediction_depth` - How many likely-next keys to pre-warm
79    #[must_use]
80    pub fn new(max_size: usize, prediction_depth: usize) -> Self {
81        Self {
82            max_size,
83            prediction_depth,
84            max_history: 100,
85            cache: HashMap::new(),
86            lru_counter: 0,
87            access_history: VecDeque::with_capacity(100),
88            access_patterns: HashMap::new(),
89            prewarmed: HashMap::new(),
90            stats: CacheStats::default(),
91        }
92    }
93
94    /// Get a value from the cache.
95    ///
96    /// Returns `Some(value)` on hit, `None` on miss.
97    /// On hit, records the access and predicts likely-next accesses.
98    pub fn get(&mut self, key: &str) -> Option<V> {
99        // Check if this was a predicted access
100        if self.prewarmed.remove(key).is_some() {
101            self.stats.prediction_hits += 1;
102        }
103
104        let found = self.cache.get(key).map(|e| e.value.clone());
105
106        if let Some(value) = found {
107            self.stats.hits += 1;
108            self.lru_counter += 1;
109            if let Some(entry) = self.cache.get_mut(key) {
110                entry.order = self.lru_counter;
111            }
112
113            self.record_access(key);
114            self.predict_next(key);
115
116            Some(value)
117        } else {
118            self.stats.misses += 1;
119            self.record_access(key);
120            None
121        }
122    }
123
124    /// Set a value in the cache.
125    ///
126    /// Evicts the least-recently-used entry if over capacity.
127    pub fn set(&mut self, key: &str, value: V) {
128        self.lru_counter += 1;
129
130        if let Some(entry) = self.cache.get_mut(key) {
131            entry.value = value;
132            entry.order = self.lru_counter;
133            return;
134        }
135
136        self.cache.insert(
137            key.to_string(),
138            Entry {
139                value,
140                order: self.lru_counter,
141            },
142        );
143
144        // Evict if over capacity
145        if self.cache.len() > self.max_size {
146            self.evict_lru();
147        }
148    }
149
150    /// Manually pre-warm the cache with specific keys using a loader function.
151    pub fn prewarm<F>(&mut self, loader: F, keys: &[String])
152    where
153        F: Fn(&str) -> Option<V>,
154    {
155        for key in keys {
156            if !self.cache.contains_key(key) {
157                if let Some(value) = loader(key) {
158                    self.set(key, value);
159                }
160            }
161        }
162    }
163
164    /// Remove a key from the cache.
165    pub fn invalidate(&mut self, key: &str) {
166        self.cache.remove(key);
167        self.prewarmed.remove(key);
168    }
169
170    /// Clear the entire cache and all state.
171    pub fn clear(&mut self) {
172        self.cache.clear();
173        self.prewarmed.clear();
174        self.access_history.clear();
175        self.access_patterns.clear();
176        self.stats = CacheStats::default();
177    }
178
179    /// Current number of cached entries.
180    #[must_use]
181    pub fn len(&self) -> usize {
182        self.cache.len()
183    }
184
185    /// Whether the cache is empty.
186    #[must_use]
187    pub fn is_empty(&self) -> bool {
188        self.cache.is_empty()
189    }
190
191    /// Get the most likely next accesses given a current key.
192    #[must_use]
193    pub fn likely_next(&self, current_key: &str, top_n: usize) -> Vec<(String, f64)> {
194        match self.access_patterns.get(current_key) {
195            None => Vec::new(),
196            Some(transitions) => {
197                let total: u64 = transitions.values().sum();
198                if total == 0 {
199                    return Vec::new();
200                }
201                let mut result: Vec<(String, f64)> = transitions
202                    .iter()
203                    .map(|(k, &count)| (k.clone(), count as f64 / total as f64))
204                    .collect();
205                result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
206                result.truncate(top_n);
207                result
208            }
209        }
210    }
211
212    /// Get the most frequently accessed keys.
213    #[must_use]
214    pub fn hot_keys(&self, top_n: usize) -> Vec<(String, u64)> {
215        let mut counts: HashMap<String, u64> = HashMap::new();
216        for key in &self.access_history {
217            *counts.entry(key.clone()).or_insert(0) += 1;
218        }
219        let mut result: Vec<(String, u64)> = counts.into_iter().collect();
220        result.sort_by_key(|x| std::cmp::Reverse(x.1));
221        result.truncate(top_n);
222        result
223    }
224
225    fn evict_lru(&mut self) {
226        if self.cache.is_empty() {
227            return;
228        }
229        // Find the entry with the lowest order
230        let min_key = self
231            .cache
232            .iter()
233            .min_by_key(|(_, e)| e.order)
234            .map(|(k, _)| k.clone());
235
236        if let Some(key) = min_key {
237            self.cache.remove(&key);
238            self.stats.evictions += 1;
239        }
240    }
241
242    fn record_access(&mut self, key: &str) {
243        self.access_history.push_back(key.to_string());
244        if self.access_history.len() > self.max_history {
245            self.access_history.pop_front();
246        }
247
248        // Update Markov transition counts
249        if self.access_history.len() >= 2 {
250            let prev_key = self
251                .access_history
252                .get(self.access_history.len() - 2)
253                .cloned();
254            if let Some(prev) = prev_key {
255                *self
256                    .access_patterns
257                    .entry(prev)
258                    .or_default()
259                    .entry(key.to_string())
260                    .or_insert(0) += 1;
261            }
262        }
263    }
264
265    fn predict_next(&mut self, current_key: &str) {
266        let transitions = match self.access_patterns.get(current_key) {
267            None => return,
268            Some(t) => t.clone(),
269        };
270
271        let total: u64 = transitions.values().sum();
272        if total == 0 {
273            return;
274        }
275
276        let mut likely: Vec<(String, f64)> = transitions
277            .iter()
278            .map(|(k, &count)| (k.clone(), count as f64 / total as f64))
279            .collect();
280        likely.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
281        likely.truncate(self.prediction_depth);
282
283        for (next_key, probability) in likely {
284            if probability > 0.3 {
285                self.prewarmed.insert(next_key, true);
286                self.stats.predictions += 1;
287            }
288        }
289    }
290}
291
292impl<V: Clone> Default for PredictiveCache<V> {
293    fn default() -> Self {
294        Self::new(1000, 3)
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn cache_miss_then_hit() {
304        let mut cache: PredictiveCache<String> = PredictiveCache::new(10, 3);
305        assert!(cache.get("missing").is_none());
306        assert_eq!(cache.stats.misses, 1);
307
308        cache.set("key", "value".to_string());
309        assert_eq!(cache.get("key"), Some("value".to_string()));
310        assert_eq!(cache.stats.hits, 1);
311    }
312
313    #[test]
314    fn cache_eviction() {
315        let mut cache: PredictiveCache<i32> = PredictiveCache::new(3, 1);
316        cache.set("a", 1);
317        cache.set("b", 2);
318        cache.set("c", 3);
319        cache.set("d", 4); // should evict "a" (least recently used)
320
321        assert_eq!(cache.len(), 3);
322        assert!(cache.get("a").is_none());
323        assert!(cache.get("d").is_some());
324        assert_eq!(cache.stats.evictions, 1);
325    }
326
327    #[test]
328    fn cache_lru_order_updates_on_get() {
329        let mut cache: PredictiveCache<i32> = PredictiveCache::new(3, 1);
330        cache.set("a", 1);
331        cache.set("b", 2);
332        cache.set("c", 3);
333
334        // Access "a" to make it recently used
335        let _ = cache.get("a");
336        cache.set("d", 4); // should evict "b" now, not "a"
337
338        assert!(cache.get("a").is_some());
339        assert!(cache.get("b").is_none());
340    }
341
342    #[test]
343    fn cache_markov_prediction() {
344        let mut cache: PredictiveCache<i32> = PredictiveCache::new(10, 3);
345
346        // Build a pattern: a → b → c → a → b → c
347        for key in &["a", "b", "c", "a", "b", "c"] {
348            cache.set(key, 1);
349            let _ = cache.get(key);
350        }
351
352        // After accessing "a", "b" should be predicted
353        let likely = cache.likely_next("a", 5);
354        assert!(!likely.is_empty());
355        assert_eq!(likely[0].0, "b");
356    }
357
358    #[test]
359    fn cache_prediction_hit_tracking() {
360        let mut cache: PredictiveCache<i32> = PredictiveCache::new(10, 3);
361
362        // Build pattern: a → b
363        cache.set("a", 1);
364        let _ = cache.get("a");
365        cache.set("b", 2);
366        let _ = cache.get("b");
367
368        // Access "a" again — should predict "b"
369        let _ = cache.get("a");
370        assert!(cache.stats.predictions > 0);
371
372        // Now access "b" — should count as prediction hit
373        let _ = cache.get("b");
374        assert!(cache.stats.prediction_hits > 0);
375    }
376
377    #[test]
378    fn cache_hot_keys() {
379        let mut cache: PredictiveCache<i32> = PredictiveCache::new(10, 1);
380        for _ in 0..5 {
381            let _ = cache.get("hot");
382        }
383        for _ in 0..2 {
384            let _ = cache.get("warm");
385        }
386
387        let hot = cache.hot_keys(10);
388        assert_eq!(hot[0].0, "hot");
389        assert!(hot[0].1 > hot[1].1);
390    }
391
392    #[test]
393    fn cache_invalidate() {
394        let mut cache: PredictiveCache<i32> = PredictiveCache::new(10, 1);
395        cache.set("a", 1);
396        cache.invalidate("a");
397        assert!(cache.get("a").is_none());
398    }
399
400    #[test]
401    fn cache_clear() {
402        let mut cache: PredictiveCache<i32> = PredictiveCache::new(10, 1);
403        cache.set("a", 1);
404        cache.set("b", 2);
405        let _ = cache.get("a");
406        cache.clear();
407        assert!(cache.is_empty());
408        assert_eq!(cache.stats.hits, 0);
409    }
410
411    #[test]
412    fn cache_prewarm() {
413        let mut cache: PredictiveCache<i32> = PredictiveCache::new(10, 1);
414        let loader = |key: &str| -> Option<i32> { i32::try_from(key.len()).ok() };
415        cache.prewarm(loader, &["alpha".to_string(), "beta".to_string()]);
416        assert_eq!(cache.get("alpha"), Some(5));
417        assert_eq!(cache.get("beta"), Some(4));
418    }
419
420    #[test]
421    fn cache_stats_hit_rate() {
422        let mut cache: PredictiveCache<i32> = PredictiveCache::new(10, 1);
423        cache.set("a", 1);
424        let _ = cache.get("a"); // hit
425        let _ = cache.get("a"); // hit
426        let _ = cache.get("b"); // miss
427
428        assert!((cache.stats.hit_rate() - 2.0 / 3.0).abs() < 0.01);
429    }
430
431    #[test]
432    fn cache_likely_next_empty_for_unknown() {
433        let cache: PredictiveCache<i32> = PredictiveCache::new(10, 3);
434        assert!(cache.likely_next("unknown", 5).is_empty());
435    }
436}