Skip to main content

pyshacl_turbo_engine/
inference.rs

1use std::collections::HashSet;
2
3use oxigraph::model::*;
4use oxigraph::sparql::{QueryResults, SparqlEvaluator};
5
6use crate::graph::{RDF, iri, objects, subject, subjects};
7use crate::path::rdf_list;
8use crate::{Error, Result};
9
10#[cfg(test)]
11#[path = "inference_tests.rs"]
12mod tests;
13
14mod axioms;
15
16const OWL: &str = "http://www.w3.org/2002/07/owl#";
17
18const PREFIXES: &str = "PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
19PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
20PREFIX owl: <http://www.w3.org/2002/07/owl#>
21PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>";
22
23const RDFS_RULES: &[(&str, &str)] = &[
24    ("?p a rdf:Property", "?s ?p ?o"),
25    ("?s a ?c", "?p rdfs:domain ?c . ?s ?p ?o"),
26    ("?o a ?c", "?p rdfs:range ?c . ?s ?p ?o"),
27    (
28        "?p rdfs:subPropertyOf ?r",
29        "?p rdfs:subPropertyOf ?q . ?q rdfs:subPropertyOf ?r",
30    ),
31    ("?p rdfs:subPropertyOf ?p", "?p a rdf:Property"),
32    ("?s ?q ?o", "?p rdfs:subPropertyOf ?q . ?s ?p ?o"),
33    ("?c rdfs:subClassOf rdfs:Resource, ?c", "?c a rdfs:Class"),
34    ("?s a ?d", "?c rdfs:subClassOf ?d . ?s a ?c"),
35    (
36        "?a rdfs:subClassOf ?c",
37        "?a rdfs:subClassOf ?b . ?b rdfs:subClassOf ?c",
38    ),
39    (
40        "?p rdfs:subPropertyOf rdfs:member",
41        "?p a rdfs:ContainerMembershipProperty",
42    ),
43    ("?d rdfs:subClassOf rdfs:Literal", "?d a rdfs:Datatype"),
44];
45
46// OWL 2 RL/RDF entailment rules, represented as native CONSTRUCT programs.
47// https://www.w3.org/TR/owl2-profiles/#Reasoning_in_OWL_2_RL_and_RDF_Graphs
48const OWL_RULES: &[(&str, &str)] = &[
49    (
50        "?s owl:sameAs ?s . ?p owl:sameAs ?p . ?o owl:sameAs ?o",
51        "?s ?p ?o",
52    ),
53    ("?y owl:sameAs ?x", "?x owl:sameAs ?y"),
54    ("?x owl:sameAs ?z", "?x owl:sameAs ?y . ?y owl:sameAs ?z"),
55    ("?y ?p ?o", "?x owl:sameAs ?y . ?x ?p ?o"),
56    ("?s ?q ?o", "?p owl:sameAs ?q . ?s ?p ?o"),
57    ("?s ?p ?y", "?x owl:sameAs ?y . ?s ?p ?x"),
58    ("?s a ?c", "?p rdfs:domain ?c . ?s ?p ?o"),
59    ("?o a ?c", "?p rdfs:range ?c . ?s ?p ?o"),
60    (
61        "?y owl:sameAs ?z",
62        "?p a owl:FunctionalProperty . ?x ?p ?y, ?z",
63    ),
64    (
65        "?x owl:sameAs ?y",
66        "?p a owl:InverseFunctionalProperty . ?x ?p ?z . ?y ?p ?z",
67    ),
68    ("?y ?p ?x", "?p a owl:SymmetricProperty . ?x ?p ?y"),
69    (
70        "?x ?p ?z",
71        "?p a owl:TransitiveProperty . ?x ?p ?y . ?y ?p ?z",
72    ),
73    ("?x ?q ?y", "?p rdfs:subPropertyOf ?q . ?x ?p ?y"),
74    (
75        "?p rdfs:subPropertyOf ?q . ?q rdfs:subPropertyOf ?p",
76        "?p owl:equivalentProperty ?q FILTER(!sameTerm(?p, ?q))",
77    ),
78    ("?y ?q ?x", "?p owl:inverseOf ?q . ?x ?p ?y"),
79    ("?y ?p ?x", "?p owl:inverseOf ?q . ?x ?q ?y"),
80    ("?x a ?d", "?c rdfs:subClassOf ?d . ?x a ?c"),
81    (
82        "?c rdfs:subClassOf ?d . ?d rdfs:subClassOf ?c",
83        "?c owl:equivalentClass ?d FILTER(!sameTerm(?c, ?d))",
84    ),
85    (
86        "?x a ?member",
87        "?c owl:intersectionOf ?list . ?list rdf:rest*/rdf:first ?member . ?x a ?c",
88    ),
89    (
90        "?x a ?c",
91        "?c owl:intersectionOf ?list . ?x a ?any FILTER NOT EXISTS { ?list rdf:rest*/rdf:first ?member FILTER NOT EXISTS { ?x a ?member } }",
92    ),
93    (
94        "?x a ?c",
95        "?c owl:unionOf ?list . ?list rdf:rest*/rdf:first ?member . ?x a ?member",
96    ),
97    (
98        "?x a ?c",
99        "?c owl:oneOf ?list . ?list rdf:rest*/rdf:first ?x",
100    ),
101    (
102        "?x a ?r",
103        "?r owl:onProperty ?p ; owl:someValuesFrom ?c . ?x ?p ?y . ?y a ?c",
104    ),
105    (
106        "?x a ?r",
107        "?r owl:onProperty ?p ; owl:someValuesFrom owl:Thing . ?x ?p ?y",
108    ),
109    (
110        "?y a ?c",
111        "?r owl:onProperty ?p ; owl:allValuesFrom ?c . ?x a ?r ; ?p ?y",
112    ),
113    (
114        "?x ?p ?v",
115        "?r owl:onProperty ?p ; owl:hasValue ?v . ?x a ?r",
116    ),
117    (
118        "?x a ?r",
119        "?r owl:onProperty ?p ; owl:hasValue ?v . ?x ?p ?v",
120    ),
121    (
122        "?y owl:sameAs ?z",
123        "?r owl:onProperty ?p ; owl:maxCardinality ?n . FILTER(?n = 1) ?x a ?r ; ?p ?y, ?z",
124    ),
125    (
126        "?y owl:sameAs ?z",
127        "?r owl:onProperty ?p ; owl:maxQualifiedCardinality ?n ; owl:onClass ?c . FILTER(?n = 1) ?x a ?r ; ?p ?y, ?z . ?y a ?c . ?z a ?c",
128    ),
129    (
130        "?y owl:sameAs ?z",
131        "?r owl:onProperty ?p ; owl:maxQualifiedCardinality ?n ; owl:onClass owl:Thing . FILTER(?n = 1) ?x a ?r ; ?p ?y, ?z",
132    ),
133    (
134        "?c rdfs:subClassOf ?c, owl:Thing ; owl:equivalentClass ?c . owl:Nothing rdfs:subClassOf ?c",
135        "?c a owl:Class",
136    ),
137    // The pinned reference emits reflexive schema entailments through class /
138    // property declarations, not through cycles involving undeclared terms.
139    (
140        "?a rdfs:subClassOf ?c",
141        "?a rdfs:subClassOf ?b . ?b rdfs:subClassOf ?c FILTER(!sameTerm(?a, ?c))",
142    ),
143    (
144        "?a owl:equivalentClass ?b",
145        "?a rdfs:subClassOf ?b . ?b rdfs:subClassOf ?a",
146    ),
147    (
148        "?p rdfs:subPropertyOf ?p ; owl:equivalentProperty ?p",
149        "{ ?p a owl:ObjectProperty } UNION { ?p a owl:DatatypeProperty } UNION { ?p a rdf:Property }",
150    ),
151    (
152        "?a rdfs:subPropertyOf ?c",
153        "?a rdfs:subPropertyOf ?b . ?b rdfs:subPropertyOf ?c FILTER(!sameTerm(?a, ?c))",
154    ),
155    (
156        "?a owl:equivalentProperty ?b",
157        "?a rdfs:subPropertyOf ?b . ?b rdfs:subPropertyOf ?a FILTER(!sameTerm(?a, ?b))",
158    ),
159    (
160        "?p rdfs:domain ?d",
161        "?p rdfs:domain ?c . ?c rdfs:subClassOf ?d",
162    ),
163    (
164        "?p rdfs:domain ?c",
165        "?p rdfs:subPropertyOf ?q . ?q rdfs:domain ?c",
166    ),
167    (
168        "?p rdfs:range ?d",
169        "?p rdfs:range ?c . ?c rdfs:subClassOf ?d",
170    ),
171    (
172        "?p rdfs:range ?c",
173        "?p rdfs:subPropertyOf ?q . ?q rdfs:range ?c",
174    ),
175    (
176        "?r rdfs:subClassOf ?s",
177        "?r owl:hasValue ?v ; owl:onProperty ?p . ?s owl:hasValue ?v ; owl:onProperty ?q . ?p rdfs:subPropertyOf ?q",
178    ),
179    (
180        "?r rdfs:subClassOf ?s",
181        "?r owl:someValuesFrom ?c ; owl:onProperty ?p . ?s owl:someValuesFrom ?d ; owl:onProperty ?p . ?c rdfs:subClassOf ?d",
182    ),
183    (
184        "?r rdfs:subClassOf ?s",
185        "?r owl:someValuesFrom ?c ; owl:onProperty ?p . ?s owl:someValuesFrom ?c ; owl:onProperty ?q . ?p rdfs:subPropertyOf ?q",
186    ),
187    (
188        "?r rdfs:subClassOf ?s",
189        "?r owl:allValuesFrom ?c ; owl:onProperty ?p . ?s owl:allValuesFrom ?d ; owl:onProperty ?p . ?c rdfs:subClassOf ?d",
190    ),
191    (
192        "?r rdfs:subClassOf ?s",
193        "?r owl:allValuesFrom ?c ; owl:onProperty ?p . ?s owl:allValuesFrom ?c ; owl:onProperty ?q . ?q rdfs:subPropertyOf ?p",
194    ),
195    (
196        "?c rdfs:subClassOf ?member",
197        "?c owl:intersectionOf ?list . ?list rdf:rest*/rdf:first ?member",
198    ),
199    (
200        "?member rdfs:subClassOf ?c",
201        "?c owl:unionOf ?list . ?list rdf:rest*/rdf:first ?member",
202    ),
203];
204
205pub fn expand(data: &mut Dataset, mode: &str) -> Result<()> {
206    if data.iter().any(|q| !q.graph_name.is_default_graph()) {
207        let before = crate::graph::with_union(data);
208        let mut working = before.clone();
209        expand_inner(&mut working, mode)?;
210        crate::graph::commit_additions(data, &before, &working);
211        return Ok(());
212    }
213    expand_inner(data, mode)
214}
215
216/// Infer over the dataset union and put only new statements in the destination.
217/// Original graph contexts remain available to callers and report descriptions.
218pub fn expand_into(data: &mut Dataset, mode: &str, destination: GraphName) -> Result<()> {
219    let before = crate::graph::with_union(data);
220    let mut working = before.clone();
221    expand_inner(&mut working, mode)?;
222    data.extend(
223        working
224            .iter()
225            .filter(|q| !before.contains(*q))
226            .map(|q| Quad::new(q.subject, q.predicate, q.object, destination.clone())),
227    );
228    Ok(())
229}
230
231fn expand_inner(data: &mut Dataset, mode: &str) -> Result<()> {
232    match mode {
233        "none" | "" => return Ok(()),
234        "rdfs" | "owlrl" | "both" => {}
235        _ => return Err(Error::Unsupported(format!("inference: {mode}"))),
236    }
237    let mut rules = Vec::new();
238    if mode != "owlrl" {
239        rules.extend_from_slice(RDFS_RULES);
240    }
241    if mode != "rdfs" {
242        rules.extend_from_slice(OWL_RULES);
243        axioms::owl(data, mode == "both");
244    }
245    let mut initial_resources = if mode != "owlrl" {
246        axioms::resources(data)
247    } else {
248        Vec::new()
249    };
250    let queries = rules
251        .iter()
252        .map(|(template, pattern)| {
253            SparqlEvaluator::new()
254                .parse_query(&format!(
255                    "{PREFIXES}\nCONSTRUCT {{ {template} }} WHERE {{ {pattern} }}"
256                ))
257                .map_err(|e| Error::Runtime(format!("Invalid inference rule: {e}")))
258        })
259        .collect::<Result<Vec<_>>>()?;
260    loop {
261        let mut additions = std::mem::take(&mut initial_resources);
262        for query in &queries {
263            let QueryResults::Graph(triples) =
264                query.clone().on_queryable_dataset(&*data).execute()?
265            else {
266                unreachable!("Inference rules are CONSTRUCT queries")
267            };
268            for triple in triples {
269                let triple = triple?;
270                additions.push(Quad::new(
271                    triple.subject,
272                    triple.predicate,
273                    triple.object,
274                    GraphName::DefaultGraph,
275                ));
276            }
277        }
278        if mode != "rdfs" {
279            // Lists can themselves be inferred, so discover them each round.
280            for quad in data
281                .iter()
282                .filter(|q| q.predicate == iri(OWL, "propertyChainAxiom").as_ref())
283            {
284                let Term::NamedNode(property) = Term::from(quad.subject.into_owned()) else {
285                    return Err(Error::Invalid(
286                        "Property chain predicate must be an IRI".into(),
287                    ));
288                };
289                let chain = rdf_list(data, &quad.object.into_owned())?;
290                if chain.is_empty() {
291                    continue;
292                }
293                let starts: Vec<Term> =
294                    data.iter().map(|q| q.subject.into_owned().into()).collect();
295                for start in starts {
296                    let mut current = vec![start.clone()];
297                    for step in &chain {
298                        let Term::NamedNode(step) = step else {
299                            return Err(Error::Invalid(
300                                "Property chain members must be IRIs".into(),
301                            ));
302                        };
303                        current = current
304                            .iter()
305                            .map(|n| objects(data, n, step))
306                            .collect::<Result<Vec<_>>>()?
307                            .into_iter()
308                            .flatten()
309                            .collect();
310                    }
311                    for end in current {
312                        additions.push(Quad::new(
313                            crate::graph::subject(&start)?,
314                            property.clone(),
315                            end,
316                            GraphName::DefaultGraph,
317                        ));
318                    }
319                }
320            }
321            for quad in data
322                .iter()
323                .filter(|q| q.predicate == iri(OWL, "hasKey").as_ref())
324            {
325                let keys = rdf_list(data, &quad.object.into_owned())?;
326                // The reference reasoner treats an empty key as inactive.
327                if keys.is_empty() {
328                    continue;
329                }
330                let keys = keys
331                    .into_iter()
332                    .map(|key| match key {
333                        Term::NamedNode(key) => Ok(key),
334                        _ => Err(Error::Invalid("Key properties must be IRIs".into())),
335                    })
336                    .collect::<Result<Vec<_>>>()?;
337                let class = quad.subject.into_owned().into();
338                let members: HashSet<_> = subjects(data, &iri(RDF, "type"), Some(&class))?
339                    .into_iter()
340                    .collect();
341                for member in &members {
342                    let mut candidates: Option<HashSet<Term>> = None;
343                    for key in &keys {
344                        // Each key must share at least one exact RDF value.
345                        // Query indexes with terms directly: interpolating a
346                        // blank-node class into SPARQL makes it a variable.
347                        let mut matches = HashSet::new();
348                        for value in objects(data, member, key)? {
349                            matches.extend(
350                                subjects(data, key, Some(&value))?
351                                    .into_iter()
352                                    .filter(|n| n != member && members.contains(n)),
353                            );
354                        }
355                        if let Some(candidates) = &mut candidates {
356                            candidates.retain(|n| matches.contains(n));
357                        } else {
358                            candidates = Some(matches);
359                        }
360                        if candidates.as_ref().is_some_and(HashSet::is_empty) {
361                            break;
362                        }
363                    }
364                    for other in candidates.into_iter().flatten() {
365                        additions.push(Quad::new(
366                            subject(member)?,
367                            iri(OWL, "sameAs"),
368                            other,
369                            GraphName::DefaultGraph,
370                        ));
371                    }
372                }
373            }
374        }
375        let before = data.len();
376        data.extend(additions);
377        if data.len() == before {
378            return Ok(());
379        }
380    }
381}