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