Skip to main content

shifty_engine/
evidence.rs

1//! Unified evidence-carrying validation.
2//!
3//! Each selected `(authored statement, focus)` pair produces exactly one
4//! [`Evidence`] polarity. The logical algebra evaluator remains
5//! the oracle; this driver only owns preparation, target enumeration, and
6//! provenance fan-out.
7//!
8//! Preparation turns the input into a reusable snapshot: it normalizes the
9//! schema, checks stratification, freezes/indexes the evaluation graph, and
10//! records the source-to-normalized mappings. A normal conformance scan then
11//! uses one memoizing `ShapeEvaluator` and materializes no evidence at all.
12//! Callers can either ask for a complete evidence run or first obtain failing
13//! [`SelectedPair`] handles and explain only those pairs later.
14//!
15//! This split is intentional. Conformance is a shared Boolean computation;
16//! evidence is a potentially much larger derivation tree whose source-facing
17//! presentation can fan one normalized conclusion back out to several authored
18//! statements. Keeping the fan-out here, after canonical evaluation, preserves
19//! authored provenance without making the evaluator or optimizer pay for it.
20
21use crate::focus::{FocusNodes, FocusScope, IndexedFocus};
22use crate::frozen::FrozenIndexedDataset;
23use crate::sparql::SparqlExecutor;
24use crate::validate::{
25    NonStratifiable, ShapeEvaluator, ValidationGraphMode, ValidationOptions,
26    entry_shape_any_name_selected, focus_nodes_with_evaluator, graph_union,
27    prefetch_sparql_constraints, uses_shapes_graph,
28};
29use crate::witness::{
30    ChildEvaluation, ConstraintCatalog, ConstraintRecord, EvaluationProgress, EvaluationStatus,
31    Evidence, EvidenceRun, EvidenceSummary, FocusEvaluation, StatementEvaluation, Witness,
32    materialize_evidence,
33};
34use oxrdf::Graph;
35use shifty_algebra::{ConstraintKind, Schema, Severity, Shape, ShapeArena, ShapeId};
36use shifty_opt::{analyze, normalize_with_mapping};
37use std::cell::OnceCell;
38use std::collections::{HashMap, HashSet};
39use std::sync::Arc;
40
41/// A prepared, immutable evidence-validation snapshot.
42///
43/// Parsing and inference happen before construction. Normalization,
44/// stratification, dataset indexing, and SPARQL-executor construction happen
45/// once here; each [`validate`](Self::validate) call creates one reusable
46/// `ShapeEvaluator` over those retained resources.
47pub struct PreparedEvidenceValidator {
48    /// The graph used for focus selection. It is retained separately from the
49    /// frozen context inside `sparql`: graph mode may deliberately use data-only
50    /// targets while paths and SPARQL read data ∪ shapes.
51    data: Option<Arc<Graph>>,
52    indexed_focus: Option<FocusScope>,
53    compatibility_union: bool,
54    union_projection: OnceCell<Graph>,
55    data_projection: OnceCell<Arc<Graph>>,
56    /// The authored schema is not an execution duplicate. It is the provenance
57    /// layer that lets progress explain a constraint normalization removed or
58    /// merged, while `schema` remains the small canonical graph to evaluate.
59    raw_schema: Arc<Schema>,
60    /// The normalized execution schema. Every cache and evidence conclusion is
61    /// keyed by ids from this arena, so one logical judgment is computed once
62    /// even when several source statements expressed it independently.
63    schema: Arc<Schema>,
64    /// Inverse statement provenance. `normalize_with_mapping` naturally emits
65    /// raw → normalized; evidence fans a computed result back to its authored
66    /// statements, so retaining the inverse makes that operation linear and
67    /// keeps the public interface source-oriented.
68    raw_by_normalized: Arc<Vec<Vec<usize>>>,
69    /// Raw arena id → normalized representative, used only for source progress.
70    /// `None` means the authored node was unreachable from any execution root.
71    shape_map: Arc<Vec<Option<ShapeId>>>,
72    sparql: SparqlExecutor,
73}
74
75/// Conformance-only totals from one prepared run.
76///
77/// Counts are over normalized `(statement, focus)` pairs — the pairs an
78/// [`EvidenceRun`] materializes evidence for, before authored statements that
79/// normalize together fan the same evidence back out.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub struct ConformanceRun {
82    pub conforms: bool,
83    pub selected_pairs: usize,
84    pub passed: usize,
85    pub failed: usize,
86}
87
88/// Selection controls for a conformance-only scan.
89///
90/// Unlike [`ValidationOptions`], this type contains only choices the
91/// short-circuiting scan can honor without materializing failure evidence.
92#[derive(Debug, Clone, Default, PartialEq, Eq)]
93pub struct ConformanceOptions {
94    pub entry_shape_names: Vec<String>,
95}
96
97impl From<&ValidationOptions> for ConformanceOptions {
98    fn from(options: &ValidationOptions) -> Self {
99        Self {
100            entry_shape_names: options.entry_shape_names.clone(),
101        }
102    }
103}
104
105/// One normalized `(statement, focus)` request, together with the authored
106/// statements whose selection produced it.
107///
108/// The handle [`explain`](PreparedEvidenceValidator::explain) takes: enough to
109/// name a pair while preserving scoped source fan-out. Several authored
110/// statements may share one normalized statement, but only the authored
111/// statements selected for this handle are retained.
112#[derive(Debug, Clone)]
113pub struct SelectedPair {
114    normalized_statement: usize,
115    focus: oxrdf::Term,
116    pub(crate) public_focus: Option<oxrdf::Term>,
117    source_statements: Vec<usize>,
118    pub(crate) snapshot: Option<Arc<()>>,
119}
120
121impl PartialEq for SelectedPair {
122    fn eq(&self, other: &Self) -> bool {
123        self.normalized_statement == other.normalized_statement
124            && self.focus == other.focus
125            && self.source_statements == other.source_statements
126            && match (&self.snapshot, &other.snapshot) {
127                (Some(left), Some(right)) => Arc::ptr_eq(left, right),
128                (None, None) => true,
129                _ => false,
130            }
131    }
132}
133
134impl Eq for SelectedPair {}
135
136impl SelectedPair {
137    fn new(normalized_statement: usize, focus: oxrdf::Term, source_statements: Vec<usize>) -> Self {
138        Self {
139            normalized_statement,
140            focus,
141            public_focus: None,
142            source_statements,
143            snapshot: None,
144        }
145    }
146
147    pub fn normalized_statement(&self) -> usize {
148        self.normalized_statement
149    }
150
151    pub fn focus(&self) -> &oxrdf::Term {
152        self.public_focus.as_ref().unwrap_or(&self.focus)
153    }
154
155    pub fn source_statements(&self) -> &[usize] {
156        &self.source_statements
157    }
158}
159
160impl PreparedEvidenceValidator {
161    pub(crate) fn property_witnesses(
162        &self,
163        source: &shifty_parse::Loaded,
164        key_path: Option<&shifty_algebra::Path>,
165        options: &ValidationOptions,
166    ) -> Vec<crate::report::PropertyWitness> {
167        self.with_focus(|focus| {
168            crate::report::property_witnesses_prepared(
169                source,
170                focus,
171                &self.sparql,
172                key_path,
173                options,
174            )
175        })
176    }
177
178    pub(crate) fn report(
179        &self,
180        source: &shifty_parse::Loaded,
181        options: &ValidationOptions,
182    ) -> crate::report::ValidationReport {
183        self.with_focus(|focus| {
184            crate::report::validate_report_prepared(source, focus, &self.sparql, options)
185        })
186    }
187
188    pub(crate) fn validate_findings(
189        &self,
190        plan: &shifty_opt::PhysicalPlan,
191        options: &ValidationOptions,
192    ) -> crate::validate::ValidationOutcome {
193        let selected: Vec<bool> = self
194            .raw_by_normalized
195            .iter()
196            .map(|sources| {
197                sources.iter().copied().any(|source| {
198                    self.source_statement_selected(source, &options.entry_shape_names)
199                })
200            })
201            .collect();
202        self.with_focus(|focus| {
203            crate::validate::validate_plan_with_executor(
204                focus,
205                plan,
206                &self.sparql,
207                options,
208                Some(&selected),
209            )
210        })
211    }
212
213    /// Prepare embedded data/shapes validation over one graph.
214    pub fn new(data: &Graph, schema: &Schema) -> Result<Self, NonStratifiable> {
215        let uses_shapes = uses_shapes_graph(&schema.arena);
216        let frozen = if uses_shapes {
217            FrozenIndexedDataset::from_graphs(data, data)
218        } else {
219            FrozenIndexedDataset::from_graph(data)
220        };
221        Self::from_frozen(data.clone(), schema, frozen, uses_shapes)
222    }
223
224    /// Prepare separate focus and evaluation graphs.
225    pub fn with_context(
226        data: &Graph,
227        context: &Graph,
228        schema: &Schema,
229    ) -> Result<Self, NonStratifiable> {
230        let uses_shapes = uses_shapes_graph(&schema.arena);
231        let frozen = if uses_shapes {
232            FrozenIndexedDataset::from_graphs(context, context)
233        } else {
234            FrozenIndexedDataset::from_graph(context)
235        };
236        Self::from_frozen(data.clone(), schema, frozen, uses_shapes)
237    }
238
239    /// Prepare split data/shapes validation in the requested graph mode.
240    pub fn with_graphs(
241        data: &Graph,
242        shapes: &Graph,
243        schema: &Schema,
244        mode: ValidationGraphMode,
245    ) -> Result<Self, NonStratifiable> {
246        let uses_shapes = uses_shapes_graph(&schema.arena);
247        match mode {
248            ValidationGraphMode::Data => {
249                let frozen = if uses_shapes {
250                    FrozenIndexedDataset::from_graphs(data, shapes)
251                } else {
252                    FrozenIndexedDataset::from_graph(data)
253                };
254                Self::from_frozen(data.clone(), schema, frozen, uses_shapes)
255            }
256            ValidationGraphMode::Union => {
257                let frozen = if uses_shapes {
258                    FrozenIndexedDataset::from_graph_union_with_shapes(data, shapes)
259                } else {
260                    FrozenIndexedDataset::from_graph_union(data, shapes)
261                };
262                Self::from_frozen(data.clone(), schema, frozen, uses_shapes)
263            }
264            ValidationGraphMode::UnionAll => {
265                let union = graph_union(data, shapes);
266                let frozen = if uses_shapes {
267                    FrozenIndexedDataset::from_graphs(&union, &union)
268                } else {
269                    FrozenIndexedDataset::from_graph(&union)
270                };
271                Self::from_frozen(union, schema, frozen, uses_shapes)
272            }
273        }
274    }
275
276    fn from_frozen(
277        data: Graph,
278        raw_schema: &Schema,
279        frozen: FrozenIndexedDataset,
280        has_shapes_graph: bool,
281    ) -> Result<Self, NonStratifiable> {
282        // Normalize exactly once per snapshot. This is where the API chooses a
283        // deep module boundary: callers supply authored SHACL, but all repeated
284        // work below runs against a compact, CSE'd schema. The two explicit maps
285        // are the narrow bridge back to source provenance for evidence output.
286        let normalized = normalize_with_mapping(raw_schema);
287        let stratification = analyze(&normalized.schema.arena);
288        if !stratification.stratifiable {
289            let components = stratification
290                .strata
291                .iter()
292                .filter(|stratum| !stratum.stratifiable)
293                .map(|stratum| stratum.shapes.clone())
294                .collect();
295            return Err(NonStratifiable { components });
296        }
297
298        // Preserve source order in each fan-out bucket. Apart from being stable
299        // for reports, this lets `explain` reconstruct the same authored view
300        // as a full run without rerunning target selection or materializing a
301        // second copy of the canonical evidence tree.
302        let mut raw_by_normalized = vec![Vec::new(); normalized.schema.statements.len()];
303        for (raw, normalized_id) in normalized.statement_map.iter().copied().enumerate() {
304            raw_by_normalized[normalized_id].push(raw);
305        }
306
307        Ok(Self {
308            data: Some(Arc::new(data)),
309            indexed_focus: None,
310            compatibility_union: false,
311            union_projection: OnceCell::new(),
312            data_projection: OnceCell::new(),
313            raw_schema: Arc::new(raw_schema.clone()),
314            schema: Arc::new(normalized.schema),
315            raw_by_normalized: Arc::new(raw_by_normalized),
316            shape_map: Arc::new(normalized.shape_map),
317            sparql: SparqlExecutor::from_frozen(frozen, has_shapes_graph),
318        })
319    }
320
321    pub(crate) fn from_compiled(
322        data: Option<Arc<Graph>>,
323        compiled: &crate::compiled::CompiledShapes,
324        frozen: FrozenIndexedDataset,
325        has_shapes_graph: bool,
326        policy: crate::validate::UnsupportedPolicy,
327        focus_scope: FocusScope,
328        compatibility_union: bool,
329    ) -> Self {
330        let mut sparql = SparqlExecutor::from_frozen_with_parsed(
331            frozen,
332            has_shapes_graph,
333            Some(compiled.parsed_queries()),
334        );
335        sparql.set_functions(compiled.inner.functions.clone(), policy);
336        Self {
337            data,
338            indexed_focus: Some(focus_scope),
339            compatibility_union,
340            union_projection: OnceCell::new(),
341            data_projection: OnceCell::new(),
342            raw_schema: Arc::clone(&compiled.inner.authored),
343            schema: Arc::clone(&compiled.inner.normalized),
344            raw_by_normalized: Arc::clone(&compiled.inner.raw_by_normalized),
345            shape_map: Arc::clone(&compiled.inner.shape_map),
346            sparql,
347        }
348    }
349
350    /// The evaluation data graph this snapshot was prepared over: the dataset
351    /// after inference (when requested), exactly as target selection and the
352    /// evaluator see it. Borrowed so a caller resolving extra paths (e.g. the
353    /// shape-map `value_paths` feature) evaluates over the same graph the run
354    /// used without cloning it.
355    pub fn data(&self) -> &Graph {
356        if self.compatibility_union {
357            self.union_projection.get_or_init(|| {
358                self.sparql
359                    .frozen()
360                    .expect("prepared dataset")
361                    .default_graph_projection()
362            })
363        } else if let Some(data) = &self.data {
364            data
365        } else {
366            self.data_projection
367                .get_or_init(|| Arc::new(self.data_graph_projection()))
368        }
369    }
370
371    fn data_graph_projection(&self) -> Graph {
372        self.sparql
373            .frozen()
374            .expect("compiled preparation has a dataset")
375            .data_graph_projection()
376    }
377
378    pub(crate) fn data_graph_projection_mapped(
379        &self,
380        blank_nodes: &HashMap<oxrdf::BlankNode, oxrdf::BlankNode>,
381    ) -> Graph {
382        self.sparql
383            .frozen()
384            .expect("compiled preparation has a dataset")
385            .data_graph_projection_mapped(blank_nodes)
386    }
387
388    pub(crate) fn data_graph_shared(&self) -> Arc<Graph> {
389        if let Some(data) = &self.data {
390            Arc::clone(data)
391        } else {
392            Arc::clone(
393                self.data_projection
394                    .get_or_init(|| Arc::new(self.data_graph_projection())),
395            )
396        }
397    }
398
399    fn with_focus<R>(&self, f: impl FnOnce(&dyn FocusNodes) -> R) -> R {
400        if let Some(scope) = self.indexed_focus {
401            let indexed = IndexedFocus::new(self.sparql.frozen().expect("prepared dataset"), scope);
402            f(&indexed)
403        } else {
404            f(self
405                .data
406                .as_deref()
407                .expect("legacy preparation has a graph"))
408        }
409    }
410
411    /// Decide one retained authored/raw constraint directly.
412    ///
413    /// Most callers should use normalized constraints and
414    /// [`Self::explain_constraint`]. This is for source-provenance consumers
415    /// that need a child made unreachable when an enclosing constraint was
416    /// normalized away (for example, an unbounded qualified property slot).
417    pub fn raw_constraint_holds(&self, focus: &oxrdf::Term, constraint: ShapeId) -> Option<bool> {
418        if (constraint.0 as usize) >= self.raw_schema.arena.len() {
419            return None;
420        }
421        let backend = self
422            .sparql
423            .frozen()
424            .expect("prepared evidence validator always owns a frozen dataset");
425        let mut evaluator = ShapeEvaluator::new(
426            backend,
427            &self.raw_schema.arena,
428            &self.raw_schema.prefixes,
429            &self.sparql,
430        );
431        Some(evaluator.holds(focus, constraint))
432    }
433
434    /// Validate the prepared snapshot for conformance only.
435    ///
436    /// Preparation, target selection, and the evaluator are exactly those of
437    /// [`validate`](Self::validate); each selected `(statement, focus)` pair is
438    /// instead decided by one short-circuiting satisfaction test. No evidence is
439    /// materialized and no authored-statement progress is computed, so the
440    /// difference against [`validate`](Self::validate) on the same snapshot is
441    /// the cost of evidence tracing alone.
442    ///
443    /// Every failing pair makes `conforms` false: severity filtering requires
444    /// the failure evidence this entry point deliberately does not construct.
445    pub fn validate_conformance(&self, options: &ConformanceOptions) -> ConformanceRun {
446        self.scan_conformance(options, |_, _, _| {})
447    }
448
449    /// Conformance totals together with the pairs that failed.
450    ///
451    /// The same single pass as [`validate_conformance`](Self::validate_conformance),
452    /// paying only a `Term` clone per *failing* pair, and giving the handles
453    /// [`explain`](Self::explain) needs. On corpora where failures are a small
454    /// fraction of selected pairs — 8,047 of 286,705 across the Brick suite —
455    /// this plus per-pair explanation is far cheaper than materializing
456    /// evidence for everything and discarding the passes.
457    pub fn find_failures(
458        &self,
459        options: &ConformanceOptions,
460    ) -> (ConformanceRun, Vec<SelectedPair>) {
461        let selected_sources: Vec<Vec<usize>> = self
462            .raw_by_normalized
463            .iter()
464            .map(|sources| {
465                sources
466                    .iter()
467                    .copied()
468                    .filter(|&source| {
469                        self.source_statement_selected(source, &options.entry_shape_names)
470                    })
471                    .collect()
472            })
473            .collect();
474        let mut failures = Vec::new();
475        let run = self.scan_conformance(options, |statement, focus, holds| {
476            if !holds {
477                failures.push(SelectedPair::new(
478                    statement,
479                    focus.clone(),
480                    selected_sources[statement].clone(),
481                ));
482            }
483        });
484        (run, failures)
485    }
486
487    /// The conformance pass, reporting each decided pair to `observe`.
488    ///
489    /// Generic over the observer so [`validate_conformance`](Self::validate_conformance)
490    /// keeps costing exactly what it did: an empty closure inlines away, which
491    /// matters because that method is the baseline the evidence-overhead
492    /// benchmark divides by.
493    fn scan_conformance(
494        &self,
495        options: &ConformanceOptions,
496        mut observe: impl FnMut(usize, &oxrdf::Term, bool),
497    ) -> ConformanceRun {
498        let backend = self
499            .sparql
500            .frozen()
501            .expect("prepared evidence validator always owns a frozen dataset");
502        let mut evaluator = ShapeEvaluator::new(
503            backend,
504            &self.schema.arena,
505            &self.schema.prefixes,
506            &self.sparql,
507        );
508        let mut run = ConformanceRun {
509            conforms: true,
510            selected_pairs: 0,
511            passed: 0,
512            failed: 0,
513        };
514
515        // One evaluator serves the whole scan. Its completed-judgment cache is
516        // therefore shared across statements and focus nodes, whereas evidence
517        // remains opt-in and is built only for a selected failure by `explain`.
518        for (statement_id, statement) in self.schema.statements.iter().enumerate() {
519            if !self.raw_by_normalized[statement_id]
520                .iter()
521                .copied()
522                .any(|source| self.source_statement_selected(source, &options.entry_shape_names))
523            {
524                continue;
525            }
526
527            let foci = self.with_focus(|focus| {
528                focus_nodes_with_evaluator(focus, &statement.selector, &mut evaluator)
529            });
530            prefetch_sparql_constraints(&self.schema.arena, statement.shape, &foci, &self.sparql);
531
532            for focus in foci {
533                run.selected_pairs += 1;
534                let holds = evaluator.holds(&focus, statement.shape);
535                if holds {
536                    run.passed += 1;
537                } else {
538                    run.failed += 1;
539                    run.conforms = false;
540                }
541                observe(statement_id, &focus, holds);
542            }
543        }
544
545        run
546    }
547
548    /// Materialize evidence for one pair, in the shape [`validate`](Self::validate)
549    /// would have produced for it.
550    ///
551    /// Returns one [`StatementEvaluation`] per authored statement that
552    /// normalizes to the pair's normalized statement and was selected when the
553    /// handle was created, each carrying the single focus, so a
554    /// caller can treat the result exactly like a slice of a full run. Empty if
555    /// the statement index is out of range.
556    ///
557    /// Target selection is *not* re-run: `pair` is taken as already selected,
558    /// which is the point — re-deriving the selection would cost what the whole
559    /// pass costs. Pairs should come from [`find_failures`](Self::find_failures)
560    /// or from an earlier run over the same snapshot. A focus this statement
561    /// never selected still yields well-defined evidence; it just describes a
562    /// pair the run did not contain.
563    ///
564    /// The constraint catalog is not included, since it is fixed per snapshot
565    /// rather than per pair; take it once from [`constraints`](Self::constraints).
566    pub fn explain(&self, pair: &SelectedPair) -> Vec<StatementEvaluation> {
567        self.explain_with_progress(pair, true)
568    }
569
570    /// Materialize only the canonical evidence for one pair.
571    ///
572    /// This omits the optional authored-statement progress view while retaining
573    /// the satisfaction trace or failure witness and the authored identities.
574    /// It is the per-pair counterpart of [`validate_canonical`](Self::validate_canonical).
575    pub fn explain_canonical(&self, pair: &SelectedPair) -> Vec<StatementEvaluation> {
576        self.explain_with_progress(pair, false)
577    }
578
579    fn explain_with_progress(
580        &self,
581        pair: &SelectedPair,
582        include_progress: bool,
583    ) -> Vec<StatementEvaluation> {
584        let Some(statement) = self.schema.statements.get(pair.normalized_statement) else {
585            return Vec::new();
586        };
587        let backend = self
588            .sparql
589            .frozen()
590            .expect("prepared evidence validator always owns a frozen dataset");
591        let mut evaluator = ShapeEvaluator::new(
592            backend,
593            &self.schema.arena,
594            &self.schema.prefixes,
595            &self.sparql,
596        );
597        prefetch_sparql_constraints(
598            &self.schema.arena,
599            statement.shape,
600            std::slice::from_ref(&pair.focus),
601            &self.sparql,
602        );
603        let evidence = materialize_evidence(&mut evaluator, &pair.focus, statement.shape);
604
605        self.raw_by_normalized[pair.normalized_statement]
606            .iter()
607            .filter(|raw_statement| pair.source_statements.contains(raw_statement))
608            .map(|&raw_statement| {
609                let raw = &self.raw_schema.statements[raw_statement];
610                let progress = include_progress
611                    .then(|| {
612                        source_progress(
613                            &mut evaluator,
614                            &self.raw_schema.arena,
615                            raw.shape,
616                            &self.shape_map,
617                            &pair.focus,
618                        )
619                    })
620                    .flatten();
621                let mut evaluation = statement_evaluation(
622                    &self.raw_schema,
623                    &self.schema,
624                    raw_statement,
625                    pair.normalized_statement,
626                );
627                evaluation.selected_foci.push(FocusEvaluation {
628                    focus: pair.focus.clone(),
629                    evidence: evidence.clone(),
630                    progress,
631                });
632                evaluation
633            })
634            .collect()
635    }
636
637    /// Is this authored statement one the caller asked for?
638    ///
639    /// Checked against the *authored* schema, whose names are lossless. Several
640    /// authored statements can normalize to one, and the caller may have named
641    /// only some of them: selecting the normalized statement must not drag the
642    /// others into the run.
643    fn source_statement_selected(
644        &self,
645        raw_statement: usize,
646        entry_shape_names: &[String],
647    ) -> bool {
648        let raw = &self.raw_schema.statements[raw_statement];
649        entry_shape_any_name_selected(entry_shape_names, self.raw_schema.names_of(raw.shape))
650    }
651
652    /// The authored statements that normalize to `normalized`, in source order.
653    ///
654    /// Common-subexpression elimination makes this one-to-many: several authored
655    /// statements can share one normalized statement, which is why
656    /// [`explain`](Self::explain) returns one evaluation per authored statement
657    /// rather than one per pair. Empty if `normalized` is out of range.
658    pub fn source_statements(&self, normalized: usize) -> &[usize] {
659        self.raw_by_normalized
660            .get(normalized)
661            .map_or(&[], Vec::as_slice)
662    }
663
664    /// Materialize evidence for one focus against one *normalized* constraint —
665    /// any arena id, not just a statement's top-level shape.
666    ///
667    /// This is the sub-statement counterpart of [`explain`](Self::explain): a
668    /// failing conjunction's [`Witness`] carries only its failing children, so a
669    /// caller reconstructing per-property coverage uses the run's
670    /// [`EvaluationProgress`] to learn which children passed and this method to
671    /// materialize the satisfaction evidence the witness elided. Like
672    /// [`explain`](Self::explain), no target selection is involved: the pair is
673    /// taken as given, and a focus no statement selects still yields
674    /// well-defined evidence. Returns `None` when `constraint` is not an arena
675    /// id of the normalized schema (see [`schema`](Self::schema)).
676    pub fn explain_constraint(&self, focus: &oxrdf::Term, constraint: ShapeId) -> Option<Evidence> {
677        if (constraint.0 as usize) >= self.schema.arena.len() {
678            return None;
679        }
680        let backend = self
681            .sparql
682            .frozen()
683            .expect("prepared evidence validator always owns a frozen dataset");
684        let mut evaluator = ShapeEvaluator::new(
685            backend,
686            &self.schema.arena,
687            &self.schema.prefixes,
688            &self.sparql,
689        );
690        prefetch_sparql_constraints(
691            &self.schema.arena,
692            constraint,
693            std::slice::from_ref(focus),
694            &self.sparql,
695        );
696        Some(materialize_evidence(&mut evaluator, focus, constraint))
697    }
698
699    /// The constraint catalogs an [`EvidenceRun`] carries.
700    ///
701    /// Fixed for the snapshot, so a caller explaining pairs one at a time takes
702    /// this once rather than paying for it per pair — on a small 223P model the
703    /// catalog is 57% of a whole run's serialized bytes.
704    pub fn constraints(&self) -> ConstraintCatalog {
705        ConstraintCatalog {
706            source: constraint_catalog(&self.raw_schema.arena),
707            normalized: constraint_catalog(&self.schema.arena),
708        }
709    }
710
711    /// Validate the prepared snapshot and return its complete coverage horizon.
712    pub fn validate(&self, options: &ValidationOptions) -> EvidenceRun {
713        self.validate_with_progress(options, true)
714    }
715
716    /// Validate the prepared snapshot and return canonical evidence only.
717    ///
718    /// The optional authored-statement progress view is omitted. This is the
719    /// evidence interface benchmarked against conformance-only validation.
720    pub fn validate_canonical(&self, options: &ValidationOptions) -> EvidenceRun {
721        self.validate_with_progress(options, false)
722    }
723
724    fn validate_with_progress(
725        &self,
726        options: &ValidationOptions,
727        include_progress: bool,
728    ) -> EvidenceRun {
729        let backend = self
730            .sparql
731            .frozen()
732            .expect("prepared evidence validator always owns a frozen dataset");
733        let mut evaluator = ShapeEvaluator::new(
734            backend,
735            &self.schema.arena,
736            &self.schema.prefixes,
737            &self.sparql,
738        );
739        let mut statements: Vec<Option<StatementEvaluation>> =
740            vec![None; self.raw_schema.statements.len()];
741        let mut conforms = true;
742        // Attribution is opt-in: without profiling this loop pays nothing, so
743        // the evidence-overhead benchmark measures materialization, not timers.
744        let profiling = crate::profile::is_enabled();
745
746        for (statement_id, statement) in self.schema.statements.iter().enumerate() {
747            if !self.raw_by_normalized[statement_id]
748                .iter()
749                .copied()
750                .any(|source| self.source_statement_selected(source, &options.entry_shape_names))
751            {
752                continue;
753            }
754
755            let label = profiling.then(|| {
756                self.schema
757                    .name_of(statement.shape)
758                    .map(str::to_string)
759                    .unwrap_or_else(|| format!("@{}", statement.shape.0))
760            });
761            let selection_start = profiling.then(web_time::Instant::now);
762            let foci = self.with_focus(|focus| {
763                focus_nodes_with_evaluator(focus, &statement.selector, &mut evaluator)
764            });
765            prefetch_sparql_constraints(&self.schema.arena, statement.shape, &foci, &self.sparql);
766            if let (Some(start), Some(label)) = (selection_start, label.as_deref()) {
767                crate::profile::record_shape_work(
768                    &format!("select:{label}"),
769                    start.elapsed().as_micros() as u64,
770                    0,
771                );
772            }
773
774            for focus in foci {
775                let pair_start = profiling
776                    .then(|| (web_time::Instant::now(), crate::profile::evidence_visits()));
777                let evidence = materialize_evidence(&mut evaluator, &focus, statement.shape);
778                if let (Some((start, visits_before)), Some(label)) = (pair_start, label.as_deref())
779                {
780                    crate::profile::record_shape_work(
781                        label,
782                        start.elapsed().as_micros() as u64,
783                        crate::profile::evidence_visits().saturating_sub(visits_before),
784                    );
785                }
786                if let Evidence::Failure(failure) = &evidence
787                    && failure_meets_threshold(
788                        &self.schema.arena,
789                        statement.shape,
790                        failure,
791                        &options.minimum_severity,
792                    )
793                {
794                    conforms = false;
795                }
796
797                for &raw_statement in &self.raw_by_normalized[statement_id] {
798                    if !self.source_statement_selected(raw_statement, &options.entry_shape_names) {
799                        continue;
800                    }
801                    let raw = &self.raw_schema.statements[raw_statement];
802                    let progress = include_progress
803                        .then(|| {
804                            source_progress(
805                                &mut evaluator,
806                                &self.raw_schema.arena,
807                                raw.shape,
808                                &self.shape_map,
809                                &focus,
810                            )
811                        })
812                        .flatten();
813                    statements[raw_statement]
814                        .get_or_insert_with(|| {
815                            statement_evaluation(
816                                &self.raw_schema,
817                                &self.schema,
818                                raw_statement,
819                                statement_id,
820                            )
821                        })
822                        .selected_foci
823                        .push(FocusEvaluation {
824                            focus: focus.clone(),
825                            evidence: evidence.clone(),
826                            progress,
827                        });
828                }
829            }
830
831            // Source statements remain visible even when target selection is empty.
832            for &raw_statement in &self.raw_by_normalized[statement_id] {
833                if !self.source_statement_selected(raw_statement, &options.entry_shape_names) {
834                    continue;
835                }
836                statements[raw_statement].get_or_insert_with(|| {
837                    statement_evaluation(
838                        &self.raw_schema,
839                        &self.schema,
840                        raw_statement,
841                        statement_id,
842                    )
843                });
844            }
845        }
846
847        let mut statements: Vec<StatementEvaluation> = statements.into_iter().flatten().collect();
848        if options.sort_results {
849            statements.sort_by_key(|statement| statement.source_statement_id);
850            for statement in &mut statements {
851                statement
852                    .selected_foci
853                    .sort_by_key(|focus| focus.focus.to_string());
854            }
855        }
856
857        // TODO(physical evidence): execute planned FocusSource/path operators
858        // here while carrying normalized logical ShapeIds through physical plan
859        // nodes, so target/path work is reused without losing evidence identity.
860        EvidenceRun {
861            conforms,
862            constraints: ConstraintCatalog {
863                source: constraint_catalog(&self.raw_schema.arena),
864                normalized: constraint_catalog(&self.schema.arena),
865            },
866            statements,
867        }
868    }
869
870    /// The normalized schema whose ids appear in returned evidence.
871    pub fn schema(&self) -> &Schema {
872        &self.schema
873    }
874}
875
876fn constraint_catalog(arena: &ShapeArena) -> Vec<ConstraintRecord> {
877    (0..arena.len())
878        .map(|index| {
879            let id = ShapeId(index as u32);
880            ConstraintRecord {
881                id,
882                constraint_kind: ConstraintKind::of(arena, id),
883                constraint: arena.get(id).clone(),
884            }
885        })
886        .collect()
887}
888
889fn statement_evaluation(
890    raw_schema: &Schema,
891    normalized_schema: &Schema,
892    source_statement_id: usize,
893    normalized_statement_id: usize,
894) -> StatementEvaluation {
895    let source = &raw_schema.statements[source_statement_id];
896    let normalized = &normalized_schema.statements[normalized_statement_id];
897    StatementEvaluation {
898        source_statement_id,
899        normalized_statement_id: Some(normalized_statement_id),
900        source_constraint_id: source.shape,
901        normalized_constraint_id: Some(normalized.shape),
902        constraint_kind: ConstraintKind::of(&normalized_schema.arena, normalized.shape),
903        constraint: normalized_schema.arena.get(normalized.shape).clone(),
904        selector: source.selector.clone(),
905        selected_foci: Vec::new(),
906    }
907}
908
909fn source_children(arena: &ShapeArena, mut id: ShapeId) -> Vec<ShapeId> {
910    while let Shape::Annotated { shape, .. } = arena.get(id) {
911        id = *shape;
912    }
913    match arena.get(id) {
914        Shape::And(children) | Shape::Or(children) => children.clone(),
915        Shape::Not(child) => vec![*child],
916        _ => Vec::new(),
917    }
918}
919
920fn source_progress(
921    evaluator: &mut ShapeEvaluator<'_>,
922    raw_arena: &ShapeArena,
923    raw_shape: ShapeId,
924    shape_map: &[Option<ShapeId>],
925    focus: &oxrdf::Term,
926) -> Option<EvaluationProgress> {
927    let children = source_children(raw_arena, raw_shape);
928    if children.is_empty() {
929        return None;
930    }
931    // Progress is deliberately a shallow, source-shaped view. It does not
932    // duplicate the normalized evidence tree: each child says whether it held
933    // and names the normalized conclusion a caller can expand on demand. This
934    // keeps authored UI structure without paying to materialize successes that
935    // a failure witness normally prunes.
936    let evaluated_children = children
937        .into_iter()
938        .map(|source_constraint_ref| {
939            let normalized_constraint_ref = shape_map
940                .get(source_constraint_ref.0 as usize)
941                .copied()
942                .flatten();
943            let status = normalized_constraint_ref
944                .map(|id| {
945                    if evaluator.holds(focus, id) {
946                        EvaluationStatus::Pass
947                    } else {
948                        EvaluationStatus::Fail
949                    }
950                })
951                .unwrap_or(EvaluationStatus::Pass);
952            let summary_id = normalized_constraint_ref.unwrap_or(source_constraint_ref);
953            let summary_kind = normalized_constraint_ref.map_or_else(
954                || ConstraintKind::of(raw_arena, source_constraint_ref),
955                |id| ConstraintKind::of(evaluator.arena(), id),
956            );
957            ChildEvaluation {
958                source_constraint_ref,
959                normalized_constraint_ref,
960                status,
961                evidence_summary: EvidenceSummary {
962                    constraint_id: summary_id,
963                    constraint_kind: summary_kind,
964                    status,
965                },
966            }
967        })
968        .collect();
969    Some(EvaluationProgress { evaluated_children })
970}
971
972fn witness_shape_ids(witness: &Witness, out: &mut HashSet<ShapeId>) {
973    let shape = match witness {
974        Witness::Atom { shape, .. }
975        | Witness::Relational { shape, .. }
976        | Witness::Closed { shape, .. }
977        | Witness::Not { shape, .. }
978        | Witness::All { shape, .. }
979        | Witness::Any { shape, .. }
980        | Witness::CountLow { shape, .. }
981        | Witness::CountHigh { shape, .. }
982        | Witness::Opaque { shape, .. } => *shape,
983    };
984    out.insert(shape);
985    match witness {
986        Witness::All { failed, .. } => failed
987            .iter()
988            .for_each(|child| witness_shape_ids(child, out)),
989        Witness::Any { branches, .. } => branches
990            .iter()
991            .for_each(|child| witness_shape_ids(child, out)),
992        Witness::CountHigh { per_value, .. } => per_value
993            .iter()
994            .for_each(|(_, child)| witness_shape_ids(child, out)),
995        _ => {}
996    }
997}
998
999fn failure_meets_threshold(
1000    arena: &ShapeArena,
1001    root: ShapeId,
1002    witness: &Witness,
1003    minimum: &Severity,
1004) -> bool {
1005    let mut failed = HashSet::new();
1006    witness_shape_ids(witness, &mut failed);
1007    let mut active = HashSet::new();
1008    failure_shape_meets(
1009        arena,
1010        root,
1011        &Severity::Violation,
1012        minimum,
1013        &failed,
1014        &mut active,
1015    )
1016}
1017
1018fn failure_shape_meets(
1019    arena: &ShapeArena,
1020    id: ShapeId,
1021    inherited: &Severity,
1022    minimum: &Severity,
1023    failed: &HashSet<ShapeId>,
1024    active: &mut HashSet<ShapeId>,
1025) -> bool {
1026    if !active.insert(id) {
1027        return false;
1028    }
1029    let result = match arena.get(id) {
1030        Shape::Annotated {
1031            severity, shape, ..
1032        } => failure_shape_meets(arena, *shape, severity, minimum, failed, active),
1033        Shape::And(children) => children
1034            .iter()
1035            .any(|child| failure_shape_meets(arena, *child, inherited, minimum, failed, active)),
1036        Shape::Or(children) => {
1037            (failed.contains(&id) && inherited.meets(minimum))
1038                || children.iter().any(|child| {
1039                    failure_shape_meets(arena, *child, inherited, minimum, failed, active)
1040                })
1041        }
1042        Shape::Count { qualifier, .. } => {
1043            (failed.contains(&id) && inherited.meets(minimum))
1044                || failure_shape_meets(arena, *qualifier, inherited, minimum, failed, active)
1045        }
1046        Shape::Not(_) => failed.contains(&id) && inherited.meets(minimum),
1047        _ => failed.contains(&id) && inherited.meets(minimum),
1048    };
1049    active.remove(&id);
1050    result
1051}
1052
1053pub fn validate_with_evidence(
1054    data: &Graph,
1055    schema: &Schema,
1056) -> Result<EvidenceRun, NonStratifiable> {
1057    validate_with_evidence_and_options(data, schema, &ValidationOptions::default())
1058}
1059
1060pub fn validate_with_evidence_and_options(
1061    data: &Graph,
1062    schema: &Schema,
1063    options: &ValidationOptions,
1064) -> Result<EvidenceRun, NonStratifiable> {
1065    Ok(PreparedEvidenceValidator::new(data, schema)?.validate(options))
1066}
1067
1068pub fn validate_with_context_and_evidence(
1069    data: &Graph,
1070    context: &Graph,
1071    schema: &Schema,
1072) -> Result<EvidenceRun, NonStratifiable> {
1073    validate_with_context_and_evidence_and_options(
1074        data,
1075        context,
1076        schema,
1077        &ValidationOptions::default(),
1078    )
1079}
1080
1081pub fn validate_with_context_and_evidence_and_options(
1082    data: &Graph,
1083    context: &Graph,
1084    schema: &Schema,
1085    options: &ValidationOptions,
1086) -> Result<EvidenceRun, NonStratifiable> {
1087    Ok(PreparedEvidenceValidator::with_context(data, context, schema)?.validate(options))
1088}
1089
1090pub fn validate_graphs_with_evidence(
1091    data: &Graph,
1092    shapes: &Graph,
1093    schema: &Schema,
1094) -> Result<EvidenceRun, NonStratifiable> {
1095    validate_graphs_with_evidence_and_mode_and_options(
1096        data,
1097        shapes,
1098        schema,
1099        ValidationGraphMode::default(),
1100        &ValidationOptions::default(),
1101    )
1102}
1103
1104pub fn validate_graphs_with_evidence_and_mode(
1105    data: &Graph,
1106    shapes: &Graph,
1107    schema: &Schema,
1108    mode: ValidationGraphMode,
1109) -> Result<EvidenceRun, NonStratifiable> {
1110    validate_graphs_with_evidence_and_mode_and_options(
1111        data,
1112        shapes,
1113        schema,
1114        mode,
1115        &ValidationOptions::default(),
1116    )
1117}
1118
1119pub fn validate_graphs_with_evidence_and_mode_and_options(
1120    data: &Graph,
1121    shapes: &Graph,
1122    schema: &Schema,
1123    mode: ValidationGraphMode,
1124    options: &ValidationOptions,
1125) -> Result<EvidenceRun, NonStratifiable> {
1126    Ok(PreparedEvidenceValidator::with_graphs(data, shapes, schema, mode)?.validate(options))
1127}
1128
1129#[cfg(test)]
1130mod tests {
1131    use super::*;
1132    use crate::{PathSupport, SatTrace};
1133    use oxrdf::Triple;
1134    use shifty_parse::{load_turtle, parse_turtle};
1135
1136    const PREFIXES: &str = r#"
1137        @prefix sh: <http://www.w3.org/ns/shacl#> .
1138        @prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
1139        @prefix ex: <http://ex/> .
1140    "#;
1141
1142    fn run(ttl: &str) -> (Graph, Schema, EvidenceRun) {
1143        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
1144        let loaded = load_turtle(ttl.as_bytes(), None).unwrap();
1145        let outcome = validate_with_evidence(&loaded.graph, &parsed.schema).unwrap();
1146        (loaded.graph, parsed.schema, outcome)
1147    }
1148
1149    /// Two named shapes stating the *same* constraint, so CSE collapses them.
1150    const COLLAPSING: &str = r#"
1151        ex:S1 a sh:NodeShape ; sh:targetClass ex:T ;
1152            sh:property [ sh:path ex:p ; sh:minCount 1 ] .
1153        ex:S2 a sh:NodeShape ; sh:targetClass ex:T ;
1154            sh:property [ sh:path ex:p ; sh:minCount 1 ] .
1155        ex:bad a ex:T .
1156    "#;
1157
1158    fn scoped_to(schema: &Schema, data: &Graph, name: &str) -> Vec<String> {
1159        let prepared = PreparedEvidenceValidator::new(data, schema).expect("stratifiable");
1160        let options = ValidationOptions {
1161            entry_shape_names: vec![name.to_string()],
1162            ..ValidationOptions::default()
1163        };
1164        prepared
1165            .validate(&options)
1166            .statements
1167            .iter()
1168            .map(|statement| {
1169                schema
1170                    .name_of(schema.statements[statement.source_statement_id].shape)
1171                    .unwrap_or("<blank>")
1172                    .to_string()
1173            })
1174            .collect()
1175    }
1176
1177    #[test]
1178    fn entry_shape_names_reach_a_shape_that_cse_collapsed_onto_another() {
1179        // Both names must work, and each must select only its own statement.
1180        // Before names accumulated, one of them selected nothing and the other
1181        // dragged in both — and *which* depended on hash iteration order.
1182        let ttl = format!("{PREFIXES}{COLLAPSING}");
1183        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
1184        let loaded = load_turtle(ttl.as_bytes(), None).unwrap();
1185
1186        assert_eq!(
1187            scoped_to(&parsed.schema, &loaded.graph, "http://ex/S1"),
1188            ["http://ex/S1"],
1189        );
1190        assert_eq!(
1191            scoped_to(&parsed.schema, &loaded.graph, "http://ex/S2"),
1192            ["http://ex/S2"],
1193        );
1194        // Unscoped still reports both authored statements.
1195        let prepared =
1196            PreparedEvidenceValidator::new(&loaded.graph, &parsed.schema).expect("stratifiable");
1197        assert_eq!(
1198            prepared
1199                .validate(&ValidationOptions::default())
1200                .statements
1201                .len(),
1202            2,
1203        );
1204    }
1205
1206    #[test]
1207    fn scoped_failure_handles_explain_only_the_selected_authored_statement() {
1208        let ttl = format!("{PREFIXES}{COLLAPSING}");
1209        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
1210        let loaded = load_turtle(ttl.as_bytes(), None).unwrap();
1211        let prepared =
1212            PreparedEvidenceValidator::new(&loaded.graph, &parsed.schema).expect("stratifiable");
1213        let options = ConformanceOptions {
1214            entry_shape_names: vec!["http://ex/S1".to_string()],
1215        };
1216
1217        let (_, failures) = prepared.find_failures(&options);
1218        assert_eq!(failures.len(), 1);
1219        assert_eq!(failures[0].source_statements(), [0]);
1220        assert_eq!(
1221            prepared
1222                .explain(&failures[0])
1223                .iter()
1224                .map(|statement| statement.source_statement_id)
1225                .collect::<Vec<_>>(),
1226            [0],
1227        );
1228    }
1229
1230    #[test]
1231    fn a_collapsed_shape_answers_to_every_name_that_reached_it() {
1232        let ttl = format!("{PREFIXES}{COLLAPSING}");
1233        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
1234        let loaded = load_turtle(ttl.as_bytes(), None).unwrap();
1235        let prepared =
1236            PreparedEvidenceValidator::new(&loaded.graph, &parsed.schema).expect("stratifiable");
1237
1238        // One normalized statement, carrying both authored names, sorted.
1239        let normalized = prepared.schema();
1240        assert_eq!(normalized.statements.len(), 1);
1241        assert_eq!(
1242            normalized.names_of(normalized.statements[0].shape),
1243            ["http://ex/S1".to_string(), "http://ex/S2".to_string()],
1244        );
1245        // `name_of` picks the first, so a display label never depends on hash
1246        // iteration order.
1247        assert_eq!(
1248            normalized.name_of(normalized.statements[0].shape),
1249            Some("http://ex/S1"),
1250        );
1251        // And the reverse lookup finds the slot from either name.
1252        assert_eq!(
1253            crate::shape_id_for_iri(normalized, "http://ex/S1"),
1254            crate::shape_id_for_iri(normalized, "http://ex/S2"),
1255        );
1256        assert!(crate::shape_id_for_iri(normalized, "http://ex/S2").is_some());
1257    }
1258
1259    fn foci(outcome: &EvidenceRun) -> Vec<&FocusEvaluation> {
1260        outcome
1261            .statements
1262            .iter()
1263            .flat_map(|statement| &statement.selected_foci)
1264            .collect()
1265    }
1266
1267    fn traces(trace: &SatTrace, pred: &impl Fn(&SatTrace) -> bool) -> bool {
1268        if pred(trace) {
1269            return true;
1270        }
1271        match trace {
1272            SatTrace::AllHeld { children, .. } => children.iter().any(|t| traces(t, pred)),
1273            SatTrace::AnyHeld { satisfied, .. } => satisfied.iter().any(|t| traces(t, pred)),
1274            SatTrace::CountHeld { matches, .. } => matches.iter().any(|(_, _, t)| traces(t, pred)),
1275            SatTrace::ForAllHeld { values, .. } => values.iter().any(|(_, _, t)| traces(t, pred)),
1276            _ => false,
1277        }
1278    }
1279
1280    fn support_edges(support: &PathSupport, out: &mut Vec<Triple>) {
1281        match support {
1282            PathSupport::Edge(edge) => out.push(edge.clone()),
1283            PathSupport::Chain(parts) | PathSupport::Alt(parts) => {
1284                parts.iter().for_each(|part| support_edges(part, out));
1285            }
1286            PathSupport::Empty => {}
1287        }
1288    }
1289
1290    #[test]
1291    fn conformance_only_run_agrees_with_evidence_run() {
1292        let ttl = format!(
1293            "{PREFIXES}
1294             ex:S a sh:NodeShape ; sh:targetClass ex:T ;
1295               sh:property [ sh:path ex:p ; sh:minCount 1 ] .
1296             ex:T2 a sh:NodeShape ; sh:targetClass ex:T ;
1297               sh:property [ sh:path ex:p ; sh:minCount 1 ] .
1298             ex:good a ex:T ; ex:p ex:value .
1299             ex:bad a ex:T .
1300             ex:unselected ex:p ex:value ."
1301        );
1302        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
1303        let loaded = load_turtle(ttl.as_bytes(), None).unwrap();
1304        let prepared = PreparedEvidenceValidator::new(&loaded.graph, &parsed.schema).unwrap();
1305        let options = ValidationOptions::default();
1306        let conformance = prepared.validate_conformance(&ConformanceOptions::from(&options));
1307        let evidence = prepared.validate(&options);
1308
1309        assert_eq!(conformance.conforms, evidence.conforms);
1310        assert_eq!(conformance.selected_pairs, 2);
1311        assert_eq!(conformance.passed, 1);
1312        assert_eq!(conformance.failed, 1);
1313        // Two authored statements normalize together, so the evidence run fans
1314        // the same two evaluated pairs back out over both of them.
1315        assert_eq!(foci(&evidence).len(), 4);
1316    }
1317
1318    #[test]
1319    fn selected_pairs_are_partitioned_and_unselected_pairs_are_absent() {
1320        let ttl = format!(
1321            "{PREFIXES}
1322             ex:S a sh:NodeShape ; sh:targetClass ex:T ;
1323               sh:property [ sh:path ex:p ; sh:minCount 1 ] .
1324             ex:good a ex:T ; ex:p ex:value .
1325             ex:bad a ex:T .
1326             ex:unselected ex:p ex:value ."
1327        );
1328        let (_, _, outcome) = run(&ttl);
1329        let results = foci(&outcome);
1330        assert!(!outcome.conforms);
1331        assert_eq!(results.len(), 2);
1332        assert_eq!(
1333            results
1334                .iter()
1335                .filter(|r| matches!(r.evidence, Evidence::Satisfaction(_)))
1336                .count(),
1337            1
1338        );
1339        assert_eq!(
1340            results
1341                .iter()
1342                .filter(|r| matches!(r.evidence, Evidence::Failure(_)))
1343                .count(),
1344            1
1345        );
1346        assert!(
1347            results
1348                .iter()
1349                .all(|r| !r.focus.to_string().contains("unselected"))
1350        );
1351    }
1352
1353    #[test]
1354    fn raw_statements_survive_normalized_deduplication() {
1355        let ttl = format!(
1356            "{PREFIXES}
1357             ex:S1 a sh:NodeShape ; sh:targetNode ex:x ; sh:nodeKind sh:IRI .
1358             ex:S2 a sh:NodeShape ; sh:targetNode ex:x ; sh:nodeKind sh:IRI ."
1359        );
1360        let (_, raw, outcome) = run(&ttl);
1361        assert_eq!(raw.statements.len(), 2);
1362        assert_eq!(outcome.statements.len(), 2);
1363        assert_eq!(outcome.statements[0].source_statement_id, 0);
1364        assert_eq!(outcome.statements[1].source_statement_id, 1);
1365        assert_eq!(
1366            outcome.statements[0].normalized_statement_id,
1367            outcome.statements[1].normalized_statement_id
1368        );
1369        assert_eq!(
1370            outcome.statements[0].normalized_constraint_id,
1371            outcome.statements[1].normalized_constraint_id
1372        );
1373    }
1374
1375    #[test]
1376    fn selector_matching_nothing_has_empty_statement_row() {
1377        let ttl = format!(
1378            "{PREFIXES}
1379             ex:S a sh:NodeShape ; sh:targetClass ex:Missing ; sh:nodeKind sh:IRI .
1380             ex:x a ex:Other ."
1381        );
1382        let (_, _, outcome) = run(&ttl);
1383        assert!(outcome.conforms);
1384        assert_eq!(outcome.statements.len(), 1);
1385        assert!(outcome.statements[0].selected_foci.is_empty());
1386    }
1387
1388    #[test]
1389    fn negation_crosses_to_the_opposite_evidence_polarity() {
1390        let passing = format!(
1391            "{PREFIXES}
1392             ex:S a sh:NodeShape ; sh:targetNode ex:x ;
1393               sh:not [ sh:class ex:C ] .
1394             ex:x a ex:Other ."
1395        );
1396        let (_, _, pass) = run(&passing);
1397        let pass_foci = foci(&pass);
1398        let Evidence::Satisfaction(trace) = &pass_foci[0].evidence else {
1399            panic!("expected satisfaction")
1400        };
1401        assert!(traces(trace, &|t| matches!(t, SatTrace::NotHeld { .. })));
1402
1403        let failing = format!(
1404            "{PREFIXES}
1405             ex:S a sh:NodeShape ; sh:targetNode ex:x ;
1406               sh:not [ sh:class ex:C ] .
1407             ex:x a ex:C ."
1408        );
1409        let (_, _, fail) = run(&failing);
1410        let fail_foci = foci(&fail);
1411        assert!(matches!(
1412            fail_foci[0].evidence,
1413            Evidence::Failure(Witness::Not { .. }) | Evidence::Failure(Witness::All { .. })
1414        ));
1415        fn contains_not(w: &Witness) -> bool {
1416            match w {
1417                Witness::Not { .. } => true,
1418                Witness::All { failed, .. } => failed.iter().any(contains_not),
1419                Witness::Any { branches, .. } => branches.iter().any(contains_not),
1420                _ => false,
1421            }
1422        }
1423        let Evidence::Failure(witness) = &fail_foci[0].evidence else {
1424            unreachable!()
1425        };
1426        assert!(contains_not(witness));
1427    }
1428
1429    #[test]
1430    fn sequence_path_satisfaction_carries_only_real_graph_edges() {
1431        let ttl = format!(
1432            "{PREFIXES}
1433             ex:S a sh:NodeShape ; sh:targetNode ex:x ;
1434               sh:property [ sh:path (ex:p ex:q) ; sh:minCount 1 ] .
1435             ex:x ex:p ex:y . ex:y ex:q ex:z ."
1436        );
1437        let (graph, _, outcome) = run(&ttl);
1438        let results = foci(&outcome);
1439        let Evidence::Satisfaction(trace) = &results[0].evidence else {
1440            panic!("expected satisfaction")
1441        };
1442        let mut edges = Vec::new();
1443        fn gather(trace: &SatTrace, edges: &mut Vec<Triple>) {
1444            match trace {
1445                SatTrace::CountHeld { matches, .. } => {
1446                    for (_, support, child) in matches {
1447                        support_edges(support, edges);
1448                        gather(child, edges);
1449                    }
1450                }
1451                SatTrace::ForAllHeld { values, .. } => {
1452                    for (_, support, child) in values {
1453                        support_edges(support, edges);
1454                        gather(child, edges);
1455                    }
1456                }
1457                SatTrace::AllHeld { children, .. } => {
1458                    children.iter().for_each(|child| gather(child, edges))
1459                }
1460                SatTrace::AnyHeld { satisfied, .. } => {
1461                    satisfied.iter().for_each(|child| gather(child, edges))
1462                }
1463                _ => {}
1464            }
1465        }
1466        gather(trace, &mut edges);
1467        assert_eq!(edges.len(), 2);
1468        assert!(edges.iter().all(|edge| graph.contains(edge.as_ref())));
1469    }
1470
1471    #[test]
1472    fn count_evidence_retains_matches_bounds_qualifiers_and_support() {
1473        let low_ttl = format!(
1474            "{PREFIXES}
1475             ex:S a sh:NodeShape ; sh:targetNode ex:x ;
1476               sh:property [ sh:path ex:p ; sh:minCount 2 ] ;
1477               sh:property [ sh:path ex:p ; sh:class ex:C ] .
1478             ex:x ex:p ex:one . ex:one a ex:C ."
1479        );
1480        let (_, _, low) = run(&low_ttl);
1481        let low_foci = foci(&low);
1482        let Evidence::Failure(low_witness) = &low_foci[0].evidence else {
1483            panic!("expected low-count failure")
1484        };
1485        fn find_low(witness: &Witness) -> Option<(u64, u64, ShapeId, &[ShapeId])> {
1486            match witness {
1487                Witness::CountLow {
1488                    have,
1489                    min,
1490                    qualifier,
1491                    sibling_qualifiers,
1492                    ..
1493                } => Some((*have, *min, *qualifier, sibling_qualifiers)),
1494                Witness::All { failed, .. } => failed.iter().find_map(find_low),
1495                Witness::Any { branches, .. } => branches.iter().find_map(find_low),
1496                _ => None,
1497            }
1498        }
1499        let (have, min, qualifier, _siblings) = find_low(low_witness).expect("CountLow");
1500        assert_eq!((have, min), (1, 2));
1501        assert_ne!(qualifier, ShapeId(u32::MAX));
1502
1503        let high_ttl = format!(
1504            "{PREFIXES}
1505             ex:S a sh:NodeShape ; sh:targetNode ex:x ;
1506               sh:property [ sh:path ex:p ; sh:maxCount 1 ] .
1507             ex:x ex:p ex:one, ex:two ."
1508        );
1509        let (graph, _, high) = run(&high_ttl);
1510        let high_foci = foci(&high);
1511        let Evidence::Failure(high_witness) = &high_foci[0].evidence else {
1512            panic!("expected high-count failure")
1513        };
1514        fn find_high(witness: &Witness) -> Option<(&[(oxrdf::Term, PathSupport)], u64)> {
1515            match witness {
1516                Witness::CountHigh { matched, max, .. } => Some((matched, *max)),
1517                Witness::All { failed, .. } => failed.iter().find_map(find_high),
1518                Witness::Any { branches, .. } => branches.iter().find_map(find_high),
1519                _ => None,
1520            }
1521        }
1522        let (matched, max) = find_high(high_witness).expect("CountHigh");
1523        assert_eq!(max, 1);
1524        assert_eq!(matched.len(), 2);
1525        let excess = high_foci[0]
1526            .evidence
1527            .walk()
1528            .into_iter()
1529            .find_map(|node| match node {
1530                crate::EvidenceNodeRef::Failure(Witness::CountHigh { excess_values, .. }) => {
1531                    Some(excess_values)
1532                }
1533                _ => None,
1534            })
1535            .expect("CountHigh excess values");
1536        assert_eq!(excess.len(), 1);
1537        let mut edges = Vec::new();
1538        matched
1539            .iter()
1540            .for_each(|(_, support)| support_edges(support, &mut edges));
1541        assert_eq!(edges.len(), 2);
1542        assert!(edges.iter().all(|edge| graph.contains(edge.as_ref())));
1543    }
1544
1545    #[test]
1546    fn failed_conjunction_progress_retains_successful_siblings() {
1547        let ttl = format!(
1548            "{PREFIXES}
1549             ex:S a sh:NodeShape ; sh:targetNode ex:x ;
1550               sh:nodeKind sh:IRI ;
1551               sh:property [ sh:path ex:p ; sh:minCount 1 ] ."
1552        );
1553        let (_, _, outcome) = run(&ttl);
1554        let results = foci(&outcome);
1555        let focus = results[0];
1556        assert_eq!(focus.status(), EvaluationStatus::Fail);
1557        let progress = focus.progress.as_ref().expect("conjunction progress");
1558        assert_eq!(progress.evaluated_children.len(), 2);
1559        assert_eq!(
1560            progress
1561                .evaluated_children
1562                .iter()
1563                .map(|child| child.status)
1564                .collect::<Vec<_>>(),
1565            vec![EvaluationStatus::Pass, EvaluationStatus::Fail]
1566        );
1567        let Evidence::Failure(Witness::All { failed, .. }) = &focus.evidence else {
1568            panic!("expected compact conjunction failure")
1569        };
1570        assert_eq!(failed.len(), 1);
1571    }
1572
1573    #[test]
1574    fn canonical_interfaces_omit_progress_without_changing_evidence() {
1575        let ttl = format!(
1576            "{PREFIXES}
1577             ex:S a sh:NodeShape ; sh:targetNode ex:x ;
1578               sh:nodeKind sh:IRI ;
1579               sh:property [ sh:path ex:p ; sh:minCount 1 ] ."
1580        );
1581        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
1582        let loaded = load_turtle(ttl.as_bytes(), None).unwrap();
1583        let prepared = PreparedEvidenceValidator::new(&loaded.graph, &parsed.schema).unwrap();
1584        let options = ValidationOptions::default();
1585
1586        let complete = prepared.validate(&options);
1587        let canonical = prepared.validate_canonical(&options);
1588        assert_eq!(complete.conforms, canonical.conforms);
1589        assert_eq!(complete.constraints, canonical.constraints);
1590        assert_eq!(complete.statements.len(), canonical.statements.len());
1591
1592        for (with_progress, without_progress) in complete
1593            .statements
1594            .iter()
1595            .flat_map(|statement| &statement.selected_foci)
1596            .zip(
1597                canonical
1598                    .statements
1599                    .iter()
1600                    .flat_map(|statement| &statement.selected_foci),
1601            )
1602        {
1603            assert!(with_progress.progress.is_some());
1604            assert!(without_progress.progress.is_none());
1605            assert_eq!(with_progress.focus, without_progress.focus);
1606            assert_eq!(with_progress.evidence, without_progress.evidence);
1607        }
1608
1609        let (_, failures) = prepared.find_failures(&ConformanceOptions::from(&options));
1610        let explained = prepared.explain_canonical(&failures[0]);
1611        assert!(
1612            explained
1613                .iter()
1614                .flat_map(|statement| &statement.selected_foci)
1615                .all(|focus| focus.progress.is_none())
1616        );
1617    }
1618
1619    #[test]
1620    fn qualified_count_low_partitions_matches_and_rejected_candidates() {
1621        let ttl = format!(
1622            "{PREFIXES}
1623             ex:S a sh:NodeShape ; sh:targetNode ex:x ;
1624               sh:property [
1625                 sh:path ex:p ;
1626                 sh:qualifiedValueShape [ sh:class ex:C ] ;
1627                 sh:qualifiedMinCount 2
1628               ] .
1629             ex:x ex:p ex:good, ex:near . ex:good a ex:C ."
1630        );
1631        let (graph, _, outcome) = run(&ttl);
1632        let results = foci(&outcome);
1633        let evidence = &results[0].evidence;
1634        let low = evidence.walk().into_iter().find_map(|node| match node {
1635            crate::EvidenceNodeRef::Failure(value @ Witness::CountLow { .. }) => Some(value),
1636            _ => None,
1637        });
1638        let Some(Witness::CountLow {
1639            qualifying_matches,
1640            rejected_candidates,
1641            have,
1642            min,
1643            ..
1644        }) = low
1645        else {
1646            panic!("expected CountLow")
1647        };
1648        assert_eq!((*have, *min), (1, 2));
1649        assert_eq!(qualifying_matches.len(), 1);
1650        assert_eq!(rejected_candidates.len(), 1);
1651        assert_eq!(evidence.missing_obligations()[0].missing, 1);
1652        assert!(
1653            evidence
1654                .supporting_triples()
1655                .iter()
1656                .all(|triple| graph.contains(triple.as_ref()))
1657        );
1658        assert_eq!(
1659            evidence.to_json().unwrap(),
1660            evidence.to_json().unwrap(),
1661            "serialization must be deterministic"
1662        );
1663    }
1664
1665    #[test]
1666    fn positive_recursion_is_coinductive_and_negative_recursion_is_rejected() {
1667        let positive = format!(
1668            "{PREFIXES}
1669             ex:S a sh:NodeShape ; sh:targetNode ex:x ;
1670               sh:property [ sh:path ex:p ; sh:node ex:S ] .
1671             ex:x ex:p ex:x ."
1672        );
1673        let (_, _, outcome) = run(&positive);
1674        let results = foci(&outcome);
1675        let Evidence::Satisfaction(trace) = &results[0].evidence else {
1676            panic!("expected coinductive satisfaction")
1677        };
1678        assert!(
1679            traces(trace, &|t| matches!(t, SatTrace::Coinductive { .. })),
1680            "trace did not retain recursion: {trace:?}"
1681        );
1682        let positive_loaded = load_turtle(positive.as_bytes(), None).unwrap();
1683        let positive_parsed = parse_turtle(positive.as_bytes(), None).unwrap();
1684        let ordinary = crate::validate(
1685            &positive_loaded.graph,
1686            &shifty_opt::normalize(&positive_parsed.schema),
1687        )
1688        .unwrap();
1689        assert_eq!(outcome.conforms, ordinary.conforms);
1690
1691        let negative = format!(
1692            "{PREFIXES}
1693             ex:S a sh:NodeShape ; sh:targetNode ex:x ;
1694               sh:not [ sh:path ex:p ; sh:qualifiedValueShape ex:S ;
1695                        sh:qualifiedMinCount 1 ] .
1696             ex:x ex:p ex:x ."
1697        );
1698        let parsed = parse_turtle(negative.as_bytes(), None).unwrap();
1699        let loaded = load_turtle(negative.as_bytes(), None).unwrap();
1700        assert!(validate_with_evidence(&loaded.graph, &parsed.schema).is_err());
1701    }
1702
1703    #[test]
1704    fn outcome_serializes_and_conforms_matches_ordinary_validation() {
1705        let ttl = format!(
1706            "{PREFIXES}
1707             ex:S a sh:NodeShape ; sh:targetNode ex:x ;
1708               sh:property [ sh:path ex:p ; sh:minCount 1 ] ."
1709        );
1710        let (graph, raw, outcome) = run(&ttl);
1711        let normalized = shifty_opt::normalize(&raw);
1712        let ordinary = crate::validate(&graph, &normalized).unwrap();
1713        assert_eq!(outcome.conforms, ordinary.conforms);
1714        let encoded = serde_json::to_string(&outcome).unwrap();
1715        let decoded: EvidenceRun = serde_json::from_str(&encoded).unwrap();
1716        assert_eq!(decoded, outcome);
1717
1718        let sparql = format!(
1719            "{PREFIXES}
1720             ex:S a sh:NodeShape ; sh:targetNode ex:x ;
1721               sh:sparql [ sh:select \"SELECT $this WHERE {{ FILTER NOT EXISTS {{ $this <http://ex/p> ?value }} }}\" ] ."
1722        );
1723        let (graph, raw, evidence) = run(&sparql);
1724        let ordinary = crate::validate(&graph, &shifty_opt::normalize(&raw)).unwrap();
1725        assert_eq!(evidence.conforms, ordinary.conforms);
1726        assert!(matches!(foci(&evidence)[0].evidence, Evidence::Failure(_)));
1727    }
1728
1729    // A fixture with both polarities, several foci, and two shapes stating the
1730    // same constraint, so the authored fan-out `explain` has to reproduce is
1731    // actually exercised.
1732    const ON_DEMAND: &str = r#"
1733        ex:S a sh:NodeShape ; sh:targetClass ex:T ;
1734          sh:property [ sh:path ex:p ; sh:minCount 1 ; sh:class ex:C ] .
1735        ex:S2 a sh:NodeShape ; sh:targetClass ex:T ;
1736          sh:property [ sh:path ex:p ; sh:minCount 1 ; sh:class ex:C ] .
1737        ex:U a sh:NodeShape ; sh:targetClass ex:T ;
1738          sh:property [ sh:path ex:q ; sh:maxCount 1 ] .
1739        ex:good a ex:T ; ex:p ex:c1 ; ex:q ex:z .
1740        ex:bad  a ex:T ; ex:q ex:z ; ex:q ex:y .
1741        ex:also a ex:T ; ex:p ex:missing .
1742        ex:c1 a ex:C .
1743    "#;
1744
1745    fn prepared(ttl: &str) -> PreparedEvidenceValidator {
1746        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
1747        let loaded = load_turtle(ttl.as_bytes(), None).unwrap();
1748        PreparedEvidenceValidator::new(&loaded.graph, &parsed.schema).unwrap()
1749    }
1750
1751    #[test]
1752    fn find_failures_agrees_with_the_full_run() {
1753        let validator = prepared(&format!("{PREFIXES}{ON_DEMAND}"));
1754        let options = ValidationOptions::default();
1755        let full = validator.validate(&options);
1756        let conformance_options = ConformanceOptions::from(&options);
1757        let (conformance, failures) = validator.find_failures(&conformance_options);
1758
1759        assert_eq!(conformance.conforms, full.conforms);
1760        assert!(!failures.is_empty(), "fixture has no failing pair");
1761        assert_eq!(conformance.failed, failures.len());
1762        // Counting is unchanged by observing.
1763        assert_eq!(
1764            conformance,
1765            validator.validate_conformance(&conformance_options)
1766        );
1767
1768        // Each reported failure explains to failure evidence, and nothing that
1769        // failed in the full run is missing from the list.
1770        for pair in &failures {
1771            for evaluation in validator.explain(pair) {
1772                assert!(matches!(
1773                    evaluation.selected_foci[0].evidence,
1774                    Evidence::Failure(_)
1775                ));
1776            }
1777        }
1778        let full_failures: HashSet<(usize, String)> = full
1779            .statements
1780            .iter()
1781            .flat_map(|statement| {
1782                statement.selected_foci.iter().map(|focus| {
1783                    (
1784                        statement.normalized_statement_id.unwrap(),
1785                        focus.focus.to_string(),
1786                    )
1787                })
1788            })
1789            .filter(|_| true)
1790            .collect();
1791        for pair in &failures {
1792            assert!(
1793                full_failures.contains(&(pair.normalized_statement(), pair.focus().to_string())),
1794                "failure {pair:?} was not a pair of the full run"
1795            );
1796        }
1797    }
1798
1799    #[test]
1800    fn explaining_an_unknown_statement_is_empty() {
1801        let validator = prepared(&format!("{PREFIXES}{ON_DEMAND}"));
1802        let pair = SelectedPair::new(
1803            usize::MAX,
1804            oxrdf::NamedNode::new("http://ex/good").unwrap().into(),
1805            vec![],
1806        );
1807        assert!(validator.explain(&pair).is_empty());
1808    }
1809
1810    #[test]
1811    fn the_catalog_is_the_one_a_full_run_carries() {
1812        let validator = prepared(&format!("{PREFIXES}{ON_DEMAND}"));
1813        let full = validator.validate(&ValidationOptions::default());
1814        assert_eq!(validator.constraints(), full.constraints);
1815    }
1816    /// A run must be reproducible, or nothing derived from one is.
1817    ///
1818    /// Path values used to reach evidence in `HashSet` iteration order, which
1819    /// varies between instances, so two runs over one snapshot disagreed — and
1820    /// for a `maxCount` failure they named different values as excess, not just
1821    /// in a different order.
1822    #[test]
1823    fn two_full_runs_agree() {
1824        let validator = prepared(&format!("{PREFIXES}{ON_DEMAND}"));
1825        let options = ValidationOptions::default();
1826        let first = validator.validate(&options);
1827        let second = validator.validate(&options);
1828        assert_eq!(first, second, "two runs over the same snapshot disagree");
1829    }
1830
1831    /// Reproducibility has to survive re-preparation too: a fresh validator
1832    /// over the same input is what a second process does.
1833    #[test]
1834    fn two_independent_validators_agree() {
1835        let options = ValidationOptions::default();
1836        let first = prepared(&format!("{PREFIXES}{ON_DEMAND}")).validate(&options);
1837        let second = prepared(&format!("{PREFIXES}{ON_DEMAND}")).validate(&options);
1838        assert_eq!(first, second, "two independent validators disagree");
1839    }
1840
1841    /// The whole reason the instability mattered: on-demand explanation has to
1842    /// reproduce the full run exactly, including which value is named excess.
1843    #[test]
1844    fn explaining_a_pair_matches_the_full_run() {
1845        let validator = prepared(&format!("{PREFIXES}{ON_DEMAND}"));
1846        let full = validator.validate(&ValidationOptions::default());
1847
1848        let mut checked = 0;
1849        for statement in &full.statements {
1850            let normalized = statement.normalized_statement_id.unwrap();
1851            for focus in &statement.selected_foci {
1852                let pair = SelectedPair::new(
1853                    normalized,
1854                    focus.focus.clone(),
1855                    vec![statement.source_statement_id],
1856                );
1857                let explained = validator.explain(&pair);
1858                let matching = explained
1859                    .iter()
1860                    .find(|candidate| {
1861                        candidate.source_statement_id == statement.source_statement_id
1862                    })
1863                    .expect("explain covers every authored statement of the pair");
1864                assert_eq!(&matching.selected_foci[0], focus);
1865                checked += 1;
1866            }
1867        }
1868        assert!(checked > 0, "fixture selected no pairs");
1869    }
1870}