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