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