Skip to main content

shifty_engine/
witness.rs

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