1use std::collections::HashMap;
21use std::sync::{
22 atomic::{AtomicU64, Ordering},
23 RwLock,
24};
25use std::time::{Duration, Instant};
26
27use thiserror::Error;
28
29fn 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#[derive(Debug, Error)]
51pub enum CacheError {
52 #[error("cache capacity exceeded: current={current}, max={max}")]
54 CapacityExceeded {
55 current: usize,
57 max: usize,
59 },
60
61 #[error(
63 "stale version for KB '{kb_id}': cache_version={cache_version}, current_version={current_version}"
64 )]
65 StaleVersion {
66 kb_id: String,
68 cache_version: u64,
70 current_version: u64,
72 },
73}
74
75#[derive(Debug, Clone, Hash, Eq, PartialEq)]
85pub struct CacheKey {
86 pub goal_hash: u64,
88 pub kb_id: String,
90 pub kb_version: u64,
92}
93
94impl CacheKey {
95 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
105const DEFAULT_TTL: Duration = Duration::from_secs(5 * 60);
111
112#[derive(Debug, Clone)]
114pub struct CacheEntry {
115 pub bindings: Vec<HashMap<String, String>>,
117 pub is_final: bool,
119 pub inserted_at: Instant,
121 pub ttl: Duration,
123 pub hit_count: u64,
125}
126
127impl CacheEntry {
128 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 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 pub fn is_expired(&self, now: Instant) -> bool {
152 now.duration_since(self.inserted_at) >= self.ttl
153 }
154}
155
156#[derive(Debug, Default)]
162pub struct CacheStats {
163 pub total_inserts: AtomicU64,
165 pub total_hits: AtomicU64,
167 pub total_misses: AtomicU64,
169 pub total_invalidations: AtomicU64,
171 pub total_evictions: AtomicU64,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct CacheStatsSnapshot {
178 pub total_inserts: u64,
180 pub total_hits: u64,
182 pub total_misses: u64,
184 pub total_invalidations: u64,
186 pub total_evictions: u64,
188}
189
190impl CacheStats {
191 fn new() -> Self {
192 Self::default()
193 }
194
195 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
208const DEFAULT_MAX_ENTRIES: usize = 10_000;
214
215pub 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 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 pub fn insert(&self, key: CacheKey, entry: CacheEntry) -> Result<(), CacheError> {
264 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 let current_len = map.len();
283
284 match map.entry(key) {
285 Entry::Occupied(mut occ) => {
286 *occ.get_mut() = entry;
288 self.stats.total_inserts.fetch_add(1, Ordering::Relaxed);
289 Ok(())
290 }
291 Entry::Vacant(vac) => {
292 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 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 drop(map);
338 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 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 pub fn bump_kb_version(&self, kb_id: &str) -> u64 {
367 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 self.invalidate_kb(kb_id);
380
381 new_version
382 }
383
384 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 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 pub fn entry_count(&self) -> usize {
434 self.entries
435 .read()
436 .expect("versioned_cache: entries read lock poisoned")
437 .len()
438 }
439
440 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#[cfg(test)]
457mod tests {
458 use super::*;
459 use std::thread;
460 use std::time::Duration;
461
462 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 #[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 #[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 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 #[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 #[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 assert_eq!(cache.entry_count(), 0);
572 assert!(cache.get(&key_v1).is_none());
573 }
574
575 #[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 #[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 #[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 #[test]
636 fn test_evict_expired_removes_stale_keeps_fresh() {
637 let cache = VersionedInferenceCache::new(100);
638
639 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 #[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 #[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 #[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); cache.get(&make_key("missing", "kb", 1)); let snap = cache.stats();
715 assert_eq!(snap.total_hits, 1);
716 assert_eq!(snap.total_misses, 1);
717 }
718
719 #[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 #[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 #[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 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 #[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 #[test]
825 fn test_stale_version_error_on_insert() {
826 let cache = VersionedInferenceCache::new(100);
827
828 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 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 #[test]
858 fn test_bump_invalidates_across_multiple_versions() {
859 let cache = VersionedInferenceCache::new(100);
860
861 {
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 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 #[test]
894 fn test_default_max_entries() {
895 let cache = VersionedInferenceCache::default();
896 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}