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