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}