Skip to main content

shifty_engine/
lib.rs

1//! Validation + SHACL-AF inference execution (Layers 3, 6, 7).
2//!
3//! Layer 3 lives here: the naive denotational evaluator that is the conformance
4//! oracle — relational path evaluation ([`path`]), value-type checks
5//! ([`value`]), and shape/schema satisfaction ([`validate`]). The rule/fixpoint
6//! inference engine (Layer 6) and compiled executors (Layer 7) come later; every
7//! execution mode must agree with this oracle.
8
9pub mod frozen;
10pub mod infer;
11mod native_exec;
12pub mod path;
13mod path_plan;
14pub mod profile;
15pub mod report;
16mod sparql;
17pub mod validate;
18pub mod value;
19
20pub use infer::{InferenceOutcome, infer, infer_graphs, infer_with_context};
21pub use report::{
22    ValidationReport, ValidationResult, report_to_graph, validate_report, validate_report_graphs,
23    validate_report_graphs_with_mode,
24};
25pub use validate::{
26    NonStratifiable, Reason, ValidationGraphMode, ValidationOutcome, Violation, focus_nodes,
27    validate, validate_graphs, validate_graphs_with_mode, validate_plan, validate_plan_graphs,
28    validate_plan_graphs_with_mode, validate_plan_with_context, validate_with_context,
29};
30
31#[cfg(test)]
32mod tests {
33    use super::*;
34    use oxrdf::Graph;
35    use shifty_parse::parse_turtle;
36
37    fn run(shapes_and_data: &str) -> ValidationOutcome {
38        let out = parse_turtle(shapes_and_data.as_bytes(), None).unwrap();
39        // data graph = the same graph (shapes + data coexist), as in the suite.
40        let loaded = shifty_parse::load_turtle(shapes_and_data.as_bytes(), None).unwrap();
41        validate(&loaded.graph, &out.schema).expect("stratifiable schema")
42    }
43
44    const PREFIXES: &str = r#"
45        @prefix sh:  <http://www.w3.org/ns/shacl#> .
46        @prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
47        @prefix ex:  <http://ex/> .
48        @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
49    "#;
50
51    #[test]
52    fn reports_specific_failing_constraints() {
53        let ttl = format!(
54            "{PREFIXES}
55            ex:S a sh:NodeShape ;
56                sh:targetNode ex:x ;
57                sh:closed true ;
58                sh:ignoredProperties ( rdf:type ) ;
59                sh:property [ sh:path ex:age ; sh:datatype xsd:integer ; sh:maxCount 1 ] .
60            ex:x ex:age \"foo\" , 5 ; ex:extra 1 .
61            "
62        );
63        let outcome = run(&ttl);
64        assert!(!outcome.conforms);
65        assert_eq!(outcome.violations.len(), 1);
66        let msgs: Vec<&str> = outcome.violations[0]
67            .reasons
68            .iter()
69            .map(|r| r.message.as_str())
70            .collect();
71        // each distinct constraint is reported, not just "the node failed"
72        assert!(
73            msgs.iter().any(|m| m.contains("datatype(xsd:integer)")),
74            "missing datatype reason: {msgs:?}"
75        );
76        assert!(
77            msgs.iter().any(|m| m.contains("at most 1")),
78            "missing maxCount reason: {msgs:?}"
79        );
80        assert!(
81            msgs.iter()
82                .any(|m| m.contains("closed") && m.contains("extra")),
83            "missing closed reason: {msgs:?}"
84        );
85    }
86
87    #[test]
88    fn cardinality_and_datatype() {
89        let ttl = format!(
90            "{PREFIXES}
91            ex:S a sh:NodeShape ;
92                sh:targetNode ex:alice, ex:bob ;
93                sh:property [ sh:path ex:age ; sh:maxCount 1 ; sh:datatype xsd:integer ] .
94            ex:alice ex:age 30 .
95            ex:bob   ex:age 30 ; ex:age 40 .
96            "
97        );
98        let outcome = run(&ttl);
99        assert!(!outcome.conforms);
100        // only ex:bob violates maxCount 1
101        let bad: Vec<_> = outcome
102            .violations
103            .iter()
104            .map(|r| r.focus.to_string())
105            .collect();
106        assert_eq!(bad, vec!["<http://ex/bob>".to_string()]);
107    }
108
109    #[test]
110    fn qualified_value_shape_disjoint_uses_all_sibling_property_shapes() {
111        let ttl = format!(
112            "{PREFIXES}
113            ex:S a sh:NodeShape ;
114                sh:targetNode ex:x ;
115                sh:property ex:A, ex:B .
116            ex:A a sh:PropertyShape ;
117                sh:path ex:p ;
118                sh:qualifiedValueShape [ sh:class ex:TypeA ] ;
119                sh:qualifiedValueShapesDisjoint true ;
120                sh:qualifiedMinCount 1 .
121            ex:B a sh:PropertyShape ;
122                sh:path ex:q ;
123                sh:qualifiedValueShape [ sh:class ex:TypeB ] ;
124                sh:qualifiedValueShapesDisjoint true ;
125                sh:qualifiedMaxCount 10 .
126            ex:x ex:p ex:value .
127            ex:value a ex:TypeA, ex:TypeB .
128            "
129        );
130        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
131        assert!(
132            parsed.diagnostics.is_empty(),
133            "diags: {:?}",
134            parsed.diagnostics
135        );
136        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
137
138        let algebra = validate(&loaded.graph, &parsed.schema).unwrap();
139        assert!(!algebra.conforms);
140
141        let report = validate_report(&loaded, &loaded.graph);
142        assert!(!report.conforms);
143        assert_eq!(report.results.len(), 1);
144        assert_eq!(
145            report.results[0].component.as_str(),
146            "http://www.w3.org/ns/shacl#QualifiedMinCountConstraintComponent"
147        );
148    }
149
150    #[test]
151    fn disjoint_on_node_shape_uses_the_focus_node_as_the_value() {
152        let ttl = format!(
153            "{PREFIXES}
154            ex:S a sh:NodeShape ;
155                sh:targetNode ex:valid, ex:invalid ;
156                sh:disjoint ex:p .
157            ex:valid ex:p ex:other .
158            ex:invalid ex:p ex:invalid .
159            "
160        );
161        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
162        assert!(
163            parsed.diagnostics.is_empty(),
164            "diags: {:?}",
165            parsed.diagnostics
166        );
167        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
168
169        let algebra = validate(&loaded.graph, &parsed.schema).unwrap();
170        assert!(!algebra.conforms);
171        assert_eq!(algebra.violations.len(), 1);
172        assert_eq!(
173            algebra.violations[0].focus.to_string(),
174            "<http://ex/invalid>"
175        );
176
177        let normalized = shifty_opt::normalize(&parsed.schema);
178        let plan = shifty_opt::plan(&normalized);
179        let planned = validate_plan(&loaded.graph, &plan).unwrap();
180        assert_eq!(planned.conforms, algebra.conforms);
181        assert_eq!(planned.violations.len(), algebra.violations.len());
182
183        let report = validate_report(&loaded, &loaded.graph);
184        assert!(!report.conforms);
185        assert_eq!(report.results.len(), 1);
186        assert_eq!(
187            report.results[0].component.as_str(),
188            "http://www.w3.org/ns/shacl#DisjointConstraintComponent"
189        );
190        assert_eq!(
191            report.results[0].value.as_ref().map(ToString::to_string),
192            Some("<http://ex/invalid>".to_string())
193        );
194    }
195
196    #[test]
197    fn equals_on_node_shape_uses_the_focus_node_as_the_value() {
198        let ttl = format!(
199            "{PREFIXES}
200            ex:S a sh:NodeShape ;
201                sh:targetNode ex:valid, ex:extra, ex:missing ;
202                sh:equals ex:p .
203            ex:valid ex:p ex:valid .
204            ex:extra ex:p ex:extra, ex:other .
205            "
206        );
207        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
208        assert!(
209            parsed.diagnostics.is_empty(),
210            "diags: {:?}",
211            parsed.diagnostics
212        );
213        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
214
215        let algebra = validate(&loaded.graph, &parsed.schema).unwrap();
216        assert!(!algebra.conforms);
217        let mut foci: Vec<_> = algebra
218            .violations
219            .iter()
220            .map(|violation| violation.focus.to_string())
221            .collect();
222        foci.sort();
223        assert_eq!(
224            foci,
225            [
226                "<http://ex/extra>".to_string(),
227                "<http://ex/missing>".to_string()
228            ]
229        );
230
231        let normalized = shifty_opt::normalize(&parsed.schema);
232        let plan = shifty_opt::plan(&normalized);
233        let planned = validate_plan(&loaded.graph, &plan).unwrap();
234        assert_eq!(planned.conforms, algebra.conforms);
235        assert_eq!(planned.violations.len(), algebra.violations.len());
236
237        let report = validate_report(&loaded, &loaded.graph);
238        assert!(!report.conforms);
239        assert_eq!(report.results.len(), 2);
240        assert!(report.results.iter().all(|result| result.component.as_str()
241            == "http://www.w3.org/ns/shacl#EqualsConstraintComponent"));
242    }
243
244    #[test]
245    fn datatype_violation() {
246        let ttl = format!(
247            "{PREFIXES}
248            ex:S a sh:NodeShape ;
249                sh:targetNode ex:x ;
250                sh:property [ sh:path ex:p ; sh:datatype xsd:integer ] .
251            ex:x ex:p \"hello\" .
252            "
253        );
254        assert!(!run(&ttl).conforms);
255    }
256
257    #[test]
258    fn nodekind_and_class_target() {
259        let ttl = format!(
260            "{PREFIXES}
261            ex:S a sh:NodeShape ;
262                sh:targetClass ex:Person ;
263                sh:property [ sh:path ex:knows ; sh:nodeKind sh:IRI ] .
264            ex:alice a ex:Person ; ex:knows ex:bob .
265            ex:carol a ex:Person ; ex:knows \"notaniri\" .
266            "
267        );
268        let outcome = run(&ttl);
269        assert!(!outcome.conforms);
270        let bad: Vec<_> = outcome
271            .violations
272            .iter()
273            .map(|r| r.focus.to_string())
274            .collect();
275        assert_eq!(bad, vec!["<http://ex/carol>".to_string()]);
276    }
277
278    #[test]
279    fn recursion_over_cyclic_data_terminates() {
280        // S requires every ex:knows neighbour to also satisfy S; data is a cycle.
281        let ttl = format!(
282            "{PREFIXES}
283            ex:S a sh:NodeShape ;
284                sh:targetNode ex:a ;
285                sh:property [ sh:path ex:knows ; sh:node ex:S ; sh:nodeKind sh:IRI ] .
286            ex:a ex:knows ex:b .
287            ex:b ex:knows ex:a .
288            "
289        );
290        // Must terminate; with all-IRI neighbours it conforms under the
291        // provisional cycle-breaking semantics.
292        assert!(run(&ttl).conforms);
293    }
294
295    #[test]
296    fn empty_graph_conforms() {
297        let outcome = validate(&Graph::new(), &shifty_algebra::Schema::new()).unwrap();
298        assert!(outcome.conforms);
299    }
300
301    #[test]
302    fn non_stratifiable_schema_is_diagnosed() {
303        // S := ¬∃p.S — recursion through negation; no defined 2-valued semantics.
304        let ttl = format!(
305            "{PREFIXES}
306            ex:S a sh:NodeShape ;
307                sh:targetNode ex:x ;
308                sh:not [ sh:path ex:p ; sh:qualifiedValueShape ex:S ; sh:qualifiedMinCount 1 ] .
309            ex:x ex:p ex:y .
310            "
311        );
312        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
313        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
314        assert!(validate(&loaded.graph, &out.schema).is_err());
315    }
316
317    fn triple(s: &str, p: &str, o: &str) -> oxrdf::Triple {
318        use oxrdf::NamedNode;
319        oxrdf::Triple::new(
320            NamedNode::new(s).unwrap(),
321            NamedNode::new(p).unwrap(),
322            NamedNode::new(o).unwrap(),
323        )
324    }
325
326    #[test]
327    fn triple_rule_infers_from_path() {
328        // copy each ex:knows value to ex:knows2
329        let ttl = format!(
330            "{PREFIXES}
331            ex:S a sh:NodeShape ; sh:targetClass ex:Person ;
332                sh:rule [ a sh:TripleRule ;
333                    sh:subject sh:this ; sh:predicate ex:knows2 ;
334                    sh:object [ sh:path ex:knows ] ] .
335            ex:a a ex:Person ; ex:knows ex:b .
336            "
337        );
338        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
339        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
340        let outcome = infer(&loaded.graph, &out.schema).unwrap();
341        assert_eq!(outcome.inferred.len(), 1);
342        assert!(
343            outcome
344                .graph
345                .contains(&triple("http://ex/a", "http://ex/knows2", "http://ex/b"))
346        );
347    }
348
349    #[test]
350    fn inference_reaches_a_fixpoint() {
351        // ex:reaches := ex:knows ∪ (ex:knows / ex:reaches) — transitive closure
352        // a→b→c, so a reaches c is derivable only after b reaches c.
353        let ttl = format!(
354            "{PREFIXES}
355            ex:S a sh:NodeShape ; sh:targetClass ex:Person ;
356                sh:rule [ a sh:TripleRule ;
357                    sh:subject sh:this ; sh:predicate ex:reaches ;
358                    sh:object [ sh:path [ sh:alternativePath ( ex:knows ( ex:knows ex:reaches ) ) ] ] ] .
359            ex:a a ex:Person ; ex:knows ex:b .
360            ex:b a ex:Person ; ex:knows ex:c .
361            ex:c a ex:Person .
362            "
363        );
364        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
365        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
366        let outcome = infer(&loaded.graph, &out.schema).unwrap();
367        assert!(
368            outcome
369                .graph
370                .contains(&triple("http://ex/a", "http://ex/reaches", "http://ex/b"))
371        );
372        assert!(
373            outcome
374                .graph
375                .contains(&triple("http://ex/b", "http://ex/reaches", "http://ex/c"))
376        );
377        // the fixpoint result: a reaches c (only via b reaches c)
378        assert!(
379            outcome
380                .graph
381                .contains(&triple("http://ex/a", "http://ex/reaches", "http://ex/c"))
382        );
383    }
384
385    #[test]
386    fn later_order_output_reactivates_an_earlier_rule() {
387        let ttl = format!(
388            "{PREFIXES}
389            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
390                sh:rule [
391                    a sh:TripleRule ; sh:order 0 ;
392                    sh:subject sh:this ; sh:predicate ex:done ;
393                    sh:object [ sh:path ex:ready ]
394                ] ;
395                sh:rule [
396                    a sh:TripleRule ; sh:order 1 ;
397                    sh:subject sh:this ; sh:predicate ex:ready ;
398                    sh:object ex:y
399                ] .
400            "
401        );
402        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
403        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
404        let outcome = infer(&loaded.graph, &out.schema).unwrap();
405
406        assert!(
407            outcome
408                .graph
409                .contains(&triple("http://ex/x", "http://ex/ready", "http://ex/y"))
410        );
411        assert!(
412            outcome
413                .graph
414                .contains(&triple("http://ex/x", "http://ex/done", "http://ex/y"))
415        );
416    }
417
418    #[test]
419    fn inferred_triples_can_create_new_rule_targets() {
420        let ttl = format!(
421            "{PREFIXES}
422            ex:Seed a sh:NodeShape ; sh:targetNode ex:x ;
423                sh:rule [
424                    a sh:TripleRule ;
425                    sh:subject sh:this ; sh:predicate ex:eligible ;
426                    sh:object ex:y
427                ] .
428            ex:Eligible a sh:NodeShape ; sh:targetSubjectsOf ex:eligible ;
429                sh:rule [
430                    a sh:TripleRule ;
431                    sh:subject sh:this ; sh:predicate ex:classified ;
432                    sh:object ex:yes
433                ] .
434            "
435        );
436        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
437        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
438        let outcome = infer(&loaded.graph, &out.schema).unwrap();
439
440        assert!(outcome.graph.contains(&triple(
441            "http://ex/x",
442            "http://ex/classified",
443            "http://ex/yes",
444        )));
445    }
446
447    #[test]
448    fn split_inference_uses_shapes_graph_as_rule_context() {
449        let shapes_ttl = format!(
450            "{PREFIXES}
451            ex:InverseShape a sh:NodeShape ;
452                sh:targetClass ex:Thing ;
453                sh:rule [
454                    a sh:SPARQLRule ;
455                    sh:construct \"\"\"
456                        CONSTRUCT {{ ?o ?inverse $this }}
457                        WHERE {{
458                            $this ?predicate ?o .
459                            ?predicate ex:inverseOf ?inverse .
460                        }}
461                    \"\"\"
462                ] .
463            ex:p ex:inverseOf ex:q .
464            "
465        );
466        let data_ttl = format!(
467            "{PREFIXES}
468            ex:a a ex:Thing ; ex:p ex:b .
469            "
470        );
471        let shapes = shifty_parse::load_turtle(shapes_ttl.as_bytes(), None).unwrap();
472        let parsed = shifty_parse::parse_loaded(&shapes);
473        let data = shifty_parse::load_turtle(data_ttl.as_bytes(), None).unwrap();
474
475        let outcome = infer_graphs(&data.graph, &shapes.graph, &parsed.schema).unwrap();
476
477        assert!(
478            outcome
479                .graph
480                .contains(&triple("http://ex/b", "http://ex/q", "http://ex/a"))
481        );
482        assert_eq!(outcome.inferred.len(), 1);
483    }
484}