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    ///    positive body atom, an earlier `Bind`, or an earlier aggregate export,
68    ///    and the `Bind`'s output variable is fresh in that bound set.
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, prior `Bind`, and prior aggregate export. 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            if let Literal::Aggregate(aggregate) = lit {
151                bound.extend(aggregate.exported_variables());
152                continue;
153            }
154            let Literal::Bind { var, expr } = lit else {
155                continue;
156            };
157            errors.extend(expr.variables().into_iter().filter(|v| !bound.contains(v)));
158            if bound.contains(var) {
159                errors.insert(var.clone());
160            }
161            bound.insert(var.clone());
162        }
163        errors.into_iter().collect()
164    }
165
166    /// Safety check for [`Literal::Aggregate`].
167    ///
168    /// Each aggregate body literal must satisfy:
169    ///
170    /// 1. `input_var` and every `group_by` key are bound by some inner-body
171    ///    positive atom or `BIND` output (the aggregator can only fold values
172    ///    that the inner sub-query actually produces).
173    /// 2. `result_var` is fresh — it does not appear in any other body
174    ///    literal's variable set or in the rule's head before this aggregate
175    ///    fires. (A later aggregate's `result_var` may shadow an earlier
176    ///    aggregate's inner-only variable, since inner scopes don't leak.)
177    /// 3. Inner-body literals satisfy the same per-kind safety rules the outer
178    ///    rule body enforces — negative literals and filters reference only
179    ///    inner-positive / inner-`BIND` variables; inner `BIND` expressions
180    ///    reference only earlier inner positives / binds and the `BIND` output
181    ///    variable is fresh inside the inner scope.
182    ///
183    /// Nested aggregates are reported separately by
184    /// [`Self::has_nested_aggregate`] and surface as
185    /// [`super::error::ProgramValidationError::NestedAggregateInInnerBody`].
186    #[must_use]
187    pub(super) fn unsafe_aggregate_variables(&self) -> Vec<VariableSymbol> {
188        let mut already_bound = self.positive_atom_variables();
189        for lit in &self.body {
190            if let Literal::Bind { var, .. } = lit {
191                already_bound.insert(var.clone());
192            }
193        }
194        let mut errors: BTreeSet<VariableSymbol> = BTreeSet::new();
195        for lit in &self.body {
196            let Literal::Aggregate(agg) = lit else {
197                continue;
198            };
199            let inner_bound = agg.inner_bound_variables();
200            if !inner_bound.contains(&agg.input_var) {
201                errors.insert(agg.input_var.clone());
202            }
203            for key in &agg.group_by {
204                if !inner_bound.contains(key) {
205                    errors.insert(key.clone());
206                }
207            }
208            if already_bound.contains(&agg.result_var) {
209                errors.insert(agg.result_var.clone());
210            }
211            errors.extend(unsafe_inner_body_variables(agg, &inner_bound));
212            // Both the result and grouping keys are visible to subsequent
213            // body literals; inner-only variables remain scoped inside.
214            already_bound.extend(agg.exported_variables());
215        }
216        errors.into_iter().collect()
217    }
218
219    /// Returns `true` when any aggregate body literal's `inner_body`
220    /// contains a nested [`Literal::Aggregate`]. Reported by
221    /// [`super::program::Program::validate`] as
222    /// [`super::error::ProgramValidationError::NestedAggregateInInnerBody`].
223    #[must_use]
224    pub(super) fn has_nested_aggregate_in_inner_body(&self) -> bool {
225        self.body.iter().any(|lit| match lit {
226            Literal::Aggregate(agg) => agg.contains_nested_aggregate(),
227            _ => false,
228        })
229    }
230
231    /// Variables bound by the time the rule head fires: positive
232    /// body atoms, every `Bind` output variable, and every
233    /// `Aggregate` body literal's exported variables
234    /// (`result_var` and `group_by` — the inner-only variables
235    /// stay scoped inside the aggregate). Used by head,
236    /// negation, and filter safety checks.
237    #[must_use]
238    fn bound_body_variables(&self) -> BTreeSet<VariableSymbol> {
239        let mut out = self.positive_atom_variables();
240        for lit in &self.body {
241            match lit {
242                Literal::Bind { var, .. } => {
243                    out.insert(var.clone());
244                }
245                Literal::Aggregate(agg) => {
246                    out.extend(agg.exported_variables());
247                }
248                Literal::Positive(_) | Literal::Negative(_) | Literal::Filter(_) => {}
249            }
250        }
251        out
252    }
253
254    /// Variables produced by positive body atoms only (no
255    /// `Bind` output variables, no aggregate exports). Used by
256    /// `Bind` safety as the initial bound-set before walking
257    /// the body.
258    #[must_use]
259    fn positive_atom_variables(&self) -> BTreeSet<VariableSymbol> {
260        self.body
261            .iter()
262            .filter_map(|lit| match lit {
263                Literal::Positive(atom) => Some(atom.variables()),
264                Literal::Negative(_)
265                | Literal::Filter(_)
266                | Literal::Bind { .. }
267                | Literal::Aggregate(_) => None,
268            })
269            .flatten()
270            .collect()
271    }
272}
273
274/// Validate variable safety inside an aggregate's inner body.
275fn unsafe_inner_body_variables(
276    agg: &AggregateBody,
277    inner_bound: &BTreeSet<VariableSymbol>,
278) -> BTreeSet<VariableSymbol> {
279    let mut errors: BTreeSet<VariableSymbol> = BTreeSet::new();
280    // Inner-Bind position-aware safety. Start the bound set
281    // from inner-positive variables; walk inner `BIND`s in
282    // order, checking expression references and freshness, and
283    // extending the bound set with each output.
284    let mut inner_bind_bound: BTreeSet<VariableSymbol> = agg
285        .inner_body
286        .iter()
287        .filter_map(|lit| match lit {
288            Literal::Positive(atom) => Some(atom.variables()),
289            _ => None,
290        })
291        .flatten()
292        .collect();
293    for lit in &agg.inner_body {
294        match lit {
295            Literal::Negative(atom) => {
296                for v in atom.variables() {
297                    if !inner_bound.contains(&v) {
298                        errors.insert(v);
299                    }
300                }
301            }
302            Literal::Filter(filter) => {
303                for v in filter.variables() {
304                    if !inner_bound.contains(&v) {
305                        errors.insert(v);
306                    }
307                }
308            }
309            Literal::Bind { var, expr } => {
310                for v in expr.variables() {
311                    if !inner_bind_bound.contains(&v) {
312                        errors.insert(v);
313                    }
314                }
315                if inner_bind_bound.contains(var) {
316                    errors.insert(var.clone());
317                }
318                inner_bind_bound.insert(var.clone());
319            }
320            Literal::Positive(_) | Literal::Aggregate(_) => {}
321        }
322    }
323    errors
324}