Skip to main content

ipfrs_semantic/
embedding_cache.rs

1//! # Semantic Embedding Cache
2//!
3//! Cache for computed embeddings to avoid re-computation. Provides
4//! TTL-based expiry, LRU batch eviction, prefix invalidation, and
5//! hit/miss statistics.
6
7use std::collections::HashMap;
8
9// ---------------------------------------------------------------------------
10// Data types
11// ---------------------------------------------------------------------------
12
13/// A single cached embedding entry.
14#[derive(Debug, Clone)]
15pub struct CachedEmbedding {
16    /// The cache key that identifies this embedding.
17    pub key: String,
18    /// The embedding vector.
19    pub embedding: Vec<f64>,
20    /// Tick at which this entry was stored.
21    pub computed_tick: u64,
22    /// Number of times the entry has been accessed via `get`.
23    pub access_count: u64,
24    /// Time-to-live measured in ticks.
25    pub ttl_ticks: u64,
26    /// Monotonic insertion sequence for deterministic eviction tie-breaking.
27    pub insertion_seq: u64,
28}
29
30/// Configuration for [`SemanticEmbeddingCache`].
31#[derive(Debug, Clone)]
32pub struct EmbeddingCacheConfig {
33    /// Maximum number of entries before eviction triggers (default 10 000).
34    pub max_entries: usize,
35    /// Default TTL in ticks for entries that do not specify one (default 500).
36    pub default_ttl_ticks: u64,
37    /// Number of entries to evict in one batch when the cache is full (default 100).
38    pub eviction_batch_size: usize,
39}
40
41impl Default for EmbeddingCacheConfig {
42    fn default() -> Self {
43        Self {
44            max_entries: 10_000,
45            default_ttl_ticks: 500,
46            eviction_batch_size: 100,
47        }
48    }
49}
50
51/// Aggregate statistics for the cache.
52#[derive(Debug, Clone)]
53pub struct EmbeddingCacheStats {
54    /// Current number of entries.
55    pub entry_count: usize,
56    /// Total number of cache hits.
57    pub hits: u64,
58    /// Total number of cache misses.
59    pub misses: u64,
60    /// Total number of evicted entries.
61    pub evictions: u64,
62    /// Hit rate as a fraction in `[0.0, 1.0]` (0.0 when no lookups).
63    pub hit_rate: f64,
64    /// Estimated memory footprint in bytes.
65    pub memory_bytes_estimate: usize,
66}
67
68// ---------------------------------------------------------------------------
69// Cache implementation
70// ---------------------------------------------------------------------------
71
72/// A tick-based embedding cache with LRU batch eviction and TTL expiry.
73pub struct SemanticEmbeddingCache {
74    config: EmbeddingCacheConfig,
75    entries: HashMap<String, CachedEmbedding>,
76    current_tick: u64,
77    next_insertion_seq: u64,
78    hits: u64,
79    misses: u64,
80    evictions: u64,
81}
82
83impl SemanticEmbeddingCache {
84    /// Create a new cache with the given configuration.
85    pub fn new(config: EmbeddingCacheConfig) -> Self {
86        Self {
87            entries: HashMap::with_capacity(config.max_entries),
88            config,
89            current_tick: 0,
90            next_insertion_seq: 0,
91            hits: 0,
92            misses: 0,
93            evictions: 0,
94        }
95    }
96
97    /// Insert an embedding into the cache.
98    ///
99    /// If `ttl` is `None` the default TTL from the config is used.
100    /// When the cache exceeds `max_entries` after insertion the least-recently
101    /// used entries (by `access_count`, then oldest `computed_tick`) are evicted
102    /// in a batch.
103    pub fn put(&mut self, key: &str, embedding: Vec<f64>, ttl: Option<u64>) {
104        let ttl_ticks = ttl.unwrap_or(self.config.default_ttl_ticks);
105        let insertion_seq = self.next_insertion_seq;
106        self.next_insertion_seq += 1;
107        let entry = CachedEmbedding {
108            key: key.to_string(),
109            embedding,
110            computed_tick: self.current_tick,
111            access_count: 0,
112            ttl_ticks,
113            insertion_seq,
114        };
115        self.entries.insert(key.to_string(), entry);
116
117        if self.entries.len() > self.config.max_entries {
118            self.evict_batch();
119        }
120    }
121
122    /// Retrieve the embedding for `key` if it exists and has not expired.
123    ///
124    /// Increments the entry's `access_count` and records a hit. Returns `None`
125    /// (and records a miss) when the key is absent or the entry has expired.
126    pub fn get(&mut self, key: &str) -> Option<&[f64]> {
127        // Two-phase: check expiry first, then borrow mutably.
128        let expired = self
129            .entries
130            .get(key)
131            .map(|e| self.current_tick >= e.computed_tick + e.ttl_ticks)
132            .unwrap_or(true);
133
134        if expired {
135            // Remove if it exists but is expired.
136            self.entries.remove(key);
137            self.misses += 1;
138            return None;
139        }
140
141        self.hits += 1;
142        // SAFETY: we know the key exists and is not expired.
143        let entry = self
144            .entries
145            .get_mut(key)
146            .expect("entry must exist after non-expired check");
147        entry.access_count += 1;
148        Some(&entry.embedding)
149    }
150
151    /// Check whether `key` is present **and** not expired without updating
152    /// the entry's `access_count`.
153    pub fn contains(&self, key: &str) -> bool {
154        self.entries
155            .get(key)
156            .is_some_and(|e| self.current_tick < e.computed_tick + e.ttl_ticks)
157    }
158
159    /// Remove a specific entry. Returns `true` if the entry existed.
160    pub fn invalidate(&mut self, key: &str) -> bool {
161        self.entries.remove(key).is_some()
162    }
163
164    /// Remove all entries whose key starts with `prefix`. Returns the number
165    /// of entries removed.
166    pub fn invalidate_prefix(&mut self, prefix: &str) -> usize {
167        let keys_to_remove: Vec<String> = self
168            .entries
169            .keys()
170            .filter(|k| k.starts_with(prefix))
171            .cloned()
172            .collect();
173        let count = keys_to_remove.len();
174        for k in keys_to_remove {
175            self.entries.remove(&k);
176        }
177        count
178    }
179
180    /// Advance the internal tick counter by one and remove all expired entries.
181    pub fn tick_cleanup(&mut self) {
182        self.current_tick += 1;
183        self.entries
184            .retain(|_, e| self.current_tick < e.computed_tick + e.ttl_ticks);
185    }
186
187    /// Return the current number of entries in the cache.
188    pub fn entry_count(&self) -> usize {
189        self.entries.len()
190    }
191
192    /// Return the hit rate as a value in `[0.0, 1.0]`.
193    ///
194    /// Returns `0.0` when no lookups have been performed.
195    pub fn hit_rate(&self) -> f64 {
196        let total = self.hits + self.misses;
197        if total == 0 {
198            0.0
199        } else {
200            self.hits as f64 / total as f64
201        }
202    }
203
204    /// Estimate the memory footprint of all cached entries in bytes.
205    ///
206    /// Each entry contributes `key.len() + embedding.len() * 8` bytes (the
207    /// heap portion of the key string plus the heap portion of the `Vec<f64>`).
208    pub fn memory_estimate(&self) -> usize {
209        self.entries
210            .values()
211            .map(|e| e.key.len() + e.embedding.len() * 8)
212            .sum()
213    }
214
215    /// Return a snapshot of the cache statistics.
216    pub fn stats(&self) -> EmbeddingCacheStats {
217        EmbeddingCacheStats {
218            entry_count: self.entry_count(),
219            hits: self.hits,
220            misses: self.misses,
221            evictions: self.evictions,
222            hit_rate: self.hit_rate(),
223            memory_bytes_estimate: self.memory_estimate(),
224        }
225    }
226
227    // -----------------------------------------------------------------------
228    // Private helpers
229    // -----------------------------------------------------------------------
230
231    /// Evict the `eviction_batch_size` least-recently-used entries.
232    ///
233    /// "Least recently used" is defined as lowest `access_count`; ties are
234    /// broken by oldest `computed_tick`.
235    fn evict_batch(&mut self) {
236        let batch = self.config.eviction_batch_size;
237        if batch == 0 || self.entries.is_empty() {
238            return;
239        }
240
241        // Collect (key, access_count, computed_tick, insertion_seq) for sorting.
242        let mut candidates: Vec<(String, u64, u64, u64)> = self
243            .entries
244            .iter()
245            .map(|(k, e)| (k.clone(), e.access_count, e.computed_tick, e.insertion_seq))
246            .collect();
247
248        // Sort ascending by access_count, then computed_tick, then insertion_seq (oldest first).
249        candidates.sort_by(|a, b| {
250            a.1.cmp(&b.1)
251                .then_with(|| a.2.cmp(&b.2))
252                .then_with(|| a.3.cmp(&b.3))
253        });
254
255        let to_remove = batch.min(candidates.len());
256        for (key, _, _, _) in candidates.into_iter().take(to_remove) {
257            self.entries.remove(&key);
258            self.evictions += 1;
259        }
260    }
261}
262
263// ---------------------------------------------------------------------------
264// Tests
265// ---------------------------------------------------------------------------
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    fn default_cache() -> SemanticEmbeddingCache {
272        SemanticEmbeddingCache::new(EmbeddingCacheConfig::default())
273    }
274
275    fn small_cache(max: usize, ttl: u64, batch: usize) -> SemanticEmbeddingCache {
276        SemanticEmbeddingCache::new(EmbeddingCacheConfig {
277            max_entries: max,
278            default_ttl_ticks: ttl,
279            eviction_batch_size: batch,
280        })
281    }
282
283    // 1 ─ put / get roundtrip
284    #[test]
285    fn test_put_get_roundtrip() {
286        let mut c = default_cache();
287        c.put("a", vec![1.0, 2.0, 3.0], None);
288        let v = c.get("a");
289        assert!(v.is_some());
290        assert_eq!(v.map(|s| s.to_vec()), Some(vec![1.0, 2.0, 3.0]));
291    }
292
293    // 2 ─ get missing key
294    #[test]
295    fn test_get_missing() {
296        let mut c = default_cache();
297        assert!(c.get("no_such_key").is_none());
298    }
299
300    // 3 ─ TTL expiry
301    #[test]
302    fn test_ttl_expiry() {
303        let mut c = small_cache(100, 2, 10);
304        c.put("x", vec![1.0], None); // ttl = 2
305        assert!(c.get("x").is_some()); // tick 0, computed 0, expires at 2
306        c.tick_cleanup(); // tick → 1
307        assert!(c.get("x").is_some());
308        c.tick_cleanup(); // tick → 2, entry expires (2 >= 0+2)
309        assert!(c.get("x").is_none());
310    }
311
312    // 4 ─ custom TTL
313    #[test]
314    fn test_custom_ttl() {
315        let mut c = small_cache(100, 100, 10);
316        c.put("short", vec![1.0], Some(1));
317        c.tick_cleanup(); // tick → 1, entry expires (1 >= 0+1)
318        assert!(c.get("short").is_none());
319    }
320
321    // 5 ─ LRU eviction triggers
322    #[test]
323    fn test_lru_eviction_triggers() {
324        let mut c = small_cache(5, 1000, 2);
325        for i in 0..5 {
326            c.put(&format!("k{i}"), vec![i as f64], None);
327        }
328        assert_eq!(c.entry_count(), 5);
329        // Access k3 and k4 to raise their access_count.
330        let _ = c.get("k3");
331        let _ = c.get("k4");
332        // Insert one more → triggers eviction of 2 LRU entries.
333        c.put("k5", vec![5.0], None);
334        assert!(c.entry_count() <= 5);
335        // k3 and k4 should survive (higher access_count).
336        assert!(c.contains("k3"));
337        assert!(c.contains("k4"));
338    }
339
340    // 6 ─ eviction counter
341    #[test]
342    fn test_eviction_counter() {
343        let mut c = small_cache(3, 1000, 2);
344        for i in 0..4 {
345            c.put(&format!("e{i}"), vec![0.0], None);
346        }
347        assert_eq!(c.evictions, 2);
348    }
349
350    // 7 ─ hit counting
351    #[test]
352    fn test_hit_counting() {
353        let mut c = default_cache();
354        c.put("h", vec![1.0], None);
355        let _ = c.get("h");
356        let _ = c.get("h");
357        assert_eq!(c.hits, 2);
358    }
359
360    // 8 ─ miss counting
361    #[test]
362    fn test_miss_counting() {
363        let mut c = default_cache();
364        let _ = c.get("nope");
365        let _ = c.get("nope2");
366        assert_eq!(c.misses, 2);
367    }
368
369    // 9 ─ hit rate
370    #[test]
371    fn test_hit_rate() {
372        let mut c = default_cache();
373        c.put("r", vec![1.0], None);
374        let _ = c.get("r"); // hit
375        let _ = c.get("miss"); // miss
376        let rate = c.hit_rate();
377        assert!((rate - 0.5).abs() < 1e-10);
378    }
379
380    // 10 ─ hit rate empty
381    #[test]
382    fn test_hit_rate_empty() {
383        let c = default_cache();
384        assert!((c.hit_rate() - 0.0).abs() < 1e-10);
385    }
386
387    // 11 ─ invalidate existing
388    #[test]
389    fn test_invalidate_existing() {
390        let mut c = default_cache();
391        c.put("del", vec![1.0], None);
392        assert!(c.invalidate("del"));
393        assert!(!c.contains("del"));
394    }
395
396    // 12 ─ invalidate missing
397    #[test]
398    fn test_invalidate_missing() {
399        let mut c = default_cache();
400        assert!(!c.invalidate("ghost"));
401    }
402
403    // 13 ─ invalidate prefix
404    #[test]
405    fn test_invalidate_prefix() {
406        let mut c = default_cache();
407        c.put("img:a", vec![1.0], None);
408        c.put("img:b", vec![2.0], None);
409        c.put("txt:c", vec![3.0], None);
410        let removed = c.invalidate_prefix("img:");
411        assert_eq!(removed, 2);
412        assert_eq!(c.entry_count(), 1);
413        assert!(c.contains("txt:c"));
414    }
415
416    // 14 ─ invalidate prefix none match
417    #[test]
418    fn test_invalidate_prefix_none() {
419        let mut c = default_cache();
420        c.put("abc", vec![1.0], None);
421        assert_eq!(c.invalidate_prefix("xyz"), 0);
422    }
423
424    // 15 ─ contains does not update access count
425    #[test]
426    fn test_contains_no_access_update() {
427        let mut c = default_cache();
428        c.put("c", vec![1.0], None);
429        assert!(c.contains("c"));
430        assert!(c.contains("c"));
431        let entry = c.entries.get("c");
432        assert!(entry.is_some());
433        assert_eq!(entry.map(|e| e.access_count), Some(0));
434    }
435
436    // 16 ─ contains expired returns false
437    #[test]
438    fn test_contains_expired() {
439        let mut c = small_cache(100, 1, 10);
440        c.put("e", vec![1.0], None);
441        c.tick_cleanup(); // tick → 1, entry expires
442        assert!(!c.contains("e"));
443    }
444
445    // 17 ─ tick_cleanup removes expired
446    #[test]
447    fn test_tick_cleanup() {
448        let mut c = small_cache(100, 2, 10);
449        c.put("a", vec![1.0], None);
450        c.put("b", vec![2.0], Some(5));
451        c.tick_cleanup(); // tick 1
452        c.tick_cleanup(); // tick 2 → a expires
453        assert_eq!(c.entry_count(), 1);
454        assert!(c.contains("b"));
455    }
456
457    // 18 ─ memory estimate
458    #[test]
459    fn test_memory_estimate() {
460        let mut c = default_cache();
461        // key "ab" (2 bytes) + 3 f64s (24 bytes) = 26
462        c.put("ab", vec![1.0, 2.0, 3.0], None);
463        assert_eq!(c.memory_estimate(), 26);
464    }
465
466    // 19 ─ memory estimate multiple entries
467    #[test]
468    fn test_memory_estimate_multiple() {
469        let mut c = default_cache();
470        c.put("a", vec![1.0], None); // 1 + 8 = 9
471        c.put("bb", vec![1.0, 2.0], None); // 2 + 16 = 18
472        assert_eq!(c.memory_estimate(), 27);
473    }
474
475    // 20 ─ stats accuracy
476    #[test]
477    fn test_stats_accuracy() {
478        let mut c = small_cache(5, 1000, 2);
479        c.put("s1", vec![1.0], None);
480        c.put("s2", vec![2.0], None);
481        let _ = c.get("s1"); // hit
482        let _ = c.get("nope"); // miss
483        let s = c.stats();
484        assert_eq!(s.entry_count, 2);
485        assert_eq!(s.hits, 1);
486        assert_eq!(s.misses, 1);
487        assert!((s.hit_rate - 0.5).abs() < 1e-10);
488        assert!(s.memory_bytes_estimate > 0);
489    }
490
491    // 21 ─ empty cache
492    #[test]
493    fn test_empty_cache() {
494        let c = default_cache();
495        assert_eq!(c.entry_count(), 0);
496        assert_eq!(c.memory_estimate(), 0);
497        assert_eq!(c.hits, 0);
498        assert_eq!(c.misses, 0);
499    }
500
501    // 22 ─ batch eviction removes correct count
502    #[test]
503    fn test_batch_eviction_size() {
504        let mut c = small_cache(10, 1000, 5);
505        for i in 0..11 {
506            c.put(&format!("b{i}"), vec![0.0], None);
507        }
508        // 11 inserted, max 10 → evict 5 → 6 remain
509        assert_eq!(c.entry_count(), 6);
510        assert_eq!(c.evictions, 5);
511    }
512
513    // 23 ─ overwrite existing key
514    #[test]
515    fn test_overwrite_key() {
516        let mut c = default_cache();
517        c.put("ow", vec![1.0], None);
518        let _ = c.get("ow"); // access_count → 1
519        c.put("ow", vec![9.0], None);
520        let v = c.get("ow");
521        assert_eq!(v.map(|s| s.to_vec()), Some(vec![9.0]));
522        // access_count reset to 0 then incremented by get → 1
523        assert_eq!(c.entries.get("ow").map(|e| e.access_count), Some(1));
524    }
525
526    // 24 ─ default config values
527    #[test]
528    fn test_default_config() {
529        let cfg = EmbeddingCacheConfig::default();
530        assert_eq!(cfg.max_entries, 10_000);
531        assert_eq!(cfg.default_ttl_ticks, 500);
532        assert_eq!(cfg.eviction_batch_size, 100);
533    }
534
535    // 25 ─ large embedding
536    #[test]
537    fn test_large_embedding() {
538        let mut c = default_cache();
539        let big = vec![0.42; 768];
540        c.put("big", big.clone(), None);
541        let v = c.get("big");
542        assert_eq!(v.map(|s| s.len()), Some(768));
543        assert_eq!(v.map(|s| s[0]), Some(0.42));
544    }
545
546    // 26 ─ multiple tick cleanups
547    #[test]
548    fn test_multiple_tick_cleanups() {
549        let mut c = small_cache(100, 3, 10);
550        c.put("t1", vec![1.0], Some(1)); // expires tick 1
551        c.put("t2", vec![2.0], Some(2)); // expires tick 2
552        c.put("t3", vec![3.0], Some(3)); // expires tick 3
553        c.tick_cleanup(); // tick 1
554        assert_eq!(c.entry_count(), 2);
555        c.tick_cleanup(); // tick 2
556        assert_eq!(c.entry_count(), 1);
557        c.tick_cleanup(); // tick 3
558        assert_eq!(c.entry_count(), 0);
559    }
560
561    // 27 ─ eviction prefers lowest access_count
562    #[test]
563    fn test_eviction_prefers_lowest_access() {
564        let mut c = small_cache(4, 1000, 1);
565        c.put("lo1", vec![0.0], None);
566        c.put("lo2", vec![0.0], None);
567        c.put("hi1", vec![0.0], None);
568        c.put("hi2", vec![0.0], None);
569
570        // Bump access counts for hi1, hi2, lo2.
571        for _ in 0..5 {
572            let _ = c.get("hi1");
573            let _ = c.get("hi2");
574            let _ = c.get("lo2");
575        }
576
577        // Trigger eviction of 1 entry (lo1 has lowest access_count = 0).
578        c.put("new", vec![0.0], None);
579        // lo1 should have been evicted (access_count 0, oldest).
580        assert!(!c.contains("lo1"));
581        assert!(c.contains("hi1"));
582        assert!(c.contains("hi2"));
583        assert!(c.contains("lo2"));
584    }
585
586    // 28 ─ stats evictions field
587    #[test]
588    fn test_stats_evictions() {
589        let mut c = small_cache(3, 1000, 1);
590        c.put("a", vec![0.0], None);
591        c.put("b", vec![0.0], None);
592        c.put("c", vec![0.0], None);
593        c.put("d", vec![0.0], None); // triggers 1 eviction
594        let s = c.stats();
595        assert_eq!(s.evictions, 1);
596    }
597
598    // 29 ─ invalidate prefix with empty prefix removes all
599    #[test]
600    fn test_invalidate_prefix_empty() {
601        let mut c = default_cache();
602        c.put("a", vec![1.0], None);
603        c.put("b", vec![2.0], None);
604        let removed = c.invalidate_prefix("");
605        assert_eq!(removed, 2);
606        assert_eq!(c.entry_count(), 0);
607    }
608
609    // 30 ─ get expired entry records miss
610    #[test]
611    fn test_get_expired_records_miss() {
612        let mut c = small_cache(100, 1, 10);
613        c.put("exp", vec![1.0], None);
614        c.tick_cleanup(); // tick 1 → expired
615        let _ = c.get("exp");
616        assert_eq!(c.misses, 1);
617        assert_eq!(c.hits, 0);
618    }
619}