Skip to main content

datalog_structural_model/ir/
rule.rs

1//! Datalog [`Rule`] — head atom plus a conjunction of body
2//! literals.
3
4use std::collections::BTreeSet;
5
6use super::aggregate::AggregateBody;
7use super::atom::Atom;
8use super::literal::Literal;
9use super::symbols::VariableSymbol;
10
11/// Datalog rule: `head :- body_1, ..., body_n`.
12#[derive(Clone, Debug, Eq, Hash, PartialEq)]
13pub struct Rule {
14    /// Head atom.
15    pub head: Atom,
16    /// Body literals. An empty body represents a fact.
17    pub body: Vec<Literal>,
18}
19
20impl Rule {
21    /// Construct a rule whose body contains only positive literals.
22    ///
23    /// Convenience for the common case — callers that do not need
24    /// negation never have to name [`Literal`].
25    #[must_use]
26    pub fn positive(head: Atom, body: Vec<Atom>) -> Self {
27        Self {
28            head,
29            body: body.into_iter().map(Literal::Positive).collect(),
30        }
31    }
32
33    /// Construct a rule with an explicit literal body.
34    #[must_use]
35    pub fn new(head: Atom, body: Vec<Literal>) -> Self {
36        Self { head, body }
37    }
38
39    /// Returns true when the rule satisfies Datalog¬ safety.
40    ///
41    /// A rule is safe when:
42    ///
43    /// 1. Every variable appearing in the head is bound by some positive body
44    ///    atom or `Bind`.
45    /// 2. Every variable appearing in a **negative** body literal is bound by
46    ///    some positive body atom or `Bind` (range restriction —
47    ///    negation-as-failure requires the negated atom to be ground by the
48    ///    time it is evaluated).
49    /// 3. Every variable appearing inside a [`Literal::Filter`] is bound by
50    ///    some positive body atom or `Bind` — filters guard candidate bindings,
51    ///    they do not produce them.
52    /// 4. Every variable inside a [`Literal::Bind`] expression is bound by an
53    ///    **earlier** positive body atom or `Bind`, and the `Bind`'s output
54    ///    variable is fresh (not bound by any earlier positive atom or `Bind`).
55    #[must_use]
56    pub fn is_safe(&self) -> bool {
57        let unsafe_head = self.unsafe_head_variables();
58        let unsafe_neg = self.unsafe_negative_variables();
59        let unsafe_filter = self.unsafe_filter_variables();
60        let unsafe_bind = self.unsafe_bind_variables();
61        let unsafe_aggregate = self.unsafe_aggregate_variables();
62        unsafe_head.is_empty()
63            && unsafe_neg.is_empty()
64            && unsafe_filter.is_empty()
65            && unsafe_bind.is_empty()
66            && unsafe_aggregate.is_empty()
67    }
68
69    #[must_use]
70    pub(super) fn unsafe_head_variables(&self) -> Vec<VariableSymbol> {
71        let head_vars = self.head.variables();
72        let bound = self.bound_body_variables();
73        head_vars.difference(&bound).cloned().collect()
74    }
75
76    #[must_use]
77    pub(super) fn unsafe_negative_variables(&self) -> Vec<VariableSymbol> {
78        let bound = self.bound_body_variables();
79        self.body
80            .iter()
81            .filter_map(|lit| match lit {
82                Literal::Negative(atom) => Some(atom),
83                Literal::Positive(_)
84                | Literal::Filter(_)
85                | Literal::Bind { .. }
86                | Literal::Aggregate(_) => None,
87            })
88            .flat_map(Atom::variables)
89            .filter(|v| !bound.contains(v))
90            .collect::<BTreeSet<_>>()
91            .into_iter()
92            .collect()
93    }
94
95    #[must_use]
96    pub(super) fn unsafe_filter_variables(&self) -> Vec<VariableSymbol> {
97        let bound = self.bound_body_variables();
98        self.body
99            .iter()
100            .filter_map(|lit| match lit {
101                Literal::Filter(filter) => Some(filter.variables()),
102                Literal::Positive(_)
103                | Literal::Negative(_)
104                | Literal::Bind { .. }
105                | Literal::Aggregate(_) => None,
106            })
107            .flatten()
108            .filter(|v| !bound.contains(v))
109            .collect::<BTreeSet<_>>()
110            .into_iter()
111            .collect()
112    }
113
114    /// Position-aware safety check for `BIND(expr AS ?var)`.
115    ///
116    /// Walks the body in order, building up the set of variables
117    /// bound by every positive atom and prior `Bind`. Each
118    /// `Bind` is inspected when first encountered:
119    ///
120    /// * every variable inside `expr` must already be in the bound-set (i.e.
121    ///   produced by an earlier positive atom or `Bind`);
122    /// * the `Bind`'s output variable must NOT already be in the bound-set —
123    ///   `BIND` introduces *fresh* variables.
124    ///
125    /// Both kinds of failure surface the offending variable for
126    /// diagnostics. Positive atoms anywhere in the body
127    /// contribute their variables to the bound-set up front (the
128    /// evaluator joins atoms in order, but positive-atom variables
129    /// are present at the binding-set level regardless of position
130    /// — so this only constrains the `Bind`-related ordering).
131    #[must_use]
132    pub(super) fn unsafe_bind_variables(&self) -> Vec<VariableSymbol> {
133        let mut bound = self.positive_atom_variables();
134        let mut errors: BTreeSet<VariableSymbol> = BTreeSet::new();
135        for lit in &self.body {
136            let Literal::Bind { var, expr } = lit else {
137                continue;
138            };
139            errors.extend(expr.variables().into_iter().filter(|v| !bound.contains(v)));
140            if bound.contains(var) {
141                errors.insert(var.clone());
142            }
143            bound.insert(var.clone());
144        }
145        errors.into_iter().collect()
146    }
147
148    /// Safety check for [`Literal::Aggregate`].
149    ///
150    /// Each aggregate body literal must satisfy:
151    ///
152    /// 1. `input_var` and every `group_by` key are bound by some inner-body
153    ///    positive atom or `BIND` output (the aggregator can only fold values
154    ///    that the inner sub-query actually produces).
155    /// 2. `result_var` is fresh — it does not appear in any other body
156    ///    literal's variable set or in the rule's head before this aggregate
157    ///    fires. (A later aggregate's `result_var` may shadow an earlier
158    ///    aggregate's inner-only variable, since inner scopes don't leak.)
159    /// 3. Inner-body literals satisfy the same per-kind safety rules the outer
160    ///    rule body enforces — negative literals and filters reference only
161    ///    inner-positive / inner-`BIND` variables; inner `BIND` expressions
162    ///    reference only earlier inner positives / binds and the `BIND` output
163    ///    variable is fresh inside the inner scope.
164    ///
165    /// Nested aggregates are reported separately by
166    /// [`Self::has_nested_aggregate`] and surface as
167    /// [`super::error::ProgramValidationError::NestedAggregateInInnerBody`].
168    #[must_use]
169    pub(super) fn unsafe_aggregate_variables(&self) -> Vec<VariableSymbol> {
170        let mut already_bound = self.positive_atom_variables();
171        for lit in &self.body {
172            if let Literal::Bind { var, .. } = lit {
173                already_bound.insert(var.clone());
174            }
175        }
176        let mut errors: BTreeSet<VariableSymbol> = BTreeSet::new();
177        for lit in &self.body {
178            let Literal::Aggregate(agg) = lit else {
179                continue;
180            };
181            let inner_bound = agg.inner_bound_variables();
182            if !inner_bound.contains(&agg.input_var) {
183                errors.insert(agg.input_var.clone());
184            }
185            for key in &agg.group_by {
186                if !inner_bound.contains(key) {
187                    errors.insert(key.clone());
188                }
189            }
190            if already_bound.contains(&agg.result_var) {
191                errors.insert(agg.result_var.clone());
192            }
193            errors.extend(unsafe_inner_body_variables(agg, &inner_bound));
194            // Once this aggregate fires, its result_var becomes
195            // visible to subsequent body literals.
196            already_bound.insert(agg.result_var.clone());
197        }
198        errors.into_iter().collect()
199    }
200
201    /// Returns `true` when any aggregate body literal's `inner_body`
202    /// contains a nested [`Literal::Aggregate`]. Reported by
203    /// [`super::program::Program::validate`] as
204    /// [`super::error::ProgramValidationError::NestedAggregateInInnerBody`].
205    #[must_use]
206    pub(super) fn has_nested_aggregate_in_inner_body(&self) -> bool {
207        self.body.iter().any(|lit| match lit {
208            Literal::Aggregate(agg) => agg.contains_nested_aggregate(),
209            _ => false,
210        })
211    }
212
213    /// Returns `true` when any body atom — positive, negative, or
214    /// inside an aggregate's `inner_body` — carries a
215    /// [`super::term::Term::Skolem`] term. SKOLEM is only
216    /// meaningful in head positions; body matches against a
217    /// SKOLEM-derived IRI would unify against a value the EDB
218    /// can't produce. Reported as
219    /// [`super::error::ProgramValidationError::SkolemInBodyAtom`].
220    #[must_use]
221    pub(super) fn has_skolem_in_body(&self) -> bool {
222        self.body.iter().any(literal_contains_skolem)
223    }
224
225    /// Variables bound by the time the rule head fires: positive
226    /// body atoms, every `Bind` output variable, and every
227    /// `Aggregate` body literal's exported variables
228    /// (`result_var` and `group_by` — the inner-only variables
229    /// stay scoped inside the aggregate). Used by head,
230    /// negation, and filter safety checks.
231    #[must_use]
232    fn bound_body_variables(&self) -> BTreeSet<VariableSymbol> {
233        let mut out = self.positive_atom_variables();
234        for lit in &self.body {
235            match lit {
236                Literal::Bind { var, .. } => {
237                    out.insert(var.clone());
238                }
239                Literal::Aggregate(agg) => {
240                    out.extend(agg.exported_variables());
241                }
242                Literal::Positive(_) | Literal::Negative(_) | Literal::Filter(_) => {}
243            }
244        }
245        out
246    }
247
248    /// Variables produced by positive body atoms only (no
249    /// `Bind` output variables, no aggregate exports). Used by
250    /// `Bind` safety as the initial bound-set before walking
251    /// the body.
252    #[must_use]
253    fn positive_atom_variables(&self) -> BTreeSet<VariableSymbol> {
254        self.body
255            .iter()
256            .filter_map(|lit| match lit {
257                Literal::Positive(atom) => Some(atom.variables()),
258                Literal::Negative(_)
259                | Literal::Filter(_)
260                | Literal::Bind { .. }
261                | Literal::Aggregate(_) => None,
262            })
263            .flatten()
264            .collect()
265    }
266}
267
268/// Returns `true` when the literal carries a SKOLEM term anywhere
269/// in its sub-tree. Filters and binds never name predicates, so
270/// they're SKOLEM-free by construction; aggregates expose their
271/// inner atoms (themselves possibly SKOLEM-bearing).
272fn literal_contains_skolem(lit: &Literal) -> bool {
273    match lit {
274        Literal::Positive(atom) | Literal::Negative(atom) => atom.contains_skolem(),
275        Literal::Aggregate(agg) => agg.inner_body.iter().any(literal_contains_skolem),
276        Literal::Filter(_) | Literal::Bind { .. } => false,
277    }
278}
279
280/// Per-kind safety check for an aggregate's inner body.
281///
282/// Mirrors the outer-rule safety rules
283/// ([`Rule::unsafe_negative_variables`] /
284/// [`Rule::unsafe_filter_variables`] /
285/// [`Rule::unsafe_bind_variables`]) applied to the inner scope:
286///
287/// - inner negative literals reference only variables that an inner positive
288///   atom or `BIND` binds;
289/// - inner filters reference only those inner-bound variables;
290/// - inner `BIND` expressions reference only earlier inner positives / binds
291///   and the `BIND` output variable is fresh inside the inner scope.
292///
293/// Nested aggregates are out of scope here — see
294/// [`Rule::has_nested_aggregate_in_inner_body`].
295fn unsafe_inner_body_variables(
296    agg: &AggregateBody,
297    inner_bound: &BTreeSet<VariableSymbol>,
298) -> BTreeSet<VariableSymbol> {
299    let mut errors: BTreeSet<VariableSymbol> = BTreeSet::new();
300    // Inner-Bind position-aware safety. Start the bound set
301    // from inner-positive variables; walk inner `BIND`s in
302    // order, checking expression references and freshness, and
303    // extending the bound set with each output.
304    let mut inner_bind_bound: BTreeSet<VariableSymbol> = agg
305        .inner_body
306        .iter()
307        .filter_map(|lit| match lit {
308            Literal::Positive(atom) => Some(atom.variables()),
309            _ => None,
310        })
311        .flatten()
312        .collect();
313    for lit in &agg.inner_body {
314        match lit {
315            Literal::Negative(atom) => {
316                for v in atom.variables() {
317                    if !inner_bound.contains(&v) {
318                        errors.insert(v);
319                    }
320                }
321            }
322            Literal::Filter(filter) => {
323                for v in filter.variables() {
324                    if !inner_bound.contains(&v) {
325                        errors.insert(v);
326                    }
327                }
328            }
329            Literal::Bind { var, expr } => {
330                for v in expr.variables() {
331                    if !inner_bind_bound.contains(&v) {
332                        errors.insert(v);
333                    }
334                }
335                if inner_bind_bound.contains(var) {
336                    errors.insert(var.clone());
337                }
338                inner_bind_bound.insert(var.clone());
339            }
340            Literal::Positive(_) | Literal::Aggregate(_) => {}
341        }
342    }
343    errors
344}