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}