1use std::cmp::Ordering;
49
50#[derive(Debug, Clone)]
56pub struct CacheKey {
57 pub query_text: String,
59 pub embedding: Vec<f64>,
61}
62
63#[derive(Debug, Clone)]
65pub struct CacheEntry {
66 pub key: CacheKey,
68 pub result: String,
70 pub hit_count: u64,
72 pub inserted_at: u64,
74 pub last_accessed: u64,
76 pub ttl_ms: Option<u64>,
78}
79
80impl CacheEntry {
81 #[inline]
83 pub fn is_expired(&self, now: u64) -> bool {
84 match self.ttl_ms {
85 Some(ttl) => now.saturating_sub(self.inserted_at) > ttl,
86 None => false,
87 }
88 }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
93pub enum CacheEvictionPolicy {
94 #[default]
96 Lru,
97 Lfu,
99 TtlFirst,
101}
102
103#[derive(Debug, Clone)]
105pub struct CacheConfig {
106 pub max_entries: usize,
108 pub similarity_threshold: f64,
111 pub ttl_ms: Option<u64>,
114 pub eviction_policy: CacheEvictionPolicy,
116}
117
118impl Default for CacheConfig {
119 fn default() -> Self {
120 Self {
121 max_entries: 1024,
122 similarity_threshold: 0.92,
123 ttl_ms: None,
124 eviction_policy: CacheEvictionPolicy::Lru,
125 }
126 }
127}
128
129#[derive(Debug, Clone, PartialEq)]
131pub enum CacheLookupResult {
132 Hit {
134 entry_id: usize,
137 similarity: f64,
139 result: String,
141 },
142 Miss,
144}
145
146#[derive(Debug, Clone)]
148pub struct ScCacheStats {
149 pub total_entries: usize,
151 pub total_hits: u64,
153 pub total_misses: u64,
155 pub hit_rate: f64,
157 pub total_insertions: u64,
159 pub avg_hit_count: f64,
161}
162
163#[derive(Debug)]
171pub struct SemanticCacheLayer {
172 pub config: CacheConfig,
174 pub entries: Vec<CacheEntry>,
176 pub next_id: usize,
178 pub total_hits: u64,
180 pub total_misses: u64,
182 pub total_insertions: u64,
184}
185
186impl SemanticCacheLayer {
187 pub fn new(config: CacheConfig) -> Self {
193 let capacity = config.max_entries.min(4096);
194 Self {
195 config,
196 entries: Vec::with_capacity(capacity),
197 next_id: 0,
198 total_hits: 0,
199 total_misses: 0,
200 total_insertions: 0,
201 }
202 }
203
204 pub fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
213 if a.len() != b.len() || a.is_empty() {
214 return 0.0;
215 }
216
217 let mut dot = 0.0_f64;
218 let mut norm_a = 0.0_f64;
219 let mut norm_b = 0.0_f64;
220
221 for (x, y) in a.iter().zip(b.iter()) {
222 dot += x * y;
223 norm_a += x * x;
224 norm_b += y * y;
225 }
226
227 let denom = norm_a.sqrt() * norm_b.sqrt();
228 if denom < f64::EPSILON {
229 return 0.0;
230 }
231
232 (dot / denom).clamp(-1.0, 1.0)
233 }
234
235 pub fn lookup(&mut self, query_embedding: &[f64], now: u64) -> CacheLookupResult {
255 let threshold = self.config.similarity_threshold;
256
257 let mut best_idx: Option<usize> = None;
258 let mut best_sim: f64 = f64::NEG_INFINITY;
259
260 for (idx, entry) in self.entries.iter().enumerate() {
261 if entry.is_expired(now) {
262 continue;
263 }
264 let sim = Self::cosine_similarity(query_embedding, &entry.key.embedding);
265 if sim > best_sim {
266 best_sim = sim;
267 best_idx = Some(idx);
268 }
269 }
270
271 if let Some(idx) = best_idx {
272 if best_sim >= threshold {
273 self.entries[idx].hit_count += 1;
275 self.entries[idx].last_accessed = now;
276 let result = self.entries[idx].result.clone();
277 let entry_id = idx;
278 self.total_hits += 1;
279 return CacheLookupResult::Hit {
280 entry_id,
281 similarity: best_sim,
282 result,
283 };
284 }
285 }
286
287 self.total_misses += 1;
288 CacheLookupResult::Miss
289 }
290
291 pub fn insert(&mut self, key: CacheKey, result: String, now: u64) {
307 if self.entries.len() >= self.config.max_entries && self.config.max_entries > 0 {
308 self.evict_one(now);
309 }
310
311 let entry = CacheEntry {
312 key,
313 result,
314 hit_count: 0,
315 inserted_at: now,
316 last_accessed: now,
317 ttl_ms: self.config.ttl_ms,
318 };
319
320 self.entries.push(entry);
321 self.total_insertions += 1;
322 self.next_id += 1;
323 }
324
325 pub fn evict_one(&mut self, now: u64) {
339 if self.entries.is_empty() {
340 return;
341 }
342
343 let victim_idx = match self.config.eviction_policy {
344 CacheEvictionPolicy::Lru => self.find_lru_victim(),
345 CacheEvictionPolicy::Lfu => self.find_lfu_victim(),
346 CacheEvictionPolicy::TtlFirst => self.find_ttl_victim(now),
347 };
348
349 if let Some(idx) = victim_idx {
350 self.entries.swap_remove(idx);
351 }
352 }
353
354 fn find_lru_victim(&self) -> Option<usize> {
356 self.entries
357 .iter()
358 .enumerate()
359 .min_by(|(_, a), (_, b)| {
360 a.last_accessed
361 .partial_cmp(&b.last_accessed)
362 .unwrap_or(Ordering::Equal)
363 })
364 .map(|(idx, _)| idx)
365 }
366
367 fn find_lfu_victim(&self) -> Option<usize> {
369 self.entries
370 .iter()
371 .enumerate()
372 .min_by(|(_, a), (_, b)| {
373 a.hit_count
374 .partial_cmp(&b.hit_count)
375 .unwrap_or(Ordering::Equal)
376 })
377 .map(|(idx, _)| idx)
378 }
379
380 fn find_ttl_victim(&self, _now: u64) -> Option<usize> {
382 let ttl_victim = self
385 .entries
386 .iter()
387 .enumerate()
388 .filter_map(|(idx, e)| e.ttl_ms.map(|ttl| (idx, e.inserted_at.saturating_add(ttl))))
389 .min_by_key(|&(_, expiry)| expiry)
390 .map(|(idx, _)| idx);
391
392 ttl_victim.or_else(|| self.find_lru_victim())
393 }
394
395 pub fn evict_expired(&mut self, now: u64) -> usize {
399 let before = self.entries.len();
400 self.entries.retain(|e| !e.is_expired(now));
401 before - self.entries.len()
402 }
403
404 pub fn invalidate_by_text(&mut self, text: &str) -> usize {
412 let before = self.entries.len();
413 self.entries.retain(|e| e.key.query_text != text);
414 before - self.entries.len()
415 }
416
417 pub fn clear(&mut self) {
419 self.entries.clear();
420 }
421
422 pub fn entry_count(&self) -> usize {
428 self.entries.len()
429 }
430
431 pub fn stats(&self) -> ScCacheStats {
433 let total_lookups = self.total_hits + self.total_misses;
434 let hit_rate = if total_lookups == 0 {
435 0.0
436 } else {
437 self.total_hits as f64 / total_lookups as f64
438 };
439
440 let avg_hit_count = if self.entries.is_empty() {
441 0.0
442 } else {
443 let sum: u64 = self.entries.iter().map(|e| e.hit_count).sum();
444 sum as f64 / self.entries.len() as f64
445 };
446
447 ScCacheStats {
448 total_entries: self.entries.len(),
449 total_hits: self.total_hits,
450 total_misses: self.total_misses,
451 hit_rate,
452 total_insertions: self.total_insertions,
453 avg_hit_count,
454 }
455 }
456}
457
458#[cfg(test)]
463mod tests {
464 use super::{
465 CacheConfig, CacheEntry, CacheEvictionPolicy, CacheKey, CacheLookupResult,
466 SemanticCacheLayer,
467 };
468
469 fn make_config(max: usize, threshold: f64, policy: CacheEvictionPolicy) -> CacheConfig {
474 CacheConfig {
475 max_entries: max,
476 similarity_threshold: threshold,
477 ttl_ms: None,
478 eviction_policy: policy,
479 }
480 }
481
482 fn make_key(text: &str, embedding: Vec<f64>) -> CacheKey {
483 CacheKey {
484 query_text: text.to_string(),
485 embedding,
486 }
487 }
488
489 fn unit_vec(dim: usize, hot: usize) -> Vec<f64> {
490 let mut v = vec![0.0_f64; dim];
491 v[hot] = 1.0;
492 v
493 }
494
495 fn normalized(v: Vec<f64>) -> Vec<f64> {
496 let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
497 if norm < f64::EPSILON {
498 return v;
499 }
500 v.into_iter().map(|x| x / norm).collect()
501 }
502
503 #[test]
508 fn test_cosine_same_vector() {
509 let v = vec![1.0, 2.0, 3.0];
510 let sim = SemanticCacheLayer::cosine_similarity(&v, &v);
511 assert!((sim - 1.0).abs() < 1e-9, "same vector should give sim=1.0");
512 }
513
514 #[test]
515 fn test_cosine_orthogonal() {
516 let a = vec![1.0, 0.0, 0.0];
517 let b = vec![0.0, 1.0, 0.0];
518 let sim = SemanticCacheLayer::cosine_similarity(&a, &b);
519 assert!((sim - 0.0).abs() < 1e-9);
520 }
521
522 #[test]
523 fn test_cosine_opposite() {
524 let a = vec![1.0, 0.0];
525 let b = vec![-1.0, 0.0];
526 let sim = SemanticCacheLayer::cosine_similarity(&a, &b);
527 assert!((sim + 1.0).abs() < 1e-9);
528 }
529
530 #[test]
531 fn test_cosine_different_lengths_returns_zero() {
532 let a = vec![1.0, 0.0];
533 let b = vec![1.0, 0.0, 0.0];
534 let sim = SemanticCacheLayer::cosine_similarity(&a, &b);
535 assert_eq!(sim, 0.0);
536 }
537
538 #[test]
539 fn test_cosine_zero_vector_a() {
540 let a = vec![0.0, 0.0];
541 let b = vec![1.0, 0.0];
542 let sim = SemanticCacheLayer::cosine_similarity(&a, &b);
543 assert_eq!(sim, 0.0);
544 }
545
546 #[test]
547 fn test_cosine_zero_vector_b() {
548 let a = vec![1.0, 0.0];
549 let b = vec![0.0, 0.0];
550 let sim = SemanticCacheLayer::cosine_similarity(&a, &b);
551 assert_eq!(sim, 0.0);
552 }
553
554 #[test]
555 fn test_cosine_both_zero() {
556 let a = vec![0.0; 4];
557 let b = vec![0.0; 4];
558 let sim = SemanticCacheLayer::cosine_similarity(&a, &b);
559 assert_eq!(sim, 0.0);
560 }
561
562 #[test]
563 fn test_cosine_empty_slices_returns_zero() {
564 let sim = SemanticCacheLayer::cosine_similarity(&[], &[]);
565 assert_eq!(sim, 0.0);
566 }
567
568 #[test]
569 fn test_cosine_known_value() {
570 let a = vec![1.0_f64, 1.0];
572 let b = vec![1.0_f64, 0.0];
573 let sim = SemanticCacheLayer::cosine_similarity(&a, &b);
574 let expected = 1.0_f64 / 2.0_f64.sqrt();
575 assert!((sim - expected).abs() < 1e-9);
576 }
577
578 #[test]
579 fn test_cosine_clamp_above_one() {
580 let n = 1000;
583 let v: Vec<f64> = (0..n).map(|i| (i as f64).sin()).collect();
584 let sim = SemanticCacheLayer::cosine_similarity(&v, &v);
585 assert!(sim <= 1.0 + 1e-12);
586 }
587
588 #[test]
593 fn test_lookup_hit_identical_embedding() {
594 let mut cache = SemanticCacheLayer::new(make_config(16, 0.90, CacheEvictionPolicy::Lru));
595 let emb = vec![1.0, 0.0, 0.0];
596 cache.insert(make_key("q1", emb.clone()), "result1".to_string(), 0);
597
598 match cache.lookup(&emb, 0) {
599 CacheLookupResult::Hit {
600 result, similarity, ..
601 } => {
602 assert_eq!(result, "result1");
603 assert!((similarity - 1.0).abs() < 1e-9);
604 }
605 CacheLookupResult::Miss => panic!("Expected a cache hit"),
606 }
607 }
608
609 #[test]
610 fn test_lookup_miss_empty_cache() {
611 let mut cache = SemanticCacheLayer::new(CacheConfig::default());
612 let result = cache.lookup(&[1.0, 0.0], 0);
613 assert_eq!(result, CacheLookupResult::Miss);
614 }
615
616 #[test]
617 fn test_lookup_miss_below_threshold() {
618 let mut cache = SemanticCacheLayer::new(make_config(16, 0.99, CacheEvictionPolicy::Lru));
619 let emb = normalized(vec![1.0, 1.0, 0.0]);
620 cache.insert(make_key("q", emb.clone()), "r".to_string(), 0);
621
622 let query = vec![0.0, 0.0, 1.0];
624 assert_eq!(cache.lookup(&query, 0), CacheLookupResult::Miss);
625 }
626
627 #[test]
628 fn test_lookup_returns_best_match() {
629 let mut cache = SemanticCacheLayer::new(make_config(16, 0.5, CacheEvictionPolicy::Lru));
630 cache.insert(make_key("a", unit_vec(3, 0)), "result_a".to_string(), 0);
631 cache.insert(make_key("b", unit_vec(3, 1)), "result_b".to_string(), 0);
632
633 match cache.lookup(&unit_vec(3, 1), 0) {
635 CacheLookupResult::Hit { result, .. } => assert_eq!(result, "result_b"),
636 CacheLookupResult::Miss => panic!("Expected hit"),
637 }
638 }
639
640 #[test]
641 fn test_lookup_updates_hit_count() {
642 let mut cache = SemanticCacheLayer::new(make_config(16, 0.9, CacheEvictionPolicy::Lru));
643 let emb = unit_vec(3, 0);
644 cache.insert(make_key("q", emb.clone()), "r".to_string(), 0);
645
646 cache.lookup(&emb, 1);
647 cache.lookup(&emb, 2);
648 assert_eq!(cache.entries[0].hit_count, 2);
649 }
650
651 #[test]
652 fn test_lookup_updates_last_accessed() {
653 let mut cache = SemanticCacheLayer::new(make_config(16, 0.9, CacheEvictionPolicy::Lru));
654 let emb = unit_vec(3, 0);
655 cache.insert(make_key("q", emb.clone()), "r".to_string(), 100);
656
657 cache.lookup(&emb, 200);
658 assert_eq!(cache.entries[0].last_accessed, 200);
659 }
660
661 #[test]
666 fn test_stats_initial() {
667 let cache = SemanticCacheLayer::new(CacheConfig::default());
668 let s = cache.stats();
669 assert_eq!(s.total_entries, 0);
670 assert_eq!(s.total_hits, 0);
671 assert_eq!(s.total_misses, 0);
672 assert_eq!(s.hit_rate, 0.0);
673 assert_eq!(s.total_insertions, 0);
674 assert_eq!(s.avg_hit_count, 0.0);
675 }
676
677 #[test]
678 fn test_stats_after_inserts_and_lookups() {
679 let mut cache = SemanticCacheLayer::new(make_config(16, 0.9, CacheEvictionPolicy::Lru));
680 let emb = unit_vec(3, 0);
681 cache.insert(make_key("q", emb.clone()), "r".to_string(), 0);
682
683 cache.lookup(&emb, 1);
685 cache.lookup(&emb, 2);
686 cache.lookup(&unit_vec(3, 2), 3); let s = cache.stats();
689 assert_eq!(s.total_hits, 2);
690 assert_eq!(s.total_misses, 1);
691 assert!((s.hit_rate - 2.0 / 3.0).abs() < 1e-9);
692 assert_eq!(s.total_insertions, 1);
693 assert_eq!(s.avg_hit_count, 2.0);
694 }
695
696 #[test]
697 fn test_stats_hit_rate_zero_lookups() {
698 let mut cache = SemanticCacheLayer::new(CacheConfig::default());
699 cache.insert(make_key("q", unit_vec(3, 0)), "r".to_string(), 0);
700 assert_eq!(cache.stats().hit_rate, 0.0);
701 }
702
703 #[test]
708 fn test_lru_eviction_removes_oldest_accessed() {
709 let mut cache = SemanticCacheLayer::new(make_config(2, 0.9, CacheEvictionPolicy::Lru));
710 cache.insert(make_key("a", unit_vec(3, 0)), "ra".to_string(), 0);
711 cache.insert(make_key("b", unit_vec(3, 1)), "rb".to_string(), 5);
712
713 cache.lookup(&unit_vec(3, 1), 10);
715
716 cache.insert(make_key("c", unit_vec(3, 2)), "rc".to_string(), 15);
718 assert_eq!(cache.entry_count(), 2);
719
720 let texts: Vec<&str> = cache
721 .entries
722 .iter()
723 .map(|e| e.key.query_text.as_str())
724 .collect();
725 assert!(!texts.contains(&"a"), "LRU should evict 'a'");
726 }
727
728 #[test]
733 fn test_lfu_eviction_removes_lowest_hit_count() {
734 let mut cache = SemanticCacheLayer::new(make_config(2, 0.9, CacheEvictionPolicy::Lfu));
735 cache.insert(make_key("a", unit_vec(3, 0)), "ra".to_string(), 0);
736 cache.insert(make_key("b", unit_vec(3, 1)), "rb".to_string(), 0);
737
738 cache.lookup(&unit_vec(3, 1), 1);
740
741 cache.insert(make_key("c", unit_vec(3, 2)), "rc".to_string(), 2);
743 assert_eq!(cache.entry_count(), 2);
744
745 let texts: Vec<&str> = cache
746 .entries
747 .iter()
748 .map(|e| e.key.query_text.as_str())
749 .collect();
750 assert!(!texts.contains(&"a"), "LFU should evict 'a'");
751 }
752
753 #[test]
758 fn test_ttlfirst_evicts_soonest_expiring() {
759 let mut cache = SemanticCacheLayer::new(make_config(2, 0.9, CacheEvictionPolicy::TtlFirst));
760
761 cache.entries.push(CacheEntry {
763 key: make_key("a", unit_vec(3, 0)),
764 result: "ra".to_string(),
765 hit_count: 0,
766 inserted_at: 0,
767 last_accessed: 0,
768 ttl_ms: Some(100), });
770 cache.entries.push(CacheEntry {
771 key: make_key("b", unit_vec(3, 1)),
772 result: "rb".to_string(),
773 hit_count: 0,
774 inserted_at: 0,
775 last_accessed: 0,
776 ttl_ms: Some(500), });
778
779 cache.insert(make_key("c", unit_vec(3, 2)), "rc".to_string(), 10);
781 assert_eq!(cache.entry_count(), 2);
782
783 let texts: Vec<&str> = cache
784 .entries
785 .iter()
786 .map(|e| e.key.query_text.as_str())
787 .collect();
788 assert!(!texts.contains(&"a"), "TTLFirst should evict 'a'");
789 }
790
791 #[test]
792 fn test_ttlfirst_fallback_to_lru_when_no_ttls() {
793 let mut cache = SemanticCacheLayer::new(make_config(2, 0.9, CacheEvictionPolicy::TtlFirst));
794 cache.insert(make_key("a", unit_vec(3, 0)), "ra".to_string(), 0);
795 cache.insert(make_key("b", unit_vec(3, 1)), "rb".to_string(), 10);
796
797 cache.lookup(&unit_vec(3, 1), 20);
799
800 cache.insert(make_key("c", unit_vec(3, 2)), "rc".to_string(), 30);
802 let texts: Vec<&str> = cache
803 .entries
804 .iter()
805 .map(|e| e.key.query_text.as_str())
806 .collect();
807 assert!(!texts.contains(&"a"));
808 }
809
810 #[test]
815 fn test_evict_expired_removes_expired_entries() {
816 let mut cache = SemanticCacheLayer::new(CacheConfig {
817 ttl_ms: Some(100),
818 ..CacheConfig::default()
819 });
820 cache.insert(make_key("a", unit_vec(3, 0)), "ra".to_string(), 0);
821 cache.insert(make_key("b", unit_vec(3, 1)), "rb".to_string(), 50);
822
823 let removed = cache.evict_expired(200);
825 assert_eq!(removed, 2);
826 assert_eq!(cache.entry_count(), 0);
827 }
828
829 #[test]
830 fn test_evict_expired_keeps_non_expired() {
831 let mut cache = SemanticCacheLayer::new(CacheConfig {
832 ttl_ms: Some(1000),
833 ..CacheConfig::default()
834 });
835 cache.insert(make_key("a", unit_vec(3, 0)), "ra".to_string(), 0);
836 cache.insert(make_key("b", unit_vec(3, 1)), "rb".to_string(), 0);
837
838 let removed = cache.evict_expired(500); assert_eq!(removed, 0);
840 assert_eq!(cache.entry_count(), 2);
841 }
842
843 #[test]
844 fn test_evict_expired_no_ttl_never_expires() {
845 let mut cache = SemanticCacheLayer::new(CacheConfig {
846 ttl_ms: None,
847 ..CacheConfig::default()
848 });
849 cache.insert(make_key("a", unit_vec(3, 0)), "ra".to_string(), 0);
850 let removed = cache.evict_expired(u64::MAX);
851 assert_eq!(removed, 0);
852 assert_eq!(cache.entry_count(), 1);
853 }
854
855 #[test]
860 fn test_lookup_skips_expired_entry() {
861 let mut cache = SemanticCacheLayer::new(CacheConfig {
862 max_entries: 16,
863 similarity_threshold: 0.9,
864 ttl_ms: Some(100),
865 eviction_policy: CacheEvictionPolicy::Lru,
866 });
867 let emb = unit_vec(3, 0);
868 cache.insert(make_key("q", emb.clone()), "r".to_string(), 0);
869
870 let result = cache.lookup(&emb, 200);
872 assert_eq!(result, CacheLookupResult::Miss);
873 }
874
875 #[test]
876 fn test_lookup_hits_non_expired_when_mixed() {
877 let mut cache = SemanticCacheLayer::new(CacheConfig {
878 max_entries: 16,
879 similarity_threshold: 0.9,
880 ttl_ms: None,
881 eviction_policy: CacheEvictionPolicy::Lru,
882 });
883
884 cache.entries.push(CacheEntry {
886 key: make_key("expired", unit_vec(4, 0)),
887 result: "old".to_string(),
888 hit_count: 0,
889 inserted_at: 0,
890 last_accessed: 0,
891 ttl_ms: Some(100),
892 });
893
894 cache.entries.push(CacheEntry {
896 key: make_key("valid", unit_vec(4, 0)),
897 result: "new".to_string(),
898 hit_count: 0,
899 inserted_at: 50,
900 last_accessed: 50,
901 ttl_ms: None,
902 });
903
904 match cache.lookup(&unit_vec(4, 0), 200) {
906 CacheLookupResult::Hit { result, .. } => assert_eq!(result, "new"),
907 CacheLookupResult::Miss => panic!("Expected hit on the non-expired entry"),
908 }
909 }
910
911 #[test]
916 fn test_invalidate_by_text_removes_matching() {
917 let mut cache = SemanticCacheLayer::new(CacheConfig::default());
918 cache.insert(make_key("hello", unit_vec(3, 0)), "r1".to_string(), 0);
919 cache.insert(make_key("hello", unit_vec(3, 1)), "r2".to_string(), 0);
920 cache.insert(make_key("world", unit_vec(3, 2)), "r3".to_string(), 0);
921
922 let removed = cache.invalidate_by_text("hello");
923 assert_eq!(removed, 2);
924 assert_eq!(cache.entry_count(), 1);
925 assert_eq!(cache.entries[0].key.query_text, "world");
926 }
927
928 #[test]
929 fn test_invalidate_by_text_no_match_returns_zero() {
930 let mut cache = SemanticCacheLayer::new(CacheConfig::default());
931 cache.insert(make_key("hello", unit_vec(3, 0)), "r".to_string(), 0);
932 let removed = cache.invalidate_by_text("nonexistent");
933 assert_eq!(removed, 0);
934 assert_eq!(cache.entry_count(), 1);
935 }
936
937 #[test]
942 fn test_clear_removes_all_entries() {
943 let mut cache = SemanticCacheLayer::new(CacheConfig::default());
944 for i in 0..10 {
945 cache.insert(
946 make_key(&i.to_string(), unit_vec(3, i % 3)),
947 "r".to_string(),
948 0,
949 );
950 }
951 assert_eq!(cache.entry_count(), 10);
952 cache.clear();
953 assert_eq!(cache.entry_count(), 0);
954 }
955
956 #[test]
957 fn test_clear_preserves_statistics() {
958 let mut cache = SemanticCacheLayer::new(make_config(16, 0.9, CacheEvictionPolicy::Lru));
959 let emb = unit_vec(3, 0);
960 cache.insert(make_key("q", emb.clone()), "r".to_string(), 0);
961 cache.lookup(&emb, 1);
962 cache.clear();
963
964 let s = cache.stats();
965 assert_eq!(s.total_hits, 1);
966 assert_eq!(s.total_insertions, 1);
967 assert_eq!(s.total_entries, 0);
968 }
969
970 #[test]
975 fn test_entry_count_tracks_insertions() {
976 let mut cache = SemanticCacheLayer::new(make_config(100, 0.9, CacheEvictionPolicy::Lru));
977 assert_eq!(cache.entry_count(), 0);
978 for i in 0..5 {
979 cache.insert(
980 make_key(&i.to_string(), unit_vec(4, i % 4)),
981 "r".to_string(),
982 i as u64,
983 );
984 }
985 assert_eq!(cache.entry_count(), 5);
986 }
987
988 #[test]
993 fn test_max_entries_respected() {
994 let mut cache = SemanticCacheLayer::new(make_config(3, 0.9, CacheEvictionPolicy::Lru));
995 for i in 0..6_usize {
996 cache.insert(
997 make_key(&i.to_string(), unit_vec(4, i % 4)),
998 "r".to_string(),
999 i as u64,
1000 );
1001 }
1002 assert!(cache.entry_count() <= 3, "Cache exceeded max_entries");
1003 }
1004
1005 #[test]
1006 fn test_max_entries_zero_does_not_insert() {
1007 let mut cache = SemanticCacheLayer::new(make_config(0, 0.9, CacheEvictionPolicy::Lru));
1008 cache.insert(make_key("q", unit_vec(3, 0)), "r".to_string(), 0);
1009 let _ = cache.entry_count();
1012 }
1013
1014 #[test]
1019 fn test_eviction_policy_default_is_lru() {
1020 assert_eq!(CacheEvictionPolicy::default(), CacheEvictionPolicy::Lru);
1021 }
1022
1023 #[test]
1028 fn test_cache_config_defaults() {
1029 let c = CacheConfig::default();
1030 assert_eq!(c.max_entries, 1024);
1031 assert!((c.similarity_threshold - 0.92).abs() < 1e-9);
1032 assert!(c.ttl_ms.is_none());
1033 assert_eq!(c.eviction_policy, CacheEvictionPolicy::Lru);
1034 }
1035
1036 #[test]
1041 fn test_avg_hit_count_across_entries() {
1042 let mut cache = SemanticCacheLayer::new(make_config(16, 0.5, CacheEvictionPolicy::Lru));
1043 cache.insert(make_key("a", unit_vec(3, 0)), "ra".to_string(), 0);
1044 cache.insert(make_key("b", unit_vec(3, 1)), "rb".to_string(), 0);
1045
1046 cache.lookup(&unit_vec(3, 0), 1);
1048 cache.lookup(&unit_vec(3, 0), 2);
1049 cache.lookup(&unit_vec(3, 1), 3);
1051
1052 let s = cache.stats();
1053 assert!((s.avg_hit_count - 1.5).abs() < 1e-9);
1055 }
1056
1057 #[test]
1062 fn test_high_dimensional_embedding_hit() {
1063 let dim = 768;
1064 let mut cache = SemanticCacheLayer::new(make_config(16, 0.95, CacheEvictionPolicy::Lru));
1065
1066 let emb: Vec<f64> = (0..dim).map(|i| (i as f64 / dim as f64).sin()).collect();
1067 let emb_n = normalized(emb.clone());
1068
1069 cache.insert(make_key("high-dim", emb_n.clone()), "result".to_string(), 0);
1070
1071 let query: Vec<f64> = emb_n
1073 .iter()
1074 .enumerate()
1075 .map(|(i, x)| x + i as f64 * 1e-5)
1076 .collect();
1077 let query_n = normalized(query);
1078
1079 let result = cache.lookup(&query_n, 0);
1080 assert!(
1081 matches!(result, CacheLookupResult::Hit { .. }),
1082 "Expected hit for slightly perturbed high-dim vector"
1083 );
1084 }
1085
1086 #[test]
1087 fn test_many_insertions_and_lookups() {
1088 let n = 50_usize;
1089 let mut cache = SemanticCacheLayer::new(make_config(n, 0.99, CacheEvictionPolicy::Lfu));
1090
1091 for i in 0..n {
1092 let emb = unit_vec(n, i);
1093 cache.insert(make_key(&format!("q{i}"), emb), format!("r{i}"), i as u64);
1094 }
1095
1096 let mut hits = 0_u64;
1098 for i in 0..n {
1099 let emb = unit_vec(n, i);
1100 if matches!(
1101 cache.lookup(&emb, n as u64 + i as u64),
1102 CacheLookupResult::Hit { .. }
1103 ) {
1104 hits += 1;
1105 }
1106 }
1107
1108 assert_eq!(hits, n as u64);
1109 }
1110
1111 #[test]
1112 fn test_total_insertions_counter() {
1113 let mut cache = SemanticCacheLayer::new(make_config(3, 0.9, CacheEvictionPolicy::Lru));
1114 for i in 0..5_usize {
1115 cache.insert(
1116 make_key(&i.to_string(), unit_vec(3, i % 3)),
1117 "r".to_string(),
1118 i as u64,
1119 );
1120 }
1121 assert_eq!(cache.total_insertions, 5);
1122 }
1123
1124 #[test]
1125 fn test_evict_one_empty_cache_noop() {
1126 let mut cache = SemanticCacheLayer::new(CacheConfig::default());
1127 cache.evict_one(0); assert_eq!(cache.entry_count(), 0);
1129 }
1130
1131 #[test]
1132 fn test_insert_with_ttl_then_lookup() {
1133 let mut cache = SemanticCacheLayer::new(CacheConfig {
1134 max_entries: 16,
1135 similarity_threshold: 0.9,
1136 ttl_ms: Some(500),
1137 eviction_policy: CacheEvictionPolicy::Lru,
1138 });
1139 let emb = unit_vec(3, 0);
1140 cache.insert(make_key("q", emb.clone()), "r".to_string(), 0);
1141
1142 assert!(
1144 matches!(cache.lookup(&emb, 400), CacheLookupResult::Hit { .. }),
1145 "Should hit within TTL"
1146 );
1147 assert_eq!(
1149 cache.lookup(&emb, 600),
1150 CacheLookupResult::Miss,
1151 "Should miss after TTL"
1152 );
1153 }
1154}