Skip to main content

ipfrs_tensorlogic/
inference_cache.rs

1//! Inference Cache with Invalidation
2//!
3//! Memoization cache for inference results, keyed by
4//! `(goal_term_hash, kb_version) → bindings`.
5//! Supports LRU eviction and monotonic-version-based invalidation.
6
7use std::collections::HashMap;
8use std::time::Instant;
9
10/// FNV-1a hash of a byte slice
11fn fnv1a(data: &[u8]) -> u64 {
12    const FNV_OFFSET: u64 = 14_695_981_039_346_656_037;
13    const FNV_PRIME: u64 = 1_099_511_628_211;
14    let mut hash = FNV_OFFSET;
15    for &byte in data {
16        hash ^= byte as u64;
17        hash = hash.wrapping_mul(FNV_PRIME);
18    }
19    hash
20}
21
22/// Compute the FNV-1a hash for a serialised goal term.
23pub fn hash_goal(goal_repr: &str) -> u64 {
24    fnv1a(goal_repr.as_bytes())
25}
26
27/// Cache key: combination of goal hash and KB version.
28#[derive(Debug, Clone, Hash, Eq, PartialEq)]
29pub struct InferenceCacheKey {
30    /// FNV-1a of goal term serialization
31    pub goal_hash: u64,
32    /// Monotonic KB version counter
33    pub kb_version: u64,
34}
35
36impl InferenceCacheKey {
37    /// Create a new cache key.
38    pub fn new(goal_hash: u64, kb_version: u64) -> Self {
39        Self {
40            goal_hash,
41            kb_version,
42        }
43    }
44}
45
46/// One cached inference result.
47pub struct CachedResult {
48    /// Variable bindings found during inference
49    pub bindings: Vec<HashMap<String, String>>,
50    /// Wall-clock instant at which this result was computed
51    pub computed_at: Instant,
52    /// Maximum proof depth reached
53    pub proof_depth: usize,
54}
55
56/// Summary statistics for the cache.
57#[derive(Debug, Clone)]
58pub struct CacheStats {
59    /// Total cache hits
60    pub hits: u64,
61    /// Total cache misses
62    pub misses: u64,
63    /// Total evictions due to capacity
64    pub evictions: u64,
65    /// Current number of entries
66    pub entries: usize,
67    /// Hit rate in [0.0, 1.0]
68    pub hit_rate: f64,
69}
70
71/// LRU-ordered key list for eviction purposes.
72/// The front is the most recently used; the back is the least recently used.
73struct LruOrder {
74    order: Vec<InferenceCacheKey>,
75}
76
77impl LruOrder {
78    fn new() -> Self {
79        Self { order: Vec::new() }
80    }
81
82    /// Touch a key (move to front). If not present, push to front.
83    fn touch(&mut self, key: &InferenceCacheKey) {
84        if let Some(pos) = self.order.iter().position(|k| k == key) {
85            self.order.remove(pos);
86        }
87        self.order.insert(0, key.clone());
88    }
89
90    /// Remove a key entirely.
91    fn remove(&mut self, key: &InferenceCacheKey) {
92        self.order.retain(|k| k != key);
93    }
94
95    /// Pop the least recently used key.
96    fn pop_lru(&mut self) -> Option<InferenceCacheKey> {
97        self.order.pop()
98    }
99}
100
101/// Memoization cache for inference results.
102/// Keyed by `(goal_term_hash, kb_version) → bindings`.
103pub struct InferenceCache {
104    entries: HashMap<InferenceCacheKey, CachedResult>,
105    lru: LruOrder,
106    max_entries: usize,
107    hit_count: u64,
108    miss_count: u64,
109    eviction_count: u64,
110}
111
112impl InferenceCache {
113    /// Create a new cache with the given capacity (default 1024).
114    pub fn new(max_entries: usize) -> Self {
115        let max_entries = if max_entries == 0 { 1024 } else { max_entries };
116        Self {
117            entries: HashMap::new(),
118            lru: LruOrder::new(),
119            max_entries,
120            hit_count: 0,
121            miss_count: 0,
122            eviction_count: 0,
123        }
124    }
125
126    /// Look up a cached result. Records hit/miss statistics.
127    pub fn get(&mut self, key: &InferenceCacheKey) -> Option<&CachedResult> {
128        if self.entries.contains_key(key) {
129            self.hit_count += 1;
130            self.lru.touch(key);
131            self.entries.get(key)
132        } else {
133            self.miss_count += 1;
134            None
135        }
136    }
137
138    /// Insert a result. Evicts the least recently used entry when at capacity.
139    pub fn insert(&mut self, key: InferenceCacheKey, result: CachedResult) {
140        if self.entries.contains_key(&key) {
141            // Update in place
142            self.entries.insert(key.clone(), result);
143            self.lru.touch(&key);
144            return;
145        }
146
147        // Evict if at capacity
148        while self.entries.len() >= self.max_entries {
149            if let Some(lru_key) = self.lru.pop_lru() {
150                self.entries.remove(&lru_key);
151                self.eviction_count += 1;
152            } else {
153                break;
154            }
155        }
156
157        self.lru.touch(&key);
158        self.entries.insert(key, result);
159    }
160
161    /// Invalidate all entries that were computed for a specific KB version.
162    pub fn invalidate_for_kb_version(&mut self, old_version: u64) {
163        let keys_to_remove: Vec<InferenceCacheKey> = self
164            .entries
165            .keys()
166            .filter(|k| k.kb_version == old_version)
167            .cloned()
168            .collect();
169
170        for key in &keys_to_remove {
171            self.entries.remove(key);
172            self.lru.remove(key);
173        }
174    }
175
176    /// Compute the hit rate as a fraction in [0.0, 1.0].
177    pub fn hit_rate(&self) -> f64 {
178        let total = self.hit_count + self.miss_count;
179        if total == 0 {
180            0.0
181        } else {
182            self.hit_count as f64 / total as f64
183        }
184    }
185
186    /// Return a snapshot of cache statistics.
187    pub fn stats(&self) -> CacheStats {
188        CacheStats {
189            hits: self.hit_count,
190            misses: self.miss_count,
191            evictions: self.eviction_count,
192            entries: self.entries.len(),
193            hit_rate: self.hit_rate(),
194        }
195    }
196
197    /// Number of currently stored entries.
198    pub fn len(&self) -> usize {
199        self.entries.len()
200    }
201
202    /// Whether the cache is empty.
203    pub fn is_empty(&self) -> bool {
204        self.entries.is_empty()
205    }
206}
207
208impl Default for InferenceCache {
209    fn default() -> Self {
210        Self::new(1024)
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    fn make_result(bindings: Vec<HashMap<String, String>>) -> CachedResult {
219        CachedResult {
220            bindings,
221            computed_at: Instant::now(),
222            proof_depth: 1,
223        }
224    }
225
226    #[test]
227    fn test_cache_hit() {
228        let mut cache = InferenceCache::new(1024);
229        let key = InferenceCacheKey::new(42, 1);
230        let mut binding = HashMap::new();
231        binding.insert("X".to_string(), "alice".to_string());
232        cache.insert(key.clone(), make_result(vec![binding.clone()]));
233
234        let result = cache.get(&key);
235        assert!(result.is_some());
236        let r = result.expect("test: should succeed");
237        assert_eq!(r.bindings.len(), 1);
238        assert_eq!(r.bindings[0].get("X").map(|s| s.as_str()), Some("alice"));
239    }
240
241    #[test]
242    fn test_cache_miss() {
243        let mut cache = InferenceCache::new(1024);
244        let key = InferenceCacheKey::new(99, 1);
245
246        let result = cache.get(&key);
247        assert!(result.is_none());
248        assert_eq!(cache.stats().misses, 1);
249        assert_eq!(cache.stats().hits, 0);
250    }
251
252    #[test]
253    fn test_cache_invalidation() {
254        let mut cache = InferenceCache::new(1024);
255
256        // Insert entries for version 1
257        let key1 = InferenceCacheKey::new(1, 1);
258        let key2 = InferenceCacheKey::new(2, 1);
259        // Also insert an entry for version 2 that should survive
260        let key3 = InferenceCacheKey::new(3, 2);
261
262        cache.insert(key1.clone(), make_result(vec![]));
263        cache.insert(key2.clone(), make_result(vec![]));
264        cache.insert(key3.clone(), make_result(vec![]));
265
266        assert_eq!(cache.len(), 3);
267
268        // Invalidate version 1
269        cache.invalidate_for_kb_version(1);
270
271        assert_eq!(cache.len(), 1);
272        assert!(cache.get(&key1).is_none());
273        assert!(cache.get(&key2).is_none());
274        // Version 2 entry remains, but get() will record a hit now
275        let stats_before = cache.stats();
276        let _ = cache.get(&key3);
277        let stats_after = cache.stats();
278        assert_eq!(stats_after.hits, stats_before.hits + 1);
279    }
280
281    #[test]
282    fn test_cache_hit_rate() {
283        let mut cache = InferenceCache::new(1024);
284        let key = InferenceCacheKey::new(7, 1);
285        cache.insert(key.clone(), make_result(vec![]));
286
287        // 3 hits
288        cache.get(&key);
289        cache.get(&key);
290        cache.get(&key);
291
292        // 1 miss
293        let missing = InferenceCacheKey::new(999, 1);
294        cache.get(&missing);
295
296        let rate = cache.hit_rate();
297        // 3 hits out of 4 total = 0.75
298        assert!((rate - 0.75).abs() < 1e-9, "expected 0.75 but got {}", rate);
299    }
300
301    #[test]
302    fn test_kb_version_bumps_on_rule_add() {
303        use crate::ir::{Predicate, Rule, Term};
304        use crate::storage::TensorLogicStore;
305        use ipfrs_storage::{BlockStoreConfig, SledBlockStore};
306        use std::sync::Arc;
307
308        let path = std::env::temp_dir().join("ipfrs-test-kb-version-bump");
309        let _ = std::fs::remove_dir_all(&path);
310        let config = BlockStoreConfig {
311            path,
312            cache_size: 32 * 1024 * 1024,
313        };
314        let store = Arc::new(SledBlockStore::new(config).expect("sled store"));
315        let tl_store = TensorLogicStore::new(store).expect("tensorlogic store");
316
317        let version_before = tl_store.kb_version();
318
319        let rule = Rule::new(
320            Predicate::new(
321                "ancestor".to_string(),
322                vec![Term::Var("X".to_string()), Term::Var("Y".to_string())],
323            ),
324            vec![Predicate::new(
325                "parent".to_string(),
326                vec![Term::Var("X".to_string()), Term::Var("Y".to_string())],
327            )],
328        );
329
330        tl_store.add_rule(rule).expect("add_rule");
331
332        let version_after = tl_store.kb_version();
333        assert!(
334            version_after > version_before,
335            "kb_version should increment after add_rule: before={}, after={}",
336            version_before,
337            version_after,
338        );
339    }
340}