Skip to main content

shifty_engine/
witness.rs

1//! The witnessing evaluator (`docs/06-repair.md` §5) — the structured, lossless
2//! sibling of [`explain`](crate::validate). For a focus node that fails a
3//! statement it returns a [`Failure`]: the failed sub-DAG of `φ`, pruned to
4//! exactly what did not hold, with the structural gap at each node. Its dual,
5//! [`Satisfaction`], records *why* a shape currently holds, so a `Not(φ)` failure can
6//! be repaired by breaking `φ`. One dispatcher produces both tagged polarities;
7//! they are mutually recursive only in their data grammar through `Not`.
8//!
9//! This is the input to repair synthesis; it makes no repair decisions. It reuses
10//! the [`ShapeEvaluator`] satisfaction oracle (`holds`) and its gfp back-edge
11//! guard verbatim, so witnessing agrees with validation by construction.
12
13use crate::frozen::FrozenIndexedDataset;
14use crate::path::{PathBackend, node_of, pred, succ};
15use crate::sparql::{SparqlDiagnostic, SparqlExecutor};
16use crate::validate::{NonStratifiable, ShapeEvaluator, focus_nodes_with, uses_shapes_graph};
17use oxrdf::{Graph, NamedNode, Term, Triple};
18use serde::{Deserialize, Serialize};
19use shifty_algebra::{ConstraintKind, Path, Schema, Selector, Shape, ShapeArena, ShapeId};
20use shifty_opt::analyze;
21use std::collections::{BTreeSet, HashSet, VecDeque};
22
23/// Why one focus node failed one statement: the failed sub-structure of `φ`,
24/// pruned to exactly the parts that did not hold. The input to repair synthesis.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
26pub struct FocusWitness {
27    pub focus: Term,
28    /// Index of the violated `(selector, shape)` statement in the schema.
29    pub statement: usize,
30    pub failure: Failure,
31}
32
33/// The relational (pairwise) leaf constraints — distinct from value-type atoms.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
35pub enum RelKind {
36    Eq,
37    Disj,
38    Lt,
39    Le,
40    UniqueLang,
41}
42
43/// One concrete positive certificate that a node is a `π`-successor of its
44/// parent. Every [`Edge`](PathSupport::Edge) is present in the evaluation graph.
45///
46/// This is deliberately *not* a complete deletion cut: an alternative or
47/// cyclic path may have additional routes that are not represented here.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
49#[serde(tag = "type", content = "details", rename_all = "snake_case")]
50pub enum PathSupport {
51    /// Reflexive (`Id`): the node reached itself; nothing to cut.
52    Empty,
53    /// A single triple edge used by this certificate.
54    Edge(Triple),
55    /// A chain (`Seq`/`Star` expansion) used by this certificate.
56    Chain(Vec<PathSupport>),
57    /// Multiple certificate branches when a caller explicitly retains them.
58    /// The current path fold is allowed to return only the first successful
59    /// syntactic alternative.
60    Alt(Vec<PathSupport>),
61}
62
63/// A reached value that satisfied a qualified count's nested shape.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
65pub struct QualifiedMatch {
66    pub value: Term,
67    pub path_support: PathSupport,
68    pub satisfaction: Box<Satisfaction>,
69}
70
71/// A reached near-match that failed a qualified count's nested shape.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
73pub struct RejectedCandidate {
74    pub value: Term,
75    pub path_support: PathSupport,
76    pub failure: Box<Failure>,
77}
78
79/// The failed sub-structure of `φ` (additive direction: what to *add*).
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
81#[serde(tag = "type", content = "details", rename_all = "snake_case")]
82pub enum Failure {
83    /// A value-type leaf failed at `node` (TestConst / TestType / TestKind).
84    /// `produced_by` names the triples that made `node` a value (`Some` for a
85    /// value-scoped atom, `None` for one on the focus itself).
86    Atom {
87        shape: ShapeId,
88        node: Term,
89        observed: Vec<Term>,
90        expected: Box<Shape>,
91        reached_by: Path,
92        produced_by: Option<PathSupport>,
93    },
94    /// A relational leaf failed (Eq / Disj / Lt / Le / UniqueLang): `lhs`/`rhs`
95    /// carry the two compared value-sets with their support, `offending` the
96    /// witnessing pairs/members.
97    Relational {
98        shape: ShapeId,
99        node: Term,
100        kind: RelKind,
101        lhs: Vec<(Term, PathSupport)>,
102        rhs: Vec<(Term, PathSupport)>,
103        offending: Vec<(Term, Term)>,
104    },
105    /// `closed(Q)` failed: these (predicate, object) pairs are not allowed.
106    Closed {
107        shape: ShapeId,
108        node: Term,
109        offenders: Vec<(NamedNode, Term)>,
110    },
111    /// `¬φ` failed because `φ` holds at `node` — it must be falsified.
112    Not {
113        shape: ShapeId,
114        node: Term,
115        inner: Box<Satisfaction>,
116    },
117    /// Conjunction: every listed child failed and ALL must be repaired.
118    All {
119        shape: ShapeId,
120        node: Term,
121        failed: Vec<Failure>,
122    },
123    /// Disjunction: no branch held; repairing ANY ONE suffices.
124    Any {
125        shape: ShapeId,
126        node: Term,
127        branches: Vec<Failure>,
128    },
129    /// `∃≥min π.q` under-satisfied: `have` values match, `min` required.
130    ///
131    /// `sibling_qualifiers` collects the inner shapes of every `∀π.φ` universal
132    /// (encoded as `∃≤0 π.¬φ`) on this same path that is *conjoined above* this
133    /// count — including ones from sibling property shapes, i.e. a different `And`
134    /// node. Those universals are vacuously satisfied when their path is empty, so
135    /// they never witness as failures themselves, yet any value added to satisfy
136    /// this `CountLow` must also conform to each of them.
137    CountLow {
138        shape: ShapeId,
139        node: Term,
140        path: Path,
141        qualifier: ShapeId,
142        have: u64,
143        min: u64,
144        qualifying_matches: Vec<QualifiedMatch>,
145        rejected_candidates: Vec<RejectedCandidate>,
146        sibling_qualifiers: Vec<ShapeId>,
147    },
148    /// `∃≤max π.q` over-satisfied. `matched` pairs each counted value with its
149    /// support (so deletion cuts the right edges for `Seq`/`Star` paths).
150    /// `per_value` is populated only for the `∀`-encoding (`∃≤0 π.¬inner`).
151    CountHigh {
152        shape: ShapeId,
153        node: Term,
154        path: Path,
155        qualifier: ShapeId,
156        matched: Vec<(Term, PathSupport)>,
157        max: u64,
158        excess_values: Vec<(Term, PathSupport)>,
159        per_value: Vec<(Term, Failure)>,
160    },
161    /// Opaque SPARQL — no algebraic witness.
162    Opaque {
163        shape: ShapeId,
164        node: Term,
165        messages: Vec<Term>,
166        diagnostic: Option<SparqlDiagnostic>,
167    },
168}
169
170/// Compatibility spelling retained for the repair-facing API. New public code
171/// should use [`Failure`], the vocabulary shared with [`Evidence`].
172pub type Witness = Failure;
173
174/// Why `φ` currently *holds* at a node (deletive direction: what to *delete*).
175/// The dual of [`Failure`].
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
177#[serde(tag = "type", content = "details", rename_all = "snake_case")]
178pub enum Satisfaction {
179    /// `⊤` — vacuously true; no graph edit falsifies it.
180    Irrefutable { shape: ShapeId },
181    /// A value-type leaf holds at `node`; `produced_by` names the edges to cut.
182    Atom {
183        shape: ShapeId,
184        node: Term,
185        observed: Vec<Term>,
186        expected: Box<Shape>,
187        reached_by: Path,
188        produced_by: PathSupport,
189    },
190    /// Conjunction holds because ALL children hold ⟹ break ANY ONE.
191    AllHeld {
192        shape: ShapeId,
193        node: Term,
194        children: Vec<Satisfaction>,
195    },
196    /// Disjunction holds because these branches hold ⟹ break EVERY one.
197    AnyHeld {
198        shape: ShapeId,
199        node: Term,
200        satisfied: Vec<Satisfaction>,
201    },
202    /// `∃[min..max] π.q` holds. `matches` carries each counted value with its
203    /// concrete path certificate and q-satisfaction trace.
204    CountHeld {
205        shape: ShapeId,
206        node: Term,
207        path: Path,
208        qualifier: ShapeId,
209        matches: Vec<(Term, PathSupport, Satisfaction)>,
210        observed_count: u64,
211        min: Option<u64>,
212        max: Option<u64>,
213    },
214    /// Universal encoding `∃≤0 π.¬q` holds because every reached value
215    /// satisfies `q`. Each checked value retains its path certificate and
216    /// satisfaction trace, including coinductive back-edges.
217    ForAllHeld {
218        shape: ShapeId,
219        node: Term,
220        path: Path,
221        qualifier: ShapeId,
222        values: Vec<(Term, PathSupport, Satisfaction)>,
223    },
224    /// `¬φ` holds because `φ` fails ⟹ make `φ` hold. Flips to the additive side.
225    NotHeld {
226        shape: ShapeId,
227        node: Term,
228        inner_fails: Box<Failure>,
229    },
230    /// Holds but cannot be falsified by data deletion in scope (closed / relational
231    /// / opaque SPARQL).
232    Blocked {
233        shape: ShapeId,
234        node: Term,
235        reason: BlockReason,
236    },
237    /// Support reached only through a gfp back-edge: coinductively assumed true,
238    /// with no finite set of facts to delete.
239    Coinductive { shape: ShapeId, node: Term },
240}
241
242/// Compatibility spelling retained while repair internals migrate to the
243/// unified evidence vocabulary. New public code should use [`Satisfaction`].
244pub type SatTrace = Satisfaction;
245
246/// The applicable evidence polarity for one selected `(statement, focus)` pair.
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
248#[serde(tag = "status", content = "evidence", rename_all = "snake_case")]
249pub enum Evidence {
250    #[serde(rename = "pass")]
251    Satisfaction(Satisfaction),
252    #[serde(rename = "fail")]
253    Failure(Failure),
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
257#[serde(rename_all = "snake_case")]
258pub enum EvaluationStatus {
259    Pass,
260    Fail,
261}
262
263/// Stable, polarity-aware discriminant for an evidence node.
264///
265/// This is the shared enumeration used when evidence must be named without
266/// copying its variant payload, including repair-origin links and language
267/// bindings. It is distinct from repair choice kinds and candidate enumeration.
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
269#[serde(rename_all = "snake_case")]
270pub enum EvidenceKind {
271    Irrefutable,
272    AtomHeld,
273    AllHeld,
274    AnyHeld,
275    CountHeld,
276    AllValuesHeld,
277    NotHeld,
278    Blocked,
279    Coinductive,
280    AtomFailed,
281    RelationalFailed,
282    ClosedFailed,
283    NotFailed,
284    AllFailed,
285    AnyFailed,
286    CountLow,
287    CountHigh,
288    Opaque,
289}
290
291impl EvidenceKind {
292    pub fn status(self) -> EvaluationStatus {
293        match self {
294            Self::Irrefutable
295            | Self::AtomHeld
296            | Self::AllHeld
297            | Self::AnyHeld
298            | Self::CountHeld
299            | Self::AllValuesHeld
300            | Self::NotHeld
301            | Self::Blocked
302            | Self::Coinductive => EvaluationStatus::Pass,
303            Self::AtomFailed
304            | Self::RelationalFailed
305            | Self::ClosedFailed
306            | Self::NotFailed
307            | Self::AllFailed
308            | Self::AnyFailed
309            | Self::CountLow
310            | Self::CountHigh
311            | Self::Opaque => EvaluationStatus::Fail,
312        }
313    }
314
315    pub fn as_str(self) -> &'static str {
316        match self {
317            Self::Irrefutable => "irrefutable",
318            Self::AtomHeld => "atom_held",
319            Self::AllHeld => "all_held",
320            Self::AnyHeld => "any_held",
321            Self::CountHeld => "count_held",
322            Self::AllValuesHeld => "all_values_held",
323            Self::NotHeld => "not_held",
324            Self::Blocked => "blocked",
325            Self::Coinductive => "coinductive",
326            Self::AtomFailed => "atom_failed",
327            Self::RelationalFailed => "relational_failed",
328            Self::ClosedFailed => "closed_failed",
329            Self::NotFailed => "not_failed",
330            Self::AllFailed => "all_failed",
331            Self::AnyFailed => "any_failed",
332            Self::CountLow => "count_low",
333            Self::CountHigh => "count_high",
334            Self::Opaque => "opaque",
335        }
336    }
337}
338
339impl Evidence {
340    pub fn status(&self) -> EvaluationStatus {
341        match self {
342            Self::Satisfaction(_) => EvaluationStatus::Pass,
343            Self::Failure(_) => EvaluationStatus::Fail,
344        }
345    }
346
347    /// Typed pre-order traversal. Variant child order, path value order, and
348    /// source conjunction/disjunction order are stable.
349    pub fn walk(&self) -> Vec<EvidenceNodeRef<'_>> {
350        let mut out = Vec::new();
351        walk_evidence(self, &mut out);
352        out
353    }
354
355    pub fn supporting_triples(&self) -> Vec<Triple> {
356        let mut triples = Vec::new();
357        collect_support(self, &mut triples);
358        dedup_stable(triples)
359    }
360
361    /// Positive path certificates referenced by the evidence in traversal
362    /// order, deduplicated by structural equality.
363    pub fn path_supports(&self) -> Vec<PathSupport> {
364        let mut supports = Vec::new();
365        collect_path_supports(self, &mut supports);
366        supports.into_iter().fold(Vec::new(), |mut out, value| {
367            if !out.contains(&value) {
368                out.push(value);
369            }
370            out
371        })
372    }
373
374    pub fn matched_values(&self) -> Vec<Term> {
375        let mut values = Vec::new();
376        collect_matched(self, &mut values);
377        dedup_stable(values)
378    }
379
380    /// The values of [`matched_values`](Self::matched_values), grouped by the
381    /// path each was counted along. Groups appear in traversal order and values
382    /// within a group in match order, both deduplicated by first occurrence; a
383    /// path counted at more than one evidence node yields one group holding
384    /// every value counted along it.
385    ///
386    /// This reads the structured match records directly, so callers need not
387    /// parse `explain()` text or re-derive values from supporting triples.
388    pub fn matched_values_by_path(&self) -> Vec<(Path, Vec<Term>)> {
389        let mut pairs = Vec::new();
390        collect_matched_by_path(self, &mut pairs);
391        let mut out: Vec<(Path, Vec<Term>)> = Vec::new();
392        for (path, value) in pairs {
393            match out.iter_mut().find(|(seen, _)| seen == path) {
394                Some((_, values)) => {
395                    if !values.contains(value) {
396                        values.push(value.clone());
397                    }
398                }
399                None => out.push((path.clone(), vec![value.clone()])),
400            }
401        }
402        out
403    }
404
405    /// The matched values counted along `path`, in match order and
406    /// deduplicated — the single-path projection of
407    /// [`matched_values_by_path`](Self::matched_values_by_path). Empty when the
408    /// evidence counted nothing along `path`.
409    pub fn values_for_path(&self, path: &Path) -> Vec<Term> {
410        let mut pairs = Vec::new();
411        collect_matched_by_path(self, &mut pairs);
412        dedup_stable(
413            pairs
414                .into_iter()
415                .filter(|(seen, _)| *seen == path)
416                .map(|(_, value)| value.clone())
417                .collect(),
418        )
419    }
420
421    pub fn missing_obligations(&self) -> Vec<MissingObligation> {
422        let mut out = Vec::new();
423        collect_missing(self, &mut out);
424        out
425    }
426
427    pub fn offending_values(&self) -> Vec<Term> {
428        let mut values = Vec::new();
429        collect_offending(self, &mut values);
430        dedup_stable(values)
431    }
432
433    pub fn source_constraints(&self) -> Vec<ShapeId> {
434        dedup_stable(
435            self.walk()
436                .into_iter()
437                .map(EvidenceNodeRef::constraint_id)
438                .collect(),
439        )
440    }
441
442    pub fn explain(&self) -> String {
443        self.walk()
444            .into_iter()
445            .enumerate()
446            .map(|(index, node)| format!("{index}: {} @{}", node.kind(), node.constraint_id().0))
447            .collect::<Vec<_>>()
448            .join("\n")
449    }
450
451    pub fn to_json(&self) -> serde_json::Result<String> {
452        serde_json::to_string(self)
453    }
454}
455
456#[derive(Debug, Clone, Copy)]
457pub enum EvidenceNodeRef<'a> {
458    Satisfaction(&'a SatTrace),
459    Failure(&'a Witness),
460}
461
462impl<'a> EvidenceNodeRef<'a> {
463    pub fn constraint_id(self) -> ShapeId {
464        match self {
465            Self::Satisfaction(value) => satisfaction_constraint_id(value),
466            Self::Failure(value) => failure_constraint_id(value),
467        }
468    }
469
470    /// The node this judgment is about, completing the `(constraint, node)`
471    /// address the shape memo (`validate.rs`) is keyed by.
472    ///
473    /// `None` only for [`SatTrace::Irrefutable`], which is `⊤` and so is about
474    /// no node at all.
475    pub fn node(self) -> Option<&'a Term> {
476        match self {
477            Self::Satisfaction(value) => satisfaction_node(value),
478            Self::Failure(value) => Some(failure_node(value)),
479        }
480    }
481
482    pub fn status(self) -> EvaluationStatus {
483        self.evidence_kind().status()
484    }
485
486    pub fn evidence_kind(self) -> EvidenceKind {
487        match self {
488            Self::Satisfaction(value) => match value {
489                SatTrace::Irrefutable { .. } => EvidenceKind::Irrefutable,
490                SatTrace::Atom { .. } => EvidenceKind::AtomHeld,
491                SatTrace::AllHeld { .. } => EvidenceKind::AllHeld,
492                SatTrace::AnyHeld { .. } => EvidenceKind::AnyHeld,
493                SatTrace::CountHeld { .. } => EvidenceKind::CountHeld,
494                SatTrace::ForAllHeld { .. } => EvidenceKind::AllValuesHeld,
495                SatTrace::NotHeld { .. } => EvidenceKind::NotHeld,
496                SatTrace::Blocked { .. } => EvidenceKind::Blocked,
497                SatTrace::Coinductive { .. } => EvidenceKind::Coinductive,
498            },
499            Self::Failure(value) => match value {
500                Witness::Atom { .. } => EvidenceKind::AtomFailed,
501                Witness::Relational { .. } => EvidenceKind::RelationalFailed,
502                Witness::Closed { .. } => EvidenceKind::ClosedFailed,
503                Witness::Not { .. } => EvidenceKind::NotFailed,
504                Witness::All { .. } => EvidenceKind::AllFailed,
505                Witness::Any { .. } => EvidenceKind::AnyFailed,
506                Witness::CountLow { .. } => EvidenceKind::CountLow,
507                Witness::CountHigh { .. } => EvidenceKind::CountHigh,
508                Witness::Opaque { .. } => EvidenceKind::Opaque,
509            },
510        }
511    }
512
513    pub fn kind(self) -> &'static str {
514        self.evidence_kind().as_str()
515    }
516
517    /// Immediate evidence children in stable semantic order.
518    ///
519    /// This is the shared grammar of failure and satisfaction evidence. `Not`
520    /// crosses polarity; qualified counts can contain both satisfaction traces
521    /// for matches and failure witnesses for rejected candidates. Callers that
522    /// only need structure should use this instead of maintaining another
523    /// variant-specific recursive walk.
524    pub fn children(self) -> Vec<Self> {
525        let mut out = Vec::new();
526        self.for_each_child(|child| out.push(child));
527        out
528    }
529
530    fn for_each_child(self, mut visit: impl FnMut(Self)) {
531        match self {
532            Self::Failure(value) => match value {
533                Witness::Not { inner, .. } => visit(Self::Satisfaction(inner)),
534                Witness::All { failed, .. } => {
535                    failed.iter().for_each(|child| visit(Self::Failure(child)))
536                }
537                Witness::Any { branches, .. } => branches
538                    .iter()
539                    .for_each(|child| visit(Self::Failure(child))),
540                Witness::CountLow {
541                    qualifying_matches,
542                    rejected_candidates,
543                    ..
544                } => {
545                    qualifying_matches.iter().for_each(|item| {
546                        visit(Self::Satisfaction(&item.satisfaction));
547                    });
548                    rejected_candidates
549                        .iter()
550                        .for_each(|item| visit(Self::Failure(&item.failure)));
551                }
552                Witness::CountHigh { per_value, .. } => per_value
553                    .iter()
554                    .for_each(|(_, child)| visit(Self::Failure(child))),
555                Witness::Atom { .. }
556                | Witness::Relational { .. }
557                | Witness::Closed { .. }
558                | Witness::Opaque { .. } => {}
559            },
560            Self::Satisfaction(value) => match value {
561                SatTrace::AllHeld { children, .. } => children
562                    .iter()
563                    .for_each(|child| visit(Self::Satisfaction(child))),
564                SatTrace::AnyHeld { satisfied, .. } => satisfied
565                    .iter()
566                    .for_each(|child| visit(Self::Satisfaction(child))),
567                SatTrace::CountHeld { matches, .. } => matches
568                    .iter()
569                    .for_each(|(_, _, child)| visit(Self::Satisfaction(child))),
570                SatTrace::ForAllHeld { values, .. } => values
571                    .iter()
572                    .for_each(|(_, _, child)| visit(Self::Satisfaction(child))),
573                SatTrace::NotHeld { inner_fails, .. } => visit(Self::Failure(inner_fails)),
574                SatTrace::Irrefutable { .. }
575                | SatTrace::Atom { .. }
576                | SatTrace::Blocked { .. }
577                | SatTrace::Coinductive { .. } => {}
578            },
579        }
580    }
581}
582
583/// One cardinality deficit: `node` has `observed_count` values along `path`
584/// satisfying `qualifier`, and needs `required_count`.
585///
586/// Everything needed to describe the missing edge is here, so a driver never
587/// has to read `explain()` to learn what would close the gap. `node` is not
588/// always the focus — a count nested inside a rejected candidate reports its own
589/// node — so filter on it when you mean deficits on the focus itself.
590#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
591pub struct MissingObligation {
592    pub constraint_id: ShapeId,
593    /// The node the deficit is about.
594    pub node: Term,
595    /// The path its values were counted along.
596    pub path: Path,
597    /// The shape each counted value must satisfy — what an added value has to
598    /// conform to for the count to move.
599    pub qualifier: ShapeId,
600    pub observed_count: u64,
601    pub required_count: u64,
602    pub missing: u64,
603}
604
605fn dedup_stable<T: Clone + Eq + std::hash::Hash>(values: Vec<T>) -> Vec<T> {
606    let mut seen = HashSet::new();
607    values
608        .into_iter()
609        .filter(|value| seen.insert(value.clone()))
610        .collect()
611}
612
613fn failure_constraint_id(value: &Witness) -> ShapeId {
614    match value {
615        Witness::Atom { shape, .. }
616        | Witness::Relational { shape, .. }
617        | Witness::Closed { shape, .. }
618        | Witness::Not { shape, .. }
619        | Witness::All { shape, .. }
620        | Witness::Any { shape, .. }
621        | Witness::CountLow { shape, .. }
622        | Witness::CountHigh { shape, .. }
623        | Witness::Opaque { shape, .. } => *shape,
624    }
625}
626
627fn satisfaction_constraint_id(value: &SatTrace) -> ShapeId {
628    match value {
629        SatTrace::Irrefutable { shape }
630        | SatTrace::Atom { shape, .. }
631        | SatTrace::AllHeld { shape, .. }
632        | SatTrace::AnyHeld { shape, .. }
633        | SatTrace::CountHeld { shape, .. }
634        | SatTrace::ForAllHeld { shape, .. }
635        | SatTrace::NotHeld { shape, .. }
636        | SatTrace::Blocked { shape, .. }
637        | SatTrace::Coinductive { shape, .. } => *shape,
638    }
639}
640
641fn failure_node(value: &Witness) -> &Term {
642    match value {
643        Witness::Atom { node, .. }
644        | Witness::Relational { node, .. }
645        | Witness::Closed { node, .. }
646        | Witness::Not { node, .. }
647        | Witness::All { node, .. }
648        | Witness::Any { node, .. }
649        | Witness::CountLow { node, .. }
650        | Witness::CountHigh { node, .. }
651        | Witness::Opaque { node, .. } => node,
652    }
653}
654
655fn satisfaction_node(value: &SatTrace) -> Option<&Term> {
656    match value {
657        SatTrace::Irrefutable { .. } => None,
658        SatTrace::Atom { node, .. }
659        | SatTrace::AllHeld { node, .. }
660        | SatTrace::AnyHeld { node, .. }
661        | SatTrace::CountHeld { node, .. }
662        | SatTrace::ForAllHeld { node, .. }
663        | SatTrace::NotHeld { node, .. }
664        | SatTrace::Blocked { node, .. }
665        | SatTrace::Coinductive { node, .. } => Some(node),
666    }
667}
668
669fn walk_evidence<'a>(value: &'a Evidence, out: &mut Vec<EvidenceNodeRef<'a>>) {
670    let root = match value {
671        Evidence::Satisfaction(value) => EvidenceNodeRef::Satisfaction(value),
672        Evidence::Failure(value) => EvidenceNodeRef::Failure(value),
673    };
674    walk_node(root, out);
675}
676
677fn walk_node<'a>(value: EvidenceNodeRef<'a>, out: &mut Vec<EvidenceNodeRef<'a>>) {
678    out.push(value);
679    value.for_each_child(|child| walk_node(child, out));
680}
681
682fn support_triples(value: &PathSupport, out: &mut Vec<Triple>) {
683    match value {
684        PathSupport::Empty => {}
685        PathSupport::Edge(triple) => out.push(triple.clone()),
686        PathSupport::Chain(children) | PathSupport::Alt(children) => {
687            children
688                .iter()
689                .for_each(|child| support_triples(child, out));
690        }
691    }
692}
693
694fn collect_support(value: &Evidence, out: &mut Vec<Triple>) {
695    for node in value.walk() {
696        match node {
697            EvidenceNodeRef::Satisfaction(SatTrace::Atom { produced_by, .. }) => {
698                support_triples(produced_by, out);
699            }
700            EvidenceNodeRef::Satisfaction(SatTrace::CountHeld { matches, .. }) => matches
701                .iter()
702                .for_each(|(_, support, _)| support_triples(support, out)),
703            EvidenceNodeRef::Satisfaction(SatTrace::ForAllHeld { values, .. }) => values
704                .iter()
705                .for_each(|(_, support, _)| support_triples(support, out)),
706            EvidenceNodeRef::Failure(Witness::Atom {
707                produced_by: Some(support),
708                ..
709            }) => support_triples(support, out),
710            EvidenceNodeRef::Failure(Witness::Relational { lhs, rhs, .. }) => lhs
711                .iter()
712                .chain(rhs)
713                .for_each(|(_, support)| support_triples(support, out)),
714            EvidenceNodeRef::Failure(Witness::CountLow {
715                qualifying_matches,
716                rejected_candidates,
717                ..
718            }) => {
719                qualifying_matches
720                    .iter()
721                    .for_each(|item| support_triples(&item.path_support, out));
722                rejected_candidates
723                    .iter()
724                    .for_each(|item| support_triples(&item.path_support, out));
725            }
726            EvidenceNodeRef::Failure(Witness::CountHigh { matched, .. }) => matched
727                .iter()
728                .for_each(|(_, support)| support_triples(support, out)),
729            _ => {}
730        }
731    }
732}
733
734fn collect_path_supports(value: &Evidence, out: &mut Vec<PathSupport>) {
735    for node in value.walk() {
736        match node {
737            EvidenceNodeRef::Satisfaction(SatTrace::Atom { produced_by, .. }) => {
738                out.push(produced_by.clone());
739            }
740            EvidenceNodeRef::Satisfaction(SatTrace::CountHeld { matches, .. }) => {
741                out.extend(matches.iter().map(|(_, support, _)| support.clone()));
742            }
743            EvidenceNodeRef::Satisfaction(SatTrace::ForAllHeld { values, .. }) => {
744                out.extend(values.iter().map(|(_, support, _)| support.clone()));
745            }
746            EvidenceNodeRef::Failure(Witness::Atom {
747                produced_by: Some(support),
748                ..
749            }) => out.push(support.clone()),
750            EvidenceNodeRef::Failure(Witness::Relational { lhs, rhs, .. }) => {
751                out.extend(lhs.iter().chain(rhs).map(|(_, support)| support.clone()))
752            }
753            EvidenceNodeRef::Failure(Witness::CountLow {
754                qualifying_matches,
755                rejected_candidates,
756                ..
757            }) => {
758                out.extend(
759                    qualifying_matches
760                        .iter()
761                        .map(|item| item.path_support.clone()),
762                );
763                out.extend(
764                    rejected_candidates
765                        .iter()
766                        .map(|item| item.path_support.clone()),
767                );
768            }
769            EvidenceNodeRef::Failure(Witness::CountHigh { matched, .. }) => {
770                out.extend(matched.iter().map(|(_, support)| support.clone()));
771            }
772            _ => {}
773        }
774    }
775}
776
777fn collect_matched(value: &Evidence, out: &mut Vec<Term>) {
778    for node in value.walk() {
779        match node {
780            EvidenceNodeRef::Satisfaction(SatTrace::CountHeld { matches, .. }) => {
781                out.extend(matches.iter().map(|(value, _, _)| value.clone()));
782            }
783            EvidenceNodeRef::Satisfaction(SatTrace::ForAllHeld { values, .. }) => {
784                out.extend(values.iter().map(|(value, _, _)| value.clone()));
785            }
786            EvidenceNodeRef::Failure(Witness::CountLow {
787                qualifying_matches, ..
788            }) => out.extend(qualifying_matches.iter().map(|item| item.value.clone())),
789            EvidenceNodeRef::Failure(Witness::CountHigh { matched, .. }) => {
790                out.extend(matched.iter().map(|(value, _)| value.clone()));
791            }
792            _ => {}
793        }
794    }
795}
796
797/// The same match records [`collect_matched`] reads, each paired with the path
798/// its containing node counted along.
799fn collect_matched_by_path<'a>(value: &'a Evidence, out: &mut Vec<(&'a Path, &'a Term)>) {
800    for node in value.walk() {
801        match node {
802            EvidenceNodeRef::Satisfaction(SatTrace::CountHeld { path, matches, .. }) => {
803                out.extend(matches.iter().map(|(value, _, _)| (path, value)));
804            }
805            EvidenceNodeRef::Satisfaction(SatTrace::ForAllHeld { path, values, .. }) => {
806                out.extend(values.iter().map(|(value, _, _)| (path, value)));
807            }
808            EvidenceNodeRef::Failure(Witness::CountLow {
809                path,
810                qualifying_matches,
811                ..
812            }) => out.extend(qualifying_matches.iter().map(|item| (path, &item.value))),
813            EvidenceNodeRef::Failure(Witness::CountHigh { path, matched, .. }) => {
814                out.extend(matched.iter().map(|(value, _)| (path, value)));
815            }
816            _ => {}
817        }
818    }
819}
820
821fn collect_missing(value: &Evidence, out: &mut Vec<MissingObligation>) {
822    for node in value.walk() {
823        if let EvidenceNodeRef::Failure(Witness::CountLow {
824            shape,
825            node,
826            path,
827            qualifier,
828            have,
829            min,
830            ..
831        }) = node
832        {
833            out.push(MissingObligation {
834                constraint_id: *shape,
835                node: node.clone(),
836                path: path.clone(),
837                qualifier: *qualifier,
838                observed_count: *have,
839                required_count: *min,
840                missing: min - have,
841            });
842        }
843    }
844}
845
846fn collect_offending(value: &Evidence, out: &mut Vec<Term>) {
847    for node in value.walk() {
848        match node {
849            EvidenceNodeRef::Failure(Witness::Atom { node, .. }) => out.push(node.clone()),
850            EvidenceNodeRef::Failure(Witness::Closed { offenders, .. }) => {
851                out.extend(offenders.iter().map(|(_, value)| value.clone()));
852            }
853            EvidenceNodeRef::Failure(Witness::Relational { offending, .. }) => {
854                out.extend(
855                    offending
856                        .iter()
857                        .flat_map(|(left, right)| [left.clone(), right.clone()]),
858                );
859            }
860            EvidenceNodeRef::Failure(Witness::CountLow {
861                rejected_candidates,
862                ..
863            }) => out.extend(rejected_candidates.iter().map(|item| item.value.clone())),
864            EvidenceNodeRef::Failure(Witness::CountHigh { excess_values, .. }) => {
865                out.extend(excess_values.iter().map(|(value, _)| value.clone()));
866            }
867            _ => {}
868        }
869    }
870}
871
872/// A serializable catalog entry. Child shape ids in `constraint` resolve in
873/// the containing source or normalized catalog.
874#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
875pub struct ConstraintRecord {
876    pub id: ShapeId,
877    pub constraint_kind: ConstraintKind,
878    pub constraint: Shape,
879}
880
881#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
882pub struct ConstraintCatalog {
883    pub source: Vec<ConstraintRecord>,
884    pub normalized: Vec<ConstraintRecord>,
885}
886
887#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
888pub struct EvidenceSummary {
889    pub constraint_id: ShapeId,
890    pub constraint_kind: ConstraintKind,
891    pub status: EvaluationStatus,
892}
893
894#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
895pub struct ChildEvaluation {
896    pub source_constraint_ref: ShapeId,
897    pub normalized_constraint_ref: Option<ShapeId>,
898    pub status: EvaluationStatus,
899    pub evidence_summary: EvidenceSummary,
900}
901
902#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
903pub struct EvaluationProgress {
904    pub evaluated_children: Vec<ChildEvaluation>,
905}
906
907/// One selected focus under one authored statement. The evidence enum makes an
908/// inconsistent status/evidence pairing unrepresentable; `status()` is derived.
909#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
910pub struct FocusEvaluation {
911    pub focus: Term,
912    pub evidence: Evidence,
913    #[serde(default, skip_serializing_if = "Option::is_none")]
914    pub progress: Option<EvaluationProgress>,
915}
916
917impl FocusEvaluation {
918    pub fn status(&self) -> EvaluationStatus {
919        self.evidence.status()
920    }
921
922    pub fn explain(&self) -> String {
923        self.evidence.explain()
924    }
925}
926
927/// Results are grouped by authored statement, including statements whose
928/// selector chooses no focus nodes.
929#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
930pub struct StatementEvaluation {
931    pub source_statement_id: usize,
932    pub normalized_statement_id: Option<usize>,
933    pub source_constraint_id: ShapeId,
934    pub normalized_constraint_id: Option<ShapeId>,
935    pub constraint_kind: ConstraintKind,
936    pub constraint: Shape,
937    pub selector: Selector,
938    pub selected_foci: Vec<FocusEvaluation>,
939}
940
941impl StatementEvaluation {
942    /// Authored constraint ids referenced by this statement and its immediate
943    /// progress views, in first-occurrence order.
944    pub fn source_constraints(&self) -> Vec<ShapeId> {
945        let mut values = vec![self.source_constraint_id];
946        for focus in &self.selected_foci {
947            if let Some(progress) = &focus.progress {
948                values.extend(
949                    progress
950                        .evaluated_children
951                        .iter()
952                        .map(|child| child.source_constraint_ref),
953                );
954            }
955        }
956        dedup_stable(values)
957    }
958}
959
960/// The complete statement-oriented coverage horizon for one validation run.
961#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
962pub struct EvidenceRun {
963    pub conforms: bool,
964    pub constraints: ConstraintCatalog,
965    pub statements: Vec<StatementEvaluation>,
966}
967
968impl EvidenceRun {
969    pub fn to_json(&self) -> serde_json::Result<String> {
970        serde_json::to_string(self)
971    }
972
973    pub fn walk(&self) -> Vec<EvidenceNodeRef<'_>> {
974        self.statements
975            .iter()
976            .flat_map(|statement| &statement.selected_foci)
977            .flat_map(|focus| focus.evidence.walk())
978            .collect()
979    }
980}
981
982/// Why a holding shape admits no data-deletion repair.
983#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
984pub enum BlockReason {
985    OpaqueSparql,
986    /// Falsifying `closed(Q)` would need *adding* a disallowed predicate.
987    ClosedNeedsAdd,
988    /// Relational falsification is not synthesized in this cut.
989    Unsupported,
990}
991
992type Stack = HashSet<(ShapeId, Term)>;
993
994/// Strat-check and build the SPARQL executor shared by every witnessing entry
995/// point. The executor's frozen dataset is built over `context` — the graph that
996/// paths, class hierarchy, and SPARQL read; focus discovery is the caller's job
997/// and reads the (possibly narrower) data graph. The caller derives
998/// `backend`/`ShapeEvaluator` from the returned executor — those borrow it, so
999/// they can't be bundled in here.
1000fn prepare(context: &Graph, schema: &Schema) -> Result<SparqlExecutor, NonStratifiable> {
1001    let strat = analyze(&schema.arena);
1002    if !strat.stratifiable {
1003        let components = strat
1004            .strata
1005            .iter()
1006            .filter(|s| !s.stratifiable)
1007            .map(|s| s.shapes.clone())
1008            .collect();
1009        return Err(NonStratifiable { components });
1010    }
1011
1012    let uses_shapes = uses_shapes_graph(&schema.arena);
1013    let frozen = if uses_shapes {
1014        FrozenIndexedDataset::from_graphs(context, context)
1015    } else {
1016        FrozenIndexedDataset::from_graph(context)
1017    };
1018    Ok(SparqlExecutor::from_frozen(frozen, uses_shapes))
1019}
1020
1021/// Witness every `(focus, statement)` that fails, mirroring `validate`'s driver.
1022/// Focus discovery uses `data`; paths, class hierarchy, and SPARQL are evaluated
1023/// against `context`, which should contain `data` (pass `data` again when there
1024/// is no separate shapes graph; for split inputs `context = data ∪ shapes`). The
1025/// witnessing dual of [`crate::validate_with_context`].
1026pub fn witness_violations(
1027    data: &Graph,
1028    context: &Graph,
1029    schema: &Schema,
1030) -> Result<Vec<FocusWitness>, NonStratifiable> {
1031    let sparql = prepare(context, schema)?;
1032    let backend = sparql
1033        .frozen()
1034        .expect("witness executor always has a frozen dataset");
1035    let mut evaluator = ShapeEvaluator::new(backend, &schema.arena, &sparql);
1036
1037    let mut out = Vec::new();
1038    for (i, st) in schema.statements.iter().enumerate() {
1039        for v in focus_nodes_with(data, backend, &st.selector, &schema.arena, &sparql) {
1040            let mut stack = Stack::new();
1041            if let Some(failure) = witness(
1042                &mut evaluator,
1043                &v,
1044                st.shape,
1045                &Path::Id,
1046                None,
1047                &[],
1048                &mut stack,
1049            ) {
1050                out.push(FocusWitness {
1051                    focus: v,
1052                    statement: i,
1053                    failure,
1054                });
1055            }
1056        }
1057    }
1058    Ok(out)
1059}
1060
1061/// The arena slot a named shape IRI refers to, if the schema names one. `iri` is
1062/// matched bare (no angle brackets), the form stored in [`Schema::names`].
1063pub fn shape_id_for_iri(schema: &Schema, iri: &str) -> Option<ShapeId> {
1064    schema
1065        .names
1066        .iter()
1067        .find_map(|(id, names)| names.iter().any(|name| name == iri).then_some(*id))
1068}
1069
1070/// Witness only the `(focus, statement)` violations whose statement targets
1071/// `shape` — the shape-scoped sibling of [`witness_violations`]. Returns the
1072/// *failing* foci with their [`Witness`] trees; passing foci are the domain of
1073/// [`satisfy_shape`]. Use [`shape_id_for_iri`] to resolve an IRI to its
1074/// `ShapeId`.
1075pub fn witness_shape(
1076    data: &Graph,
1077    context: &Graph,
1078    schema: &Schema,
1079    shape: ShapeId,
1080) -> Result<Vec<FocusWitness>, NonStratifiable> {
1081    let sparql = prepare(context, schema)?;
1082    let backend = sparql
1083        .frozen()
1084        .expect("witness executor always has a frozen dataset");
1085    let mut evaluator = ShapeEvaluator::new(backend, &schema.arena, &sparql);
1086
1087    let mut out = Vec::new();
1088    for (i, st) in schema.statements.iter().enumerate() {
1089        if st.shape != shape {
1090            continue;
1091        }
1092        for v in focus_nodes_with(data, backend, &st.selector, &schema.arena, &sparql) {
1093            let mut stack = Stack::new();
1094            if let Some(failure) = witness(
1095                &mut evaluator,
1096                &v,
1097                st.shape,
1098                &Path::Id,
1099                None,
1100                &[],
1101                &mut stack,
1102            ) {
1103                out.push(FocusWitness {
1104                    focus: v,
1105                    statement: i,
1106                    failure,
1107                });
1108            }
1109        }
1110    }
1111    Ok(out)
1112}
1113
1114/// Why one focus node *satisfies* one statement: the [`SatTrace`] recording why
1115/// `φ` holds, including the values matched along each checked path. The
1116/// satisfaction-side dual of [`FocusWitness`].
1117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1118pub struct FocusSat {
1119    pub focus: Term,
1120    /// Index of the satisfied `(selector, shape)` statement in the schema.
1121    pub statement: usize,
1122    pub trace: SatTrace,
1123}
1124
1125/// Trace every `(focus, statement)` that *holds* for a statement targeting
1126/// `shape` — the dual of [`witness_shape`]. Each [`FocusSat`] carries why the
1127/// node conforms, down to the values matched along every checked path (see
1128/// [`SatTrace`]). Use [`shape_id_for_iri`] to resolve an IRI to its `ShapeId`.
1129pub fn satisfy_shape(
1130    data: &Graph,
1131    context: &Graph,
1132    schema: &Schema,
1133    shape: ShapeId,
1134) -> Result<Vec<FocusSat>, NonStratifiable> {
1135    let sparql = prepare(context, schema)?;
1136    let backend = sparql
1137        .frozen()
1138        .expect("witness executor always has a frozen dataset");
1139    let mut evaluator = ShapeEvaluator::new(backend, &schema.arena, &sparql);
1140
1141    let mut out = Vec::new();
1142    for (i, st) in schema.statements.iter().enumerate() {
1143        if st.shape != shape {
1144            continue;
1145        }
1146        for v in focus_nodes_with(data, backend, &st.selector, &schema.arena, &sparql) {
1147            let mut stack = Stack::new();
1148            if let Some(trace) =
1149                sat_trace(&mut evaluator, &v, st.shape, &Path::Id, None, &mut stack)
1150            {
1151                out.push(FocusSat {
1152                    focus: v,
1153                    statement: i,
1154                    trace,
1155                });
1156            }
1157        }
1158    }
1159    Ok(out)
1160}
1161
1162/// Witness one specific `node` against one specific `shape` (by id) — the building
1163/// block for repairing a `ConformsTo` hole: bind it to a node, then witness that
1164/// node against the sub-shape and synthesize its repair. Returns `Ok(None)` when
1165/// the node already conforms (nothing to build), `Ok(Some(_))` otherwise. Paths
1166/// and class hierarchy are evaluated over `context`; there is no focus discovery
1167/// here (`node` is given), so unlike the other entry points this takes no
1168/// separate data graph.
1169///
1170/// The `statement` field of the returned [`FocusWitness`] is a sentinel
1171/// (`usize::MAX`): this is not a top-level statement. Traceable synthesis maps
1172/// it to an absent statement origin rather than exposing the sentinel.
1173pub fn witness_node(
1174    context: &Graph,
1175    schema: &Schema,
1176    node: &Term,
1177    shape: ShapeId,
1178) -> Result<Option<FocusWitness>, NonStratifiable> {
1179    let sparql = prepare(context, schema)?;
1180    let backend = sparql
1181        .frozen()
1182        .expect("witness executor always has a frozen dataset");
1183    let mut evaluator = ShapeEvaluator::new(backend, &schema.arena, &sparql);
1184
1185    let mut stack = Stack::new();
1186    Ok(witness(
1187        &mut evaluator,
1188        node,
1189        shape,
1190        &Path::Id,
1191        None,
1192        &[],
1193        &mut stack,
1194    )
1195    .map(|failure| FocusWitness {
1196        focus: node.clone(),
1197        statement: usize::MAX,
1198        failure,
1199    }))
1200}
1201
1202/// Reasons `φ` (slot `id`) fails at `node`. `None` ⟺ it holds (incl. on a gfp
1203/// back-edge). `reached_by` is the structured path from the focus; `produced_by`
1204/// is how `node` was reached from its parent value (for replace-in-place).
1205///
1206/// `scope` carries the universals `∀π.φ` (encoded `∃≤0 π.¬φ`) conjoined *above*
1207/// this point — every `And` ancestor contributes its universal children. They
1208/// hold vacuously when their path is empty, so they never witness as failures of
1209/// their own; a `CountLow` on the same path attaches them as `sibling_qualifiers`
1210/// so a value built to satisfy the count also satisfies them (`∀` and a sibling
1211/// count may live in *different* property shapes, hence different `And` nodes).
1212fn witness(
1213    eval: &mut ShapeEvaluator<'_>,
1214    node: &Term,
1215    id: ShapeId,
1216    reached_by: &Path,
1217    produced_by: Option<&PathSupport>,
1218    scope: &[(Path, ShapeId)],
1219    stack: &mut Stack,
1220) -> Option<Witness> {
1221    match evaluate_node(eval, node, id, reached_by, produced_by, scope, stack) {
1222        Evidence::Failure(value) => Some(value),
1223        Evidence::Satisfaction(_) => None,
1224    }
1225}
1226
1227/// Peel transparent `sh:severity` (`Annotated`) wrappers to the shape beneath.
1228fn peel_annotated(arena: &ShapeArena, mut id: ShapeId) -> ShapeId {
1229    while let Shape::Annotated { shape, .. } = arena.get(id) {
1230        id = *shape;
1231    }
1232    id
1233}
1234
1235/// If `id` is a universal `∀π.φ` (encoded `∃≤0 π.¬φ`, modulo `Annotated`
1236/// wrappers), its `(π, φ)`. `None` for any other shape. This is what an `And`
1237/// contributes to the scope its children witness under: such universals hold
1238/// vacuously on an empty path, yet any value later added there must satisfy `φ`.
1239fn as_universal(arena: &ShapeArena, id: ShapeId) -> Option<(Path, ShapeId)> {
1240    let Shape::Count {
1241        path,
1242        max: Some(0),
1243        qualifier,
1244        ..
1245    } = arena.get(peel_annotated(arena, id)).clone()
1246    else {
1247        return None;
1248    };
1249    match arena.get(peel_annotated(arena, qualifier)) {
1250        Shape::Not(inner) => Some((path, *inner)),
1251        _ => None,
1252    }
1253}
1254
1255fn relational_witness(
1256    eval: &ShapeEvaluator<'_>,
1257    node: &Term,
1258    id: ShapeId,
1259    shape: &Shape,
1260) -> Witness {
1261    let g = eval.backend();
1262    let (kind, lpath, rpred) = match shape {
1263        Shape::Eq(p, q) => (RelKind::Eq, p.clone(), Some(q.clone())),
1264        Shape::Disj(p, q) => (RelKind::Disj, p.clone(), Some(q.clone())),
1265        Shape::Lt(p, q) => (RelKind::Lt, p.clone(), Some(q.clone())),
1266        Shape::Le(p, q) => (RelKind::Le, p.clone(), Some(q.clone())),
1267        Shape::UniqueLang(p) => (RelKind::UniqueLang, p.clone(), None),
1268        _ => unreachable!("relational_witness on non-relational shape"),
1269    };
1270    let with_support = |g: &dyn PathBackend, p: &Path| -> Vec<(Term, PathSupport)> {
1271        succ_with_support(g, node, p)
1272    };
1273    let lhs = with_support(g, &lpath);
1274    let rhs = match &rpred {
1275        Some(q) => with_support(g, &Path::Pred(q.clone())),
1276        None => Vec::new(),
1277    };
1278    let offending = offending_pairs(kind, &lhs, &rhs);
1279    Witness::Relational {
1280        shape: id,
1281        node: node.clone(),
1282        kind,
1283        lhs,
1284        rhs,
1285        offending,
1286    }
1287}
1288
1289/// The witnessing pairs/members for a failed relational constraint.
1290fn offending_pairs(
1291    kind: RelKind,
1292    lhs: &[(Term, PathSupport)],
1293    rhs: &[(Term, PathSupport)],
1294) -> Vec<(Term, Term)> {
1295    let lvals: Vec<&Term> = lhs.iter().map(|(v, _)| v).collect();
1296    let rvals: Vec<&Term> = rhs.iter().map(|(v, _)| v).collect();
1297    match kind {
1298        RelKind::Eq => lvals
1299            .iter()
1300            .filter(|v| !rvals.contains(v))
1301            .chain(rvals.iter().filter(|v| !lvals.contains(v)))
1302            .map(|v| ((*v).clone(), (*v).clone()))
1303            .collect(),
1304        RelKind::Disj => lvals
1305            .iter()
1306            .filter(|v| rvals.contains(v))
1307            .map(|v| ((*v).clone(), (*v).clone()))
1308            .collect(),
1309        RelKind::Lt | RelKind::Le => {
1310            // pairs (a, b) that fail a < b (resp. a ≤ b)
1311            let mut bad = Vec::new();
1312            for a in &lvals {
1313                for b in &rvals {
1314                    let ok = match crate::value::compare_terms(a, b) {
1315                        Some(std::cmp::Ordering::Less) => true,
1316                        Some(std::cmp::Ordering::Equal) => kind == RelKind::Le,
1317                        _ => false,
1318                    };
1319                    if !ok {
1320                        bad.push(((*a).clone(), (*b).clone()));
1321                    }
1322                }
1323            }
1324            bad
1325        }
1326        RelKind::UniqueLang => {
1327            // pairs of lhs values sharing a language tag
1328            let mut bad = Vec::new();
1329            for i in 0..lvals.len() {
1330                for j in (i + 1)..lvals.len() {
1331                    if let (Term::Literal(a), Term::Literal(b)) = (lvals[i], lvals[j])
1332                        && let (Some(la), Some(lb)) = (a.language(), b.language())
1333                        && la.eq_ignore_ascii_case(lb)
1334                    {
1335                        bad.push((lvals[i].clone(), lvals[j].clone()));
1336                    }
1337                }
1338            }
1339            bad
1340        }
1341    }
1342}
1343
1344/// Support for why `φ` holds at `node`. `None` ⟺ it fails. The dual of [`witness`].
1345fn sat_trace(
1346    eval: &mut ShapeEvaluator<'_>,
1347    node: &Term,
1348    id: ShapeId,
1349    reached_by: &Path,
1350    produced_by: Option<&PathSupport>,
1351    stack: &mut Stack,
1352) -> Option<SatTrace> {
1353    match evaluate_node(eval, node, id, reached_by, produced_by, &[], stack) {
1354        Evidence::Satisfaction(value) => Some(value),
1355        Evidence::Failure(_) => None,
1356    }
1357}
1358
1359/// Materialize both evidence polarities through one dispatcher. Composite
1360/// children and qualified-count candidates are evaluated once and partitioned
1361/// into the applicable canonical side; negation crosses sides directly.
1362fn evaluate_node(
1363    eval: &mut ShapeEvaluator<'_>,
1364    node: &Term,
1365    id: ShapeId,
1366    reached_by: &Path,
1367    produced_by: Option<&PathSupport>,
1368    scope: &[(Path, ShapeId)],
1369    stack: &mut Stack,
1370) -> Evidence {
1371    crate::profile::record_evidence_visit();
1372    let key = (id, node.clone());
1373    if !stack.insert(key.clone()) {
1374        return Evidence::Satisfaction(SatTrace::Coinductive {
1375            shape: id,
1376            node: node.clone(),
1377        });
1378    }
1379
1380    let shape = eval.arena().get(id).clone();
1381    let evidence = match shape {
1382        Shape::Annotated { shape, .. } => {
1383            evaluate_node(eval, node, shape, reached_by, produced_by, scope, stack)
1384        }
1385        Shape::Top | Shape::Pending => Evidence::Satisfaction(SatTrace::Irrefutable { shape: id }),
1386        Shape::TestConst(_) | Shape::TestType(_) | Shape::TestKind(_) => {
1387            if eval.holds(node, id) {
1388                Evidence::Satisfaction(SatTrace::Atom {
1389                    shape: id,
1390                    node: node.clone(),
1391                    observed: vec![node.clone()],
1392                    expected: Box::new(eval.arena().get(id).clone()),
1393                    reached_by: reached_by.clone(),
1394                    produced_by: produced_by.cloned().unwrap_or(PathSupport::Empty),
1395                })
1396            } else {
1397                Evidence::Failure(Witness::Atom {
1398                    shape: id,
1399                    node: node.clone(),
1400                    observed: vec![node.clone()],
1401                    expected: Box::new(eval.arena().get(id).clone()),
1402                    reached_by: reached_by.clone(),
1403                    produced_by: produced_by.cloned(),
1404                })
1405            }
1406        }
1407        Shape::Eq(..) | Shape::Disj(..) | Shape::Lt(..) | Shape::Le(..) | Shape::UniqueLang(_) => {
1408            if eval.holds(node, id) {
1409                Evidence::Satisfaction(SatTrace::Blocked {
1410                    shape: id,
1411                    node: node.clone(),
1412                    reason: BlockReason::Unsupported,
1413                })
1414            } else {
1415                Evidence::Failure(relational_witness(eval, node, id, &shape))
1416            }
1417        }
1418        Shape::Closed(ref allowed) => {
1419            let offenders = closed_offenders(eval.backend(), node, allowed);
1420            if offenders.is_empty() {
1421                Evidence::Satisfaction(SatTrace::Blocked {
1422                    shape: id,
1423                    node: node.clone(),
1424                    reason: BlockReason::ClosedNeedsAdd,
1425                })
1426            } else {
1427                Evidence::Failure(Witness::Closed {
1428                    shape: id,
1429                    node: node.clone(),
1430                    offenders,
1431                })
1432            }
1433        }
1434        Shape::Sparql(constraint) => {
1435            if eval.holds(node, id) {
1436                Evidence::Satisfaction(SatTrace::Blocked {
1437                    shape: id,
1438                    node: node.clone(),
1439                    reason: BlockReason::OpaqueSparql,
1440                })
1441            } else {
1442                let violations = eval
1443                    .sparql()
1444                    .constraint_violations(&constraint, node)
1445                    .unwrap_or_default();
1446                let diagnostic = eval
1447                    .sparql()
1448                    .constraint_diagnostic(&constraint, node, &violations)
1449                    .ok();
1450                Evidence::Failure(Witness::Opaque {
1451                    shape: id,
1452                    node: node.clone(),
1453                    messages: constraint.messages,
1454                    diagnostic,
1455                })
1456            }
1457        }
1458        Shape::Expression(_) => {
1459            if eval.holds(node, id) {
1460                Evidence::Satisfaction(SatTrace::Blocked {
1461                    shape: id,
1462                    node: node.clone(),
1463                    reason: BlockReason::Unsupported,
1464                })
1465            } else {
1466                Evidence::Failure(Witness::Opaque {
1467                    shape: id,
1468                    node: node.clone(),
1469                    messages: Vec::new(),
1470                    diagnostic: None,
1471                })
1472            }
1473        }
1474        Shape::Not(child) => {
1475            match evaluate_node(eval, node, child, reached_by, produced_by, &[], stack) {
1476                Evidence::Failure(inner_fails) => Evidence::Satisfaction(SatTrace::NotHeld {
1477                    shape: id,
1478                    node: node.clone(),
1479                    inner_fails: Box::new(inner_fails),
1480                }),
1481                Evidence::Satisfaction(inner) => Evidence::Failure(Witness::Not {
1482                    shape: id,
1483                    node: node.clone(),
1484                    inner: Box::new(inner),
1485                }),
1486            }
1487        }
1488        Shape::And(children) => {
1489            let mut child_scope = scope.to_vec();
1490            child_scope.extend(
1491                children
1492                    .iter()
1493                    .filter_map(|&child| as_universal(eval.arena(), child)),
1494            );
1495            let evaluated: Vec<Evidence> = children
1496                .iter()
1497                .map(|&child| {
1498                    evaluate_node(
1499                        eval,
1500                        node,
1501                        child,
1502                        reached_by,
1503                        produced_by,
1504                        &child_scope,
1505                        stack,
1506                    )
1507                })
1508                .collect();
1509            if evaluated
1510                .iter()
1511                .all(|item| matches!(item, Evidence::Satisfaction(_)))
1512            {
1513                Evidence::Satisfaction(SatTrace::AllHeld {
1514                    shape: id,
1515                    node: node.clone(),
1516                    children: evaluated
1517                        .into_iter()
1518                        .map(|item| match item {
1519                            Evidence::Satisfaction(value) => value,
1520                            Evidence::Failure(_) => unreachable!(),
1521                        })
1522                        .collect(),
1523                })
1524            } else {
1525                Evidence::Failure(Witness::All {
1526                    shape: id,
1527                    node: node.clone(),
1528                    failed: evaluated
1529                        .into_iter()
1530                        .filter_map(|item| match item {
1531                            Evidence::Failure(value) => Some(value),
1532                            Evidence::Satisfaction(_) => None,
1533                        })
1534                        .collect(),
1535                })
1536            }
1537        }
1538        Shape::Or(children) => {
1539            let evaluated: Vec<Evidence> = children
1540                .iter()
1541                .map(|&child| {
1542                    evaluate_node(eval, node, child, reached_by, produced_by, scope, stack)
1543                })
1544                .collect();
1545            if evaluated
1546                .iter()
1547                .any(|item| matches!(item, Evidence::Satisfaction(_)))
1548            {
1549                Evidence::Satisfaction(SatTrace::AnyHeld {
1550                    shape: id,
1551                    node: node.clone(),
1552                    satisfied: evaluated
1553                        .into_iter()
1554                        .filter_map(|item| match item {
1555                            Evidence::Satisfaction(value) => Some(value),
1556                            Evidence::Failure(_) => None,
1557                        })
1558                        .collect(),
1559                })
1560            } else {
1561                Evidence::Failure(Witness::Any {
1562                    shape: id,
1563                    node: node.clone(),
1564                    branches: evaluated
1565                        .into_iter()
1566                        .map(|item| match item {
1567                            Evidence::Failure(value) => value,
1568                            Evidence::Satisfaction(_) => unreachable!(),
1569                        })
1570                        .collect(),
1571                })
1572            }
1573        }
1574        Shape::Count {
1575            path,
1576            min,
1577            max,
1578            qualifier,
1579        } => {
1580            let reached = Path::seq(vec![reached_by.clone(), path.clone()]);
1581            let mut matches = Vec::new();
1582            let mut rejected = Vec::new();
1583            for (value, support) in succ_with_support(eval.backend(), node, &path) {
1584                match evaluate_node(
1585                    eval,
1586                    &value,
1587                    qualifier,
1588                    &reached,
1589                    Some(&support),
1590                    &[],
1591                    stack,
1592                ) {
1593                    Evidence::Satisfaction(satisfaction) => matches.push(QualifiedMatch {
1594                        value,
1595                        path_support: support,
1596                        satisfaction: Box::new(satisfaction),
1597                    }),
1598                    Evidence::Failure(failure) => rejected.push(RejectedCandidate {
1599                        value,
1600                        path_support: support,
1601                        failure: Box::new(failure),
1602                    }),
1603                }
1604            }
1605            let observed = matches.len() as u64;
1606            if min.is_some_and(|minimum| observed < minimum) {
1607                let minimum = min.expect("checked above");
1608                let sibling_qualifiers = scope
1609                    .iter()
1610                    .filter(|(candidate_path, _)| candidate_path == &path)
1611                    .map(|(_, inner)| *inner)
1612                    .collect();
1613                Evidence::Failure(Witness::CountLow {
1614                    shape: id,
1615                    node: node.clone(),
1616                    path,
1617                    qualifier,
1618                    have: observed,
1619                    min: minimum,
1620                    qualifying_matches: matches,
1621                    rejected_candidates: rejected,
1622                    sibling_qualifiers,
1623                })
1624            } else if max.is_some_and(|maximum| observed > maximum) {
1625                let maximum = max.expect("checked above");
1626                let matched: Vec<(Term, PathSupport)> = matches
1627                    .iter()
1628                    .map(|item| (item.value.clone(), item.path_support.clone()))
1629                    .collect();
1630                let excess_values = matched.iter().skip(maximum as usize).cloned().collect();
1631                let per_value = if maximum == 0 {
1632                    matches
1633                        .iter()
1634                        .filter_map(|item| match item.satisfaction.as_ref() {
1635                            SatTrace::NotHeld { inner_fails, .. } => {
1636                                Some((item.value.clone(), (**inner_fails).clone()))
1637                            }
1638                            _ => None,
1639                        })
1640                        .collect()
1641                } else {
1642                    Vec::new()
1643                };
1644                Evidence::Failure(Witness::CountHigh {
1645                    shape: id,
1646                    node: node.clone(),
1647                    path,
1648                    qualifier,
1649                    matched,
1650                    max: maximum,
1651                    excess_values,
1652                    per_value,
1653                })
1654            } else if min.is_none()
1655                && max == Some(0)
1656                && let Shape::Not(inner) = eval.arena().get(qualifier)
1657            {
1658                let values = rejected
1659                    .into_iter()
1660                    .filter_map(|candidate| match candidate.failure.as_ref() {
1661                        Witness::Not { inner: trace, .. } => {
1662                            Some((candidate.value, candidate.path_support, (**trace).clone()))
1663                        }
1664                        _ => None,
1665                    })
1666                    .collect();
1667                Evidence::Satisfaction(SatTrace::ForAllHeld {
1668                    shape: id,
1669                    node: node.clone(),
1670                    path,
1671                    qualifier: *inner,
1672                    values,
1673                })
1674            } else {
1675                let trace_matches = matches
1676                    .into_iter()
1677                    .map(|item| (item.value, item.path_support, *item.satisfaction))
1678                    .collect();
1679                Evidence::Satisfaction(SatTrace::CountHeld {
1680                    shape: id,
1681                    node: node.clone(),
1682                    path,
1683                    qualifier,
1684                    matches: trace_matches,
1685                    observed_count: observed,
1686                    min,
1687                    max,
1688                })
1689            }
1690        }
1691    };
1692    stack.remove(&key);
1693    evidence
1694}
1695
1696pub(crate) fn materialize_evidence(
1697    eval: &mut ShapeEvaluator<'_>,
1698    node: &Term,
1699    shape: ShapeId,
1700) -> Evidence {
1701    evaluate_node(eval, node, shape, &Path::Id, None, &[], &mut Stack::new())
1702}
1703
1704/// Predicates+objects on `node` not allowed by a closed shape's set `q`.
1705fn closed_offenders(
1706    g: &dyn PathBackend,
1707    node: &Term,
1708    q: &BTreeSet<NamedNode>,
1709) -> Vec<(NamedNode, Term)> {
1710    let allowed: HashSet<&NamedNode> = q.iter().collect();
1711    let mut out = Vec::new();
1712    // `out_predicates` is already ordered; the objects behind each are not, and
1713    // they are reported verbatim as the offending values. Ordered for the same
1714    // reason as `succ_with_support`.
1715    for p in g.out_predicates(node) {
1716        if !allowed.contains(&p) {
1717            let mut objects: Vec<Term> = g.objects(node, &p).into_iter().collect();
1718            objects.sort_by(compare_terms);
1719            out.extend(objects.into_iter().map(|object| (p.clone(), object)));
1720        }
1721    }
1722    out
1723}
1724
1725/// Every `π`-successor of `from`, each paired with one concrete certificate.
1726///
1727/// Same values as [`succ`] and the same kind of certificate as
1728/// [`path_support`], but derived in a single traversal. Pairing `succ` with a
1729/// per-value `path_support` instead re-derives the whole route for every
1730/// candidate: for `sh:class` — which compiles to `type · subClassOf*` — that is
1731/// one hierarchy BFS per candidate value, and it dominated evidence
1732/// materialization on Brick.
1733///
1734/// Values are returned in first-reached order and deduplicated, so an
1735/// alternative or cycle keeps the first successful route exactly as
1736/// `path_support` does.
1737/// Path values with their certificates, in a deterministic order.
1738///
1739/// [`PathBackend`] yields `HashSet`s, and a `HashSet`'s iteration order varies
1740/// between instances, so evidence built straight from one differs between two
1741/// runs over the very same snapshot. That is not merely cosmetic: `CountHigh`
1742/// names the values past `max` as excess, so a different order reports a
1743/// different value as the offender. Ordering here — once, at the single point
1744/// where path values enter evidence — makes a run, and any artifact built from
1745/// it, reproducible.
1746///
1747/// Only evidence materialization pays for this. Conformance never calls it:
1748/// counts and satisfaction are order-independent, which is why the instability
1749/// was invisible until evidence started being serialized.
1750fn succ_with_support(g: &dyn PathBackend, from: &Term, path: &Path) -> Vec<(Term, PathSupport)> {
1751    let mut values = succ_with_support_unordered(g, from, path);
1752    values.sort_by(|(left, _), (right, _)| compare_terms(left, right));
1753    values
1754}
1755
1756/// A total order on terms, for reproducibility rather than for meaning.
1757///
1758/// RDF defines no order across term kinds, so this fixes one: named nodes,
1759/// then blank nodes, then literals, each by its lexical form. Literals compare
1760/// on value, then datatype, then language, so that terms differing only in
1761/// their tag still order stably.
1762fn compare_terms(left: &Term, right: &Term) -> std::cmp::Ordering {
1763    fn rank(term: &Term) -> u8 {
1764        match term {
1765            Term::NamedNode(_) => 0,
1766            Term::BlankNode(_) => 1,
1767            Term::Literal(_) => 2,
1768        }
1769    }
1770    rank(left)
1771        .cmp(&rank(right))
1772        .then_with(|| match (left, right) {
1773            (Term::NamedNode(left), Term::NamedNode(right)) => left.as_str().cmp(right.as_str()),
1774            (Term::BlankNode(left), Term::BlankNode(right)) => left.as_str().cmp(right.as_str()),
1775            (Term::Literal(left), Term::Literal(right)) => left
1776                .value()
1777                .cmp(right.value())
1778                .then_with(|| left.datatype().as_str().cmp(right.datatype().as_str()))
1779                .then_with(|| left.language().cmp(&right.language())),
1780            _ => std::cmp::Ordering::Equal,
1781        })
1782}
1783
1784fn succ_with_support_unordered(
1785    g: &dyn PathBackend,
1786    from: &Term,
1787    path: &Path,
1788) -> Vec<(Term, PathSupport)> {
1789    match path {
1790        Path::Id => vec![(from.clone(), PathSupport::Empty)],
1791        Path::Pred(q) => {
1792            let Some(subject) = node_of(from) else {
1793                return Vec::new();
1794            };
1795            g.objects(from, q)
1796                .into_iter()
1797                .map(|object| {
1798                    let edge = Triple::new(subject.clone(), q.clone(), object.clone());
1799                    (object, PathSupport::Edge(edge))
1800                })
1801                .collect()
1802        }
1803        // `Inverse(Pred)` is the common case and its certificate is the same
1804        // edge read backwards; anything else falls back to a per-value probe.
1805        Path::Inverse(inner) => match inner.as_ref() {
1806            Path::Pred(q) => g
1807                .subjects(q, from)
1808                .into_iter()
1809                .filter_map(|subject| {
1810                    let node = node_of(&subject)?;
1811                    let edge = Triple::new(node, q.clone(), from.clone());
1812                    Some((subject, PathSupport::Edge(edge)))
1813                })
1814                .collect(),
1815            _ => pred(g, from, inner)
1816                .into_iter()
1817                .map(|value| {
1818                    let support = path_support(g, from, path, &value).unwrap_or(PathSupport::Empty);
1819                    (value, support)
1820                })
1821                .collect(),
1822        },
1823        Path::Alt(branches) => {
1824            let mut out = Vec::new();
1825            let mut seen = HashSet::new();
1826            for branch in branches {
1827                for (value, support) in succ_with_support(g, from, branch) {
1828                    if seen.insert(value.clone()) {
1829                        out.push((value, support));
1830                    }
1831                }
1832            }
1833            out
1834        }
1835        Path::Seq(steps) => {
1836            let Some((first, rest)) = steps.split_first() else {
1837                return vec![(from.clone(), PathSupport::Empty)];
1838            };
1839            let mut frontier: Vec<(Term, Vec<PathSupport>)> = Vec::new();
1840            let mut seen = HashSet::new();
1841            for (value, support) in succ_with_support(g, from, first) {
1842                if seen.insert(value.clone()) {
1843                    frontier.push((value, vec![support]));
1844                }
1845            }
1846            for step in rest {
1847                let mut next: Vec<(Term, Vec<PathSupport>)> = Vec::new();
1848                let mut seen = HashSet::new();
1849                for (value, chain) in &frontier {
1850                    for (reached, support) in succ_with_support(g, value, step) {
1851                        if seen.insert(reached.clone()) {
1852                            let mut chain = chain.clone();
1853                            chain.push(support);
1854                            next.push((reached, chain));
1855                        }
1856                    }
1857                }
1858                frontier = next;
1859            }
1860            frontier
1861                .into_iter()
1862                .map(|(value, chain)| (value, flatten_chain(chain)))
1863                .collect()
1864        }
1865        // One breadth-first walk records every reachable value with the route
1866        // that first reached it, replacing one walk per target.
1867        Path::Star(inner) => {
1868            let mut out = vec![(from.clone(), PathSupport::Empty)];
1869            let mut visited: HashSet<Term> = HashSet::from([from.clone()]);
1870            let mut queue: VecDeque<(Term, Vec<PathSupport>)> =
1871                VecDeque::from([(from.clone(), Vec::new())]);
1872            while let Some((current, chain)) = queue.pop_front() {
1873                for (next, support) in succ_with_support(g, &current, inner) {
1874                    if !visited.insert(next.clone()) {
1875                        continue;
1876                    }
1877                    let mut chain = chain.clone();
1878                    chain.push(support);
1879                    out.push((next.clone(), PathSupport::Chain(chain.clone())));
1880                    queue.push_back((next, chain));
1881                }
1882            }
1883            out
1884        }
1885    }
1886}
1887
1888/// Splice one level of nested chains, matching `seq_support`'s shape.
1889fn flatten_chain(parts: Vec<PathSupport>) -> PathSupport {
1890    let mut chain = Vec::with_capacity(parts.len());
1891    for part in parts {
1892        match part {
1893            PathSupport::Empty => {}
1894            PathSupport::Chain(inner) => chain.extend(inner),
1895            other => chain.push(other),
1896        }
1897    }
1898    if chain.is_empty() {
1899        PathSupport::Empty
1900    } else {
1901        PathSupport::Chain(chain)
1902    }
1903}
1904
1905/// The existing triples that make `to` a `π`-successor of `from`, if any. The
1906/// edges a deletion would cut to remove `to` from the value set.
1907fn path_support(g: &dyn PathBackend, from: &Term, path: &Path, to: &Term) -> Option<PathSupport> {
1908    crate::profile::record_path_probe();
1909    match path {
1910        Path::Id => (from == to).then_some(PathSupport::Empty),
1911        Path::Pred(q) => {
1912            // One triple lookup, not one materialized successor set: this is
1913            // called once per candidate value, so building the whole set here
1914            // makes a value-set of size `n` cost `O(n²)`.
1915            if g.contains(from, q, to) {
1916                let s = node_of(from)?;
1917                Some(PathSupport::Edge(Triple::new(s, q.clone(), to.clone())))
1918            } else {
1919                None
1920            }
1921        }
1922        Path::Inverse(p) => path_support(g, to, p, from),
1923        Path::Alt(ps) => ps.iter().find_map(|p| path_support(g, from, p, to)),
1924        Path::Seq(ps) => seq_support(g, from, ps, to),
1925        Path::Star(p) => star_support(g, from, p, to),
1926    }
1927}
1928
1929fn seq_support(g: &dyn PathBackend, from: &Term, ps: &[Path], to: &Term) -> Option<PathSupport> {
1930    let Some((first, rest)) = ps.split_first() else {
1931        return (from == to).then_some(PathSupport::Empty);
1932    };
1933    for mid in succ(g, from, first) {
1934        let Some(head) = path_support(g, from, first, &mid) else {
1935            continue;
1936        };
1937        if let Some(tail) = seq_support(g, &mid, rest, to) {
1938            let mut chain = vec![head];
1939            match tail {
1940                PathSupport::Empty => {}
1941                PathSupport::Chain(v) => chain.extend(v),
1942                other => chain.push(other),
1943            }
1944            return Some(PathSupport::Chain(chain));
1945        }
1946    }
1947    None
1948}
1949
1950fn star_support(g: &dyn PathBackend, from: &Term, p: &Path, to: &Term) -> Option<PathSupport> {
1951    if from == to {
1952        return Some(PathSupport::Empty);
1953    }
1954    let mut visited: HashSet<Term> = HashSet::from([from.clone()]);
1955    let mut queue: VecDeque<(Term, Vec<PathSupport>)> =
1956        VecDeque::from([(from.clone(), Vec::new())]);
1957    while let Some((cur, chain)) = queue.pop_front() {
1958        for next in succ(g, &cur, p) {
1959            let Some(edge) = path_support(g, &cur, p, &next) else {
1960                continue;
1961            };
1962            let mut chain2 = chain.clone();
1963            chain2.push(edge);
1964            if next == *to {
1965                return Some(PathSupport::Chain(chain2));
1966            }
1967            if visited.insert(next.clone()) {
1968                queue.push_back((next, chain2));
1969            }
1970        }
1971    }
1972    None
1973}
1974
1975#[cfg(test)]
1976mod tests {
1977    use super::*;
1978    use shifty_parse::{load_turtle, parse_turtle};
1979
1980    const PREFIXES: &str = r#"
1981        @prefix sh:  <http://www.w3.org/ns/shacl#> .
1982        @prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
1983        @prefix ex:  <http://ex/> .
1984        @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
1985    "#;
1986
1987    fn run(ttl: &str) -> Vec<FocusWitness> {
1988        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
1989        let loaded = load_turtle(ttl.as_bytes(), None).unwrap();
1990        witness_violations(&loaded.graph, &loaded.graph, &parsed.schema).expect("stratifiable")
1991    }
1992
1993    /// Does any node in the witness tree satisfy `pred`?
1994    fn any(w: &Witness, pred: &impl Fn(&Witness) -> bool) -> bool {
1995        if pred(w) {
1996            return true;
1997        }
1998        match w {
1999            Witness::All { failed, .. } => failed.iter().any(|c| any(c, pred)),
2000            Witness::Any { branches, .. } => branches.iter().any(|c| any(c, pred)),
2001            Witness::CountHigh { per_value, .. } => per_value.iter().any(|(_, c)| any(c, pred)),
2002            _ => false,
2003        }
2004    }
2005
2006    #[test]
2007    fn conforming_graph_yields_no_witnesses() {
2008        let ttl = format!(
2009            "{PREFIXES}
2010            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
2011                sh:property [ sh:path ex:p ; sh:minCount 1 ] .
2012            ex:x ex:p ex:y .
2013            "
2014        );
2015        assert!(run(&ttl).is_empty());
2016    }
2017
2018    #[test]
2019    fn min_count_violation_is_count_low() {
2020        let ttl = format!(
2021            "{PREFIXES}
2022            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
2023                sh:property [ sh:path ex:p ; sh:minCount 2 ] .
2024            ex:x ex:p ex:y .
2025            "
2026        );
2027        let ws = run(&ttl);
2028        assert_eq!(ws.len(), 1);
2029        assert_eq!(ws[0].focus.to_string(), "<http://ex/x>");
2030        assert!(any(&ws[0].failure, &|w| matches!(
2031            w,
2032            Witness::CountLow {
2033                have: 1,
2034                min: 2,
2035                ..
2036            }
2037        )));
2038    }
2039
2040    /// Every `CountLow`'s `sibling_qualifiers`, in pre-order.
2041    fn count_low_siblings(w: &Witness) -> Vec<Vec<ShapeId>> {
2042        fn go(w: &Witness, out: &mut Vec<Vec<ShapeId>>) {
2043            match w {
2044                Witness::CountLow {
2045                    sibling_qualifiers, ..
2046                } => out.push(sibling_qualifiers.clone()),
2047                Witness::All { failed, .. } => failed.iter().for_each(|c| go(c, out)),
2048                Witness::Any { branches, .. } => branches.iter().for_each(|c| go(c, out)),
2049                Witness::CountHigh { per_value, .. } => {
2050                    per_value.iter().for_each(|(_, c)| go(c, out))
2051                }
2052                _ => {}
2053            }
2054        }
2055        let mut out = Vec::new();
2056        go(w, &mut out);
2057        out
2058    }
2059
2060    #[test]
2061    fn cross_property_universals_attach_to_count_low() {
2062        // The min-count and the `sh:class` live in *separate* property shapes on the
2063        // same path — different `And` conjuncts. The class universal holds vacuously
2064        // (no values), so it never witnesses as a failure, yet a value added for the
2065        // count must still satisfy it: it must reach the `CountLow` across `And`s.
2066        let ttl = format!(
2067            "{PREFIXES}
2068            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
2069                sh:property [ sh:path ex:p ; sh:minCount 1 ] ;
2070                sh:property [ sh:path ex:p ; sh:class ex:C ] .
2071            ex:x a ex:Thing .
2072            "
2073        );
2074        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
2075        let loaded = load_turtle(ttl.as_bytes(), None).unwrap();
2076        let ws =
2077            witness_violations(&loaded.graph, &loaded.graph, &parsed.schema).expect("stratifiable");
2078        assert_eq!(ws.len(), 1);
2079        let sibs = count_low_siblings(&ws[0].failure);
2080        assert_eq!(sibs.len(), 1, "one CountLow");
2081        assert_eq!(sibs[0].len(), 1, "the class universal is attached");
2082        assert_eq!(
2083            class_of(sibs[0][0], &parsed.schema.arena),
2084            Some("http://ex/C".to_string()),
2085        );
2086    }
2087
2088    #[test]
2089    fn disjoint_or_branch_universal_does_not_attach() {
2090        // The `sh:class` sits in a *different* `sh:or` branch than the count, so the
2091        // two are not conjoined — a value satisfying the count branch need not be a
2092        // class. Scope must not cross the disjunction: the CountLow carries no
2093        // sibling. (Both branches fail here, so the Or genuinely witnesses.)
2094        let ttl = format!(
2095            "{PREFIXES}
2096            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
2097                sh:or (
2098                    [ sh:path ex:p ; sh:minCount 2 ]
2099                    [ sh:path ex:p ; sh:class ex:C ]
2100                ) .
2101            ex:x a ex:Thing ; ex:p ex:y .
2102            "
2103        );
2104        let ws = run(&ttl);
2105        assert_eq!(ws.len(), 1);
2106        let sibs = count_low_siblings(&ws[0].failure);
2107        assert!(!sibs.is_empty(), "the count branch yields a CountLow");
2108        assert!(
2109            sibs.iter().all(|s| s.is_empty()),
2110            "no universal leaks across the disjunction: {sibs:?}",
2111        );
2112    }
2113
2114    /// The class IRI of a `∃≥1 (rdf:type/subClassOf*).test(C)` shape, as a string.
2115    fn class_of(id: ShapeId, arena: &ShapeArena) -> Option<String> {
2116        match shifty_algebra::render::class_target_shape(id, arena)? {
2117            Term::NamedNode(n) => Some(n.as_str().to_string()),
2118            _ => None,
2119        }
2120    }
2121
2122    #[test]
2123    fn datatype_violation_is_an_atom_with_support() {
2124        let ttl = format!(
2125            "{PREFIXES}
2126            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
2127                sh:property [ sh:path ex:p ; sh:datatype xsd:integer ] .
2128            ex:x ex:p \"hello\" .
2129            "
2130        );
2131        let ws = run(&ttl);
2132        assert_eq!(ws.len(), 1);
2133        // The bad value is reached via ex:p, so its atom carries a cut edge.
2134        assert!(any(&ws[0].failure, &|w| matches!(
2135            w,
2136            Witness::Atom {
2137                produced_by: Some(PathSupport::Edge(_)),
2138                ..
2139            }
2140        )));
2141    }
2142
2143    #[test]
2144    fn focus_level_nodekind_atom_has_no_support() {
2145        let ttl = format!(
2146            "{PREFIXES}
2147            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
2148                sh:nodeKind sh:IRI .
2149            ex:x ex:p \"v\" .
2150            ex:y ex:q ex:x .
2151            "
2152        );
2153        // ex:x is an IRI so it conforms; use a literal-targeted shape instead:
2154        let _ = ttl;
2155        let ttl2 = format!(
2156            "{PREFIXES}
2157            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
2158                sh:nodeKind sh:Literal .
2159            ex:x ex:p ex:y .
2160            "
2161        );
2162        let ws = run(&ttl2);
2163        assert_eq!(ws.len(), 1);
2164        assert!(matches!(
2165            ws[0].failure,
2166            Witness::Atom {
2167                produced_by: None,
2168                ..
2169            }
2170        ));
2171    }
2172
2173    #[test]
2174    fn alternative_path_support_is_one_positive_certificate() {
2175        let ttl = format!(
2176            "{PREFIXES}
2177             ex:x ex:p ex:y ; ex:q ex:y ."
2178        );
2179        let loaded = load_turtle(ttl.as_bytes(), None).unwrap();
2180        let x = Term::NamedNode(NamedNode::new("http://ex/x").unwrap());
2181        let y = Term::NamedNode(NamedNode::new("http://ex/y").unwrap());
2182        let path = Path::Alt(vec![
2183            Path::Pred(NamedNode::new("http://ex/p").unwrap()),
2184            Path::Pred(NamedNode::new("http://ex/q").unwrap()),
2185        ]);
2186        let support = path_support(&loaded.graph, &x, &path, &y).expect("reachable");
2187        let PathSupport::Edge(edge) = support else {
2188            panic!("Alt retains one successful route, got {support:?}")
2189        };
2190        assert!(loaded.graph.contains(edge.as_ref()));
2191    }
2192
2193    fn collect_support_edges(support: &PathSupport, out: &mut Vec<Triple>) {
2194        match support {
2195            PathSupport::Edge(edge) => out.push(edge.clone()),
2196            PathSupport::Chain(parts) | PathSupport::Alt(parts) => {
2197                parts
2198                    .iter()
2199                    .for_each(|part| collect_support_edges(part, out));
2200            }
2201            PathSupport::Empty => {}
2202        }
2203    }
2204
2205    #[test]
2206    fn traversal_certificates_match_per_value_probing() {
2207        // `succ_with_support` replaced `succ` + a `path_support` probe per
2208        // value. It must still return exactly the successor set, with a
2209        // certificate that probing would accept for each value.
2210        let ttl = format!(
2211            "{PREFIXES}
2212             ex:x ex:p ex:a ; ex:p ex:b ; ex:q ex:c .
2213             ex:a ex:r ex:d . ex:d ex:r ex:e .
2214             ex:b rdf:type ex:C .
2215             ex:C <http://www.w3.org/2000/01/rdf-schema#subClassOf> ex:D .
2216             ex:D <http://www.w3.org/2000/01/rdf-schema#subClassOf> ex:E .
2217             ex:back ex:p ex:x ."
2218        );
2219        let loaded = load_turtle(ttl.as_bytes(), None).unwrap();
2220        let graph = &loaded.graph;
2221        let x = Term::NamedNode(NamedNode::new("http://ex/x").unwrap());
2222        let named = |iri: &str| NamedNode::new(iri).unwrap();
2223        let paths = vec![
2224            Path::Id,
2225            Path::Pred(named("http://ex/p")),
2226            Path::Inverse(Box::new(Path::Pred(named("http://ex/p")))),
2227            Path::Alt(vec![
2228                Path::Pred(named("http://ex/p")),
2229                Path::Pred(named("http://ex/q")),
2230            ]),
2231            Path::Seq(vec![
2232                Path::Pred(named("http://ex/p")),
2233                Path::Pred(named("http://ex/r")),
2234            ]),
2235            Path::Star(Path::Pred(named("http://ex/r")).into()),
2236            Path::Seq(vec![
2237                Path::Pred(named("http://www.w3.org/1999/02/22-rdf-syntax-ns#type")),
2238                Path::Star(
2239                    Path::Pred(named("http://www.w3.org/2000/01/rdf-schema#subClassOf")).into(),
2240                ),
2241            ]),
2242        ];
2243
2244        for path in paths {
2245            let derived = succ_with_support(graph, &x, &path);
2246            let values: HashSet<Term> = derived.iter().map(|(v, _)| v.clone()).collect();
2247            assert_eq!(values, succ(graph, &x, &path), "values differ for {path:?}");
2248            assert_eq!(derived.len(), values.len(), "duplicate value for {path:?}");
2249            for (value, support) in &derived {
2250                assert!(
2251                    path_support(graph, &x, &path, value).is_some(),
2252                    "probing rejects {value:?} for {path:?}"
2253                );
2254                let mut edges = Vec::new();
2255                collect_support_edges(support, &mut edges);
2256                for edge in edges {
2257                    assert!(
2258                        graph.contains(edge.as_ref()),
2259                        "certificate cites a missing triple for {path:?}"
2260                    );
2261                }
2262            }
2263        }
2264    }
2265
2266    #[test]
2267    fn non_stratifiable_schema_is_diagnosed() {
2268        let ttl = format!(
2269            "{PREFIXES}
2270            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
2271                sh:not [ sh:path ex:p ; sh:qualifiedValueShape ex:S ; sh:qualifiedMinCount 1 ] .
2272            ex:x ex:p ex:y .
2273            "
2274        );
2275        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
2276        let loaded = load_turtle(ttl.as_bytes(), None).unwrap();
2277        assert!(witness_violations(&loaded.graph, &loaded.graph, &parsed.schema).is_err());
2278    }
2279
2280    #[test]
2281    fn witness_shape_and_satisfy_shape_scope_to_one_shape() {
2282        // Two targeted shapes; one focus fails ex:S, one passes ex:S, and a third
2283        // node fails an unrelated shape ex:T that the ex:S queries must ignore.
2284        let ttl = format!(
2285            "{PREFIXES}
2286            ex:S a sh:NodeShape ; sh:targetClass ex:C ;
2287                sh:property [ sh:path ex:p ; sh:minCount 1 ] .
2288            ex:T a sh:NodeShape ; sh:targetClass ex:D ;
2289                sh:property [ sh:path ex:q ; sh:minCount 1 ] .
2290            ex:good a ex:C ; ex:p ex:y .
2291            ex:bad  a ex:C .
2292            ex:other a ex:D .
2293            "
2294        );
2295        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
2296        let loaded = load_turtle(ttl.as_bytes(), None).unwrap();
2297        let schema = &parsed.schema;
2298
2299        let s = shape_id_for_iri(schema, "http://ex/S").expect("ex:S is named");
2300        assert!(shape_id_for_iri(schema, "http://ex/missing").is_none());
2301
2302        // Failures: just ex:bad, never the ex:T violation on ex:other.
2303        let fails = witness_shape(&loaded.graph, &loaded.graph, schema, s).expect("stratifiable");
2304        assert_eq!(fails.len(), 1);
2305        assert_eq!(fails[0].focus.to_string(), "<http://ex/bad>");
2306
2307        // Satisfactions: just ex:good, with the matched value recorded.
2308        let sats = satisfy_shape(&loaded.graph, &loaded.graph, schema, s).expect("stratifiable");
2309        assert_eq!(sats.len(), 1);
2310        assert_eq!(sats[0].focus.to_string(), "<http://ex/good>");
2311        // ex:good holds because the ex:p count is met by ex:y.
2312        assert!(any_sat(&sats[0].trace, &|t| matches!(
2313            t,
2314            SatTrace::CountHeld { matches, .. } if matches.iter().any(|(v, _, _)| v.to_string() == "<http://ex/y>")
2315        )));
2316    }
2317
2318    fn pred_path(iri: &str) -> Path {
2319        Path::Pred(NamedNode::new_unchecked(iri))
2320    }
2321
2322    fn terms(values: &[Term]) -> Vec<String> {
2323        values.iter().map(|value| value.to_string()).collect()
2324    }
2325
2326    #[test]
2327    fn satisfaction_groups_matched_values_by_the_path_they_were_counted_along() {
2328        let ttl = format!(
2329            "{PREFIXES}
2330            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
2331                sh:property [ sh:path ex:p ; sh:minCount 1 ] ;
2332                sh:property [ sh:path ex:q ; sh:minCount 1 ] .
2333            ex:x ex:p ex:y1, ex:y2 ; ex:q ex:z .
2334            "
2335        );
2336        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
2337        let loaded = load_turtle(ttl.as_bytes(), None).unwrap();
2338        let schema = &parsed.schema;
2339        let s = shape_id_for_iri(schema, "http://ex/S").expect("ex:S is named");
2340        let sats = satisfy_shape(&loaded.graph, &loaded.graph, schema, s).expect("stratifiable");
2341        let evidence = Evidence::Satisfaction(sats[0].trace.clone());
2342
2343        // One group per counted path. Group order follows evidence traversal,
2344        // which is the planner's business, so look each path up by name.
2345        let grouped = evidence.matched_values_by_path();
2346        assert_eq!(grouped.len(), 2);
2347        let group = |iri: &str| {
2348            grouped
2349                .iter()
2350                .find(|(path, _)| *path == pred_path(iri))
2351                .map(|(_, values)| terms(values))
2352                .expect("path was counted")
2353        };
2354        assert_eq!(group("http://ex/p"), ["<http://ex/y1>", "<http://ex/y2>"]);
2355        assert_eq!(group("http://ex/q"), ["<http://ex/z>"]);
2356
2357        // The single-path projection agrees with the grouping, and together the
2358        // groups partition `matched_values` in the same order.
2359        assert_eq!(
2360            terms(&evidence.values_for_path(&pred_path("http://ex/p"))),
2361            ["<http://ex/y1>", "<http://ex/y2>"],
2362        );
2363        assert_eq!(
2364            terms(&evidence.values_for_path(&pred_path("http://ex/q"))),
2365            ["<http://ex/z>"],
2366        );
2367        assert_eq!(
2368            grouped
2369                .into_iter()
2370                .flat_map(|(_, values)| values)
2371                .collect::<Vec<_>>(),
2372            evidence.matched_values(),
2373        );
2374
2375        // A path this evidence never counted along is empty, not an error.
2376        assert!(
2377            evidence
2378                .values_for_path(&pred_path("http://ex/absent"))
2379                .is_empty()
2380        );
2381        assert!(evidence.values_for_path(&Path::Id).is_empty());
2382    }
2383
2384    #[test]
2385    fn a_short_count_attributes_its_qualifying_matches_to_its_own_path() {
2386        // ex:p is short by one; ex:q is met. Both contribute matched values, and
2387        // each set must land under the path that counted it.
2388        let ttl = format!(
2389            "{PREFIXES}
2390            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
2391                sh:property [ sh:path ex:p ; sh:minCount 3 ] ;
2392                sh:property [ sh:path ex:q ; sh:minCount 1 ] .
2393            ex:x ex:p ex:y1, ex:y2 ; ex:q ex:z .
2394            "
2395        );
2396        let ws = run(&ttl);
2397        assert_eq!(ws.len(), 1);
2398        let evidence = Evidence::Failure(ws[0].failure.clone());
2399
2400        assert_eq!(
2401            terms(&evidence.values_for_path(&pred_path("http://ex/p"))),
2402            ["<http://ex/y1>", "<http://ex/y2>"],
2403        );
2404        assert!(
2405            evidence
2406                .values_for_path(&pred_path("http://ex/q"))
2407                .is_empty(),
2408            "a satisfied sibling is not part of the failure's evidence",
2409        );
2410    }
2411
2412    #[test]
2413    fn a_deficit_names_the_node_path_and_qualifier_that_would_close_it() {
2414        // ex:x needs two ex:p values of class ex:C and has one; ex:near is
2415        // reached but rejected, and its own class check is short as well.
2416        let ttl = format!(
2417            "{PREFIXES}
2418            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
2419                sh:property [ sh:path ex:p ;
2420                              sh:qualifiedValueShape [ sh:class ex:C ] ;
2421                              sh:qualifiedMinCount 2 ] .
2422            ex:x ex:p ex:good, ex:near .
2423            ex:good a ex:C .
2424            "
2425        );
2426        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
2427        let loaded = load_turtle(ttl.as_bytes(), None).unwrap();
2428        let ws =
2429            witness_violations(&loaded.graph, &loaded.graph, &parsed.schema).expect("stratifiable");
2430        let obligations = Evidence::Failure(ws[0].failure.clone()).missing_obligations();
2431
2432        // The deficit on the focus: one more ex:p value, of class ex:C.
2433        let on_focus: Vec<_> = obligations
2434            .iter()
2435            .filter(|item| item.node.to_string() == "<http://ex/x>")
2436            .collect();
2437        assert_eq!(on_focus.len(), 1);
2438        assert_eq!(on_focus[0].path, pred_path("http://ex/p"));
2439        assert_eq!((on_focus[0].observed_count, on_focus[0].missing), (1, 1));
2440        assert_eq!(
2441            shifty_algebra::render::describe_shape(&parsed.schema.arena, on_focus[0].qualifier),
2442            "instance of <http://ex/C>",
2443            "the qualifier says what an added value must satisfy",
2444        );
2445
2446        // A count nested inside the rejected candidate reports *its* node, so
2447        // the two deficits are told apart without reading `explain()`.
2448        assert!(
2449            obligations
2450                .iter()
2451                .any(|item| item.node.to_string() == "<http://ex/near>"),
2452            "obligations: {obligations:?}",
2453        );
2454    }
2455
2456    /// Does any node in the satisfaction trace satisfy `pred`?
2457    fn any_sat(t: &SatTrace, pred: &impl Fn(&SatTrace) -> bool) -> bool {
2458        if pred(t) {
2459            return true;
2460        }
2461        match t {
2462            SatTrace::AllHeld { children, .. } => children.iter().any(|c| any_sat(c, pred)),
2463            SatTrace::AnyHeld { satisfied, .. } => satisfied.iter().any(|c| any_sat(c, pred)),
2464            SatTrace::CountHeld { matches, .. } => matches.iter().any(|(_, _, c)| any_sat(c, pred)),
2465            SatTrace::ForAllHeld { values, .. } => values.iter().any(|(_, _, c)| any_sat(c, pred)),
2466            _ => false,
2467        }
2468    }
2469
2470    #[test]
2471    fn shared_child_grammar_is_preorder_and_crosses_polarity_at_not() {
2472        let node = Term::NamedNode(NamedNode::new_unchecked("http://ex/x"));
2473        let failure = Evidence::Failure(Witness::All {
2474            shape: ShapeId(0),
2475            node: node.clone(),
2476            failed: vec![
2477                Witness::Not {
2478                    shape: ShapeId(1),
2479                    node: node.clone(),
2480                    inner: Box::new(SatTrace::Irrefutable { shape: ShapeId(2) }),
2481                },
2482                Witness::Opaque {
2483                    shape: ShapeId(3),
2484                    node: node.clone(),
2485                    messages: Vec::new(),
2486                    diagnostic: None,
2487                },
2488            ],
2489        });
2490
2491        let root = failure.walk()[0];
2492        assert_eq!(
2493            root.children()
2494                .into_iter()
2495                .map(EvidenceNodeRef::kind)
2496                .collect::<Vec<_>>(),
2497            ["not_failed", "opaque"],
2498        );
2499        assert_eq!(
2500            failure
2501                .walk()
2502                .into_iter()
2503                .map(|item| (item.kind(), item.status()))
2504                .collect::<Vec<_>>(),
2505            [
2506                ("all_failed", EvaluationStatus::Fail),
2507                ("not_failed", EvaluationStatus::Fail),
2508                ("irrefutable", EvaluationStatus::Pass),
2509                ("opaque", EvaluationStatus::Fail),
2510            ],
2511        );
2512
2513        let satisfaction = Evidence::Satisfaction(SatTrace::NotHeld {
2514            shape: ShapeId(4),
2515            node,
2516            inner_fails: Box::new(Witness::Opaque {
2517                shape: ShapeId(5),
2518                node: Term::NamedNode(NamedNode::new_unchecked("http://ex/x")),
2519                messages: Vec::new(),
2520                diagnostic: None,
2521            }),
2522        });
2523        assert_eq!(
2524            satisfaction
2525                .walk()
2526                .into_iter()
2527                .map(|item| (item.kind(), item.status()))
2528                .collect::<Vec<_>>(),
2529            [
2530                ("not_held", EvaluationStatus::Pass),
2531                ("opaque", EvaluationStatus::Fail),
2532            ],
2533        );
2534    }
2535
2536    #[test]
2537    fn evidence_kind_is_an_exhaustive_polarity_discriminant() {
2538        let passing = [
2539            EvidenceKind::Irrefutable,
2540            EvidenceKind::AtomHeld,
2541            EvidenceKind::AllHeld,
2542            EvidenceKind::AnyHeld,
2543            EvidenceKind::CountHeld,
2544            EvidenceKind::AllValuesHeld,
2545            EvidenceKind::NotHeld,
2546            EvidenceKind::Blocked,
2547            EvidenceKind::Coinductive,
2548        ];
2549        let failing = [
2550            EvidenceKind::AtomFailed,
2551            EvidenceKind::RelationalFailed,
2552            EvidenceKind::ClosedFailed,
2553            EvidenceKind::NotFailed,
2554            EvidenceKind::AllFailed,
2555            EvidenceKind::AnyFailed,
2556            EvidenceKind::CountLow,
2557            EvidenceKind::CountHigh,
2558            EvidenceKind::Opaque,
2559        ];
2560
2561        assert!(
2562            passing
2563                .iter()
2564                .all(|kind| kind.status() == EvaluationStatus::Pass)
2565        );
2566        assert!(
2567            failing
2568                .iter()
2569                .all(|kind| kind.status() == EvaluationStatus::Fail)
2570        );
2571        let names = passing
2572            .iter()
2573            .chain(&failing)
2574            .map(|kind| kind.as_str())
2575            .collect::<HashSet<_>>();
2576        assert_eq!(names.len(), passing.len() + failing.len());
2577    }
2578}