Skip to main content

ipfrs_tensorlogic/
proof_cache.rs

1//! Proof Caching Layer
2//!
3//! Caches proof results keyed by `(goal_hash, kb_version)` to avoid redundant
4//! inference. Supports LFU-style eviction (lowest access_count first) and
5//! TTL-based expiry.
6//!
7//! # Design
8//!
9//! - Linear scan over a `Vec<CachedProof>` (suitable for small caches ≤ 256).
10//! - LFU eviction: when at capacity, the entry with the lowest `access_count`
11//!   is removed to make room for a new entry.
12//! - TTL: entries are considered stale if `now_secs - cached_at_secs >= ttl_secs`.
13//! - Optional invalidation on KB version change via `invalidate_on_kb_change`.
14
15// ──────────────────────────────────────────────────────────────────────────────
16// FNV-1a hash helper
17// ──────────────────────────────────────────────────────────────────────────────
18
19const FNV_OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
20const FNV_PRIME: u64 = 1_099_511_628_211;
21
22/// Compute the FNV-1a hash of a string.
23///
24/// This is exposed as `pub` so that callers can build [`ProofCacheKey`] values
25/// without importing a separate hashing crate.
26///
27/// # Examples
28///
29/// ```
30/// use ipfrs_tensorlogic::proof_cache::fnv1a_hash;
31/// let h = fnv1a_hash("parent(alice, bob)");
32/// assert_ne!(h, 0);
33/// // Deterministic across calls
34/// assert_eq!(h, fnv1a_hash("parent(alice, bob)"));
35/// ```
36pub fn fnv1a_hash(s: &str) -> u64 {
37    let mut hash = FNV_OFFSET_BASIS;
38    for byte in s.bytes() {
39        hash ^= u64::from(byte);
40        hash = hash.wrapping_mul(FNV_PRIME);
41    }
42    hash
43}
44
45// ──────────────────────────────────────────────────────────────────────────────
46// ProofCacheKey
47// ──────────────────────────────────────────────────────────────────────────────
48
49/// Composite key for a cached proof: goal identity × KB version.
50///
51/// Using the FNV-1a hash of the goal string avoids storing arbitrarily long
52/// goal representations in the cache.
53#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
54pub struct ProofCacheKey {
55    /// FNV-1a hash of the serialised goal string.
56    pub goal_hash: u64,
57    /// Monotonic version counter of the knowledge base at proof time.
58    pub kb_version: u64,
59}
60
61impl ProofCacheKey {
62    /// Construct a key from already-computed parts.
63    pub fn new(goal_hash: u64, kb_version: u64) -> Self {
64        Self {
65            goal_hash,
66            kb_version,
67        }
68    }
69
70    /// Convenience constructor: hash the goal string on your behalf.
71    pub fn from_goal(goal: &str, kb_version: u64) -> Self {
72        Self {
73            goal_hash: fnv1a_hash(goal),
74            kb_version,
75        }
76    }
77}
78
79// ──────────────────────────────────────────────────────────────────────────────
80// CachedProof
81// ──────────────────────────────────────────────────────────────────────────────
82
83/// A single cached proof result together with its bookkeeping metadata.
84#[derive(Debug, Clone)]
85pub struct CachedProof {
86    /// The key under which this proof is stored.
87    pub key: ProofCacheKey,
88    /// Whether the proof succeeded.
89    pub proved: bool,
90    /// Variable→value binding pairs produced by the proof.
91    pub bindings: Vec<(String, String)>,
92    /// Maximum inference depth reached during the proof.
93    pub proof_depth: usize,
94    /// Unix timestamp (seconds) at which the proof was cached.
95    pub cached_at_secs: u64,
96    /// Unix timestamp (seconds) of the most recent access.
97    pub last_accessed_secs: u64,
98    /// Total number of times this entry has been accessed via [`ProofCachingLayer::lookup`].
99    pub access_count: u64,
100}
101
102impl CachedProof {
103    /// Returns `true` if the entry has lived past its TTL.
104    ///
105    /// An entry is stale when `now_secs - cached_at_secs >= ttl_secs`.
106    pub fn is_stale(&self, ttl_secs: u64, now_secs: u64) -> bool {
107        now_secs.saturating_sub(self.cached_at_secs) >= ttl_secs
108    }
109}
110
111// ──────────────────────────────────────────────────────────────────────────────
112// ProofCacheConfig
113// ──────────────────────────────────────────────────────────────────────────────
114
115/// Configuration for [`ProofCachingLayer`].
116#[derive(Debug, Clone)]
117pub struct ProofCacheConfig {
118    /// Maximum number of entries held in the cache at any time.
119    ///
120    /// Defaults to 256.
121    pub max_entries: usize,
122    /// Time-to-live in seconds for cached entries.
123    ///
124    /// Defaults to 300 (5 minutes).
125    pub ttl_secs: u64,
126    /// When `true`, all entries whose `key.kb_version` matches the supplied
127    /// `old_version` are removed on [`ProofCachingLayer::invalidate_kb_version`].
128    ///
129    /// Defaults to `true`.
130    pub invalidate_on_kb_change: bool,
131}
132
133impl Default for ProofCacheConfig {
134    fn default() -> Self {
135        Self {
136            max_entries: 256,
137            ttl_secs: 300,
138            invalidate_on_kb_change: true,
139        }
140    }
141}
142
143// ──────────────────────────────────────────────────────────────────────────────
144// ProofCacheStats
145// ──────────────────────────────────────────────────────────────────────────────
146
147/// Cumulative statistics for a [`ProofCachingLayer`].
148#[derive(Debug, Clone, Default)]
149pub struct ProofCacheStats {
150    /// Total successful lookups (stale entries do **not** count as hits).
151    pub hits: u64,
152    /// Total failed lookups (no entry or stale entry).
153    pub misses: u64,
154    /// Total entries removed by LFU eviction to make room for new entries.
155    pub evictions: u64,
156    /// Total entries removed by explicit KB-version invalidation.
157    pub invalidations: u64,
158}
159
160impl ProofCacheStats {
161    /// Fraction of lookups that resulted in a cache hit, in `[0.0, 1.0]`.
162    ///
163    /// Returns `0.0` when no lookups have been performed.
164    pub fn hit_rate(&self) -> f64 {
165        let total = self.hits + self.misses;
166        if total == 0 {
167            0.0
168        } else {
169            self.hits as f64 / total as f64
170        }
171    }
172}
173
174// ──────────────────────────────────────────────────────────────────────────────
175// ProofCachingLayer
176// ──────────────────────────────────────────────────────────────────────────────
177
178/// An LFU-evicting, TTL-expiring cache for proof results.
179///
180/// The internal store is a plain `Vec` intentionally: the default capacity is
181/// 256 entries so linear scan overhead is negligible, and it avoids the
182/// overhead of a hash-map for this small working set.
183#[derive(Debug)]
184pub struct ProofCachingLayer {
185    /// All currently cached proofs.
186    entries: Vec<CachedProof>,
187    /// Cache behaviour configuration.
188    config: ProofCacheConfig,
189    /// Cumulative statistics.
190    stats: ProofCacheStats,
191}
192
193impl ProofCachingLayer {
194    /// Create a new caching layer with the supplied configuration.
195    pub fn new(config: ProofCacheConfig) -> Self {
196        Self {
197            entries: Vec::new(),
198            config,
199            stats: ProofCacheStats::default(),
200        }
201    }
202
203    /// Look up a proof by key.
204    ///
205    /// - Stale entries (TTL exceeded) are skipped and treated as misses.
206    /// - On a hit, `access_count` and `last_accessed_secs` of the entry are
207    ///   updated in place, and `stats.hits` is incremented.
208    /// - On a miss, `stats.misses` is incremented.
209    ///
210    /// Returns a shared reference into `self.entries` on success.
211    pub fn lookup(&mut self, key: &ProofCacheKey, now_secs: u64) -> Option<&CachedProof> {
212        let ttl = self.config.ttl_secs;
213
214        // Find a non-stale entry that matches the key.
215        let pos = self
216            .entries
217            .iter()
218            .position(|e| &e.key == key && !e.is_stale(ttl, now_secs));
219
220        match pos {
221            Some(idx) => {
222                // Update bookkeeping fields.
223                self.entries[idx].access_count += 1;
224                self.entries[idx].last_accessed_secs = now_secs;
225                self.stats.hits += 1;
226                Some(&self.entries[idx])
227            }
228            None => {
229                self.stats.misses += 1;
230                None
231            }
232        }
233    }
234
235    /// Insert a proof into the cache.
236    ///
237    /// - If an entry with the same key already exists it is replaced.
238    /// - If the cache is at capacity, the entry with the **lowest**
239    ///   `access_count` is evicted first (LFU policy), and
240    ///   `stats.evictions` is incremented.
241    pub fn insert(&mut self, proof: CachedProof) {
242        // Replace existing entry with the same key.
243        if let Some(idx) = self.entries.iter().position(|e| e.key == proof.key) {
244            self.entries[idx] = proof;
245            return;
246        }
247
248        // Evict the least-frequently-used entry when at capacity.
249        if self.entries.len() >= self.config.max_entries {
250            if let Some(lfu_idx) = self
251                .entries
252                .iter()
253                .enumerate()
254                .min_by_key(|(_, e)| e.access_count)
255                .map(|(i, _)| i)
256            {
257                self.entries.swap_remove(lfu_idx);
258                self.stats.evictions += 1;
259            }
260        }
261
262        self.entries.push(proof);
263    }
264
265    /// Remove all entries whose `key.kb_version == old_version` (when
266    /// `invalidate_on_kb_change` is `true`).
267    ///
268    /// `stats.invalidations` is incremented by the number of entries removed.
269    pub fn invalidate_kb_version(&mut self, old_version: u64) {
270        if !self.config.invalidate_on_kb_change {
271            return;
272        }
273
274        let before = self.entries.len();
275        self.entries.retain(|e| e.key.kb_version != old_version);
276        let removed = before - self.entries.len();
277        self.stats.invalidations += removed as u64;
278    }
279
280    /// Remove all stale entries and return the number removed.
281    pub fn evict_stale(&mut self, now_secs: u64) -> usize {
282        let ttl = self.config.ttl_secs;
283        let before = self.entries.len();
284        self.entries.retain(|e| !e.is_stale(ttl, now_secs));
285        before - self.entries.len()
286    }
287
288    /// Return a shared reference to the current statistics.
289    pub fn stats(&self) -> &ProofCacheStats {
290        &self.stats
291    }
292
293    /// Number of entries currently in the cache.
294    pub fn len(&self) -> usize {
295        self.entries.len()
296    }
297
298    /// `true` if the cache holds no entries.
299    pub fn is_empty(&self) -> bool {
300        self.entries.is_empty()
301    }
302}
303
304impl Default for ProofCachingLayer {
305    fn default() -> Self {
306        Self::new(ProofCacheConfig::default())
307    }
308}
309
310// ──────────────────────────────────────────────────────────────────────────────
311// Tests
312// ──────────────────────────────────────────────────────────────────────────────
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    // ── tiny clock shim ──────────────────────────────────────────────────────
319    const T0: u64 = 1_000_000; // arbitrary epoch offset
320
321    fn default_layer() -> ProofCachingLayer {
322        ProofCachingLayer::new(ProofCacheConfig::default())
323    }
324
325    /// Build a minimal [`CachedProof`] for tests.
326    fn make_proof(
327        key: ProofCacheKey,
328        proved: bool,
329        bindings: Vec<(String, String)>,
330        now_secs: u64,
331    ) -> CachedProof {
332        CachedProof {
333            key,
334            proved,
335            bindings,
336            proof_depth: 1,
337            cached_at_secs: now_secs,
338            last_accessed_secs: now_secs,
339            access_count: 0,
340        }
341    }
342
343    fn proof_at(goal: &str, kb_version: u64, proved: bool, now: u64) -> CachedProof {
344        let key = ProofCacheKey::from_goal(goal, kb_version);
345        make_proof(key, proved, vec![], now)
346    }
347
348    // ── 1. lookup miss ───────────────────────────────────────────────────────
349    #[test]
350    fn test_lookup_miss_returns_none() {
351        let mut layer = default_layer();
352        let key = ProofCacheKey::from_goal("missing_goal", 1);
353        let result = layer.lookup(&key, T0);
354        assert!(result.is_none());
355        assert_eq!(layer.stats().misses, 1);
356        assert_eq!(layer.stats().hits, 0);
357    }
358
359    // ── 2. insert then lookup hit ────────────────────────────────────────────
360    #[test]
361    fn test_insert_then_lookup_hit() {
362        let mut layer = default_layer();
363        let proof = proof_at("parent(a, b)", 1, true, T0);
364        let key = proof.key;
365        layer.insert(proof);
366
367        let result = layer.lookup(&key, T0);
368        assert!(result.is_some());
369        assert_eq!(layer.stats().hits, 1);
370        assert_eq!(layer.stats().misses, 0);
371    }
372
373    // ── 3. stale entry is skipped (treated as miss) ──────────────────────────
374    #[test]
375    fn test_stale_entry_skipped() {
376        let config = ProofCacheConfig {
377            ttl_secs: 60,
378            ..Default::default()
379        };
380        let mut layer = ProofCachingLayer::new(config);
381        let proof = proof_at("ancestor(a, c)", 1, true, T0);
382        let key = proof.key;
383        layer.insert(proof);
384
385        // Advance time past TTL
386        let future = T0 + 61;
387        let result = layer.lookup(&key, future);
388        assert!(result.is_none(), "stale entry should not be returned");
389        assert_eq!(layer.stats().misses, 1);
390        assert_eq!(layer.stats().hits, 0);
391    }
392
393    // ── 4. LFU eviction ──────────────────────────────────────────────────────
394    #[test]
395    fn test_lfu_eviction() {
396        let config = ProofCacheConfig {
397            max_entries: 2,
398            ..Default::default()
399        };
400        let mut layer = ProofCachingLayer::new(config);
401
402        let proof_a = proof_at("goal_a", 1, true, T0);
403        let proof_b = proof_at("goal_b", 1, true, T0);
404        let key_a = proof_a.key;
405        let key_b = proof_b.key;
406
407        layer.insert(proof_a);
408        layer.insert(proof_b);
409
410        // Access goal_a once so it has access_count = 1; goal_b stays at 0.
411        let _ = layer.lookup(&key_a, T0);
412
413        // Inserting a third entry should evict goal_b (access_count = 0).
414        let proof_c = proof_at("goal_c", 1, true, T0);
415        let key_c = proof_c.key;
416        layer.insert(proof_c);
417
418        assert_eq!(layer.stats().evictions, 1);
419        assert_eq!(layer.len(), 2);
420
421        // goal_b should be gone, goal_a and goal_c should remain.
422        assert!(layer.lookup(&key_b, T0).is_none());
423        assert!(layer.lookup(&key_a, T0).is_some());
424        assert!(layer.lookup(&key_c, T0).is_some());
425    }
426
427    // ── 5. replace same key ──────────────────────────────────────────────────
428    #[test]
429    fn test_replace_same_key() {
430        let mut layer = default_layer();
431        let key = ProofCacheKey::from_goal("goal_replace", 1);
432
433        let proof_v1 = CachedProof {
434            key,
435            proved: false,
436            bindings: vec![],
437            proof_depth: 1,
438            cached_at_secs: T0,
439            last_accessed_secs: T0,
440            access_count: 0,
441        };
442        layer.insert(proof_v1);
443
444        let proof_v2 = CachedProof {
445            key,
446            proved: true,
447            bindings: vec![("X".to_string(), "alice".to_string())],
448            proof_depth: 3,
449            cached_at_secs: T0 + 1,
450            last_accessed_secs: T0 + 1,
451            access_count: 0,
452        };
453        layer.insert(proof_v2);
454
455        // Should still have only one entry (replaced, not duplicated).
456        assert_eq!(layer.len(), 1);
457        assert_eq!(layer.stats().evictions, 0);
458
459        let result = layer.lookup(&key, T0 + 1).expect("entry should exist");
460        assert!(result.proved);
461        assert_eq!(result.bindings.len(), 1);
462    }
463
464    // ── 6. invalidate_kb_version removes correct entries ────────────────────
465    #[test]
466    fn test_invalidate_kb_version_removes_correct_entries() {
467        let mut layer = default_layer();
468
469        layer.insert(proof_at("g1", 1, true, T0));
470        layer.insert(proof_at("g2", 1, true, T0));
471        layer.insert(proof_at("g3", 2, true, T0)); // different version
472
473        layer.invalidate_kb_version(1);
474
475        assert_eq!(layer.len(), 1, "only version-2 entry should remain");
476        assert_eq!(layer.stats().invalidations, 2);
477
478        // The version-2 entry must still be accessible.
479        let key3 = ProofCacheKey::from_goal("g3", 2);
480        assert!(layer.lookup(&key3, T0).is_some());
481    }
482
483    // ── 7. evict_stale count ─────────────────────────────────────────────────
484    #[test]
485    fn test_evict_stale_count() {
486        let config = ProofCacheConfig {
487            ttl_secs: 10,
488            ..Default::default()
489        };
490        let mut layer = ProofCachingLayer::new(config);
491
492        layer.insert(proof_at("gs1", 1, true, T0));
493        layer.insert(proof_at("gs2", 1, true, T0));
494        layer.insert(proof_at("gs3", 1, true, T0 + 5)); // not yet stale at T0+11
495
496        let removed = layer.evict_stale(T0 + 11);
497        assert_eq!(removed, 2, "two entries should be stale and removed");
498        assert_eq!(layer.len(), 1);
499    }
500
501    // ── 8. hit_rate computation ──────────────────────────────────────────────
502    #[test]
503    fn test_hit_rate() {
504        let mut layer = default_layer();
505        let proof = proof_at("hr_goal", 1, true, T0);
506        let key = proof.key;
507        layer.insert(proof);
508
509        // 3 hits
510        layer.lookup(&key, T0);
511        layer.lookup(&key, T0);
512        layer.lookup(&key, T0);
513
514        // 1 miss
515        let absent = ProofCacheKey::from_goal("absent", 1);
516        layer.lookup(&absent, T0);
517
518        let rate = layer.stats().hit_rate();
519        // 3/(3+1) = 0.75
520        assert!((rate - 0.75).abs() < 1e-9, "expected 0.75, got {}", rate);
521    }
522
523    // ── 9. access_count increments on each lookup hit ───────────────────────
524    #[test]
525    fn test_access_count_increments() {
526        let mut layer = default_layer();
527        let proof = proof_at("access_goal", 1, true, T0);
528        let key = proof.key;
529        layer.insert(proof);
530
531        layer.lookup(&key, T0);
532        layer.lookup(&key, T0);
533        layer.lookup(&key, T0);
534
535        // Inspect the entry directly via a final lookup.
536        let entry = layer.lookup(&key, T0).expect("entry present");
537        assert_eq!(entry.access_count, 4);
538    }
539
540    // ── 10. fnv1a_hash is deterministic ─────────────────────────────────────
541    #[test]
542    fn test_fnv1a_hash_deterministic() {
543        let s = "parent(alice, bob)";
544        let h1 = fnv1a_hash(s);
545        let h2 = fnv1a_hash(s);
546        assert_eq!(h1, h2, "hash must be deterministic");
547        assert_ne!(h1, 0, "hash should not be zero for non-empty input");
548    }
549
550    // ── 11. fnv1a_hash different strings differ ──────────────────────────────
551    #[test]
552    fn test_fnv1a_hash_different_strings_differ() {
553        let h1 = fnv1a_hash("ancestor(a, b)");
554        let h2 = fnv1a_hash("ancestor(b, a)");
555        assert_ne!(h1, h2, "hash of different strings should differ");
556    }
557
558    // ── 12. invalidate_on_kb_change=false skips invalidation ────────────────
559    #[test]
560    fn test_invalidate_on_kb_change_false_skips() {
561        let config = ProofCacheConfig {
562            invalidate_on_kb_change: false,
563            ..Default::default()
564        };
565        let mut layer = ProofCachingLayer::new(config);
566
567        layer.insert(proof_at("g1", 1, true, T0));
568        layer.insert(proof_at("g2", 1, true, T0));
569
570        layer.invalidate_kb_version(1);
571
572        // Nothing should have been removed.
573        assert_eq!(layer.len(), 2);
574        assert_eq!(layer.stats().invalidations, 0);
575    }
576
577    // ── 13. empty cache has zero hit_rate ────────────────────────────────────
578    #[test]
579    fn test_empty_cache_hit_rate_is_zero() {
580        let layer = default_layer();
581        assert_eq!(layer.stats().hit_rate(), 0.0);
582    }
583
584    // ── 14. is_stale boundary conditions ────────────────────────────────────
585    #[test]
586    fn test_is_stale_boundary() {
587        let proof = CachedProof {
588            key: ProofCacheKey::new(1, 1),
589            proved: true,
590            bindings: vec![],
591            proof_depth: 0,
592            cached_at_secs: 100,
593            last_accessed_secs: 100,
594            access_count: 0,
595        };
596        // Exactly at TTL boundary: stale
597        assert!(proof.is_stale(50, 150));
598        // One second before: not stale
599        assert!(!proof.is_stale(50, 149));
600        // Far past TTL: stale
601        assert!(proof.is_stale(50, 9999));
602    }
603
604    // ── 15. evict_stale on empty cache returns 0 ────────────────────────────
605    #[test]
606    fn test_evict_stale_empty_cache() {
607        let mut layer = default_layer();
608        let removed = layer.evict_stale(T0 + 10_000);
609        assert_eq!(removed, 0);
610    }
611
612    // ── 16. fresh entries survive evict_stale ───────────────────────────────
613    #[test]
614    fn test_evict_stale_fresh_entries_survive() {
615        let config = ProofCacheConfig {
616            ttl_secs: 300,
617            ..Default::default()
618        };
619        let mut layer = ProofCachingLayer::new(config);
620
621        layer.insert(proof_at("fresh1", 1, true, T0));
622        layer.insert(proof_at("fresh2", 1, true, T0));
623
624        // Only 10 seconds have passed — both entries are still fresh.
625        let removed = layer.evict_stale(T0 + 10);
626        assert_eq!(removed, 0);
627        assert_eq!(layer.len(), 2);
628    }
629
630    // ── 17. last_accessed_secs updated on hit ───────────────────────────────
631    #[test]
632    fn test_last_accessed_secs_updated_on_hit() {
633        let mut layer = default_layer();
634        let proof = proof_at("la_goal", 1, true, T0);
635        let key = proof.key;
636        layer.insert(proof);
637
638        let later = T0 + 42;
639        {
640            let entry = layer.lookup(&key, later).expect("entry present");
641            assert_eq!(entry.last_accessed_secs, later);
642        }
643    }
644
645    // ── 18. insert does not evict when below capacity ────────────────────────
646    #[test]
647    fn test_no_eviction_below_capacity() {
648        let config = ProofCacheConfig {
649            max_entries: 10,
650            ..Default::default()
651        };
652        let mut layer = ProofCachingLayer::new(config);
653
654        for i in 0..10u64 {
655            layer.insert(proof_at(&format!("goal_{i}"), 1, true, T0));
656        }
657        assert_eq!(layer.stats().evictions, 0);
658        assert_eq!(layer.len(), 10);
659    }
660
661    // ── 19. bindings preserved through insert/lookup cycle ──────────────────
662    #[test]
663    fn test_bindings_preserved() {
664        let mut layer = default_layer();
665        let key = ProofCacheKey::from_goal("bound_goal", 1);
666        let proof = CachedProof {
667            key,
668            proved: true,
669            bindings: vec![
670                ("X".to_string(), "alice".to_string()),
671                ("Y".to_string(), "bob".to_string()),
672            ],
673            proof_depth: 2,
674            cached_at_secs: T0,
675            last_accessed_secs: T0,
676            access_count: 0,
677        };
678        layer.insert(proof);
679
680        let entry = layer.lookup(&key, T0).expect("entry present");
681        assert_eq!(entry.bindings.len(), 2);
682        assert_eq!(entry.bindings[0], ("X".to_string(), "alice".to_string()));
683        assert_eq!(entry.bindings[1], ("Y".to_string(), "bob".to_string()));
684    }
685}