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