1use std::collections::HashMap;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub struct SimilarityKey {
16 pub a_id: u64,
18 pub b_id: u64,
20}
21
22impl SimilarityKey {
23 #[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#[derive(Debug, Clone)]
39pub struct SimilarityEntry {
40 pub score: f32,
42 pub computed_at: u64,
44 pub hit_count: u32,
46}
47
48impl SimilarityEntry {
49 #[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#[derive(Debug, Clone, Default)]
63pub struct CacheStats {
64 pub hits: u64,
66 pub misses: u64,
68 pub evictions: u64,
71 pub current_size: usize,
73}
74
75impl CacheStats {
76 #[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
90pub struct EmbeddingSimilarityCache {
100 cache: HashMap<SimilarityKey, SimilarityEntry>,
101 max_capacity: usize,
102 ttl_secs: u64,
103 stats: CacheStats,
104}
105
106impl EmbeddingSimilarityCache {
107 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 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 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 pub fn insert(&mut self, a: u64, b: u64, score: f32) {
163 let key = SimilarityKey::new(a, b);
164
165 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 entry.hit_count = 0;
172 self.stats.current_size = self.cache.len();
173 return;
174 }
175
176 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 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 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 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 #[inline]
253 pub fn stats(&self) -> &CacheStats {
254 &self.stats
255 }
256
257 pub fn clear(&mut self) {
259 self.cache.clear();
260 self.stats = CacheStats::default();
261 }
262}
263
264#[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#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[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 #[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 assert!(entry.is_stale(50, 200));
335 }
336
337 #[test]
338 fn test_similarity_entry_exactly_at_ttl_boundary() {
339 let entry = SimilarityEntry {
341 score: 0.5,
342 computed_at: 100,
343 hit_count: 0,
344 };
345 assert!(!entry.is_stale(100, 200)); assert!(entry.is_stale(99, 200)); }
348
349 #[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 #[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 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 let key = SimilarityKey::new(10, 20);
402 cache.cache.insert(
403 key,
404 SimilarityEntry {
405 score: 0.5,
406 computed_at: 1, 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 #[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 cache.get(1, 2);
434 cache.get(1, 2);
435 cache.get(3, 4);
436
437 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 #[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 #[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); 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); let score = cache.compute_and_cache(1, &a, 2, &b); assert!((score - 1.0).abs() < 1e-6);
508 assert_eq!(cache.stats().hits, 1);
509 assert_eq!(cache.stats().misses, 1);
510 }
511
512 #[test]
515 fn test_evict_stale_removes_expired_entries() {
516 let mut cache = EmbeddingSimilarityCache::new(100, 3600);
517 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 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 #[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}