1const FNV_OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
20const FNV_PRIME: u64 = 1_099_511_628_211;
21
22pub 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#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
54pub struct ProofCacheKey {
55 pub goal_hash: u64,
57 pub kb_version: u64,
59}
60
61impl ProofCacheKey {
62 pub fn new(goal_hash: u64, kb_version: u64) -> Self {
64 Self {
65 goal_hash,
66 kb_version,
67 }
68 }
69
70 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#[derive(Debug, Clone)]
85pub struct CachedProof {
86 pub key: ProofCacheKey,
88 pub proved: bool,
90 pub bindings: Vec<(String, String)>,
92 pub proof_depth: usize,
94 pub cached_at_secs: u64,
96 pub last_accessed_secs: u64,
98 pub access_count: u64,
100}
101
102impl CachedProof {
103 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#[derive(Debug, Clone)]
117pub struct ProofCacheConfig {
118 pub max_entries: usize,
122 pub ttl_secs: u64,
126 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#[derive(Debug, Clone, Default)]
149pub struct ProofCacheStats {
150 pub hits: u64,
152 pub misses: u64,
154 pub evictions: u64,
156 pub invalidations: u64,
158}
159
160impl ProofCacheStats {
161 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#[derive(Debug)]
184pub struct ProofCachingLayer {
185 entries: Vec<CachedProof>,
187 config: ProofCacheConfig,
189 stats: ProofCacheStats,
191}
192
193impl ProofCachingLayer {
194 pub fn new(config: ProofCacheConfig) -> Self {
196 Self {
197 entries: Vec::new(),
198 config,
199 stats: ProofCacheStats::default(),
200 }
201 }
202
203 pub fn lookup(&mut self, key: &ProofCacheKey, now_secs: u64) -> Option<&CachedProof> {
212 let ttl = self.config.ttl_secs;
213
214 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 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 pub fn insert(&mut self, proof: CachedProof) {
242 if let Some(idx) = self.entries.iter().position(|e| e.key == proof.key) {
244 self.entries[idx] = proof;
245 return;
246 }
247
248 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 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 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 pub fn stats(&self) -> &ProofCacheStats {
290 &self.stats
291 }
292
293 pub fn len(&self) -> usize {
295 self.entries.len()
296 }
297
298 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#[cfg(test)]
315mod tests {
316 use super::*;
317
318 const T0: u64 = 1_000_000; fn default_layer() -> ProofCachingLayer {
322 ProofCachingLayer::new(ProofCacheConfig::default())
323 }
324
325 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 #[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 #[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 #[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 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 #[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 let _ = layer.lookup(&key_a, T0);
412
413 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 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 #[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 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 #[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)); 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 let key3 = ProofCacheKey::from_goal("g3", 2);
480 assert!(layer.lookup(&key3, T0).is_some());
481 }
482
483 #[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)); 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 #[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 layer.lookup(&key, T0);
511 layer.lookup(&key, T0);
512 layer.lookup(&key, T0);
513
514 let absent = ProofCacheKey::from_goal("absent", 1);
516 layer.lookup(&absent, T0);
517
518 let rate = layer.stats().hit_rate();
519 assert!((rate - 0.75).abs() < 1e-9, "expected 0.75, got {}", rate);
521 }
522
523 #[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 let entry = layer.lookup(&key, T0).expect("entry present");
537 assert_eq!(entry.access_count, 4);
538 }
539
540 #[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 #[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 #[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 assert_eq!(layer.len(), 2);
574 assert_eq!(layer.stats().invalidations, 0);
575 }
576
577 #[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 #[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 assert!(proof.is_stale(50, 150));
598 assert!(!proof.is_stale(50, 149));
600 assert!(proof.is_stale(50, 9999));
602 }
603
604 #[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 #[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 let removed = layer.evict_stale(T0 + 10);
626 assert_eq!(removed, 0);
627 assert_eq!(layer.len(), 2);
628 }
629
630 #[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 #[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 #[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}