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 enumerate;
10pub mod frozen;
11pub mod gate;
12pub mod infer;
13mod native_exec;
14pub mod path;
15mod path_plan;
16pub mod profile;
17pub mod report;
18mod sparql;
19pub mod synthesize;
20pub mod validate;
21pub mod value;
22pub mod witness;
23
24pub use enumerate::{
25    EnumOptions, FixpointResult, RepairSolution, candidates, enumerate_repair, repair_to_fixpoint,
26};
27pub use gate::{RepairOutcome, apply, gate};
28pub use infer::{InferenceOutcome, infer, infer_graphs, infer_with_context};
29pub use report::{
30    ValidationReport, ValidationResult, report_to_graph, validate_report, validate_report_graphs,
31    validate_report_graphs_with_mode, validate_report_graphs_with_mode_and_options,
32    validate_report_with_options,
33};
34pub use synthesize::{synthesize, synthesize_focus};
35pub use validate::{
36    NonStratifiable, Reason, ValidationGraphMode, ValidationOptions, ValidationOutcome, Violation,
37    focus_nodes, validate, validate_graphs, validate_graphs_with_mode,
38    validate_graphs_with_mode_and_options, validate_plan, validate_plan_graphs,
39    validate_plan_graphs_with_mode, validate_plan_graphs_with_mode_and_options,
40    validate_plan_with_context, validate_plan_with_context_and_options, validate_plan_with_options,
41    validate_with_context, validate_with_context_and_options, validate_with_options,
42};
43pub use witness::{
44    BlockReason, FocusSat, FocusWitness, PathSupport, RelKind, SatTrace, Witness, satisfy_shape,
45    shape_id_for_iri, witness_node, witness_shape, witness_violations,
46};
47
48#[cfg(test)]
49mod tests {
50    use super::*;
51    use oxrdf::Graph;
52    use shifty_parse::parse_turtle;
53
54    fn run(shapes_and_data: &str) -> ValidationOutcome {
55        let out = parse_turtle(shapes_and_data.as_bytes(), None).unwrap();
56        // data graph = the same graph (shapes + data coexist), as in the suite.
57        let loaded = shifty_parse::load_turtle(shapes_and_data.as_bytes(), None).unwrap();
58        validate(&loaded.graph, &out.schema).expect("stratifiable schema")
59    }
60
61    const PREFIXES: &str = r#"
62        @prefix sh:  <http://www.w3.org/ns/shacl#> .
63        @prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
64        @prefix ex:  <http://ex/> .
65        @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
66    "#;
67
68    #[test]
69    fn planned_validation_preserves_severity_and_applies_threshold() {
70        let ttl = format!(
71            "{PREFIXES}
72            ex:S a sh:NodeShape ;
73                sh:targetNode ex:x ;
74                sh:property ex:InfoShape, ex:WarningShape .
75            ex:InfoShape a sh:PropertyShape ;
76                sh:path ex:required ;
77                sh:minCount 1 ;
78                sh:severity sh:Info .
79            ex:WarningShape a sh:PropertyShape ;
80                sh:path ex:required ;
81                sh:minCount 1 ;
82                sh:severity sh:Warning .
83            "
84        );
85        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
86        let parsed = shifty_parse::parse_loaded(&loaded);
87        let normalized = shifty_opt::normalize(&parsed.schema);
88        let plan = shifty_opt::plan(&normalized);
89
90        let info = validate_plan_with_options(
91            &loaded.graph,
92            &plan,
93            &ValidationOptions {
94                minimum_severity: shifty_algebra::Severity::Info,
95                sort_results: true,
96            },
97        )
98        .unwrap();
99        assert!(!info.conforms);
100        assert_eq!(info.violations.len(), 1);
101        assert_eq!(
102            info.violations[0].severity,
103            shifty_algebra::Severity::Warning
104        );
105        let mut severities: Vec<_> = info.violations[0]
106            .reasons
107            .iter()
108            .map(|reason| reason.severity.clone())
109            .collect();
110        severities.sort_by_key(shifty_algebra::Severity::rank);
111        assert_eq!(
112            severities,
113            vec![
114                shifty_algebra::Severity::Info,
115                shifty_algebra::Severity::Warning
116            ]
117        );
118
119        let warning = validate_plan_with_options(
120            &loaded.graph,
121            &plan,
122            &ValidationOptions {
123                minimum_severity: shifty_algebra::Severity::Warning,
124                sort_results: true,
125            },
126        )
127        .unwrap();
128        assert!(!warning.conforms);
129
130        let violation = validate_plan_with_options(
131            &loaded.graph,
132            &plan,
133            &ValidationOptions {
134                minimum_severity: shifty_algebra::Severity::Violation,
135                sort_results: true,
136            },
137        )
138        .unwrap();
139        assert!(violation.conforms);
140        assert_eq!(violation.violations.len(), 1);
141
142        let report = validate_report_with_options(
143            &loaded,
144            &loaded.graph,
145            &ValidationOptions {
146                minimum_severity: shifty_algebra::Severity::Violation,
147                sort_results: true,
148            },
149        );
150        assert!(report.conforms);
151        assert_eq!(report.results.len(), 2);
152    }
153
154    #[test]
155    fn validation_findings_sort_by_severity_then_focus_node() {
156        let ttl = format!(
157            "{PREFIXES}
158            ex:InfoShape a sh:NodeShape ;
159                sh:targetNode ex:a ;
160                sh:nodeKind sh:Literal ;
161                sh:severity sh:Info .
162            ex:WarningShape a sh:NodeShape ;
163                sh:targetNode ex:z ;
164                sh:nodeKind sh:Literal ;
165                sh:severity sh:Warning .
166            ex:ViolationShape a sh:NodeShape ;
167                sh:targetNode ex:m ;
168                sh:nodeKind sh:Literal .
169            "
170        );
171        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
172        let parsed = shifty_parse::parse_loaded(&loaded);
173        let plan = shifty_opt::plan(&shifty_opt::normalize(&parsed.schema));
174        let outcome = validate_plan(&loaded.graph, &plan).unwrap();
175
176        let ordered: Vec<_> = outcome
177            .violations
178            .iter()
179            .map(|finding| (finding.severity.clone(), finding.focus.to_string()))
180            .collect();
181        assert_eq!(
182            ordered,
183            vec![
184                (
185                    shifty_algebra::Severity::Violation,
186                    "<http://ex/m>".to_string()
187                ),
188                (
189                    shifty_algebra::Severity::Warning,
190                    "<http://ex/z>".to_string()
191                ),
192                (shifty_algebra::Severity::Info, "<http://ex/a>".to_string()),
193            ]
194        );
195    }
196
197    #[test]
198    fn reports_specific_failing_constraints() {
199        let ttl = format!(
200            "{PREFIXES}
201            ex:S a sh:NodeShape ;
202                sh:targetNode ex:x ;
203                sh:closed true ;
204                sh:ignoredProperties ( rdf:type ) ;
205                sh:property [ sh:path ex:age ; sh:datatype xsd:integer ; sh:maxCount 1 ] .
206            ex:x ex:age \"foo\" , 5 ; ex:extra 1 .
207            "
208        );
209        let outcome = run(&ttl);
210        assert!(!outcome.conforms);
211        assert_eq!(outcome.violations.len(), 1);
212        let msgs: Vec<&str> = outcome.violations[0]
213            .reasons
214            .iter()
215            .map(|r| r.message.as_str())
216            .collect();
217        // each distinct constraint is reported, not just "the node failed"
218        assert!(
219            msgs.iter().any(|m| m.contains("datatype(xsd:integer)")),
220            "missing datatype reason: {msgs:?}"
221        );
222        assert!(
223            msgs.iter().any(|m| m.contains("at most 1")),
224            "missing maxCount reason: {msgs:?}"
225        );
226        assert!(
227            msgs.iter()
228                .any(|m| m.contains("closed") && m.contains("extra")),
229            "missing closed reason: {msgs:?}"
230        );
231    }
232
233    #[test]
234    fn cardinality_and_datatype() {
235        let ttl = format!(
236            "{PREFIXES}
237            ex:S a sh:NodeShape ;
238                sh:targetNode ex:alice, ex:bob ;
239                sh:property [ sh:path ex:age ; sh:maxCount 1 ; sh:datatype xsd:integer ] .
240            ex:alice ex:age 30 .
241            ex:bob   ex:age 30 ; ex:age 40 .
242            "
243        );
244        let outcome = run(&ttl);
245        assert!(!outcome.conforms);
246        // only ex:bob violates maxCount 1
247        let bad: Vec<_> = outcome
248            .violations
249            .iter()
250            .map(|r| r.focus.to_string())
251            .collect();
252        assert_eq!(bad, vec!["<http://ex/bob>".to_string()]);
253    }
254
255    #[test]
256    fn qualified_value_shape_disjoint_uses_all_sibling_property_shapes() {
257        let ttl = format!(
258            "{PREFIXES}
259            ex:S a sh:NodeShape ;
260                sh:targetNode ex:x ;
261                sh:property ex:A, ex:B .
262            ex:A a sh:PropertyShape ;
263                sh:path ex:p ;
264                sh:qualifiedValueShape [ sh:class ex:TypeA ] ;
265                sh:qualifiedValueShapesDisjoint true ;
266                sh:qualifiedMinCount 1 .
267            ex:B a sh:PropertyShape ;
268                sh:path ex:q ;
269                sh:qualifiedValueShape [ sh:class ex:TypeB ] ;
270                sh:qualifiedValueShapesDisjoint true ;
271                sh:qualifiedMaxCount 10 .
272            ex:x ex:p ex:value .
273            ex:value a ex:TypeA, ex:TypeB .
274            "
275        );
276        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
277        assert!(
278            parsed.diagnostics.is_empty(),
279            "diags: {:?}",
280            parsed.diagnostics
281        );
282        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
283
284        let algebra = validate(&loaded.graph, &parsed.schema).unwrap();
285        assert!(!algebra.conforms);
286
287        let report = validate_report(&loaded, &loaded.graph);
288        assert!(!report.conforms);
289        assert_eq!(report.results.len(), 1);
290        assert_eq!(
291            report.results[0].component.as_str(),
292            "http://www.w3.org/ns/shacl#QualifiedMinCountConstraintComponent"
293        );
294    }
295
296    #[test]
297    fn disjoint_on_node_shape_uses_the_focus_node_as_the_value() {
298        let ttl = format!(
299            "{PREFIXES}
300            ex:S a sh:NodeShape ;
301                sh:targetNode ex:valid, ex:invalid ;
302                sh:disjoint ex:p .
303            ex:valid ex:p ex:other .
304            ex:invalid ex:p ex:invalid .
305            "
306        );
307        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
308        assert!(
309            parsed.diagnostics.is_empty(),
310            "diags: {:?}",
311            parsed.diagnostics
312        );
313        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
314
315        let algebra = validate(&loaded.graph, &parsed.schema).unwrap();
316        assert!(!algebra.conforms);
317        assert_eq!(algebra.violations.len(), 1);
318        assert_eq!(
319            algebra.violations[0].focus.to_string(),
320            "<http://ex/invalid>"
321        );
322
323        let normalized = shifty_opt::normalize(&parsed.schema);
324        let plan = shifty_opt::plan(&normalized);
325        let planned = validate_plan(&loaded.graph, &plan).unwrap();
326        assert_eq!(planned.conforms, algebra.conforms);
327        assert_eq!(planned.violations.len(), algebra.violations.len());
328
329        let report = validate_report(&loaded, &loaded.graph);
330        assert!(!report.conforms);
331        assert_eq!(report.results.len(), 1);
332        assert_eq!(
333            report.results[0].component.as_str(),
334            "http://www.w3.org/ns/shacl#DisjointConstraintComponent"
335        );
336        assert_eq!(
337            report.results[0].value.as_ref().map(ToString::to_string),
338            Some("<http://ex/invalid>".to_string())
339        );
340    }
341
342    #[test]
343    fn equals_on_node_shape_uses_the_focus_node_as_the_value() {
344        let ttl = format!(
345            "{PREFIXES}
346            ex:S a sh:NodeShape ;
347                sh:targetNode ex:valid, ex:extra, ex:missing ;
348                sh:equals ex:p .
349            ex:valid ex:p ex:valid .
350            ex:extra ex:p ex:extra, ex:other .
351            "
352        );
353        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
354        assert!(
355            parsed.diagnostics.is_empty(),
356            "diags: {:?}",
357            parsed.diagnostics
358        );
359        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
360
361        let algebra = validate(&loaded.graph, &parsed.schema).unwrap();
362        assert!(!algebra.conforms);
363        let mut foci: Vec<_> = algebra
364            .violations
365            .iter()
366            .map(|violation| violation.focus.to_string())
367            .collect();
368        foci.sort();
369        assert_eq!(
370            foci,
371            [
372                "<http://ex/extra>".to_string(),
373                "<http://ex/missing>".to_string()
374            ]
375        );
376
377        let normalized = shifty_opt::normalize(&parsed.schema);
378        let plan = shifty_opt::plan(&normalized);
379        let planned = validate_plan(&loaded.graph, &plan).unwrap();
380        assert_eq!(planned.conforms, algebra.conforms);
381        assert_eq!(planned.violations.len(), algebra.violations.len());
382
383        let report = validate_report(&loaded, &loaded.graph);
384        assert!(!report.conforms);
385        assert_eq!(report.results.len(), 2);
386        assert!(report.results.iter().all(|result| result.component.as_str()
387            == "http://www.w3.org/ns/shacl#EqualsConstraintComponent"));
388    }
389
390    #[test]
391    fn datatype_violation() {
392        let ttl = format!(
393            "{PREFIXES}
394            ex:S a sh:NodeShape ;
395                sh:targetNode ex:x ;
396                sh:property [ sh:path ex:p ; sh:datatype xsd:integer ] .
397            ex:x ex:p \"hello\" .
398            "
399        );
400        assert!(!run(&ttl).conforms);
401    }
402
403    #[test]
404    fn nodekind_and_class_target() {
405        let ttl = format!(
406            "{PREFIXES}
407            ex:S a sh:NodeShape ;
408                sh:targetClass ex:Person ;
409                sh:property [ sh:path ex:knows ; sh:nodeKind sh:IRI ] .
410            ex:alice a ex:Person ; ex:knows ex:bob .
411            ex:carol a ex:Person ; ex:knows \"notaniri\" .
412            "
413        );
414        let outcome = run(&ttl);
415        assert!(!outcome.conforms);
416        let bad: Vec<_> = outcome
417            .violations
418            .iter()
419            .map(|r| r.focus.to_string())
420            .collect();
421        assert_eq!(bad, vec!["<http://ex/carol>".to_string()]);
422    }
423
424    #[test]
425    fn recursion_over_cyclic_data_terminates() {
426        // S requires every ex:knows neighbour to also satisfy S; data is a cycle.
427        let ttl = format!(
428            "{PREFIXES}
429            ex:S a sh:NodeShape ;
430                sh:targetNode ex:a ;
431                sh:property [ sh:path ex:knows ; sh:node ex:S ; sh:nodeKind sh:IRI ] .
432            ex:a ex:knows ex:b .
433            ex:b ex:knows ex:a .
434            "
435        );
436        // Must terminate; with all-IRI neighbours it conforms under the
437        // provisional cycle-breaking semantics.
438        assert!(run(&ttl).conforms);
439    }
440
441    #[test]
442    fn empty_graph_conforms() {
443        let outcome = validate(&Graph::new(), &shifty_algebra::Schema::new()).unwrap();
444        assert!(outcome.conforms);
445    }
446
447    #[test]
448    fn non_stratifiable_schema_is_diagnosed() {
449        // S := ¬∃p.S — recursion through negation; no defined 2-valued semantics.
450        let ttl = format!(
451            "{PREFIXES}
452            ex:S a sh:NodeShape ;
453                sh:targetNode ex:x ;
454                sh:not [ sh:path ex:p ; sh:qualifiedValueShape ex:S ; sh:qualifiedMinCount 1 ] .
455            ex:x ex:p ex:y .
456            "
457        );
458        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
459        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
460        assert!(validate(&loaded.graph, &out.schema).is_err());
461    }
462
463    fn triple(s: &str, p: &str, o: &str) -> oxrdf::Triple {
464        use oxrdf::NamedNode;
465        oxrdf::Triple::new(
466            NamedNode::new(s).unwrap(),
467            NamedNode::new(p).unwrap(),
468            NamedNode::new(o).unwrap(),
469        )
470    }
471
472    #[test]
473    fn triple_rule_infers_from_path() {
474        // copy each ex:knows value to ex:knows2
475        let ttl = format!(
476            "{PREFIXES}
477            ex:S a sh:NodeShape ; sh:targetClass ex:Person ;
478                sh:rule [ a sh:TripleRule ;
479                    sh:subject sh:this ; sh:predicate ex:knows2 ;
480                    sh:object [ sh:path ex:knows ] ] .
481            ex:a a ex:Person ; ex:knows ex:b .
482            "
483        );
484        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
485        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
486        let outcome = infer(&loaded.graph, &out.schema).unwrap();
487        assert_eq!(outcome.inferred.len(), 1);
488        assert!(
489            outcome
490                .graph
491                .contains(&triple("http://ex/a", "http://ex/knows2", "http://ex/b"))
492        );
493    }
494
495    #[test]
496    fn inference_reaches_a_fixpoint() {
497        // ex:reaches := ex:knows ∪ (ex:knows / ex:reaches) — transitive closure
498        // a→b→c, so a reaches c is derivable only after b reaches c.
499        let ttl = format!(
500            "{PREFIXES}
501            ex:S a sh:NodeShape ; sh:targetClass ex:Person ;
502                sh:rule [ a sh:TripleRule ;
503                    sh:subject sh:this ; sh:predicate ex:reaches ;
504                    sh:object [ sh:path [ sh:alternativePath ( ex:knows ( ex:knows ex:reaches ) ) ] ] ] .
505            ex:a a ex:Person ; ex:knows ex:b .
506            ex:b a ex:Person ; ex:knows ex:c .
507            ex:c a ex:Person .
508            "
509        );
510        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
511        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
512        let outcome = infer(&loaded.graph, &out.schema).unwrap();
513        assert!(
514            outcome
515                .graph
516                .contains(&triple("http://ex/a", "http://ex/reaches", "http://ex/b"))
517        );
518        assert!(
519            outcome
520                .graph
521                .contains(&triple("http://ex/b", "http://ex/reaches", "http://ex/c"))
522        );
523        // the fixpoint result: a reaches c (only via b reaches c)
524        assert!(
525            outcome
526                .graph
527                .contains(&triple("http://ex/a", "http://ex/reaches", "http://ex/c"))
528        );
529    }
530
531    #[test]
532    fn later_order_output_reactivates_an_earlier_rule() {
533        let ttl = format!(
534            "{PREFIXES}
535            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
536                sh:rule [
537                    a sh:TripleRule ; sh:order 0 ;
538                    sh:subject sh:this ; sh:predicate ex:done ;
539                    sh:object [ sh:path ex:ready ]
540                ] ;
541                sh:rule [
542                    a sh:TripleRule ; sh:order 1 ;
543                    sh:subject sh:this ; sh:predicate ex:ready ;
544                    sh:object ex:y
545                ] .
546            "
547        );
548        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
549        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
550        let outcome = infer(&loaded.graph, &out.schema).unwrap();
551
552        assert!(
553            outcome
554                .graph
555                .contains(&triple("http://ex/x", "http://ex/ready", "http://ex/y"))
556        );
557        assert!(
558            outcome
559                .graph
560                .contains(&triple("http://ex/x", "http://ex/done", "http://ex/y"))
561        );
562    }
563
564    #[test]
565    fn inferred_triples_can_create_new_rule_targets() {
566        let ttl = format!(
567            "{PREFIXES}
568            ex:Seed a sh:NodeShape ; sh:targetNode ex:x ;
569                sh:rule [
570                    a sh:TripleRule ;
571                    sh:subject sh:this ; sh:predicate ex:eligible ;
572                    sh:object ex:y
573                ] .
574            ex:Eligible a sh:NodeShape ; sh:targetSubjectsOf ex:eligible ;
575                sh:rule [
576                    a sh:TripleRule ;
577                    sh:subject sh:this ; sh:predicate ex:classified ;
578                    sh:object ex:yes
579                ] .
580            "
581        );
582        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
583        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
584        let outcome = infer(&loaded.graph, &out.schema).unwrap();
585
586        assert!(outcome.graph.contains(&triple(
587            "http://ex/x",
588            "http://ex/classified",
589            "http://ex/yes",
590        )));
591    }
592
593    #[test]
594    fn split_inference_uses_shapes_graph_as_rule_context() {
595        let shapes_ttl = format!(
596            "{PREFIXES}
597            ex:InverseShape a sh:NodeShape ;
598                sh:targetClass ex:Thing ;
599                sh:rule [
600                    a sh:SPARQLRule ;
601                    sh:construct \"\"\"
602                        CONSTRUCT {{ ?o ?inverse $this }}
603                        WHERE {{
604                            $this ?predicate ?o .
605                            ?predicate ex:inverseOf ?inverse .
606                        }}
607                    \"\"\"
608                ] .
609            ex:p ex:inverseOf ex:q .
610            "
611        );
612        let data_ttl = format!(
613            "{PREFIXES}
614            ex:a a ex:Thing ; ex:p ex:b .
615            "
616        );
617        let shapes = shifty_parse::load_turtle(shapes_ttl.as_bytes(), None).unwrap();
618        let parsed = shifty_parse::parse_loaded(&shapes);
619        let data = shifty_parse::load_turtle(data_ttl.as_bytes(), None).unwrap();
620
621        let outcome = infer_graphs(&data.graph, &shapes.graph, &parsed.schema).unwrap();
622
623        assert!(
624            outcome
625                .graph
626                .contains(&triple("http://ex/b", "http://ex/q", "http://ex/a"))
627        );
628        assert_eq!(outcome.inferred.len(), 1);
629    }
630}