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