Skip to main content

ipfrs_semantic/
similarity_cache.rs

1//! Two-level cache for cosine similarity scores between embedding pairs.
2//!
3//! Avoids redundant computation during k-NN searches by caching previously
4//! computed cosine similarity scores with LFU eviction and TTL-based staleness.
5
6use std::collections::HashMap;
7
8// ---------------------------------------------------------------------------
9// SimilarityKey
10// ---------------------------------------------------------------------------
11
12/// Canonical key for a similarity pair. Always stores the smaller ID first so
13/// that `new(a, b) == new(b, a)`.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub struct SimilarityKey {
16    /// Smaller of the two embedding IDs.
17    pub a_id: u64,
18    /// Larger of the two embedding IDs.
19    pub b_id: u64,
20}
21
22impl SimilarityKey {
23    /// Construct a canonical key ensuring `a_id <= b_id`.
24    #[inline]
25    pub fn new(x: u64, y: u64) -> Self {
26        Self {
27            a_id: x.min(y),
28            b_id: x.max(y),
29        }
30    }
31}
32
33// ---------------------------------------------------------------------------
34// SimilarityEntry
35// ---------------------------------------------------------------------------
36
37/// A cached similarity score along with bookkeeping metadata.
38#[derive(Debug, Clone)]
39pub struct SimilarityEntry {
40    /// The cosine similarity score in `[-1.0, 1.0]`.
41    pub score: f32,
42    /// Unix timestamp (seconds) at which this entry was computed.
43    pub computed_at: u64,
44    /// Number of times this entry has been returned from the cache.
45    pub hit_count: u32,
46}
47
48impl SimilarityEntry {
49    /// Returns `true` if the entry is older than `ttl_secs` relative to
50    /// `now_secs`.
51    #[inline]
52    pub fn is_stale(&self, ttl_secs: u64, now_secs: u64) -> bool {
53        now_secs.saturating_sub(self.computed_at) > ttl_secs
54    }
55}
56
57// ---------------------------------------------------------------------------
58// CacheStats
59// ---------------------------------------------------------------------------
60
61/// Aggregate statistics for an [`EmbeddingSimilarityCache`].
62#[derive(Debug, Clone, Default)]
63pub struct CacheStats {
64    /// Total number of successful cache lookups.
65    pub hits: u64,
66    /// Total number of failed cache lookups (including stale evictions).
67    pub misses: u64,
68    /// Total number of entries evicted (both LFU capacity evictions and stale
69    /// sweeps).
70    pub evictions: u64,
71    /// Current number of entries stored in the cache.
72    pub current_size: usize,
73}
74
75impl CacheStats {
76    /// Fraction of lookups that were satisfied by the cache.
77    ///
78    /// Returns `0.0` when no lookups have been performed.
79    #[inline]
80    pub fn hit_rate(&self) -> f64 {
81        let total = self.hits + self.misses;
82        if total == 0 {
83            0.0
84        } else {
85            self.hits as f64 / total as f64
86        }
87    }
88}
89
90// ---------------------------------------------------------------------------
91// EmbeddingSimilarityCache
92// ---------------------------------------------------------------------------
93
94/// Two-level cache for cosine similarity scores between embedding ID pairs.
95///
96/// Entries are evicted using a **Least-Frequently-Used (LFU)** policy when the
97/// cache reaches `max_capacity`, and are considered stale after `ttl_secs`
98/// seconds.
99pub struct EmbeddingSimilarityCache {
100    cache: HashMap<SimilarityKey, SimilarityEntry>,
101    max_capacity: usize,
102    ttl_secs: u64,
103    stats: CacheStats,
104}
105
106impl EmbeddingSimilarityCache {
107    // -----------------------------------------------------------------------
108    // Construction
109    // -----------------------------------------------------------------------
110
111    /// Create a new cache with the given capacity and TTL.
112    pub fn new(max_capacity: usize, ttl_secs: u64) -> Self {
113        Self {
114            cache: HashMap::new(),
115            max_capacity,
116            ttl_secs,
117            stats: CacheStats::default(),
118        }
119    }
120
121    // -----------------------------------------------------------------------
122    // Core cache operations
123    // -----------------------------------------------------------------------
124
125    /// Look up the similarity score for the pair `(a, b)`.
126    ///
127    /// - Returns `Some(score)` and increments `hit_count` + `stats.hits` on a
128    ///   fresh hit.
129    /// - Removes the entry and increments `stats.misses` when stale.
130    /// - Returns `None` and increments `stats.misses` when absent.
131    pub fn get(&mut self, a: u64, b: u64) -> Option<f32> {
132        let key = SimilarityKey::new(a, b);
133        let now = current_unix_secs();
134
135        match self.cache.get_mut(&key) {
136            Some(entry) if !entry.is_stale(self.ttl_secs, now) => {
137                entry.hit_count = entry.hit_count.saturating_add(1);
138                let score = entry.score;
139                self.stats.hits += 1;
140                self.stats.current_size = self.cache.len();
141                Some(score)
142            }
143            Some(_stale) => {
144                // Entry exists but is stale — remove it.
145                self.cache.remove(&key);
146                self.stats.misses += 1;
147                self.stats.evictions += 1;
148                self.stats.current_size = self.cache.len();
149                None
150            }
151            None => {
152                self.stats.misses += 1;
153                None
154            }
155        }
156    }
157
158    /// Insert a similarity score for the pair `(a, b)`.
159    ///
160    /// When the cache is at capacity the entry with the lowest `hit_count` is
161    /// evicted first (ties broken arbitrarily).
162    pub fn insert(&mut self, a: u64, b: u64, score: f32) {
163        let key = SimilarityKey::new(a, b);
164
165        // If the key already exists, overwrite in-place without eviction.
166        if self.cache.contains_key(&key) {
167            let entry = self.cache.get_mut(&key).expect("key confirmed present");
168            entry.score = score;
169            entry.computed_at = current_unix_secs();
170            // Reset hit_count for the refreshed entry.
171            entry.hit_count = 0;
172            self.stats.current_size = self.cache.len();
173            return;
174        }
175
176        // Evict LFU entry when at capacity.
177        if self.max_capacity > 0 && self.cache.len() >= self.max_capacity {
178            if let Some(lfu_key) = self
179                .cache
180                .iter()
181                .min_by_key(|(_, e)| e.hit_count)
182                .map(|(k, _)| *k)
183            {
184                self.cache.remove(&lfu_key);
185                self.stats.evictions += 1;
186            }
187        }
188
189        self.cache.insert(
190            key,
191            SimilarityEntry {
192                score,
193                computed_at: current_unix_secs(),
194                hit_count: 0,
195            },
196        );
197        self.stats.current_size = self.cache.len();
198    }
199
200    // -----------------------------------------------------------------------
201    // Similarity computation
202    // -----------------------------------------------------------------------
203
204    /// Compute the cosine similarity between two dense vectors.
205    ///
206    /// Returns `0.0` if either vector has a zero norm.  Vectors of different
207    /// lengths are treated as though padded with zeros — only the shorter
208    /// length is used for the dot product, and each norm is computed over its
209    /// own full slice.
210    pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
211        let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
212        let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
213        let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
214
215        if norm_a == 0.0 || norm_b == 0.0 {
216            0.0
217        } else {
218            dot / (norm_a * norm_b)
219        }
220    }
221
222    /// Return the cached similarity for `(a_id, b_id)`, computing and caching
223    /// it on a miss.
224    pub fn compute_and_cache(&mut self, a_id: u64, a_vec: &[f32], b_id: u64, b_vec: &[f32]) -> f32 {
225        if let Some(score) = self.get(a_id, b_id) {
226            return score;
227        }
228        let score = Self::cosine_similarity(a_vec, b_vec);
229        self.insert(a_id, b_id, score);
230        score
231    }
232
233    // -----------------------------------------------------------------------
234    // Maintenance
235    // -----------------------------------------------------------------------
236
237    /// Remove all entries whose TTL has expired.
238    ///
239    /// Returns the number of entries removed and updates `stats.evictions`.
240    pub fn evict_stale(&mut self) -> usize {
241        let now = current_unix_secs();
242        let ttl = self.ttl_secs;
243        let before = self.cache.len();
244        self.cache.retain(|_, e| !e.is_stale(ttl, now));
245        let removed = before - self.cache.len();
246        self.stats.evictions += removed as u64;
247        self.stats.current_size = self.cache.len();
248        removed
249    }
250
251    /// Return a reference to the current cache statistics.
252    #[inline]
253    pub fn stats(&self) -> &CacheStats {
254        &self.stats
255    }
256
257    /// Clear all entries and reset all statistics.
258    pub fn clear(&mut self) {
259        self.cache.clear();
260        self.stats = CacheStats::default();
261    }
262}
263
264// ---------------------------------------------------------------------------
265// Internal helpers
266// ---------------------------------------------------------------------------
267
268/// Returns the current Unix timestamp in seconds, or 0 on error.
269#[inline]
270fn current_unix_secs() -> u64 {
271    std::time::SystemTime::now()
272        .duration_since(std::time::UNIX_EPOCH)
273        .map(|d| d.as_secs())
274        .unwrap_or(0)
275}
276
277// ---------------------------------------------------------------------------
278// Tests
279// ---------------------------------------------------------------------------
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    // -- SimilarityKey -------------------------------------------------------
286
287    #[test]
288    fn test_similarity_key_canonical_ordering() {
289        let k1 = SimilarityKey::new(5, 3);
290        assert_eq!(k1.a_id, 3);
291        assert_eq!(k1.b_id, 5);
292    }
293
294    #[test]
295    fn test_similarity_key_already_ordered() {
296        let k = SimilarityKey::new(1, 9);
297        assert_eq!(k.a_id, 1);
298        assert_eq!(k.b_id, 9);
299    }
300
301    #[test]
302    fn test_similarity_key_symmetric() {
303        assert_eq!(SimilarityKey::new(1, 2), SimilarityKey::new(2, 1));
304    }
305
306    #[test]
307    fn test_similarity_key_same_ids() {
308        let k = SimilarityKey::new(7, 7);
309        assert_eq!(k.a_id, 7);
310        assert_eq!(k.b_id, 7);
311    }
312
313    // -- SimilarityEntry -----------------------------------------------------
314
315    #[test]
316    fn test_similarity_entry_not_stale() {
317        let now = current_unix_secs();
318        let entry = SimilarityEntry {
319            score: 0.9,
320            computed_at: now,
321            hit_count: 0,
322        };
323        assert!(!entry.is_stale(60, now));
324    }
325
326    #[test]
327    fn test_similarity_entry_stale() {
328        let entry = SimilarityEntry {
329            score: 0.9,
330            computed_at: 100,
331            hit_count: 0,
332        };
333        // now = 200, ttl = 50 → age = 100 > 50 → stale
334        assert!(entry.is_stale(50, 200));
335    }
336
337    #[test]
338    fn test_similarity_entry_exactly_at_ttl_boundary() {
339        // age == ttl should NOT be stale (strict >)
340        let entry = SimilarityEntry {
341            score: 0.5,
342            computed_at: 100,
343            hit_count: 0,
344        };
345        assert!(!entry.is_stale(100, 200)); // age == ttl → not stale
346        assert!(entry.is_stale(99, 200)); // age > ttl → stale
347    }
348
349    // -- CacheStats ----------------------------------------------------------
350
351    #[test]
352    fn test_cache_stats_hit_rate_zero_when_empty() {
353        let stats = CacheStats::default();
354        assert_eq!(stats.hit_rate(), 0.0);
355    }
356
357    #[test]
358    fn test_cache_stats_hit_rate() {
359        let stats = CacheStats {
360            hits: 3,
361            misses: 1,
362            evictions: 0,
363            current_size: 0,
364        };
365        assert!((stats.hit_rate() - 0.75).abs() < f64::EPSILON);
366    }
367
368    // -- get() ---------------------------------------------------------------
369
370    #[test]
371    fn test_get_miss_increments_misses() {
372        let mut cache = EmbeddingSimilarityCache::new(100, 3600);
373        assert!(cache.get(1, 2).is_none());
374        assert_eq!(cache.stats().misses, 1);
375        assert_eq!(cache.stats().hits, 0);
376    }
377
378    #[test]
379    fn test_get_hit_increments_hits_and_hit_count() {
380        let mut cache = EmbeddingSimilarityCache::new(100, 3600);
381        cache.insert(1, 2, 0.8);
382        let score = cache.get(1, 2);
383        assert!(score.is_some());
384        assert!(
385            (score.expect("test: similarity score should be present after insert") - 0.8).abs()
386                < f32::EPSILON
387        );
388        assert_eq!(cache.stats().hits, 1);
389        assert_eq!(cache.stats().misses, 0);
390
391        // Verify hit_count was incremented
392        let key = SimilarityKey::new(1, 2);
393        assert_eq!(cache.cache[&key].hit_count, 1);
394    }
395
396    #[test]
397    fn test_get_stale_entry_removed_and_misses_incremented() {
398        let mut cache = EmbeddingSimilarityCache::new(100, 0);
399        // Insert with ttl=0; any positive age makes it stale.
400        // We manually plant a stale entry.
401        let key = SimilarityKey::new(10, 20);
402        cache.cache.insert(
403            key,
404            SimilarityEntry {
405                score: 0.5,
406                computed_at: 1, // epoch start → definitely stale
407                hit_count: 0,
408            },
409        );
410        let result = cache.get(10, 20);
411        assert!(result.is_none());
412        assert_eq!(cache.stats().misses, 1);
413        assert!(!cache.cache.contains_key(&key));
414    }
415
416    // -- insert() ------------------------------------------------------------
417
418    #[test]
419    fn test_insert_under_capacity() {
420        let mut cache = EmbeddingSimilarityCache::new(10, 3600);
421        cache.insert(1, 2, 0.7);
422        assert_eq!(cache.stats().current_size, 1);
423    }
424
425    #[test]
426    fn test_insert_at_capacity_evicts_lowest_hit_count() {
427        let mut cache = EmbeddingSimilarityCache::new(3, 3600);
428        cache.insert(1, 2, 0.1);
429        cache.insert(3, 4, 0.2);
430        cache.insert(5, 6, 0.3);
431
432        // Bump hit_count on (1,2) and (3,4) so (5,6) has the lowest.
433        cache.get(1, 2);
434        cache.get(1, 2);
435        cache.get(3, 4);
436
437        // Now insert a 4th entry — should evict (5,6) with hit_count=0.
438        cache.insert(7, 8, 0.9);
439        assert_eq!(cache.stats().current_size, 3);
440        assert!(!cache.cache.contains_key(&SimilarityKey::new(5, 6)));
441        assert!(cache.cache.contains_key(&SimilarityKey::new(1, 2)));
442        assert!(cache.cache.contains_key(&SimilarityKey::new(3, 4)));
443        assert!(cache.cache.contains_key(&SimilarityKey::new(7, 8)));
444    }
445
446    // -- cosine_similarity() -------------------------------------------------
447
448    #[test]
449    fn test_cosine_similarity_identical_vectors() {
450        let v = vec![1.0_f32, 2.0, 3.0];
451        let sim = EmbeddingSimilarityCache::cosine_similarity(&v, &v);
452        assert!((sim - 1.0).abs() < 1e-6);
453    }
454
455    #[test]
456    fn test_cosine_similarity_orthogonal_vectors() {
457        let a = vec![1.0_f32, 0.0];
458        let b = vec![0.0_f32, 1.0];
459        let sim = EmbeddingSimilarityCache::cosine_similarity(&a, &b);
460        assert!(sim.abs() < 1e-6);
461    }
462
463    #[test]
464    fn test_cosine_similarity_zero_vector() {
465        let a = vec![0.0_f32, 0.0, 0.0];
466        let b = vec![1.0_f32, 2.0, 3.0];
467        let sim = EmbeddingSimilarityCache::cosine_similarity(&a, &b);
468        assert_eq!(sim, 0.0);
469    }
470
471    #[test]
472    fn test_cosine_similarity_positive() {
473        let a = vec![1.0_f32, 1.0];
474        let b = vec![1.0_f32, 0.5];
475        let sim = EmbeddingSimilarityCache::cosine_similarity(&a, &b);
476        assert!(sim > 0.0 && sim <= 1.0);
477    }
478
479    #[test]
480    fn test_cosine_similarity_negative() {
481        let a = vec![1.0_f32, 0.0];
482        let b = vec![-1.0_f32, 0.0];
483        let sim = EmbeddingSimilarityCache::cosine_similarity(&a, &b);
484        assert!((sim - (-1.0)).abs() < 1e-6);
485    }
486
487    // -- compute_and_cache() -------------------------------------------------
488
489    #[test]
490    fn test_compute_and_cache_on_miss_computes_and_inserts() {
491        let mut cache = EmbeddingSimilarityCache::new(100, 3600);
492        let a = vec![1.0_f32, 0.0];
493        let b = vec![0.0_f32, 1.0];
494        let score = cache.compute_and_cache(1, &a, 2, &b);
495        assert!(score.abs() < 1e-6); // orthogonal
496        assert_eq!(cache.stats().current_size, 1);
497        assert_eq!(cache.stats().misses, 1);
498    }
499
500    #[test]
501    fn test_compute_and_cache_on_hit_returns_cached() {
502        let mut cache = EmbeddingSimilarityCache::new(100, 3600);
503        let a = vec![1.0_f32, 0.0];
504        let b = vec![1.0_f32, 0.0];
505        cache.compute_and_cache(1, &a, 2, &b); // miss → compute
506        let score = cache.compute_and_cache(1, &a, 2, &b); // hit
507        assert!((score - 1.0).abs() < 1e-6);
508        assert_eq!(cache.stats().hits, 1);
509        assert_eq!(cache.stats().misses, 1);
510    }
511
512    // -- evict_stale() -------------------------------------------------------
513
514    #[test]
515    fn test_evict_stale_removes_expired_entries() {
516        let mut cache = EmbeddingSimilarityCache::new(100, 3600);
517        // Plant two entries with ancient timestamps.
518        for (a, b) in [(1u64, 2u64), (3, 4)] {
519            cache.cache.insert(
520                SimilarityKey::new(a, b),
521                SimilarityEntry {
522                    score: 0.5,
523                    computed_at: 1,
524                    hit_count: 0,
525                },
526            );
527        }
528        // Insert one fresh entry.
529        cache.insert(5, 6, 0.9);
530
531        let removed = cache.evict_stale();
532        assert_eq!(removed, 2);
533        assert_eq!(cache.stats().current_size, 1);
534    }
535
536    #[test]
537    fn test_evict_stale_updates_eviction_count() {
538        let mut cache = EmbeddingSimilarityCache::new(100, 3600);
539        cache.cache.insert(
540            SimilarityKey::new(99, 100),
541            SimilarityEntry {
542                score: 0.3,
543                computed_at: 1,
544                hit_count: 0,
545            },
546        );
547        cache.evict_stale();
548        assert_eq!(cache.stats().evictions, 1);
549    }
550
551    // -- clear() -------------------------------------------------------------
552
553    #[test]
554    fn test_clear_resets_cache_and_stats() {
555        let mut cache = EmbeddingSimilarityCache::new(100, 3600);
556        cache.insert(1, 2, 0.5);
557        cache.get(1, 2);
558        cache.clear();
559
560        assert_eq!(cache.stats().hits, 0);
561        assert_eq!(cache.stats().misses, 0);
562        assert_eq!(cache.stats().evictions, 0);
563        assert_eq!(cache.stats().current_size, 0);
564        assert!(cache.cache.is_empty());
565    }
566}