Skip to main content

shifty_engine/
validate.rs

1//! Reference shape satisfaction `G, v ⊨ φ` and schema validation `G ⊨ S`
2//! (doc 00 §3–§4, Table 2). This is the conformance *oracle*: the optimized
3//! engines in later layers must agree with it.
4//!
5//! Two evaluators share the logic: `holds` returns a bare bool (used for
6//! target selection and counting), while `explain` returns the specific
7//! atomic constraints that failed, with the value node and path at which they
8//! failed — enough for per-constraint reporting. The `∀π = ∃≤0 π.¬φ` encoding
9//! lets a failed universal drill straight into the offending value node's inner
10//! constraint.
11//!
12//! `ShapeEvaluator` is the deep module behind every validation-facing API. Its
13//! callers provide a graph/path backend, one finalized arena, and a SPARQL
14//! executor; they ask only whether a `(focus, ShapeId)` holds. The evaluator
15//! centralizes path traversal, qualified counting, short-circuit Boolean logic,
16//! class/value comparisons, opaque SPARQL leaves, and recursion semantics so
17//! these concerns are not reimplemented in reporting, inference, or evidence.
18//!
19//! Completed judgments are memoized across a whole graph snapshot. The active
20//! set is different: encountering an active positive recursive judgment returns
21//! the coinductive provisional truth needed for the greatest fixpoint, and that
22//! context-dependent answer is deliberately not memoized. This separation is
23//! the crucial invariant—cache only conclusions independent of the current
24//! recursion proof, while making the recursion rule explicit in one place.
25
26use crate::focus::FocusNodes;
27use crate::frozen::FrozenIndexedDataset;
28use crate::path::{PathBackend, pred, succ};
29use crate::profile::ShapeCacheSample;
30use crate::sparql::{SparqlDiagnostic, SparqlExecutor, SparqlViolation};
31use crate::value::{compare_terms, value_type_holds};
32use oxrdf::{Graph, NamedNode, Term};
33use regex::Regex;
34use rustc_hash::{FxHashMap, FxHashSet};
35use serde::{Deserialize, Serialize};
36use shifty_algebra::render::{
37    describe_negation_in, describe_shape_in, negated_class_target_shape, path_to_string_in,
38    shape_to_string_in, xone_alternatives,
39};
40use shifty_algebra::{
41    ConstraintKind, NodeExpr, Path, Prefixes, Schema, Selector, Severity, Shape, ShapeArena,
42    ShapeId, SparqlConstraint,
43};
44use shifty_opt::{FocusSource, PhysicalPlan, analyze};
45use std::cmp::Ordering;
46use std::collections::{BTreeSet, HashMap, HashSet};
47use std::fmt;
48use std::sync::OnceLock;
49
50#[derive(Debug, Clone, Copy)]
51struct EvalResult {
52    holds: bool,
53    cacheable: bool,
54}
55
56/// Keyed by shape first so a probe can borrow the term instead of owning it: a
57/// flat `(ShapeId, Term)` key cannot be looked up without materializing the
58/// tuple, so every hit paid a `Term` clone it then discarded — 75–82% of probes
59/// on the Brick corpus. Hashing is `Fx` rather than SipHash because these keys
60/// are shape ids and terms from an already-parsed graph, never adversarial
61/// input.
62///
63/// Both are modest: together they are ~3–5% of evidence-materialization time
64/// and 4–15% of conformance time. Key handling is *not* where evidence tracing
65/// spends itself — the cost is re-deriving the same `(shape, focus)` conclusion
66/// through many parents, which needs a cache of evidence values, not a cheaper
67/// probe.
68#[derive(Default)]
69struct EvalState {
70    memo: FxHashMap<ShapeId, FxHashMap<Term, bool>>,
71    active: FxHashMap<ShapeId, FxHashSet<Term>>,
72    telemetry: Option<ShapeCacheSample>,
73}
74
75/// Per-graph-snapshot shape evaluator.
76///
77/// Completed checks are shared across statements and focus nodes. Results that
78/// depend on the coinductive recursion back-edge are deliberately not cached:
79/// their provisional `true` may only be valid in the active call context.
80pub(crate) struct ShapeEvaluator<'a> {
81    g: &'a dyn PathBackend,
82    arena: &'a ShapeArena,
83    /// The vocabulary report messages are rendered in. Paired with `arena`
84    /// because every message built here compacts IRIs from it.
85    prefixes: &'a Prefixes,
86    sparql: &'a SparqlExecutor,
87    state: EvalState,
88}
89
90impl<'a> ShapeEvaluator<'a> {
91    pub(crate) fn new(
92        g: &'a dyn PathBackend,
93        arena: &'a ShapeArena,
94        prefixes: &'a Prefixes,
95        sparql: &'a SparqlExecutor,
96    ) -> Self {
97        Self {
98            g,
99            arena,
100            prefixes,
101            sparql,
102            state: EvalState {
103                telemetry: crate::profile::is_enabled().then(ShapeCacheSample::default),
104                ..EvalState::default()
105            },
106        }
107    }
108
109    pub(crate) fn holds(&mut self, node: &Term, id: ShapeId) -> bool {
110        holds_memoized(self.g, self.arena, node, id, self.sparql, &mut self.state).holds
111    }
112
113    pub(crate) fn sparql(&self) -> &SparqlExecutor {
114        self.sparql
115    }
116
117    /// The path-evaluation backend, for sibling folds (e.g. witnessing).
118    pub(crate) fn backend(&self) -> &dyn PathBackend {
119        self.g
120    }
121
122    /// The shape arena, for sibling folds (e.g. witnessing).
123    pub(crate) fn arena(&self) -> &ShapeArena {
124        self.arena
125    }
126}
127
128impl Drop for ShapeEvaluator<'_> {
129    fn drop(&mut self) {
130        let Some(mut sample) = self.state.telemetry else {
131            return;
132        };
133        sample.entries = self.state.memo.values().map(HashMap::len).sum();
134        sample.estimated_bytes = estimated_memo_bytes(&self.state.memo);
135        crate::profile::record_shape_cache(sample);
136    }
137}
138
139/// A single failed atomic constraint.
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141pub struct Reason {
142    /// The node at which the constraint failed (a value node, or the focus).
143    pub value: Term,
144    /// The path from the enclosing focus to `value`, if the failure is
145    /// value-scoped (rendered in `π` notation).
146    pub path: Option<String>,
147    /// The failing constraint's arena slot (cross-references the algebra dump).
148    pub shape: ShapeId,
149    /// The complete algebraic constraint/operator that produced this reason.
150    /// Child references are expressed as [`ShapeId`]s into the same arena, so
151    /// this is lossless together with the schema/plan being validated.
152    #[serde(default = "default_constraint")]
153    pub constraint: Shape,
154    /// Stable semantic kind of [`constraint`](Self::constraint), independent of
155    /// Rust enum variant names and source-language encodings.
156    #[serde(default)]
157    pub constraint_kind: ConstraintKind,
158    /// Stable algebra node id for the originating constraint. This aliases
159    /// [`shape`](Self::shape) but names the field by its semantic role.
160    #[serde(default)]
161    pub constraint_id: ShapeId,
162    /// Stable id of the `(selector, shape)` statement being validated.
163    #[serde(default)]
164    pub statement_id: usize,
165    /// `sh:severity` on the source shape, defaulting to `sh:Violation`.
166    #[serde(default)]
167    pub severity: Severity,
168    /// Engine-generated description of the failure — always present.
169    pub message: String,
170    /// The author's `sh:message` from the source shape, if any (with
171    /// `{$this}`/`{?var}` placeholders resolved). Consumers should prefer this
172    /// over [`message`](Self::message) when set; `message` remains the fallback.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub author_message: Option<String>,
175    /// For a cardinality constraint, how many values along the path satisfied
176    /// the qualifier. The bound this had to meet is already in
177    /// [`constraint`](Self::constraint) (`∃[min..max]`); the observed count is
178    /// the one number a report needs that the algebra does not carry, so a
179    /// renderer can state the shortfall without parsing
180    /// [`message`](Self::message). `None` for every other constraint kind.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub observed_count: Option<u64>,
183    /// Non-empty when this reason is an `sh:or` group: one entry per OR branch
184    /// that failed, so the caller can tell "fix any one of these."
185    #[serde(default, skip_serializing_if = "Vec::is_empty")]
186    pub sub_reasons: Vec<Reason>,
187    /// Present only for a failed `sh:sparql`/custom SPARQL-based constraint
188    /// component: the executed query text, its SHACL bindings, and (if
189    /// natively lowered) the compiled physical plan. `None` for every other
190    /// failed constraint.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub sparql_diagnostic: Option<SparqlDiagnostic>,
193}
194
195fn default_constraint() -> Shape {
196    Shape::Pending
197}
198
199/// One focus node that failed its statement's shape, with the reasons why.
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct Violation {
202    pub focus: Term,
203    /// Index of the violated `(selector, shape)` statement in the schema.
204    pub statement: usize,
205    /// The most severe top-level reason in this grouped finding.
206    pub severity: Severity,
207    pub reasons: Vec<Reason>,
208}
209
210/// The outcome of validating a data graph against a schema.
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212pub struct ValidationOutcome {
213    pub conforms: bool,
214    pub violations: Vec<Violation>,
215}
216
217/// How the engine treats SHACL features it only partially supports (rather than
218/// silently producing a best-effort, possibly wrong, result).
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
220pub enum UnsupportedPolicy {
221    /// Best-effort: run anyway and accept that the result may be unreliable.
222    /// This preserves the historical behavior, so it is the default.
223    #[default]
224    Ignore,
225    /// Fail loudly: refuse to evaluate the unsupported construct so the failure
226    /// surfaces (e.g. as a constraint error) instead of a silent wrong answer.
227    Error,
228}
229
230/// Optional, forward-looking engine configuration. New feature toggles are added
231/// here as fields; both validation ([`ValidationOptions`]) and compiled
232/// inference sessions accept it, so callers configure behavior in one place.
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
234pub struct EngineOptions {
235    /// How to handle partially supported features encountered at run time —
236    /// currently graph-reading `sh:SPARQLFunction` bodies called from a SPARQL
237    /// context (which the engine can only evaluate as pure functions).
238    pub unsupported: UnsupportedPolicy,
239}
240
241/// Controls which retained findings make a validation outcome non-conforming.
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct ValidationOptions {
244    /// Lowest severity that makes `conforms` false. Defaults to `sh:Info`, so
245    /// all findings fail validation as they did before severity was retained.
246    pub minimum_severity: Severity,
247    /// Whether to sort violations by severity, focus node, and statement.
248    /// Defaults to `true` for deterministic output.
249    pub sort_results: bool,
250    /// Optional set of named shapes to use as validation entry points. When
251    /// empty, every target-bearing shape is validated. Dependencies reachable
252    /// from the selected shapes remain available in the arena and are evaluated
253    /// normally when referenced by the selected entries.
254    pub entry_shape_names: Vec<String>,
255    /// Feature-handling policy (see [`EngineOptions`]).
256    pub engine: EngineOptions,
257}
258
259impl Default for ValidationOptions {
260    fn default() -> Self {
261        Self {
262            minimum_severity: Severity::Info,
263            sort_results: true,
264            entry_shape_names: Vec::new(),
265            engine: EngineOptions::default(),
266        }
267    }
268}
269
270fn requested_shape_name_matches(requested: &str, actual: &str) -> bool {
271    let requested = requested.trim();
272    requested == actual
273        || requested
274            .strip_prefix('<')
275            .and_then(|s| s.strip_suffix('>'))
276            .is_some_and(|stripped| stripped == actual)
277}
278
279/// Returns whether a named shape should be treated as a top-level validation
280/// entry under `entry_shape_names`. Empty selection means "all entries".
281pub(crate) fn entry_shape_name_selected(
282    entry_shape_names: &[String],
283    actual_name: Option<&str>,
284) -> bool {
285    entry_shape_names.is_empty()
286        || actual_name.is_some_and(|actual| {
287            entry_shape_names
288                .iter()
289                .any(|requested| requested_shape_name_matches(requested, actual))
290        })
291}
292
293/// Is a shape carrying *any* of `actual_names` selected?
294///
295/// The schema-driven form: normalization collapses several named shapes onto
296/// one slot, so a slot answers to every name that reached it. Checking only one
297/// of them would drop a shape the caller explicitly asked for.
298pub(crate) fn entry_shape_any_name_selected(
299    entry_shape_names: &[String],
300    actual_names: &[String],
301) -> bool {
302    entry_shape_names.is_empty()
303        || actual_names
304            .iter()
305            .any(|actual| entry_shape_name_selected(entry_shape_names, Some(actual)))
306}
307
308fn most_severe(reasons: &[Reason]) -> Severity {
309    reasons
310        .iter()
311        .max_by_key(|reason| reason.severity.rank())
312        .map(|reason| reason.severity.clone())
313        .unwrap_or(Severity::Violation)
314}
315
316fn conforms_at_threshold(violations: &[Violation], minimum: &Severity) -> bool {
317    !violations
318        .iter()
319        .flat_map(|violation| &violation.reasons)
320        .any(|reason| reason.severity.meets(minimum))
321}
322
323fn sort_violations(violations: &mut [Violation], enabled: bool) {
324    if enabled {
325        violations.sort_by(|left, right| {
326            right
327                .severity
328                .rank()
329                .cmp(&left.severity.rank())
330                .then_with(|| left.focus.to_string().cmp(&right.focus.to_string()))
331                .then_with(|| left.statement.cmp(&right.statement))
332        });
333    }
334}
335
336fn stamp_statement_id(reasons: &mut [Reason], statement_id: usize) {
337    for reason in reasons {
338        reason.statement_id = statement_id;
339        stamp_statement_id(&mut reason.sub_reasons, statement_id);
340    }
341}
342
343/// Which RDF graph(s) validation uses for focus discovery and evaluation.
344#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
345pub enum ValidationGraphMode {
346    /// Focus nodes and evaluation both use only the data graph.
347    Data,
348    /// Focus nodes come from data; paths, class hierarchy, and SPARQL use the
349    /// union of data and shapes. This is the default for split graphs.
350    #[default]
351    Union,
352    /// Focus discovery and evaluation both use the full data/shapes union.
353    UnionAll,
354}
355
356/// The schema is not stratifiable: it recurses through genuine negation, so it
357/// has no defined 2-valued semantics (`docs/03-recursion-semantics.md`). We
358/// diagnose rather than guess. Carries the offending shape components.
359#[derive(Debug, Clone, PartialEq, Eq)]
360pub struct NonStratifiable {
361    pub components: Vec<Vec<ShapeId>>,
362}
363
364impl fmt::Display for NonStratifiable {
365    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
366        write!(f, "non-stratifiable schema (recursion through negation): ")?;
367        for (i, c) in self.components.iter().enumerate() {
368            if i > 0 {
369                write!(f, "; ")?;
370            }
371            let ids: Vec<String> = c.iter().map(|s| format!("@{}", s.0)).collect();
372            write!(f, "{{{}}}", ids.join(" "))?;
373        }
374        Ok(())
375    }
376}
377
378impl std::error::Error for NonStratifiable {}
379
380impl NonStratifiable {
381    /// The same message, naming shapes as the document named them.
382    ///
383    /// [`Display`](fmt::Display) has only arena slot ids to work with, so it
384    /// prints `{@0 @1}` — meaningless outside a debugging dump, and the reader
385    /// has no way to map it back to the shape they wrote. Given the schema the
386    /// analysis ran over, a slot can be named: its authored IRI compacted
387    /// against the document's own prefixes, else the blank node it was lowered
388    /// from, else the slot id as a last resort for a shape lowering synthesized.
389    pub fn describe(&self, schema: &Schema) -> String {
390        let mut out = String::from("non-stratifiable schema (recursion through negation): ");
391        for (i, component) in self.components.iter().enumerate() {
392            if i > 0 {
393                out.push_str("; ");
394            }
395            out.push('{');
396            for (j, id) in component.iter().enumerate() {
397                if j > 0 {
398                    // Comma, not space: a synthesized slot's label is several
399                    // words, so space-separated members run together.
400                    out.push_str(", ");
401                }
402                out.push_str(&describe_shape_id(schema, *id));
403            }
404            out.push('}');
405        }
406        out
407    }
408}
409
410fn describe_shape_id(schema: &Schema, id: ShapeId) -> String {
411    if let Some(name) = schema.name_of(id) {
412        return schema.prefixes.compact(name);
413    }
414    match schema.sources.get(&id) {
415        Some(Term::BlankNode(node)) => format!("_:{}", node.as_str()),
416        Some(Term::NamedNode(node)) => schema.prefixes.compact(node.as_str()),
417        // A slot lowering synthesized, with no node of its own in the document.
418        // Its operator is the only thing about it the reader can recognize, and
419        // it is what makes the cycle: `ex:S` through `negation` back to `ex:S`
420        // says where the negation is. The id stays as a suffix so a debugging
421        // dump still lines up with the arena.
422        _ => format!("{} @{}", constraint_kind_label(schema, id), id.0),
423    }
424}
425
426fn constraint_kind_label(schema: &Schema, id: ShapeId) -> &'static str {
427    match ConstraintKind::of(&schema.arena, id) {
428        ConstraintKind::Top => "top",
429        ConstraintKind::Constant => "constant",
430        ConstraintKind::ClassMembership => "class",
431        ConstraintKind::ValueType => "value type",
432        ConstraintKind::NodeKind => "node kind",
433        ConstraintKind::Closed => "closed",
434        ConstraintKind::Equals => "equals",
435        ConstraintKind::Disjoint => "disjoint",
436        ConstraintKind::LessThan => "less than",
437        ConstraintKind::LessThanOrEquals => "less than or equals",
438        ConstraintKind::UniqueLang => "unique lang",
439        ConstraintKind::Negation => "negation",
440        ConstraintKind::Conjunction => "conjunction",
441        ConstraintKind::Disjunction => "disjunction",
442        ConstraintKind::Cardinality => "cardinality",
443        ConstraintKind::Sparql => "sparql",
444        ConstraintKind::Expression => "expression",
445        ConstraintKind::Unknown => "shape",
446    }
447}
448
449/// Validate `data` against `schema`.
450///
451/// Honors the decided recursion semantics (`docs/03-recursion-semantics.md`):
452/// the schema must be **stratifiable** (no recursion through net negation), else
453/// we return [`NonStratifiable`]. For a stratifiable schema all recursion is
454/// net-positive (monotone), and `explain`/`holds`'s "assume conforming on a
455/// back-edge" cycle guard computes exactly the **greatest fixpoint** — the
456/// coinductive validation reading we chose.
457pub fn validate(data: &Graph, schema: &Schema) -> Result<ValidationOutcome, NonStratifiable> {
458    validate_with_options(data, schema, &ValidationOptions::default())
459}
460
461/// Validate `data` against `schema` using an explicit severity policy.
462pub fn validate_with_options(
463    data: &Graph,
464    schema: &Schema,
465    options: &ValidationOptions,
466) -> Result<ValidationOutcome, NonStratifiable> {
467    validate_with_context_and_options(data, data, schema, options)
468}
469
470/// Validate split data and shapes graphs using the selected graph mode.
471pub fn validate_graphs(
472    data: &Graph,
473    shapes: &Graph,
474    schema: &Schema,
475) -> Result<ValidationOutcome, NonStratifiable> {
476    validate_graphs_with_mode_and_options(
477        data,
478        shapes,
479        schema,
480        ValidationGraphMode::default(),
481        &ValidationOptions::default(),
482    )
483}
484
485/// Validate split data and shapes graphs using an explicit graph mode.
486pub fn validate_graphs_with_mode(
487    data: &Graph,
488    shapes: &Graph,
489    schema: &Schema,
490    mode: ValidationGraphMode,
491) -> Result<ValidationOutcome, NonStratifiable> {
492    validate_graphs_with_mode_and_options(data, shapes, schema, mode, &ValidationOptions::default())
493}
494
495/// Validate split graphs using an explicit graph mode and severity policy.
496pub fn validate_graphs_with_mode_and_options(
497    data: &Graph,
498    shapes: &Graph,
499    schema: &Schema,
500    mode: ValidationGraphMode,
501    options: &ValidationOptions,
502) -> Result<ValidationOutcome, NonStratifiable> {
503    match mode {
504        ValidationGraphMode::Data => {
505            let uses_shapes = uses_shapes_graph(&schema.arena);
506            let frozen = if uses_shapes {
507                FrozenIndexedDataset::from_graphs(data, shapes)
508            } else {
509                FrozenIndexedDataset::from_graph(data)
510            };
511            validate_with_frozen(data, schema, frozen, uses_shapes, options)
512        }
513        ValidationGraphMode::Union => {
514            let uses_shapes = uses_shapes_graph(&schema.arena);
515            let frozen = if uses_shapes {
516                FrozenIndexedDataset::from_graph_union_with_shapes(data, shapes)
517            } else {
518                FrozenIndexedDataset::from_graph_union(data, shapes)
519            };
520            validate_with_frozen(data, schema, frozen, uses_shapes, options)
521        }
522        ValidationGraphMode::UnionAll => {
523            let union = graph_union(data, shapes);
524            validate_with_context_and_options(&union, &union, schema, options)
525        }
526    }
527}
528
529/// Validate focus nodes from `data` while evaluating paths, class hierarchy,
530/// and SPARQL against `context`. For split data/shapes inputs, `context` should
531/// be their RDF union.
532pub fn validate_with_context(
533    data: &Graph,
534    context: &Graph,
535    schema: &Schema,
536) -> Result<ValidationOutcome, NonStratifiable> {
537    validate_with_context_and_options(data, context, schema, &ValidationOptions::default())
538}
539
540/// Validate with separate focus/context graphs and an explicit severity policy.
541pub fn validate_with_context_and_options(
542    data: &Graph,
543    context: &Graph,
544    schema: &Schema,
545    options: &ValidationOptions,
546) -> Result<ValidationOutcome, NonStratifiable> {
547    let uses_shapes = uses_shapes_graph(&schema.arena);
548    let frozen = if uses_shapes {
549        FrozenIndexedDataset::from_graphs(context, context)
550    } else {
551        FrozenIndexedDataset::from_graph(context)
552    };
553    validate_with_frozen(data, schema, frozen, uses_shapes, options)
554}
555
556fn validate_with_frozen(
557    data: &Graph,
558    schema: &Schema,
559    frozen: FrozenIndexedDataset,
560    has_shapes_graph: bool,
561    options: &ValidationOptions,
562) -> Result<ValidationOutcome, NonStratifiable> {
563    let strat = analyze(&schema.arena);
564    if !strat.stratifiable {
565        let components = strat
566            .strata
567            .iter()
568            .filter(|s| !s.stratifiable)
569            .map(|s| s.shapes.clone())
570            .collect();
571        return Err(NonStratifiable { components });
572    }
573
574    let sparql = SparqlExecutor::from_frozen(frozen, has_shapes_graph);
575    Ok(validate_with_executor(data, schema, &sparql, options))
576}
577
578pub(crate) fn validate_with_executor(
579    data: &dyn FocusNodes,
580    schema: &Schema,
581    sparql: &SparqlExecutor,
582    options: &ValidationOptions,
583) -> ValidationOutcome {
584    validate_with_executor_selected(data, schema, sparql, options, None)
585}
586
587pub(crate) fn validate_with_executor_selected(
588    data: &dyn FocusNodes,
589    schema: &Schema,
590    sparql: &SparqlExecutor,
591    options: &ValidationOptions,
592    selected: Option<&[bool]>,
593) -> ValidationOutcome {
594    let entries = schema.statements.iter().enumerate().filter_map(|(i, st)| {
595        if selected.is_some_and(|selected| !selected[i])
596            || (selected.is_none()
597                && !entry_shape_any_name_selected(
598                    &options.entry_shape_names,
599                    schema.names_of(st.shape),
600                ))
601        {
602            return None;
603        }
604        let label = schema
605            .name_of(st.shape)
606            .map(str::to_string)
607            .unwrap_or_else(|| format!("@{}", st.shape.0));
608        Some((i, st.shape, &st.selector, label))
609    });
610    validate_entries(
611        data,
612        &schema.arena,
613        &schema.prefixes,
614        sparql,
615        options,
616        entries,
617        focus_nodes_with_evaluator,
618    )
619}
620
621fn validate_entries<'a, S: 'a>(
622    data: &dyn FocusNodes,
623    arena: &ShapeArena,
624    prefixes: &Prefixes,
625    sparql: &SparqlExecutor,
626    options: &ValidationOptions,
627    entries: impl IntoIterator<Item = (usize, ShapeId, &'a S, String)>,
628    focus: impl Fn(&dyn FocusNodes, &S, &mut ShapeEvaluator<'_>) -> Vec<Term>,
629) -> ValidationOutcome {
630    let backend = sparql
631        .frozen()
632        .expect("validation executor always has a frozen dataset");
633    let mut evaluator = ShapeEvaluator::new(backend, arena, prefixes, sparql);
634    let mut violations = Vec::new();
635    for (statement, shape, source, label) in entries {
636        let foci = focus(data, source, &mut evaluator);
637        prefetch_sparql_constraints(arena, shape, &foci, sparql);
638        for focus in foci {
639            let start = web_time::Instant::now();
640            let mut stack = HashSet::new();
641            let mut reasons = explain(
642                &mut evaluator,
643                &focus,
644                shape,
645                None,
646                &Severity::Violation,
647                &mut stack,
648            );
649            crate::profile::record_shape(&label, start.elapsed().as_micros() as u64);
650            dedup_reasons(&mut reasons);
651            stamp_statement_id(&mut reasons, statement);
652            if !reasons.is_empty() {
653                let severity = most_severe(&reasons);
654                violations.push(Violation {
655                    focus,
656                    statement,
657                    severity,
658                    reasons,
659                });
660            }
661        }
662    }
663    sort_violations(&mut violations, options.sort_results);
664    ValidationOutcome {
665        conforms: conforms_at_threshold(&violations, &options.minimum_severity),
666        violations,
667    }
668}
669
670/// Whether any `sh:sparql` constraint references `$shapesGraph`, requiring the
671/// shapes graph to be mirrored into a named graph for evaluation.
672pub(crate) fn uses_shapes_graph(arena: &ShapeArena) -> bool {
673    (0..arena.len()).any(|i| {
674        matches!(arena.get(ShapeId(i as u32)), Shape::Sparql(c) if c.query.contains("shapesGraph"))
675    })
676}
677
678/// The RDF merge of two graphs (`left ∪ right`). The standard way to build the
679/// `context` graph the `*_with_context` repair entry points expect: the union of
680/// a data graph and a shapes/ontology graph.
681///
682/// Cloning a graph copies its indexes wholesale, while inserting builds them a
683/// triple at a time — so the larger side is always the one to clone, and only
684/// the smaller side is inserted. Set union is commutative, so the result is
685/// identical either way. Callers pass `(data, shapes)`, and a shapes closure
686/// routinely dwarfs the data graph (228k triples against 16 for a small Brick
687/// model), which made the naive direction quadratically the wrong choice.
688pub fn graph_union(left: &Graph, right: &Graph) -> Graph {
689    let (base, extra) = if left.len() >= right.len() {
690        (left, right)
691    } else {
692        (right, left)
693    };
694    let mut union = base.clone();
695    for triple in extra.iter() {
696        union.insert(triple);
697    }
698    crate::profile::record_graph_union(union.len());
699    union
700}
701
702/// Validate using a [`PhysicalPlan`] (Layer 5): focus nodes come from compiled
703/// [`FocusSource`]s (so class targets seed backward from the constant instead of
704/// scanning every node) and checks run over the plan's cost-ordered arena. The
705/// result is identical to [`validate`] on the same schema — the W3C harness
706/// cross-checks this.
707pub fn validate_plan(
708    data: &Graph,
709    plan: &PhysicalPlan,
710) -> Result<ValidationOutcome, NonStratifiable> {
711    validate_plan_with_options(data, plan, &ValidationOptions::default())
712}
713
714/// Validate a physical plan using an explicit severity policy.
715pub fn validate_plan_with_options(
716    data: &Graph,
717    plan: &PhysicalPlan,
718    options: &ValidationOptions,
719) -> Result<ValidationOutcome, NonStratifiable> {
720    validate_plan_with_context_and_options(data, data, plan, options)
721}
722
723/// Validate a physical plan over split graphs using the default graph mode.
724pub fn validate_plan_graphs(
725    data: &Graph,
726    shapes: &Graph,
727    plan: &PhysicalPlan,
728) -> Result<ValidationOutcome, NonStratifiable> {
729    validate_plan_graphs_with_mode_and_options(
730        data,
731        shapes,
732        plan,
733        ValidationGraphMode::default(),
734        &ValidationOptions::default(),
735    )
736}
737
738/// Validate a physical plan over split graphs using an explicit graph mode.
739pub fn validate_plan_graphs_with_mode(
740    data: &Graph,
741    shapes: &Graph,
742    plan: &PhysicalPlan,
743    mode: ValidationGraphMode,
744) -> Result<ValidationOutcome, NonStratifiable> {
745    validate_plan_graphs_with_mode_and_options(
746        data,
747        shapes,
748        plan,
749        mode,
750        &ValidationOptions::default(),
751    )
752}
753
754/// Validate a physical plan over split graphs with an explicit severity policy.
755pub fn validate_plan_graphs_with_mode_and_options(
756    data: &Graph,
757    shapes: &Graph,
758    plan: &PhysicalPlan,
759    mode: ValidationGraphMode,
760    options: &ValidationOptions,
761) -> Result<ValidationOutcome, NonStratifiable> {
762    match mode {
763        ValidationGraphMode::Data => {
764            let uses_shapes = uses_shapes_graph(&plan.arena);
765            let frozen = if uses_shapes {
766                FrozenIndexedDataset::from_graphs(data, shapes)
767            } else {
768                FrozenIndexedDataset::from_graph(data)
769            };
770            validate_plan_with_frozen(data, plan, frozen, uses_shapes, options)
771        }
772        ValidationGraphMode::Union => {
773            let uses_shapes = uses_shapes_graph(&plan.arena);
774            let frozen = if uses_shapes {
775                FrozenIndexedDataset::from_graph_union_with_shapes(data, shapes)
776            } else {
777                FrozenIndexedDataset::from_graph_union(data, shapes)
778            };
779            validate_plan_with_frozen(data, plan, frozen, uses_shapes, options)
780        }
781        ValidationGraphMode::UnionAll => {
782            let union = graph_union(data, shapes);
783            validate_plan_with_context_and_options(&union, &union, plan, options)
784        }
785    }
786}
787
788/// Validate plan focus nodes from `data` against the supplied execution context.
789pub fn validate_plan_with_context(
790    data: &Graph,
791    context: &Graph,
792    plan: &PhysicalPlan,
793) -> Result<ValidationOutcome, NonStratifiable> {
794    validate_plan_with_context_and_options(data, context, plan, &ValidationOptions::default())
795}
796
797/// Validate plan focus nodes against a context with a severity policy.
798pub fn validate_plan_with_context_and_options(
799    data: &Graph,
800    context: &Graph,
801    plan: &PhysicalPlan,
802    options: &ValidationOptions,
803) -> Result<ValidationOutcome, NonStratifiable> {
804    let uses_shapes = uses_shapes_graph(&plan.arena);
805    let frozen = if uses_shapes {
806        FrozenIndexedDataset::from_graphs(context, context)
807    } else {
808        FrozenIndexedDataset::from_graph(context)
809    };
810    validate_plan_with_frozen(data, plan, frozen, uses_shapes, options)
811}
812
813fn validate_plan_with_frozen(
814    data: &Graph,
815    plan: &PhysicalPlan,
816    frozen: FrozenIndexedDataset,
817    has_shapes_graph: bool,
818    options: &ValidationOptions,
819) -> Result<ValidationOutcome, NonStratifiable> {
820    let strat = analyze(&plan.arena);
821    if !strat.stratifiable {
822        let components = strat
823            .strata
824            .iter()
825            .filter(|s| !s.stratifiable)
826            .map(|s| s.shapes.clone())
827            .collect();
828        return Err(NonStratifiable { components });
829    }
830
831    let sparql = SparqlExecutor::from_frozen(frozen, has_shapes_graph);
832    Ok(validate_plan_with_executor(
833        data, plan, &sparql, options, None,
834    ))
835}
836
837pub(crate) fn validate_plan_with_executor(
838    data: &dyn FocusNodes,
839    plan: &PhysicalPlan,
840    sparql: &SparqlExecutor,
841    options: &ValidationOptions,
842    selected: Option<&[bool]>,
843) -> ValidationOutcome {
844    let entries = plan.statements.iter().enumerate().filter_map(|(i, sp)| {
845        if selected.is_some_and(|selected| !selected[i])
846            || (selected.is_none()
847                && !entry_shape_any_name_selected(
848                    &options.entry_shape_names,
849                    plan.names_of(sp.shape),
850                ))
851        {
852            return None;
853        }
854        let label = plan
855            .name_of(sp.shape)
856            .map(str::to_string)
857            .unwrap_or_else(|| format!("@{}", sp.shape.0));
858        Some((i, sp.shape, &sp.source, label))
859    });
860    validate_entries(
861        data,
862        &plan.arena,
863        &plan.prefixes,
864        sparql,
865        options,
866        entries,
867        focus_for_source,
868    )
869}
870
871/// Focus nodes for a compiled [`FocusSource`].
872fn focus_for_source(
873    data: &dyn FocusNodes,
874    source: &FocusSource,
875    evaluator: &mut ShapeEvaluator<'_>,
876) -> Vec<Term> {
877    match source {
878        FocusSource::SubjectsOf(p) => data.subjects_of(p),
879        FocusSource::ObjectsOf(p) => data.objects_of(p),
880        FocusSource::Node(c) => vec![c.clone()],
881        // the optimization: seed backward from the constant, no full scan
882        FocusSource::PathToConst { path, target } => pred(evaluator.g, target, path)
883            .into_iter()
884            .filter(|node| data.contains_term(node))
885            .collect(),
886        FocusSource::ScanFilter { path, qualifier } => data
887            .all_nodes()
888            .into_iter()
889            .filter(|v| {
890                succ(evaluator.g, v, path)
891                    .iter()
892                    .any(|u| evaluator.holds(u, *qualifier))
893            })
894            .collect(),
895        FocusSource::Sparql(target) => {
896            let candidates = data.all_nodes();
897            evaluator
898                .sparql
899                .target_nodes(&target.query)
900                .unwrap_or_default()
901                .into_iter()
902                .filter(|node| candidates.contains(node))
903                .collect()
904        }
905    }
906}
907
908/// The focus nodes selected by a selector.
909pub fn focus_nodes(data: &Graph, sel: &Selector, arena: &ShapeArena) -> Vec<Term> {
910    let sparql =
911        SparqlExecutor::new(data).expect("building an in-memory Oxigraph store should succeed");
912    let mut evaluator = ShapeEvaluator::new(data, arena, Prefixes::empty(), &sparql);
913    focus_nodes_with_evaluator(data, sel, &mut evaluator)
914}
915
916pub(crate) fn focus_nodes_with(
917    data: &dyn FocusNodes,
918    backend: &dyn PathBackend,
919    sel: &Selector,
920    arena: &ShapeArena,
921    sparql: &SparqlExecutor,
922) -> Vec<Term> {
923    let mut evaluator = ShapeEvaluator::new(backend, arena, Prefixes::empty(), sparql);
924    focus_nodes_with_evaluator(data, sel, &mut evaluator)
925}
926
927pub(crate) fn focus_nodes_with_evaluator(
928    data: &dyn FocusNodes,
929    sel: &Selector,
930    evaluator: &mut ShapeEvaluator<'_>,
931) -> Vec<Term> {
932    match sel {
933        Selector::HasOut(q) => data.subjects_of(q),
934        Selector::HasIn(q) => data.objects_of(q),
935        Selector::IsConst(c) => vec![c.clone()],
936        Selector::HasPath(path, qual) => match evaluator.arena.get(*qual) {
937            // Class targets are lowered to
938            // rdf:type/rdfs:subClassOf* ending at a constant. Searching
939            // backward from that constant avoids traversing the hierarchy once
940            // for every node in the data graph.
941            Shape::TestConst(target) => pred(evaluator.g, target, path)
942                .into_iter()
943                .filter(|node| data.contains_term(node))
944                .collect(),
945            _ => data
946                .all_nodes()
947                .into_iter()
948                .filter(|v| {
949                    succ(evaluator.g, v, path)
950                        .iter()
951                        .any(|u| evaluator.holds(u, *qual))
952                })
953                .collect(),
954        },
955        Selector::Sparql(target) => {
956            let candidates = data.all_nodes();
957            evaluator
958                .sparql
959                .target_nodes(&target.query)
960                .unwrap_or_default()
961                .into_iter()
962                .filter(|node| candidates.contains(node))
963                .collect()
964        }
965    }
966}
967
968fn holds_memoized(
969    g: &dyn PathBackend,
970    arena: &ShapeArena,
971    v: &Term,
972    id: ShapeId,
973    sparql: &SparqlExecutor,
974    state: &mut EvalState,
975) -> EvalResult {
976    if let Some(&holds) = state.memo.get(&id).and_then(|by_term| by_term.get(v)) {
977        if let Some(telemetry) = state.telemetry.as_mut() {
978            telemetry.hits += 1;
979        }
980        return EvalResult {
981            holds,
982            cacheable: true,
983        };
984    }
985    if let Some(telemetry) = state.telemetry.as_mut() {
986        telemetry.misses += 1;
987    }
988    if !state.active.entry(id).or_default().insert(v.clone()) {
989        if let Some(telemetry) = state.telemetry.as_mut() {
990            telemetry.recursion_back_edges += 1;
991        }
992        return EvalResult {
993            holds: true,
994            cacheable: false,
995        }; // back-edge ⇒ assume conforming: the gfp choice (doc 03)
996    }
997    let result = match arena.get(id) {
998        Shape::Annotated { shape, .. } => holds_memoized(g, arena, v, *shape, sparql, state),
999        Shape::Top | Shape::Pending => EvalResult {
1000            holds: true,
1001            cacheable: true,
1002        },
1003        Shape::Sparql(constraint) => EvalResult {
1004            holds: sparql
1005                .constraint_violations(constraint, v)
1006                .is_ok_and(|violations| violations.is_empty()),
1007            cacheable: true,
1008        },
1009        Shape::Expression(expr) => {
1010            // SHACL-AF §5: conform iff every value the expression produces (with
1011            // `v` as `?this`) is the boolean `true`.
1012            let mut cacheable = true;
1013            let results = eval_expr(g, arena, v, expr, sparql, state, &mut cacheable);
1014            EvalResult {
1015                holds: results.iter().all(is_boolean_true),
1016                cacheable,
1017            }
1018        }
1019        Shape::TestConst(c) => EvalResult {
1020            holds: v == c,
1021            cacheable: true,
1022        },
1023        Shape::TestType(t) => EvalResult {
1024            holds: value_type_holds(t, v),
1025            cacheable: true,
1026        },
1027        Shape::TestKind(k) => EvalResult {
1028            holds: k.matches(v),
1029            cacheable: true,
1030        },
1031        Shape::Closed(q) => EvalResult {
1032            holds: closed_offenders(g, v, q).is_empty(),
1033            cacheable: true,
1034        },
1035        Shape::Eq(path, p) => EvalResult {
1036            holds: succ(g, v, path) == objects(g, v, p),
1037            cacheable: true,
1038        },
1039        Shape::Disj(path, p) => EvalResult {
1040            holds: succ(g, v, path).is_disjoint(&objects(g, v, p)),
1041            cacheable: true,
1042        },
1043        Shape::Lt(path, p) => EvalResult {
1044            holds: all_pairs_ordered(g, v, path, p, false),
1045            cacheable: true,
1046        },
1047        Shape::Le(path, p) => EvalResult {
1048            holds: all_pairs_ordered(g, v, path, p, true),
1049            cacheable: true,
1050        },
1051        Shape::UniqueLang(path) => EvalResult {
1052            holds: unique_lang(&succ(g, v, path)),
1053            cacheable: true,
1054        },
1055        Shape::Not(c) => {
1056            let child = holds_memoized(g, arena, v, *c, sparql, state);
1057            EvalResult {
1058                holds: !child.holds,
1059                cacheable: child.cacheable,
1060            }
1061        }
1062        Shape::And(cs) => {
1063            let mut result = EvalResult {
1064                holds: true,
1065                cacheable: true,
1066            };
1067            for child in cs {
1068                let child = holds_memoized(g, arena, v, *child, sparql, state);
1069                result.cacheable &= child.cacheable;
1070                if !child.holds {
1071                    result.holds = false;
1072                    break;
1073                }
1074            }
1075            result
1076        }
1077        Shape::Or(cs) => {
1078            let mut result = EvalResult {
1079                holds: false,
1080                cacheable: true,
1081            };
1082            for child in cs {
1083                let child = holds_memoized(g, arena, v, *child, sparql, state);
1084                result.cacheable &= child.cacheable;
1085                if child.holds {
1086                    result.holds = true;
1087                    break;
1088                }
1089            }
1090            result
1091        }
1092        Shape::Count {
1093            path,
1094            min,
1095            max,
1096            qualifier,
1097        } => {
1098            let mut n = 0;
1099            let mut cacheable = true;
1100            for value in succ(g, v, path) {
1101                let qualified = holds_memoized(g, arena, &value, *qualifier, sparql, state);
1102                cacheable &= qualified.cacheable;
1103                n += u64::from(qualified.holds);
1104            }
1105            EvalResult {
1106                holds: min.is_none_or(|m| n >= m) && max.is_none_or(|m| n <= m),
1107                cacheable,
1108            }
1109        }
1110    };
1111    // Reclaim the term the active set owns rather than cloning `v` again: the
1112    // miss path then allocates once, not twice.
1113    let owned = state.active.get_mut(&id).and_then(|set| set.take(v));
1114    if result.cacheable {
1115        let key = owned.unwrap_or_else(|| v.clone());
1116        state.memo.entry(id).or_default().insert(key, result.holds);
1117        if let Some(telemetry) = state.telemetry.as_mut() {
1118            telemetry.insertions += 1;
1119        }
1120    } else if let Some(telemetry) = state.telemetry.as_mut() {
1121        telemetry.non_cacheable_results += 1;
1122    }
1123    result
1124}
1125
1126/// Evaluate a SHACL-AF node expression at focus `v` to its set of result terms,
1127/// over the same low-level primitives as [`holds_memoized`]. `cacheable` is
1128/// cleared if any nested shape evaluation observed a recursion back-edge, so the
1129/// enclosing `Shape::Expression` result is not memoized on a provisional truth.
1130/// `Function` applications are refused at parse time, so they never appear here.
1131fn eval_expr(
1132    g: &dyn PathBackend,
1133    arena: &ShapeArena,
1134    v: &Term,
1135    expr: &NodeExpr,
1136    sparql: &SparqlExecutor,
1137    state: &mut EvalState,
1138    cacheable: &mut bool,
1139) -> HashSet<Term> {
1140    match expr {
1141        NodeExpr::This => {
1142            let mut s = HashSet::with_capacity(1);
1143            s.insert(v.clone());
1144            s
1145        }
1146        NodeExpr::Constant(t) => {
1147            let mut s = HashSet::with_capacity(1);
1148            s.insert(t.clone());
1149            s
1150        }
1151        NodeExpr::Path(p) => succ(g, v, p),
1152        NodeExpr::Filter { input, shape } => {
1153            let inputs = eval_expr(g, arena, v, input, sparql, state, cacheable);
1154            inputs
1155                .into_iter()
1156                .filter(|x| {
1157                    let r = holds_memoized(g, arena, x, *shape, sparql, state);
1158                    *cacheable &= r.cacheable;
1159                    r.holds
1160                })
1161                .collect()
1162        }
1163        NodeExpr::Intersection(es) => {
1164            let mut iter = es.iter();
1165            match iter.next() {
1166                Some(first) => {
1167                    let mut acc = eval_expr(g, arena, v, first, sparql, state, cacheable);
1168                    for e in iter {
1169                        let s = eval_expr(g, arena, v, e, sparql, state, cacheable);
1170                        acc.retain(|x| s.contains(x));
1171                    }
1172                    acc
1173                }
1174                None => HashSet::new(),
1175            }
1176        }
1177        NodeExpr::Union(es) => {
1178            let mut acc = HashSet::new();
1179            for e in es {
1180                acc.extend(eval_expr(g, arena, v, e, sparql, state, cacheable));
1181            }
1182            acc
1183        }
1184        NodeExpr::Function { .. } => HashSet::new(),
1185    }
1186}
1187
1188/// The boolean literal `true` (`"true"^^xsd:boolean`).
1189pub(crate) fn is_boolean_true(t: &Term) -> bool {
1190    matches!(t, Term::Literal(l) if l.datatype() == oxrdf::vocab::xsd::BOOLEAN && l.value() == "true")
1191}
1192
1193fn estimated_memo_bytes(memo: &FxHashMap<ShapeId, FxHashMap<Term, bool>>) -> usize {
1194    const CONTROL_BYTE_ESTIMATE: usize = 1;
1195    const ENTRY_BYTES: usize = std::mem::size_of::<(Term, bool)>() + CONTROL_BYTE_ESTIMATE;
1196    memo.values()
1197        .map(|by_term| {
1198            by_term.capacity() * ENTRY_BYTES
1199                + by_term.keys().map(estimated_term_heap_bytes).sum::<usize>()
1200        })
1201        .sum::<usize>()
1202        + memo.capacity()
1203            * (std::mem::size_of::<(ShapeId, FxHashMap<Term, bool>)>() + CONTROL_BYTE_ESTIMATE)
1204}
1205
1206fn estimated_term_heap_bytes(term: &Term) -> usize {
1207    match term {
1208        Term::NamedNode(node) => node.as_str().len(),
1209        Term::BlankNode(node) => node.as_str().len(),
1210        Term::Literal(literal) => {
1211            literal.value().len()
1212                + literal.language().map_or_else(
1213                    || {
1214                        let datatype = literal.datatype();
1215                        if datatype.as_str() == "http://www.w3.org/2001/XMLSchema#string" {
1216                            0
1217                        } else {
1218                            datatype.as_str().len()
1219                        }
1220                    },
1221                    str::len,
1222                )
1223        }
1224    }
1225}
1226
1227/// Batch-evaluate the SPARQL constraints reachable from `root` at the focus set
1228/// before the per-node walk, so their fallback queries run once for the whole
1229/// focus set instead of once per focus (doc §189). Only constraints reached through
1230/// focus-preserving operators (`∧`/`∨`/`¬`) are evaluated at `foci`; constraints
1231/// under a `Count` path apply to value nodes, not the statement focus, so they
1232/// are skipped here and fall back to per-focus execution. Prefetching is a pure
1233/// memo (constraint violations depend only on focus + immutable dataset), so it
1234/// is sound regardless of the operator context the constraint is reached in.
1235pub(crate) fn prefetch_sparql_constraints(
1236    arena: &ShapeArena,
1237    root: ShapeId,
1238    foci: &[Term],
1239    sparql: &SparqlExecutor,
1240) {
1241    if foci.len() < 2 {
1242        return;
1243    }
1244    let mut constraints = Vec::new();
1245    let mut seen = HashSet::new();
1246    collect_focus_sparql(arena, root, &mut seen, &mut constraints);
1247    for constraint in constraints {
1248        let _ = sparql.prefetch_constraint(constraint, foci);
1249    }
1250}
1251
1252fn collect_focus_sparql<'a>(
1253    arena: &'a ShapeArena,
1254    id: ShapeId,
1255    seen: &mut HashSet<ShapeId>,
1256    out: &mut Vec<&'a SparqlConstraint>,
1257) {
1258    if !seen.insert(id) {
1259        return; // cyclic (recursive) shape: stop at the back-edge
1260    }
1261    match arena.get(id) {
1262        Shape::Annotated { shape, .. } => collect_focus_sparql(arena, *shape, seen, out),
1263        Shape::Sparql(constraint) => out.push(constraint),
1264        Shape::Not(inner) => collect_focus_sparql(arena, *inner, seen, out),
1265        Shape::And(ids) | Shape::Or(ids) => {
1266            for &child in ids {
1267                collect_focus_sparql(arena, child, seen, out);
1268            }
1269        }
1270        // `Count` crosses a path (different focus); all other variants are leaves.
1271        _ => {}
1272    }
1273}
1274
1275/// The reasons `φ` (slot `id`) fails at `node`. Empty iff it holds. `path_ctx`
1276/// is the rendered path by which `node` was reached from the enclosing focus.
1277fn explain(
1278    evaluator: &mut ShapeEvaluator<'_>,
1279    node: &Term,
1280    id: ShapeId,
1281    path_ctx: Option<&str>,
1282    severity: &Severity,
1283    stack: &mut HashSet<(ShapeId, Term)>,
1284) -> Vec<Reason> {
1285    let key = (id, node.clone());
1286    if !stack.insert(key.clone()) {
1287        return Vec::new(); // back-edge ⇒ assume conforming (gfp, doc 03)
1288    }
1289    if evaluator.holds(node, id) {
1290        stack.remove(&key);
1291        return Vec::new();
1292    }
1293    let reasons = match evaluator.arena.get(id).clone() {
1294        Shape::Annotated {
1295            severity: source_severity,
1296            messages,
1297            shape,
1298        } => {
1299            let mut reasons = explain(evaluator, node, shape, path_ctx, &source_severity, stack);
1300            if !messages.is_empty() {
1301                // Resolve the author's `sh:message` once at this source-shape
1302                // boundary (`$this` = the node this shape validates) and stamp it
1303                // onto any reason that doesn't already carry a nearer one. Since
1304                // the deepest `Annotated` returns first, the innermost (most
1305                // specific) message wins — outer shapes only fill the gaps.
1306                let author = messages
1307                    .iter()
1308                    .map(|m| apply_message_template(&term_text(m), node, &HashMap::new()))
1309                    .collect::<Vec<_>>()
1310                    .join("; ");
1311                for r in &mut reasons {
1312                    if r.author_message.is_none() {
1313                        r.author_message = Some(author.clone());
1314                    }
1315                }
1316            }
1317            reasons
1318        }
1319        Shape::Top | Shape::Pending => Vec::new(),
1320        Shape::Sparql(constraint) => {
1321            match evaluator.sparql.constraint_violations(&constraint, node) {
1322                Ok(violations) if violations.is_empty() => Vec::new(),
1323                Ok(violations) => {
1324                    // Same (constraint, node) for every reason below, so build the
1325                    // diagnostic once rather than per violation; `.ok()` because a
1326                    // second, redundant compile can't fail once the call above
1327                    // already succeeded.
1328                    let diagnostic = evaluator
1329                        .sparql
1330                        .constraint_diagnostic(&constraint, node, &violations)
1331                        .ok();
1332                    violations
1333                        .into_iter()
1334                        .map(|violation| {
1335                            // Compute the message before the value/path fields are
1336                            // moved out of `violation`.
1337                            let message = sparql_violation_message(&violation, &constraint, node);
1338                            reason(
1339                                evaluator.arena,
1340                                id,
1341                                violation.value.unwrap_or_else(|| node.clone()),
1342                                violation
1343                                    .path
1344                                    .map(|path| path.to_string())
1345                                    .or_else(|| path_ctx.map(str::to_string))
1346                                    .or_else(|| {
1347                                        constraint
1348                                            .path
1349                                            .as_ref()
1350                                            .map(|p| path_to_string_in(p, evaluator.prefixes))
1351                                    }),
1352                                severity,
1353                                message,
1354                                None,
1355                                Vec::new(),
1356                                diagnostic.clone(),
1357                            )
1358                        })
1359                        .collect()
1360                }
1361                Err(error) => vec![reason(
1362                    evaluator.arena,
1363                    id,
1364                    node.clone(),
1365                    path_ctx.map(str::to_string),
1366                    severity,
1367                    format!("SPARQL constraint evaluation failed: {error}"),
1368                    None,
1369                    Vec::new(),
1370                    evaluator
1371                        .sparql
1372                        .constraint_diagnostic(&constraint, node, &[])
1373                        .ok(),
1374                )],
1375            }
1376        }
1377        Shape::TestConst(_)
1378        | Shape::TestType(_)
1379        | Shape::TestKind(_)
1380        | Shape::Eq(..)
1381        | Shape::Disj(..)
1382        | Shape::Lt(..)
1383        | Shape::Le(..)
1384        | Shape::UniqueLang(_) => leaf(
1385            evaluator.arena,
1386            evaluator.holds(node, id),
1387            node,
1388            id,
1389            path_ctx,
1390            severity,
1391            format!(
1392                "{} not satisfied",
1393                shape_to_string_in(evaluator.arena, id, evaluator.prefixes)
1394            ),
1395        ),
1396        Shape::Closed(q) => {
1397            let bad = closed_offenders(evaluator.g, node, &q);
1398            if bad.is_empty() {
1399                Vec::new()
1400            } else {
1401                // The offending predicates are IRIs like any others in a
1402                // message, so they compact against the document's vocabulary.
1403                let preds: Vec<String> = bad
1404                    .iter()
1405                    .map(|p| evaluator.prefixes.compact(p.as_str()))
1406                    .collect();
1407                vec![reason(
1408                    evaluator.arena,
1409                    id,
1410                    node.clone(),
1411                    path_ctx.map(str::to_string),
1412                    severity,
1413                    format!("closed: unexpected predicate(s) {}", preds.join(", ")),
1414                    None,
1415                    Vec::new(),
1416                    None,
1417                )]
1418            }
1419        }
1420        Shape::Not(c) => {
1421            if explain(evaluator, node, c, path_ctx, severity, stack).is_empty() {
1422                vec![reason(
1423                    evaluator.arena,
1424                    id,
1425                    node.clone(),
1426                    path_ctx.map(str::to_string),
1427                    severity,
1428                    format!(
1429                        "must satisfy `{}`",
1430                        describe_negation_in(evaluator.arena, c, evaluator.prefixes)
1431                    ),
1432                    None,
1433                    Vec::new(),
1434                    None,
1435                )]
1436            } else {
1437                Vec::new()
1438            }
1439        }
1440        Shape::And(cs) => cs
1441            .iter()
1442            .flat_map(|c| explain(evaluator, node, *c, path_ctx, severity, stack))
1443            .collect(),
1444        Shape::Or(cs) => {
1445            let mut sub_reasons = Vec::new();
1446            let mut satisfied = false;
1447            for c in &cs {
1448                let sub = explain(evaluator, node, *c, path_ctx, severity, stack);
1449                if sub.is_empty() {
1450                    satisfied = true;
1451                    break;
1452                }
1453                sub_reasons.extend(sub);
1454            }
1455            if satisfied {
1456                return Vec::new();
1457            }
1458            // `sh:xone` is lowered to `⋁ᵢ (φᵢ ∧ ⋀_{j≠i} ¬φⱼ)`. Reported as the
1459            // plain disjunction it is, a node satisfying *two* alternatives —
1460            // the usual way to fail a xone — gets told that none were satisfied,
1461            // which is the opposite of the finding. Count what actually holds.
1462            if let Some(alternatives) = xone_alternatives(id, evaluator.arena) {
1463                let held: Vec<ShapeId> = alternatives
1464                    .iter()
1465                    .copied()
1466                    .filter(|a| evaluator.holds(node, *a))
1467                    .collect();
1468                let total = alternatives.len();
1469                // Naming the ones that hold is the whole of the fix: the reader
1470                // has to drop all but one of them, and "2 of 3" does not say
1471                // which two.
1472                let describe_all = |ids: &[ShapeId]| {
1473                    ids.iter()
1474                        .map(|a| describe_shape_in(evaluator.arena, *a, evaluator.prefixes))
1475                        .collect::<Vec<_>>()
1476                        .join(", ")
1477                };
1478                let message = if held.is_empty() {
1479                    format!("none of the {total} alternatives hold; exactly one must")
1480                } else {
1481                    // Both sides: which to drop, and which is left to keep.
1482                    let idle: Vec<ShapeId> = alternatives
1483                        .iter()
1484                        .copied()
1485                        .filter(|a| !held.contains(a))
1486                        .collect();
1487                    let mut message = format!(
1488                        "exactly one alternative may hold; {} of {total} do — holds: {}",
1489                        held.len(),
1490                        describe_all(&held)
1491                    );
1492                    if !idle.is_empty() {
1493                        message.push_str(&format!("; does not hold: {}", describe_all(&idle)));
1494                    }
1495                    message
1496                };
1497                // The branch sub-reasons explain the rewrite, not the shape the
1498                // author wrote, and "fix any one of these" is the wrong advice
1499                // for a node that already satisfies too many.
1500                let sub_reasons = if held.is_empty() {
1501                    sub_reasons
1502                } else {
1503                    Vec::new()
1504                };
1505                return vec![reason(
1506                    evaluator.arena,
1507                    id,
1508                    node.clone(),
1509                    path_ctx.map(str::to_string),
1510                    severity,
1511                    message,
1512                    None,
1513                    sub_reasons,
1514                    None,
1515                )];
1516            }
1517            vec![reason(
1518                evaluator.arena,
1519                id,
1520                node.clone(),
1521                path_ctx.map(str::to_string),
1522                severity,
1523                format!("none of {} alternative(s) satisfied", cs.len()),
1524                None,
1525                sub_reasons,
1526                None,
1527            )]
1528        }
1529        Shape::Count {
1530            path,
1531            min,
1532            max,
1533            qualifier,
1534        } => explain_count(
1535            evaluator, node, id, &path, min, max, qualifier, severity, stack,
1536        ),
1537        Shape::Expression(_) => leaf(
1538            evaluator.arena,
1539            false, // reached only because the constraint failed (line ~923)
1540            node,
1541            id,
1542            path_ctx,
1543            severity,
1544            "sh:expression did not evaluate to true".to_string(),
1545        ),
1546    };
1547    stack.remove(&key);
1548    reasons
1549}
1550
1551#[allow(clippy::too_many_arguments)]
1552fn explain_count(
1553    evaluator: &mut ShapeEvaluator<'_>,
1554    node: &Term,
1555    id: ShapeId,
1556    path: &Path,
1557    min: Option<u64>,
1558    max: Option<u64>,
1559    qualifier: ShapeId,
1560    severity: &Severity,
1561    stack: &mut HashSet<(ShapeId, Term)>,
1562) -> Vec<Reason> {
1563    let path_str = path_to_string_in(path, evaluator.prefixes);
1564    let matched: Vec<Term> = succ(evaluator.g, node, path)
1565        .into_iter()
1566        .filter(|u| evaluator.holds(u, qualifier))
1567        .collect();
1568    let n = matched.len() as u64;
1569    let mut reasons = Vec::new();
1570
1571    // For a *qualified* count (`sh:qualifiedValueShape`, e.g. `sh:class C`), the
1572    // counted values are only those conforming to the qualifier, so the message
1573    // must name it — otherwise "found 0" reads as if the path were empty when in
1574    // fact it held values that just didn't match the qualifier. Plain
1575    // `sh:minCount`/`sh:maxCount` lower with a `⊤` qualifier and need no clause.
1576    let qual_clause = match evaluator.arena.get(qualifier) {
1577        Shape::Top => String::new(),
1578        _ => format!(
1579            " matching `{}`",
1580            describe_shape_in(evaluator.arena, qualifier, evaluator.prefixes)
1581        ),
1582    };
1583
1584    if let Some(mx) = max
1585        && n > mx
1586    {
1587        // A universal `∀path.φ` is lowered to `∃≤0 path.¬φ`; more generally, when
1588        // a `∃≤0` (max 0) count over a non-trivial qualifier `ψ` fails, every
1589        // offending value satisfies `ψ` but must satisfy `¬ψ`. Drill into each
1590        // offender and report that positive requirement `¬ψ` rather than echoing
1591        // the machine's double-negated `ψ`. A bare `Shape::Not` still routes
1592        // through `explain` (richest, keeps sub-reasons); the `sh:class` case
1593        // gets a dedicated phrasing; anything else (`sh:nodeKind`, De Morgan
1594        // combinations, …) is described by `describe_negation`. A `⊤` qualifier
1595        // is a plain `sh:maxCount` and keeps the concise count message.
1596        let class_offense = (mx == 0)
1597            .then(|| negated_class_target_shape(qualifier, evaluator.arena))
1598            .flatten();
1599        match evaluator.arena.get(qualifier).clone() {
1600            Shape::Not(inner) if mx == 0 => {
1601                for u in &matched {
1602                    reasons.extend(explain(
1603                        evaluator,
1604                        u,
1605                        inner,
1606                        Some(&path_str),
1607                        severity,
1608                        stack,
1609                    ));
1610                }
1611            }
1612            // Normalized `sh:class` universal: name the class each offending value
1613            // must be an instance of. (The value node and path are surfaced
1614            // alongside the message by the report renderer.)
1615            _ if class_offense.is_some() => {
1616                let class = class_offense.expect("guard ensured Some");
1617                for u in &matched {
1618                    reasons.push(reason(
1619                        evaluator.arena,
1620                        id,
1621                        u.clone(),
1622                        Some(path_str.clone()),
1623                        severity,
1624                        format!(
1625                            "must be an instance of {}",
1626                            term_display(&class, evaluator.prefixes)
1627                        ),
1628                        None,
1629                        Vec::new(),
1630                        None,
1631                    ));
1632                }
1633            }
1634            // Plain `sh:maxCount` (⊤ qualifier): concise count message.
1635            Shape::Top => reasons.push(with_observed(
1636                reason(
1637                    evaluator.arena,
1638                    id,
1639                    node.clone(),
1640                    Some(path_str.clone()),
1641                    severity,
1642                    format!(
1643                        "at most {mx} value(s){qual_clause} allowed along {path_str}, found {n}"
1644                    ),
1645                    None,
1646                    Vec::new(),
1647                    None,
1648                ),
1649                n,
1650            )),
1651            // Any other `∃≤0` qualifier (`sh:nodeKind`, several value constraints
1652            // De-Morgan'd to an `Or`, …): describe the positive requirement.
1653            _ if mx == 0 => {
1654                let requirement =
1655                    describe_negation_in(evaluator.arena, qualifier, evaluator.prefixes);
1656                for u in &matched {
1657                    reasons.push(reason(
1658                        evaluator.arena,
1659                        id,
1660                        u.clone(),
1661                        Some(path_str.clone()),
1662                        severity,
1663                        format!("must satisfy `{requirement}`"),
1664                        None,
1665                        Vec::new(),
1666                        None,
1667                    ));
1668                }
1669            }
1670            // Genuine `sh:qualifiedMaxCount` ≥ 1: concise count message.
1671            _ => reasons.push(with_observed(
1672                reason(
1673                    evaluator.arena,
1674                    id,
1675                    node.clone(),
1676                    Some(path_str.clone()),
1677                    severity,
1678                    format!(
1679                        "at most {mx} value(s){qual_clause} allowed along {path_str}, found {n}"
1680                    ),
1681                    None,
1682                    Vec::new(),
1683                    None,
1684                ),
1685                n,
1686            )),
1687        }
1688    }
1689
1690    if let Some(mn) = min
1691        && n < mn
1692    {
1693        reasons.push(with_observed(
1694            reason(
1695                evaluator.arena,
1696                id,
1697                node.clone(),
1698                Some(path_str.clone()),
1699                severity,
1700                format!("at least {mn} value(s){qual_clause} required along {path_str}, found {n}"),
1701                None,
1702                Vec::new(),
1703                None,
1704            ),
1705            n,
1706        ));
1707    }
1708
1709    reasons
1710}
1711
1712/// The message for a `sh:sparql` violation, by SHACL §5.2.1 precedence: the
1713/// result's own `?message` binding, then the constraint's (or shape's)
1714/// `sh:message` (with `{$this}`/`{?var}` substitution), then a constructed
1715/// description naming the shape and value.
1716fn sparql_violation_message(
1717    violation: &SparqlViolation,
1718    constraint: &SparqlConstraint,
1719    node: &Term,
1720) -> String {
1721    if let Some(message) = &violation.message {
1722        return term_text(message);
1723    }
1724    if !constraint.messages.is_empty() {
1725        return constraint
1726            .messages
1727            .iter()
1728            .map(|m| apply_message_template(&term_text(m), node, &violation.bindings))
1729            .collect::<Vec<_>>()
1730            .join("; ");
1731    }
1732    let mut message = match &constraint.shape {
1733        Some(shape) => format!("SPARQL constraint at {shape} not satisfied"),
1734        None => "SPARQL constraint not satisfied".to_string(),
1735    };
1736    if let Some(value) = &violation.value {
1737        message.push_str(&format!(" (value: {value})"));
1738    }
1739    message
1740}
1741
1742/// Substitute `{$varName}` / `{?varName}` placeholders in a message template.
1743///
1744/// `$this` resolves to `focus`; all other names are looked up in `bindings`
1745/// (keyed without the `$`/`?` sigil). Unresolved placeholders are left as-is.
1746pub(crate) fn apply_message_template(
1747    template: &str,
1748    focus: &Term,
1749    bindings: &HashMap<String, Term>,
1750) -> String {
1751    static RE: OnceLock<Regex> = OnceLock::new();
1752    let re = RE
1753        .get_or_init(|| Regex::new(r"\{(\$[A-Za-z_]\w*|\?[A-Za-z_]\w*)\}").expect("static regex"));
1754    re.replace_all(template, |caps: &regex::Captures| {
1755        let placeholder = &caps[1];
1756        let name = &placeholder[1..]; // strip leading `$` or `?`
1757        let term = if name == "this" {
1758            Some(focus)
1759        } else {
1760            bindings.get(name)
1761        };
1762        term.map(|t| match t {
1763            Term::NamedNode(n) => format!("<{}>", n.as_str()),
1764            Term::BlankNode(b) => format!("_:{}", b.as_str()),
1765            Term::Literal(l) => l.value().to_string(),
1766        })
1767        .unwrap_or_else(|| placeholder.to_string())
1768    })
1769    .to_string()
1770}
1771
1772/// A term's human-facing text: a literal's lexical value, otherwise its RDF
1773/// rendering (`<iri>` / `_:id`).
1774fn term_text(term: &Term) -> String {
1775    match term {
1776        Term::Literal(literal) => literal.value().to_string(),
1777        other => other.to_string(),
1778    }
1779}
1780
1781/// [`term_text`], but compacting an IRI against the document's vocabulary — for
1782/// terms that appear inside a generated message rather than in a structured
1783/// field, where the reader wants the spelling the shapes were authored in.
1784fn term_display(term: &Term, px: &Prefixes) -> String {
1785    match term {
1786        Term::NamedNode(iri) => px.compact(iri.as_str()),
1787        other => term_text(other),
1788    }
1789}
1790
1791#[allow(clippy::too_many_arguments)]
1792fn reason(
1793    arena: &ShapeArena,
1794    id: ShapeId,
1795    value: Term,
1796    path: Option<String>,
1797    severity: &Severity,
1798    message: String,
1799    author_message: Option<String>,
1800    sub_reasons: Vec<Reason>,
1801    sparql_diagnostic: Option<SparqlDiagnostic>,
1802) -> Reason {
1803    Reason {
1804        value,
1805        path,
1806        shape: id,
1807        constraint: arena.get(id).clone(),
1808        constraint_kind: ConstraintKind::of(arena, id),
1809        constraint_id: id,
1810        statement_id: usize::MAX,
1811        severity: severity.clone(),
1812        message,
1813        author_message,
1814        observed_count: None,
1815        sub_reasons,
1816        sparql_diagnostic,
1817    }
1818}
1819
1820/// Stamp the observed value count onto a cardinality reason. Kept off
1821/// [`reason`]'s argument list, which is already at the lint's limit, and set
1822/// only where a count was actually taken.
1823fn with_observed(mut r: Reason, observed: u64) -> Reason {
1824    r.observed_count = Some(observed);
1825    r
1826}
1827
1828fn leaf(
1829    arena: &ShapeArena,
1830    ok: bool,
1831    node: &Term,
1832    id: ShapeId,
1833    path_ctx: Option<&str>,
1834    severity: &Severity,
1835    message: String,
1836) -> Vec<Reason> {
1837    if ok {
1838        Vec::new()
1839    } else {
1840        vec![reason(
1841            arena,
1842            id,
1843            node.clone(),
1844            path_ctx.map(str::to_string),
1845            severity,
1846            message,
1847            None,
1848            Vec::new(),
1849            None,
1850        )]
1851    }
1852}
1853
1854fn all_pairs_ordered(
1855    g: &dyn PathBackend,
1856    v: &Term,
1857    path: &Path,
1858    p: &NamedNode,
1859    allow_eq: bool,
1860) -> bool {
1861    let lhs = succ(g, v, path);
1862    let rhs = objects(g, v, p);
1863    for a in &lhs {
1864        for b in &rhs {
1865            match compare_terms(a, b) {
1866                Some(Ordering::Less) => {}
1867                Some(Ordering::Equal) if allow_eq => {}
1868                _ => return false,
1869            }
1870        }
1871    }
1872    true
1873}
1874
1875fn objects(g: &dyn PathBackend, v: &Term, p: &NamedNode) -> HashSet<Term> {
1876    succ(g, v, &Path::Pred(p.clone()))
1877}
1878
1879/// Predicates on `node` not allowed by a closed shape's set `q`.
1880fn closed_offenders(
1881    g: &dyn PathBackend,
1882    node: &Term,
1883    q: &BTreeSet<NamedNode>,
1884) -> BTreeSet<NamedNode> {
1885    g.out_predicates(node)
1886        .into_iter()
1887        .filter(|p| !q.contains(p))
1888        .collect()
1889}
1890
1891fn unique_lang(values: &HashSet<Term>) -> bool {
1892    let mut seen = HashSet::new();
1893    for term in values {
1894        if let Term::Literal(l) = term
1895            && let Some(lang) = l.language()
1896            && !seen.insert(lang.to_ascii_lowercase())
1897        {
1898            return false;
1899        }
1900    }
1901    true
1902}
1903
1904fn dedup_reasons(reasons: &mut Vec<Reason>) {
1905    let mut seen = HashSet::new();
1906    reasons.retain(|r| {
1907        seen.insert((
1908            r.value.to_string(),
1909            r.message.clone(),
1910            r.severity.as_str().to_string(),
1911        ))
1912    });
1913}
1914
1915#[cfg(test)]
1916mod tests {
1917    use super::*;
1918    use oxrdf::{NamedNode, Triple};
1919
1920    fn iri(local: &str) -> NamedNode {
1921        NamedNode::new(format!("http://ex/{local}")).unwrap()
1922    }
1923
1924    fn term(local: &str) -> Term {
1925        Term::NamedNode(iri(local))
1926    }
1927
1928    #[test]
1929    fn memoizes_shared_value_checks_across_focus_nodes() {
1930        let p = iri("p");
1931        let shared = term("shared");
1932        let mut graph = Graph::new();
1933        graph.insert(&Triple::new(iri("a"), p.clone(), shared.clone()));
1934        graph.insert(&Triple::new(iri("b"), p.clone(), shared.clone()));
1935
1936        let mut arena = ShapeArena::new();
1937        let qualifier = arena.insert(Shape::TestConst(shared));
1938        let root = arena.insert(Shape::Count {
1939            path: Path::Pred(p),
1940            min: Some(1),
1941            max: None,
1942            qualifier,
1943        });
1944        let sparql = SparqlExecutor::new(&graph).unwrap();
1945        crate::profile::enable();
1946        {
1947            let mut evaluator = ShapeEvaluator::new(&graph, &arena, Prefixes::empty(), &sparql);
1948            assert!(evaluator.holds(&term("a"), root));
1949            assert!(evaluator.holds(&term("b"), root));
1950        }
1951        let profile = crate::profile::take().unwrap();
1952        let cache = profile.shape_cache();
1953        assert_eq!(cache.evaluators, 1);
1954        assert!(cache.hits >= 1, "shared qualifier should hit the cache");
1955        assert_eq!(cache.peak_entries, 3);
1956        assert!(cache.estimated_peak_bytes > 0);
1957    }
1958
1959    #[test]
1960    fn does_not_cache_cycle_dependent_results() {
1961        // A := B ∧ false; B := A. While evaluating A, the B result is
1962        // provisionally true through the A back-edge, but the gfp solution is
1963        // A=false, B=false. Caching that provisional B=true would be unsound.
1964        let mut arena = ShapeArena::new();
1965        let a = arena.reserve();
1966        let b = arena.reserve();
1967        let bottom = arena.insert(Shape::Or(Vec::new()));
1968        arena.set(a, Shape::And(vec![b, bottom]));
1969        arena.set(b, Shape::And(vec![a]));
1970
1971        let graph = Graph::new();
1972        let sparql = SparqlExecutor::new(&graph).unwrap();
1973        let node = term("x");
1974
1975        crate::profile::enable();
1976        {
1977            let mut evaluator = ShapeEvaluator::new(&graph, &arena, Prefixes::empty(), &sparql);
1978            assert!(!evaluator.holds(&node, a));
1979            assert!(!evaluator.holds(&node, b));
1980        }
1981        let profile = crate::profile::take().unwrap();
1982        let cache = profile.shape_cache();
1983        assert!(cache.recursion_back_edges > 0);
1984        assert!(cache.non_cacheable_results > 0);
1985    }
1986}