Skip to main content

shifty_engine/
synthesize.rs

1//! Synthesis: `FocusWitness` → [`RepairTree`] (`docs/06-repair.md` §6).
2//!
3//! Two mutually-recursive folds turn the failed sub-DAG of `φ` into the inspectable
4//! repair space a driver fills:
5//! - [`Synth::repair`] (additive) walks a failure, emitting adds.
6//! - [`Synth::break_`] (deletive) walks a satisfaction, emitting deletes.
7//!
8//! They cross at `Not`. The third design direction — a structural `build` that
9//! expands a fresh value's qualifier shape — is represented in this first cut by a
10//! single `ConformsTo` hole (value-type leaves get the precise `Typed`/`Kind`
11//! constraint), deferring structural expansion to the driver. So this cut needs no
12//! fuel: the recursion bottoms out at the witness/trace leaves.
13
14use crate::witness::{
15    BlockReason as WBlock, EvaluationStatus, EvidenceKind, EvidenceNodeRef, FocusWitness,
16    PathSupport, RelKind, SatTrace, Witness,
17};
18use oxrdf::{NamedOrBlankNode, Term, Triple};
19use serde::{Deserialize, Serialize};
20use shifty_algebra::{Path, Shape, ShapeArena, ShapeId};
21use shifty_repair::{
22    BlockReason, Edit, Hole, HoleConstraint, NodeId, RepairTree, Slot, TriplePattern,
23};
24use std::collections::{BTreeMap, BTreeSet};
25
26/// Add-edges plus the fresh interior holes they introduce.
27type Materialized = (Vec<Edit>, Vec<(Hole, HoleConstraint)>);
28
29/// The exact evidence occurrence that justified a synthesized repair node.
30/// `path` is a child-index path from the root evidence node under `statement`.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct EvidenceOrigin {
33    /// Authored statement containing the root evidence, or `None` when the
34    /// evidence was requested directly for a node and sub-constraint.
35    pub statement: Option<usize>,
36    pub path: Vec<usize>,
37    pub constraint_id: ShapeId,
38    pub node: Option<Term>,
39    pub status: EvaluationStatus,
40    pub kind: EvidenceKind,
41}
42
43/// A repair tree together with the evidence origins of every retained node.
44///
45/// Origins live at the engine synthesis boundary rather than in
46/// `shifty-repair`: the latter remains a pure, reusable repair IR with no
47/// dependency on validation evidence. A joint synthetic node can have several
48/// origins; an ordinary evidence-derived node has one.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct SynthesizedRepair {
51    pub tree: RepairTree,
52    pub origins: BTreeMap<NodeId, Vec<EvidenceOrigin>>,
53}
54
55impl SynthesizedRepair {
56    pub fn origins_for(&self, node: NodeId) -> &[EvidenceOrigin] {
57        self.origins.get(&node).map(Vec::as_slice).unwrap_or(&[])
58    }
59
60    pub fn into_tree(self) -> RepairTree {
61        self.tree
62    }
63}
64
65/// Synthesize the repair space for one focus node failing one statement.
66pub fn synthesize(arena: &ShapeArena, w: &FocusWitness) -> RepairTree {
67    let mut s = Synth::new(arena, false);
68    s.repair_witness(w)
69}
70
71/// Synthesize one violation and retain the evidence origin of every repair
72/// node. This is the traceable form used by interactive and publication-facing
73/// drivers; [`synthesize`] remains the compatibility projection to the pure IR.
74pub fn synthesize_with_origins(arena: &ShapeArena, w: &FocusWitness) -> SynthesizedRepair {
75    let mut s = Synth::new(arena, true);
76    let tree = s.repair_witness(w);
77    s.finish(tree)
78}
79
80/// Per-focus grouping: `All` over a focus's statement-witnesses, so a driver can
81/// fill one plan that fixes everything a focus violates at once.
82pub fn synthesize_focus(arena: &ShapeArena, ws: &[FocusWitness]) -> RepairTree {
83    let mut s = Synth::new(arena, false);
84    let children: Vec<RepairTree> = ws.iter().map(|w| s.repair_witness(w)).collect();
85    s.all(children)
86}
87
88/// Joint synthesis with evidence origins. The synthetic grouping node points to
89/// every root failure it combines.
90pub fn synthesize_focus_with_origins(arena: &ShapeArena, ws: &[FocusWitness]) -> SynthesizedRepair {
91    let mut s = Synth::new(arena, true);
92    let children: Vec<RepairTree> = ws.iter().map(|w| s.repair_witness(w)).collect();
93    let roots = ws
94        .iter()
95        .map(|w| {
96            evidence_origin(
97                (w.statement != usize::MAX).then_some(w.statement),
98                &[],
99                EvidenceNodeRef::Failure(&w.failure),
100            )
101        })
102        .collect();
103    let tree = s.with_origins(roots, |s| s.all(children));
104    s.finish(tree)
105}
106
107struct Synth<'a> {
108    arena: &'a ShapeArena,
109    next_node: u32,
110    next_hole: u32,
111    /// Outer `Option` means synthesis is rooted; inner `Option` is the optional
112    /// authored-statement identity of that root.
113    statement: Option<Option<usize>>,
114    evidence_path: Vec<usize>,
115    current_origins: Vec<EvidenceOrigin>,
116    origins: BTreeMap<NodeId, Vec<EvidenceOrigin>>,
117    track_origins: bool,
118}
119
120impl<'a> Synth<'a> {
121    fn new(arena: &'a ShapeArena, track_origins: bool) -> Self {
122        Self {
123            arena,
124            next_node: 0,
125            next_hole: 0,
126            statement: None,
127            evidence_path: Vec::new(),
128            current_origins: Vec::new(),
129            origins: BTreeMap::new(),
130            track_origins,
131        }
132    }
133
134    fn node(&mut self) -> NodeId {
135        let n = NodeId(self.next_node);
136        self.next_node += 1;
137        if !self.current_origins.is_empty() {
138            self.origins.insert(n, self.current_origins.clone());
139        }
140        n
141    }
142
143    fn finish(mut self, tree: RepairTree) -> SynthesizedRepair {
144        let mut retained = BTreeSet::new();
145        collect_tree_nodes(&tree, &mut retained);
146        self.origins.retain(|node, _| retained.contains(node));
147        SynthesizedRepair {
148            tree,
149            origins: self.origins,
150        }
151    }
152
153    fn repair_witness(&mut self, witness: &FocusWitness) -> RepairTree {
154        let statement = (witness.statement != usize::MAX).then_some(witness.statement);
155        let previous_statement = self.statement.replace(statement);
156        let previous_path = std::mem::take(&mut self.evidence_path);
157        let tree = self.repair(&witness.failure);
158        self.evidence_path = previous_path;
159        self.statement = previous_statement;
160        tree
161    }
162
163    fn with_origins<T>(
164        &mut self,
165        origins: Vec<EvidenceOrigin>,
166        f: impl FnOnce(&mut Self) -> T,
167    ) -> T {
168        let previous = std::mem::replace(&mut self.current_origins, origins);
169        let value = f(self);
170        self.current_origins = previous;
171        value
172    }
173
174    fn with_evidence<T>(
175        &mut self,
176        evidence: EvidenceNodeRef<'_>,
177        f: impl FnOnce(&mut Self) -> T,
178    ) -> T {
179        if !self.track_origins {
180            return f(self);
181        }
182        let statement = self
183            .statement
184            .expect("evidence synthesis must be rooted in a FocusWitness");
185        let origin = evidence_origin(statement, &self.evidence_path, evidence);
186        self.with_origins(vec![origin], f)
187    }
188
189    fn with_child<T>(&mut self, index: usize, f: impl FnOnce(&mut Self) -> T) -> T {
190        if !self.track_origins {
191            return f(self);
192        }
193        self.evidence_path.push(index);
194        let value = f(self);
195        self.evidence_path.pop();
196        value
197    }
198
199    fn repair_child(&mut self, index: usize, value: &Witness) -> RepairTree {
200        self.with_child(index, |s| s.repair(value))
201    }
202
203    fn break_child(&mut self, index: usize, value: &SatTrace) -> RepairTree {
204        self.with_child(index, |s| s.break_(value))
205    }
206
207    fn hole(&mut self) -> Hole {
208        let h = Hole(self.next_hole);
209        self.next_hole += 1;
210        h
211    }
212
213    // ── smart constructors maintaining the Blocked-propagation invariant ──
214
215    /// `All`: any `Blocked` child ⇒ `Blocked`; drop `Noop`; collapse trivials.
216    fn all(&mut self, children: Vec<RepairTree>) -> RepairTree {
217        let mut kept = Vec::with_capacity(children.len());
218        for c in children {
219            match c {
220                RepairTree::Blocked(_, r) => return RepairTree::Blocked(self.node(), r),
221                RepairTree::Noop(_) => {}
222                other => kept.push(other),
223            }
224        }
225        match kept.len() {
226            0 => RepairTree::Noop(self.node()),
227            1 => kept.pop().unwrap(),
228            _ => RepairTree::All {
229                id: self.node(),
230                children: kept,
231            },
232        }
233    }
234
235    /// `Any`: drop `Blocked` children; all-`Blocked` ⇒ `Blocked`; collapse trivials.
236    fn any(&mut self, children: Vec<RepairTree>) -> RepairTree {
237        let had = !children.is_empty();
238        let mut reason = None;
239        let mut kept = Vec::with_capacity(children.len());
240        for c in children {
241            match c {
242                RepairTree::Blocked(_, r) => reason = Some(r),
243                other => kept.push(other),
244            }
245        }
246        match kept.len() {
247            0 if had => RepairTree::Blocked(
248                self.node(),
249                reason.unwrap_or(BlockReason::Unsupported("no repair branch".into())),
250            ),
251            0 => RepairTree::Noop(self.node()),
252            1 => kept.pop().unwrap(),
253            _ => RepairTree::Any {
254                id: self.node(),
255                children: kept,
256            },
257        }
258    }
259
260    fn blocked(&mut self, reason: BlockReason) -> RepairTree {
261        RepairTree::Blocked(self.node(), reason)
262    }
263
264    fn edits(&mut self, edits: Vec<Edit>, holes: Vec<(Hole, HoleConstraint)>) -> RepairTree {
265        RepairTree::Edits {
266            id: self.node(),
267            edits,
268            holes,
269        }
270    }
271
272    // ── additive: Failure → RepairTree ──
273
274    fn repair(&mut self, w: &Witness) -> RepairTree {
275        self.with_evidence(EvidenceNodeRef::Failure(w), |s| s.repair_inner(w))
276    }
277
278    fn repair_inner(&mut self, w: &Witness) -> RepairTree {
279        match w {
280            Witness::Atom {
281                shape, produced_by, ..
282            } => self.repair_atom(*shape, produced_by.as_ref()),
283            Witness::Relational {
284                kind,
285                lhs,
286                rhs,
287                offending,
288                ..
289            } => self.repair_relational(*kind, lhs, rhs, offending),
290            Witness::Closed {
291                node, offenders, ..
292            } => {
293                let edits = offenders
294                    .iter()
295                    .map(|(p, o)| {
296                        Edit::delete(TriplePattern::new(
297                            Slot::Bound(node.clone()),
298                            Slot::Bound(Term::NamedNode(p.clone())),
299                            Slot::Bound(o.clone()),
300                        ))
301                    })
302                    .collect();
303                self.edits(edits, vec![])
304            }
305            Witness::Not { inner, .. } => self.break_child(0, inner),
306            Witness::All { failed, .. } => {
307                let cs = failed
308                    .iter()
309                    .enumerate()
310                    .map(|(index, child)| self.repair_child(index, child))
311                    .collect();
312                self.all(cs)
313            }
314            Witness::Any { branches, .. } => {
315                let cs = branches
316                    .iter()
317                    .enumerate()
318                    .map(|(index, child)| self.repair_child(index, child))
319                    .collect();
320                self.any(cs)
321            }
322            Witness::CountLow {
323                node,
324                path,
325                qualifier,
326                have,
327                min,
328                sibling_qualifiers,
329                ..
330            } => {
331                let effective = self.effective_qualifiers(*qualifier, sibling_qualifiers);
332                let body = self.build_value(node, path, &effective);
333                if body.is_blocked() {
334                    return body;
335                }
336                RepairTree::Repeat {
337                    id: self.node(),
338                    body: Box::new(body),
339                    min: min - have,
340                    max: None,
341                }
342            }
343            Witness::CountHigh {
344                node,
345                path,
346                matched,
347                max,
348                per_value,
349                ..
350            } => self.repair_count_high(node, path, matched, *max, per_value),
351            Witness::Opaque { .. } => self.blocked(BlockReason::OpaqueSparql),
352        }
353    }
354
355    fn repair_atom(&mut self, shape: ShapeId, produced_by: Option<&PathSupport>) -> RepairTree {
356        // Replace-in-place: delete the offending value's edge, add a good one.
357        let Some(ps) = produced_by else {
358            // focus-scoped (e.g. nodeKind on the focus) — data edits can't fix it.
359            return self.blocked(BlockReason::CannotMutateIdentity);
360        };
361        let Some(edge) = cut_edge(ps) else {
362            return self.blocked(BlockReason::CannotMutateIdentity);
363        };
364        let del = Edit::delete(delete_pattern(&edge));
365        let hole = self.hole();
366        let add = Edit::add(TriplePattern::new(
367            Slot::Bound(subj_term(&edge)),
368            Slot::Bound(Term::NamedNode(edge.predicate.clone())),
369            Slot::Open(hole),
370        ));
371        let constraint = self.leaf_constraint(shape);
372        self.edits(vec![del, add], vec![(hole, constraint)])
373    }
374
375    fn repair_relational(
376        &mut self,
377        kind: RelKind,
378        lhs: &[(Term, PathSupport)],
379        rhs: &[(Term, PathSupport)],
380        offending: &[(Term, Term)],
381    ) -> RepairTree {
382        match kind {
383            RelKind::Eq => {
384                self.blocked(BlockReason::Unsupported("sh:equals reconciliation".into()))
385            }
386            RelKind::Disj => {
387                // shared value: delete it from the lhs path or the rhs predicate.
388                let parts = offending
389                    .iter()
390                    .map(|(v, _)| {
391                        let mut opts = Vec::new();
392                        opts.extend(self.delete_value(v, lhs));
393                        opts.extend(self.delete_value(v, rhs));
394                        self.any(opts)
395                    })
396                    .collect();
397                self.all(parts)
398            }
399            RelKind::UniqueLang => {
400                // each duplicated pair: delete one of the two.
401                let parts = offending
402                    .iter()
403                    .map(|(a, b)| {
404                        let mut opts = Vec::new();
405                        opts.extend(self.delete_value(a, lhs));
406                        opts.extend(self.delete_value(b, lhs));
407                        self.any(opts)
408                    })
409                    .collect();
410                self.all(parts)
411            }
412            RelKind::Lt | RelKind::Le => {
413                // delete the offending lhs value (value-shrinking deferred).
414                let parts = offending
415                    .iter()
416                    .map(|(a, _)| {
417                        let opts = self.delete_value(a, lhs);
418                        self.any(opts)
419                    })
420                    .collect();
421                self.all(parts)
422            }
423        }
424    }
425
426    /// A delete edit for `v` if it appears (with cuttable support) in `set`.
427    fn delete_value(&mut self, v: &Term, set: &[(Term, PathSupport)]) -> Vec<RepairTree> {
428        set.iter()
429            .filter(|(val, _)| val == v)
430            .filter_map(|(_, ps)| cut_edge(ps))
431            .map(|edge| self.edits(vec![Edit::delete(delete_pattern(&edge))], vec![]))
432            .collect()
433    }
434
435    fn repair_count_high(
436        &mut self,
437        node: &Term,
438        path: &Path,
439        matched: &[(Term, PathSupport)],
440        max: u64,
441        per_value: &[(Term, Witness)],
442    ) -> RepairTree {
443        if !per_value.is_empty() {
444            // ∀-encoding: fix each offending value in place.
445            let cs = per_value
446                .iter()
447                .enumerate()
448                .map(|(index, (_, witness))| self.repair_child(index, witness))
449                .collect();
450            return self.all(cs);
451        }
452        // plain maxCount: delete (have - max) of the matched values.
453        let d = (matched.len() as u64).saturating_sub(max);
454        if d == 0 {
455            return self.blocked(BlockReason::Unsupported(
456                "maxCount: nothing to delete".into(),
457            ));
458        }
459        let Path::Pred(p) = path else {
460            return self.blocked(BlockReason::Unsupported(
461                "maxCount over a compound path".into(),
462            ));
463        };
464        let hole = self.hole();
465        let values: Vec<Term> = matched.iter().map(|(v, _)| v.clone()).collect();
466        let body = self.edits(
467            vec![Edit::delete(TriplePattern::new(
468                Slot::Bound(node.clone()),
469                Slot::Bound(Term::NamedNode(p.clone())),
470                Slot::Open(hole),
471            ))],
472            vec![(hole, HoleConstraint::OneOf(values))],
473        );
474        RepairTree::Repeat {
475            id: self.node(),
476            body: Box::new(body),
477            min: d,
478            max: Some(d),
479        }
480    }
481
482    /// Materialize a fresh `π`-reachable value from `subject`, constrained to the
483    /// `qualifiers` it must satisfy. The value hole carries `Typed`/`Kind`/`Const`
484    /// for a single value-type qualifier, a `ConformsTo` for a single structural
485    /// one, or a `ConformsToAll` conjunction for several (structural expansion
486    /// deferred to the driver).
487    fn build_value(&mut self, subject: &Term, path: &Path, qualifiers: &[ShapeId]) -> RepairTree {
488        let vh = self.hole();
489        let Some((edits, mut holes)) = self.materialize_path(subject.clone(), path, vh) else {
490            return self.blocked(BlockReason::Unsupported("path materialization".into()));
491        };
492        holes.push((vh, self.value_constraint(qualifiers)));
493        self.edits(edits, holes)
494    }
495
496    /// Add-edges realizing `path` from `subject` to the value hole `vh`, plus any
497    /// fresh interior holes. Handles `Pred`, `Inverse(Pred)`, `Seq` of those, and
498    /// `Star` — the last reflexively, which is what makes `sh:class` buildable:
499    /// `rdf:type/rdfs:subClassOf*` materializes to `subject rdf:type ?vh` (a value
500    /// bound to the class `C`, since `C subClassOf* C`).
501    fn materialize_path(&mut self, subject: Term, path: &Path, vh: Hole) -> Option<Materialized> {
502        match path {
503            Path::Pred(p) => Some((
504                vec![Edit::add(TriplePattern::new(
505                    Slot::Bound(subject),
506                    Slot::Bound(Term::NamedNode(p.clone())),
507                    Slot::Open(vh),
508                ))],
509                vec![],
510            )),
511            Path::Inverse(inner) => {
512                if let Path::Pred(p) = inner.as_ref() {
513                    Some((
514                        vec![Edit::add(TriplePattern::new(
515                            Slot::Open(vh),
516                            Slot::Bound(Term::NamedNode(p.clone())),
517                            Slot::Bound(subject),
518                        ))],
519                        vec![],
520                    ))
521                } else {
522                    None
523                }
524            }
525            // `π*`: a single application is a valid member of the closure, so
526            // materialize one hop of the inner path.
527            Path::Star(inner) => self.materialize_path(subject, inner, vh),
528            Path::Seq(steps) => self.materialize_seq(subject, steps, vh),
529            _ => None,
530        }
531    }
532
533    /// Materialize a `Seq` of steps from `subject` to `vh`. Trailing `Star` steps
534    /// are dropped: they are satisfied reflexively, so the value sits at the result
535    /// of the last concrete step. Each remaining step must be `Pred` or
536    /// `Inverse(Pred)`.
537    fn materialize_seq(&mut self, subject: Term, steps: &[Path], vh: Hole) -> Option<Materialized> {
538        let mut end = steps.len();
539        while end > 0 && matches!(steps[end - 1], Path::Star(_)) {
540            end -= 1;
541        }
542        let steps = &steps[..end];
543        if steps.is_empty() {
544            return None; // wholly reflexive: nothing concrete to add
545        }
546        let mut edits = Vec::new();
547        let mut holes = Vec::new();
548        let mut cursor = Slot::Bound(subject);
549        for (i, step) in steps.iter().enumerate() {
550            let target = if i == steps.len() - 1 {
551                Slot::Open(vh)
552            } else {
553                let m = self.hole();
554                holes.push((m, HoleConstraint::Fresh));
555                Slot::Open(m)
556            };
557            match step {
558                Path::Pred(p) => edits.push(Edit::add(TriplePattern::new(
559                    cursor.clone(),
560                    Slot::Bound(Term::NamedNode(p.clone())),
561                    target.clone(),
562                ))),
563                Path::Inverse(inner) => {
564                    let Path::Pred(p) = inner.as_ref() else {
565                        return None;
566                    };
567                    edits.push(Edit::add(TriplePattern::new(
568                        target.clone(),
569                        Slot::Bound(Term::NamedNode(p.clone())),
570                        cursor.clone(),
571                    )));
572                }
573                _ => return None, // interior Star / Alt / nested Seq not supported
574            }
575            cursor = target;
576        }
577        Some((edits, holes))
578    }
579
580    /// Every shape a value added to satisfy this `CountLow` must conform to: the
581    /// count's own qualifier together with the inner shape of each sibling
582    /// universal `∀π.φ` on the same path. Those universals are vacuously satisfied
583    /// while `have == 0`, so they never witness as their own failures — but a
584    /// freshly-added value is checked against all of them, so the hole must carry
585    /// the *full* conjunction, not just the base qualifier or a single sibling.
586    /// `⊤` qualifiers contribute nothing and are dropped; duplicates are merged.
587    fn effective_qualifiers(&self, qualifier: ShapeId, siblings: &[ShapeId]) -> Vec<ShapeId> {
588        let mut out: Vec<ShapeId> = Vec::with_capacity(1 + siblings.len());
589        for id in std::iter::once(qualifier).chain(siblings.iter().copied()) {
590            if matches!(self.arena.get(id), Shape::Top | Shape::Pending) {
591                continue;
592            }
593            if !out.contains(&id) {
594                out.push(id);
595            }
596        }
597        out
598    }
599
600    /// The constraint a freshly-built value hole carries, given every shape it must
601    /// conform to. No effective qualifier (all were `⊤`) is `AnyNode`; a single one
602    /// gets the precise leaf constraint or a `ConformsTo`; several are kept whole as
603    /// a `ConformsToAll` conjunction (the gate still enforces each, but the hole now
604    /// *describes* the complete obligation rather than an arbitrary representative).
605    fn value_constraint(&self, qualifiers: &[ShapeId]) -> HoleConstraint {
606        match qualifiers {
607            [] => HoleConstraint::AnyNode,
608            [single] => self.single_value_constraint(*single),
609            many => HoleConstraint::ConformsToAll(many.to_vec()),
610        }
611    }
612
613    fn single_value_constraint(&self, qualifier: ShapeId) -> HoleConstraint {
614        match self.arena.get(qualifier) {
615            Shape::Top | Shape::Pending => HoleConstraint::AnyNode,
616            Shape::TestConst(c) => HoleConstraint::Const(c.clone()),
617            Shape::TestType(t) => HoleConstraint::Typed(t.clone()),
618            Shape::TestKind(k) => HoleConstraint::Kind(*k),
619            _ => HoleConstraint::ConformsTo(qualifier),
620        }
621    }
622
623    fn leaf_constraint(&self, shape: ShapeId) -> HoleConstraint {
624        match self.arena.get(shape) {
625            Shape::TestConst(c) => HoleConstraint::Const(c.clone()),
626            Shape::TestType(t) => HoleConstraint::Typed(t.clone()),
627            Shape::TestKind(k) => HoleConstraint::Kind(*k),
628            _ => HoleConstraint::ConformsTo(shape),
629        }
630    }
631
632    // ── deletive: Satisfaction → RepairTree ──
633
634    fn break_(&mut self, s: &SatTrace) -> RepairTree {
635        self.with_evidence(EvidenceNodeRef::Satisfaction(s), |this| this.break_inner(s))
636    }
637
638    fn break_inner(&mut self, s: &SatTrace) -> RepairTree {
639        match s {
640            SatTrace::Atom { produced_by, .. } => match cut_edge(produced_by) {
641                Some(edge) => self.edits(vec![Edit::delete(delete_pattern(&edge))], vec![]),
642                None => self.blocked(BlockReason::CannotMutateIdentity),
643            },
644            SatTrace::AllHeld { children, .. } => {
645                // break any one conjunct.
646                let cs = children
647                    .iter()
648                    .enumerate()
649                    .map(|(index, child)| self.break_child(index, child))
650                    .collect();
651                self.any(cs)
652            }
653            SatTrace::AnyHeld { satisfied, .. } => {
654                // break every satisfied disjunct.
655                let cs = satisfied
656                    .iter()
657                    .enumerate()
658                    .map(|(index, child)| self.break_child(index, child))
659                    .collect();
660                self.all(cs)
661            }
662            SatTrace::CountHeld { matches, min, .. } => {
663                // Drop below min by breaking the qualifier at the matches. First
664                // cut breaks all of them (sound, non-minimal); needs a min>0 to be
665                // falsifiable by deletion at all.
666                if matches!(min, Some(m) if *m > 0) {
667                    let cs = matches
668                        .iter()
669                        .enumerate()
670                        .map(|(index, (_, _, trace))| self.break_child(index, trace))
671                        .collect();
672                    self.all(cs)
673                } else {
674                    self.blocked(BlockReason::Unsupported(
675                        "falsify maxCount needs adds".into(),
676                    ))
677                }
678            }
679            SatTrace::ForAllHeld { values, .. } => {
680                let choices = values
681                    .iter()
682                    .enumerate()
683                    .map(|(index, (_, _, trace))| self.break_child(index, trace))
684                    .collect();
685                self.any(choices)
686            }
687            SatTrace::NotHeld { inner_fails, .. } => self.repair_child(0, inner_fails),
688            SatTrace::Irrefutable { .. } => {
689                self.blocked(BlockReason::Unsupported("tautology".into()))
690            }
691            SatTrace::Blocked { reason, .. } => {
692                let r = match reason {
693                    WBlock::OpaqueSparql => BlockReason::OpaqueSparql,
694                    WBlock::ClosedNeedsAdd => {
695                        BlockReason::Unsupported("closed needs an addition".into())
696                    }
697                    WBlock::Unsupported => {
698                        BlockReason::Unsupported("relational falsification".into())
699                    }
700                };
701                self.blocked(r)
702            }
703            SatTrace::Coinductive { .. } => self.blocked(BlockReason::Coinductive),
704        }
705    }
706}
707
708fn evidence_origin(
709    statement: Option<usize>,
710    path: &[usize],
711    evidence: EvidenceNodeRef<'_>,
712) -> EvidenceOrigin {
713    EvidenceOrigin {
714        statement,
715        path: path.to_vec(),
716        constraint_id: evidence.constraint_id(),
717        node: evidence.node().cloned(),
718        status: evidence.status(),
719        kind: evidence.evidence_kind(),
720    }
721}
722
723fn collect_tree_nodes(tree: &RepairTree, out: &mut BTreeSet<NodeId>) {
724    out.insert(tree.id());
725    match tree {
726        RepairTree::All { children, .. } | RepairTree::Any { children, .. } => {
727            children
728                .iter()
729                .for_each(|child| collect_tree_nodes(child, out));
730        }
731        RepairTree::Repeat { body, .. } => collect_tree_nodes(body, out),
732        RepairTree::Noop(_) | RepairTree::Blocked(..) | RepairTree::Edits { .. } => {}
733    }
734}
735
736// ── PathSupport helpers ──
737
738/// The immediate edge to a value (the final hop): the one to cut for a
739/// replace/delete. `None` for reflexive (`Empty`) support.
740fn cut_edge(ps: &PathSupport) -> Option<Triple> {
741    match ps {
742        PathSupport::Empty => None,
743        PathSupport::Edge(t) => Some(t.clone()),
744        PathSupport::Chain(v) => v.last().and_then(cut_edge),
745        PathSupport::Alt(v) => v.first().and_then(cut_edge),
746    }
747}
748
749fn delete_pattern(t: &Triple) -> TriplePattern {
750    TriplePattern::new(
751        Slot::Bound(subj_term(t)),
752        Slot::Bound(Term::NamedNode(t.predicate.clone())),
753        Slot::Bound(t.object.clone()),
754    )
755}
756
757fn subj_term(t: &Triple) -> Term {
758    match &t.subject {
759        NamedOrBlankNode::NamedNode(n) => Term::NamedNode(n.clone()),
760        NamedOrBlankNode::BlankNode(b) => Term::BlankNode(b.clone()),
761    }
762}
763
764#[cfg(test)]
765mod tests {
766    use super::*;
767    use crate::witness::{Evidence, witness_node, witness_violations};
768    use shifty_parse::{load_turtle, parse_turtle};
769    use shifty_repair::{Plan, instantiate};
770
771    const PREFIXES: &str = r#"
772        @prefix sh:  <http://www.w3.org/ns/shacl#> .
773        @prefix ex:  <http://ex/> .
774        @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
775    "#;
776
777    fn synth_one(ttl: &str) -> (RepairTree, shifty_algebra::Schema) {
778        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
779        let loaded = load_turtle(ttl.as_bytes(), None).unwrap();
780        let ws =
781            witness_violations(&loaded.graph, &loaded.graph, &parsed.schema).expect("stratifiable");
782        assert_eq!(ws.len(), 1, "expected exactly one focus witness");
783        (synthesize(&parsed.schema.arena, &ws[0]), parsed.schema)
784    }
785
786    #[test]
787    fn every_retained_repair_node_links_to_its_exact_evidence_occurrence() {
788        let ttl = format!(
789            "{PREFIXES}
790            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
791                sh:not [ sh:class ex:C ] .
792            ex:x a ex:C .
793            "
794        );
795        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
796        let loaded = load_turtle(ttl.as_bytes(), None).unwrap();
797        let witnesses =
798            witness_violations(&loaded.graph, &loaded.graph, &parsed.schema).expect("stratifiable");
799        assert_eq!(witnesses.len(), 1);
800
801        let synthesized = synthesize_with_origins(&parsed.schema.arena, &witnesses[0]);
802        assert_eq!(
803            synthesize(&parsed.schema.arena, &witnesses[0]),
804            synthesized.tree,
805            "origin capture must not change the compatibility repair tree",
806        );
807        let mut nodes = BTreeSet::new();
808        collect_tree_nodes(&synthesized.tree, &mut nodes);
809        assert_eq!(
810            synthesized.origins.keys().copied().collect::<BTreeSet<_>>(),
811            nodes,
812            "every retained repair node has an origin and discarded smart-constructor nodes do not",
813        );
814
815        let evidence = Evidence::Failure(witnesses[0].failure.clone());
816        for origins in synthesized.origins.values() {
817            assert_eq!(origins.len(), 1);
818            let origin = &origins[0];
819            let mut cursor = evidence.walk()[0];
820            for &index in &origin.path {
821                cursor = cursor.children()[index];
822            }
823            assert_eq!(origin.statement, Some(witnesses[0].statement));
824            assert_eq!(origin.constraint_id, cursor.constraint_id());
825            assert_eq!(origin.node.as_ref(), cursor.node());
826            assert_eq!(origin.status, cursor.status());
827            assert_eq!(origin.kind, cursor.evidence_kind());
828        }
829
830        assert!(
831            synthesized
832                .origins
833                .values()
834                .flatten()
835                .any(|origin| origin.status == EvaluationStatus::Pass),
836            "repairing a failed Not must link its deletive operators to the inner satisfaction",
837        );
838    }
839
840    #[test]
841    fn direct_node_repair_has_no_authored_statement_origin() {
842        let ttl = format!(
843            "{PREFIXES}
844            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
845                sh:property [ sh:path ex:p ; sh:minCount 1 ] .
846            "
847        );
848        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
849        let loaded = load_turtle(ttl.as_bytes(), None).unwrap();
850        let statement = &parsed.schema.statements[0];
851        let node = Term::NamedNode(oxrdf::NamedNode::new("http://ex/direct").unwrap());
852        let witness = witness_node(&loaded.graph, &parsed.schema, &node, statement.shape)
853            .expect("stratifiable")
854            .expect("direct node fails");
855
856        let synthesized = synthesize_with_origins(&parsed.schema.arena, &witness);
857        assert!(
858            synthesized
859                .origins
860                .values()
861                .flatten()
862                .all(|origin| origin.statement.is_none())
863        );
864    }
865
866    #[test]
867    fn joint_repair_root_links_every_failure_it_groups() {
868        let ttl = format!(
869            "{PREFIXES}
870            ex:S1 a sh:NodeShape ; sh:targetNode ex:x ;
871                sh:property [ sh:path ex:p ; sh:minCount 1 ] .
872            ex:S2 a sh:NodeShape ; sh:targetNode ex:x ;
873                sh:property [ sh:path ex:q ; sh:minCount 1 ] .
874            "
875        );
876        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
877        let loaded = load_turtle(ttl.as_bytes(), None).unwrap();
878        let witnesses =
879            witness_violations(&loaded.graph, &loaded.graph, &parsed.schema).expect("stratifiable");
880        assert_eq!(witnesses.len(), 2);
881
882        let synthesized = synthesize_focus_with_origins(&parsed.schema.arena, &witnesses);
883        let RepairTree::All { id, .. } = &synthesized.tree else {
884            panic!("two independent obligations must retain a joint All root")
885        };
886        let origins = synthesized.origins_for(*id);
887        assert_eq!(origins.len(), 2);
888        assert_eq!(
889            origins
890                .iter()
891                .filter_map(|origin| origin.statement)
892                .collect::<BTreeSet<_>>(),
893            witnesses.iter().map(|witness| witness.statement).collect(),
894        );
895        assert!(origins.iter().all(|origin| origin.path.is_empty()));
896    }
897
898    #[test]
899    fn min_count_yields_a_repeat_adding_one_value() {
900        let ttl = format!(
901            "{PREFIXES}
902            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
903                sh:property [ sh:path ex:p ; sh:minCount 2 ] .
904            ex:x ex:p ex:y .
905            "
906        );
907        let (tree, _) = synth_one(&ttl);
908        // ∃≥2 with 1 present ⇒ add 1 more value reachable by ex:p.
909        let id = match &tree {
910            RepairTree::Repeat { id, min: 1, .. } => *id,
911            other => panic!("expected Repeat{{min:1}}, got {other:?}"),
912        };
913        // one instance ⇒ one fresh value hole; binding it yields one add.
914        let plan = Plan {
915            count: std::collections::HashMap::from([(id, 1)]),
916            ..Default::default()
917        };
918        let disc = instantiate(&tree, &plan);
919        assert_eq!(disc.open_holes.len(), 1);
920        let (hole, _) = disc.open_holes[0].clone();
921        let mut bound = plan;
922        bound.binding.insert(
923            hole,
924            Term::NamedNode(oxrdf::NamedNode::new("http://ex/z").unwrap()),
925        );
926        let out = instantiate(&tree, &bound);
927        assert_eq!(out.delta.add.len(), 1);
928        assert!(out.open_holes.is_empty());
929    }
930
931    #[test]
932    fn datatype_value_replace_instantiates_to_delete_plus_add() {
933        let ttl = format!(
934            "{PREFIXES}
935            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
936                sh:property [ sh:path ex:p ; sh:datatype xsd:integer ] .
937            ex:x ex:p \"hello\" .
938            "
939        );
940        let (tree, _) = synth_one(&ttl);
941        // bind the replacement hole and check we get delete + add.
942        let mut plan = Plan::default();
943        // discover the hole id first.
944        let disc = instantiate(&tree, &plan);
945        assert_eq!(disc.open_holes.len(), 1, "one replacement hole");
946        let (hole, constraint) = disc.open_holes[0].clone();
947        assert!(matches!(constraint, HoleConstraint::Typed(_)));
948        plan.binding.insert(
949            hole,
950            Term::from(oxrdf::Literal::new_typed_literal(
951                "7",
952                oxrdf::vocab::xsd::INTEGER,
953            )),
954        );
955        let out = instantiate(&tree, &plan);
956        assert_eq!(out.delta.delete.len(), 1, "deletes the bad value");
957        assert_eq!(out.delta.add.len(), 1, "adds a good one");
958    }
959
960    #[test]
961    fn focus_nodekind_is_blocked() {
962        let ttl = format!(
963            "{PREFIXES}
964            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
965                sh:nodeKind sh:Literal .
966            ex:x ex:p ex:y .
967            "
968        );
969        let (tree, _) = synth_one(&ttl);
970        assert!(matches!(
971            tree,
972            RepairTree::Blocked(_, BlockReason::CannotMutateIdentity)
973        ));
974    }
975
976    #[test]
977    fn class_qualified_min_count_builds_a_type_assertion() {
978        // ex:x needs a part conforming to (sh:class ex:Widget); none present.
979        // The build must add `<part> rdf:type ex:Widget` — i.e. materialize the
980        // `rdf:type/rdfs:subClassOf*` path reflexively, not give up on it.
981        let ttl = format!(
982            "{PREFIXES}
983            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
984                sh:property [ sh:path ex:part ;
985                    sh:qualifiedValueShape ex:PartShape ; sh:qualifiedMinCount 1 ] .
986            ex:PartShape a sh:NodeShape ; sh:class ex:Widget .
987            ex:x a ex:Thing .
988            "
989        );
990        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
991        let loaded = load_turtle(ttl.as_bytes(), None).unwrap();
992        let ws = witness_violations(&loaded.graph, &loaded.graph, &parsed.schema).unwrap();
993        let parent = synthesize(&parsed.schema.arena, &ws[0]);
994        // the parent hole is the part value (ConformsTo PartShape).
995        let mut plan = Plan {
996            count: std::collections::HashMap::new(),
997            ..Default::default()
998        };
999        // bind the part hole to a fresh node, then build that node against PartShape.
1000        let parent_hole = {
1001            let disc = instantiate(
1002                &parent,
1003                &Plan {
1004                    count: std::collections::HashMap::from([(parent.id(), 1)]),
1005                    ..Default::default()
1006                },
1007            );
1008            disc.open_holes[0].clone()
1009        };
1010        // Find PartShape's id via the ConformsTo constraint, build the node.
1011        let HoleConstraint::ConformsTo(part_shape) = parent_hole.1 else {
1012            panic!("expected ConformsTo, got {:?}", parent_hole.1);
1013        };
1014        let fresh = Term::NamedNode(oxrdf::NamedNode::new("http://ex/part1").unwrap());
1015        let sub_fw =
1016            crate::witness::witness_node(&loaded.graph, &parsed.schema, &fresh, part_shape)
1017                .unwrap()
1018                .expect("fresh node fails PartShape");
1019        let sub = synthesize(&parsed.schema.arena, &sub_fw);
1020        assert!(
1021            !sub.is_blocked(),
1022            "class build must not be blocked: {sub:?}"
1023        );
1024        // instantiate the sub-build: one Repeat instance; the value hole is Const(Widget).
1025        let disc = instantiate(
1026            &sub,
1027            &Plan {
1028                count: std::collections::HashMap::from([(sub.id(), 1)]),
1029                ..Default::default()
1030            },
1031        );
1032        let (h, c) = disc.open_holes[0].clone();
1033        assert!(
1034            matches!(c, HoleConstraint::Const(_)),
1035            "type hole is Const(C): {c:?}"
1036        );
1037        plan.count.insert(sub.id(), 1);
1038        plan.binding.insert(
1039            h,
1040            Term::NamedNode(oxrdf::NamedNode::new("http://ex/Widget").unwrap()),
1041        );
1042        let out = instantiate(&sub, &plan);
1043        assert_eq!(out.delta.add.len(), 1);
1044        assert_eq!(
1045            out.delta.add[0].predicate.as_str(),
1046            "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"
1047        );
1048    }
1049
1050    #[test]
1051    fn closed_violation_deletes_the_extra_triple() {
1052        let ttl = format!(
1053            "{PREFIXES}
1054            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
1055                sh:closed true ;
1056                sh:property [ sh:path ex:p ; sh:minCount 0 ] .
1057            ex:x ex:p ex:y ; ex:extra ex:z .
1058            "
1059        );
1060        let (tree, _) = synth_one(&ttl);
1061        let out = instantiate(&tree, &Plan::default());
1062        assert!(out.delta.add.is_empty());
1063        assert_eq!(out.delta.delete.len(), 1);
1064        assert_eq!(
1065            out.delta.delete[0].predicate,
1066            oxrdf::NamedNode::new("http://ex/extra").unwrap()
1067        );
1068    }
1069
1070    /// The sole open hole of `tree`, taking exactly one instance of any `Repeat`.
1071    fn sole_hole(tree: &RepairTree) -> HoleConstraint {
1072        let disc = instantiate(
1073            tree,
1074            &Plan {
1075                count: std::collections::HashMap::from([(tree.id(), 1)]),
1076                ..Default::default()
1077            },
1078        );
1079        assert_eq!(disc.open_holes.len(), 1, "expected exactly one open hole");
1080        disc.open_holes[0].1.clone()
1081    }
1082
1083    fn class_named(name: &str) -> Term {
1084        Term::NamedNode(oxrdf::NamedNode::new(name).unwrap())
1085    }
1086
1087    #[test]
1088    fn min_count_with_class_value_constraint_carries_the_class() {
1089        // `sh:minCount 1 ; sh:class ex:C` on one property: the count's qualifier is
1090        // ⊤, but the `∀ex:p.(class ex:C)` universal is vacuously satisfied while no
1091        // value exists. The single effective qualifier is that class, so the hole
1092        // is a `ConformsTo` of it — not an unconstrained `AnyNode`.
1093        let ttl = format!(
1094            "{PREFIXES}
1095            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
1096                sh:property [ sh:path ex:p ; sh:minCount 1 ; sh:class ex:C ] .
1097            ex:x a ex:Thing .
1098            "
1099        );
1100        let (tree, schema) = synth_one(&ttl);
1101        let HoleConstraint::ConformsTo(s) = sole_hole(&tree) else {
1102            panic!("expected a single ConformsTo hole");
1103        };
1104        assert_eq!(
1105            shifty_algebra::render::class_target_shape(s, &schema.arena),
1106            Some(class_named("http://ex/C")),
1107        );
1108    }
1109
1110    #[test]
1111    fn qualified_min_count_carries_qualifier_and_sibling_universal() {
1112        // The motivating case: a non-⊤ count qualifier (`sh:qualifiedValueShape`)
1113        // *and* a sibling `∀ex:p.(class ex:C)`. The old synthesis kept only the
1114        // qualifier and dropped the class; now the hole is a `ConformsToAll`
1115        // carrying *both*, so the description reflects every obligation.
1116        let ttl = format!(
1117            "{PREFIXES}
1118            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
1119                sh:property [ sh:path ex:p ;
1120                    sh:qualifiedValueShape ex:Q ; sh:qualifiedMinCount 1 ;
1121                    sh:class ex:C ] .
1122            ex:Q a sh:NodeShape ; sh:nodeKind sh:IRI .
1123            ex:x a ex:Thing .
1124            "
1125        );
1126        let (tree, schema) = synth_one(&ttl);
1127        let HoleConstraint::ConformsToAll(ss) = sole_hole(&tree) else {
1128            panic!("expected a ConformsToAll hole carrying every qualifier");
1129        };
1130        assert_eq!(ss.len(), 2, "qualifier + sibling universal");
1131        // Exactly one member is the class ex:C; the other is the qualifier shape.
1132        let classes: Vec<Term> = ss
1133            .iter()
1134            .filter_map(|s| shifty_algebra::render::class_target_shape(*s, &schema.arena))
1135            .collect();
1136        assert_eq!(classes, vec![class_named("http://ex/C")]);
1137        // …and the non-class member is the qualifier (nodeKind IRI), not dropped.
1138        assert!(
1139            ss.iter().any(|s| matches!(
1140                schema.arena.get(*s),
1141                Shape::Annotated { .. } | Shape::TestKind(_)
1142            )),
1143            "the qualifiedValueShape must still be present: {ss:?}",
1144        );
1145    }
1146}