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}