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";