Skip to main content

ipfrs_semantic/
query_cache.rs

1//! Semantic query cache with TTL expiry, LRU eviction, and hit/miss statistics.
2//!
3//! Caches semantic search results keyed by query embedding fingerprint (FNV-1a).
4//! Entries expire after a configurable number of ticks and are evicted in LRU
5//! order when the cache reaches its capacity limit.
6
7use std::collections::HashMap;
8
9// ---------------------------------------------------------------------------
10// FNV-1a fingerprint
11// ---------------------------------------------------------------------------
12
13/// Compute an FNV-1a 64-bit fingerprint over the raw bytes of a `&[f32]` slice.
14///
15/// Each `f32` is decomposed into its 4 little-endian bytes before hashing, so
16/// the fingerprint is independent of the host's native endianness.
17fn embedding_fingerprint(embedding: &[f32]) -> u64 {
18    const OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
19    const PRIME: u64 = 1_099_511_628_211;
20
21    let mut hash = OFFSET_BASIS;
22    for &v in embedding {
23        for byte in v.to_le_bytes() {
24            hash ^= u64::from(byte);
25            hash = hash.wrapping_mul(PRIME);
26        }
27    }
28    hash
29}
30
31// ---------------------------------------------------------------------------
32// CachedQueryResult
33// ---------------------------------------------------------------------------
34
35/// A single cached search result entry, keyed by query embedding fingerprint.
36#[derive(Debug, Clone)]
37pub struct CachedQueryResult {
38    /// FNV-1a fingerprint of the query embedding bytes (little-endian f32).
39    pub query_fingerprint: u64,
40    /// Ordered list of document IDs returned by the search.
41    pub result_doc_ids: Vec<u64>,
42    /// Tick at which this entry was inserted or last refreshed.
43    pub cached_at_tick: u64,
44    /// Number of ticks this entry remains valid.
45    pub ttl_ticks: u64,
46    /// Number of times this entry has been returned as a cache hit.
47    pub hit_count: u64,
48}
49
50impl CachedQueryResult {
51    /// Returns `true` when `current_tick` has advanced past the entry's
52    /// expiry boundary (`cached_at_tick + ttl_ticks`).
53    #[inline]
54    pub fn is_expired(&self, current_tick: u64) -> bool {
55        current_tick > self.cached_at_tick.saturating_add(self.ttl_ticks)
56    }
57}
58
59// ---------------------------------------------------------------------------
60// QueryCacheConfig
61// ---------------------------------------------------------------------------
62
63/// Configuration for a [`SemanticQueryCache`] instance.
64#[derive(Debug, Clone)]
65pub struct QueryCacheConfig {
66    /// Maximum number of entries that may be held in the cache simultaneously.
67    /// When this limit is exceeded the least-recently-used entry is evicted.
68    pub max_entries: usize,
69    /// Default TTL (in ticks) applied to new entries when no override is given.
70    pub default_ttl_ticks: u64,
71}
72
73impl Default for QueryCacheConfig {
74    fn default() -> Self {
75        Self {
76            max_entries: 512,
77            default_ttl_ticks: 300,
78        }
79    }
80}
81
82// ---------------------------------------------------------------------------
83// QueryCacheStats  (named QueryCacheStats to avoid collision with router::CacheStats)
84// ---------------------------------------------------------------------------
85
86/// Accumulated statistics for a [`SemanticQueryCache`] instance.
87///
88/// Exported as `QueryCacheStats` to avoid a name collision with the
89/// `CacheStats` type that already exists in `router`.
90#[derive(Debug, Clone, Default)]
91pub struct QueryCacheStats {
92    /// Number of entries currently stored in the cache.
93    pub total_entries: usize,
94    /// Total number of successful cache lookups.
95    pub hits: u64,
96    /// Total number of unsuccessful cache lookups (including expired entries).
97    pub misses: u64,
98    /// Total number of LRU evictions (capacity overflow).
99    pub evictions: u64,
100    /// Total number of TTL expirations detected during lookups or explicit
101    /// calls to [`SemanticQueryCache::evict_expired`].
102    pub expirations: u64,
103}
104
105impl QueryCacheStats {
106    /// Returns the fraction of lookups that resulted in a cache hit.
107    ///
108    /// Returns `0.0` when no lookups have been performed yet.
109    pub fn hit_rate(&self) -> f64 {
110        let total = self.hits + self.misses;
111        if total == 0 {
112            0.0
113        } else {
114            self.hits as f64 / total as f64
115        }
116    }
117}
118
119// ---------------------------------------------------------------------------
120// SemanticQueryCache
121// ---------------------------------------------------------------------------
122
123/// An in-memory cache for semantic search results with:
124///
125/// * **FNV-1a fingerprinting** — O(d) key computation over the query embedding.
126/// * **TTL expiry** — stale entries are rejected on lookup and purged by
127///   [`Self::evict_expired`].
128/// * **LRU eviction** — when the cache is full the least-recently-used entry
129///   (front of `access_order`) is discarded.
130/// * **Hit/miss statistics** — exposed through [`Self::stats`].
131pub struct SemanticQueryCache {
132    /// Primary storage keyed by embedding fingerprint.
133    entries: HashMap<u64, CachedQueryResult>,
134    /// Ordered list of fingerprints from least- to most-recently used.
135    ///
136    /// The front of the vector is the LRU candidate; the back is the MRU entry.
137    access_order: Vec<u64>,
138    /// Immutable configuration provided at construction time.
139    config: QueryCacheConfig,
140    /// Running statistics updated on every operation.
141    stats: QueryCacheStats,
142}
143
144impl SemanticQueryCache {
145    /// Create a new, empty cache with the given [`QueryCacheConfig`].
146    pub fn new(config: QueryCacheConfig) -> Self {
147        Self {
148            entries: HashMap::new(),
149            access_order: Vec::new(),
150            config,
151            stats: QueryCacheStats::default(),
152        }
153    }
154
155    // -----------------------------------------------------------------------
156    // Public API
157    // -----------------------------------------------------------------------
158
159    /// Look up cached results for `embedding` at the given `current_tick`.
160    ///
161    /// # Return value
162    ///
163    /// * `Some(doc_ids)` — cache hit; `doc_ids` is a clone of the stored
164    ///   result vector.
165    /// * `None` — cache miss (unknown key or expired entry).
166    pub fn get(&mut self, embedding: &[f32], current_tick: u64) -> Option<Vec<u64>> {
167        let fp = embedding_fingerprint(embedding);
168
169        match self.entries.get(&fp) {
170            None => {
171                self.stats.misses += 1;
172                None
173            }
174            Some(entry) if entry.is_expired(current_tick) => {
175                // Remove the stale entry.
176                self.entries.remove(&fp);
177                Self::remove_from_order(&mut self.access_order, fp);
178                self.stats.expirations += 1;
179                self.stats.misses += 1;
180                self.stats.total_entries = self.entries.len();
181                None
182            }
183            Some(_) => {
184                // Valid hit — update hit_count and LRU order.
185                let entry = self
186                    .entries
187                    .get_mut(&fp)
188                    .expect("key confirmed present above");
189                entry.hit_count += 1;
190                let result = entry.result_doc_ids.clone();
191                Self::move_to_back(&mut self.access_order, fp);
192                self.stats.hits += 1;
193                Some(result)
194            }
195        }
196    }
197
198    /// Store `result_doc_ids` for `embedding` in the cache.
199    ///
200    /// If an entry already exists for this embedding it is refreshed in-place
201    /// (TTL reset, results replaced, hit_count zeroed).  Otherwise a new entry
202    /// is created, potentially evicting the LRU entry when the cache is full.
203    ///
204    /// Pass `ttl_override` to use a TTL other than
205    /// [`QueryCacheConfig::default_ttl_ticks`] for this specific entry.
206    pub fn put(
207        &mut self,
208        embedding: &[f32],
209        result_doc_ids: Vec<u64>,
210        current_tick: u64,
211        ttl_override: Option<u64>,
212    ) {
213        let fp = embedding_fingerprint(embedding);
214        let ttl = ttl_override.unwrap_or(self.config.default_ttl_ticks);
215
216        if let Some(entry) = self.entries.get_mut(&fp) {
217            // Refresh existing entry.
218            entry.result_doc_ids = result_doc_ids;
219            entry.cached_at_tick = current_tick;
220            entry.ttl_ticks = ttl;
221            entry.hit_count = 0;
222            Self::move_to_back(&mut self.access_order, fp);
223        } else {
224            // Evict LRU if at capacity.
225            if self.entries.len() >= self.config.max_entries {
226                if let Some(lru_fp) = Self::pop_front(&mut self.access_order) {
227                    self.entries.remove(&lru_fp);
228                    self.stats.evictions += 1;
229                }
230            }
231
232            // Insert the new entry.
233            self.entries.insert(
234                fp,
235                CachedQueryResult {
236                    query_fingerprint: fp,
237                    result_doc_ids,
238                    cached_at_tick: current_tick,
239                    ttl_ticks: ttl,
240                    hit_count: 0,
241                },
242            );
243            self.access_order.push(fp);
244        }
245
246        self.stats.total_entries = self.entries.len();
247    }
248
249    /// Remove the cache entry for `embedding`.
250    ///
251    /// Returns `true` if an entry existed and was removed, `false` otherwise.
252    pub fn invalidate(&mut self, embedding: &[f32]) -> bool {
253        let fp = embedding_fingerprint(embedding);
254        let existed = self.entries.remove(&fp).is_some();
255        if existed {
256            Self::remove_from_order(&mut self.access_order, fp);
257            self.stats.total_entries = self.entries.len();
258        }
259        existed
260    }
261
262    /// Remove all entries whose TTL has elapsed at `current_tick`.
263    ///
264    /// Returns the number of entries removed.  Each removed entry increments
265    /// [`QueryCacheStats::expirations`].
266    pub fn evict_expired(&mut self, current_tick: u64) -> usize {
267        let expired: Vec<u64> = self
268            .entries
269            .iter()
270            .filter(|(_, e)| e.is_expired(current_tick))
271            .map(|(k, _)| *k)
272            .collect();
273
274        let count = expired.len();
275        for fp in expired {
276            self.entries.remove(&fp);
277            Self::remove_from_order(&mut self.access_order, fp);
278        }
279
280        self.stats.expirations += count as u64;
281        self.stats.total_entries = self.entries.len();
282        count
283    }
284
285    /// Return a shared reference to the accumulated cache statistics.
286    pub fn stats(&self) -> &QueryCacheStats {
287        &self.stats
288    }
289
290    // -----------------------------------------------------------------------
291    // Private helpers
292    // -----------------------------------------------------------------------
293
294    /// Move `fp` to the back of `order` (most-recently used position).
295    ///
296    /// If `fp` is not already present it is appended without removing anything.
297    fn move_to_back(order: &mut Vec<u64>, fp: u64) {
298        if let Some(pos) = order.iter().position(|&x| x == fp) {
299            order.remove(pos);
300        }
301        order.push(fp);
302    }
303
304    /// Remove the first occurrence of `fp` from `order`.
305    fn remove_from_order(order: &mut Vec<u64>, fp: u64) {
306        if let Some(pos) = order.iter().position(|&x| x == fp) {
307            order.remove(pos);
308        }
309    }
310
311    /// Remove and return the front element of `order` (least-recently used).
312    fn pop_front(order: &mut Vec<u64>) -> Option<u64> {
313        if order.is_empty() {
314            None
315        } else {
316            Some(order.remove(0))
317        }
318    }
319}
320
321// ---------------------------------------------------------------------------
322// Tests
323// ---------------------------------------------------------------------------
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    // -----------------------------------------------------------------------
330    // Helpers
331    // -----------------------------------------------------------------------
332
333    fn default_cache() -> SemanticQueryCache {
334        SemanticQueryCache::new(QueryCacheConfig::default())
335    }
336
337    fn cache_with_max(max_entries: usize) -> SemanticQueryCache {
338        SemanticQueryCache::new(QueryCacheConfig {
339            max_entries,
340            ..QueryCacheConfig::default()
341        })
342    }
343
344    fn cache_with_ttl(ttl: u64) -> SemanticQueryCache {
345        SemanticQueryCache::new(QueryCacheConfig {
346            default_ttl_ticks: ttl,
347            ..QueryCacheConfig::default()
348        })
349    }
350
351    fn vec_a() -> Vec<f32> {
352        vec![1.0_f32, 0.0, 0.0]
353    }
354
355    fn vec_b() -> Vec<f32> {
356        vec![0.0_f32, 1.0, 0.0]
357    }
358
359    fn vec_c() -> Vec<f32> {
360        vec![0.0_f32, 0.0, 1.0]
361    }
362
363    // -----------------------------------------------------------------------
364    // 1. get returns None for unknown embedding
365    // -----------------------------------------------------------------------
366    #[test]
367    fn test_get_returns_none_for_unknown_embedding() {
368        let mut cache = default_cache();
369        assert!(cache.get(&vec_a(), 0).is_none());
370    }
371
372    // -----------------------------------------------------------------------
373    // 2. get increments misses for unknown embedding
374    // -----------------------------------------------------------------------
375    #[test]
376    fn test_get_increments_misses_for_unknown_embedding() {
377        let mut cache = default_cache();
378        cache.get(&vec_a(), 0);
379        cache.get(&vec_b(), 0);
380        assert_eq!(cache.stats().misses, 2);
381        assert_eq!(cache.stats().hits, 0);
382    }
383
384    // -----------------------------------------------------------------------
385    // 3. get returns None for expired entry
386    // -----------------------------------------------------------------------
387    #[test]
388    fn test_get_returns_none_for_expired_entry() {
389        let mut cache = cache_with_ttl(10);
390        cache.put(&vec_a(), vec![1, 2, 3], 0, None);
391        // Expiry boundary: 0 + 10 = 10; tick 11 > 10 → expired
392        assert!(cache.get(&vec_a(), 11).is_none());
393    }
394
395    // -----------------------------------------------------------------------
396    // 4. expired entry is removed from storage
397    // -----------------------------------------------------------------------
398    #[test]
399    fn test_expired_entry_is_removed_from_storage() {
400        let mut cache = cache_with_ttl(5);
401        cache.put(&vec_a(), vec![42], 0, None);
402        cache.get(&vec_a(), 10);
403        assert_eq!(cache.entries.len(), 0);
404        assert_eq!(cache.access_order.len(), 0);
405    }
406
407    // -----------------------------------------------------------------------
408    // 5. expired lookup increments expirations and misses
409    // -----------------------------------------------------------------------
410    #[test]
411    fn test_expired_lookup_increments_expirations_and_misses() {
412        let mut cache = cache_with_ttl(5);
413        cache.put(&vec_a(), vec![1], 0, None);
414        cache.get(&vec_a(), 100);
415        assert_eq!(cache.stats().expirations, 1);
416        assert_eq!(cache.stats().misses, 1);
417        assert_eq!(cache.stats().hits, 0);
418    }
419
420    // -----------------------------------------------------------------------
421    // 6. get returns cached results on valid hit
422    // -----------------------------------------------------------------------
423    #[test]
424    fn test_get_returns_cached_results_on_valid_hit() {
425        let mut cache = default_cache();
426        let ids = vec![10_u64, 20, 30];
427        cache.put(&vec_a(), ids.clone(), 0, None);
428        let result = cache.get(&vec_a(), 0);
429        assert_eq!(result, Some(ids));
430    }
431
432    // -----------------------------------------------------------------------
433    // 7. get increments hit_count on the entry
434    // -----------------------------------------------------------------------
435    #[test]
436    fn test_get_increments_hit_count_on_entry() {
437        let mut cache = default_cache();
438        cache.put(&vec_a(), vec![1], 0, None);
439        cache.get(&vec_a(), 0);
440        cache.get(&vec_a(), 0);
441        let fp = embedding_fingerprint(&vec_a());
442        let hit_count = cache.entries[&fp].hit_count;
443        assert_eq!(hit_count, 2);
444    }
445
446    // -----------------------------------------------------------------------
447    // 8. get increments hits stat on valid hit
448    // -----------------------------------------------------------------------
449    #[test]
450    fn test_get_increments_hits_stat() {
451        let mut cache = default_cache();
452        cache.put(&vec_a(), vec![1], 0, None);
453        cache.get(&vec_a(), 0);
454        assert_eq!(cache.stats().hits, 1);
455    }
456
457    // -----------------------------------------------------------------------
458    // 9. put inserts a new entry
459    // -----------------------------------------------------------------------
460    #[test]
461    fn test_put_inserts_new_entry() {
462        let mut cache = default_cache();
463        cache.put(&vec_a(), vec![5, 6], 0, None);
464        assert_eq!(cache.entries.len(), 1);
465        assert_eq!(cache.stats().total_entries, 1);
466    }
467
468    // -----------------------------------------------------------------------
469    // 10. put updates existing entry (results replaced, hit_count reset)
470    // -----------------------------------------------------------------------
471    #[test]
472    fn test_put_updates_existing_entry() {
473        let mut cache = default_cache();
474        cache.put(&vec_a(), vec![1, 2], 0, None);
475        // Simulate a hit to raise hit_count.
476        cache.get(&vec_a(), 0);
477        // Now overwrite.
478        cache.put(&vec_a(), vec![99], 100, None);
479        let result = cache.get(&vec_a(), 100);
480        assert_eq!(result, Some(vec![99_u64]));
481        // hit_count should have been reset to 0 then incremented once by the get above.
482        let fp = embedding_fingerprint(&vec_a());
483        assert_eq!(cache.entries[&fp].hit_count, 1);
484        // Still only one logical entry.
485        assert_eq!(cache.entries.len(), 1);
486    }
487
488    // -----------------------------------------------------------------------
489    // 11. LRU eviction when max_entries exceeded
490    // -----------------------------------------------------------------------
491    #[test]
492    fn test_lru_eviction_when_max_entries_exceeded() {
493        let mut cache = cache_with_max(2);
494
495        // Insert A then B (A is LRU).
496        cache.put(&vec_a(), vec![1], 0, None);
497        cache.put(&vec_b(), vec![2], 0, None);
498
499        // Access A to make it MRU; B becomes LRU.
500        cache.get(&vec_a(), 0);
501
502        // Inserting C should evict B (the LRU).
503        cache.put(&vec_c(), vec![3], 0, None);
504
505        assert_eq!(cache.entries.len(), 2);
506        assert!(cache.entries.contains_key(&embedding_fingerprint(&vec_a())));
507        assert!(!cache.entries.contains_key(&embedding_fingerprint(&vec_b())));
508        assert!(cache.entries.contains_key(&embedding_fingerprint(&vec_c())));
509        assert_eq!(cache.stats().evictions, 1);
510    }
511
512    // -----------------------------------------------------------------------
513    // 12. LRU eviction increments evictions stat
514    // -----------------------------------------------------------------------
515    #[test]
516    fn test_lru_eviction_increments_evictions_stat() {
517        let mut cache = cache_with_max(1);
518        cache.put(&vec_a(), vec![1], 0, None);
519        cache.put(&vec_b(), vec![2], 0, None);
520        assert_eq!(cache.stats().evictions, 1);
521    }
522
523    // -----------------------------------------------------------------------
524    // 13. evict_expired removes expired entries
525    // -----------------------------------------------------------------------
526    #[test]
527    fn test_evict_expired_removes_expired_entries() {
528        let mut cache = cache_with_ttl(10);
529        cache.put(&vec_a(), vec![1], 0, None); // expires at tick 11
530        cache.put(&vec_b(), vec![2], 100, None); // expires at tick 111
531        let removed = cache.evict_expired(20);
532        assert_eq!(removed, 1);
533        assert!(!cache.entries.contains_key(&embedding_fingerprint(&vec_a())));
534        assert!(cache.entries.contains_key(&embedding_fingerprint(&vec_b())));
535    }
536
537    // -----------------------------------------------------------------------
538    // 14. evict_expired updates total_entries
539    // -----------------------------------------------------------------------
540    #[test]
541    fn test_evict_expired_updates_total_entries() {
542        let mut cache = cache_with_ttl(5);
543        cache.put(&vec_a(), vec![1], 0, None);
544        cache.put(&vec_b(), vec![2], 0, None);
545        cache.evict_expired(10);
546        assert_eq!(cache.stats().total_entries, 0);
547    }
548
549    // -----------------------------------------------------------------------
550    // 15. evict_expired increments expirations stat
551    // -----------------------------------------------------------------------
552    #[test]
553    fn test_evict_expired_increments_expirations_stat() {
554        let mut cache = cache_with_ttl(5);
555        cache.put(&vec_a(), vec![1], 0, None);
556        cache.put(&vec_b(), vec![2], 0, None);
557        cache.evict_expired(10);
558        assert_eq!(cache.stats().expirations, 2);
559    }
560
561    // -----------------------------------------------------------------------
562    // 16. evict_expired returns zero when nothing expired
563    // -----------------------------------------------------------------------
564    #[test]
565    fn test_evict_expired_returns_zero_when_nothing_expired() {
566        let mut cache = cache_with_ttl(100);
567        cache.put(&vec_a(), vec![1], 0, None);
568        let removed = cache.evict_expired(5);
569        assert_eq!(removed, 0);
570    }
571
572    // -----------------------------------------------------------------------
573    // 17. invalidate removes specific entry
574    // -----------------------------------------------------------------------
575    #[test]
576    fn test_invalidate_removes_specific_entry() {
577        let mut cache = default_cache();
578        cache.put(&vec_a(), vec![1], 0, None);
579        cache.put(&vec_b(), vec![2], 0, None);
580        let existed = cache.invalidate(&vec_a());
581        assert!(existed);
582        assert!(!cache.entries.contains_key(&embedding_fingerprint(&vec_a())));
583        assert!(cache.entries.contains_key(&embedding_fingerprint(&vec_b())));
584    }
585
586    // -----------------------------------------------------------------------
587    // 18. invalidate returns false for unknown entry
588    // -----------------------------------------------------------------------
589    #[test]
590    fn test_invalidate_returns_false_for_unknown_entry() {
591        let mut cache = default_cache();
592        assert!(!cache.invalidate(&vec_a()));
593    }
594
595    // -----------------------------------------------------------------------
596    // 19. invalidate updates access_order
597    // -----------------------------------------------------------------------
598    #[test]
599    fn test_invalidate_updates_access_order() {
600        let mut cache = default_cache();
601        cache.put(&vec_a(), vec![1], 0, None);
602        cache.invalidate(&vec_a());
603        assert!(cache.access_order.is_empty());
604    }
605
606    // -----------------------------------------------------------------------
607    // 20. hit_rate computation — all hits
608    // -----------------------------------------------------------------------
609    #[test]
610    fn test_hit_rate_all_hits() {
611        let mut cache = default_cache();
612        cache.put(&vec_a(), vec![1], 0, None);
613        cache.get(&vec_a(), 0);
614        cache.get(&vec_a(), 0);
615        assert!((cache.stats().hit_rate() - 1.0).abs() < f64::EPSILON);
616    }
617
618    // -----------------------------------------------------------------------
619    // 21. hit_rate computation — all misses
620    // -----------------------------------------------------------------------
621    #[test]
622    fn test_hit_rate_all_misses() {
623        let mut cache = default_cache();
624        cache.get(&vec_a(), 0);
625        cache.get(&vec_b(), 0);
626        assert!((cache.stats().hit_rate() - 0.0).abs() < f64::EPSILON);
627    }
628
629    // -----------------------------------------------------------------------
630    // 22. hit_rate computation — no queries
631    // -----------------------------------------------------------------------
632    #[test]
633    fn test_hit_rate_no_queries_returns_zero() {
634        let cache = default_cache();
635        assert_eq!(cache.stats().hit_rate(), 0.0);
636    }
637
638    // -----------------------------------------------------------------------
639    // 23. hit_rate computation — mixed hits and misses
640    // -----------------------------------------------------------------------
641    #[test]
642    fn test_hit_rate_mixed() {
643        let mut cache = default_cache();
644        cache.put(&vec_a(), vec![1], 0, None);
645        cache.get(&vec_a(), 0); // hit
646        cache.get(&vec_a(), 0); // hit
647        cache.get(&vec_b(), 0); // miss
648                                // 2 hits / 3 total = 0.666…
649        let rate = cache.stats().hit_rate();
650        assert!((rate - 2.0 / 3.0).abs() < 1e-10);
651    }
652
653    // -----------------------------------------------------------------------
654    // 24. ttl_override in put is respected
655    // -----------------------------------------------------------------------
656    #[test]
657    fn test_ttl_override_in_put_is_respected() {
658        // Default TTL is 300 ticks; override to 5.
659        let mut cache = default_cache();
660        cache.put(&vec_a(), vec![7], 0, Some(5));
661        // Should be present at tick 5 (boundary: 0 + 5 = 5, current 5 is NOT > 5).
662        assert!(cache.get(&vec_a(), 5).is_some());
663        // Should be expired at tick 6.
664        cache.put(&vec_a(), vec![7], 0, Some(5)); // re-insert after expiry removed it
665        assert!(cache.get(&vec_a(), 6).is_none());
666    }
667
668    // -----------------------------------------------------------------------
669    // 25. expirations stat accumulated correctly
670    // -----------------------------------------------------------------------
671    #[test]
672    fn test_expirations_stat_accumulated_correctly() {
673        let mut cache = cache_with_ttl(1);
674        cache.put(&vec_a(), vec![1], 0, None);
675        cache.put(&vec_b(), vec![2], 0, None);
676        cache.put(&vec_c(), vec![3], 0, None);
677        // Trigger expiry via get.
678        cache.get(&vec_a(), 5);
679        cache.get(&vec_b(), 5);
680        // Trigger expiry via evict_expired for C.
681        cache.evict_expired(5);
682        assert_eq!(cache.stats().expirations, 3);
683    }
684
685    // -----------------------------------------------------------------------
686    // 26. evictions stat accumulated correctly across multiple evictions
687    // -----------------------------------------------------------------------
688    #[test]
689    fn test_evictions_stat_accumulated_correctly() {
690        let mut cache = cache_with_max(1);
691        cache.put(&vec_a(), vec![1], 0, None);
692        cache.put(&vec_b(), vec![2], 0, None); // evicts A
693        cache.put(&vec_c(), vec![3], 0, None); // evicts B
694        assert_eq!(cache.stats().evictions, 2);
695    }
696
697    // -----------------------------------------------------------------------
698    // 27. is_expired boundary conditions
699    // -----------------------------------------------------------------------
700    #[test]
701    fn test_is_expired_boundary_conditions() {
702        let entry = CachedQueryResult {
703            query_fingerprint: 0,
704            result_doc_ids: vec![],
705            cached_at_tick: 100,
706            ttl_ticks: 50,
707            hit_count: 0,
708        };
709        // Exactly at expiry boundary — NOT expired (current_tick must be GREATER THAN).
710        assert!(!entry.is_expired(150));
711        // One tick beyond boundary — expired.
712        assert!(entry.is_expired(151));
713        // Well before expiry.
714        assert!(!entry.is_expired(100));
715    }
716
717    // -----------------------------------------------------------------------
718    // 28. QueryCacheConfig::default() values
719    // -----------------------------------------------------------------------
720    #[test]
721    fn test_query_cache_config_default_values() {
722        let cfg = QueryCacheConfig::default();
723        assert_eq!(cfg.max_entries, 512);
724        assert_eq!(cfg.default_ttl_ticks, 300);
725    }
726
727    // -----------------------------------------------------------------------
728    // 29. FNV-1a fingerprint is deterministic
729    // -----------------------------------------------------------------------
730    #[test]
731    fn test_embedding_fingerprint_is_deterministic() {
732        let v = vec![1.0_f32, 2.0, 3.0, 4.0];
733        assert_eq!(embedding_fingerprint(&v), embedding_fingerprint(&v));
734    }
735
736    // -----------------------------------------------------------------------
737    // 30. FNV-1a fingerprint differs for distinct embeddings
738    // -----------------------------------------------------------------------
739    #[test]
740    fn test_embedding_fingerprint_differs_for_distinct_embeddings() {
741        let v1 = vec![1.0_f32, 2.0, 3.0];
742        let v2 = vec![3.0_f32, 2.0, 1.0];
743        assert_ne!(embedding_fingerprint(&v1), embedding_fingerprint(&v2));
744    }
745
746    // -----------------------------------------------------------------------
747    // 31. put then multiple invalidations — second returns false
748    // -----------------------------------------------------------------------
749    #[test]
750    fn test_double_invalidate_second_returns_false() {
751        let mut cache = default_cache();
752        cache.put(&vec_a(), vec![1], 0, None);
753        assert!(cache.invalidate(&vec_a()));
754        assert!(!cache.invalidate(&vec_a()));
755    }
756
757    // -----------------------------------------------------------------------
758    // 32. LRU order maintained across multiple accesses
759    // -----------------------------------------------------------------------
760    #[test]
761    fn test_lru_order_maintained_across_accesses() {
762        let mut cache = cache_with_max(3);
763
764        // Insert A, B, C in order.
765        cache.put(&vec_a(), vec![1], 0, None);
766        cache.put(&vec_b(), vec![2], 0, None);
767        cache.put(&vec_c(), vec![3], 0, None);
768
769        // Access A — order is now B, C, A (B is LRU).
770        cache.get(&vec_a(), 0);
771
772        // Access B — order is now C, A, B (C is LRU).
773        cache.get(&vec_b(), 0);
774
775        // Insert a fourth element — C should be evicted.
776        let vec_d = vec![0.5_f32, 0.5, 0.0];
777        cache.put(&vec_d, vec![4], 0, None);
778
779        assert!(!cache.entries.contains_key(&embedding_fingerprint(&vec_c())));
780        assert!(cache.entries.contains_key(&embedding_fingerprint(&vec_a())));
781        assert!(cache.entries.contains_key(&embedding_fingerprint(&vec_b())));
782        assert!(cache.entries.contains_key(&embedding_fingerprint(&vec_d)));
783    }
784}