Skip to main content

strixonomy_reasoner/
dl_query.rs

1//! Protégé-style DL Query over a workspace reasoner input.
2
3use crate::error::{ReasonerError, Result};
4use crate::input::ReasonerInput;
5use crate::result::{ClassificationResult, RealizationResult};
6use crate::{classify, realize, ReasonerId};
7use horned_owl::model::ClassExpression;
8use ontologos_bridge::{core_to_triples_all, merge_triples_into_ontology};
9use ontologos_core::Ontology;
10use ontologos_parser::load_ontology;
11use serde::{Deserialize, Serialize};
12use std::collections::{BTreeMap, BTreeSet};
13use std::time::Instant;
14use strixonomy_catalog::ClassHierarchy;
15use strixonomy_owl::{class_expression_to_turtle_value, parse_class_expression};
16
17/// Temporary named class used to materialize anonymous Manchester expressions.
18pub const DL_QUERY_CLASS_IRI: &str = "urn:strixonomy:dl-query#Q";
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
21#[serde(rename_all = "snake_case")]
22pub enum DlQueryMode {
23    #[default]
24    Inferred,
25    Asserted,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct DlQueryResult {
30    pub expression: String,
31    pub normalized: String,
32    pub query_class_iri: String,
33    pub subclasses: Vec<String>,
34    pub superclasses: Vec<String>,
35    pub equivalents: Vec<String>,
36    pub instances: Vec<String>,
37    pub profile: String,
38    pub mode: DlQueryMode,
39    pub duration_ms: u64,
40    #[serde(default, skip_serializing_if = "Vec::is_empty")]
41    pub warnings: Vec<String>,
42    #[serde(default, skip_serializing_if = "Vec::is_empty")]
43    pub diagnostics: Vec<String>,
44}
45
46/// Run a Manchester class-expression DL Query against `input`.
47///
48/// Named class expressions use the hierarchy (asserted or inferred) and realization
49/// directly. Anonymous expressions are evaluated by injecting a temporary
50/// `EquivalentClasses(Q, CE)` axiom into a cloned ontology (never written to disk).
51pub fn run_dl_query(
52    profile: ReasonerId,
53    input: &ReasonerInput,
54    expression: &str,
55    namespaces: &BTreeMap<String, String>,
56    mode: DlQueryMode,
57) -> Result<DlQueryResult> {
58    let started = Instant::now();
59    let parsed = parse_class_expression(expression, namespaces)
60        .map_err(|e| ReasonerError::Classify(format!("Manchester parse failed: {e}")))?;
61    let diagnostics: Vec<String> = parsed.diagnostics.iter().map(|d| d.message.clone()).collect();
62
63    let named_iri = named_class_iri(&parsed.expression);
64    let is_named = named_iri.is_some();
65    let (query_iri, query_input, mut warnings) = if let Some(iri) = named_iri {
66        (iri, input.clone_shallow(), Vec::new())
67    } else {
68        let (augmented, w) = inject_temp_equivalent(input, &parsed.expression, namespaces)?;
69        (DL_QUERY_CLASS_IRI.to_string(), augmented, w)
70    };
71
72    let (hierarchy, profile_used, realization, equiv_clusters) = match mode {
73        DlQueryMode::Asserted => {
74            (query_input.asserted_hierarchy.clone(), "asserted".to_string(), None, Vec::new())
75        }
76        DlQueryMode::Inferred => {
77            let classification = classify(profile, &query_input, true)?;
78            let realization = match realize(profile, &query_input) {
79                Ok(result) => Some(result),
80                Err(err) => {
81                    // #371: do not treat realize failures as empty Instances.
82                    warnings.push(format!(
83                        "realization unavailable; Instances may be incomplete: {err}"
84                    ));
85                    None
86                }
87            };
88            collect_inferred(&classification, realization)
89        }
90    };
91
92    let mut subclasses = collect_descendants(&hierarchy, &query_iri);
93    let mut superclasses = collect_ancestors(&hierarchy, &query_iri);
94    let mut equivalents =
95        collect_equivalents(&hierarchy, &query_input.ontology, &query_iri, &equiv_clusters);
96    subclasses.retain(|iri| iri != &query_iri && iri != DL_QUERY_CLASS_IRI);
97    superclasses.retain(|iri| {
98        iri != &query_iri
99            && iri != DL_QUERY_CLASS_IRI
100            && iri != "http://www.w3.org/2002/07/owl#Thing"
101    });
102    equivalents.retain(|iri| iri != &query_iri && iri != DL_QUERY_CLASS_IRI);
103
104    // #372: Protégé-style — equivalents only under Equivalents, not Sub/Super.
105    let equiv_set: BTreeSet<_> = equivalents.iter().cloned().collect();
106    subclasses.retain(|iri| !equiv_set.contains(iri));
107    superclasses.retain(|iri| !equiv_set.contains(iri));
108
109    let mut instances = Vec::new();
110    if let Some(realization) = realization {
111        for entry in realization.individuals {
112            if entry.types.iter().any(|t| t == &query_iri) {
113                instances.push(entry.individual_iri);
114            }
115        }
116    } else if mode == DlQueryMode::Asserted {
117        if is_named {
118            instances = asserted_instances_of_class(&query_input, &hierarchy, &query_iri);
119        } else {
120            warnings.push(
121                "asserted mode cannot materialize instances for anonymous class expressions; use inferred mode"
122                    .to_string(),
123            );
124        }
125    }
126
127    instances.sort();
128    instances.dedup();
129
130    Ok(DlQueryResult {
131        expression: expression.to_string(),
132        normalized: parsed.normalized,
133        query_class_iri: query_iri,
134        subclasses,
135        superclasses,
136        equivalents,
137        instances,
138        profile: profile_used,
139        mode,
140        duration_ms: started.elapsed().as_millis() as u64,
141        warnings,
142        diagnostics,
143    })
144}
145
146impl ReasonerInput {
147    /// Clone ontology + hierarchy without re-scanning the workspace.
148    fn clone_shallow(&self) -> Self {
149        Self {
150            workspace: self.workspace.clone(),
151            content_hash: self.content_hash.clone(),
152            ontology: self.ontology.clone(),
153            asserted_hierarchy: self.asserted_hierarchy.clone(),
154            document_overrides: self.document_overrides.clone(),
155        }
156    }
157}
158
159fn collect_inferred(
160    classification: &ClassificationResult,
161    realization: Option<RealizationResult>,
162) -> (ClassHierarchy, String, Option<RealizationResult>, Vec<Vec<String>>) {
163    (
164        classification.inferred.combined.clone(),
165        classification.profile_used.clone(),
166        realization,
167        classification.equivalences.clone(),
168    )
169}
170
171/// Collect individuals with an asserted type of `class_iri`, an asserted equivalent,
172/// or any asserted descendant of those classes (#373).
173fn asserted_instances_of_class(
174    input: &ReasonerInput,
175    hierarchy: &ClassHierarchy,
176    class_iri: &str,
177) -> Vec<String> {
178    let mut class_iris: BTreeSet<String> =
179        collect_descendants(hierarchy, class_iri).into_iter().collect();
180    class_iris.insert(class_iri.to_string());
181    close_under_asserted_equivalents(&input.ontology, &mut class_iris);
182    // Descendants of equivalents (and equivalents of descendants) matter too.
183    let seeds: Vec<String> = class_iris.iter().cloned().collect();
184    for seed in seeds {
185        for child in collect_descendants(hierarchy, &seed) {
186            class_iris.insert(child);
187        }
188    }
189    close_under_asserted_equivalents(&input.ontology, &mut class_iris);
190
191    let mut out = BTreeSet::new();
192    for iri in &class_iris {
193        let Some(class_id) = input.ontology.lookup_entity(iri) else {
194            continue;
195        };
196        for ind_id in input.ontology.individuals_of(class_id) {
197            if let Ok(ind_iri) = crate::result::entity_iri(&input.ontology, *ind_id) {
198                out.insert(ind_iri);
199            }
200        }
201    }
202
203    // Fallback: walk ClassAssertion axioms (including load-marked "inferred" ones).
204    if out.is_empty() {
205        use ontologos_core::Axiom;
206        for (_id, axiom) in input.ontology.axioms().iter() {
207            let Axiom::ClassAssertion { individual, class } = axiom else {
208                continue;
209            };
210            let Ok(class_s) = crate::result::entity_iri(&input.ontology, *class) else {
211                continue;
212            };
213            if !class_iris.contains(&class_s) {
214                continue;
215            }
216            if let Ok(ind_s) = crate::result::entity_iri(&input.ontology, *individual) {
217                out.insert(ind_s);
218            }
219        }
220    }
221
222    out.into_iter().collect()
223}
224
225fn close_under_asserted_equivalents(ontology: &Ontology, class_iris: &mut BTreeSet<String>) {
226    let seeds: Vec<String> = class_iris.iter().cloned().collect();
227    for seed in seeds {
228        let Some(id) = ontology.lookup_entity(&seed) else {
229            continue;
230        };
231        let Some(equivs) = ontology.equivalents_of(id) else {
232            continue;
233        };
234        for eid in equivs {
235            if let Ok(iri) = crate::result::entity_iri(ontology, *eid) {
236                class_iris.insert(iri);
237            }
238        }
239    }
240}
241
242fn named_class_iri(expr: &ClassExpression<horned_owl::model::RcStr>) -> Option<String> {
243    match expr {
244        ClassExpression::Class(c) => Some(c.to_string()),
245        _ => None,
246    }
247}
248
249fn inject_temp_equivalent(
250    input: &ReasonerInput,
251    expr: &ClassExpression<horned_owl::model::RcStr>,
252    namespaces: &BTreeMap<String, String>,
253) -> Result<(ReasonerInput, Vec<String>)> {
254    let ce_turtle = class_expression_to_turtle_value(expr, namespaces, 0).map_err(|e| {
255        ReasonerError::Classify(format!("failed to serialize class expression: {e}"))
256    })?;
257    let supplement = build_query_supplement(&ce_turtle, namespaces);
258    let loaded = load_ontology_from_temp_text(&supplement)?;
259    let mut ontology = input.ontology.clone();
260    let triples =
261        core_to_triples_all(&loaded).map_err(|e| ReasonerError::Ontology(e.to_string()))?;
262    merge_triples_into_ontology(&mut ontology, &triples, &[])
263        .map_err(|e| ReasonerError::Ontology(e.to_string()))?;
264    let asserted_hierarchy = crate::hierarchy::asserted_hierarchy_from_ontology(&ontology);
265    Ok((
266        ReasonerInput {
267            workspace: input.workspace.clone(),
268            content_hash: format!("{}:dl-query", input.content_hash),
269            ontology,
270            asserted_hierarchy,
271            document_overrides: input.document_overrides.clone(),
272        },
273        vec!["evaluated via temporary equivalent class (not written to disk)".to_string()],
274    ))
275}
276
277fn build_query_supplement(ce_turtle: &str, namespaces: &BTreeMap<String, String>) -> String {
278    let mut out = String::new();
279    out.push_str("@prefix owl: <http://www.w3.org/2002/07/owl#> .\n");
280    out.push_str("@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .\n");
281    out.push_str("@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\n");
282    out.push_str("@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n");
283    for (prefix, iri) in namespaces {
284        if matches!(prefix.as_str(), "owl" | "rdf" | "rdfs" | "xsd") {
285            continue;
286        }
287        out.push_str(&format!("@prefix {prefix}: <{iri}> .\n"));
288    }
289    out.push_str(&format!(
290        "<{DL_QUERY_CLASS_IRI}> a owl:Class ;\n  owl:equivalentClass {ce_turtle} .\n"
291    ));
292    out
293}
294
295fn load_ontology_from_temp_text(text: &str) -> Result<ontologos_core::Ontology> {
296    let tmp = tempfile::Builder::new()
297        .suffix(".ttl")
298        .tempfile()
299        .map_err(|e| ReasonerError::Ontology(e.to_string()))?;
300    std::fs::write(tmp.path(), text).map_err(|e| ReasonerError::Ontology(e.to_string()))?;
301    load_ontology(tmp.path()).map_err(|e| ReasonerError::Ontology(e.to_string()))
302}
303
304fn collect_descendants(hierarchy: &ClassHierarchy, root: &str) -> Vec<String> {
305    let mut out = BTreeSet::new();
306    let mut stack = vec![root.to_string()];
307    let mut seen = BTreeSet::new();
308    while let Some(current) = stack.pop() {
309        if !seen.insert(current.clone()) {
310            continue;
311        }
312        if let Some(children) = hierarchy.children.get(&current) {
313            for child in children {
314                if child != root {
315                    out.insert(child.clone());
316                }
317                stack.push(child.clone());
318            }
319        }
320    }
321    out.into_iter().collect()
322}
323
324fn collect_ancestors(hierarchy: &ClassHierarchy, root: &str) -> Vec<String> {
325    let mut out = BTreeSet::new();
326    let mut stack = vec![root.to_string()];
327    let mut seen = BTreeSet::new();
328    while let Some(current) = stack.pop() {
329        if !seen.insert(current.clone()) {
330            continue;
331        }
332        if let Some(parents) = hierarchy.parents.get(&current) {
333            for parent in parents {
334                if parent != root {
335                    out.insert(parent.clone());
336                }
337                stack.push(parent.clone());
338            }
339        }
340    }
341    out.into_iter().collect()
342}
343
344/// Equivalents = mutual SubClassOf ∩ ∪ asserted `equivalents_of` ∪ taxonomy clusters (#370).
345fn collect_equivalents(
346    hierarchy: &ClassHierarchy,
347    ontology: &Ontology,
348    root: &str,
349    inferred_clusters: &[Vec<String>],
350) -> Vec<String> {
351    let descendants: BTreeSet<_> = collect_descendants(hierarchy, root).into_iter().collect();
352    let ancestors: BTreeSet<_> = collect_ancestors(hierarchy, root).into_iter().collect();
353    let mut out: BTreeSet<String> = descendants.intersection(&ancestors).cloned().collect();
354
355    if let Some(id) = ontology.lookup_entity(root) {
356        if let Some(equivs) = ontology.equivalents_of(id) {
357            for eid in equivs {
358                if let Ok(iri) = crate::result::entity_iri(ontology, *eid) {
359                    out.insert(iri);
360                }
361            }
362        }
363    }
364
365    for cluster in inferred_clusters {
366        if cluster.iter().any(|iri| iri == root) {
367            for iri in cluster {
368                if iri != root {
369                    out.insert(iri.clone());
370                }
371            }
372        }
373    }
374
375    out.into_iter().collect()
376}