Skip to main content

datalog_structural_model/ir/
aggregate.rs

1//! Aggregation IR for extension.
2//!
3//! Datalog with aggregation extends pure Datalog by allowing a
4//! body literal that folds a set of inner bindings into a single
5//! aggregate value, optionally grouped by a chosen set of
6//! variables. The aggregate result is bound to a fresh variable
7//! that downstream body literals (and the rule head) can consume.
8//!
9//! This module defines the IR data types only:
10//! [`Aggregator`] (the SQL/SPARQL aggregator vocabulary) and
11//! [`AggregateBody`] (an aggregation sub-query). The
12//! corresponding body-literal variant is
13//! [`Literal::Aggregate`](super::literal::Literal::Aggregate),
14//! and rule-level safety analysis is in
15//! [`Rule::unsafe_aggregate_variables`](super::rule::Rule::unsafe_aggregate_variables).
16//!
17//! **Stratification**: an aggregation depends on the *complete*
18//! extension of its inner predicates, so each predicate referenced
19//! from the aggregate's inner body forms a polarity-bearing
20//! dependency edge analogous to a negative literal — those
21//! predicates must sit in a strictly lower stratum than the
22//! rule's head predicate.
23
24use std::collections::BTreeSet;
25
26use super::literal::Literal;
27use super::symbols::VariableSymbol;
28
29/// SQL/SPARQL aggregator vocabulary supported in rule bodies.
30///
31/// Mirrors the subset called out in the extension deliverable
32/// (the rule validation specification):
33/// MIN, MAX, SUM, COUNT, AVG. SPARQL's `SAMPLE` and
34/// `GROUP_CONCAT` are not in scope for extension — they map onto
35/// SPARQL's `Group` operator rather than a Datalog rule body.
36#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
37pub enum Aggregator {
38    /// `COUNT(?expr)` — number of distinct `?expr` values that
39    /// appear in the inner result set.
40    Count,
41    /// `SUM(?expr)` — numeric sum of `?expr` values.
42    Sum,
43    /// `MIN(?expr)` — minimum `?expr` value under SPARQL's
44    /// ordering (numeric / lexicographic per term kind).
45    Min,
46    /// `MAX(?expr)` — maximum `?expr` value under the same
47    /// ordering as [`Self::Min`].
48    Max,
49    /// `AVG(?expr)` — numeric mean of `?expr` values. Undefined
50    /// for an empty input.
51    Avg,
52}
53
54impl Aggregator {
55    /// Render the aggregator as its canonical uppercase name.
56    /// Used by diagnostics and rule-name maps.
57    #[must_use]
58    pub fn name(self) -> &'static str {
59        match self {
60            Self::Count => "COUNT",
61            Self::Sum => "SUM",
62            Self::Min => "MIN",
63            Self::Max => "MAX",
64            Self::Avg => "AVG",
65        }
66    }
67}
68
69/// An aggregation sub-query inside a rule body.
70///
71/// Semantically equivalent to a SPARQL `Group { ... } over { ... }`
72/// projection: evaluate `inner_body` as a sub-query, group the
73/// results by `group_by`, fold each group's `input_var` values
74/// with `aggregator`, and bind the per-group result to
75/// `result_var`. The aggregate then participates in the outer
76/// rule body as if `result_var` were produced by a positive
77/// atom that ranges over the per-group results.
78///
79/// # Variable scoping
80///
81/// `inner_body` is its own scope: positive atoms and `BIND`
82/// outputs inside the inner body bind variables that are visible
83/// only within the aggregation. The three sets of variables that
84/// leak out are `group_by`, `input_var`, and (after the
85/// aggregation fires) `result_var`. Concretely:
86///
87/// - `group_by` and `input_var` MUST be bound by some positive atom or `BIND`
88///   inside `inner_body`.
89/// - `result_var` MUST be fresh — it must not appear anywhere else in the outer
90///   rule (head or body).
91///
92/// # Inner-body literal kinds
93///
94/// `inner_body` accepts the same four literal kinds the outer
95/// rule body accepts — [`Literal::Positive`], [`Literal::Negative`],
96/// [`Literal::Filter`], and [`Literal::Bind`] — under the usual
97/// safety rules (negated atoms and filter expressions reference
98/// only inner-body-bound variables; `BIND` expressions reference
99/// only earlier inner positives / binds and bind a fresh variable).
100/// Nested aggregates inside an aggregate's inner body are rejected
101/// at validation time as
102/// [`super::error::ProgramValidationError::NestedAggregateInInnerBody`].
103#[derive(Clone, Debug, Eq, Hash, PartialEq)]
104pub struct AggregateBody {
105    /// The variable that receives the per-group aggregate
106    /// result.
107    pub result_var: VariableSymbol,
108    /// Which aggregator to apply.
109    pub aggregator: Aggregator,
110    /// The variable whose values are folded into the aggregate
111    /// (the "input expression" in SPARQL terms).
112    pub input_var: VariableSymbol,
113    /// Group-by keys. The aggregator runs once per distinct
114    /// tuple of `group_by` values; an empty `group_by`
115    /// collapses every inner-body binding into a single group.
116    pub group_by: Vec<VariableSymbol>,
117    /// Literals forming the aggregation's inner sub-query.
118    /// Accepts [`Literal::Positive`], [`Literal::Negative`],
119    /// [`Literal::Filter`], and [`Literal::Bind`] under the
120    /// per-kind safety rules. Nested
121    /// [`Literal::Aggregate`] is rejected at validation time.
122    pub inner_body: Vec<Literal>,
123}
124
125impl AggregateBody {
126    /// Variables bound by the inner body — every variable a
127    /// positive inner atom binds, plus every `BIND`'s output
128    /// variable. Used by safety analysis to verify that
129    /// `input_var` and `group_by` are sourced from the inner
130    /// body and that inner negation / filter / bind references
131    /// stay within the inner scope.
132    #[must_use]
133    pub fn inner_bound_variables(&self) -> BTreeSet<VariableSymbol> {
134        let mut out = BTreeSet::new();
135        for lit in &self.inner_body {
136            match lit {
137                Literal::Positive(atom) => out.extend(atom.variables()),
138                Literal::Bind { var, .. } => {
139                    out.insert(var.clone());
140                }
141                Literal::Negative(_) | Literal::Filter(_) | Literal::Aggregate(_) => {}
142            }
143        }
144        out
145    }
146
147    /// Variables this aggregate exposes to the outer rule body:
148    /// the freshly-bound `result_var` plus every key the
149    /// outer rule groups by. The outer-scope view of an
150    /// aggregate body literal.
151    #[must_use]
152    pub fn exported_variables(&self) -> BTreeSet<VariableSymbol> {
153        let mut out: BTreeSet<VariableSymbol> = self.group_by.iter().cloned().collect();
154        out.insert(self.result_var.clone());
155        out
156    }
157
158    /// Returns `true` when the inner body contains a nested
159    /// `Literal::Aggregate`. Nested aggregates are rejected as
160    /// [`super::error::ProgramValidationError::NestedAggregateInInnerBody`].
161    #[must_use]
162    pub fn contains_nested_aggregate(&self) -> bool {
163        self.inner_body
164            .iter()
165            .any(|lit| matches!(lit, Literal::Aggregate(_)))
166    }
167}