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