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