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(
37    Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize,
38)]
39#[serde(deny_unknown_fields)]
40pub enum Aggregator {
41    /// `COUNT(?expr)` — number of distinct `?expr` values that
42    /// appear in the inner result set.
43    Count,
44    /// `SUM(?expr)` — numeric sum of `?expr` values.
45    Sum,
46    /// `MIN(?expr)` — minimum `?expr` value under SPARQL's
47    /// ordering (numeric / lexicographic per term kind).
48    Min,
49    /// `MAX(?expr)` — maximum `?expr` value under the same
50    /// ordering as [`Self::Min`].
51    Max,
52    /// `AVG(?expr)` — numeric mean of `?expr` values. Undefined
53    /// for an empty input.
54    Avg,
55}
56
57impl Aggregator {
58    /// Render the aggregator as its canonical uppercase name.
59    /// Used by diagnostics and rule-name maps.
60    #[must_use]
61    pub fn name(self) -> &'static str {
62        match self {
63            Self::Count => "COUNT",
64            Self::Sum => "SUM",
65            Self::Min => "MIN",
66            Self::Max => "MAX",
67            Self::Avg => "AVG",
68        }
69    }
70}
71
72/// An aggregation sub-query inside a rule body.
73///
74/// Semantically equivalent to a SPARQL `Group { ... } over { ... }`
75/// projection: evaluate `inner_body` as a sub-query, group the
76/// results by `group_by`, fold each group's `input_var` values
77/// with `aggregator`, and bind the per-group result to
78/// `result_var`. The aggregate then participates in the outer
79/// rule body as if `result_var` were produced by a positive
80/// atom that ranges over the per-group results.
81///
82/// # Variable scoping
83///
84/// `inner_body` is its own scope: positive atoms and `BIND`
85/// outputs inside the inner body bind variables that are visible
86/// only within the aggregation. The three sets of variables that
87/// leak out are `group_by`, `input_var`, and (after the
88/// aggregation fires) `result_var`. Concretely:
89///
90/// - `group_by` and `input_var` MUST be bound by some positive atom or `BIND`
91///   inside `inner_body`.
92/// - `result_var` MUST be fresh — it must not appear anywhere else in the outer
93///   rule (head or body).
94///
95/// # Inner-body literal kinds
96///
97/// `inner_body` accepts the same four literal kinds the outer
98/// rule body accepts — [`Literal::Positive`], [`Literal::Negative`],
99/// [`Literal::Filter`], and [`Literal::Bind`] — under the usual
100/// safety rules (negated atoms and filter expressions reference
101/// only inner-body-bound variables; `BIND` expressions reference
102/// only earlier inner positives / binds and bind a fresh variable).
103/// Nested aggregates inside an aggregate's inner body are rejected
104/// at validation time as
105/// [`super::error::ProgramValidationError::NestedAggregateInInnerBody`].
106#[derive(Clone, Debug, Eq, Hash, PartialEq, serde::Serialize, serde::Deserialize)]
107#[serde(deny_unknown_fields)]
108pub struct AggregateBody {
109    /// The variable that receives the per-group aggregate
110    /// result.
111    pub result_var: VariableSymbol,
112    /// Which aggregator to apply.
113    pub aggregator: Aggregator,
114    /// The variable whose values are folded into the aggregate
115    /// (the "input expression" in SPARQL terms).
116    pub input_var: VariableSymbol,
117    /// Group-by keys. The aggregator runs once per distinct
118    /// tuple of `group_by` values; an empty `group_by`
119    /// collapses every inner-body binding into a single group.
120    pub group_by: Vec<VariableSymbol>,
121    /// Literals forming the aggregation's inner sub-query.
122    /// Accepts [`Literal::Positive`], [`Literal::Negative`],
123    /// [`Literal::Filter`], and [`Literal::Bind`] under the
124    /// per-kind safety rules. Nested
125    /// [`Literal::Aggregate`] is rejected at validation time.
126    pub inner_body: Vec<Literal>,
127}
128
129impl AggregateBody {
130    /// Variables bound by the inner body — every variable a
131    /// positive inner atom binds, plus every `BIND`'s output
132    /// variable. Used by safety analysis to verify that
133    /// `input_var` and `group_by` are sourced from the inner
134    /// body and that inner negation / filter / bind references
135    /// stay within the inner scope.
136    #[must_use]
137    pub fn inner_bound_variables(&self) -> BTreeSet<VariableSymbol> {
138        let mut out = BTreeSet::new();
139        for lit in &self.inner_body {
140            match lit {
141                Literal::Positive(atom) => out.extend(atom.variables()),
142                Literal::Bind { var, .. } => {
143                    out.insert(var.clone());
144                }
145                Literal::Negative(_) | Literal::Filter(_) | Literal::Aggregate(_) => {}
146            }
147        }
148        out
149    }
150
151    /// Variables this aggregate exposes to the outer rule body:
152    /// the freshly-bound `result_var` plus every key the
153    /// outer rule groups by. The outer-scope view of an
154    /// aggregate body literal.
155    #[must_use]
156    pub fn exported_variables(&self) -> BTreeSet<VariableSymbol> {
157        let mut out: BTreeSet<VariableSymbol> = self.group_by.iter().cloned().collect();
158        out.insert(self.result_var.clone());
159        out
160    }
161
162    /// Returns `true` when the inner body contains a nested
163    /// `Literal::Aggregate`. Nested aggregates are rejected as
164    /// [`super::error::ProgramValidationError::NestedAggregateInInnerBody`].
165    #[must_use]
166    pub fn contains_nested_aggregate(&self) -> bool {
167        self.inner_body
168            .iter()
169            .any(|lit| matches!(lit, Literal::Aggregate(_)))
170    }
171}