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