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}