Skip to main content

shifty_engine/
infer.rs

1//! SHACL-AF rule inference (Layer 6) — least-fixpoint forward chaining.
2//!
3//! A rule fires on its focus nodes for which all `sh:condition`s hold, producing
4//! triples from its head's node expressions. Per the decided semantics
5//! ([`docs/03-recursion-semantics.md`](../../../docs/03-recursion-semantics.md)),
6//! inference is the **least fixpoint**. Rules run in ascending `sh:order`
7//! groups, and output from a later group may reactivate an earlier group in the
8//! next pass. Predicate-level delta scheduling avoids rerunning rules whose
9//! graph reads cannot observe the newly added triples. Triple rules only
10//! combine existing terms, and SPARQL `CONSTRUCT` results containing fresh
11//! blank nodes are rejected, preserving termination for the supported subset.
12//!
13//! Function node expressions are not executed yet; they are reported as
14//! diagnostics rather than silently skipped.
15
16use crate::frozen::FrozenIndexedDataset;
17use crate::path::{node_of, succ};
18use crate::sparql::{FunctionDef, SparqlExecutor, query_reads_graph};
19use crate::validate::{
20    EngineOptions, NonStratifiable, ShapeEvaluator, focus_nodes_with, graph_union,
21};
22use oxrdf::{Graph, NamedNode, NamedOrBlankNode, Term, Triple};
23use shifty_algebra::{NodeExpr, Rule, RuleHead, Schema, Selector, ShapeArena};
24use shifty_opt::{RuleDependencies, analyze, rule_dependencies, rule_guard_dependencies};
25use shifty_parse::vocab;
26use std::collections::{BTreeSet, HashMap, HashSet};
27
28/// The result of running inference over a data graph.
29pub struct InferenceOutcome {
30    /// The data graph augmented with all inferred triples.
31    pub graph: Graph,
32    /// The triples that were newly inferred (not already asserted).
33    pub inferred: Vec<Triple>,
34    /// Unsupported rule features encountered (deduplicated).
35    pub diagnostics: Vec<String>,
36}
37
38/// Run rule inference to a least fixpoint, ordered by `sh:order`.
39pub fn infer(data: &Graph, schema: &Schema) -> Result<InferenceOutcome, NonStratifiable> {
40    infer_with_context(data, data, schema)
41}
42
43/// Run inference with explicit [`EngineOptions`] (e.g. the `UnsupportedPolicy`
44/// for graph-reading SHACL functions called from CONSTRUCT rule bodies).
45pub fn infer_with_options(
46    data: &Graph,
47    schema: &Schema,
48    options: &EngineOptions,
49) -> Result<InferenceOutcome, NonStratifiable> {
50    infer_with_context_and_options(data, data, schema, options)
51}
52
53/// Run inference over split data and shapes graphs.
54///
55/// Rule focus nodes come from `data`, while class hierarchy, paths, conditions,
56/// and SPARQL rule bodies see the RDF union of `data` and `shapes`.
57pub fn infer_graphs(
58    data: &Graph,
59    shapes: &Graph,
60    schema: &Schema,
61) -> Result<InferenceOutcome, NonStratifiable> {
62    // The union is freshly built and owned by this call, so hand it over rather
63    // than letting the callee clone it -- on a large shapes graph that copy is
64    // pure overhead (~150 ms for Brick's 228k triples).
65    let context = graph_union(data, shapes);
66    infer_with_owned_context_and_options(data, context, schema, &EngineOptions::default())
67}
68
69/// Run inference with data-scoped focus discovery and a broader execution
70/// context. `context` should contain `data`; newly inferred triples are added to
71/// both the returned data graph and the mutable execution context.
72pub fn infer_with_context(
73    data: &Graph,
74    context: &Graph,
75    schema: &Schema,
76) -> Result<InferenceOutcome, NonStratifiable> {
77    infer_with_context_and_options(data, context, schema, &EngineOptions::default())
78}
79
80/// [`infer_with_context`] with an explicit feature-handling policy.
81pub fn infer_with_context_and_options(
82    data: &Graph,
83    context: &Graph,
84    schema: &Schema,
85    options: &EngineOptions,
86) -> Result<InferenceOutcome, NonStratifiable> {
87    infer_with_owned_context_and_options(data, context.clone(), schema, options)
88}
89
90/// [`infer_with_context_and_options`] taking ownership of the execution context.
91///
92/// Inference mutates the context as it commits inferred triples, so a borrowed
93/// context has to be copied first. Callers that build the context themselves
94/// (see [`infer_graphs`]) can pass it by value and skip that copy.
95pub fn infer_with_owned_context_and_options(
96    data: &Graph,
97    context: Graph,
98    schema: &Schema,
99    options: &EngineOptions,
100) -> Result<InferenceOutcome, NonStratifiable> {
101    let strat = analyze(&schema.arena);
102    if !strat.stratifiable {
103        let components = strat
104            .strata
105            .iter()
106            .filter(|s| !s.stratifiable)
107            .map(|s| s.shapes.clone())
108            .collect();
109        return Err(NonStratifiable { components });
110    }
111
112    let mut graph = data.clone();
113    let mut context = context;
114    let mut sparql =
115        SparqlExecutor::new(&context).expect("building an in-memory Oxigraph store should succeed");
116    // Register sh:SPARQLFunctions so CONSTRUCT rule bodies can call them (node
117    // expressions use the graph-aware call_sparql_function path separately).
118    sparql.set_functions(collect_functions(&context), options.unsupported);
119    let mut inferred: Vec<Triple> = Vec::new();
120    let mut diags: BTreeSet<String> = BTreeSet::new();
121
122    let mut rules: Vec<ScheduledRule<'_>> = schema
123        .rules
124        .iter()
125        .enumerate()
126        .filter(|(_, rule)| !rule.deactivated)
127        .map(|(index, rule)| ScheduledRule {
128            index,
129            order: rule.order.unwrap_or(0),
130            dependencies: rule_dependencies(rule, &schema.arena),
131            guard_dependencies: rule_guard_dependencies(rule, &schema.arena),
132            rule,
133        })
134        .collect();
135    rules.sort_by_key(|scheduled| (scheduled.order, scheduled.index));
136    let mut frozen = rules
137        .iter()
138        .any(|scheduled| matches!(scheduled.rule.head, RuleHead::Sparql(_)))
139        .then(|| FrozenIndexedDataset::from_graph(&context));
140
141    // The first pass evaluates every rule. Later passes are semi-naive at rule
142    // granularity: only rules that may read a changed predicate are active.
143    let mut active: HashSet<usize> = (0..rules.len()).collect();
144    // Additions from each pass occupy one contiguous suffix of `inferred`.
145    // `delta_start` avoids cloning RDF terms into separate delta buffers.
146    let mut delta_start = 0;
147    let mut first_pass = true;
148    loop {
149        let mut changed_predicates = HashSet::new();
150        let mut added = false;
151        let mut start = 0;
152        let pass_start = inferred.len();
153        let mut visible_changed: HashSet<NamedNode> = inferred[delta_start..]
154            .iter()
155            .map(|triple| triple.predicate.clone())
156            .collect();
157
158        // Focus node sets are recomputed at most once per selector per pass.
159        // Entries are evicted lazily when a committed triple's predicate matches
160        // the selector's read dependency.
161        let mut focus_cache: HashMap<Selector, Vec<Term>> = HashMap::new();
162        // Predicates of triples committed so far within this pass, used to
163        // invalidate stale cache entries before they are read.
164        let mut pass_changed: HashSet<NamedNode> = HashSet::new();
165
166        while start < rules.len() {
167            let order = rules[start].order;
168            let mut end = start + 1;
169            while end < rules.len() && rules[end].order == order {
170                end += 1;
171            }
172
173            // Tied rules observe the same graph snapshot. Their additions are
174            // visible to subsequent order groups in this pass.
175            // HashSet deduplicates within the batch; fire_rule pre-filters
176            // against the context so only genuinely new triples reach here.
177            let mut candidates: HashSet<Triple> = HashSet::new();
178            for (position, scheduled) in rules[start..end].iter().enumerate() {
179                if !active.contains(&(start + position)) {
180                    continue;
181                }
182                let sel = &scheduled.rule.selector;
183                if selector_stale(sel, &pass_changed) {
184                    focus_cache.remove(sel);
185                }
186                let focus_nodes = focus_cache.entry(sel.clone()).or_insert_with(|| {
187                    focus_nodes_with(&graph, &context, sel, &schema.arena, &sparql)
188                });
189                let mut delta_focus_nodes = Vec::new();
190                let execution_focus_nodes = match &scheduled.rule.head {
191                    RuleHead::Sparql(construct)
192                        if !first_pass
193                            && !focus_nodes.is_empty()
194                            // Differential BGP execution visits the delta once
195                            // per scan. Above this crossover, the existing
196                            // focus-bound batch is the cheaper access path.
197                            && (inferred.len() - delta_start).saturating_mul(2)
198                                < focus_nodes.len()
199                            && !scheduled
200                                .guard_dependencies
201                                .affected_by(&visible_changed) =>
202                    {
203                        match sparql.construct_delta_foci(
204                            &construct.query,
205                            &inferred[delta_start..],
206                            frozen.as_ref(),
207                        ) {
208                            Ok(Some(affected)) => {
209                                delta_focus_nodes.extend(
210                                    focus_nodes
211                                        .iter()
212                                        .filter(|focus| affected.contains(*focus))
213                                        .cloned(),
214                                );
215                                delta_focus_nodes.as_slice()
216                            }
217                            Ok(None) | Err(_) => focus_nodes.as_slice(),
218                        }
219                    }
220                    _ => focus_nodes.as_slice(),
221                };
222                let rule_label = format!("rule[{}]", start + position);
223                let rule_t = web_time::Instant::now();
224                fire_rule(
225                    execution_focus_nodes,
226                    &context,
227                    &schema.arena,
228                    scheduled.rule,
229                    &sparql,
230                    frozen.as_ref(),
231                    &mut candidates,
232                    &mut diags,
233                );
234                crate::profile::record_shape(&rule_label, rule_t.elapsed().as_micros() as u64);
235            }
236            if let Some(frozen) = frozen.as_mut() {
237                frozen.extend_triples(candidates.iter());
238            }
239            for t in candidates {
240                pass_changed.insert(t.predicate.clone());
241                visible_changed.insert(t.predicate.clone());
242                graph.insert(&t);
243                context.insert(&t);
244                if let Err(error) = sparql.insert(&t) {
245                    diags.insert(format!("failed to update SPARQL inference store: {error}"));
246                }
247                changed_predicates.insert(t.predicate.clone());
248                inferred.push(t);
249                added = true;
250            }
251
252            start = end;
253        }
254
255        if !added {
256            break;
257        }
258
259        delta_start = pass_start;
260        first_pass = false;
261        active.clear();
262        for (position, scheduled) in rules.iter().enumerate() {
263            if scheduled.dependencies.affected_by(&changed_predicates) {
264                active.insert(position);
265            }
266        }
267        if active.is_empty() {
268            break;
269        }
270    }
271
272    Ok(InferenceOutcome {
273        graph,
274        inferred,
275        diagnostics: diags.into_iter().collect(),
276    })
277}
278
279struct ScheduledRule<'a> {
280    index: usize,
281    order: i64,
282    dependencies: RuleDependencies,
283    guard_dependencies: RuleDependencies,
284    rule: &'a Rule,
285}
286
287/// Whether a cached focus-node set for `sel` may have become stale given the
288/// predicates committed so far within the current pass.
289fn selector_stale(sel: &Selector, pass_changed: &HashSet<NamedNode>) -> bool {
290    if pass_changed.is_empty() {
291        return false;
292    }
293    match sel {
294        Selector::HasOut(p) | Selector::HasIn(p) => pass_changed.contains(p),
295        Selector::IsConst(_) => false,
296        // HasPath traversal and SPARQL queries can read any predicate.
297        Selector::HasPath(..) | Selector::Sparql(_) => true,
298    }
299}
300
301#[allow(clippy::too_many_arguments)]
302fn fire_rule(
303    focus_nodes: &[Term],
304    context: &Graph,
305    arena: &ShapeArena,
306    rule: &shifty_algebra::Rule,
307    sparql: &SparqlExecutor,
308    frozen: Option<&FrozenIndexedDataset>,
309    out: &mut HashSet<Triple>,
310    diags: &mut BTreeSet<String>,
311) {
312    let mut evaluator = ShapeEvaluator::new(context, arena, sparql);
313    let eligible: Vec<&Term> = focus_nodes
314        .iter()
315        .filter(|v| rule.conditions.iter().all(|c| evaluator.holds(v, *c)))
316        .collect();
317
318    match &rule.head {
319        RuleHead::Triple {
320            subject,
321            predicate,
322            object,
323        } => {
324            for v in eligible {
325                let subjects = eval_node_expr(context, v, subject, &mut evaluator, diags);
326                let predicates = eval_node_expr(context, v, predicate, &mut evaluator, diags);
327                let objects = eval_node_expr(context, v, object, &mut evaluator, diags);
328                for s in &subjects {
329                    let Some(subj) = node_of(s) else { continue };
330                    for p in &predicates {
331                        let Term::NamedNode(pred) = p else { continue };
332                        for o in &objects {
333                            let t = Triple::new(subj.clone(), pred.clone(), o.clone());
334                            if !context.contains(&t) {
335                                out.insert(t);
336                            }
337                        }
338                    }
339                }
340            }
341        }
342        RuleHead::Sparql(construct) => {
343            let eligible: Vec<Term> = eligible.into_iter().cloned().collect();
344            match sparql.construct_many(&construct.query, &eligible, frozen) {
345                Ok(triples) => {
346                    for triple in triples {
347                        if matches!(triple.subject, oxrdf::NamedOrBlankNode::BlankNode(_))
348                            || matches!(triple.object, Term::BlankNode(_))
349                        {
350                            diags.insert(
351                                "sh:SPARQLRule CONSTRUCT blank nodes are not supported because \
352                                 they can prevent fixpoint termination"
353                                    .to_string(),
354                            );
355                        } else {
356                            out.insert(triple);
357                        }
358                    }
359                }
360                Err(error) => {
361                    diags.insert(format!("sh:SPARQLRule evaluation failed: {error}"));
362                }
363            }
364        }
365    }
366}
367
368/// Evaluate a node expression at focus node `v` to its set of result terms.
369fn eval_node_expr(
370    g: &Graph,
371    v: &Term,
372    expr: &NodeExpr,
373    evaluator: &mut ShapeEvaluator<'_>,
374    diags: &mut BTreeSet<String>,
375) -> HashSet<Term> {
376    match expr {
377        NodeExpr::This => once(v.clone()),
378        NodeExpr::Constant(t) => once(t.clone()),
379        NodeExpr::Path(p) => succ(g, v, p),
380        NodeExpr::Filter { input, shape } => eval_node_expr(g, v, input, evaluator, diags)
381            .into_iter()
382            .filter(|x| evaluator.holds(x, *shape))
383            .collect(),
384        NodeExpr::Intersection(es) => {
385            let mut iter = es.iter();
386            match iter.next() {
387                Some(first) => {
388                    let mut acc = eval_node_expr(g, v, first, evaluator, diags);
389                    for e in iter {
390                        let s = eval_node_expr(g, v, e, evaluator, diags);
391                        acc.retain(|x| s.contains(x));
392                    }
393                    acc
394                }
395                None => HashSet::new(),
396            }
397        }
398        NodeExpr::Union(es) => {
399            let mut acc = HashSet::new();
400            for e in es {
401                acc.extend(eval_node_expr(g, v, e, evaluator, diags));
402            }
403            acc
404        }
405        NodeExpr::Function { iri, args } => {
406            // Evaluate arguments before borrowing evaluator for sparql().
407            let arg_values: Vec<HashSet<Term>> = args
408                .iter()
409                .map(|a| eval_node_expr(g, v, a, evaluator, diags))
410                .collect();
411
412            let func = NamedOrBlankNode::NamedNode(iri.clone());
413            let Some(query_text) = g
414                .object_for_subject_predicate(&func, vocab::SH_SELECT)
415                .map(|t| t.into_owned())
416                .and_then(|t| match t {
417                    Term::Literal(l) => Some(l.value().to_string()),
418                    _ => None,
419                })
420            else {
421                diags.insert(format!("function <{}> has no sh:select", iri.as_str()));
422                return HashSet::new();
423            };
424
425            let params = function_params(g, &func);
426            let sparql = evaluator.sparql();
427            let mut results = HashSet::new();
428            for combo in cartesian_product(&arg_values) {
429                if combo.len() != params.len() {
430                    continue;
431                }
432                let bindings: Vec<(String, Term)> = params
433                    .iter()
434                    .zip(combo)
435                    .map(|(name, val)| (name.clone(), val))
436                    .collect();
437                match sparql.call_sparql_function(&query_text, &bindings) {
438                    Ok(terms) => results.extend(terms),
439                    Err(e) => {
440                        diags.insert(format!("function <{}> error: {e}", iri.as_str()));
441                    }
442                }
443            }
444            results
445        }
446    }
447}
448
449fn once(t: Term) -> HashSet<Term> {
450    let mut s = HashSet::with_capacity(1);
451    s.insert(t);
452    s
453}
454
455/// Return the local name of an IRI (the part after the last `#` or `/`).
456fn local_name(iri: &str) -> &str {
457    iri.rsplit(['#', '/']).next().unwrap_or(iri)
458}
459
460/// Build registrable [`FunctionDef`]s for every `sh:SPARQLFunction` in the
461/// context graph (raw bodies; node-expression calls handle their own prefixes).
462fn collect_functions(g: &Graph) -> Vec<FunctionDef> {
463    let mut out = Vec::new();
464    for func in g
465        .subjects_for_predicate_object(vocab::RDF_TYPE, vocab::SH_SPARQL_FUNCTION)
466        .map(|s| s.into_owned())
467        .collect::<Vec<_>>()
468    {
469        let NamedOrBlankNode::NamedNode(iri) = &func else {
470            continue;
471        };
472        let raw = match g
473            .object_for_subject_predicate(&func, vocab::SH_SELECT)
474            .or_else(|| g.object_for_subject_predicate(&func, vocab::SH_ASK))
475            .map(|t| t.into_owned())
476        {
477            Some(Term::Literal(l)) => l.value().to_string(),
478            _ => continue,
479        };
480        out.push(FunctionDef {
481            iri: iri.clone(),
482            params: function_params(g, &func),
483            reads_graph: query_reads_graph(&raw),
484            query: raw,
485        });
486    }
487    out
488}
489
490/// Resolve a SPARQL function's parameter names from the context graph,
491/// sorted by `sh:order` then by local name of `sh:path` (or `sh:name`).
492fn function_params(g: &Graph, func: &NamedOrBlankNode) -> Vec<String> {
493    let mut params: Vec<(i64, String)> = g
494        .objects_for_subject_predicate(func, vocab::SH_PARAMETER)
495        .filter_map(|param_ref| {
496            let param_node = node_of(&param_ref.into_owned())?;
497            let order = g
498                .object_for_subject_predicate(&param_node, vocab::SH_ORDER)
499                .map(|t| t.into_owned())
500                .and_then(|t| match t {
501                    Term::Literal(l) => l.value().parse::<i64>().ok(),
502                    _ => None,
503                })
504                .unwrap_or(0);
505            let name = g
506                .object_for_subject_predicate(&param_node, vocab::SH_NAME)
507                .map(|t| t.into_owned())
508                .and_then(|t| match t {
509                    Term::Literal(l) => Some(l.value().to_string()),
510                    _ => None,
511                })
512                .or_else(|| {
513                    g.object_for_subject_predicate(&param_node, vocab::SH_PATH)
514                        .map(|t| t.into_owned())
515                        .and_then(|t| match t {
516                            Term::NamedNode(n) => Some(local_name(n.as_str()).to_string()),
517                            _ => None,
518                        })
519                })?;
520            Some((order, name))
521        })
522        .collect();
523    params.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
524    params.into_iter().map(|(_, name)| name).collect()
525}
526
527/// Cartesian product of term sets — one arg combo per returned vec.
528fn cartesian_product(sets: &[HashSet<Term>]) -> Vec<Vec<Term>> {
529    sets.iter().fold(vec![vec![]], |acc, set| {
530        acc.into_iter()
531            .flat_map(|combo| {
532                set.iter().map(move |item| {
533                    let mut row = combo.clone();
534                    row.push(item.clone());
535                    row
536                })
537            })
538            .collect()
539    })
540}