Skip to main content

ipfrs_semantic/
similarity_cache_v2.rs

1//! Pairwise cosine-similarity cache with LFU eviction and tick-based TTL.
2//!
3//! Unlike `similarity_cache`, which is optimised for k-NN query result caching,
4//! `similarity_cache_v2` focuses on caching individual *pairwise* similarity
5//! scores so that repeated lookups of the same `(id_a, id_b)` tuple avoid
6//! redundant floating-point computation.
7//!
8//! # Design
9//!
10//! * [`PairKey`] canonicalises the pair so that `(a, b)` and `(b, a)` map to
11//!   the same entry.
12//! * [`SemanticSimilarityCache`] stores up to `max_entries` pairs. When full,
13//!   the entry with the lowest `access_count` (LFU) is evicted.
14//! * TTL is expressed in abstract *ticks* (driven by the caller via
15//!   [`SemanticSimilarityCache::advance_tick`]), keeping the cache
16//!   deterministic and test-friendly.
17//!
18//! # Example
19//! ```rust
20//! use ipfrs_semantic::similarity_cache_v2::{PairCacheConfig, SemanticSimilarityCache};
21//!
22//! let config = PairCacheConfig { max_entries: 100, ttl_ticks: 50 };
23//! let mut cache = SemanticSimilarityCache::new(config);
24//!
25//! let va = vec![1.0_f32, 0.0, 0.0];
26//! let vb = vec![0.0_f32, 1.0, 0.0];
27//! let sim = cache.compute_and_cache(1, &va, 2, &vb);
28//! assert!((sim - 0.0).abs() < 1e-6);
29//! ```
30
31use std::collections::HashMap;
32
33// ---------------------------------------------------------------------------
34// PairKey
35// ---------------------------------------------------------------------------
36
37/// Canonical key for a similarity pair.
38///
39/// The constructor ensures `id_a == min(a, b)` and `id_b == max(a, b)`, so
40/// `PairKey::new(x, y) == PairKey::new(y, x)` for all `x`, `y`.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
42pub struct PairKey {
43    /// The smaller of the two embedding IDs.
44    pub id_a: u64,
45    /// The larger of the two embedding IDs.
46    pub id_b: u64,
47}
48
49impl PairKey {
50    /// Construct a canonical [`PairKey`], always placing the smaller ID first.
51    #[inline]
52    pub fn new(a: u64, b: u64) -> Self {
53        Self {
54            id_a: a.min(b),
55            id_b: a.max(b),
56        }
57    }
58}
59
60// ---------------------------------------------------------------------------
61// SimilarityEntry
62// ---------------------------------------------------------------------------
63
64/// A single cached pairwise similarity record.
65#[derive(Debug, Clone)]
66pub struct SimilarityEntry {
67    /// The canonical key this entry belongs to.
68    pub key: PairKey,
69    /// The cosine similarity score, in `[-1.0, 1.0]`.
70    pub similarity: f32,
71    /// The tick at which this entry was last written.
72    pub computed_at_tick: u64,
73    /// Number of successful cache reads (hits) for this entry.
74    pub access_count: u64,
75}
76
77impl SimilarityEntry {
78    /// Returns `true` when the entry is older than `ttl_ticks` ticks relative
79    /// to `current_tick`.
80    #[inline]
81    pub fn is_stale(&self, ttl_ticks: u64, current_tick: u64) -> bool {
82        current_tick.saturating_sub(self.computed_at_tick) > ttl_ticks
83    }
84}
85
86// ---------------------------------------------------------------------------
87// PairCacheConfig
88// ---------------------------------------------------------------------------
89
90/// Configuration for [`SemanticSimilarityCache`].
91#[derive(Debug, Clone)]
92pub struct PairCacheConfig {
93    /// Maximum number of pairwise entries stored simultaneously.
94    pub max_entries: usize,
95    /// Number of ticks after which an entry is considered stale.
96    pub ttl_ticks: u64,
97}
98
99impl Default for PairCacheConfig {
100    fn default() -> Self {
101        Self {
102            max_entries: 10_000,
103            ttl_ticks: 1_000,
104        }
105    }
106}
107
108// ---------------------------------------------------------------------------
109// PairCacheStats
110// ---------------------------------------------------------------------------
111
112/// Aggregate statistics for a [`SemanticSimilarityCache`].
113#[derive(Debug, Clone, Default)]
114pub struct PairCacheStats {
115    /// Total successful cache reads.
116    pub hits: u64,
117    /// Total cache misses (key absent or stale).
118    pub misses: u64,
119    /// Total LFU evictions performed.
120    pub evictions: u64,
121}
122
123impl PairCacheStats {
124    /// Fraction of lookups that were cache hits.
125    ///
126    /// Returns `0.0` when no lookups have been performed yet.
127    pub fn hit_rate(&self) -> f64 {
128        let total = self.hits + self.misses;
129        if total == 0 {
130            0.0
131        } else {
132            self.hits as f64 / total as f64
133        }
134    }
135}
136
137// ---------------------------------------------------------------------------
138// SemanticSimilarityCache
139// ---------------------------------------------------------------------------
140
141/// Pairwise cosine-similarity cache with LFU eviction and TTL staleness.
142///
143/// All public mutating methods are `&mut self`; no internal locking is
144/// performed — the caller is responsible for synchronisation when used from
145/// multiple threads.
146pub struct SemanticSimilarityCache {
147    entries: HashMap<PairKey, SimilarityEntry>,
148    config: PairCacheConfig,
149    stats: PairCacheStats,
150    current_tick: u64,
151}
152
153impl SemanticSimilarityCache {
154    // -----------------------------------------------------------------------
155    // Construction
156    // -----------------------------------------------------------------------
157
158    /// Create a new cache with the given [`PairCacheConfig`].
159    pub fn new(config: PairCacheConfig) -> Self {
160        Self {
161            entries: HashMap::new(),
162            config,
163            stats: PairCacheStats::default(),
164            current_tick: 0,
165        }
166    }
167
168    // -----------------------------------------------------------------------
169    // Public API
170    // -----------------------------------------------------------------------
171
172    /// Look up the cached similarity for the pair `(a, b)`.
173    ///
174    /// Returns `None` when:
175    /// * the pair is not in the cache, or
176    /// * the cached entry is stale (older than `ttl_ticks`).
177    ///
178    /// On a hit the entry's `access_count` is incremented.
179    pub fn get(&mut self, a: u64, b: u64) -> Option<f32> {
180        let key = PairKey::new(a, b);
181        let ttl = self.config.ttl_ticks;
182        let tick = self.current_tick;
183
184        match self.entries.get_mut(&key) {
185            Some(entry) if !entry.is_stale(ttl, tick) => {
186                entry.access_count += 1;
187                self.stats.hits += 1;
188                Some(entry.similarity)
189            }
190            _ => {
191                self.stats.misses += 1;
192                None
193            }
194        }
195    }
196
197    /// Insert (or update) the similarity score for the pair `(a, b)`.
198    ///
199    /// When the cache is at capacity the entry with the lowest
200    /// `access_count` is evicted first (LFU).
201    pub fn insert(&mut self, a: u64, b: u64, similarity: f32) {
202        let key = PairKey::new(a, b);
203
204        // If already present, just overwrite without triggering eviction.
205        if self.entries.contains_key(&key) {
206            if let Some(entry) = self.entries.get_mut(&key) {
207                entry.similarity = similarity;
208                entry.computed_at_tick = self.current_tick;
209            }
210            return;
211        }
212
213        // Evict if we are at capacity.
214        if self.entries.len() >= self.config.max_entries {
215            self.evict_lfu();
216        }
217
218        self.entries.insert(
219            key,
220            SimilarityEntry {
221                key,
222                similarity,
223                computed_at_tick: self.current_tick,
224                access_count: 0,
225            },
226        );
227    }
228
229    /// Compute the cosine similarity between `vec_a` and `vec_b`, cache the
230    /// result under `(a, b)`, and return the score.
231    ///
232    /// If `a == b` the result is `1.0` without evaluating the vectors.
233    pub fn compute_and_cache(&mut self, a: u64, vec_a: &[f32], b: u64, vec_b: &[f32]) -> f32 {
234        let similarity = if a == b {
235            1.0_f32
236        } else {
237            Self::cosine_similarity(vec_a, vec_b)
238        };
239        self.insert(a, b, similarity);
240        similarity
241    }
242
243    /// Remove all stale entries from the cache and return the number removed.
244    pub fn evict_stale(&mut self) -> usize {
245        let ttl = self.config.ttl_ticks;
246        let tick = self.current_tick;
247        let before = self.entries.len();
248        self.entries.retain(|_, entry| !entry.is_stale(ttl, tick));
249        let removed = before - self.entries.len();
250        self.stats.evictions += removed as u64;
251        removed
252    }
253
254    /// Advance the internal logical clock by one tick.
255    pub fn advance_tick(&mut self) {
256        self.current_tick = self.current_tick.saturating_add(1);
257    }
258
259    /// Return a reference to the current aggregate statistics.
260    pub fn stats(&self) -> &PairCacheStats {
261        &self.stats
262    }
263
264    /// Return the current tick value.
265    pub fn current_tick(&self) -> u64 {
266        self.current_tick
267    }
268
269    /// Return the number of entries currently stored in the cache.
270    pub fn len(&self) -> usize {
271        self.entries.len()
272    }
273
274    /// Return `true` when the cache contains no entries.
275    pub fn is_empty(&self) -> bool {
276        self.entries.is_empty()
277    }
278
279    // -----------------------------------------------------------------------
280    // Private helpers
281    // -----------------------------------------------------------------------
282
283    /// Evict the entry with the lowest `access_count` (LFU policy).
284    ///
285    /// When multiple entries share the same minimum count the one that was
286    /// computed earliest is evicted (i.e., smallest `computed_at_tick`).
287    fn evict_lfu(&mut self) {
288        let victim = self
289            .entries
290            .iter()
291            .min_by(|(_ka, ea), (_kb, eb)| {
292                ea.access_count
293                    .cmp(&eb.access_count)
294                    .then(ea.computed_at_tick.cmp(&eb.computed_at_tick))
295            })
296            .map(|(k, _)| *k);
297
298        if let Some(key) = victim {
299            self.entries.remove(&key);
300            self.stats.evictions += 1;
301        }
302    }
303
304    /// Compute cosine similarity between two float slices.
305    ///
306    /// Returns `0.0` when either vector has zero norm or the slices differ in
307    /// length.
308    fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
309        if a.len() != b.len() || a.is_empty() {
310            return 0.0;
311        }
312
313        let mut dot = 0.0_f64;
314        let mut norm_a = 0.0_f64;
315        let mut norm_b = 0.0_f64;
316
317        for (&ai, &bi) in a.iter().zip(b.iter()) {
318            let af = ai as f64;
319            let bf = bi as f64;
320            dot += af * bf;
321            norm_a += af * af;
322            norm_b += bf * bf;
323        }
324
325        let denom = norm_a.sqrt() * norm_b.sqrt();
326        if denom < f64::EPSILON {
327            0.0
328        } else {
329            (dot / denom).clamp(-1.0, 1.0) as f32
330        }
331    }
332}
333
334// ---------------------------------------------------------------------------
335// Tests
336// ---------------------------------------------------------------------------
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    fn default_cache() -> SemanticSimilarityCache {
343        SemanticSimilarityCache::new(PairCacheConfig::default())
344    }
345
346    fn small_cache(max: usize) -> SemanticSimilarityCache {
347        SemanticSimilarityCache::new(PairCacheConfig {
348            max_entries: max,
349            ttl_ticks: 100,
350        })
351    }
352
353    // -----------------------------------------------------------------------
354    // 1. PairKey canonical ordering
355    // -----------------------------------------------------------------------
356
357    #[test]
358    fn pair_key_canonical_ordering_small_first() {
359        let k1 = PairKey::new(3, 7);
360        assert_eq!(k1.id_a, 3);
361        assert_eq!(k1.id_b, 7);
362    }
363
364    #[test]
365    fn pair_key_canonical_ordering_large_first() {
366        let k1 = PairKey::new(99, 1);
367        assert_eq!(k1.id_a, 1);
368        assert_eq!(k1.id_b, 99);
369    }
370
371    #[test]
372    fn pair_key_symmetry() {
373        assert_eq!(PairKey::new(5, 10), PairKey::new(10, 5));
374    }
375
376    #[test]
377    fn pair_key_equal_ids() {
378        let k = PairKey::new(42, 42);
379        assert_eq!(k.id_a, 42);
380        assert_eq!(k.id_b, 42);
381    }
382
383    // -----------------------------------------------------------------------
384    // 2. get — cache miss
385    // -----------------------------------------------------------------------
386
387    #[test]
388    fn get_miss_on_empty_cache() {
389        let mut cache = default_cache();
390        assert!(cache.get(1, 2).is_none());
391        assert_eq!(cache.stats().misses, 1);
392        assert_eq!(cache.stats().hits, 0);
393    }
394
395    #[test]
396    fn get_miss_increments_miss_counter() {
397        let mut cache = default_cache();
398        cache.get(10, 20);
399        cache.get(10, 20);
400        assert_eq!(cache.stats().misses, 2);
401    }
402
403    // -----------------------------------------------------------------------
404    // 3. insert + get hit
405    // -----------------------------------------------------------------------
406
407    #[test]
408    fn insert_and_get_hit() {
409        let mut cache = default_cache();
410        cache.insert(1, 2, 0.75);
411        let result = cache.get(1, 2);
412        assert!(result.is_some());
413        assert!(
414            (result.expect("test: get after insert should return cached similarity") - 0.75).abs()
415                < 1e-6
416        );
417    }
418
419    #[test]
420    fn insert_symmetric_get() {
421        let mut cache = default_cache();
422        cache.insert(7, 3, 0.5);
423        // Reverse order should hit the same entry.
424        let result = cache.get(3, 7);
425        assert!(result.is_some());
426        assert!(
427            (result.expect("test: symmetric get should return cached similarity") - 0.5).abs()
428                < 1e-6
429        );
430    }
431
432    #[test]
433    fn get_hit_increments_hit_counter() {
434        let mut cache = default_cache();
435        cache.insert(1, 2, 0.9);
436        cache.get(1, 2);
437        cache.get(2, 1);
438        assert_eq!(cache.stats().hits, 2);
439    }
440
441    // -----------------------------------------------------------------------
442    // 4. Stale entry miss
443    // -----------------------------------------------------------------------
444
445    #[test]
446    fn stale_entry_returns_none() {
447        let mut cache = SemanticSimilarityCache::new(PairCacheConfig {
448            max_entries: 100,
449            ttl_ticks: 2,
450        });
451        cache.insert(1, 2, 0.8);
452        // Advance past TTL.
453        cache.advance_tick();
454        cache.advance_tick();
455        cache.advance_tick(); // tick 3 > ttl 2
456        assert!(cache.get(1, 2).is_none());
457        assert_eq!(cache.stats().misses, 1);
458    }
459
460    #[test]
461    fn entry_not_stale_within_ttl() {
462        let mut cache = SemanticSimilarityCache::new(PairCacheConfig {
463            max_entries: 100,
464            ttl_ticks: 5,
465        });
466        cache.insert(1, 2, 0.6);
467        cache.advance_tick();
468        cache.advance_tick(); // tick 2, ttl 5 — still fresh
469        assert!(cache.get(1, 2).is_some());
470    }
471
472    // -----------------------------------------------------------------------
473    // 5. LFU eviction
474    // -----------------------------------------------------------------------
475
476    #[test]
477    fn lfu_eviction_removes_lowest_access_count() {
478        let mut cache = small_cache(2);
479
480        cache.insert(1, 2, 0.1);
481        cache.insert(3, 4, 0.2);
482
483        // Access (1,2) twice so its access_count > (3,4).
484        cache.get(1, 2);
485        cache.get(1, 2);
486
487        // Inserting a third entry must evict (3,4) because access_count == 0.
488        cache.insert(5, 6, 0.3);
489
490        assert!(cache.get(1, 2).is_some(), "(1,2) should survive");
491        assert!(cache.get(3, 4).is_none(), "(3,4) should be evicted");
492        assert!(cache.get(5, 6).is_some(), "(5,6) should be present");
493        assert_eq!(cache.stats().evictions, 1);
494    }
495
496    #[test]
497    fn lfu_eviction_on_full_cache() {
498        let mut cache = small_cache(1);
499        cache.insert(1, 2, 0.5);
500        // Warm up access count.
501        cache.get(1, 2);
502        cache.get(1, 2);
503
504        // Insert a new pair — must evict (1,2) since the cache is full.
505        cache.insert(3, 4, 0.9);
506        assert_eq!(cache.len(), 1);
507        assert_eq!(cache.stats().evictions, 1);
508        assert!(cache.get(3, 4).is_some());
509    }
510
511    // -----------------------------------------------------------------------
512    // 6. compute_and_cache
513    // -----------------------------------------------------------------------
514
515    #[test]
516    fn compute_and_cache_returns_correct_value() {
517        let mut cache = default_cache();
518        let va = vec![1.0_f32, 0.0, 0.0];
519        let vb = vec![1.0_f32, 0.0, 0.0];
520        let sim = cache.compute_and_cache(10, &va, 20, &vb);
521        assert!((sim - 1.0).abs() < 1e-5, "parallel vectors => similarity 1");
522    }
523
524    #[test]
525    fn compute_and_cache_orthogonal_vectors() {
526        let mut cache = default_cache();
527        let va = vec![1.0_f32, 0.0];
528        let vb = vec![0.0_f32, 1.0];
529        let sim = cache.compute_and_cache(1, &va, 2, &vb);
530        assert!((sim - 0.0).abs() < 1e-5, "orthogonal => similarity 0");
531    }
532
533    #[test]
534    fn compute_and_cache_stores_in_cache() {
535        let mut cache = default_cache();
536        let va = vec![1.0_f32, 1.0];
537        let vb = vec![1.0_f32, 1.0];
538        cache.compute_and_cache(5, &va, 6, &vb);
539        let result = cache.get(5, 6);
540        assert!(result.is_some());
541    }
542
543    // -----------------------------------------------------------------------
544    // 7. Same-id pair → similarity 1.0
545    // -----------------------------------------------------------------------
546
547    #[test]
548    fn same_id_pair_returns_one() {
549        let mut cache = default_cache();
550        let v = vec![3.0_f32, 4.0, 0.0];
551        let sim = cache.compute_and_cache(7, &v, 7, &v);
552        assert!((sim - 1.0).abs() < 1e-6, "same-id pair must return 1.0");
553    }
554
555    // -----------------------------------------------------------------------
556    // 8. evict_stale
557    // -----------------------------------------------------------------------
558
559    #[test]
560    fn evict_stale_removes_expired_entries() {
561        let mut cache = SemanticSimilarityCache::new(PairCacheConfig {
562            max_entries: 100,
563            ttl_ticks: 1,
564        });
565        cache.insert(1, 2, 0.1);
566        cache.insert(3, 4, 0.2);
567        // Advance past TTL.
568        cache.advance_tick();
569        cache.advance_tick(); // tick == 2 > ttl 1
570
571        // Fresh entry inserted at tick 2.
572        cache.insert(5, 6, 0.3);
573
574        let removed = cache.evict_stale();
575        assert_eq!(removed, 2, "two stale entries should be removed");
576        assert_eq!(cache.len(), 1, "only the fresh entry remains");
577    }
578
579    #[test]
580    fn evict_stale_noop_when_all_fresh() {
581        let mut cache = default_cache();
582        cache.insert(1, 2, 0.5);
583        let removed = cache.evict_stale();
584        assert_eq!(removed, 0);
585    }
586
587    // -----------------------------------------------------------------------
588    // 9. advance_tick
589    // -----------------------------------------------------------------------
590
591    #[test]
592    fn advance_tick_increments() {
593        let mut cache = default_cache();
594        assert_eq!(cache.current_tick(), 0);
595        cache.advance_tick();
596        assert_eq!(cache.current_tick(), 1);
597        cache.advance_tick();
598        assert_eq!(cache.current_tick(), 2);
599    }
600
601    // -----------------------------------------------------------------------
602    // 10. hit_rate
603    // -----------------------------------------------------------------------
604
605    #[test]
606    fn hit_rate_zero_when_no_lookups() {
607        let cache = default_cache();
608        assert!((cache.stats().hit_rate() - 0.0).abs() < f64::EPSILON);
609    }
610
611    #[test]
612    fn hit_rate_one_hundred_percent() {
613        let mut cache = default_cache();
614        cache.insert(1, 2, 0.5);
615        cache.get(1, 2);
616        assert!((cache.stats().hit_rate() - 1.0).abs() < f64::EPSILON);
617    }
618
619    #[test]
620    fn hit_rate_fifty_percent() {
621        let mut cache = default_cache();
622        cache.insert(1, 2, 0.5);
623        cache.get(1, 2); // hit
624        cache.get(3, 4); // miss
625        let rate = cache.stats().hit_rate();
626        assert!((rate - 0.5).abs() < f64::EPSILON);
627    }
628
629    // -----------------------------------------------------------------------
630    // 11. access_count increments on hit
631    // -----------------------------------------------------------------------
632
633    #[test]
634    fn access_count_increments_on_each_hit() {
635        let mut cache = default_cache();
636        cache.insert(1, 2, 0.9);
637        for _ in 0..5 {
638            cache.get(1, 2);
639        }
640        // Inspect the entry directly through the map.
641        let key = PairKey::new(1, 2);
642        let entry = cache.entries.get(&key).expect("entry must exist");
643        assert_eq!(entry.access_count, 5);
644    }
645
646    // -----------------------------------------------------------------------
647    // 12. insert upsert does not evict when key already exists
648    // -----------------------------------------------------------------------
649
650    #[test]
651    fn insert_upsert_no_spurious_eviction() {
652        let mut cache = small_cache(1);
653        cache.insert(1, 2, 0.3);
654        // Re-insert same key — should overwrite, not evict.
655        cache.insert(2, 1, 0.7);
656        assert_eq!(cache.stats().evictions, 0);
657        let result = cache.get(1, 2);
658        assert!(result.is_some());
659        assert!(
660            (result.expect("test: get after upsert should return updated similarity") - 0.7).abs()
661                < 1e-6
662        );
663    }
664
665    // -----------------------------------------------------------------------
666    // 13. Zero-norm vector handling
667    // -----------------------------------------------------------------------
668
669    #[test]
670    fn zero_norm_vector_returns_zero_similarity() {
671        let mut cache = default_cache();
672        let zero = vec![0.0_f32, 0.0, 0.0];
673        let v = vec![1.0_f32, 2.0, 3.0];
674        let sim = cache.compute_and_cache(1, &zero, 2, &v);
675        assert!((sim - 0.0).abs() < 1e-6);
676    }
677
678    // -----------------------------------------------------------------------
679    // 14. is_empty / len helpers
680    // -----------------------------------------------------------------------
681
682    #[test]
683    fn is_empty_and_len() {
684        let mut cache = default_cache();
685        assert!(cache.is_empty());
686        cache.insert(1, 2, 0.5);
687        assert!(!cache.is_empty());
688        assert_eq!(cache.len(), 1);
689    }
690
691    // -----------------------------------------------------------------------
692    // 15. PairCacheConfig default values
693    // -----------------------------------------------------------------------
694
695    #[test]
696    fn pair_cache_config_defaults() {
697        let cfg = PairCacheConfig::default();
698        assert_eq!(cfg.max_entries, 10_000);
699        assert_eq!(cfg.ttl_ticks, 1_000);
700    }
701
702    // -----------------------------------------------------------------------
703    // 16. SimilarityEntry::is_stale boundary conditions
704    // -----------------------------------------------------------------------
705
706    #[test]
707    fn is_stale_exactly_at_ttl_boundary() {
708        let entry = SimilarityEntry {
709            key: PairKey::new(0, 1),
710            similarity: 0.5,
711            computed_at_tick: 0,
712            access_count: 0,
713        };
714        // Age == ttl_ticks: NOT stale (> required, not >=).
715        assert!(!entry.is_stale(5, 5));
716        // Age > ttl_ticks: stale.
717        assert!(entry.is_stale(5, 6));
718    }
719
720    // -----------------------------------------------------------------------
721    // 17. cosine_similarity: mismatched lengths
722    // -----------------------------------------------------------------------
723
724    #[test]
725    fn cosine_similarity_mismatched_lengths() {
726        // Access private fn through compute_and_cache with mismatched vectors.
727        let mut cache = default_cache();
728        let va = vec![1.0_f32, 0.0];
729        let vb = vec![1.0_f32, 0.0, 0.0];
730        let sim = cache.compute_and_cache(1, &va, 2, &vb);
731        assert!((sim - 0.0).abs() < 1e-6, "mismatched lengths => 0.0");
732    }
733}