Skip to main content

strixonomy_reasoner/
cache.rs

1use crate::adapter::ReasonerId;
2use crate::input::ReasonerInput;
3use crate::result::{ClassificationResult, ReasonerSnapshot};
4use std::collections::HashMap;
5
6/// Keep at most one classification entry per profile (plus a small global cap).
7const MAX_CACHE_ENTRIES: usize = 8;
8
9#[derive(Debug, Clone)]
10pub struct ReasonerCache {
11    pub content_hash: String,
12    pub profile: ReasonerId,
13    pub snapshot: ReasonerSnapshot,
14    pub classification: ClassificationResult,
15    pub input: ReasonerInput,
16}
17
18#[derive(Debug, Default)]
19pub struct ReasonerCacheStore {
20    entries: HashMap<(String, String), ReasonerCache>,
21    /// Insertion order for eviction (oldest first).
22    order: Vec<(String, String)>,
23}
24
25impl ReasonerCacheStore {
26    pub fn new() -> Self {
27        Self::default()
28    }
29
30    pub fn get(&self, content_hash: &str, profile: ReasonerId) -> Option<&ReasonerCache> {
31        self.entries.get(&(content_hash.to_string(), profile.as_str().to_string()))
32    }
33
34    pub fn get_any_for_profile(&self, profile: ReasonerId) -> Option<&ReasonerCache> {
35        self.entries.values().find(|e| e.profile == profile)
36    }
37
38    pub fn insert(&mut self, cache: ReasonerCache) {
39        let profile_key = cache.profile.as_str().to_string();
40        let key = (cache.content_hash.clone(), profile_key.clone());
41
42        // One entry per profile: drop any prior entry for the same profile.
43        let stale: Vec<_> =
44            self.entries.keys().filter(|(_, p)| p == &profile_key).cloned().collect();
45        for stale_key in stale {
46            self.entries.remove(&stale_key);
47            self.order.retain(|k| k != &stale_key);
48        }
49
50        if self.entries.insert(key.clone(), cache).is_none() {
51            self.order.push(key);
52        } else if let Some(pos) = self.order.iter().position(|k| k == &key) {
53            let k = self.order.remove(pos);
54            self.order.push(k);
55        }
56
57        while self.entries.len() > MAX_CACHE_ENTRIES {
58            if let Some(oldest) = self.order.first().cloned() {
59                self.entries.remove(&oldest);
60                self.order.remove(0);
61            } else {
62                break;
63            }
64        }
65    }
66
67    pub fn invalidate(&mut self) {
68        self.entries.clear();
69        self.order.clear();
70    }
71
72    pub fn latest_snapshot(&self) -> Option<&ReasonerSnapshot> {
73        self.entries.values().max_by_key(|e| e.snapshot.classified_at).map(|e| &e.snapshot)
74    }
75
76    pub fn len(&self) -> usize {
77        self.entries.len()
78    }
79
80    pub fn is_empty(&self) -> bool {
81        self.entries.is_empty()
82    }
83
84    pub fn store_classification(
85        &mut self,
86        input: ReasonerInput,
87        profile: ReasonerId,
88        classification: ClassificationResult,
89    ) -> ReasonerSnapshot {
90        let snapshot = ReasonerSnapshot::from(classification.clone());
91        let cache = ReasonerCache {
92            content_hash: input.content_hash.clone(),
93            profile,
94            snapshot: snapshot.clone(),
95            classification,
96            input,
97        };
98        self.insert(cache);
99        snapshot
100    }
101
102    /// Persist an enriched snapshot (realization / consistency) after ABox enrich (#355).
103    pub fn update_snapshot(
104        &mut self,
105        content_hash: &str,
106        profile: ReasonerId,
107        snapshot: ReasonerSnapshot,
108    ) -> bool {
109        let key = (content_hash.to_string(), profile.as_str().to_string());
110        if let Some(entry) = self.entries.get_mut(&key) {
111            entry.classification.consistent = snapshot.consistent;
112            entry.snapshot = snapshot;
113            true
114        } else {
115            false
116        }
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::input::ReasonerInput;
124    use crate::result::{ClassificationResult, InferredHierarchy};
125    use ontologos_core::Ontology;
126    use std::collections::HashMap;
127    use std::path::PathBuf;
128    use strixonomy_catalog::ClassHierarchy;
129
130    fn dummy_input(hash: &str) -> ReasonerInput {
131        ReasonerInput {
132            workspace: PathBuf::from("/tmp"),
133            content_hash: hash.to_string(),
134            ontology: Ontology::new(),
135            asserted_hierarchy: ClassHierarchy::default(),
136            document_overrides: HashMap::new(),
137        }
138    }
139
140    fn dummy_classification(profile: &str) -> ClassificationResult {
141        ClassificationResult {
142            profile_used: profile.to_string(),
143            consistent: true,
144            unsatisfiable: Vec::new(),
145            inferred: InferredHierarchy {
146                edges: Vec::new(),
147                unsatisfiable: Vec::new(),
148                combined: ClassHierarchy::default(),
149            },
150            new_inferences: Vec::new(),
151            warnings: Vec::new(),
152            duration_ms: 0,
153            subsumption_count: 0,
154            inferred_axiom_count: 0,
155            equivalences: Vec::new(),
156        }
157    }
158
159    #[test]
160    fn one_entry_per_profile() {
161        let mut store = ReasonerCacheStore::new();
162        store.store_classification(dummy_input("a"), ReasonerId::El, dummy_classification("el"));
163        store.store_classification(dummy_input("b"), ReasonerId::El, dummy_classification("el"));
164        assert_eq!(store.len(), 1);
165        assert!(store.get("b", ReasonerId::El).is_some());
166        assert!(store.get("a", ReasonerId::El).is_none());
167    }
168
169    #[test]
170    fn update_snapshot_preserves_realization() {
171        let mut store = ReasonerCacheStore::new();
172        let mut snapshot = store.store_classification(
173            dummy_input("h"),
174            ReasonerId::Dl,
175            dummy_classification("dl"),
176        );
177        assert!(snapshot.realization.is_none());
178        snapshot.realization = Some(crate::result::RealizationResult {
179            profile_used: "dl".into(),
180            individuals: Vec::new(),
181            duration_ms: 1,
182            truncated: false,
183            entailment_errors: 0,
184        });
185        assert!(store.update_snapshot("h", ReasonerId::Dl, snapshot.clone()));
186        let cached = store.get("h", ReasonerId::Dl).expect("cached");
187        assert!(cached.snapshot.realization.is_some());
188    }
189}