Rindle docs and package mapSkip to main content

rindle/op/
reduce.rs

1//! `Reduce` — incremental invertible aggregates (`REDUCE-DESIGN.md`): `count(*)`,
2//! `sum(col)`, and `avg(col)`, each maintained from a fixed-size accumulator so a
3//! `Remove` never re-reads the inputs (the [`StorageValue::Reduce`] state carries the
4//! row `count` plus one `(int_sum, float_sum, non_null, float_count)`
5//! [`ReduceAcc`](crate::storage::ReduceAcc) per `Sum`/`Avg`). Two regimes:
6//!
7//! - **Global** (`partition_key = None`): collapses the whole input into **one**
8//!   synthetic `[<agg>]` row, born once at hydrate and then **immortal** (`count` of
9//!   empty input is `0`, `sum`/`avg` are `NULL`; the row never dies → steady state is a
10//!   pure `Edit` stream).
11//! - **Grouped** (`partition_key = Some`, the top-level `GROUP BY` case): one
12//!   `[group_key…, <agg>]` row **per group**, keyed in storage per group. A group is
13//!   **born** (`Add`) on its first row and **dies** (`Remove`) when its row count hits
14//!   `0`. Unlike `count`, an in-place `Edit` within a group can move a `sum`/`avg`, so
15//!   the operator emits whenever the aggregate row actually changes (§6).
16//!
17//! This is the first operator that *originates* `Edit`s mid-graph and the first that
18//! **reshapes** its row (output is synthetic, not an input row), so — unlike
19//! [`Skip`](crate::op::Skip)/[`Take`](crate::op::Take), which forward input nodes
20//! unchanged — it carries its own output [`Schema`] and reads/writes a
21//! [`StorageValue::Reduce`] accumulator. Like `Take` it is **stateful** (state in the
22//! [`Graph`](crate::graph::Graph) storage arena, read through a shared `&Graph` borrow, so the reentrant
23//! first-sight fold composes for free) and, in the grouped case, relies on the input
24//! connection **split-editing the partition key** so an `Edit` never crosses groups.
25//!
26//! ## Hydration regime (grouped)
27//!
28//! This file implements the **eager / top-level `GROUP BY`** regime: the consumer
29//! (`View`) hydrates with one unconstrained `fetch`, which folds the entire input and
30//! materializes *every* group, so after hydrate a `push` `Add` to a group with no
31//! state is a genuine **birth**. The **lazy** regime — a relationship aggregate whose
32//! groups are fetched one at a time (constrained) by a parent join — needs the
33//! opposite "drop-and-fold-on-fetch" rule (and persisting count-0 groups for the 0→1
34//! transition); it lands with the join-attachment milestone (`REDUCE-DESIGN.md` §8/§9)
35//! and is **not** handled here. Until then `fetch` ignores the request constraint and
36//! always full-folds.
37
38use std::cell::Cell;
39use std::collections::HashMap;
40
41use crate::change::{Change, Constraint, FetchRequest, Node, NodeStream, OutEdge};
42use crate::graph::{Graph, NodeId, StorageId};
43use crate::op::partition::{
44    constraint_matches_partition_key, encode_state_key, partition_key_unchanged,
45};
46use crate::storage::{ReduceAcc, StorageValue};
47use crate::value::{
48    compare_rows, owned_row, ColId, OwnedRow as Row, OwnedValue, Schema, Value, ValueType,
49};
50
51/// Which aggregate a [`Reduce`] column computes, in output-column order
52/// (`REDUCE-DESIGN.md` §5). All three are **invertible** — a `Remove` folds into a
53/// fixed-size accumulator without re-reading the inputs — which is what lets `Reduce`
54/// maintain them from a pure delta stream.
55#[derive(Clone, Debug)]
56pub enum AggSpec {
57    /// `count(*)` — counts input rows (`NULL`s included). `Add` `+1`, `Remove` `-1`.
58    Count,
59    /// `sum(col)` — Σ of the column's non-`NULL` values. `Add` `+v`, `Remove` `-v`.
60    Sum(ColId),
61    /// `avg(col)` — `sum(col) / count(col)` over non-`NULL` values (SQL semantics:
62    /// the denominator is the non-`NULL` count, not `count(*)`); `NULL` on empty.
63    Avg(ColId),
64}
65
66impl AggSpec {
67    /// The synthetic output column name this aggregate contributes (`count`/`sum`/`avg`).
68    /// The builder's view schema mirrors these so the production `View` differ and the
69    /// scalar projection resolve the same column.
70    fn col_name(&self) -> &'static str {
71        match self {
72            AggSpec::Count => "count",
73            AggSpec::Sum(_) => "sum",
74            AggSpec::Avg(_) => "avg",
75        }
76    }
77
78    /// The declared [`ValueType`] of this aggregate's output column (design 226
79    /// §4.1): `count` and `avg` are `Number`; `sum` follows its input column —
80    /// `Int` over a declared `int64` input (the exact-i64 sum plane), `Number`
81    /// otherwise. Shared by [`Reduce::with_input_types`] and the builder's
82    /// view-schema twins so the two derivations cannot drift.
83    pub fn output_type(&self, input: &Schema) -> ValueType {
84        match self {
85            AggSpec::Count | AggSpec::Avg(_) => ValueType::Number,
86            AggSpec::Sum(col) => match input.column_types.get(*col) {
87                Some(ValueType::Int) => ValueType::Int,
88                _ => ValueType::Number,
89            },
90        }
91    }
92}
93
94/// Fold one row's cell into an accumulator with `sign` `+1` (an `Add`) or `-1`
95/// (a `Remove`). `NULL`/`Absent` contribute nothing; a non-numeric non-`NULL` value
96/// counts toward `non_null` with a `0` sum contribution (matching SQLite's coerce-to-0).
97fn acc_delta(acc: &mut ReduceAcc, v: Value, sign: i64) {
98    match v {
99        Value::Int(i) => {
100            // i128 accumulation (design 226 §5.3): a delta-order transient past
101            // i64::MAX must neither wrap nor error, and `-1 × i64::MIN` must not
102            // overflow on a Remove. The i128 cannot overflow at any real
103            // cardinality; only the EMITTED total is bounded (`sum_value`).
104            acc.int_sum += i128::from(sign) * i128::from(i);
105            acc.non_null += sign;
106        }
107        Value::Float(f) => {
108            acc.float_sum += sign as f64 * f;
109            acc.float_count += sign;
110            acc.non_null += sign;
111        }
112        Value::Null | Value::Absent => {}
113        Value::Bool(_) | Value::Str(_) | Value::Json(_) => acc.non_null += sign,
114    }
115}
116
117/// The emitted `sum` cell: `NULL` for an empty (all-`NULL`) input, otherwise `Int` while
118/// every contributing value was an integer and `Float` once any float contributed.
119///
120/// `Err(total)` when the **set total** of an all-integer sum falls outside i64
121/// (design 226 §5.3) — SQLite's *result* contract, not its scan mechanics: the
122/// i128 accumulator has already absorbed any delta-order transient; only what
123/// would be emitted is bounded. [`Reduce::agg_row`] renders the error case as
124/// `NULL`; the typed error itself is raised at the `try_*` boundary, where
125/// `Graph::take_runtime_error` polls [`Reduce::overflow_count`].
126fn sum_value(a: &ReduceAcc) -> Result<OwnedValue, i128> {
127    if a.non_null == 0 {
128        Ok(OwnedValue::Null)
129    } else if a.float_count == 0 {
130        match i64::try_from(a.int_sum) {
131            Ok(total) => Ok(OwnedValue::Int(total)),
132            Err(_) => Err(a.int_sum),
133        }
134    } else {
135        Ok(OwnedValue::Float(a.int_sum as f64 + a.float_sum))
136    }
137}
138
139/// The emitted `avg` cell: SQL `avg` is always real (or `NULL` when there are no
140/// non-`NULL` values).
141fn avg_value(a: &ReduceAcc) -> OwnedValue {
142    if a.non_null == 0 {
143        OwnedValue::Null
144    } else {
145        OwnedValue::Float((a.int_sum as f64 + a.float_sum) / a.non_null as f64)
146    }
147}
148
149/// Whole-row equality for a synthetic aggregate row, treating `NULL == NULL` and — unlike
150/// [`compare_values`](crate::value::compare_values) — an `Int`/`Float` type change as a
151/// *change* (so a `sum` that flips storage class re-emits). Drives the "emit only if the
152/// row actually changed" rule (`REDUCE-DESIGN.md` §6).
153fn agg_rows_equal(a: &Row, b: &Row) -> bool {
154    a.len() == b.len() && (0..a.len()).all(|i| agg_val_eq(a.col(i), b.col(i)))
155}
156
157fn agg_val_eq(a: Value, b: Value) -> bool {
158    match (a, b) {
159        (Value::Null, Value::Null) | (Value::Absent, Value::Absent) => true,
160        (Value::Bool(x), Value::Bool(y)) => x == y,
161        (Value::Int(x), Value::Int(y)) => x == y,
162        (Value::Float(x), Value::Float(y)) => x == y,
163        (Value::Str(x), Value::Str(y)) => x == y,
164        (Value::Json(x), Value::Json(y)) => x == y,
165        _ => false,
166    }
167}
168
169/// `Reduce`: an invertible aggregate, global or grouped. The running accumulator(s)
170/// live in [`Graph`](crate::graph::Graph) storage under [`Reduce::storage`], not in the struct, so they
171/// survive across `fetch`/`push`.
172pub struct Reduce {
173    /// Upstream input operator (the source connection, or a sub-pipeline tail).
174    pub input: NodeId,
175    /// Scratch-state slot holding the per-group [`StorageValue::Reduce`] counters.
176    pub storage: StorageId,
177    /// The aggregates this node computes, in output-column order. v1: `[Count]`.
178    pub aggs: Vec<AggSpec>,
179    /// The grouping columns, **in input-row coordinates**. `None` = global (a single
180    /// immortal row); `Some` = one row per distinct value-tuple (top-level `GROUP BY`).
181    /// In the output row the group columns occupy positions `0..partition_key.len()`.
182    pub partition_key: Option<Vec<ColId>>,
183    /// Hydration regime for the grouped case (`REDUCE-DESIGN.md` §8.1; moot when
184    /// `partition_key` is `None`). `true` = **eager** (top-level `GROUP BY` → `View`,
185    /// full-fold hydrate; an `Add` to a no-state group births it, death deletes the
186    /// slot). `false` = **lazy** (relationship aggregate → per-group constrained
187    /// `fetch`; an `Add` to a no-state group is dropped and folded on the next fetch,
188    /// death keeps the count-0 slot so the next `Add` re-births).
189    pub eager: bool,
190    /// The **synthetic** output schema (`REDUCE-DESIGN.md` §3): `[group_cols…,
191    /// agg_cols…]`. Global ⇒ just `[count]` with empty PK/sort (singleton-only, §8);
192    /// grouped ⇒ the group columns are the PK and sort, then the aggregate columns.
193    pub schema: Schema,
194    /// The single downstream edge, wired two-phase like every fan-out-seam operator
195    /// (via [`Graph::set_output`](crate::graph::Graph::set_output)).
196    pub output: Cell<Option<OutEdge>>,
197    /// How many **stored** groups currently hold an all-integer `sum` whose set
198    /// total is outside i64 (design 226 §5.3). Maintained by `write_at`/`del_at`
199    /// against the stored state, so it is **state-based, not event-based**: a
200    /// delta-order transient (an Add past `i64::MAX` compensated by a Remove
201    /// before the boundary is consulted) nets back to `0` and does not error,
202    /// while a genuinely-unrepresentable stored total raises the typed error at
203    /// every `try_*` boundary until the state shrinks back into range —
204    /// SQLite's *result* contract (a fresh `SELECT sum(…)` there errors every
205    /// time too). Read by `Graph::take_runtime_error`.
206    pub(crate) overflowed: Cell<i64>,
207}
208
209impl Reduce {
210    /// A **global** single-aggregate reducer over `input` (`spec`). Output is a
211    /// one-column `[<agg>]` row (no key, no sort — a singleton, §8).
212    pub fn global_agg(input: NodeId, storage: StorageId, spec: AggSpec) -> Reduce {
213        let cols = vec![spec.col_name()];
214        Reduce {
215            input,
216            storage,
217            aggs: vec![spec],
218            partition_key: None,
219            eager: true,
220            schema: Schema::new(cols, Vec::new(), Vec::new()),
221            output: Cell::new(None),
222            overflowed: Cell::new(0),
223        }
224    }
225
226    /// A **grouped** single-aggregate reducer: one `[group_key…, <agg>]` row per distinct
227    /// value of `partition_key` (input-row column indices). `key_cols` are the output
228    /// names of the group columns (same arity/order as `partition_key`). The output
229    /// schema's PK and sort are the group columns (`0..k`, ascending), then the aggregate.
230    pub fn grouped_agg(
231        input: NodeId,
232        storage: StorageId,
233        partition_key: Vec<ColId>,
234        key_cols: Vec<&str>,
235        spec: AggSpec,
236    ) -> Reduce {
237        assert_eq!(
238            partition_key.len(),
239            key_cols.len(),
240            "reduce: partition_key arity must match key_cols arity"
241        );
242        let k = partition_key.len();
243        let mut cols = key_cols;
244        cols.push(spec.col_name());
245        let key_ids: Vec<ColId> = (0..k).collect();
246        let sort = (0..k).map(|i| (i, true)).collect();
247        Reduce {
248            input,
249            storage,
250            aggs: vec![spec],
251            partition_key: Some(partition_key),
252            eager: true,
253            schema: Schema::new(cols, key_ids, sort),
254            output: Cell::new(None),
255            overflowed: Cell::new(0),
256        }
257    }
258
259    /// A **global** `count(*)` reducer over `input` — [`global_agg`](Self::global_agg)
260    /// with [`AggSpec::Count`].
261    pub fn count(input: NodeId, storage: StorageId) -> Reduce {
262        Self::global_agg(input, storage, AggSpec::Count)
263    }
264
265    /// A **global** `sum(col)` reducer (input-row column index).
266    pub fn sum(input: NodeId, storage: StorageId, col: ColId) -> Reduce {
267        Self::global_agg(input, storage, AggSpec::Sum(col))
268    }
269
270    /// A **global** `avg(col)` reducer (input-row column index).
271    pub fn avg(input: NodeId, storage: StorageId, col: ColId) -> Reduce {
272        Self::global_agg(input, storage, AggSpec::Avg(col))
273    }
274
275    /// A **grouped** `count(*)` reducer — [`grouped_agg`](Self::grouped_agg) with
276    /// [`AggSpec::Count`].
277    pub fn count_by(
278        input: NodeId,
279        storage: StorageId,
280        partition_key: Vec<ColId>,
281        key_cols: Vec<&str>,
282    ) -> Reduce {
283        Self::grouped_agg(input, storage, partition_key, key_cols, AggSpec::Count)
284    }
285
286    /// A **grouped** `sum(col)` reducer (`col` in input-row coordinates).
287    pub fn sum_by(
288        input: NodeId,
289        storage: StorageId,
290        partition_key: Vec<ColId>,
291        key_cols: Vec<&str>,
292        col: ColId,
293    ) -> Reduce {
294        Self::grouped_agg(input, storage, partition_key, key_cols, AggSpec::Sum(col))
295    }
296
297    /// A **grouped** `avg(col)` reducer (`col` in input-row coordinates).
298    pub fn avg_by(
299        input: NodeId,
300        storage: StorageId,
301        partition_key: Vec<ColId>,
302        key_cols: Vec<&str>,
303        col: ColId,
304    ) -> Reduce {
305        Self::grouped_agg(input, storage, partition_key, key_cols, AggSpec::Avg(col))
306    }
307
308    /// Switch a grouped reducer to the **lazy** regime (`REDUCE-DESIGN.md` §8.1) — for
309    /// a relationship aggregate whose groups a parent join fetches one at a time. A
310    /// constrained `fetch` folds just that group (persisting even a count-0 group); a
311    /// `push` to a group with no state is dropped (the next fetch folds it), and a
312    /// group that drains to `0` keeps its slot so the next `Add` re-births it.
313    pub fn lazy(mut self) -> Reduce {
314        debug_assert!(
315            self.partition_key.is_some(),
316            "Reduce::lazy is only meaningful for a grouped (partitioned) reducer"
317        );
318        self.eager = false;
319        self
320    }
321
322    /// Derive the synthetic output schema's `column_types` from the **input**
323    /// schema (design 226 §4.1): each group column preserves its input column's
324    /// declared type (`partition_key[i]` in input coordinates → output position
325    /// `i`), and each aggregate column carries [`AggSpec::output_type`]. The
326    /// builder chains this on every reduce it constructs; the constructors alone
327    /// leave [`Schema::new`]'s all-`Number` default, which direct-op tests keep.
328    pub fn with_input_types(mut self, input: &Schema) -> Reduce {
329        let mut types: Vec<ValueType> = Vec::with_capacity(self.schema.columns.len());
330        if let Some(pk) = &self.partition_key {
331            types.extend(pk.iter().map(|&c| {
332                input
333                    .column_types
334                    .get(c)
335                    .copied()
336                    .unwrap_or(ValueType::Number)
337            }));
338        }
339        types.extend(self.aggs.iter().map(|a| a.output_type(input)));
340        debug_assert_eq!(types.len(), self.schema.columns.len());
341        self.schema.column_types = types;
342        self
343    }
344
345    /// The group's value-tuple for a row (input coordinates). Global ⇒ `[]`.
346    fn group_vals(&self, row: &Row) -> Vec<OwnedValue> {
347        match &self.partition_key {
348            None => Vec::new(),
349            Some(pk) => pk.iter().map(|&c| row.col(c).to_owned()).collect(),
350        }
351    }
352
353    /// The group's value-tuple from a fetch **constraint** (which is in *output*
354    /// coordinates — the group columns are output positions `0..k`), in key order.
355    fn group_vals_from_constraint(&self, c: &Constraint, k: usize) -> Vec<OwnedValue> {
356        (0..k)
357            .map(|i| {
358                c.iter()
359                    .find(|(col, _)| *col == i)
360                    .map(|(_, v)| v.clone())
361                    .unwrap_or(OwnedValue::Null)
362            })
363            .collect()
364    }
365
366    /// Translate a group's output-coordinate value-tuple into an *input* constraint
367    /// (`partition_key[i] → group_vals[i]`) to fold just that group's source rows.
368    fn input_constraint(&self, group_vals: &[OwnedValue]) -> Constraint {
369        let pk = self
370            .partition_key
371            .as_ref()
372            .expect("input_constraint on a global reducer");
373        pk.iter()
374            .zip(group_vals)
375            .map(|(&col, v)| (col, v.clone()))
376            .collect()
377    }
378
379    /// The storage key for a group value-tuple. Global's empty tuple encodes to the
380    /// single fixed key, so the global and grouped paths share one keyspace.
381    fn group_key(vals: &[OwnedValue]) -> String {
382        encode_state_key("reduce", vals)
383    }
384
385    /// One zeroed accumulator per `Sum`/`Avg` aggregate (a plain `count` yields `[]`).
386    fn new_accs(&self) -> Vec<ReduceAcc> {
387        self.aggs
388            .iter()
389            .filter(|a| matches!(a, AggSpec::Sum(_) | AggSpec::Avg(_)))
390            .map(|_| ReduceAcc::default())
391            .collect()
392    }
393
394    /// Fold `row`'s summed cells into the per-aggregate accumulators with `sign` (`+1`
395    /// for an `Add`, `-1` for a `Remove`). `accs` is parallel to the `Sum`/`Avg` specs.
396    fn acc_apply(&self, accs: &mut [ReduceAcc], row: &Row, sign: i64) {
397        let mut ai = 0;
398        for spec in &self.aggs {
399            match spec {
400                AggSpec::Count => {}
401                AggSpec::Sum(c) | AggSpec::Avg(c) => {
402                    acc_delta(&mut accs[ai], row.col(*c), sign);
403                    ai += 1;
404                }
405            }
406        }
407    }
408
409    /// Read a group's stored `(count, accs)`, or `None` if it has no state yet.
410    fn read_at(&self, g: &Graph, key: &str) -> Option<(i64, Vec<ReduceAcc>)> {
411        match g.storage(self.storage).get(key) {
412            None => None,
413            Some(StorageValue::Reduce { count, accs }) => Some((count, accs)),
414            Some(other) => unreachable!("reduce state slot held {other:?}"),
415        }
416    }
417
418    /// Whether any **`Sum`-spec** accumulator's current all-integer set total is
419    /// outside i64 (design 226 §5.3). `Avg` accs never emit an integer, so an
420    /// out-of-range `int_sum` under an `Avg` is fine — it emits as f64.
421    fn sum_total_out_of_range(&self, accs: &[ReduceAcc]) -> bool {
422        let mut ai = 0;
423        for spec in &self.aggs {
424            match spec {
425                AggSpec::Count => {}
426                AggSpec::Sum(_) => {
427                    let a = &accs[ai];
428                    if a.non_null > 0 && a.float_count == 0 && i64::try_from(a.int_sum).is_err() {
429                        return true;
430                    }
431                    ai += 1;
432                }
433                AggSpec::Avg(_) => ai += 1,
434            }
435        }
436        false
437    }
438
439    /// How many stored groups currently hold an unrepresentable `sum` total —
440    /// non-zero makes [`Graph::take_runtime_error`](crate::graph::Graph::take_runtime_error) raise the §5.3 typed error.
441    pub(crate) fn overflow_count(&self) -> i64 {
442        self.overflowed.get()
443    }
444
445    /// Adjust the [`Reduce::overflowed`] counter for a stored-state transition
446    /// `old → new` of one group. `None` = the group is absent (birth/death).
447    fn track_overflow(&self, old: Option<&[ReduceAcc]>, new: Option<&[ReduceAcc]>) {
448        let was = old.is_some_and(|a| self.sum_total_out_of_range(a));
449        let is = new.is_some_and(|a| self.sum_total_out_of_range(a));
450        match (was, is) {
451            (false, true) => self.overflowed.set(self.overflowed.get() + 1),
452            (true, false) => self.overflowed.set(self.overflowed.get() - 1),
453            _ => {}
454        }
455    }
456
457    fn write_at(&self, g: &Graph, key: &str, count: i64, accs: Vec<ReduceAcc>) {
458        let old = self.read_at(g, key);
459        self.track_overflow(old.as_ref().map(|(_, a)| a.as_slice()), Some(&accs));
460        g.storage(self.storage)
461            .set(key, StorageValue::Reduce { count, accs });
462    }
463
464    /// Delete the group slot `constraint` identifies — the twin of
465    /// [`Take::evict_partition`](crate::op::Take::evict_partition), called by
466    /// `Graph::evict_child_partitions` when a parent leaves a bounded parent view.
467    ///
468    /// A **lazy** grouped reducer (every relationship aggregate) never deletes a slot on
469    /// its own: the grouped-delta death arm deletes only when `eager`, and it
470    /// must stay that way — the lazy regime keeps a count-0 slot precisely so a later
471    /// `Add` can re-birth `0 → 1` for a parent that is still in the view. Nothing else
472    /// dropped a lazy slot, so a long-lived query kept one entry per parent row it ever
473    /// aggregated, for the life of the query. The parent's *departure* is the event that
474    /// makes the slot dead, which is what this hook is.
475    ///
476    /// Safe for the same reason `Take::evict_partition` is: both callers gate on the
477    /// parent correlation being the parent's primary key, so the group belongs to exactly
478    /// the one parent that left, and a deleted lazy slot is indistinguishable from a
479    /// never-folded one — the next constrained fetch re-folds it.
480    ///
481    /// The constraint is in **output** coordinates (the join's child key for an aggregate
482    /// is `0..k`, `builder.rs`), while [`Reduce::partition_key`] is in input coordinates —
483    /// so the gate and the value extraction both work in `0..k`, exactly as
484    /// [`Reduce::fetch`]'s single-group path does. Matching against `partition_key`
485    /// directly would silently evict nothing whenever the correlation is not the child's
486    /// leading columns.
487    pub fn evict_partition(&self, g: &Graph, constraint: &Constraint) {
488        let Some(pk) = &self.partition_key else {
489            return;
490        };
491        let k = pk.len();
492        if !constraint_matches_partition_key(constraint, &(0..k).collect::<Vec<_>>()) {
493            return;
494        }
495        let vals = self.group_vals_from_constraint(constraint, k);
496        // `del_at`, not a raw `storage.del`: it carries the `overflowed` bookkeeping, and
497        // stranding that counter high would turn this cleanup into a permanent §5.3
498        // overflow error for an evicted group whose `sum` was out of range.
499        self.del_at(g, &Self::group_key(&vals));
500    }
501
502    fn del_at(&self, g: &Graph, key: &str) {
503        let old = self.read_at(g, key);
504        self.track_overflow(old.as_ref().map(|(_, a)| a.as_slice()), None);
505        g.storage(self.storage).del(key);
506    }
507
508    /// The synthetic aggregate row `[group_vals…, agg_0, …]` from the `(count, accs)`
509    /// accumulator. Global passes `&[]` ⇒ just the aggregate columns. `accs` is parallel
510    /// to the `Sum`/`Avg` specs (a `Count` consumes none).
511    ///
512    /// A `sum` whose set total leaves i64 (design 226 §5.3) substitutes `NULL` in
513    /// the cell; the error surfaces at the operation's `try_*` boundary
514    /// (`try_source_push` / `try_fetch_all` / `try_hydrate`), where
515    /// [`Graph::take_runtime_error`](crate::graph::Graph::take_runtime_error) polls [`Reduce::overflow_count`] — state-based,
516    /// so a transient corrected before the boundary never errors. The `NULL` is
517    /// deliberately invalid (SQL `sum` over a non-empty set is never `NULL`), so a
518    /// swallowed error cannot masquerade as a plausible total.
519    fn agg_row(&self, group_vals: &[OwnedValue], count: i64, accs: &[ReduceAcc]) -> Row {
520        let mut v = group_vals.to_vec();
521        let mut ai = 0;
522        for spec in &self.aggs {
523            v.push(match spec {
524                AggSpec::Count => OwnedValue::Int(count),
525                AggSpec::Sum(_) => {
526                    let out = sum_value(&accs[ai]).unwrap_or(OwnedValue::Null);
527                    ai += 1;
528                    out
529                }
530                AggSpec::Avg(_) => {
531                    let out = avg_value(&accs[ai]);
532                    ai += 1;
533                    out
534                }
535            });
536        }
537        owned_row(v)
538    }
539
540    /// Fold `input` rows into an accumulator, optionally constrained to one group's rows.
541    /// Reads only each row's own cells (relationship thunks stay uncalled), so folding a
542    /// `count`/`sum`/`avg` never materializes children.
543    fn fold(&self, g: &Graph, req: &FetchRequest) -> (i64, Vec<ReduceAcc>) {
544        let mut count = 0i64;
545        let mut accs = self.new_accs();
546        for node in g.fetch(self.input, req) {
547            count += 1;
548            self.acc_apply(&mut accs, &node.row, 1);
549        }
550        (count, accs)
551    }
552
553    /// Lazy pull. The request is ignored for the eager regimes (see the module note):
554    /// global serves/folds the single accumulator; grouped folds the whole input into
555    /// per-group accumulators, persists each, and emits one row per group in output-sort
556    /// order. On first sight the fold seeds state; thereafter `push` maintains it.
557    pub fn fetch<'g>(&'g self, g: &'g Graph, req: &FetchRequest) -> NodeStream<'g> {
558        match &self.partition_key {
559            None => {
560                let _ = req; // a global aggregate is one row regardless of constraint
561                let (count, accs) = match self.read_at(g, GLOBAL_KEY) {
562                    Some(s) => s,
563                    None => {
564                        let s = self.fold(g, &FetchRequest::all());
565                        self.write_at(g, GLOBAL_KEY, s.0, s.1.clone());
566                        s
567                    }
568                };
569                Box::new(std::iter::once(Node::leaf(self.agg_row(&[], count, &accs))))
570            }
571            Some(pk) => {
572                let k = pk.len();
573                // Lazy + a partition-key constraint ⇒ serve/fold just that one group.
574                let single = (!self.eager)
575                    .then_some(req.constraint.as_ref())
576                    .flatten()
577                    .filter(|c| constraint_matches_partition_key(c, &(0..k).collect::<Vec<_>>()));
578                if let Some(c) = single {
579                    return self.fetch_one_group(g, self.group_vals_from_constraint(c, k));
580                }
581                // Eager (always) or lazy-unconstrained: full-fold every group.
582                let mut groups: HashMap<String, (Vec<OwnedValue>, i64, Vec<ReduceAcc>)> =
583                    HashMap::new();
584                for node in g.fetch(self.input, &FetchRequest::all()) {
585                    let vals = self.group_vals(&node.row);
586                    let entry = groups
587                        .entry(Self::group_key(&vals))
588                        .or_insert_with(|| (vals, 0, self.new_accs()));
589                    entry.1 += 1;
590                    self.acc_apply(&mut entry.2, &node.row, 1);
591                }
592                let mut rows: Vec<Row> = Vec::with_capacity(groups.len());
593                for (key, (vals, count, accs)) in &groups {
594                    self.write_at(g, key, *count, accs.clone());
595                    rows.push(self.agg_row(vals, *count, accs));
596                }
597                rows.sort_by(|a, b| compare_rows(&self.schema.sort, a, b));
598                Box::new(rows.into_iter().map(Node::leaf))
599            }
600        }
601    }
602
603    /// Lazy single-group fetch: serve the group's accumulator from state, or — on first
604    /// sight — fold *only that group's* source rows (constraint pushed into the input),
605    /// **persist even a count-0 group** (so a later `Add` finds state and fires the
606    /// `0 → 1` birth, §8.1), and emit one row iff the group is non-empty.
607    fn fetch_one_group<'g>(&'g self, g: &'g Graph, group_vals: Vec<OwnedValue>) -> NodeStream<'g> {
608        let key = Self::group_key(&group_vals);
609        let (count, accs) = match self.read_at(g, &key) {
610            Some(s) => s,
611            None => {
612                let s = self.fold(
613                    g,
614                    &FetchRequest::with_constraint(self.input_constraint(&group_vals)),
615                );
616                self.write_at(g, &key, s.0, s.1.clone());
617                s
618            }
619        };
620        if count > 0 {
621            Box::new(std::iter::once(Node::leaf(self.agg_row(
622                &group_vals,
623                count,
624                &accs,
625            ))))
626        } else {
627            Box::new(std::iter::empty())
628        }
629    }
630
631    /// Eager push. Global maintains the one immortal accumulator (always an `Edit`);
632    /// grouped maintains per-group accumulators with birth (`Add`), shift (`Edit`), and
633    /// death (`Remove` at row `count → 0`). A same-group `Edit` leaves `count(*)`
634    /// unchanged but can still move a `sum`/`avg`, so it re-folds the accumulator and
635    /// emits iff the aggregate row actually changed (`REDUCE-DESIGN.md` §6); a `Child`
636    /// never changes the input-row population, so it emits nothing.
637    pub fn push<'g>(&'g self, g: &'g Graph, change: Change<'g>) {
638        let out = self.output.get().expect("Reduce output not wired");
639        match &self.partition_key {
640            None => self.push_global(g, out, change),
641            Some(pk) => match change {
642                Change::Add(node) => self.grouped_delta(g, out, &node.row, 1),
643                Change::Remove(node) => self.grouped_delta(g, out, &node.row, -1),
644                Change::Edit { node, old } => {
645                    debug_assert!(
646                        partition_key_unchanged(&old.row, &node.row, pk),
647                        "reduce: Edit crossed a partition; the input connection must \
648                         split-edit the partition key (REDUCE-DESIGN.md §6)"
649                    );
650                    self.grouped_edit(g, out, &old.row, &node.row);
651                }
652                Change::Child { .. } => {}
653            },
654        }
655    }
656
657    /// Global push: the single immortal accumulator. Bails if not hydrated (mirrors
658    /// `Take`'s unhydrated-partition guard); a dropped push still lands in the source,
659    /// so the next hydrate fold folds it. Emits an `Edit` only if the aggregate row
660    /// changed — a `count` always moves, but a `sum`/`avg` of a row that is `NULL` in the
661    /// summed column does not.
662    fn push_global<'g>(&'g self, g: &'g Graph, out: OutEdge, change: Change<'g>) {
663        let Some((count, accs)) = self.read_at(g, GLOBAL_KEY) else {
664            return;
665        };
666        if matches!(change, Change::Child { .. }) {
667            return;
668        }
669        let old_row = self.agg_row(&[], count, &accs);
670        let mut new_accs = accs;
671        let new_count = match &change {
672            Change::Add(node) => {
673                self.acc_apply(&mut new_accs, &node.row, 1);
674                count + 1
675            }
676            Change::Remove(node) => {
677                self.acc_apply(&mut new_accs, &node.row, -1);
678                count - 1
679            }
680            Change::Edit { node, old } => {
681                self.acc_apply(&mut new_accs, &old.row, -1);
682                self.acc_apply(&mut new_accs, &node.row, 1);
683                count
684            }
685            Change::Child { .. } => unreachable!("Child returned above"),
686        };
687        let new_row = self.agg_row(&[], new_count, &new_accs);
688        self.write_at(g, GLOBAL_KEY, new_count, new_accs);
689        if !agg_rows_equal(&old_row, &new_row) {
690            g.push(
691                out.node,
692                Change::Edit {
693                    node: Node::leaf(new_row),
694                    old: Node::leaf(old_row),
695                },
696                out.port,
697            );
698        }
699    }
700
701    /// Apply an `Add`/`Remove` (`delta` `±1`) of `row` to its group, emitting by the
702    /// `(old_count > 0, new_count > 0)` transition: birth (`Add`), shift (`Edit`), death
703    /// (`Remove`), or nothing. The two regime-dependent points (`REDUCE-DESIGN.md` §8.1):
704    ///
705    /// - **No state.** Eager treats it as a genuinely-new group (count `0`): a `+1`
706    ///   births it (seeding the accumulator from `row`); a `-1` is impossible and is
707    ///   dropped. Lazy drops *both* — the group is merely un-folded, and the next
708    ///   constrained `fetch` will fold it from source.
709    /// - **Death** (`new_count == 0`). Eager **deletes** the slot; Lazy **keeps** it (at
710    ///   count `0`, accumulator drained to zero) so a future `Add` re-births (`0 → 1`).
711    fn grouped_delta<'g>(&'g self, g: &'g Graph, out: OutEdge, row: &Row, delta: i64) {
712        let group_vals = self.group_vals(row);
713        let key = Self::group_key(&group_vals);
714        let (old_count, old_accs) = match self.read_at(g, &key) {
715            Some(s) => s,
716            None => {
717                if self.eager && delta > 0 {
718                    let mut accs = self.new_accs();
719                    self.acc_apply(&mut accs, row, 1);
720                    let born = self.agg_row(&group_vals, delta, &accs);
721                    self.write_at(g, &key, delta, accs);
722                    g.push(out.node, Change::Add(Node::leaf(born)), out.port);
723                }
724                return; // lazy no-state, or eager `-1` to absent: nothing to do.
725            }
726        };
727        let new_count = (old_count + delta).max(0);
728        let mut new_accs = old_accs.clone();
729        self.acc_apply(&mut new_accs, row, delta);
730        let change = match (old_count > 0, new_count > 0) {
731            (false, true) => {
732                Change::Add(Node::leaf(self.agg_row(&group_vals, new_count, &new_accs)))
733            }
734            (true, true) => {
735                let old_row = self.agg_row(&group_vals, old_count, &old_accs);
736                let new_row = self.agg_row(&group_vals, new_count, &new_accs);
737                if agg_rows_equal(&old_row, &new_row) {
738                    // Row count moved but the emitted aggregate did not (e.g. `sum` of a
739                    // row that is `NULL` in the summed column) — persist, emit nothing.
740                    self.write_at(g, &key, new_count, new_accs);
741                    return;
742                }
743                Change::Edit {
744                    node: Node::leaf(new_row),
745                    old: Node::leaf(old_row),
746                }
747            }
748            (true, false) => {
749                Change::Remove(Node::leaf(self.agg_row(&group_vals, old_count, &old_accs)))
750            }
751            (false, false) => {
752                // Stays empty (lazy slot kept at 0); nothing emitted.
753                self.write_at(g, &key, new_count, new_accs);
754                return;
755            }
756        };
757        if matches!(change, Change::Remove(_)) && self.eager {
758            self.del_at(g, &key);
759        } else {
760            self.write_at(g, &key, new_count, new_accs);
761        }
762        g.push(out.node, change, out.port);
763    }
764
765    /// Apply a same-group in-place `Edit` (`old` → `new`, same partition key). The row
766    /// count is unchanged, so this never births or kills a group; it re-folds the
767    /// accumulator (`Remove(old) + Add(new)`) and emits an `Edit` iff the aggregate row
768    /// moved — a `count` never does (nothing emitted), a `sum`/`avg` does when the summed
769    /// cell changes. A group with no state (lazy, un-folded) drops the edit; the next
770    /// constrained `fetch` folds the updated row.
771    fn grouped_edit<'g>(&'g self, g: &'g Graph, out: OutEdge, old_in: &Row, new_in: &Row) {
772        let group_vals = self.group_vals(new_in);
773        let key = Self::group_key(&group_vals);
774        let Some((count, old_accs)) = self.read_at(g, &key) else {
775            return;
776        };
777        let mut new_accs = old_accs.clone();
778        self.acc_apply(&mut new_accs, old_in, -1);
779        self.acc_apply(&mut new_accs, new_in, 1);
780        let old_row = self.agg_row(&group_vals, count, &old_accs);
781        let new_row = self.agg_row(&group_vals, count, &new_accs);
782        self.write_at(g, &key, count, new_accs);
783        if !agg_rows_equal(&old_row, &new_row) {
784            g.push(
785                out.node,
786                Change::Edit {
787                    node: Node::leaf(new_row),
788                    old: Node::leaf(old_row),
789                },
790                out.port,
791            );
792        }
793    }
794}
795
796/// The single fixed state key for the global accumulator — the encoding of the empty
797/// group tuple (`encode_state_key("reduce", &[])`), so the global and grouped paths
798/// share one keyspace.
799const GLOBAL_KEY: &str = "reduce";