Reduce each group of a grouped frame to one row, evaluating one
expression per output column. Each expression is a reduction over
its group: col("revenue").sum(), col("revenue").max() -col("revenue").min() (a per-group range), (col("revenue") -col("cost")).sum() (a reduction over a derived column) — the group
collapses to a single cell per expression.
Output shape: the key columns head the frame (in key order, each named
by its key expression's output name and keeping its evaluated dtype —
the backend, like every row gather's, follows the gathered content —
one representative row per group), followed by one column per
expression (in expression order), named by the expression's
output name (alias, else leftmost column reference, else "literal");
one row per group, in the first-appearance group order fixed by
group_by. The result routes through DataFrame::from_parts, so name
collisions — two expressions sharing an output name, or an expression
shadowing a key column — surface as DuplicateColumn, and every output
satisfies check_invariants(). agg([]) degenerates to a distinct over
the key tuples (zero expressions ⇒ zero aggregated columns).
The height is the group count, never inferred from the output columns, so
the one shape with neither a key column nor an aggregated one keeps its
rows: group_by([]).agg([]) over a non-empty frame is the 1×0 frame
(the grand-total group, reduced to nothing), not 0×0.
Each expression must be reduction-shaped (reduces_per_group):
aggregations and literals are per-group scalars, and every combinator —
arithmetic / comparison / Kleene operators, not and the null probes,
cast, with_alias, when/then/otherwise — preserves scalarness; a
bare column reference pins the result to the group height, so it is
not a reduction. The check is structural, hence deterministic:
agg([col("a")]) raises InvalidOperation even when every group
happens to hold a single row, where a dynamic length test would
data-dependently pass. (Polars would implicitly collect the group into
a list value; MoonFrame has no list dtype, so implicit list-aggregation
stays out of scope.)
Evaluation reuses the expr_eval.mbt engine with scope = each
group's row indices, inheriting the documented dtype / null / NaN
rules verbatim: sum / mean propagate NaN and reject non-numeric
dtypes, min / max skip NaN and are total over every dtype, an
all-null group sums to the additive identity and means to a null
cell, count counts non-null cells. Dtype errors (ColumnNotFound,
TypeMismatch) surface from the per-group evaluation itself (the
bare-column fast path still reports them up front, resolving its
reducer before any group work); the output dtype is taken from the
reduced cells themselves, since a map(...) closure's result dtype is
scope-dependent. A probe evaluation under the empty scope is consulted
only as a fallback that types — and gates — a zero-group or all-null
result: consulted eagerly it would spuriously reject a dtype-changing
map(...) under a numeric reduction, because the closure never runs
under the empty scope and the probe would type the Map by its
leftmost input column instead. Computed columns follow the
expression-engine backend convention: an all-valid numeric column
converges onto Numeric, anything nullable (or Bool / String) stays
Builtin.
An aggregation directly over a bare column — col(name).<agg>() for any
aggregation op, the common case once aliases are peeled — takes a
single-pass fast path (bare_col_agg): the shared reduction kernel
resolves its reducer and validity mask once and folds each group's row
indices straight off the source column, instead of gathering a fresh
sub-column per group through the general evaluator. It is purely an
optimization — the result is cell-for-cell and backend identical to the
general path, with the same dtype / null / NaN rules and the same up-front
error surfacing — so an aggregation over a derived operand
((col("a") - col("b")).sum()) transparently falls back to it.
Raises:
- InvalidOperation(...) — an expression is not reduction-shaped, or
the handle's groups no longer satisfy the group_by invariants (an
empty group, or a row index outside the source). The fields are
priv, so no external caller can reach the arrays; only in-package
code could plant such a handle, and it is re-checked here rather than
allowed to drive the folds into an abort.
- ColumnNotFound(name) — an expression references an absent column.
- TypeMismatch(...) — an expression's dtypes don't unify (e.g. sum
over a String column), from the first group's evaluation — or from
the fallback probe when zero groups / an all-null result leave no
dtype witness.
- DuplicateColumn(name) — two output column names collide.
- LengthMismatch — a group's reduction produced something other than one
cell, which is the expr_eval.mbt length contract narrowed to a group. The
built-in aggregations, the literals, and the combinators over them cannot;
a map_batches(returns_scalar=true) closure declared to reduce can, by
returning a series of another length. Reported on the first group that
does, in group order.