Skip to main content

ipfrs_tensorlogic/
versioned_cache.rs

1//! Versioned Inference Cache with Atomic KB Invalidation
2//!
3//! Extends the basic `InferenceCache` with explicit version tracking per knowledge base.
4//! When a knowledge base is updated, all cached inferences for that KB are atomically
5//! invalidated in O(n) time, where n is the number of cached entries for that KB.
6//!
7//! ## Design
8//!
9//! - [`CacheKey`] identifies an inference result by goal hash, KB identity, and KB version.
10//! - [`CacheEntry`] stores variable bindings, a TTL, and a hit counter.
11//! - [`VersionedInferenceCache`] is a concurrent, version-aware cache backed by `RwLock<HashMap>`.
12//! - [`CacheStats`] tracks inserts/hits/misses/invalidations/evictions via `AtomicU64`.
13//!
14//! ## Thread Safety
15//!
16//! All public methods are `&self` (shared reference). Internal mutation is protected by
17//! `RwLock`. [`CacheStats`] uses `AtomicU64` with `Relaxed` ordering for performance —
18//! exact ordering guarantees are not required for statistics counters.
19
20use std::collections::HashMap;
21use std::sync::{
22    atomic::{AtomicU64, Ordering},
23    RwLock,
24};
25use std::time::{Duration, Instant};
26
27use thiserror::Error;
28
29// ---------------------------------------------------------------------------
30// FNV-1a
31// ---------------------------------------------------------------------------
32
33/// Compute FNV-1a hash of an arbitrary byte slice.
34fn fnv1a(data: &[u8]) -> u64 {
35    const FNV_OFFSET: u64 = 14_695_981_039_346_656_037;
36    const FNV_PRIME: u64 = 1_099_511_628_211;
37    let mut hash = FNV_OFFSET;
38    for &byte in data {
39        hash ^= u64::from(byte);
40        hash = hash.wrapping_mul(FNV_PRIME);
41    }
42    hash
43}
44
45// ---------------------------------------------------------------------------
46// CacheError
47// ---------------------------------------------------------------------------
48
49/// Errors that can occur when interacting with [`VersionedInferenceCache`].
50#[derive(Debug, Error)]
51pub enum CacheError {
52    /// The cache is at capacity; the entry was not inserted.
53    #[error("cache capacity exceeded: current={current}, max={max}")]
54    CapacityExceeded {
55        /// Number of entries currently in the cache.
56        current: usize,
57        /// Maximum number of entries allowed.
58        max: usize,
59    },
60
61    /// The supplied key's `kb_version` is older than the current version for that KB.
62    #[error(
63        "stale version for KB '{kb_id}': cache_version={cache_version}, current_version={current_version}"
64    )]
65    StaleVersion {
66        /// Knowledge base identifier.
67        kb_id: String,
68        /// Version stored in the cache key.
69        cache_version: u64,
70        /// Version currently tracked for this KB.
71        current_version: u64,
72    },
73}
74
75// ---------------------------------------------------------------------------
76// CacheKey
77// ---------------------------------------------------------------------------
78
79/// Composite key for a single cached inference result.
80///
81/// Two keys are equal iff `goal_hash`, `kb_id`, **and** `kb_version` all match.
82/// This means the same goal cached against version 1 and version 2 of the same
83/// KB are stored as completely independent entries.
84#[derive(Debug, Clone, Hash, Eq, PartialEq)]
85pub struct CacheKey {
86    /// FNV-1a hash of the goal string.
87    pub goal_hash: u64,
88    /// Identifier of the knowledge base that was queried.
89    pub kb_id: String,
90    /// Version of the knowledge base at the time of inference.
91    pub kb_version: u64,
92}
93
94impl CacheKey {
95    /// Construct a new [`CacheKey`], hashing `goal` with FNV-1a.
96    pub fn new(goal: &str, kb_id: &str, kb_version: u64) -> Self {
97        Self {
98            goal_hash: fnv1a(goal.as_bytes()),
99            kb_id: kb_id.to_owned(),
100            kb_version,
101        }
102    }
103}
104
105// ---------------------------------------------------------------------------
106// CacheEntry
107// ---------------------------------------------------------------------------
108
109/// Default TTL for a cached inference result: 5 minutes.
110const DEFAULT_TTL: Duration = Duration::from_secs(5 * 60);
111
112/// A single cached inference result.
113#[derive(Debug, Clone)]
114pub struct CacheEntry {
115    /// Variable bindings produced by the inference engine.
116    pub bindings: Vec<HashMap<String, String>>,
117    /// `true` if the result is definitive (no further answers possible).
118    pub is_final: bool,
119    /// Wall-clock instant at which this entry was inserted.
120    pub inserted_at: Instant,
121    /// Maximum time this entry may be retained.
122    pub ttl: Duration,
123    /// Number of times this entry has been returned via [`VersionedInferenceCache::get`].
124    pub hit_count: u64,
125}
126
127impl CacheEntry {
128    /// Create a new [`CacheEntry`] with the default TTL (5 minutes).
129    pub fn new(bindings: Vec<HashMap<String, String>>, is_final: bool) -> Self {
130        Self {
131            bindings,
132            is_final,
133            inserted_at: Instant::now(),
134            ttl: DEFAULT_TTL,
135            hit_count: 0,
136        }
137    }
138
139    /// Create a new [`CacheEntry`] with a custom TTL.
140    pub fn with_ttl(bindings: Vec<HashMap<String, String>>, is_final: bool, ttl: Duration) -> Self {
141        Self {
142            bindings,
143            is_final,
144            inserted_at: Instant::now(),
145            ttl,
146            hit_count: 0,
147        }
148    }
149
150    /// Returns `true` if this entry's TTL has elapsed relative to `now`.
151    pub fn is_expired(&self, now: Instant) -> bool {
152        now.duration_since(self.inserted_at) >= self.ttl
153    }
154}
155
156// ---------------------------------------------------------------------------
157// CacheStats
158// ---------------------------------------------------------------------------
159
160/// Atomic statistics counters for [`VersionedInferenceCache`].
161#[derive(Debug, Default)]
162pub struct CacheStats {
163    /// Total number of successful `insert` calls.
164    pub total_inserts: AtomicU64,
165    /// Total number of cache hits (entry found and not expired).
166    pub total_hits: AtomicU64,
167    /// Total number of cache misses (entry not found or expired).
168    pub total_misses: AtomicU64,
169    /// Total number of entries removed via `invalidate_kb` or `bump_kb_version`.
170    pub total_invalidations: AtomicU64,
171    /// Total number of entries removed via `evict_expired`.
172    pub total_evictions: AtomicU64,
173}
174
175/// A point-in-time snapshot of [`CacheStats`].
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct CacheStatsSnapshot {
178    /// Total successful inserts since the cache was created.
179    pub total_inserts: u64,
180    /// Total hits since the cache was created.
181    pub total_hits: u64,
182    /// Total misses since the cache was created.
183    pub total_misses: u64,
184    /// Total invalidations since the cache was created.
185    pub total_invalidations: u64,
186    /// Total evictions since the cache was created.
187    pub total_evictions: u64,
188}
189
190impl CacheStats {
191    fn new() -> Self {
192        Self::default()
193    }
194
195    /// Take a consistent snapshot.  Note: individual counters are read with
196    /// `Relaxed`; the values are informational and do not require fencing.
197    pub fn snapshot(&self) -> CacheStatsSnapshot {
198        CacheStatsSnapshot {
199            total_inserts: self.total_inserts.load(Ordering::Relaxed),
200            total_hits: self.total_hits.load(Ordering::Relaxed),
201            total_misses: self.total_misses.load(Ordering::Relaxed),
202            total_invalidations: self.total_invalidations.load(Ordering::Relaxed),
203            total_evictions: self.total_evictions.load(Ordering::Relaxed),
204        }
205    }
206}
207
208// ---------------------------------------------------------------------------
209// VersionedInferenceCache
210// ---------------------------------------------------------------------------
211
212/// Default maximum number of entries in a [`VersionedInferenceCache`].
213const DEFAULT_MAX_ENTRIES: usize = 10_000;
214
215/// Concurrent, version-aware memoization cache for inference results.
216///
217/// Entries are keyed by `(goal_hash, kb_id, kb_version)`.  Calling
218/// [`bump_kb_version`](Self::bump_kb_version) atomically increments the version
219/// for a given KB **and** removes every cached entry that was derived from that
220/// KB, regardless of their recorded version.
221///
222/// # Concurrency
223///
224/// Read operations (`get`, `current_kb_version`, `entry_count`, `stats`) acquire
225/// a read lock; write operations acquire an exclusive write lock.
226pub struct VersionedInferenceCache {
227    entries: RwLock<HashMap<CacheKey, CacheEntry>>,
228    kb_versions: RwLock<HashMap<String, u64>>,
229    max_entries: usize,
230    stats: CacheStats,
231}
232
233impl VersionedInferenceCache {
234    /// Create a new cache with the given capacity limit.
235    ///
236    /// If `max_entries` is 0 the default (`10_000`) is used.
237    pub fn new(max_entries: usize) -> Self {
238        let max_entries = if max_entries == 0 {
239            DEFAULT_MAX_ENTRIES
240        } else {
241            max_entries
242        };
243        Self {
244            entries: RwLock::new(HashMap::new()),
245            kb_versions: RwLock::new(HashMap::new()),
246            max_entries,
247            stats: CacheStats::new(),
248        }
249    }
250
251    // ------------------------------------------------------------------
252    // Insert
253    // ------------------------------------------------------------------
254
255    /// Insert a new entry.
256    ///
257    /// Returns [`CacheError::CapacityExceeded`] when the cache is full.
258    /// Returns [`CacheError::StaleVersion`] when `key.kb_version` is lower
259    /// than the currently tracked version for `key.kb_id`.
260    ///
261    /// If an entry with the same key already exists it is overwritten (the
262    /// capacity check is skipped for replacements).
263    pub fn insert(&self, key: CacheKey, entry: CacheEntry) -> Result<(), CacheError> {
264        // Check for stale version before acquiring the write lock.
265        let current_ver = self.current_kb_version(&key.kb_id);
266        if current_ver > 0 && key.kb_version < current_ver {
267            return Err(CacheError::StaleVersion {
268                kb_id: key.kb_id.clone(),
269                cache_version: key.kb_version,
270                current_version: current_ver,
271            });
272        }
273
274        let mut map = self
275            .entries
276            .write()
277            .expect("versioned_cache: entries write lock poisoned");
278
279        use std::collections::hash_map::Entry;
280
281        // Snapshot length before the entry borrow.
282        let current_len = map.len();
283
284        match map.entry(key) {
285            Entry::Occupied(mut occ) => {
286                // Replacement — update in place; no capacity check needed.
287                *occ.get_mut() = entry;
288                self.stats.total_inserts.fetch_add(1, Ordering::Relaxed);
289                Ok(())
290            }
291            Entry::Vacant(vac) => {
292                // Capacity check before a new insertion.
293                if current_len >= self.max_entries {
294                    return Err(CacheError::CapacityExceeded {
295                        current: current_len,
296                        max: self.max_entries,
297                    });
298                }
299                vac.insert(entry);
300                self.stats.total_inserts.fetch_add(1, Ordering::Relaxed);
301                Ok(())
302            }
303        }
304    }
305
306    // ------------------------------------------------------------------
307    // Get
308    // ------------------------------------------------------------------
309
310    /// Look up a cached entry by key.
311    ///
312    /// Returns `None` if the entry is not found **or** if it has expired.
313    /// On a hit the entry's `hit_count` is incremented and a clone is returned.
314    pub fn get(&self, key: &CacheKey) -> Option<CacheEntry> {
315        let now = Instant::now();
316
317        let map = self
318            .entries
319            .read()
320            .expect("versioned_cache: entries read lock poisoned");
321
322        match map.get(key) {
323            None => {
324                self.stats.total_misses.fetch_add(1, Ordering::Relaxed);
325                None
326            }
327            Some(entry) if entry.is_expired(now) => {
328                self.stats.total_misses.fetch_add(1, Ordering::Relaxed);
329                None
330            }
331            Some(entry) => {
332                self.stats.total_hits.fetch_add(1, Ordering::Relaxed);
333                let mut clone = entry.clone();
334                clone.hit_count += 1;
335                // We return the clone with the updated hit_count; we also need
336                // to persist the increment in the map.  Drop the read lock first.
337                drop(map);
338                // Re-acquire write lock to bump the stored hit_count.
339                if let Ok(mut wmap) = self.entries.write() {
340                    if let Some(stored) = wmap.get_mut(key) {
341                        stored.hit_count += 1;
342                    }
343                }
344                Some(clone)
345            }
346        }
347    }
348
349    // ------------------------------------------------------------------
350    // KB version management
351    // ------------------------------------------------------------------
352
353    /// Return the current version for the given KB, or `0` if unknown.
354    pub fn current_kb_version(&self, kb_id: &str) -> u64 {
355        let map = self
356            .kb_versions
357            .read()
358            .expect("versioned_cache: kb_versions read lock poisoned");
359        map.get(kb_id).copied().unwrap_or(0)
360    }
361
362    /// Increment the version counter for `kb_id` and atomically invalidate
363    /// all cached entries that were derived from that KB.
364    ///
365    /// Returns the **new** version number.
366    pub fn bump_kb_version(&self, kb_id: &str) -> u64 {
367        // Bump the version counter.
368        let new_version = {
369            let mut ver_map = self
370                .kb_versions
371                .write()
372                .expect("versioned_cache: kb_versions write lock poisoned");
373            let v = ver_map.entry(kb_id.to_owned()).or_insert(0);
374            *v += 1;
375            *v
376        };
377
378        // Invalidate all entries for this KB (any version).
379        self.invalidate_kb(kb_id);
380
381        new_version
382    }
383
384    /// Remove **all** cached entries whose `kb_id` matches, regardless of
385    /// their recorded version.
386    ///
387    /// This is called internally by [`bump_kb_version`](Self::bump_kb_version)
388    /// but can also be called directly (e.g., when a KB is deleted).
389    pub fn invalidate_kb(&self, kb_id: &str) {
390        let mut map = self
391            .entries
392            .write()
393            .expect("versioned_cache: entries write lock poisoned");
394
395        let before = map.len();
396        map.retain(|k, _| k.kb_id != kb_id);
397        let removed = before - map.len();
398
399        self.stats
400            .total_invalidations
401            .fetch_add(removed as u64, Ordering::Relaxed);
402    }
403
404    // ------------------------------------------------------------------
405    // Eviction
406    // ------------------------------------------------------------------
407
408    /// Remove all expired entries from the cache.
409    ///
410    /// Returns the number of entries removed.
411    pub fn evict_expired(&self) -> usize {
412        let now = Instant::now();
413        let mut map = self
414            .entries
415            .write()
416            .expect("versioned_cache: entries write lock poisoned");
417
418        let before = map.len();
419        map.retain(|_, entry| !entry.is_expired(now));
420        let removed = before - map.len();
421
422        self.stats
423            .total_evictions
424            .fetch_add(removed as u64, Ordering::Relaxed);
425        removed
426    }
427
428    // ------------------------------------------------------------------
429    // Introspection
430    // ------------------------------------------------------------------
431
432    /// Current number of entries in the cache (including possibly-expired ones).
433    pub fn entry_count(&self) -> usize {
434        self.entries
435            .read()
436            .expect("versioned_cache: entries read lock poisoned")
437            .len()
438    }
439
440    /// Snapshot of the current statistics counters.
441    pub fn stats(&self) -> CacheStatsSnapshot {
442        self.stats.snapshot()
443    }
444}
445
446impl Default for VersionedInferenceCache {
447    fn default() -> Self {
448        Self::new(DEFAULT_MAX_ENTRIES)
449    }
450}
451
452// ---------------------------------------------------------------------------
453// Tests
454// ---------------------------------------------------------------------------
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use std::thread;
460    use std::time::Duration;
461
462    // ------------------------------------------------------------------
463    // Helpers
464    // ------------------------------------------------------------------
465
466    fn make_key(goal: &str, kb_id: &str, kb_version: u64) -> CacheKey {
467        CacheKey::new(goal, kb_id, kb_version)
468    }
469
470    fn make_entry(bindings: Vec<(&str, &str)>, is_final: bool) -> CacheEntry {
471        let map: HashMap<String, String> = bindings
472            .into_iter()
473            .map(|(k, v)| (k.to_owned(), v.to_owned()))
474            .collect();
475        CacheEntry::new(vec![map], is_final)
476    }
477
478    fn empty_entry() -> CacheEntry {
479        CacheEntry::new(vec![], true)
480    }
481
482    fn expired_entry() -> CacheEntry {
483        CacheEntry::with_ttl(vec![], true, Duration::from_nanos(1))
484    }
485
486    // ------------------------------------------------------------------
487    // 1. Insert and get returns entry
488    // ------------------------------------------------------------------
489
490    #[test]
491    fn test_insert_and_get_returns_entry() {
492        let cache = VersionedInferenceCache::new(100);
493        let key = make_key("parent(X, bob)", "kb1", 1);
494        let entry = make_entry(vec![("X", "alice")], true);
495
496        cache.insert(key.clone(), entry).expect("insert failed");
497        let result = cache.get(&key).expect("expected Some");
498
499        assert_eq!(result.bindings.len(), 1);
500        assert_eq!(
501            result.bindings[0].get("X").map(String::as_str),
502            Some("alice")
503        );
504        assert!(result.is_final);
505    }
506
507    // ------------------------------------------------------------------
508    // 2. Get expired entry returns None
509    // ------------------------------------------------------------------
510
511    #[test]
512    fn test_get_expired_entry_returns_none() {
513        let cache = VersionedInferenceCache::new(100);
514        let key = make_key("ancestor(X, eve)", "kb1", 1);
515        let entry = expired_entry();
516
517        cache.insert(key.clone(), entry).expect("insert failed");
518        // Brief sleep to ensure the 1ns TTL has elapsed.
519        thread::sleep(Duration::from_millis(1));
520
521        let result = cache.get(&key);
522        assert!(result.is_none(), "expected None for expired entry");
523    }
524
525    // ------------------------------------------------------------------
526    // 3. invalidate_kb removes all entries for that KB
527    // ------------------------------------------------------------------
528
529    #[test]
530    fn test_invalidate_kb_removes_all_entries_for_kb() {
531        let cache = VersionedInferenceCache::new(100);
532
533        cache
534            .insert(make_key("g1", "kb_a", 1), empty_entry())
535            .expect("test: should succeed");
536        cache
537            .insert(make_key("g2", "kb_a", 1), empty_entry())
538            .expect("test: should succeed");
539        cache
540            .insert(make_key("g3", "kb_b", 1), empty_entry())
541            .expect("test: should succeed");
542
543        assert_eq!(cache.entry_count(), 3);
544
545        cache.invalidate_kb("kb_a");
546
547        assert_eq!(cache.entry_count(), 1);
548        assert!(cache.get(&make_key("g1", "kb_a", 1)).is_none());
549        assert!(cache.get(&make_key("g2", "kb_a", 1)).is_none());
550        assert!(cache.get(&make_key("g3", "kb_b", 1)).is_some());
551    }
552
553    // ------------------------------------------------------------------
554    // 4. bump_kb_version increments version and invalidates
555    // ------------------------------------------------------------------
556
557    #[test]
558    fn test_bump_kb_version_increments_and_invalidates() {
559        let cache = VersionedInferenceCache::new(100);
560
561        let key_v1 = make_key("fact(x)", "kb_main", 1);
562        cache
563            .insert(key_v1.clone(), empty_entry())
564            .expect("test: should succeed");
565        assert_eq!(cache.entry_count(), 1);
566
567        let new_ver = cache.bump_kb_version("kb_main");
568        assert_eq!(new_ver, 1);
569
570        // All kb_main entries gone.
571        assert_eq!(cache.entry_count(), 0);
572        assert!(cache.get(&key_v1).is_none());
573    }
574
575    // ------------------------------------------------------------------
576    // 5. bump_kb_version starts at 1 from 0
577    // ------------------------------------------------------------------
578
579    #[test]
580    fn test_bump_kb_version_from_zero() {
581        let cache = VersionedInferenceCache::new(100);
582
583        assert_eq!(cache.current_kb_version("new_kb"), 0);
584        let v1 = cache.bump_kb_version("new_kb");
585        assert_eq!(v1, 1);
586        let v2 = cache.bump_kb_version("new_kb");
587        assert_eq!(v2, 2);
588    }
589
590    // ------------------------------------------------------------------
591    // 6. current_kb_version returns 0 for unknown KB
592    // ------------------------------------------------------------------
593
594    #[test]
595    fn test_current_kb_version_returns_zero_for_unknown() {
596        let cache = VersionedInferenceCache::new(100);
597        assert_eq!(cache.current_kb_version("totally_unknown_kb"), 0);
598    }
599
600    // ------------------------------------------------------------------
601    // 7. Capacity limit enforcement
602    // ------------------------------------------------------------------
603
604    #[test]
605    fn test_capacity_limit_enforced() {
606        let cache = VersionedInferenceCache::new(3);
607
608        cache
609            .insert(make_key("g1", "kb", 1), empty_entry())
610            .expect("test: should succeed");
611        cache
612            .insert(make_key("g2", "kb", 1), empty_entry())
613            .expect("test: should succeed");
614        cache
615            .insert(make_key("g3", "kb", 1), empty_entry())
616            .expect("test: should succeed");
617
618        let err = cache
619            .insert(make_key("g4", "kb", 1), empty_entry())
620            .unwrap_err();
621
622        match err {
623            CacheError::CapacityExceeded { current, max } => {
624                assert_eq!(current, 3);
625                assert_eq!(max, 3);
626            }
627            other => panic!("unexpected error: {:?}", other),
628        }
629    }
630
631    // ------------------------------------------------------------------
632    // 8. evict_expired removes stale, keeps fresh
633    // ------------------------------------------------------------------
634
635    #[test]
636    fn test_evict_expired_removes_stale_keeps_fresh() {
637        let cache = VersionedInferenceCache::new(100);
638
639        // Insert 2 entries with instant expiry and 2 fresh ones.
640        cache
641            .insert(make_key("stale1", "kb", 1), expired_entry())
642            .expect("test: should succeed");
643        cache
644            .insert(make_key("stale2", "kb", 1), expired_entry())
645            .expect("test: should succeed");
646        cache
647            .insert(make_key("fresh1", "kb", 1), empty_entry())
648            .expect("test: should succeed");
649        cache
650            .insert(make_key("fresh2", "kb", 1), empty_entry())
651            .expect("test: should succeed");
652
653        thread::sleep(Duration::from_millis(1));
654        let removed = cache.evict_expired();
655
656        assert_eq!(removed, 2);
657        assert_eq!(cache.entry_count(), 2);
658    }
659
660    // ------------------------------------------------------------------
661    // 9. Hit count increments on repeated get
662    // ------------------------------------------------------------------
663
664    #[test]
665    fn test_hit_count_increments_on_repeated_get() {
666        let cache = VersionedInferenceCache::new(100);
667        let key = make_key("rule(Y)", "kb", 1);
668        cache
669            .insert(key.clone(), empty_entry())
670            .expect("test: should succeed");
671
672        let r1 = cache.get(&key).expect("test: should succeed");
673        assert_eq!(r1.hit_count, 1);
674
675        let r2 = cache.get(&key).expect("test: should succeed");
676        assert_eq!(r2.hit_count, 2);
677
678        let r3 = cache.get(&key).expect("test: should succeed");
679        assert_eq!(r3.hit_count, 3);
680    }
681
682    // ------------------------------------------------------------------
683    // 10. Stats: inserts counted
684    // ------------------------------------------------------------------
685
686    #[test]
687    fn test_stats_inserts_counted() {
688        let cache = VersionedInferenceCache::new(100);
689        cache
690            .insert(make_key("g1", "kb", 1), empty_entry())
691            .expect("test: should succeed");
692        cache
693            .insert(make_key("g2", "kb", 1), empty_entry())
694            .expect("test: should succeed");
695
696        assert_eq!(cache.stats().total_inserts, 2);
697    }
698
699    // ------------------------------------------------------------------
700    // 11. Stats: hits and misses counted
701    // ------------------------------------------------------------------
702
703    #[test]
704    fn test_stats_hits_and_misses() {
705        let cache = VersionedInferenceCache::new(100);
706        let key = make_key("p(a)", "kb", 1);
707        cache
708            .insert(key.clone(), empty_entry())
709            .expect("test: should succeed");
710
711        cache.get(&key); // hit
712        cache.get(&make_key("missing", "kb", 1)); // miss
713
714        let snap = cache.stats();
715        assert_eq!(snap.total_hits, 1);
716        assert_eq!(snap.total_misses, 1);
717    }
718
719    // ------------------------------------------------------------------
720    // 12. Stats: invalidations counted
721    // ------------------------------------------------------------------
722
723    #[test]
724    fn test_stats_invalidations_counted() {
725        let cache = VersionedInferenceCache::new(100);
726        cache
727            .insert(make_key("g1", "kb", 1), empty_entry())
728            .expect("test: should succeed");
729        cache
730            .insert(make_key("g2", "kb", 1), empty_entry())
731            .expect("test: should succeed");
732
733        cache.invalidate_kb("kb");
734
735        assert_eq!(cache.stats().total_invalidations, 2);
736    }
737
738    // ------------------------------------------------------------------
739    // 13. Stats: evictions counted
740    // ------------------------------------------------------------------
741
742    #[test]
743    fn test_stats_evictions_counted() {
744        let cache = VersionedInferenceCache::new(100);
745        cache
746            .insert(make_key("old1", "kb", 1), expired_entry())
747            .expect("test: should succeed");
748        cache
749            .insert(make_key("old2", "kb", 1), expired_entry())
750            .expect("test: should succeed");
751        cache
752            .insert(make_key("old3", "kb", 1), expired_entry())
753            .expect("test: should succeed");
754
755        thread::sleep(Duration::from_millis(1));
756        let removed = cache.evict_expired();
757
758        assert_eq!(removed, 3);
759        assert_eq!(cache.stats().total_evictions, 3);
760    }
761
762    // ------------------------------------------------------------------
763    // 14. Same goal + kb_id, different kb_version => distinct keys
764    // ------------------------------------------------------------------
765
766    #[test]
767    fn test_different_kb_versions_are_distinct_keys() {
768        let cache = VersionedInferenceCache::new(100);
769
770        let key_v1 = make_key("same_goal", "kb", 1);
771        let key_v2 = make_key("same_goal", "kb", 2);
772
773        assert_ne!(key_v1, key_v2, "keys with different versions must differ");
774
775        let entry_v1 = make_entry(vec![("X", "alice")], true);
776        let entry_v2 = make_entry(vec![("X", "bob")], true);
777
778        // Insert both independently (bypass version-check by not bumping).
779        let mut entries_map = cache.entries.write().expect("entries write lock");
780        entries_map.insert(key_v1.clone(), entry_v1);
781        entries_map.insert(key_v2.clone(), entry_v2);
782        drop(entries_map);
783
784        assert_eq!(cache.entry_count(), 2);
785
786        let r1 = cache.get(&key_v1).expect("test: should succeed");
787        let r2 = cache.get(&key_v2).expect("test: should succeed");
788
789        assert_eq!(r1.bindings[0].get("X").map(String::as_str), Some("alice"));
790        assert_eq!(r2.bindings[0].get("X").map(String::as_str), Some("bob"));
791    }
792
793    // ------------------------------------------------------------------
794    // 15. entry_count decreases after eviction
795    // ------------------------------------------------------------------
796
797    #[test]
798    fn test_entry_count_decreases_after_eviction() {
799        let cache = VersionedInferenceCache::new(100);
800
801        for i in 0u64..5 {
802            let entry = CacheEntry::with_ttl(vec![], true, Duration::from_nanos(1));
803            let key = CacheKey {
804                goal_hash: i,
805                kb_id: "kb".to_owned(),
806                kb_version: 1,
807            };
808            cache.insert(key, entry).expect("test: should succeed");
809        }
810
811        assert_eq!(cache.entry_count(), 5);
812
813        thread::sleep(Duration::from_millis(1));
814        let evicted = cache.evict_expired();
815
816        assert_eq!(evicted, 5);
817        assert_eq!(cache.entry_count(), 0);
818    }
819
820    // ------------------------------------------------------------------
821    // 16. StaleVersion error when inserting with outdated kb_version
822    // ------------------------------------------------------------------
823
824    #[test]
825    fn test_stale_version_error_on_insert() {
826        let cache = VersionedInferenceCache::new(100);
827
828        // Bump to version 3.
829        cache.bump_kb_version("kb");
830        cache.bump_kb_version("kb");
831        cache.bump_kb_version("kb");
832
833        assert_eq!(cache.current_kb_version("kb"), 3);
834
835        // Attempt to insert with an old version.
836        let stale_key = make_key("goal", "kb", 1);
837        let err = cache.insert(stale_key, empty_entry()).unwrap_err();
838
839        match err {
840            CacheError::StaleVersion {
841                kb_id,
842                cache_version,
843                current_version,
844            } => {
845                assert_eq!(kb_id, "kb");
846                assert_eq!(cache_version, 1);
847                assert_eq!(current_version, 3);
848            }
849            other => panic!("unexpected error: {:?}", other),
850        }
851    }
852
853    // ------------------------------------------------------------------
854    // 17. bump_kb_version invalidates entries across multiple versions
855    // ------------------------------------------------------------------
856
857    #[test]
858    fn test_bump_invalidates_across_multiple_versions() {
859        let cache = VersionedInferenceCache::new(100);
860
861        // Manually insert entries with different versions (bypass stale check).
862        {
863            let mut map = cache.entries.write().expect("write lock");
864            for v in 1u64..=4 {
865                let k = CacheKey {
866                    goal_hash: v,
867                    kb_id: "kb_x".to_owned(),
868                    kb_version: v,
869                };
870                map.insert(k, CacheEntry::new(vec![], true));
871            }
872            // One entry from a different KB — must survive.
873            let other = CacheKey {
874                goal_hash: 99,
875                kb_id: "other_kb".to_owned(),
876                kb_version: 1,
877            };
878            map.insert(other, CacheEntry::new(vec![], true));
879        }
880
881        assert_eq!(cache.entry_count(), 5);
882
883        cache.invalidate_kb("kb_x");
884
885        assert_eq!(cache.entry_count(), 1);
886        assert_eq!(cache.stats().total_invalidations, 4);
887    }
888
889    // ------------------------------------------------------------------
890    // 18. Default cache has max_entries = 10_000
891    // ------------------------------------------------------------------
892
893    #[test]
894    fn test_default_max_entries() {
895        let cache = VersionedInferenceCache::default();
896        // Insert one entry — should succeed even in default config.
897        cache
898            .insert(make_key("test_goal", "default_kb", 0), empty_entry())
899            .expect("test: should succeed");
900        assert_eq!(cache.entry_count(), 1);
901    }
902}