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