Skip to main content

oxirs_shacl/cache/
validation_cache.rs

1//! Constraint satisfaction cache for SHACL validation
2//!
3//! Caches per-node validation results and supports:
4//! - TTL-based expiry
5//! - Targeted invalidation by focus node or accessed triple
6//! - LRU-style eviction when the cache is full
7//! - Thread-safe access via `Arc<Mutex<…>>`
8
9use std::collections::{HashMap, HashSet};
10use std::hash::{Hash, Hasher};
11use std::sync::{Arc, Mutex};
12use std::time::{Duration, Instant};
13
14/// A triple expressed as `"<s> <p> <o>"` (the string form used as a key).
15pub type TripleKey = String;
16
17/// Cached validation result for a single focus node against a single shape.
18#[derive(Debug, Clone)]
19pub struct CachedValidationResult {
20    /// The focus node that was validated
21    pub focus_node: String,
22    /// The shape ID the node was validated against
23    pub shape_id: String,
24    /// Whether the node is valid according to the shape
25    pub is_valid: bool,
26    /// Violation messages produced (empty when valid)
27    pub violation_messages: Vec<String>,
28    /// Timestamp at which this entry was cached
29    pub cached_at: Instant,
30    /// How long this entry remains valid
31    pub ttl: Duration,
32    /// The set of triple keys accessed while computing this result.
33    ///
34    /// Used to invalidate the entry when any of these triples changes.
35    pub accessed_triples: HashSet<TripleKey>,
36}
37
38impl CachedValidationResult {
39    /// Create a new entry.
40    pub fn new(
41        focus_node: impl Into<String>,
42        shape_id: impl Into<String>,
43        is_valid: bool,
44        violation_messages: Vec<String>,
45        ttl: Duration,
46    ) -> Self {
47        Self {
48            focus_node: focus_node.into(),
49            shape_id: shape_id.into(),
50            is_valid,
51            violation_messages,
52            cached_at: Instant::now(),
53            ttl,
54            accessed_triples: HashSet::new(),
55        }
56    }
57
58    /// Add a triple key to the dependency set.
59    pub fn add_accessed_triple(&mut self, triple: impl Into<TripleKey>) {
60        self.accessed_triples.insert(triple.into());
61    }
62
63    /// Returns `true` once this entry has reached or exceeded its TTL.
64    ///
65    /// Uses `>=` so an entry created with a zero TTL is immediately stale
66    /// regardless of monotonic-clock granularity: an `elapsed()` of exactly
67    /// zero (possible right after creation on a coarse/fast clock) must still
68    /// count as expired, matching [`remaining_ttl`](Self::remaining_ttl).
69    pub fn is_stale(&self) -> bool {
70        self.cached_at.elapsed() >= self.ttl
71    }
72
73    /// Remaining lifetime of this entry, or `Duration::ZERO` if already stale.
74    pub fn remaining_ttl(&self) -> Duration {
75        let elapsed = self.cached_at.elapsed();
76        self.ttl.checked_sub(elapsed).unwrap_or(Duration::ZERO)
77    }
78}
79
80// ---------------------------------------------------------------------------
81// Cache key
82// ---------------------------------------------------------------------------
83
84/// Composite cache key uniquely identifying a (focus_node, shape, shape_hash) triple.
85#[derive(Debug, Clone, PartialEq, Eq, Hash)]
86pub struct ValidationCacheKey {
87    /// The focus node IRI or blank node identifier
88    pub focus_node: String,
89    /// The shape ID (IRI or generated UUID)
90    pub shape_id: String,
91    /// A content hash of the shape definition.
92    ///
93    /// When the shape changes, the hash changes and old entries become
94    /// permanently unreachable (they will eventually be evicted as stale).
95    pub shape_hash: u64,
96}
97
98impl ValidationCacheKey {
99    /// Create a new cache key.
100    pub fn new(
101        focus_node: impl Into<String>,
102        shape_id: impl Into<String>,
103        shape_hash: u64,
104    ) -> Self {
105        Self {
106            focus_node: focus_node.into(),
107            shape_id: shape_id.into(),
108            shape_hash,
109        }
110    }
111
112    /// Compute a simple shape hash from any `Hash`-able shape representation.
113    pub fn hash_shape<T: Hash>(shape: &T) -> u64 {
114        use std::collections::hash_map::DefaultHasher;
115        let mut hasher = DefaultHasher::new();
116        shape.hash(&mut hasher);
117        hasher.finish()
118    }
119}
120
121// ---------------------------------------------------------------------------
122// Internal mutable state
123// ---------------------------------------------------------------------------
124
125struct CacheInner {
126    entries: HashMap<ValidationCacheKey, CachedValidationResult>,
127    /// Ordered insertion history for approximate LRU eviction.
128    ///
129    /// The `usize` is a monotonically increasing sequence number.
130    access_order: HashMap<ValidationCacheKey, usize>,
131    /// Monotonically increasing counter incremented on every cache write.
132    sequence: usize,
133    hit_count: u64,
134    miss_count: u64,
135}
136
137impl CacheInner {
138    fn new() -> Self {
139        Self {
140            entries: HashMap::new(),
141            access_order: HashMap::new(),
142            sequence: 0,
143            hit_count: 0,
144            miss_count: 0,
145        }
146    }
147
148    fn record_access(&mut self, key: &ValidationCacheKey) {
149        self.sequence += 1;
150        self.access_order.insert(key.clone(), self.sequence);
151    }
152
153    /// Evict the least-recently-used entry to make room.
154    fn evict_lru(&mut self) {
155        if let Some((lru_key, _)) = self
156            .access_order
157            .iter()
158            .min_by_key(|(_, &seq)| seq)
159            .map(|(k, v)| (k.clone(), *v))
160        {
161            self.entries.remove(&lru_key);
162            self.access_order.remove(&lru_key);
163        }
164    }
165}
166
167// ---------------------------------------------------------------------------
168// Public API
169// ---------------------------------------------------------------------------
170
171/// Thread-safe, TTL-aware validation result cache.
172///
173/// ## Usage
174///
175/// ```rust
176/// use oxirs_shacl::cache::validation_cache::{ValidationCache, ValidationCacheKey, CachedValidationResult};
177/// use std::time::Duration;
178///
179/// let cache = ValidationCache::new(1000, Duration::from_secs(300));
180/// let key = ValidationCacheKey::new("http://ex/Alice", "http://ex/PersonShape", 42);
181/// let entry = CachedValidationResult::new(
182///     "http://ex/Alice",
183///     "http://ex/PersonShape",
184///     true,
185///     vec![],
186///     Duration::from_secs(300),
187/// );
188/// cache.put(key.clone(), entry);
189/// assert!(cache.get(&key).is_some());
190/// ```
191#[derive(Clone)]
192pub struct ValidationCache {
193    inner: Arc<Mutex<CacheInner>>,
194    max_entries: usize,
195    default_ttl: Duration,
196}
197
198impl ValidationCache {
199    /// Create a new cache.
200    ///
201    /// * `max_entries` — maximum number of entries before LRU eviction kicks in
202    /// * `ttl` — default time-to-live for new entries
203    pub fn new(max_entries: usize, ttl: Duration) -> Self {
204        Self {
205            inner: Arc::new(Mutex::new(CacheInner::new())),
206            max_entries,
207            default_ttl: ttl,
208        }
209    }
210
211    /// Look up a validation result.
212    ///
213    /// Returns `None` if the key is not present or the entry is stale.
214    /// Stale entries are removed on lookup.
215    pub fn get(&self, key: &ValidationCacheKey) -> Option<CachedValidationResult> {
216        let mut inner = self
217            .inner
218            .lock()
219            .expect("cache lock should not be poisoned");
220
221        match inner.entries.get(key) {
222            None => {
223                inner.miss_count += 1;
224                None
225            }
226            Some(entry) if entry.is_stale() => {
227                inner.miss_count += 1;
228                let k = key.clone();
229                inner.entries.remove(&k);
230                inner.access_order.remove(&k);
231                None
232            }
233            Some(_) => {
234                inner.hit_count += 1;
235                inner.record_access(key);
236                inner.entries.get(key).cloned()
237            }
238        }
239    }
240
241    /// Insert or update a validation result.
242    ///
243    /// If the cache is at capacity, the least-recently-used entry is evicted first.
244    pub fn put(&self, key: ValidationCacheKey, result: CachedValidationResult) {
245        let mut inner = self
246            .inner
247            .lock()
248            .expect("cache lock should not be poisoned");
249
250        // Evict stale entries first (cheap pass)
251        if inner.entries.len() >= self.max_entries {
252            // Try to remove stale entries before falling back to LRU
253            let stale_keys: Vec<_> = inner
254                .entries
255                .iter()
256                .filter(|(_, v)| v.is_stale())
257                .map(|(k, _)| k.clone())
258                .collect();
259
260            for sk in stale_keys {
261                inner.entries.remove(&sk);
262                inner.access_order.remove(&sk);
263            }
264
265            // If still at capacity, evict LRU
266            if inner.entries.len() >= self.max_entries {
267                inner.evict_lru();
268            }
269        }
270
271        inner.record_access(&key);
272        inner.entries.insert(key, result);
273    }
274
275    /// Invalidate all cache entries whose `focus_node` matches the given string.
276    ///
277    /// Returns the number of entries removed.
278    pub fn invalidate_node(&self, focus_node: &str) -> usize {
279        let mut inner = self
280            .inner
281            .lock()
282            .expect("cache lock should not be poisoned");
283
284        let to_remove: Vec<_> = inner
285            .entries
286            .keys()
287            .filter(|k| k.focus_node == focus_node)
288            .cloned()
289            .collect();
290
291        let count = to_remove.len();
292        for k in &to_remove {
293            inner.entries.remove(k);
294            inner.access_order.remove(k);
295        }
296        count
297    }
298
299    /// Invalidate all cache entries that accessed the given triple during validation.
300    ///
301    /// Returns the number of entries removed.
302    pub fn invalidate_triple(&self, triple_key: &str) -> usize {
303        let mut inner = self
304            .inner
305            .lock()
306            .expect("cache lock should not be poisoned");
307
308        let to_remove: Vec<_> = inner
309            .entries
310            .iter()
311            .filter(|(_, v)| v.accessed_triples.contains(triple_key))
312            .map(|(k, _)| k.clone())
313            .collect();
314
315        let count = to_remove.len();
316        for k in &to_remove {
317            inner.entries.remove(k);
318            inner.access_order.remove(k);
319        }
320        count
321    }
322
323    /// Remove all stale (TTL-expired) entries.
324    ///
325    /// Returns the number of entries removed.
326    pub fn evict_stale(&self) -> usize {
327        let mut inner = self
328            .inner
329            .lock()
330            .expect("cache lock should not be poisoned");
331
332        let stale_keys: Vec<_> = inner
333            .entries
334            .iter()
335            .filter(|(_, v)| v.is_stale())
336            .map(|(k, _)| k.clone())
337            .collect();
338
339        let count = stale_keys.len();
340        for k in &stale_keys {
341            inner.entries.remove(k);
342            inner.access_order.remove(k);
343        }
344        count
345    }
346
347    /// Clear the entire cache.
348    pub fn clear(&self) {
349        let mut inner = self
350            .inner
351            .lock()
352            .expect("cache lock should not be poisoned");
353        inner.entries.clear();
354        inner.access_order.clear();
355        inner.hit_count = 0;
356        inner.miss_count = 0;
357        inner.sequence = 0;
358    }
359
360    /// Return the current number of (non-stale) entries in the cache.
361    pub fn size(&self) -> usize {
362        let inner = self
363            .inner
364            .lock()
365            .expect("cache lock should not be poisoned");
366        inner.entries.values().filter(|v| !v.is_stale()).count()
367    }
368
369    /// Total number of entries (including stale ones not yet evicted).
370    pub fn raw_size(&self) -> usize {
371        let inner = self
372            .inner
373            .lock()
374            .expect("cache lock should not be poisoned");
375        inner.entries.len()
376    }
377
378    /// Cache hit rate (`hits / (hits + misses)`), or `0.0` when no lookups have occurred.
379    pub fn hit_rate(&self) -> f64 {
380        let inner = self
381            .inner
382            .lock()
383            .expect("cache lock should not be poisoned");
384        let total = inner.hit_count + inner.miss_count;
385        if total == 0 {
386            0.0
387        } else {
388            inner.hit_count as f64 / total as f64
389        }
390    }
391
392    /// Returns a snapshot of cache statistics.
393    pub fn stats(&self) -> CacheStats {
394        let inner = self
395            .inner
396            .lock()
397            .expect("cache lock should not be poisoned");
398        let total = inner.hit_count + inner.miss_count;
399        CacheStats {
400            entries: inner.entries.len(),
401            hit_count: inner.hit_count,
402            miss_count: inner.miss_count,
403            hit_rate: if total == 0 {
404                0.0
405            } else {
406                inner.hit_count as f64 / total as f64
407            },
408            max_entries: self.max_entries,
409            default_ttl: self.default_ttl,
410        }
411    }
412
413    /// Return the default TTL for this cache instance.
414    pub fn default_ttl(&self) -> Duration {
415        self.default_ttl
416    }
417}
418
419/// Snapshot of cache statistics.
420#[derive(Debug, Clone)]
421pub struct CacheStats {
422    pub entries: usize,
423    pub hit_count: u64,
424    pub miss_count: u64,
425    pub hit_rate: f64,
426    pub max_entries: usize,
427    pub default_ttl: Duration,
428}
429
430// ---------------------------------------------------------------------------
431// Tests
432// ---------------------------------------------------------------------------
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use std::thread;
438
439    fn make_entry(focus: &str, shape: &str, valid: bool, ttl: Duration) -> CachedValidationResult {
440        CachedValidationResult::new(focus, shape, valid, vec![], ttl)
441    }
442
443    fn make_key(focus: &str, shape: &str) -> ValidationCacheKey {
444        ValidationCacheKey::new(focus, shape, 0)
445    }
446
447    // ---- Basic get / put -------------------------------------------------
448
449    #[test]
450    fn test_put_and_get_hit() {
451        let cache = ValidationCache::new(100, Duration::from_secs(60));
452        let key = make_key("http://ex/Alice", "http://ex/PersonShape");
453        let entry = make_entry(
454            "http://ex/Alice",
455            "http://ex/PersonShape",
456            true,
457            Duration::from_secs(60),
458        );
459
460        cache.put(key.clone(), entry);
461        let result = cache.get(&key);
462        assert!(result.is_some());
463        assert!(result.expect("entry should exist").is_valid);
464    }
465
466    #[test]
467    fn test_get_miss() {
468        let cache = ValidationCache::new(100, Duration::from_secs(60));
469        let key = make_key("http://ex/Alice", "http://ex/PersonShape");
470        assert!(cache.get(&key).is_none());
471    }
472
473    #[test]
474    fn test_hit_rate_tracking() {
475        let cache = ValidationCache::new(100, Duration::from_secs(60));
476        let key = make_key("http://ex/Alice", "http://ex/PersonShape");
477        let entry = make_entry(
478            "http://ex/Alice",
479            "http://ex/PersonShape",
480            true,
481            Duration::from_secs(60),
482        );
483
484        cache.put(key.clone(), entry);
485
486        // 1 hit
487        let _ = cache.get(&key);
488        // 1 miss
489        let _ = cache.get(&make_key("http://ex/Bob", "http://ex/PersonShape"));
490
491        let stats = cache.stats();
492        assert_eq!(stats.hit_count, 1);
493        assert_eq!(stats.miss_count, 1);
494        assert!((cache.hit_rate() - 0.5).abs() < f64::EPSILON);
495    }
496
497    // ---- TTL / staleness -------------------------------------------------
498
499    #[test]
500    fn test_stale_entry_removed_on_get() {
501        let cache = ValidationCache::new(100, Duration::from_millis(10));
502        let key = make_key("http://ex/Alice", "http://ex/PersonShape");
503        let entry = make_entry(
504            "http://ex/Alice",
505            "http://ex/PersonShape",
506            true,
507            Duration::from_millis(10),
508        );
509
510        cache.put(key.clone(), entry);
511
512        // Wait for TTL to expire
513        thread::sleep(Duration::from_millis(20));
514
515        let result = cache.get(&key);
516        assert!(result.is_none(), "stale entry should not be returned");
517    }
518
519    #[test]
520    fn test_evict_stale() {
521        let cache = ValidationCache::new(100, Duration::from_millis(10));
522
523        for i in 0..5 {
524            let key = make_key(&format!("http://ex/Node{i}"), "http://ex/S");
525            let entry = make_entry(
526                &format!("http://ex/Node{i}"),
527                "http://ex/S",
528                true,
529                Duration::from_millis(10),
530            );
531            cache.put(key, entry);
532        }
533
534        thread::sleep(Duration::from_millis(20));
535
536        let removed = cache.evict_stale();
537        assert_eq!(removed, 5);
538        assert_eq!(cache.raw_size(), 0);
539    }
540
541    // ---- Invalidation ----------------------------------------------------
542
543    #[test]
544    fn test_invalidate_node() {
545        let cache = ValidationCache::new(100, Duration::from_secs(60));
546
547        cache.put(
548            make_key("http://ex/Alice", "http://ex/S1"),
549            make_entry(
550                "http://ex/Alice",
551                "http://ex/S1",
552                true,
553                Duration::from_secs(60),
554            ),
555        );
556        cache.put(
557            make_key("http://ex/Alice", "http://ex/S2"),
558            make_entry(
559                "http://ex/Alice",
560                "http://ex/S2",
561                true,
562                Duration::from_secs(60),
563            ),
564        );
565        cache.put(
566            make_key("http://ex/Bob", "http://ex/S1"),
567            make_entry(
568                "http://ex/Bob",
569                "http://ex/S1",
570                true,
571                Duration::from_secs(60),
572            ),
573        );
574
575        let removed = cache.invalidate_node("http://ex/Alice");
576        assert_eq!(removed, 2);
577
578        // Bob's entry should still be present
579        let bob_key = make_key("http://ex/Bob", "http://ex/S1");
580        assert!(cache.get(&bob_key).is_some());
581    }
582
583    #[test]
584    fn test_invalidate_triple() {
585        let cache = ValidationCache::new(100, Duration::from_secs(60));
586
587        let triple = "<http://ex/Alice> <http://ex/age> \"30\"";
588
589        let mut entry_a = make_entry(
590            "http://ex/Alice",
591            "http://ex/S1",
592            true,
593            Duration::from_secs(60),
594        );
595        entry_a.add_accessed_triple(triple);
596
597        let entry_b = make_entry(
598            "http://ex/Bob",
599            "http://ex/S1",
600            true,
601            Duration::from_secs(60),
602        );
603
604        cache.put(make_key("http://ex/Alice", "http://ex/S1"), entry_a);
605        cache.put(make_key("http://ex/Bob", "http://ex/S1"), entry_b);
606
607        let removed = cache.invalidate_triple(triple);
608        assert_eq!(removed, 1);
609
610        // Alice's entry should be gone; Bob's should remain
611        assert!(cache
612            .get(&make_key("http://ex/Alice", "http://ex/S1"))
613            .is_none());
614        assert!(cache
615            .get(&make_key("http://ex/Bob", "http://ex/S1"))
616            .is_some());
617    }
618
619    // ---- Capacity / LRU eviction -----------------------------------------
620
621    #[test]
622    fn test_lru_eviction_at_capacity() {
623        let cache = ValidationCache::new(3, Duration::from_secs(60));
624
625        for i in 0..3 {
626            cache.put(
627                make_key(&format!("http://ex/Node{i}"), "http://ex/S"),
628                make_entry(
629                    &format!("http://ex/Node{i}"),
630                    "http://ex/S",
631                    true,
632                    Duration::from_secs(60),
633                ),
634            );
635        }
636
637        assert_eq!(cache.raw_size(), 3);
638
639        // This should evict the LRU entry
640        cache.put(
641            make_key("http://ex/Node3", "http://ex/S"),
642            make_entry(
643                "http://ex/Node3",
644                "http://ex/S",
645                true,
646                Duration::from_secs(60),
647            ),
648        );
649
650        assert_eq!(
651            cache.raw_size(),
652            3,
653            "cache should remain at max capacity after LRU eviction"
654        );
655    }
656
657    // ---- Concurrency -----------------------------------------------------
658
659    #[test]
660    fn test_concurrent_put_and_get() {
661        let cache = Arc::new(ValidationCache::new(1000, Duration::from_secs(60)));
662        let mut handles = Vec::new();
663
664        for i in 0..10 {
665            let c = Arc::clone(&cache);
666            handles.push(thread::spawn(move || {
667                let key = make_key(&format!("http://ex/Node{i}"), "http://ex/S");
668                let entry = make_entry(
669                    &format!("http://ex/Node{i}"),
670                    "http://ex/S",
671                    true,
672                    Duration::from_secs(60),
673                );
674                c.put(key.clone(), entry);
675                let r = c.get(&key);
676                assert!(r.is_some(), "should find own entry");
677            }));
678        }
679
680        for h in handles {
681            h.join().expect("thread should not panic");
682        }
683    }
684
685    // ---- Clear -----------------------------------------------------------
686
687    #[test]
688    fn test_clear() {
689        let cache = ValidationCache::new(100, Duration::from_secs(60));
690        cache.put(
691            make_key("http://ex/Alice", "http://ex/S"),
692            make_entry(
693                "http://ex/Alice",
694                "http://ex/S",
695                true,
696                Duration::from_secs(60),
697            ),
698        );
699        cache.clear();
700        assert_eq!(cache.raw_size(), 0);
701        assert_eq!(cache.hit_rate(), 0.0);
702    }
703
704    // ---- CachedValidationResult API --------------------------------------
705
706    #[test]
707    fn test_is_stale_false_for_fresh_entry() {
708        let entry = make_entry("http://ex/A", "http://ex/S", true, Duration::from_secs(60));
709        assert!(!entry.is_stale());
710    }
711
712    #[test]
713    fn test_remaining_ttl_nonzero() {
714        let entry = make_entry("http://ex/A", "http://ex/S", true, Duration::from_secs(60));
715        assert!(entry.remaining_ttl() > Duration::ZERO);
716    }
717
718    #[test]
719    fn test_shape_hash_helper() {
720        let h1 = ValidationCacheKey::hash_shape(&"MyShape".to_string());
721        let h2 = ValidationCacheKey::hash_shape(&"MyShape".to_string());
722        assert_eq!(h1, h2);
723
724        let h3 = ValidationCacheKey::hash_shape(&"OtherShape".to_string());
725        assert_ne!(h1, h3);
726    }
727}
728
729// ---------------------------------------------------------------------------
730// Extended validation cache tests
731// ---------------------------------------------------------------------------
732
733#[cfg(test)]
734mod extended_cache_tests {
735    use super::*;
736    use std::thread;
737
738    fn entry(focus: &str, shape: &str, valid: bool) -> CachedValidationResult {
739        CachedValidationResult::new(focus, shape, valid, vec![], Duration::from_secs(60))
740    }
741
742    fn key(focus: &str, shape: &str) -> ValidationCacheKey {
743        ValidationCacheKey::new(focus, shape, 0)
744    }
745
746    // ---- invalidate_node -----------------------------------------------
747
748    #[test]
749    fn test_invalidate_node_removes_single_entry() {
750        let cache = ValidationCache::new(100, Duration::from_secs(60));
751        cache.put(
752            key("http://ex/Alice", "http://ex/S"),
753            entry("http://ex/Alice", "http://ex/S", true),
754        );
755        assert_eq!(cache.raw_size(), 1);
756
757        let removed = cache.invalidate_node("http://ex/Alice");
758        assert_eq!(removed, 1);
759        assert_eq!(cache.raw_size(), 0);
760    }
761
762    #[test]
763    fn test_invalidate_node_removes_multiple_shapes() {
764        let cache = ValidationCache::new(100, Duration::from_secs(60));
765        cache.put(
766            key("http://ex/Alice", "http://ex/S1"),
767            entry("http://ex/Alice", "http://ex/S1", true),
768        );
769        cache.put(
770            key("http://ex/Alice", "http://ex/S2"),
771            entry("http://ex/Alice", "http://ex/S2", false),
772        );
773        cache.put(
774            key("http://ex/Bob", "http://ex/S1"),
775            entry("http://ex/Bob", "http://ex/S1", true),
776        );
777
778        let removed = cache.invalidate_node("http://ex/Alice");
779        assert_eq!(removed, 2, "both Alice entries should be removed");
780        assert_eq!(cache.raw_size(), 1, "Bob entry should remain");
781    }
782
783    #[test]
784    fn test_invalidate_node_nonexistent_is_zero() {
785        let cache = ValidationCache::new(100, Duration::from_secs(60));
786        let removed = cache.invalidate_node("http://ex/NoSuchNode");
787        assert_eq!(removed, 0);
788    }
789
790    // ---- invalidate_triple ---------------------------------------------
791
792    #[test]
793    fn test_invalidate_triple_removes_dependent_entries() {
794        let cache = ValidationCache::new(100, Duration::from_secs(60));
795        let triple_key = "http://ex/Alice/name/Bob";
796
797        let mut e = entry("http://ex/Alice", "http://ex/S", true);
798        e.add_accessed_triple(triple_key);
799
800        cache.put(key("http://ex/Alice", "http://ex/S"), e);
801        assert_eq!(cache.raw_size(), 1);
802
803        let removed = cache.invalidate_triple(triple_key);
804        assert_eq!(removed, 1);
805        assert_eq!(cache.raw_size(), 0);
806    }
807
808    #[test]
809    fn test_invalidate_triple_non_dependent_entry_stays() {
810        let cache = ValidationCache::new(100, Duration::from_secs(60));
811        // Entry without any accessed triple
812        cache.put(
813            key("http://ex/Bob", "http://ex/S"),
814            entry("http://ex/Bob", "http://ex/S", true),
815        );
816
817        let removed = cache.invalidate_triple("some:triple:key");
818        assert_eq!(removed, 0);
819        assert_eq!(cache.raw_size(), 1);
820    }
821
822    // ---- evict_stale ---------------------------------------------------
823
824    #[test]
825    fn test_evict_stale_removes_expired_entries() {
826        let cache = ValidationCache::new(100, Duration::from_secs(60));
827
828        // Insert a "stale" entry with zero TTL
829        let stale = CachedValidationResult::new(
830            "http://ex/Alice",
831            "http://ex/S",
832            true,
833            vec![],
834            Duration::ZERO, // immediately expired
835        );
836        cache.put(key("http://ex/Alice", "http://ex/S"), stale);
837
838        // Insert a fresh entry
839        cache.put(
840            key("http://ex/Bob", "http://ex/S"),
841            entry("http://ex/Bob", "http://ex/S", true),
842        );
843
844        let evicted = cache.evict_stale();
845        assert_eq!(evicted, 1, "one stale entry should be evicted");
846    }
847
848    // ---- size vs raw_size ----------------------------------------------
849
850    #[test]
851    fn test_size_excludes_stale_entries() {
852        let cache = ValidationCache::new(100, Duration::from_secs(60));
853
854        let stale = CachedValidationResult::new(
855            "http://ex/Alice",
856            "http://ex/S",
857            true,
858            vec![],
859            Duration::ZERO,
860        );
861        cache.put(key("http://ex/Alice", "http://ex/S"), stale);
862        cache.put(
863            key("http://ex/Bob", "http://ex/S"),
864            entry("http://ex/Bob", "http://ex/S", true),
865        );
866
867        // raw_size counts everything including stale
868        assert_eq!(cache.raw_size(), 2);
869        // size() filters out stale entries
870        assert!(cache.size() <= 2);
871    }
872
873    // ---- CachedValidationResult API ------------------------------------
874
875    #[test]
876    fn test_entry_is_stale_with_zero_ttl() {
877        let stale = CachedValidationResult::new(
878            "http://ex/Alice",
879            "http://ex/S",
880            true,
881            vec![],
882            Duration::ZERO,
883        );
884        assert!(stale.is_stale());
885    }
886
887    #[test]
888    fn test_entry_not_stale_with_large_ttl() {
889        let fresh = entry("http://ex/Alice", "http://ex/S", true);
890        assert!(!fresh.is_stale());
891    }
892
893    #[test]
894    fn test_remaining_ttl_is_zero_for_stale_entry() {
895        let stale = CachedValidationResult::new(
896            "http://ex/Alice",
897            "http://ex/S",
898            true,
899            vec![],
900            Duration::ZERO,
901        );
902        assert_eq!(stale.remaining_ttl(), Duration::ZERO);
903    }
904
905    #[test]
906    fn test_accessed_triples_recorded() {
907        let mut e = entry("http://ex/Alice", "http://ex/S", true);
908        e.add_accessed_triple("triple:a");
909        e.add_accessed_triple("triple:b");
910        assert_eq!(e.accessed_triples.len(), 2);
911    }
912
913    // ---- ValidationCacheKey equality -----------------------------------
914
915    #[test]
916    fn test_cache_key_equality_same_inputs() {
917        let k1 = ValidationCacheKey::new("http://ex/Alice", "http://ex/S", 0u64);
918        let k2 = ValidationCacheKey::new("http://ex/Alice", "http://ex/S", 0u64);
919        assert_eq!(k1, k2);
920    }
921
922    #[test]
923    fn test_cache_key_inequality_different_shape() {
924        let k1 = ValidationCacheKey::new("http://ex/Alice", "http://ex/S1", 0u64);
925        let k2 = ValidationCacheKey::new("http://ex/Alice", "http://ex/S2", 0u64);
926        assert_ne!(k1, k2);
927    }
928
929    #[test]
930    fn test_cache_key_inequality_different_node() {
931        let k1 = ValidationCacheKey::new("http://ex/Alice", "http://ex/S", 0u64);
932        let k2 = ValidationCacheKey::new("http://ex/Bob", "http://ex/S", 0u64);
933        assert_ne!(k1, k2);
934    }
935
936    // ---- CacheStats ----------------------------------------------------
937
938    #[test]
939    fn test_stats_initial_zero() {
940        let cache = ValidationCache::new(100, Duration::from_secs(60));
941        let stats = cache.stats();
942        assert_eq!(stats.hit_count, 0);
943        assert_eq!(stats.miss_count, 0);
944        assert_eq!(stats.entries, 0);
945    }
946
947    #[test]
948    fn test_stats_put_count_increments() {
949        let cache = ValidationCache::new(100, Duration::from_secs(60));
950        cache.put(
951            key("http://ex/A", "http://ex/S"),
952            entry("http://ex/A", "http://ex/S", true),
953        );
954        cache.put(
955            key("http://ex/B", "http://ex/S"),
956            entry("http://ex/B", "http://ex/S", true),
957        );
958        let stats = cache.stats();
959        assert_eq!(stats.entries, 2);
960    }
961
962    #[test]
963    fn test_stats_miss_increments_on_absent_key() {
964        let cache = ValidationCache::new(100, Duration::from_secs(60));
965        let _ = cache.get(&key("http://ex/NoNode", "http://ex/S"));
966        let stats = cache.stats();
967        assert_eq!(stats.miss_count, 1);
968    }
969
970    // ---- default_ttl ---------------------------------------------------
971
972    #[test]
973    fn test_default_ttl_matches_constructor() {
974        let ttl = Duration::from_secs(120);
975        let cache = ValidationCache::new(50, ttl);
976        assert_eq!(cache.default_ttl(), ttl);
977    }
978
979    // ---- Concurrent invalidation ---------------------------------------
980
981    #[test]
982    fn test_concurrent_invalidation_safety() {
983        let cache = std::sync::Arc::new(ValidationCache::new(1000, Duration::from_secs(60)));
984
985        // Pre-populate
986        for i in 0..50 {
987            cache.put(
988                key(&format!("http://ex/Node{i}"), "http://ex/S"),
989                entry(&format!("http://ex/Node{i}"), "http://ex/S", true),
990            );
991        }
992
993        let cache2 = std::sync::Arc::clone(&cache);
994        let handle = thread::spawn(move || {
995            for i in 0..50 {
996                cache2.invalidate_node(&format!("http://ex/Node{i}"));
997            }
998        });
999
1000        // Simultaneously get from main thread
1001        for i in 0..50 {
1002            let _ = cache.get(&key(&format!("http://ex/Node{i}"), "http://ex/S"));
1003        }
1004
1005        handle.join().expect("thread should not panic");
1006    }
1007
1008    // ---- hit_rate edge cases -------------------------------------------
1009
1010    #[test]
1011    fn test_hit_rate_zero_when_no_accesses() {
1012        let cache = ValidationCache::new(100, Duration::from_secs(60));
1013        assert_eq!(cache.hit_rate(), 0.0);
1014    }
1015
1016    #[test]
1017    fn test_hit_rate_one_when_all_hits() {
1018        let cache = ValidationCache::new(100, Duration::from_secs(60));
1019        let k = key("http://ex/Alice", "http://ex/S");
1020        cache.put(k.clone(), entry("http://ex/Alice", "http://ex/S", true));
1021        let _ = cache.get(&k);
1022        let _ = cache.get(&k);
1023        // All accesses were hits
1024        assert!((cache.hit_rate() - 1.0).abs() < 1e-9);
1025    }
1026}