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