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