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