Skip to main content

strixonomy_reasoner/
input.rs

1use crate::error::{ReasonerError, Result};
2use crate::hierarchy::asserted_hierarchy_from_ontology;
3use ontologos_bridge::{core_to_triples_all, merge_triples_into_ontology};
4use ontologos_core::Ontology;
5use ontologos_parser::load_ontology;
6use sha2::{Digest, Sha256};
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use strixonomy_catalog::ClassHierarchy;
10use strixonomy_core::{OntologyFile, OntologyFormat, WorkspaceScanner};
11use strixonomy_parser::{parse_ontology_file, parse_ontology_text, serialize_quads_turtle};
12
13#[derive(Debug, Clone)]
14pub struct ReasonerInput {
15    pub workspace: PathBuf,
16    pub content_hash: String,
17    pub ontology: Ontology,
18    pub asserted_hierarchy: ClassHierarchy,
19    pub document_overrides: HashMap<PathBuf, String>,
20}
21
22pub struct WorkspaceInputLoader {
23    workspace: PathBuf,
24    scan_roots: Vec<PathBuf>,
25    document_overrides: HashMap<PathBuf, String>,
26}
27
28impl WorkspaceInputLoader {
29    pub fn new(workspace: impl Into<PathBuf>) -> Self {
30        Self {
31            workspace: workspace.into(),
32            scan_roots: Vec::new(),
33            document_overrides: HashMap::new(),
34        }
35    }
36
37    pub fn document_overrides(mut self, overrides: HashMap<PathBuf, String>) -> Self {
38        self.document_overrides = overrides;
39        self
40    }
41
42    /// Additional workspace roots to scan (multi-root), matching catalog `scan_roots`.
43    pub fn scan_roots(mut self, roots: Vec<PathBuf>) -> Self {
44        self.scan_roots = roots;
45        self
46    }
47
48    pub fn load(&self) -> Result<ReasonerInput> {
49        let scan_roots = strixonomy_catalog::IndexBuilder::effective_scan_roots(
50            &self.workspace,
51            &self.scan_roots,
52        );
53        let mut files: Vec<OntologyFile> = Vec::new();
54        for root in &scan_roots {
55            let scanner = WorkspaceScanner::new(root);
56            for file in scanner.scan()? {
57                if !files.iter().any(|f| paths_equal(&f.path, &file.path)) {
58                    files.push(file);
59                }
60            }
61        }
62
63        let mut hasher = Sha256::new();
64        let mut ontology = Ontology::new();
65
66        for file in &files {
67            let override_text = self.document_override_text(&file.path).cloned();
68            let loaded = if let Some(ref text) = override_text {
69                // Hash override body so open-buffer edits invalidate the reasoner cache.
70                hasher.update(file.path.to_string_lossy().as_bytes());
71                hasher.update(text.as_bytes());
72                load_workspace_file(&file.path, file.format, Some(text), file)?
73            } else {
74                hasher.update(file.content_hash.as_bytes());
75                load_workspace_file(&file.path, file.format, None, file)?
76            };
77            merge_ontology(&mut ontology, loaded)?;
78            if let Some(ref text) = override_text {
79                let _ = crate::swrl_run::inject_swrl_from_turtle(&mut ontology, text);
80            } else if matches!(file.format, OntologyFormat::Turtle) {
81                if let Ok(text) = std::fs::read_to_string(&file.path) {
82                    let _ = crate::swrl_run::inject_swrl_from_turtle(&mut ontology, &text);
83                }
84            }
85        }
86
87        for (path, text) in &self.document_overrides {
88            if files.iter().any(|f| paths_equal(&f.path, path)) {
89                continue;
90            }
91            hasher.update(path.to_string_lossy().as_bytes());
92            hasher.update(text.as_bytes());
93            let format = OntologyFormat::from_extension(
94                path.extension().and_then(|e| e.to_str()).unwrap_or("ttl"),
95            );
96            let file_stub = OntologyFile {
97                path: path.clone(),
98                format,
99                content_hash: String::new(),
100                modified_time: 0,
101                size_bytes: text.len() as u64,
102            };
103            let loaded = load_workspace_file(path, format, Some(text), &file_stub)?;
104            merge_ontology(&mut ontology, loaded)?;
105            let _ = crate::swrl_run::inject_swrl_from_turtle(&mut ontology, text);
106        }
107
108        let asserted_hierarchy = asserted_hierarchy_from_ontology(&ontology);
109
110        Ok(ReasonerInput {
111            workspace: self.workspace.clone(),
112            content_hash: hex::encode(hasher.finalize()),
113            ontology,
114            asserted_hierarchy,
115            document_overrides: self.document_overrides.clone(),
116        })
117    }
118
119    fn document_override_text(&self, path: &Path) -> Option<&String> {
120        if let Some(text) = self.document_overrides.get(path) {
121            return Some(text);
122        }
123        let canonical = path.canonicalize().ok();
124        if let Some(ref canon) = canonical {
125            if let Some(text) = self.document_overrides.get(canon) {
126                return Some(text);
127            }
128        }
129        // Match overrides stored under a different spelling of the same path
130        // (e.g. /var vs /private/var on macOS).
131        self.document_overrides.iter().find_map(|(key, text)| {
132            if paths_equal(key, path) {
133                Some(text)
134            } else {
135                None
136            }
137        })
138    }
139}
140
141fn paths_equal(a: &Path, b: &Path) -> bool {
142    a == b || a.canonicalize().ok().zip(b.canonicalize().ok()).is_some_and(|(x, y)| x == y)
143}
144
145fn load_workspace_file(
146    path: &Path,
147    format: OntologyFormat,
148    override_text: Option<&str>,
149    file: &OntologyFile,
150) -> Result<Ontology> {
151    if format == OntologyFormat::Obo {
152        return load_obo_as_ontology(path, override_text, file);
153    }
154    if let Some(text) = override_text {
155        return load_ontology_from_temp(path, text);
156    }
157    load_ontology(path)
158        .map_err(|e| ReasonerError::Load { path: path.to_path_buf(), message: e.to_string() })
159}
160
161fn load_obo_as_ontology(
162    path: &Path,
163    override_text: Option<&str>,
164    file: &OntologyFile,
165) -> Result<Ontology> {
166    let parsed = if let Some(text) = override_text {
167        parse_ontology_text(path, OntologyFormat::Obo, "reasoner", text, text.as_bytes())
168    } else {
169        parse_ontology_file(
170            path,
171            OntologyFormat::Obo,
172            "reasoner",
173            &file.content_hash,
174            file.modified_time,
175        )
176    }
177    .map_err(|e| ReasonerError::Load { path: path.to_path_buf(), message: e.to_string() })?;
178
179    if parsed.quads().is_empty() {
180        return Err(ReasonerError::Load {
181            path: path.to_path_buf(),
182            message: "OBO file produced no RDF quads".to_string(),
183        });
184    }
185
186    let turtle = serialize_quads_turtle(parsed.quads())
187        .map_err(|e| ReasonerError::Load { path: path.to_path_buf(), message: e.to_string() })?;
188    load_ontology_from_temp_with_suffix(path, &turtle, "ttl")
189}
190
191fn load_ontology_from_temp(path: &Path, text: &str) -> Result<Ontology> {
192    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("ttl");
193    load_ontology_from_temp_with_suffix(path, text, ext)
194}
195
196fn load_ontology_from_temp_with_suffix(path: &Path, text: &str, ext: &str) -> Result<Ontology> {
197    use strixonomy_core::MAX_FILE_BYTES;
198    if text.len() as u64 > MAX_FILE_BYTES {
199        return Err(ReasonerError::Load {
200            path: path.to_path_buf(),
201            message: format!("document exceeds maximum size of {MAX_FILE_BYTES} bytes"),
202        });
203    }
204    let tmp = tempfile::Builder::new()
205        .suffix(&format!(".{ext}"))
206        .tempfile()
207        .map_err(|e| ReasonerError::Load { path: path.to_path_buf(), message: e.to_string() })?;
208    std::fs::write(tmp.path(), text)
209        .map_err(|e| ReasonerError::Load { path: path.to_path_buf(), message: e.to_string() })?;
210    load_ontology(tmp.path())
211        .map_err(|e| ReasonerError::Load { path: path.to_path_buf(), message: e.to_string() })
212}
213
214fn merge_ontology(target: &mut Ontology, source: Ontology) -> Result<()> {
215    let triples =
216        core_to_triples_all(&source).map_err(|e| ReasonerError::Ontology(e.to_string()))?;
217    merge_triples_into_ontology(target, &triples, &[])
218        .map_err(|e| ReasonerError::Ontology(e.to_string()))?;
219    Ok(())
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::{classify, ReasonerId};
226    use std::fs;
227    use strixonomy_catalog::IndexBuilder;
228
229    #[test]
230    fn content_hash_changes_when_override_differs_from_disk() {
231        let dir = tempfile::tempdir().unwrap();
232        let path = dir.path().join("ex.ttl");
233        fs::write(
234            &path,
235            "@prefix ex: <http://ex#> .\n@prefix owl: <http://www.w3.org/2002/07/owl#> .\nex:A a owl:Class .\n",
236        )
237        .unwrap();
238
239        let disk_input = WorkspaceInputLoader::new(dir.path()).load().expect("disk load");
240
241        let mut overrides = HashMap::new();
242        overrides.insert(
243            path.clone(),
244            "@prefix ex: <http://ex#> .\n@prefix owl: <http://www.w3.org/2002/07/owl#> .\nex:A a owl:Class .\nex:B a owl:Class .\n"
245                .to_string(),
246        );
247        let override_input = WorkspaceInputLoader::new(dir.path())
248            .document_overrides(overrides)
249            .load()
250            .expect("override load");
251
252        assert_ne!(
253            disk_input.content_hash, override_input.content_hash,
254            "open-buffer overrides must change reasoner content_hash"
255        );
256    }
257
258    #[test]
259    fn buffer_subclass_axiom_not_reported_as_new_inference() {
260        let dir = tempfile::tempdir().unwrap();
261        let path = dir.path().join("ex.ttl");
262        fs::write(
263            &path,
264            "@prefix ex: <http://ex#> .\n\
265             @prefix owl: <http://www.w3.org/2002/07/owl#> .\n\
266             @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\n\
267             ex:A a owl:Class .\n\
268             ex:C a owl:Class ; rdfs:subClassOf ex:A .\n",
269        )
270        .unwrap();
271
272        let catalog = IndexBuilder::new().workspace(dir.path()).build().expect("index");
273        assert!(
274            !catalog.class_hierarchy().edges.iter().any(|e| e.child.ends_with("D")),
275            "stale catalog must not yet include ex:D"
276        );
277
278        let mut overrides = HashMap::new();
279        overrides.insert(
280            path,
281            "@prefix ex: <http://ex#> .\n\
282             @prefix owl: <http://www.w3.org/2002/07/owl#> .\n\
283             @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\n\
284             ex:A a owl:Class .\n\
285             ex:C a owl:Class ; rdfs:subClassOf ex:A .\n\
286             ex:D a owl:Class ; rdfs:subClassOf ex:C .\n"
287                .to_string(),
288        );
289
290        let input = WorkspaceInputLoader::new(dir.path())
291            .document_overrides(overrides)
292            .load()
293            .expect("override load");
294
295        assert!(
296            input
297                .asserted_hierarchy
298                .edges
299                .iter()
300                .any(|e| e.child.ends_with("D") && e.parent.ends_with("C")),
301            "asserted hierarchy must include buffer subclass axiom D ⊑ C"
302        );
303
304        let result = classify(ReasonerId::Rdfs, &input, false).expect("classify");
305        assert!(
306            !result
307                .new_inferences
308                .iter()
309                .any(|e| e.child.ends_with("D") && e.parent.ends_with("C")),
310            "buffer-authored D ⊑ C must not appear in new_inferences"
311        );
312    }
313
314    #[test]
315    fn asserted_hierarchy_matches_catalog_without_overrides() {
316        let dir = tempfile::tempdir().unwrap();
317        let path = dir.path().join("ex.ttl");
318        fs::write(
319            &path,
320            "@prefix ex: <http://ex#> .\n\
321             @prefix owl: <http://www.w3.org/2002/07/owl#> .\n\
322             @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\n\
323             ex:A a owl:Class .\n\
324             ex:C a owl:Class ; rdfs:subClassOf ex:A .\n",
325        )
326        .unwrap();
327
328        let catalog = IndexBuilder::new().workspace(dir.path()).build().expect("index");
329        let input = WorkspaceInputLoader::new(dir.path()).load().expect("load");
330
331        let catalog_edges: std::collections::BTreeSet<_> = catalog
332            .class_hierarchy()
333            .edges
334            .iter()
335            .map(|e| (e.child.clone(), e.parent.clone()))
336            .collect();
337        let input_edges: std::collections::BTreeSet<_> = input
338            .asserted_hierarchy
339            .edges
340            .iter()
341            .map(|e| (e.child.clone(), e.parent.clone()))
342            .collect();
343        assert_eq!(catalog_edges, input_edges);
344    }
345
346    #[test]
347    fn loads_minimal_obo_workspace() {
348        let dir = tempfile::tempdir().unwrap();
349        let path = dir.path().join("test.obo");
350        fs::write(
351            &path,
352            "format-version: 1.2\nontology: test\n\n\
353[Term]\n\
354id: TEST:0000001\n\
355name: child\n\
356is_a: TEST:0000002 ! parent\n\n\
357[Term]\n\
358id: TEST:0000002\n\
359name: parent\n",
360        )
361        .unwrap();
362
363        let input = WorkspaceInputLoader::new(dir.path())
364            .load()
365            .expect("OBO workspace should load for reasoner");
366        let triples = core_to_triples_all(&input.ontology).expect("triples");
367        assert!(!triples.is_empty(), "OBO-derived ontology should contain triples");
368    }
369}