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}