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