Skip to main content

shifty_engine/
gate.rs

1//! The re-validation gate (`docs/06-repair.md` §8).
2//!
3//! A repair is sound only if it does not trade one violation for another, so the
4//! gate is **whole-graph**, not focus-local: it re-validates `G ⊕ ΔG` and diffs
5//! the violation set against `G`'s. It **decides nothing and applies nothing** —
6//! it returns a [`RepairOutcome`] the driver reads.
7//!
8//! The verdict is a set difference over the existing [`validate`] oracle, keyed by
9//! `(focus, statement)`. Because the contract is this delta (not a bare `v ⊨ φ`),
10//! a cheaper *affected-set* re-validation can replace the implementation later
11//! with identical semantics.
12
13use crate::validate::{NonStratifiable, Violation, validate_with_context};
14use oxrdf::{Graph, Term};
15use serde::{Deserialize, Serialize};
16use shifty_algebra::Schema;
17use shifty_repair::GraphDelta;
18use std::collections::{HashMap, HashSet};
19
20/// The gate's verdict on a candidate `ΔG`.
21#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
22pub struct RepairOutcome {
23    /// Violations present in `G` that `ΔG` removes.
24    pub fixed: Vec<Violation>,
25    /// New violations `ΔG` would cause (empty ⟺ sound).
26    pub introduced: Vec<Violation>,
27    /// Pre-existing violations still unaddressed.
28    pub remaining: Vec<Violation>,
29}
30
31impl RepairOutcome {
32    /// Sound iff it introduces no new violation anywhere.
33    pub fn is_sound(&self) -> bool {
34        self.introduced.is_empty()
35    }
36
37    /// Progress iff sound *and* it fixes at least one violation.
38    pub fn is_progress(&self) -> bool {
39        self.is_sound() && !self.fixed.is_empty()
40    }
41}
42
43/// Validate `data ⊕ delta` against `schema` and diff the violations against
44/// `data`'s. Paths and the class hierarchy are evaluated against `context`, which
45/// should contain `data`; pass `data` again when there is no separate
46/// shapes/ontology graph. For split inputs `context = data ∪ shapes`, so the
47/// re-validation sees `(data ⊕ δ)` for focus and `(context ⊕ δ)` for evaluation
48/// — letting the gate discharge a constraint like `sh:class C` against a value
49/// typed with a *subclass* of `C` when the hierarchy lives in the shapes graph.
50/// The gate dual of [`crate::validate_with_context`]. Errors only if the schema
51/// is not stratifiable.
52pub fn gate(
53    data: &Graph,
54    context: &Graph,
55    schema: &Schema,
56    delta: &GraphDelta,
57) -> Result<RepairOutcome, NonStratifiable> {
58    let baseline = validate_with_context(data, context, schema)?.violations;
59    let patched_data = apply(data, delta);
60    let patched_context = apply(context, delta);
61    let patched = validate_with_context(&patched_data, &patched_context, schema)?.violations;
62    Ok(diff(baseline, patched))
63}
64
65/// `G ⊕ ΔG`: deletes first, then adds (so a re-add wins on conflict).
66pub fn apply(data: &Graph, delta: &GraphDelta) -> Graph {
67    let mut g = data.clone();
68    for t in &delta.delete {
69        g.remove(t);
70    }
71    for t in &delta.add {
72        g.insert(t);
73    }
74    g
75}
76
77/// A violation's identity: which focus failed which statement. `validate` emits
78/// at most one violation per such pair.
79fn key(v: &Violation) -> (Term, usize) {
80    (v.focus.clone(), v.statement)
81}
82
83fn diff(baseline: Vec<Violation>, patched: Vec<Violation>) -> RepairOutcome {
84    let baseline_keys: HashSet<(Term, usize)> = baseline.iter().map(key).collect();
85    let patched_keys: HashSet<(Term, usize)> = patched.iter().map(key).collect();
86
87    let fixed = baseline
88        .into_iter()
89        .filter(|v| !patched_keys.contains(&key(v)))
90        .collect();
91
92    let mut introduced = Vec::new();
93    let mut remaining = Vec::new();
94    for v in patched {
95        if baseline_keys.contains(&key(&v)) {
96            remaining.push(v);
97        } else {
98            introduced.push(v);
99        }
100    }
101    RepairOutcome {
102        fixed,
103        introduced,
104        remaining,
105    }
106}
107
108/// A driver-friendly key index, should a driver wish to look outcomes up.
109pub fn outcome_index(outcome: &RepairOutcome) -> HashMap<(Term, usize), &'static str> {
110    let mut m = HashMap::new();
111    for v in &outcome.fixed {
112        m.insert(key(v), "fixed");
113    }
114    for v in &outcome.remaining {
115        m.insert(key(v), "remaining");
116    }
117    for v in &outcome.introduced {
118        m.insert(key(v), "introduced");
119    }
120    m
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use oxrdf::{NamedNode, Triple};
127    use shifty_parse::{load_turtle, parse_turtle};
128
129    const PREFIXES: &str = r#"
130        @prefix sh:  <http://www.w3.org/ns/shacl#> .
131        @prefix ex:  <http://ex/> .
132    "#;
133
134    fn schema_and_graph(ttl: &str) -> (Schema, Graph) {
135        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
136        let loaded = load_turtle(ttl.as_bytes(), None).unwrap();
137        (parsed.schema, loaded.graph)
138    }
139
140    fn t(s: &str, p: &str, o: &str) -> Triple {
141        Triple::new(
142            NamedNode::new(s).unwrap(),
143            NamedNode::new(p).unwrap(),
144            NamedNode::new(o).unwrap(),
145        )
146    }
147
148    #[test]
149    fn sound_repair_fixes_one_and_introduces_none() {
150        // ex:x needs an ex:p; it has none.
151        let (schema, graph) = schema_and_graph(&format!(
152            "{PREFIXES}
153            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
154                sh:property [ sh:path ex:p ; sh:minCount 1 ] .
155            "
156        ));
157        let delta = GraphDelta {
158            add: vec![t("http://ex/x", "http://ex/p", "http://ex/y")],
159            delete: vec![],
160        };
161        let outcome = gate(&graph, &graph, &schema, &delta).unwrap();
162        assert!(outcome.is_sound());
163        assert!(outcome.is_progress());
164        assert_eq!(outcome.fixed.len(), 1);
165        assert!(outcome.remaining.is_empty());
166    }
167
168    #[test]
169    fn collateral_delete_is_caught_as_introduced() {
170        // Both ex:x and ex:y need an ex:p; both have one. Deleting y's breaks y.
171        let (schema, graph) = schema_and_graph(&format!(
172            "{PREFIXES}
173            ex:S a sh:NodeShape ; sh:targetNode ex:x, ex:y ;
174                sh:property [ sh:path ex:p ; sh:minCount 1 ] .
175            ex:x ex:p ex:a .
176            ex:y ex:p ex:b .
177            "
178        ));
179        // a delete that fixes nothing and breaks ex:y.
180        let delta = GraphDelta {
181            add: vec![],
182            delete: vec![t("http://ex/y", "http://ex/p", "http://ex/b")],
183        };
184        let outcome = gate(&graph, &graph, &schema, &delta).unwrap();
185        assert!(!outcome.is_sound(), "introduces a violation at ex:y");
186        assert_eq!(outcome.introduced.len(), 1);
187        assert_eq!(outcome.introduced[0].focus.to_string(), "<http://ex/y>");
188        assert!(outcome.fixed.is_empty());
189    }
190
191    #[test]
192    fn noop_delta_over_conforming_graph_is_empty() {
193        let (schema, graph) = schema_and_graph(&format!(
194            "{PREFIXES}
195            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
196                sh:property [ sh:path ex:p ; sh:minCount 1 ] .
197            ex:x ex:p ex:y .
198            "
199        ));
200        let outcome = gate(&graph, &graph, &schema, &GraphDelta::default()).unwrap();
201        assert_eq!(outcome, RepairOutcome::default());
202    }
203
204    #[test]
205    fn end_to_end_synthesized_repair_passes_the_gate() {
206        use crate::synthesize::synthesize;
207        use crate::witness::witness_violations;
208        use shifty_repair::{Plan, instantiate};
209
210        let ttl = format!(
211            "{PREFIXES}
212            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
213                sh:property [ sh:path ex:p ; sh:datatype <http://www.w3.org/2001/XMLSchema#integer> ] .
214            ex:x ex:p \"hello\" .
215            "
216        );
217        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
218        let loaded = load_turtle(ttl.as_bytes(), None).unwrap();
219
220        let ws = witness_violations(&loaded.graph, &loaded.graph, &parsed.schema).unwrap();
221        let tree = synthesize(&parsed.schema.arena, &ws[0]);
222
223        // fill the replacement hole with a good integer.
224        let mut plan = Plan::default();
225        let hole = instantiate(&tree, &plan).open_holes[0].0;
226        plan.binding.insert(
227            hole,
228            oxrdf::Literal::new_typed_literal("7", oxrdf::vocab::xsd::INTEGER).into(),
229        );
230        let delta = instantiate(&tree, &plan).delta;
231
232        let outcome = gate(&loaded.graph, &loaded.graph, &parsed.schema, &delta).unwrap();
233        assert!(outcome.is_progress(), "{outcome:?}");
234        assert!(outcome.introduced.is_empty());
235    }
236
237    #[test]
238    fn context_gate_discharges_subclass_from_shapes_hierarchy() {
239        use crate::validate::graph_union;
240
241        // `sh:class ex:Super` on a path with `minCount 1`; the only class-hierarchy
242        // axiom (`ex:Sub ⊑ ex:Super`) lives in the *shapes* graph, not the data.
243        let shapes_ttl = r#"
244            @prefix sh:   <http://www.w3.org/ns/shacl#> .
245            @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
246            @prefix ex:   <http://ex/> .
247            ex:Sub rdfs:subClassOf ex:Super .
248            ex:S a sh:NodeShape ; sh:targetClass ex:Vav ;
249                sh:property [ sh:path ex:hasPoint ; sh:minCount 1 ; sh:class ex:Super ] .
250        "#;
251        let data_ttl = "@prefix ex: <http://ex/> . ex:vav1 a ex:Vav .";
252
253        let schema = parse_turtle(shapes_ttl.as_bytes(), None).unwrap().schema;
254        let shapes = load_turtle(shapes_ttl.as_bytes(), None).unwrap().graph;
255        let data = load_turtle(data_ttl.as_bytes(), None).unwrap().graph;
256        let context = graph_union(&data, &shapes);
257
258        // Propose a point typed with the *subclass* of the required class.
259        let rdf_type = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
260        let delta = GraphDelta {
261            add: vec![
262                t("http://ex/n1", rdf_type, "http://ex/Sub"),
263                t("http://ex/vav1", "http://ex/hasPoint", "http://ex/n1"),
264            ],
265            delete: vec![],
266        };
267
268        // Data-only: the gate can't follow `subClassOf` (the axiom isn't in the
269        // data), so `sh:class ex:Super` stays unsatisfied — no progress.
270        let data_only = gate(&data, &data, &schema, &delta).unwrap();
271        assert!(!data_only.is_progress(), "{data_only:?}");
272
273        // Union context: the hierarchy is visible, so the subclass-typed value
274        // discharges `sh:class` and the violation is fixed.
275        let with_ctx = gate(&data, &context, &schema, &delta).unwrap();
276        assert!(with_ctx.is_progress(), "{with_ctx:?}");
277        assert!(with_ctx.introduced.is_empty(), "{with_ctx:?}");
278    }
279}