1use crate::frozen::FrozenIndexedDataset;
13use crate::path::{PathBackend, node_of, pred, succ};
14use crate::profile::ShapeCacheSample;
15use crate::sparql::{SparqlDiagnostic, SparqlExecutor, SparqlViolation};
16use crate::value::{compare_terms, value_type_holds};
17use oxrdf::{Graph, NamedNode, Term};
18use regex::Regex;
19use serde::{Deserialize, Serialize};
20use shifty_algebra::render::{
21 describe_negation, describe_shape, negated_class_target_shape, path_to_string, shape_to_string,
22};
23use shifty_algebra::{
24 NodeExpr, Path, Schema, Selector, Severity, Shape, ShapeArena, ShapeId, SparqlConstraint,
25};
26use shifty_opt::{FocusSource, PhysicalPlan, analyze};
27use std::cmp::Ordering;
28use std::collections::{BTreeSet, HashMap, HashSet};
29use std::fmt;
30use std::sync::OnceLock;
31
32#[derive(Debug, Clone, Copy)]
33struct EvalResult {
34 holds: bool,
35 cacheable: bool,
36}
37
38#[derive(Default)]
39struct EvalState {
40 memo: HashMap<(ShapeId, Term), bool>,
41 active: HashSet<(ShapeId, Term)>,
42 telemetry: Option<ShapeCacheSample>,
43}
44
45pub(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 pub(crate) fn backend(&self) -> &dyn PathBackend {
84 self.g
85 }
86
87 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct Reason {
107 pub value: Term,
109 pub path: Option<String>,
112 pub shape: ShapeId,
114 #[serde(default)]
116 pub severity: Severity,
117 pub message: String,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub author_message: Option<String>,
124 #[serde(default, skip_serializing_if = "Vec::is_empty")]
127 pub sub_reasons: Vec<Reason>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub sparql_diagnostic: Option<SparqlDiagnostic>,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct Violation {
139 pub focus: Term,
140 pub statement: usize,
142 pub severity: Severity,
144 pub reasons: Vec<Reason>,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149pub struct ValidationOutcome {
150 pub conforms: bool,
151 pub violations: Vec<Violation>,
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
157pub enum UnsupportedPolicy {
158 #[default]
161 Ignore,
162 Error,
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
172pub struct EngineOptions {
173 pub unsupported: UnsupportedPolicy,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct ValidationOptions {
182 pub minimum_severity: Severity,
185 pub sort_results: bool,
188 pub entry_shape_names: Vec<String>,
193 pub engine: EngineOptions,
195}
196
197impl Default for ValidationOptions {
198 fn default() -> Self {
199 Self {
200 minimum_severity: Severity::Info,
201 sort_results: true,
202 entry_shape_names: Vec::new(),
203 engine: EngineOptions::default(),
204 }
205 }
206}
207
208fn requested_shape_name_matches(requested: &str, actual: &str) -> bool {
209 let requested = requested.trim();
210 requested == actual
211 || requested
212 .strip_prefix('<')
213 .and_then(|s| s.strip_suffix('>'))
214 .is_some_and(|stripped| stripped == actual)
215}
216
217pub(crate) fn entry_shape_name_selected(
220 entry_shape_names: &[String],
221 actual_name: Option<&str>,
222) -> bool {
223 entry_shape_names.is_empty()
224 || actual_name.is_some_and(|actual| {
225 entry_shape_names
226 .iter()
227 .any(|requested| requested_shape_name_matches(requested, actual))
228 })
229}
230
231fn most_severe(reasons: &[Reason]) -> Severity {
232 reasons
233 .iter()
234 .max_by_key(|reason| reason.severity.rank())
235 .map(|reason| reason.severity.clone())
236 .unwrap_or(Severity::Violation)
237}
238
239fn conforms_at_threshold(violations: &[Violation], minimum: &Severity) -> bool {
240 !violations
241 .iter()
242 .flat_map(|violation| &violation.reasons)
243 .any(|reason| reason.severity.meets(minimum))
244}
245
246fn sort_violations(violations: &mut [Violation], enabled: bool) {
247 if enabled {
248 violations.sort_by(|left, right| {
249 right
250 .severity
251 .rank()
252 .cmp(&left.severity.rank())
253 .then_with(|| left.focus.to_string().cmp(&right.focus.to_string()))
254 .then_with(|| left.statement.cmp(&right.statement))
255 });
256 }
257}
258
259#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
261pub enum ValidationGraphMode {
262 Data,
264 #[default]
267 Union,
268 UnionAll,
270}
271
272#[derive(Debug, Clone, PartialEq, Eq)]
276pub struct NonStratifiable {
277 pub components: Vec<Vec<ShapeId>>,
278}
279
280impl fmt::Display for NonStratifiable {
281 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
282 write!(f, "non-stratifiable schema (recursion through negation): ")?;
283 for (i, c) in self.components.iter().enumerate() {
284 if i > 0 {
285 write!(f, "; ")?;
286 }
287 let ids: Vec<String> = c.iter().map(|s| format!("@{}", s.0)).collect();
288 write!(f, "{{{}}}", ids.join(" "))?;
289 }
290 Ok(())
291 }
292}
293
294impl std::error::Error for NonStratifiable {}
295
296pub fn validate(data: &Graph, schema: &Schema) -> Result<ValidationOutcome, NonStratifiable> {
305 validate_with_options(data, schema, &ValidationOptions::default())
306}
307
308pub fn validate_with_options(
310 data: &Graph,
311 schema: &Schema,
312 options: &ValidationOptions,
313) -> Result<ValidationOutcome, NonStratifiable> {
314 validate_with_context_and_options(data, data, schema, options)
315}
316
317pub fn validate_graphs(
319 data: &Graph,
320 shapes: &Graph,
321 schema: &Schema,
322) -> Result<ValidationOutcome, NonStratifiable> {
323 validate_graphs_with_mode_and_options(
324 data,
325 shapes,
326 schema,
327 ValidationGraphMode::default(),
328 &ValidationOptions::default(),
329 )
330}
331
332pub fn validate_graphs_with_mode(
334 data: &Graph,
335 shapes: &Graph,
336 schema: &Schema,
337 mode: ValidationGraphMode,
338) -> Result<ValidationOutcome, NonStratifiable> {
339 validate_graphs_with_mode_and_options(data, shapes, schema, mode, &ValidationOptions::default())
340}
341
342pub fn validate_graphs_with_mode_and_options(
344 data: &Graph,
345 shapes: &Graph,
346 schema: &Schema,
347 mode: ValidationGraphMode,
348 options: &ValidationOptions,
349) -> Result<ValidationOutcome, NonStratifiable> {
350 match mode {
351 ValidationGraphMode::Data => {
352 let uses_shapes = uses_shapes_graph(&schema.arena);
353 let frozen = if uses_shapes {
354 FrozenIndexedDataset::from_graphs(data, shapes)
355 } else {
356 FrozenIndexedDataset::from_graph(data)
357 };
358 validate_with_frozen(data, schema, frozen, uses_shapes, options)
359 }
360 ValidationGraphMode::Union => {
361 let uses_shapes = uses_shapes_graph(&schema.arena);
362 let frozen = if uses_shapes {
363 FrozenIndexedDataset::from_graph_union_with_shapes(data, shapes)
364 } else {
365 FrozenIndexedDataset::from_graph_union(data, shapes)
366 };
367 validate_with_frozen(data, schema, frozen, uses_shapes, options)
368 }
369 ValidationGraphMode::UnionAll => {
370 let union = graph_union(data, shapes);
371 validate_with_context_and_options(&union, &union, schema, options)
372 }
373 }
374}
375
376pub fn validate_with_context(
380 data: &Graph,
381 context: &Graph,
382 schema: &Schema,
383) -> Result<ValidationOutcome, NonStratifiable> {
384 validate_with_context_and_options(data, context, schema, &ValidationOptions::default())
385}
386
387pub fn validate_with_context_and_options(
389 data: &Graph,
390 context: &Graph,
391 schema: &Schema,
392 options: &ValidationOptions,
393) -> Result<ValidationOutcome, NonStratifiable> {
394 let uses_shapes = uses_shapes_graph(&schema.arena);
395 let frozen = if uses_shapes {
396 FrozenIndexedDataset::from_graphs(context, context)
397 } else {
398 FrozenIndexedDataset::from_graph(context)
399 };
400 validate_with_frozen(data, schema, frozen, uses_shapes, options)
401}
402
403fn validate_with_frozen(
404 data: &Graph,
405 schema: &Schema,
406 frozen: FrozenIndexedDataset,
407 has_shapes_graph: bool,
408 options: &ValidationOptions,
409) -> Result<ValidationOutcome, NonStratifiable> {
410 let strat = analyze(&schema.arena);
411 if !strat.stratifiable {
412 let components = strat
413 .strata
414 .iter()
415 .filter(|s| !s.stratifiable)
416 .map(|s| s.shapes.clone())
417 .collect();
418 return Err(NonStratifiable { components });
419 }
420
421 let sparql = SparqlExecutor::from_frozen(frozen, has_shapes_graph);
422 let backend = sparql
423 .frozen()
424 .expect("validation executor always has a frozen dataset");
425 let mut evaluator = ShapeEvaluator::new(backend, &schema.arena, &sparql);
426 let mut violations = Vec::new();
427 for (i, st) in schema.statements.iter().enumerate() {
428 if !entry_shape_name_selected(
429 &options.entry_shape_names,
430 schema.names.get(&st.shape).map(String::as_str),
431 ) {
432 continue;
433 }
434 let label = schema
435 .names
436 .get(&st.shape)
437 .cloned()
438 .unwrap_or_else(|| format!("@{}", st.shape.0));
439 let foci = focus_nodes_with_evaluator(data, &st.selector, &mut evaluator);
440 prefetch_sparql_constraints(&schema.arena, st.shape, &foci, &sparql);
441 for v in foci {
442 let t = web_time::Instant::now();
443 let mut stack = HashSet::new();
444 let mut reasons = explain(
445 &mut evaluator,
446 &v,
447 st.shape,
448 None,
449 &Severity::Violation,
450 &mut stack,
451 );
452 crate::profile::record_shape(&label, t.elapsed().as_micros() as u64);
453 dedup_reasons(&mut reasons);
454 if !reasons.is_empty() {
455 let severity = most_severe(&reasons);
456 violations.push(Violation {
457 focus: v,
458 statement: i,
459 severity,
460 reasons,
461 });
462 }
463 }
464 }
465 sort_violations(&mut violations, options.sort_results);
466 Ok(ValidationOutcome {
467 conforms: conforms_at_threshold(&violations, &options.minimum_severity),
468 violations,
469 })
470}
471
472pub(crate) fn uses_shapes_graph(arena: &ShapeArena) -> bool {
475 (0..arena.len()).any(|i| {
476 matches!(arena.get(ShapeId(i as u32)), Shape::Sparql(c) if c.query.contains("shapesGraph"))
477 })
478}
479
480pub fn graph_union(left: &Graph, right: &Graph) -> Graph {
491 let (base, extra) = if left.len() >= right.len() {
492 (left, right)
493 } else {
494 (right, left)
495 };
496 let mut union = base.clone();
497 for triple in extra.iter() {
498 union.insert(triple);
499 }
500 union
501}
502
503pub fn validate_plan(
509 data: &Graph,
510 plan: &PhysicalPlan,
511) -> Result<ValidationOutcome, NonStratifiable> {
512 validate_plan_with_options(data, plan, &ValidationOptions::default())
513}
514
515pub fn validate_plan_with_options(
517 data: &Graph,
518 plan: &PhysicalPlan,
519 options: &ValidationOptions,
520) -> Result<ValidationOutcome, NonStratifiable> {
521 validate_plan_with_context_and_options(data, data, plan, options)
522}
523
524pub fn validate_plan_graphs(
526 data: &Graph,
527 shapes: &Graph,
528 plan: &PhysicalPlan,
529) -> Result<ValidationOutcome, NonStratifiable> {
530 validate_plan_graphs_with_mode_and_options(
531 data,
532 shapes,
533 plan,
534 ValidationGraphMode::default(),
535 &ValidationOptions::default(),
536 )
537}
538
539pub fn validate_plan_graphs_with_mode(
541 data: &Graph,
542 shapes: &Graph,
543 plan: &PhysicalPlan,
544 mode: ValidationGraphMode,
545) -> Result<ValidationOutcome, NonStratifiable> {
546 validate_plan_graphs_with_mode_and_options(
547 data,
548 shapes,
549 plan,
550 mode,
551 &ValidationOptions::default(),
552 )
553}
554
555pub fn validate_plan_graphs_with_mode_and_options(
557 data: &Graph,
558 shapes: &Graph,
559 plan: &PhysicalPlan,
560 mode: ValidationGraphMode,
561 options: &ValidationOptions,
562) -> Result<ValidationOutcome, NonStratifiable> {
563 match mode {
564 ValidationGraphMode::Data => {
565 let uses_shapes = uses_shapes_graph(&plan.arena);
566 let frozen = if uses_shapes {
567 FrozenIndexedDataset::from_graphs(data, shapes)
568 } else {
569 FrozenIndexedDataset::from_graph(data)
570 };
571 validate_plan_with_frozen(data, plan, frozen, uses_shapes, options)
572 }
573 ValidationGraphMode::Union => {
574 let uses_shapes = uses_shapes_graph(&plan.arena);
575 let frozen = if uses_shapes {
576 FrozenIndexedDataset::from_graph_union_with_shapes(data, shapes)
577 } else {
578 FrozenIndexedDataset::from_graph_union(data, shapes)
579 };
580 validate_plan_with_frozen(data, plan, frozen, uses_shapes, options)
581 }
582 ValidationGraphMode::UnionAll => {
583 let union = graph_union(data, shapes);
584 validate_plan_with_context_and_options(&union, &union, plan, options)
585 }
586 }
587}
588
589pub fn validate_plan_with_context(
591 data: &Graph,
592 context: &Graph,
593 plan: &PhysicalPlan,
594) -> Result<ValidationOutcome, NonStratifiable> {
595 validate_plan_with_context_and_options(data, context, plan, &ValidationOptions::default())
596}
597
598pub fn validate_plan_with_context_and_options(
600 data: &Graph,
601 context: &Graph,
602 plan: &PhysicalPlan,
603 options: &ValidationOptions,
604) -> Result<ValidationOutcome, NonStratifiable> {
605 let uses_shapes = uses_shapes_graph(&plan.arena);
606 let frozen = if uses_shapes {
607 FrozenIndexedDataset::from_graphs(context, context)
608 } else {
609 FrozenIndexedDataset::from_graph(context)
610 };
611 validate_plan_with_frozen(data, plan, frozen, uses_shapes, options)
612}
613
614fn validate_plan_with_frozen(
615 data: &Graph,
616 plan: &PhysicalPlan,
617 frozen: FrozenIndexedDataset,
618 has_shapes_graph: bool,
619 options: &ValidationOptions,
620) -> Result<ValidationOutcome, NonStratifiable> {
621 let strat = analyze(&plan.arena);
622 if !strat.stratifiable {
623 let components = strat
624 .strata
625 .iter()
626 .filter(|s| !s.stratifiable)
627 .map(|s| s.shapes.clone())
628 .collect();
629 return Err(NonStratifiable { components });
630 }
631
632 let sparql = SparqlExecutor::from_frozen(frozen, has_shapes_graph);
633 let backend = sparql
634 .frozen()
635 .expect("validation executor always has a frozen dataset");
636 let mut evaluator = ShapeEvaluator::new(backend, &plan.arena, &sparql);
637 let mut violations = Vec::new();
638 for (i, sp) in plan.statements.iter().enumerate() {
639 if !entry_shape_name_selected(
640 &options.entry_shape_names,
641 plan.names.get(&sp.shape).map(String::as_str),
642 ) {
643 continue;
644 }
645 let label = plan
646 .names
647 .get(&sp.shape)
648 .cloned()
649 .unwrap_or_else(|| format!("@{}", sp.shape.0));
650 let foci = focus_for_source(data, &sp.source, &mut evaluator);
651 prefetch_sparql_constraints(&plan.arena, sp.shape, &foci, &sparql);
652 for v in foci {
653 let t = web_time::Instant::now();
654 let mut stack = HashSet::new();
655 let mut reasons = explain(
656 &mut evaluator,
657 &v,
658 sp.shape,
659 None,
660 &Severity::Violation,
661 &mut stack,
662 );
663 crate::profile::record_shape(&label, t.elapsed().as_micros() as u64);
664 dedup_reasons(&mut reasons);
665 if !reasons.is_empty() {
666 let severity = most_severe(&reasons);
667 violations.push(Violation {
668 focus: v,
669 statement: i,
670 severity,
671 reasons,
672 });
673 }
674 }
675 }
676 sort_violations(&mut violations, options.sort_results);
677 Ok(ValidationOutcome {
678 conforms: conforms_at_threshold(&violations, &options.minimum_severity),
679 violations,
680 })
681}
682
683fn focus_for_source(
685 data: &Graph,
686 source: &FocusSource,
687 evaluator: &mut ShapeEvaluator<'_>,
688) -> Vec<Term> {
689 match source {
690 FocusSource::SubjectsOf(p) => subjects_of(data, p),
691 FocusSource::ObjectsOf(p) => objects_of(data, p),
692 FocusSource::Node(c) => vec![c.clone()],
693 FocusSource::PathToConst { path, target } => pred(evaluator.g, target, path)
695 .into_iter()
696 .filter(|node| graph_contains_term(data, node))
697 .collect(),
698 FocusSource::ScanFilter { path, qualifier } => all_nodes(data)
699 .into_iter()
700 .filter(|v| {
701 succ(evaluator.g, v, path)
702 .iter()
703 .any(|u| evaluator.holds(u, *qualifier))
704 })
705 .collect(),
706 FocusSource::Sparql(target) => {
707 let candidates = all_nodes(data);
708 evaluator
709 .sparql
710 .target_nodes(&target.query)
711 .unwrap_or_default()
712 .into_iter()
713 .filter(|node| candidates.contains(node))
714 .collect()
715 }
716 }
717}
718
719pub fn focus_nodes(data: &Graph, sel: &Selector, arena: &ShapeArena) -> Vec<Term> {
721 let sparql =
722 SparqlExecutor::new(data).expect("building an in-memory Oxigraph store should succeed");
723 let mut evaluator = ShapeEvaluator::new(data, arena, &sparql);
724 focus_nodes_with_evaluator(data, sel, &mut evaluator)
725}
726
727pub(crate) fn focus_nodes_with(
728 data: &Graph,
729 backend: &dyn PathBackend,
730 sel: &Selector,
731 arena: &ShapeArena,
732 sparql: &SparqlExecutor,
733) -> Vec<Term> {
734 let mut evaluator = ShapeEvaluator::new(backend, arena, sparql);
735 focus_nodes_with_evaluator(data, sel, &mut evaluator)
736}
737
738fn focus_nodes_with_evaluator(
739 data: &Graph,
740 sel: &Selector,
741 evaluator: &mut ShapeEvaluator<'_>,
742) -> Vec<Term> {
743 match sel {
744 Selector::HasOut(q) => subjects_of(data, q),
745 Selector::HasIn(q) => objects_of(data, q),
746 Selector::IsConst(c) => vec![c.clone()],
747 Selector::HasPath(path, qual) => match evaluator.arena.get(*qual) {
748 Shape::TestConst(target) => pred(evaluator.g, target, path)
753 .into_iter()
754 .filter(|node| graph_contains_term(data, node))
755 .collect(),
756 _ => all_nodes(data)
757 .into_iter()
758 .filter(|v| {
759 succ(evaluator.g, v, path)
760 .iter()
761 .any(|u| evaluator.holds(u, *qual))
762 })
763 .collect(),
764 },
765 Selector::Sparql(target) => {
766 let candidates = all_nodes(data);
767 evaluator
768 .sparql
769 .target_nodes(&target.query)
770 .unwrap_or_default()
771 .into_iter()
772 .filter(|node| candidates.contains(node))
773 .collect()
774 }
775 }
776}
777
778fn holds_memoized(
779 g: &dyn PathBackend,
780 arena: &ShapeArena,
781 v: &Term,
782 id: ShapeId,
783 sparql: &SparqlExecutor,
784 state: &mut EvalState,
785) -> EvalResult {
786 let key = (id, v.clone());
787 if let Some(&holds) = state.memo.get(&key) {
788 if let Some(telemetry) = state.telemetry.as_mut() {
789 telemetry.hits += 1;
790 }
791 return EvalResult {
792 holds,
793 cacheable: true,
794 };
795 }
796 if let Some(telemetry) = state.telemetry.as_mut() {
797 telemetry.misses += 1;
798 }
799 if !state.active.insert(key.clone()) {
800 if let Some(telemetry) = state.telemetry.as_mut() {
801 telemetry.recursion_back_edges += 1;
802 }
803 return EvalResult {
804 holds: true,
805 cacheable: false,
806 }; }
808 let result = match arena.get(id) {
809 Shape::Annotated { shape, .. } => holds_memoized(g, arena, v, *shape, sparql, state),
810 Shape::Top | Shape::Pending => EvalResult {
811 holds: true,
812 cacheable: true,
813 },
814 Shape::Sparql(constraint) => EvalResult {
815 holds: sparql
816 .constraint_violations(constraint, v)
817 .is_ok_and(|violations| violations.is_empty()),
818 cacheable: true,
819 },
820 Shape::Expression(expr) => {
821 let mut cacheable = true;
824 let results = eval_expr(g, arena, v, expr, sparql, state, &mut cacheable);
825 EvalResult {
826 holds: results.iter().all(is_boolean_true),
827 cacheable,
828 }
829 }
830 Shape::TestConst(c) => EvalResult {
831 holds: v == c,
832 cacheable: true,
833 },
834 Shape::TestType(t) => EvalResult {
835 holds: value_type_holds(t, v),
836 cacheable: true,
837 },
838 Shape::TestKind(k) => EvalResult {
839 holds: k.matches(v),
840 cacheable: true,
841 },
842 Shape::Closed(q) => EvalResult {
843 holds: closed_offenders(g, v, q).is_empty(),
844 cacheable: true,
845 },
846 Shape::Eq(path, p) => EvalResult {
847 holds: succ(g, v, path) == objects(g, v, p),
848 cacheable: true,
849 },
850 Shape::Disj(path, p) => EvalResult {
851 holds: succ(g, v, path).is_disjoint(&objects(g, v, p)),
852 cacheable: true,
853 },
854 Shape::Lt(path, p) => EvalResult {
855 holds: all_pairs_ordered(g, v, path, p, false),
856 cacheable: true,
857 },
858 Shape::Le(path, p) => EvalResult {
859 holds: all_pairs_ordered(g, v, path, p, true),
860 cacheable: true,
861 },
862 Shape::UniqueLang(path) => EvalResult {
863 holds: unique_lang(&succ(g, v, path)),
864 cacheable: true,
865 },
866 Shape::Not(c) => {
867 let child = holds_memoized(g, arena, v, *c, sparql, state);
868 EvalResult {
869 holds: !child.holds,
870 cacheable: child.cacheable,
871 }
872 }
873 Shape::And(cs) => {
874 let mut result = EvalResult {
875 holds: true,
876 cacheable: true,
877 };
878 for child in cs {
879 let child = holds_memoized(g, arena, v, *child, sparql, state);
880 result.cacheable &= child.cacheable;
881 if !child.holds {
882 result.holds = false;
883 break;
884 }
885 }
886 result
887 }
888 Shape::Or(cs) => {
889 let mut result = EvalResult {
890 holds: false,
891 cacheable: true,
892 };
893 for child in cs {
894 let child = holds_memoized(g, arena, v, *child, sparql, state);
895 result.cacheable &= child.cacheable;
896 if child.holds {
897 result.holds = true;
898 break;
899 }
900 }
901 result
902 }
903 Shape::Count {
904 path,
905 min,
906 max,
907 qualifier,
908 } => {
909 let mut n = 0;
910 let mut cacheable = true;
911 for value in succ(g, v, path) {
912 let qualified = holds_memoized(g, arena, &value, *qualifier, sparql, state);
913 cacheable &= qualified.cacheable;
914 n += u64::from(qualified.holds);
915 }
916 EvalResult {
917 holds: min.is_none_or(|m| n >= m) && max.is_none_or(|m| n <= m),
918 cacheable,
919 }
920 }
921 };
922 state.active.remove(&key);
923 if result.cacheable {
924 state.memo.insert(key, result.holds);
925 if let Some(telemetry) = state.telemetry.as_mut() {
926 telemetry.insertions += 1;
927 }
928 } else if let Some(telemetry) = state.telemetry.as_mut() {
929 telemetry.non_cacheable_results += 1;
930 }
931 result
932}
933
934fn eval_expr(
940 g: &dyn PathBackend,
941 arena: &ShapeArena,
942 v: &Term,
943 expr: &NodeExpr,
944 sparql: &SparqlExecutor,
945 state: &mut EvalState,
946 cacheable: &mut bool,
947) -> HashSet<Term> {
948 match expr {
949 NodeExpr::This => {
950 let mut s = HashSet::with_capacity(1);
951 s.insert(v.clone());
952 s
953 }
954 NodeExpr::Constant(t) => {
955 let mut s = HashSet::with_capacity(1);
956 s.insert(t.clone());
957 s
958 }
959 NodeExpr::Path(p) => succ(g, v, p),
960 NodeExpr::Filter { input, shape } => {
961 let inputs = eval_expr(g, arena, v, input, sparql, state, cacheable);
962 inputs
963 .into_iter()
964 .filter(|x| {
965 let r = holds_memoized(g, arena, x, *shape, sparql, state);
966 *cacheable &= r.cacheable;
967 r.holds
968 })
969 .collect()
970 }
971 NodeExpr::Intersection(es) => {
972 let mut iter = es.iter();
973 match iter.next() {
974 Some(first) => {
975 let mut acc = eval_expr(g, arena, v, first, sparql, state, cacheable);
976 for e in iter {
977 let s = eval_expr(g, arena, v, e, sparql, state, cacheable);
978 acc.retain(|x| s.contains(x));
979 }
980 acc
981 }
982 None => HashSet::new(),
983 }
984 }
985 NodeExpr::Union(es) => {
986 let mut acc = HashSet::new();
987 for e in es {
988 acc.extend(eval_expr(g, arena, v, e, sparql, state, cacheable));
989 }
990 acc
991 }
992 NodeExpr::Function { .. } => HashSet::new(),
993 }
994}
995
996pub(crate) fn is_boolean_true(t: &Term) -> bool {
998 matches!(t, Term::Literal(l) if l.datatype() == oxrdf::vocab::xsd::BOOLEAN && l.value() == "true")
999}
1000
1001fn estimated_memo_bytes(memo: &HashMap<(ShapeId, Term), bool>) -> usize {
1002 const CONTROL_BYTE_ESTIMATE: usize = 1;
1003 let bucket_bytes =
1004 memo.capacity() * (std::mem::size_of::<((ShapeId, Term), bool)>() + CONTROL_BYTE_ESTIMATE);
1005 bucket_bytes
1006 + memo
1007 .keys()
1008 .map(|(_, term)| estimated_term_heap_bytes(term))
1009 .sum::<usize>()
1010}
1011
1012fn estimated_term_heap_bytes(term: &Term) -> usize {
1013 match term {
1014 Term::NamedNode(node) => node.as_str().len(),
1015 Term::BlankNode(node) => node.as_str().len(),
1016 Term::Literal(literal) => {
1017 literal.value().len()
1018 + literal.language().map_or_else(
1019 || {
1020 let datatype = literal.datatype();
1021 if datatype.as_str() == "http://www.w3.org/2001/XMLSchema#string" {
1022 0
1023 } else {
1024 datatype.as_str().len()
1025 }
1026 },
1027 str::len,
1028 )
1029 }
1030 }
1031}
1032
1033fn prefetch_sparql_constraints(
1042 arena: &ShapeArena,
1043 root: ShapeId,
1044 foci: &[Term],
1045 sparql: &SparqlExecutor,
1046) {
1047 if foci.len() < 2 {
1048 return;
1049 }
1050 let mut constraints = Vec::new();
1051 let mut seen = HashSet::new();
1052 collect_focus_sparql(arena, root, &mut seen, &mut constraints);
1053 for constraint in constraints {
1054 let _ = sparql.prefetch_constraint(constraint, foci);
1055 }
1056}
1057
1058fn collect_focus_sparql<'a>(
1059 arena: &'a ShapeArena,
1060 id: ShapeId,
1061 seen: &mut HashSet<ShapeId>,
1062 out: &mut Vec<&'a SparqlConstraint>,
1063) {
1064 if !seen.insert(id) {
1065 return; }
1067 match arena.get(id) {
1068 Shape::Annotated { shape, .. } => collect_focus_sparql(arena, *shape, seen, out),
1069 Shape::Sparql(constraint) => out.push(constraint),
1070 Shape::Not(inner) => collect_focus_sparql(arena, *inner, seen, out),
1071 Shape::And(ids) | Shape::Or(ids) => {
1072 for &child in ids {
1073 collect_focus_sparql(arena, child, seen, out);
1074 }
1075 }
1076 _ => {}
1078 }
1079}
1080
1081fn explain(
1084 evaluator: &mut ShapeEvaluator<'_>,
1085 node: &Term,
1086 id: ShapeId,
1087 path_ctx: Option<&str>,
1088 severity: &Severity,
1089 stack: &mut HashSet<(ShapeId, Term)>,
1090) -> Vec<Reason> {
1091 let key = (id, node.clone());
1092 if !stack.insert(key.clone()) {
1093 return Vec::new(); }
1095 if evaluator.holds(node, id) {
1096 stack.remove(&key);
1097 return Vec::new();
1098 }
1099 let reasons = match evaluator.arena.get(id).clone() {
1100 Shape::Annotated {
1101 severity: source_severity,
1102 messages,
1103 shape,
1104 } => {
1105 let mut reasons = explain(evaluator, node, shape, path_ctx, &source_severity, stack);
1106 if !messages.is_empty() {
1107 let author = messages
1113 .iter()
1114 .map(|m| apply_message_template(&term_text(m), node, &HashMap::new()))
1115 .collect::<Vec<_>>()
1116 .join("; ");
1117 for r in &mut reasons {
1118 if r.author_message.is_none() {
1119 r.author_message = Some(author.clone());
1120 }
1121 }
1122 }
1123 reasons
1124 }
1125 Shape::Top | Shape::Pending => Vec::new(),
1126 Shape::Sparql(constraint) => {
1127 match evaluator.sparql.constraint_violations(&constraint, node) {
1128 Ok(violations) if violations.is_empty() => Vec::new(),
1129 Ok(violations) => {
1130 let diagnostic = evaluator
1135 .sparql
1136 .constraint_diagnostic(&constraint, node, &violations)
1137 .ok();
1138 violations
1139 .into_iter()
1140 .map(|violation| {
1141 let message = sparql_violation_message(&violation, &constraint, node);
1144 Reason {
1145 value: violation.value.unwrap_or_else(|| node.clone()),
1146 path: violation
1147 .path
1148 .map(|path| path.to_string())
1149 .or_else(|| path_ctx.map(str::to_string))
1150 .or_else(|| constraint.path.as_ref().map(path_to_string)),
1151 message,
1152 shape: id,
1153 severity: severity.clone(),
1154 author_message: None,
1155 sub_reasons: Vec::new(),
1156 sparql_diagnostic: diagnostic.clone(),
1157 }
1158 })
1159 .collect()
1160 }
1161 Err(error) => vec![Reason {
1162 value: node.clone(),
1163 path: path_ctx.map(str::to_string),
1164 shape: id,
1165 severity: severity.clone(),
1166 message: format!("SPARQL constraint evaluation failed: {error}"),
1167 author_message: None,
1168 sub_reasons: Vec::new(),
1169 sparql_diagnostic: evaluator
1170 .sparql
1171 .constraint_diagnostic(&constraint, node, &[])
1172 .ok(),
1173 }],
1174 }
1175 }
1176 Shape::TestConst(_)
1177 | Shape::TestType(_)
1178 | Shape::TestKind(_)
1179 | Shape::Eq(..)
1180 | Shape::Disj(..)
1181 | Shape::Lt(..)
1182 | Shape::Le(..)
1183 | Shape::UniqueLang(_) => leaf(
1184 evaluator.holds(node, id),
1185 node,
1186 id,
1187 path_ctx,
1188 severity,
1189 format!("{} not satisfied", shape_to_string(evaluator.arena, id)),
1190 ),
1191 Shape::Closed(q) => {
1192 let bad = closed_offenders(evaluator.g, node, &q);
1193 if bad.is_empty() {
1194 Vec::new()
1195 } else {
1196 let preds: Vec<String> = bad.iter().map(|p| p.to_string()).collect();
1197 vec![Reason {
1198 value: node.clone(),
1199 path: path_ctx.map(str::to_string),
1200 shape: id,
1201 severity: severity.clone(),
1202 message: format!("closed: unexpected predicate(s) {}", preds.join(", ")),
1203 author_message: None,
1204 sub_reasons: Vec::new(),
1205 sparql_diagnostic: None,
1206 }]
1207 }
1208 }
1209 Shape::Not(c) => {
1210 if explain(evaluator, node, c, path_ctx, severity, stack).is_empty() {
1211 vec![Reason {
1212 value: node.clone(),
1213 path: path_ctx.map(str::to_string),
1214 shape: id,
1215 severity: severity.clone(),
1216 message: "negated shape unexpectedly held".to_string(),
1217 author_message: None,
1218 sub_reasons: Vec::new(),
1219 sparql_diagnostic: None,
1220 }]
1221 } else {
1222 Vec::new()
1223 }
1224 }
1225 Shape::And(cs) => cs
1226 .iter()
1227 .flat_map(|c| explain(evaluator, node, *c, path_ctx, severity, stack))
1228 .collect(),
1229 Shape::Or(cs) => {
1230 let mut sub_reasons = Vec::new();
1231 let mut satisfied = false;
1232 for c in &cs {
1233 let sub = explain(evaluator, node, *c, path_ctx, severity, stack);
1234 if sub.is_empty() {
1235 satisfied = true;
1236 break;
1237 }
1238 sub_reasons.extend(sub);
1239 }
1240 if satisfied {
1241 Vec::new()
1242 } else {
1243 vec![Reason {
1244 value: node.clone(),
1245 path: path_ctx.map(str::to_string),
1246 shape: id,
1247 severity: severity.clone(),
1248 message: format!("none of {} alternative(s) satisfied", cs.len()),
1249 author_message: None,
1250 sub_reasons,
1251 sparql_diagnostic: None,
1252 }]
1253 }
1254 }
1255 Shape::Count {
1256 path,
1257 min,
1258 max,
1259 qualifier,
1260 } => explain_count(
1261 evaluator, node, id, &path, min, max, qualifier, severity, stack,
1262 ),
1263 Shape::Expression(_) => leaf(
1264 false, node,
1266 id,
1267 path_ctx,
1268 severity,
1269 "sh:expression did not evaluate to true".to_string(),
1270 ),
1271 };
1272 stack.remove(&key);
1273 reasons
1274}
1275
1276#[allow(clippy::too_many_arguments)]
1277fn explain_count(
1278 evaluator: &mut ShapeEvaluator<'_>,
1279 node: &Term,
1280 id: ShapeId,
1281 path: &Path,
1282 min: Option<u64>,
1283 max: Option<u64>,
1284 qualifier: ShapeId,
1285 severity: &Severity,
1286 stack: &mut HashSet<(ShapeId, Term)>,
1287) -> Vec<Reason> {
1288 let path_str = path_to_string(path);
1289 let matched: Vec<Term> = succ(evaluator.g, node, path)
1290 .into_iter()
1291 .filter(|u| evaluator.holds(u, qualifier))
1292 .collect();
1293 let n = matched.len() as u64;
1294 let mut reasons = Vec::new();
1295
1296 let qual_clause = match evaluator.arena.get(qualifier) {
1302 Shape::Top => String::new(),
1303 _ => format!(" matching `{}`", describe_shape(evaluator.arena, qualifier)),
1304 };
1305
1306 if let Some(mx) = max
1307 && n > mx
1308 {
1309 let class_offense = (mx == 0)
1319 .then(|| negated_class_target_shape(qualifier, evaluator.arena))
1320 .flatten();
1321 match evaluator.arena.get(qualifier).clone() {
1322 Shape::Not(inner) if mx == 0 => {
1323 for u in &matched {
1324 reasons.extend(explain(
1325 evaluator,
1326 u,
1327 inner,
1328 Some(&path_str),
1329 severity,
1330 stack,
1331 ));
1332 }
1333 }
1334 _ if class_offense.is_some() => {
1338 let class = class_offense.expect("guard ensured Some");
1339 for u in &matched {
1340 reasons.push(Reason {
1341 value: u.clone(),
1342 path: Some(path_str.clone()),
1343 shape: id,
1344 severity: severity.clone(),
1345 message: format!("must be an instance of {}", term_text(&class)),
1346 author_message: None,
1347 sub_reasons: Vec::new(),
1348 sparql_diagnostic: None,
1349 });
1350 }
1351 }
1352 Shape::Top => reasons.push(Reason {
1354 value: node.clone(),
1355 path: Some(path_str.clone()),
1356 shape: id,
1357 severity: severity.clone(),
1358 message: format!(
1359 "at most {mx} value(s){qual_clause} allowed along {path_str}, found {n}"
1360 ),
1361 author_message: None,
1362 sub_reasons: Vec::new(),
1363 sparql_diagnostic: None,
1364 }),
1365 _ if mx == 0 => {
1368 let requirement = describe_negation(evaluator.arena, qualifier);
1369 for u in &matched {
1370 reasons.push(Reason {
1371 value: u.clone(),
1372 path: Some(path_str.clone()),
1373 shape: id,
1374 severity: severity.clone(),
1375 message: format!("must satisfy `{requirement}`"),
1376 author_message: None,
1377 sub_reasons: Vec::new(),
1378 sparql_diagnostic: None,
1379 });
1380 }
1381 }
1382 _ => reasons.push(Reason {
1384 value: node.clone(),
1385 path: Some(path_str.clone()),
1386 shape: id,
1387 severity: severity.clone(),
1388 message: format!(
1389 "at most {mx} value(s){qual_clause} allowed along {path_str}, found {n}"
1390 ),
1391 author_message: None,
1392 sub_reasons: Vec::new(),
1393 sparql_diagnostic: None,
1394 }),
1395 }
1396 }
1397
1398 if let Some(mn) = min
1399 && n < mn
1400 {
1401 reasons.push(Reason {
1402 value: node.clone(),
1403 path: Some(path_str.clone()),
1404 shape: id,
1405 severity: severity.clone(),
1406 message: format!(
1407 "at least {mn} value(s){qual_clause} required along {path_str}, found {n}"
1408 ),
1409 sparql_diagnostic: None,
1410 author_message: None,
1411 sub_reasons: Vec::new(),
1412 });
1413 }
1414
1415 reasons
1416}
1417
1418fn sparql_violation_message(
1423 violation: &SparqlViolation,
1424 constraint: &SparqlConstraint,
1425 node: &Term,
1426) -> String {
1427 if let Some(message) = &violation.message {
1428 return term_text(message);
1429 }
1430 if !constraint.messages.is_empty() {
1431 return constraint
1432 .messages
1433 .iter()
1434 .map(|m| apply_message_template(&term_text(m), node, &violation.bindings))
1435 .collect::<Vec<_>>()
1436 .join("; ");
1437 }
1438 let mut message = match &constraint.shape {
1439 Some(shape) => format!("SPARQL constraint at {shape} not satisfied"),
1440 None => "SPARQL constraint not satisfied".to_string(),
1441 };
1442 if let Some(value) = &violation.value {
1443 message.push_str(&format!(" (value: {value})"));
1444 }
1445 message
1446}
1447
1448pub(crate) fn apply_message_template(
1453 template: &str,
1454 focus: &Term,
1455 bindings: &HashMap<String, Term>,
1456) -> String {
1457 static RE: OnceLock<Regex> = OnceLock::new();
1458 let re = RE
1459 .get_or_init(|| Regex::new(r"\{(\$[A-Za-z_]\w*|\?[A-Za-z_]\w*)\}").expect("static regex"));
1460 re.replace_all(template, |caps: ®ex::Captures| {
1461 let placeholder = &caps[1];
1462 let name = &placeholder[1..]; let term = if name == "this" {
1464 Some(focus)
1465 } else {
1466 bindings.get(name)
1467 };
1468 term.map(|t| match t {
1469 Term::NamedNode(n) => format!("<{}>", n.as_str()),
1470 Term::BlankNode(b) => format!("_:{}", b.as_str()),
1471 Term::Literal(l) => l.value().to_string(),
1472 })
1473 .unwrap_or_else(|| placeholder.to_string())
1474 })
1475 .to_string()
1476}
1477
1478fn term_text(term: &Term) -> String {
1481 match term {
1482 Term::Literal(literal) => literal.value().to_string(),
1483 other => other.to_string(),
1484 }
1485}
1486
1487fn leaf(
1488 ok: bool,
1489 node: &Term,
1490 id: ShapeId,
1491 path_ctx: Option<&str>,
1492 severity: &Severity,
1493 message: String,
1494) -> Vec<Reason> {
1495 if ok {
1496 Vec::new()
1497 } else {
1498 vec![Reason {
1499 value: node.clone(),
1500 path: path_ctx.map(str::to_string),
1501 shape: id,
1502 severity: severity.clone(),
1503 message,
1504 author_message: None,
1505 sub_reasons: Vec::new(),
1506 sparql_diagnostic: None,
1507 }]
1508 }
1509}
1510
1511fn all_pairs_ordered(
1512 g: &dyn PathBackend,
1513 v: &Term,
1514 path: &Path,
1515 p: &NamedNode,
1516 allow_eq: bool,
1517) -> bool {
1518 let lhs = succ(g, v, path);
1519 let rhs = objects(g, v, p);
1520 for a in &lhs {
1521 for b in &rhs {
1522 match compare_terms(a, b) {
1523 Some(Ordering::Less) => {}
1524 Some(Ordering::Equal) if allow_eq => {}
1525 _ => return false,
1526 }
1527 }
1528 }
1529 true
1530}
1531
1532fn objects(g: &dyn PathBackend, v: &Term, p: &NamedNode) -> HashSet<Term> {
1533 succ(g, v, &Path::Pred(p.clone()))
1534}
1535
1536fn closed_offenders(
1538 g: &dyn PathBackend,
1539 node: &Term,
1540 q: &BTreeSet<NamedNode>,
1541) -> BTreeSet<NamedNode> {
1542 g.out_predicates(node)
1543 .into_iter()
1544 .filter(|p| !q.contains(p))
1545 .collect()
1546}
1547
1548fn unique_lang(values: &HashSet<Term>) -> bool {
1549 let mut seen = HashSet::new();
1550 for term in values {
1551 if let Term::Literal(l) = term
1552 && let Some(lang) = l.language()
1553 && !seen.insert(lang.to_ascii_lowercase())
1554 {
1555 return false;
1556 }
1557 }
1558 true
1559}
1560
1561fn dedup_reasons(reasons: &mut Vec<Reason>) {
1562 let mut seen = HashSet::new();
1563 reasons.retain(|r| {
1564 seen.insert((
1565 r.value.to_string(),
1566 r.message.clone(),
1567 r.severity.as_str().to_string(),
1568 ))
1569 });
1570}
1571
1572fn subject_term(s: oxrdf::NamedOrBlankNodeRef) -> Term {
1573 crate::path::term_of(s.into_owned())
1574}
1575
1576fn subjects_of(data: &Graph, p: &NamedNode) -> Vec<Term> {
1578 let mut seen = HashSet::new();
1579 data.triples_for_predicate(p.as_ref())
1580 .filter_map(|t| {
1581 let term = subject_term(t.subject);
1582 seen.insert(term.clone()).then_some(term)
1583 })
1584 .collect()
1585}
1586
1587fn objects_of(data: &Graph, p: &NamedNode) -> Vec<Term> {
1589 let mut seen = HashSet::new();
1590 data.triples_for_predicate(p.as_ref())
1591 .filter_map(|t| {
1592 let term = t.object.into_owned();
1593 seen.insert(term.clone()).then_some(term)
1594 })
1595 .collect()
1596}
1597
1598fn all_nodes(g: &Graph) -> HashSet<Term> {
1600 let mut nodes = HashSet::new();
1601 for t in g.iter() {
1602 nodes.insert(subject_term(t.subject));
1603 nodes.insert(t.object.into_owned());
1604 }
1605 nodes
1606}
1607
1608fn graph_contains_term(g: &Graph, term: &Term) -> bool {
1610 node_of(term).is_some_and(|node| g.triples_for_subject(&node).next().is_some())
1611 || g.triples_for_object(term).next().is_some()
1612}
1613
1614#[cfg(test)]
1615mod tests {
1616 use super::*;
1617 use oxrdf::{NamedNode, Triple};
1618
1619 fn iri(local: &str) -> NamedNode {
1620 NamedNode::new(format!("http://ex/{local}")).unwrap()
1621 }
1622
1623 fn term(local: &str) -> Term {
1624 Term::NamedNode(iri(local))
1625 }
1626
1627 #[test]
1628 fn memoizes_shared_value_checks_across_focus_nodes() {
1629 let p = iri("p");
1630 let shared = term("shared");
1631 let mut graph = Graph::new();
1632 graph.insert(&Triple::new(iri("a"), p.clone(), shared.clone()));
1633 graph.insert(&Triple::new(iri("b"), p.clone(), shared.clone()));
1634
1635 let mut arena = ShapeArena::new();
1636 let qualifier = arena.insert(Shape::TestConst(shared));
1637 let root = arena.insert(Shape::Count {
1638 path: Path::Pred(p),
1639 min: Some(1),
1640 max: None,
1641 qualifier,
1642 });
1643 let sparql = SparqlExecutor::new(&graph).unwrap();
1644 crate::profile::enable();
1645 {
1646 let mut evaluator = ShapeEvaluator::new(&graph, &arena, &sparql);
1647 assert!(evaluator.holds(&term("a"), root));
1648 assert!(evaluator.holds(&term("b"), root));
1649 }
1650 let profile = crate::profile::take().unwrap();
1651 let cache = profile.shape_cache();
1652 assert_eq!(cache.evaluators, 1);
1653 assert!(cache.hits >= 1, "shared qualifier should hit the cache");
1654 assert_eq!(cache.peak_entries, 3);
1655 assert!(cache.estimated_peak_bytes > 0);
1656 }
1657
1658 #[test]
1659 fn does_not_cache_cycle_dependent_results() {
1660 let mut arena = ShapeArena::new();
1664 let a = arena.reserve();
1665 let b = arena.reserve();
1666 let bottom = arena.insert(Shape::Or(Vec::new()));
1667 arena.set(a, Shape::And(vec![b, bottom]));
1668 arena.set(b, Shape::And(vec![a]));
1669
1670 let graph = Graph::new();
1671 let sparql = SparqlExecutor::new(&graph).unwrap();
1672 let node = term("x");
1673
1674 crate::profile::enable();
1675 {
1676 let mut evaluator = ShapeEvaluator::new(&graph, &arena, &sparql);
1677 assert!(!evaluator.holds(&node, a));
1678 assert!(!evaluator.holds(&node, b));
1679 }
1680 let profile = crate::profile::take().unwrap();
1681 let cache = profile.shape_cache();
1682 assert!(cache.recursion_back_edges > 0);
1683 assert!(cache.non_cacheable_results > 0);
1684 }
1685}