Skip to main content

ipfrs_semantic/
semantic_cache.rs

1//! # Semantic Cache Layer
2//!
3//! A vector-similarity-based cache that returns cached results for queries whose
4//! embeddings are semantically close to previously seen queries, avoiding redundant
5//! computation.
6//!
7//! ## Overview
8//!
9//! The [`SemanticCacheLayer`] maintains a collection of [`CacheEntry`] items, each
10//! associated with a query text and its embedding vector. On each lookup the cache
11//! computes the cosine similarity between the incoming query embedding and every
12//! non-expired stored embedding. If the best match exceeds `config.similarity_threshold`
13//! the cached result is returned as a [`CacheLookupResult::Hit`]; otherwise
14//! [`CacheLookupResult::Miss`] is returned and the caller should compute the result
15//! and insert it.
16//!
17//! ## Example
18//!
19//! ```rust
20//! use ipfrs_semantic::semantic_cache::{
21//!     CacheConfig, CacheEvictionPolicy, CacheKey, CacheLookupResult, SemanticCacheLayer,
22//! };
23//!
24//! let config = CacheConfig {
25//!     max_entries: 128,
26//!     similarity_threshold: 0.92,
27//!     ttl_ms: None,
28//!     eviction_policy: CacheEvictionPolicy::Lru,
29//! };
30//!
31//! let mut cache = SemanticCacheLayer::new(config);
32//! let now: u64 = 0;
33//!
34//! let key = CacheKey {
35//!     query_text: "hello world".to_string(),
36//!     embedding: vec![1.0, 0.0, 0.0],
37//! };
38//! cache.insert(key, "result text".to_string(), now);
39//!
40//! match cache.lookup(&[0.999, 0.0447, 0.0], now) {
41//!     CacheLookupResult::Hit { similarity, result, .. } => {
42//!         println!("Cache hit (similarity={:.3}): {}", similarity, result);
43//!     }
44//!     CacheLookupResult::Miss => println!("Cache miss"),
45//! }
46//! ```
47
48use std::cmp::Ordering;
49
50// ---------------------------------------------------------------------------
51// Public types
52// ---------------------------------------------------------------------------
53
54/// The key used to store a query in the cache.
55#[derive(Debug, Clone)]
56pub struct CacheKey {
57    /// The original query text.
58    pub query_text: String,
59    /// The embedding vector for the query.
60    pub embedding: Vec<f64>,
61}
62
63/// A single entry stored in the [`SemanticCacheLayer`].
64#[derive(Debug, Clone)]
65pub struct CacheEntry {
66    /// The cache key (query text + embedding).
67    pub key: CacheKey,
68    /// The cached result string.
69    pub result: String,
70    /// How many times this entry has been returned as a cache hit.
71    pub hit_count: u64,
72    /// Timestamp (milliseconds, caller-supplied) when the entry was inserted.
73    pub inserted_at: u64,
74    /// Timestamp (milliseconds, caller-supplied) when the entry was last accessed.
75    pub last_accessed: u64,
76    /// Optional time-to-live in milliseconds; `None` means the entry never expires.
77    pub ttl_ms: Option<u64>,
78}
79
80impl CacheEntry {
81    /// Returns `true` if this entry has expired at the given `now` timestamp.
82    #[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/// Policy used to select which entry to evict when the cache is full.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
93pub enum CacheEvictionPolicy {
94    /// Evict the entry that was least recently accessed.
95    #[default]
96    Lru,
97    /// Evict the entry with the lowest hit count.
98    Lfu,
99    /// Evict the entry with the soonest expiry; fall back to LRU when no TTLs.
100    TtlFirst,
101}
102
103/// Configuration for the [`SemanticCacheLayer`].
104#[derive(Debug, Clone)]
105pub struct CacheConfig {
106    /// Maximum number of entries to hold before eviction kicks in.
107    pub max_entries: usize,
108    /// Cosine similarity threshold in [0, 1].  A lookup whose best match is
109    /// strictly below this value is treated as a miss.
110    pub similarity_threshold: f64,
111    /// Default time-to-live (milliseconds) applied to each inserted entry.
112    /// `None` means entries never expire based on time.
113    pub ttl_ms: Option<u64>,
114    /// Eviction strategy to use when the cache reaches `max_entries`.
115    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/// The result returned by [`SemanticCacheLayer::lookup`].
130#[derive(Debug, Clone, PartialEq)]
131pub enum CacheLookupResult {
132    /// A sufficiently similar query was found in the cache.
133    Hit {
134        /// Index of the matching entry in the internal entry vector at the time
135        /// of the lookup (informational; may become stale after mutations).
136        entry_id: usize,
137        /// Cosine similarity between the query and the stored embedding.
138        similarity: f64,
139        /// The cached result.
140        result: String,
141    },
142    /// No sufficiently similar query was found.
143    Miss,
144}
145
146/// Aggregate statistics returned by [`SemanticCacheLayer::stats`].
147#[derive(Debug, Clone)]
148pub struct ScCacheStats {
149    /// Current number of entries in the cache.
150    pub total_entries: usize,
151    /// Cumulative cache hits since the cache was created.
152    pub total_hits: u64,
153    /// Cumulative cache misses since the cache was created.
154    pub total_misses: u64,
155    /// Hit rate (hits / (hits + misses)); 0.0 when no lookups have been made.
156    pub hit_rate: f64,
157    /// Total entries ever inserted (not counting evictions/expiry removals).
158    pub total_insertions: u64,
159    /// Average `hit_count` across all current entries; 0.0 if empty.
160    pub avg_hit_count: f64,
161}
162
163// ---------------------------------------------------------------------------
164// SemanticCacheLayer
165// ---------------------------------------------------------------------------
166
167/// A vector-similarity-based cache.
168///
169/// See the [module-level documentation](self) for a usage example.
170#[derive(Debug)]
171pub struct SemanticCacheLayer {
172    /// Runtime configuration.
173    pub config: CacheConfig,
174    /// The stored entries.
175    pub entries: Vec<CacheEntry>,
176    /// Monotonically increasing counter used to assign stable entry IDs.
177    pub next_id: usize,
178    /// Total cache hits since creation.
179    pub total_hits: u64,
180    /// Total cache misses since creation.
181    pub total_misses: u64,
182    /// Total entries ever inserted.
183    pub total_insertions: u64,
184}
185
186impl SemanticCacheLayer {
187    // ------------------------------------------------------------------
188    // Construction
189    // ------------------------------------------------------------------
190
191    /// Create a new cache with the given configuration.
192    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    // ------------------------------------------------------------------
205    // Core similarity primitive
206    // ------------------------------------------------------------------
207
208    /// Compute the cosine similarity between two f64 slices.
209    ///
210    /// Returns `0.0` if the vectors have different lengths, if either vector is
211    /// all-zeros, or if the denominator underflows to zero.
212    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    // ------------------------------------------------------------------
236    // Lookup
237    // ------------------------------------------------------------------
238
239    /// Look up the cache for a semantically similar previous query.
240    ///
241    /// Scans all non-expired entries and finds the one with the highest cosine
242    /// similarity to `query_embedding`. If that similarity is ≥
243    /// `config.similarity_threshold` the entry's `hit_count` and `last_accessed`
244    /// fields are updated and a [`CacheLookupResult::Hit`] is returned; otherwise
245    /// [`CacheLookupResult::Miss`] is returned.
246    ///
247    /// The global `total_hits` / `total_misses` counters are always updated.
248    ///
249    /// # Arguments
250    ///
251    /// * `query_embedding` — embedding vector for the incoming query.
252    /// * `now` — current timestamp in milliseconds (caller-supplied for
253    ///   testability).
254    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                // Update hit metadata.
274                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    // ------------------------------------------------------------------
292    // Insertion
293    // ------------------------------------------------------------------
294
295    /// Insert a new entry into the cache.
296    ///
297    /// If the cache has reached `config.max_entries`, one entry is evicted first
298    /// according to `config.eviction_policy`. The `config.ttl_ms` value is
299    /// applied to the new entry's `ttl_ms` field.
300    ///
301    /// # Arguments
302    ///
303    /// * `key` — the query key (text + embedding).
304    /// * `result` — the result to cache.
305    /// * `now` — current timestamp in milliseconds.
306    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    // ------------------------------------------------------------------
326    // Eviction
327    // ------------------------------------------------------------------
328
329    /// Evict a single entry according to the configured [`CacheEvictionPolicy`].
330    ///
331    /// * **LRU** — remove the entry with the smallest `last_accessed` timestamp.
332    /// * **LFU** — remove the entry with the smallest `hit_count`.
333    /// * **TTLFirst** — among entries that have a `ttl_ms`, remove the one that
334    ///   will expire soonest (`inserted_at + ttl_ms` is smallest); if no entry
335    ///   has a TTL, fall back to LRU.
336    ///
337    /// Does nothing if the cache is empty.
338    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    /// Find the index of the least-recently-used entry.
355    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    /// Find the index of the least-frequently-used entry.
368    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    /// Find the index of the soonest-expiring entry; fall back to LRU.
381    fn find_ttl_victim(&self, _now: u64) -> Option<usize> {
382        // Among entries that have TTLs, pick the one whose absolute expiry
383        // (inserted_at + ttl_ms) is smallest — i.e. expires soonest.
384        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    /// Remove all expired entries from the cache.
396    ///
397    /// Returns the number of entries that were removed.
398    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    // ------------------------------------------------------------------
405    // Invalidation / management
406    // ------------------------------------------------------------------
407
408    /// Remove all entries whose `key.query_text` exactly matches `text`.
409    ///
410    /// Returns the number of entries removed.
411    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    /// Remove all entries from the cache (statistics are preserved).
418    pub fn clear(&mut self) {
419        self.entries.clear();
420    }
421
422    // ------------------------------------------------------------------
423    // Accessors
424    // ------------------------------------------------------------------
425
426    /// Return the current number of entries in the cache.
427    pub fn entry_count(&self) -> usize {
428        self.entries.len()
429    }
430
431    /// Return aggregate statistics for this cache instance.
432    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// ---------------------------------------------------------------------------
459// Tests
460// ---------------------------------------------------------------------------
461
462#[cfg(test)]
463mod tests {
464    use super::{
465        CacheConfig, CacheEntry, CacheEvictionPolicy, CacheKey, CacheLookupResult,
466        SemanticCacheLayer,
467    };
468
469    // ------------------------------------------------------------------
470    // Helpers
471    // ------------------------------------------------------------------
472
473    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    // ------------------------------------------------------------------
504    // cosine_similarity
505    // ------------------------------------------------------------------
506
507    #[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        // [1,1] vs [1,0] → dot=1, |a|=√2, |b|=1  → sim = 1/√2 ≈ 0.7071
571        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        // Floating-point rounding can push the result slightly above 1.0; it
581        // must be clamped to ≤ 1.0.
582        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    // ------------------------------------------------------------------
589    // Basic insert + lookup
590    // ------------------------------------------------------------------
591
592    #[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        // Query perpendicular to stored embedding → sim = 0.0
623        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        // Query aligned with axis 1 → should match "b"
634        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    // ------------------------------------------------------------------
662    // Statistics
663    // ------------------------------------------------------------------
664
665    #[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        // 2 hits, 1 miss
684        cache.lookup(&emb, 1);
685        cache.lookup(&emb, 2);
686        cache.lookup(&unit_vec(3, 2), 3); // perpendicular → miss
687
688        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    // ------------------------------------------------------------------
704    // Eviction — LRU
705    // ------------------------------------------------------------------
706
707    #[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        // Access "b" so its last_accessed is newer
714        cache.lookup(&unit_vec(3, 1), 10);
715
716        // Insert "c" — must evict "a" (last_accessed=0)
717        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    // ------------------------------------------------------------------
729    // Eviction — LFU
730    // ------------------------------------------------------------------
731
732    #[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        // Give "b" one hit
739        cache.lookup(&unit_vec(3, 1), 1);
740
741        // Insert "c" → must evict "a" (hit_count=0 < b's hit_count=1)
742        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    // ------------------------------------------------------------------
754    // Eviction — TTLFirst
755    // ------------------------------------------------------------------
756
757    #[test]
758    fn test_ttlfirst_evicts_soonest_expiring() {
759        let mut cache = SemanticCacheLayer::new(make_config(2, 0.9, CacheEvictionPolicy::TtlFirst));
760
761        // Manually insert entries with different TTLs
762        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), // expires at 100
769        });
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), // expires at 500
777        });
778
779        // Insert "c" → must evict "a" (expires soonest)
780        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        // Access "b" to update last_accessed
798        cache.lookup(&unit_vec(3, 1), 20);
799
800        // Insert "c" → TTLFirst with no TTLs → fall back to LRU → evict "a"
801        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    // ------------------------------------------------------------------
811    // evict_expired
812    // ------------------------------------------------------------------
813
814    #[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        // Advance time by 200ms — "a" has expired (200 > 100), "b" has not (200-50=150 > 100 — also expired)
824        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); // 500ms < 1000ms TTL
839        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    // ------------------------------------------------------------------
856    // TTL expiry during lookup
857    // ------------------------------------------------------------------
858
859    #[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        // After 200ms the entry is expired; lookup should return Miss
871        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        // First entry: TTL 100ms, expires at 100
885        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        // Second entry: no TTL, same embedding direction
895        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        // At t=200 the first entry is expired; lookup should find the second
905        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    // ------------------------------------------------------------------
912    // invalidate_by_text
913    // ------------------------------------------------------------------
914
915    #[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    // ------------------------------------------------------------------
938    // clear
939    // ------------------------------------------------------------------
940
941    #[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    // ------------------------------------------------------------------
971    // entry_count
972    // ------------------------------------------------------------------
973
974    #[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    // ------------------------------------------------------------------
989    // max_entries capacity
990    // ------------------------------------------------------------------
991
992    #[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        // max_entries=0 means evict immediately before push, so the entry may or may not remain.
1010        // The key invariant is that we do not panic.
1011        let _ = cache.entry_count();
1012    }
1013
1014    // ------------------------------------------------------------------
1015    // CacheEvictionPolicy default
1016    // ------------------------------------------------------------------
1017
1018    #[test]
1019    fn test_eviction_policy_default_is_lru() {
1020        assert_eq!(CacheEvictionPolicy::default(), CacheEvictionPolicy::Lru);
1021    }
1022
1023    // ------------------------------------------------------------------
1024    // CacheConfig default
1025    // ------------------------------------------------------------------
1026
1027    #[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    // ------------------------------------------------------------------
1037    // ScCacheStats avg_hit_count
1038    // ------------------------------------------------------------------
1039
1040    #[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        // Hit "a" twice
1047        cache.lookup(&unit_vec(3, 0), 1);
1048        cache.lookup(&unit_vec(3, 0), 2);
1049        // Hit "b" once
1050        cache.lookup(&unit_vec(3, 1), 3);
1051
1052        let s = cache.stats();
1053        // avg_hit_count = (2 + 1) / 2 = 1.5
1054        assert!((s.avg_hit_count - 1.5).abs() < 1e-9);
1055    }
1056
1057    // ------------------------------------------------------------------
1058    // Multi-dimensional embeddings
1059    // ------------------------------------------------------------------
1060
1061    #[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        // Slightly perturb the query — should still hit at threshold 0.95
1072        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        // Each exact lookup should hit its own entry
1097        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); // must not panic
1128        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        // Within TTL → hit
1143        assert!(
1144            matches!(cache.lookup(&emb, 400), CacheLookupResult::Hit { .. }),
1145            "Should hit within TTL"
1146        );
1147        // After TTL → miss
1148        assert_eq!(
1149            cache.lookup(&emb, 600),
1150            CacheLookupResult::Miss,
1151            "Should miss after TTL"
1152        );
1153    }
1154}