From 94131abc3d06e8a37d0ed0794dec27e1f55388b3 Mon Sep 17 00:00:00 2001
From: Bob Jansen
Date: Sat, 5 Sep 2026 09:18:48 +0200
Subject: [PATCH 01/42] Generalize trim()
---
include/ibex/plugin/schema.hpp | 1 +
libs/adbc/adbc.cpp | 2 ++
libs/kafka/kafka_common.hpp | 1 +
src/repl/repl.cpp | 1 +
src/ui/server.cpp | 2 ++
tools/ibex_bench.cpp | 1 +
6 files changed, 8 insertions(+)
diff --git a/include/ibex/plugin/schema.hpp b/include/ibex/plugin/schema.hpp
index a25719ba..e8711a2b 100644
--- a/include/ibex/plugin/schema.hpp
+++ b/include/ibex/plugin/schema.hpp
@@ -28,6 +28,7 @@
#include
#include
#include
+#include
#include
#include
diff --git a/libs/adbc/adbc.cpp b/libs/adbc/adbc.cpp
index 545ce130..64dbee8c 100644
--- a/libs/adbc/adbc.cpp
+++ b/libs/adbc/adbc.cpp
@@ -10,7 +10,9 @@
// import "adbc";
// let df = read_adbc("adbc_driver_sqlite", "", "select 1 as x");
+#include
#include
+#include
#include
#include
diff --git a/libs/kafka/kafka_common.hpp b/libs/kafka/kafka_common.hpp
index 047963d7..951a3d79 100644
--- a/libs/kafka/kafka_common.hpp
+++ b/libs/kafka/kafka_common.hpp
@@ -25,6 +25,7 @@ using ibex::trim;
using ibex::plugin::parse_key_value_options;
using ibex::plugin::table_from_json_payload;
using ibex::plugin::table_row_to_json;
+using ibex::trim;
inline auto parse_kafka_field_kind(std::string_view type_str)
-> std::expected {
diff --git a/src/repl/repl.cpp b/src/repl/repl.cpp
index 0ba000c0..a9e542f4 100644
--- a/src/repl/repl.cpp
+++ b/src/repl/repl.cpp
@@ -5,6 +5,7 @@
#include
#include
#include
+#include
#include
#include
#include
diff --git a/src/ui/server.cpp b/src/ui/server.cpp
index 079d926f..b1abf917 100644
--- a/src/ui/server.cpp
+++ b/src/ui/server.cpp
@@ -11,6 +11,8 @@
#include
#include
+#include
+
#include
#include
#include
diff --git a/tools/ibex_bench.cpp b/tools/ibex_bench.cpp
index 138d585e..facff715 100644
--- a/tools/ibex_bench.cpp
+++ b/tools/ibex_bench.cpp
@@ -3,6 +3,7 @@
#include
#include
+#include
#include
#include
#include
From ff8438c50b12a9449985beefb551105496dec2b0 Mon Sep 17 00:00:00 2001
From: Bob Jansen
Date: Sat, 5 Sep 2026 13:05:15 +0200
Subject: [PATCH 02/42] Benchmarking fixes
---
docs/benchmarks.html | 10 +-
docs/engine/.gitignore | 10 +
docs/engine/README.md | 56 ++++
docs/engine/build.sh | 24 ++
docs/engine/cost.tex | 186 ++++++++++++
docs/engine/engine.tex | 524 +++++++++++++++++++++++++++++++++
docs/engine/operations.tex | 294 ++++++++++++++++++
docs/engine/references.bib | 85 ++++++
docs/engine/trade_summary.ibex | 21 ++
9 files changed, 1206 insertions(+), 4 deletions(-)
create mode 100644 docs/engine/.gitignore
create mode 100644 docs/engine/README.md
create mode 100755 docs/engine/build.sh
create mode 100644 docs/engine/cost.tex
create mode 100644 docs/engine/engine.tex
create mode 100644 docs/engine/operations.tex
create mode 100644 docs/engine/references.bib
create mode 100644 docs/engine/trade_summary.ibex
diff --git a/docs/benchmarks.html b/docs/benchmarks.html
index 9cb7a23e..620ffba7 100644
--- a/docs/benchmarks.html
+++ b/docs/benchmarks.html
@@ -143,10 +143,12 @@ Per-query timings
Caveats. Every engine now materialises its full result.
- tf rolling EWMA is time-windowed in Ibex versus full-series in
- Polars (both O(n), different maths). Each page is generated from a single
- run; SQLite and the data.table rolling median/std
- cells are omitted (they dominate wall-clock and add no competitive signal).
+ tf rolling EWMA uses the bounded window definition in Ibex,
+ pandas and Polars. ClickHouse statistical/OHLC cells cannot compute
+ correctly under the shared semantics and are excluded from claims.
+ Each page is generated from a single run; SQLite and the
+ data.table rolling median/std cells are omitted (they dominate
+ wall-clock and add no competitive signal).
The code behind these numbers. Every query's exact code, in
diff --git a/docs/engine/.gitignore b/docs/engine/.gitignore
new file mode 100644
index 00000000..3537b4a1
--- /dev/null
+++ b/docs/engine/.gitignore
@@ -0,0 +1,10 @@
+*.aux
+*.bbl
+*.blg
+*.fdb_latexmk
+*.fls
+*.log
+*.out
+*.pdf
+*.synctex.gz
+*.toc
diff --git a/docs/engine/README.md b/docs/engine/README.md
new file mode 100644
index 00000000..4fd3571b
--- /dev/null
+++ b/docs/engine/README.md
@@ -0,0 +1,56 @@
+# Ibex analytics engine description
+
+`engine.tex` is an intentionally incomplete technical foundation, complementary
+to `SPEC.md`. It describes the inspected implementation and identifies questions
+for author review. `references.bib` contains the cited research literature.
+
+Build with a standard LaTeX installation, from the repository root:
+
+```sh
+bash docs/engine/build.sh
+```
+
+Equivalently, run these commands from this directory:
+
+```sh
+pdflatex -interaction=nonstopmode -halt-on-error engine.tex
+bibtex engine
+pdflatex -interaction=nonstopmode -halt-on-error engine.tex
+pdflatex -interaction=nonstopmode -halt-on-error engine.tex
+```
+
+The output is `engine.pdf`. Generated files are ignored by Git. Required packages
+are listed in the TeX preamble; no shell escape or external fonts are required.
+
+Interpreted and compiled execution share a functional-equivalence requirement.
+The draft's parity bug appendix records encountered divergences; an unsupported
+diagnostic alone does not establish an intentional exception.
+
+Local source paths are repository-relative. Each substantive section includes an
+implementation evidence paragraph; citations to papers supply context and do
+not establish that Ibex implements every technique in those papers.
+
+Agreed direction: address external database-systems readers with operational
+detail, using a time-series running workload. Preserve row-order guarantees;
+floating-point reproducibility may be relaxed, with the precise contract still
+to be defined. `operations.tex` now covers lazy decoding, map kernels, streaming
+equality joins, aggregation phases, Arrow ownership, and retained memory.
+`cost.tex` explains cost drivers using the September 3, 2026 benchmark snapshot
+in `docs/benchmarks.html`, checked against its archived source CSV. It records
+hardware, timing boundaries, and the limits of interpreting the measurements.
+No new benchmark was run. Detailed time-series semantics, numerical
+reproducibility rules, and new performance experiments remain deferred.
+The agreed-direction section records remaining investigations.
+
+`trade_summary.ibex` is the executable running example, included directly in
+the LaTeX document. From the repository root:
+
+```sh
+build-release/tools/ibex --no-history --report-planner docs/engine/trade_summary.ibex
+```
+
+This was checked using the existing release executable: two output rows with
+volume/notional totals of 30/3040 (AAPL) and 20/4030 (MSFT). The planner reported
+statement execution for this in-memory example. This is an example check, not
+a benchmark or a compiled/interpreted parity test. No engine rebuild or test
+suite run was needed for this documentation change.
diff --git a/docs/engine/build.sh b/docs/engine/build.sh
new file mode 100755
index 00000000..69f51aef
--- /dev/null
+++ b/docs/engine/build.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: AGPL-3.0-only
+# Copyright (C) 2026 Bob Jansen
+
+# Build the Ibex engine document and resolve its bibliography and references.
+
+set -euo pipefail
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "${script_dir}"
+
+for command in pdflatex bibtex; do
+ if ! command -v "${command}" >/dev/null 2>&1; then
+ echo "error: ${command} is required to build engine.tex" >&2
+ exit 1
+ fi
+done
+
+pdflatex -interaction=nonstopmode -halt-on-error engine.tex
+bibtex engine
+pdflatex -interaction=nonstopmode -halt-on-error engine.tex
+pdflatex -interaction=nonstopmode -halt-on-error engine.tex
+
+echo "built ${script_dir}/engine.pdf"
diff --git a/docs/engine/cost.tex b/docs/engine/cost.tex
new file mode 100644
index 00000000..10c7e8e4
--- /dev/null
+++ b/docs/engine/cost.tex
@@ -0,0 +1,186 @@
+\section{Execution cost and existing measurements}
+\label{sec:cost}
+Ibex already has a substantial measurement corpus. The scale suite and its
+published benchmark page provide observations across operators, row counts,
+and thread configurations~\cite{ibexBenchmarks}. This section uses a small,
+explicitly identified snapshot to ground the cost explanation. It separates
+what those measurements show from the mechanisms that can explain a cost;
+timing a query alone does not identify its bottleneck.
+
+\subsection{What determines the amount of work?}
+Row count is only one dimension. Let $n$ denote input rows, $s$ the fraction
+surviving a filter, $w$ the bytes of demanded payload per row, $g$ the number
+of groups, and $m$ the number of join-result rows. These are explanatory
+quantities, not parameters of a claimed Ibex optimizer cost model.
+
+\paragraph{Reading and selecting.}
+Column demand limits which payloads must be read. A selective scan can reduce
+payload materialization toward $snw$ bytes, but predicate evaluation still
+has its own input cost. Source encodings and page boundaries determine whether
+skipped rows also avoid decoding work. A small result therefore does not imply
+a proportionally cheap scan. Cached columns change the work again: gathering
+from existing buffers avoids source decoding but still reads and writes values.
+
+\paragraph{Computing and moving values.}
+A row-local expression such as doubling a price visits $n$ values and produces
+$n$ outputs. A projection can share payloads, whereas a filter generally gathers
+survivors into output buffers. Fusion can remove intermediate passes and avoid
+gathering unused columns. Fixed-width output volume depends on rows and width;
+string output also depends on the number of character bytes. Consequently,
+operations with similar arithmetic can have different allocation and copying
+costs. These mechanisms motivate a bandwidth hypothesis, but the published
+wall times alone do not establish memory-bandwidth saturation.
+
+\paragraph{Maintaining state.}
+Aggregation combines row processing with state indexed by $g$ groups. More
+groups, additional key components, and different key representations alter
+lookup and state-management work. A hash join similarly pays for its build
+index, probe lookups, and materializing $m$ matches. Duplicate keys can make
+$m$ much larger than either input. Reducing output width helps the gather,
+but does not eliminate the cost of identifying the matches.
+
+\paragraph{Ordering and reducing output.}
+Sorting a complete table and selecting its top 100 rows request different
+amounts of output and can use different algorithms. Ibex's bounded-heap top-k
+path performs selection before gathering its small result, rather than
+materializing a full sorted table. Useful input ordering can also avoid
+sorting or admit the sorted aggregate described in Section~\ref{sec:operations}.
+Maintaining time order can thus have an execution benefit as well as a semantic
+purpose, although time order and group-contiguous order are different facts.
+
+\evidence{\src{src/runtime/lazy_table.cpp}, selected decoding;
+\src{include/ibex/runtime/pipeline.hpp}, fused map descriptions;
+\src{src/runtime/join_chunked.cpp} and
+\src{src/runtime/aggregate_chunked.cpp}, state and output construction;
+\src{src/runtime/runtime_entry.cpp}, the TopK execution-path description.
+These are implementation-based explanations, not measured attribution of the
+timings below to individual phases.}
+
+\subsection{The measurement boundary}
+The inspected \src{docs/benchmarks.html} snapshot was generated on
+3 September 2026 at 13:08 UTC and records commit
+\code{0d991d3677f2927eb40c77ba9f489ad6877baa24}. Its source is
+\src{benchmarking/results/scales_aws_20260903T100845.csv}.
+The published methodology describes AWS r7i.2xlarge instances with 8 vCPUs
+(4 physical cores with two hardware threads each), 64 GB of memory, and one
+instance per engine. The timings below are reported means from that run,
+not fresh measurements of the current checkout.
+
+For that archived snapshot, the Ibex wrapper selects resident input tables
+and parses and lowers the query before the timed iterations. It warms up, then times
+\code{runtime::interpret}, including construction of the materialized result.
+This measures execution rather than file loading or C++ compilation. Separate
+parse-inclusive and scan-inclusive harness modes have different boundaries
+and must not be mixed into that interpretation. Physical execution decisions
+made inside \code{interpret} remain inside the timer.
+The revised in-memory harness includes parsing and lowering; its new results
+must be distinguished from this historical snapshot.
+
+\evidence{\src{docs/benchmarks.html}, embedded \code{PAYLOAD} metadata and
+timings; \src{docs/methodology.html};
+\src{benchmarking/gen_website.py}, \code{avg_ms} payload construction;
+\src{tools/ibex_bench.cpp}, \code{run_benchmark}, especially the
+\code{!include_parse} branch. The selected page cells were checked against
+their source CSV. The page snapshot does not expose individual iteration
+samples or the invocation's repetition count; harness defaults alone do not
+establish that count for this archived run.}
+
+\subsection{Representative observations}
+All times in Table~\ref{tab:cost-snapshot} are milliseconds. ``Default'' is
+the published \code{ibex} configuration; ``one'' is \code{ibex-st}, the same
+build with \code{IBEX_CORES=1}. Query identifiers are retained so the exact
+expressions can be found in the methodology page and harness.
+
+\begin{table}[htbp]
+\centering
+\small
+\begin{tabular}{@{}lrrr@{}}
+\toprule
+Query & 1M, default & 16M, default & 16M, one\\
+\midrule
+\code{update_price_x2} & 0.212 & 4.363 & 18.995\\
+\code{mean_by_symbol} & 0.729 & 7.424 & 30.339\\
+\code{sort_price} & 21.431 & 516.333 & 697.558\\
+\code{order_head_topk} & 2.650 & 39.369 & 39.481\\
+\code{tf_rolling_sum_1m} & 2.880 & 53.370 & 50.783\\
+\code{tf_rolling_median_1m} & 29.884 & 485.317 & 483.917\\
+\code{tf_resample_1m_ohlc} & 4.342 & 98.409 & 96.602\\
+\bottomrule
+\end{tabular}
+\caption{Selected means from the identified September 2026 benchmark
+snapshot~\cite{ibexBenchmarks}. M denotes one million input rows.}
+\label{tab:cost-snapshot}
+\end{table}
+
+Three observations are useful for understanding the engine. First, at 16M
+rows, \code{mean_by_symbol} emits 252 groups, \code{order_head_topk} emits
+100 rows, and \code{sort_price} emits all 16M rows. Their timings concern
+different work and result sizes. In particular, top-k and full sorting are
+not interchangeable benchmark answers: the harness requests the top 100
+descending prices for the former and a full ascending sort for the latter.
+
+Second, comparing the reported 16M means gives about $4.35\times$ acceleration
+for doubling prices and $4.09\times$ for mean by symbol, but only about
+$1.35\times$ for full price sorting and essentially none for this top-k
+query. These ratios describe the complete operations at that scale. They do
+not mean that every phase has that speedup, or that eight vCPUs are eight
+independent physical cores.
+
+Third, the time-series rows expose substantial differences within the same
+workload family: the one-minute rolling median takes 485.317 ms at 16M rows,
+versus 53.370 ms for the rolling sum. The default and one-core means are close
+for these examples and for resampling. That is evidence of little observed
+threading benefit in this snapshot, not a statistical verdict about their
+small differences. The harness constructs timestamps at one-second spacing
+and the expressions include \code{as_timeframe}; these numbers therefore do
+not isolate window-state maintenance. Varying timestamp density or window
+duration would change a different dimension from simply adding more rows.
+The detailed window algorithms remain a later investigation.
+
+\evidence{The source CSV's \code{rows} and \code{avg_ms} columns;
+\src{tools/ibex_bench.cpp}, definitions of \code{order_head_topk},
+\code{sort_price}, and the TimeFrame benchmark group. The published page also
+notes that its cross-engine rolling-EWMA comparison uses different mathematics;
+that row is not treated here as a comparison of equivalent computations.}
+
+\subsection{Wall time, CPU work, and memory answer different questions}
+Parallel execution overlaps work, so adding per-operator or per-worker times
+does not generally reconstruct elapsed query time. The critical dependencies
+include build-before-probe, aggregate finalization, and ordered output delivery.
+Task submission, buffering, and merging also cost time. A larger thread budget
+is useful only where it reduces elapsed time enough to offset those costs.
+
+The repository's \src{benchmarking/work_multiplication.py} complements wall
+time with total child-process CPU consumption at different core counts.
+\src{MEASURING.md} records why this matters: a historical sharded decoder
+repeated work while still showing attractive parallel occupancy. High CPU
+growth is a diagnostic signal, not proof of redundant decoding; contention
+can also increase CPU time. Counting rows, calls, or bytes is needed to
+distinguish those explanations.
+
+The benchmark's memory column is absolute peak resident memory, including
+already-resident input and other live process storage. It is not an
+operator's incremental allocation count. The Ibex harness attempts to reset
+Linux's high-water mark before timed iterations; if that mechanism is
+unavailable, the code documents a lifetime-peak fallback. Consequently, these
+figures describe the footprint of the recorded harness execution and cannot
+be substituted directly for the per-operator state accounting above.
+
+\evidence{\src{MEASURING.md}, work multiplication and profile interpretation;
+\src{benchmarking/work_multiplication.py}; \src{tools/ibex_bench.cpp},
+\code{reset_peak_rss} and \code{peak_rss_mb}.}
+
+\subsection{Using the wider corpus}
+The scale suite provides broad coverage, while the PDS-H query suite,
+operator profiles, and focused time-series experiments provide complementary
+evidence. Their measurement boundaries and datasets should be recorded before
+combining results. Existing measurements are the starting point; new runs are
+needed only for a specific unresolved claim, such as the contribution of a
+particular decoder or the cost of maintaining an ordering guarantee.
+
+For causal claims about a change, the repository's paired, interleaved A/B
+workflow and per-operator profiles are more appropriate than differences
+between independently published snapshots. This document has reused the
+archived evidence without running a benchmark. It neither extrapolates those
+times to other machines nor treats a single snapshot as a current performance
+guarantee.
diff --git a/docs/engine/engine.tex b/docs/engine/engine.tex
new file mode 100644
index 00000000..04c9be2f
--- /dev/null
+++ b/docs/engine/engine.tex
@@ -0,0 +1,524 @@
+\documentclass[11pt,a4paper]{article}
+\usepackage[T1]{fontenc}
+\usepackage[utf8]{inputenc}
+\usepackage[margin=27mm]{geometry}
+\usepackage{amsmath}
+\usepackage{booktabs,tabularx}
+\usepackage{listings}
+\usepackage{xcolor}
+\usepackage{xurl}
+\usepackage{hyperref}
+\hypersetup{colorlinks=true,linkcolor=blue!45!black,urlcolor=blue!45!black,citecolor=blue!45!black}
+\urlstyle{tt}
+\lstset{basicstyle=\small\ttfamily,columns=fullflexible,keepspaces=true,
+breaklines=true,frame=single}
+\setlength{\emergencystretch}{3em}
+\newcommand{\code}[1]{\nolinkurl{#1}}
+\newcommand{\src}[1]{\path{#1}}
+\newcommand{\evidence}[1]{\par\smallskip{\small\raggedright\noindent\textit{Implementation evidence:} #1\par}}
+\title{Ibex as an Analytics Engine\\\large Data representation, planning, and execution
+\\Working technical description}
+\author{Bob Jansen, OpenAI GPT}
+\date{\today}
+
+\begin{document}
+\maketitle
+\begin{abstract}
+ \noindent
+ Ibex exposes analytical operations through a statically typed language and
+ executes them over columnar data. This document explains the implementation
+ boundary between that language and its host analytics runtime: how table data
+ is represented, how logical operations become executable operators, where
+ intermediate results are stored, and how one query uses multiple threads.
+ It connects these mechanisms to query-processing literature while keeping
+ literature comparisons separate from claims about Ibex. This first draft is
+ deliberately incomplete: further operator specializations and design rationale
+ remain subjects for investigation and author review.
+\end{abstract}
+
+\section{Scope and evidence}
+The primary audience is external database-systems readers. The text should
+explain the design and its relationship to established techniques, with enough
+operational detail to follow how a query executes. It assumes familiarity with
+basic relational operations and programming, but not the Ibex implementation.
+\src{SPEC.md} remains the authority
+for language semantics. This document describes mechanisms behind those
+semantics; it does not introduce syntax or promise performance.
+
+Three kinds of statement are distinguished throughout. \emph{Implementation
+evidence} names code and symbols inspected for a claim. \emph{Literature
+context} explains related published techniques; similarity does not establish
+historical influence or equivalent implementation. \emph{Open questions} name
+unfinished investigations. Existing runtime notes are useful context, but
+their architectural aspirations are not treated as proof that every execution
+path satisfies them.
+
+\section{The execution architecture}
+The host runtime is a single-process, shared-memory engine. Its public
+\code{interpret} entry point claims a query-execution lease, constructs an
+operator, and drains it into a materialized result table. A concurrent or
+re-entrant top-level call is rejected with an error. This is a restriction on
+the host runtime entry point; it does not imply that a query executes on one
+thread, or specify how an embedding application manages multiple processes.
+
+Interpreted and compiled execution are functionally equivalent implementations
+of the same Ibex language. Both must implement the same specified behavior;
+a functional divergence is a bug unless explicitly designated as an intentional
+exception. As of writing there are no intentional exceptions. Different
+planning, scheduling, and code-generation mechanisms do not establish different
+language semantics or feature sets. This is the required contract, not a claim
+that the implementation is currently bug-free.
+
+There are two execution routes. The interpreted route lowers input into an
+intermediate representation (IR) and evaluates it through the runtime. A
+separate emitter translates IR into C++ source, including calls to
+\code{ibex::ops} functions. The distinction below explains how each route
+implements the shared functionality.
+
+\begin{center}
+ \begin{tabular}{c}
+ Ibex source\\
+ $\downarrow$ parsing, analysis, lowering\\
+ logical IR and rewrites\\
+ $\swarrow$ \hspace{5em} $\searrow$\\
+ \begin{tabular}{c@{\hspace{3em}}c}
+ host execution & C++ source emission\\
+ physical planning / fallback & external C++ compilation\\
+ operators and kernels & generated executable\\
+ materialized result & calls into C++ operations
+ \end{tabular}
+ \end{tabular}
+\end{center}
+This outline permits different sequences of analyses while requiring
+equivalent functionality. Rejections encountered in the emitter are recorded
+as parity bugs in Appendix~\ref{sec:backend-bugs}. An ``unsupported'' diagnostic
+documents an implementation gap; it does not by itself designate an intentional
+exception to equivalence.
+
+\evidence{\src{src/runtime/interpreter.cpp}, \code{interpret};
+\src{include/ibex/runtime/query_lease.hpp}, \code{QueryExecutionLease};
+\src{tools/ibex_compile.cpp}; \src{src/codegen/emitter.cpp},
+\code{Emitter::emit_node}, especially Filter, Project, and Window cases.}
+
+\paragraph{Literature context.}
+Neumann's query-compilation work describes data-centric code generation that
+pushes tuples through generated pipelines and aims to retain values in CPU
+registers~\cite{neumann2011}. Emitting C++ does not by itself establish that
+Ibex implements that execution model. The supported statement here is that
+Ibex has an IR-to-C++ backend alongside its host operator runtime.
+
+\section{Data representation and ownership}
+Ibex's column layouts are heavily inspired by the Apache Arrow columnar
+format~\cite{arrowColumnar}. The relevant Arrow layouts are fixed-size
+primitive arrays, variable-size binary arrays (including UTF-8 strings),
+and dictionary-encoded arrays, together with separate validity bitmaps.
+The following describes how these ideas appear in Ibex's internal storage.
+
+\subsection{Tables contain typed columns}
+A runtime \code{Table} contains an ordered vector of \code{ColumnEntry}
+objects and a name-to-position index. Each entry carries a name, shared
+ownership of a \code{ColumnValue}, and optional validity information.
+\code{ColumnValue} is a variant of concrete typed columns: 64-bit integers,
+double-precision floating point, strings, categoricals, dates, timestamps,
+and booleans in the inspected header. A statically typed language therefore
+coexists with runtime dispatch over concrete column representations.
+
+The useful physical distinction is between an element's logical meaning and
+its storage. In particular, strings can use either a flat byte representation
+or dictionary encoding.
+
+\begin{center}
+ \begin{tabularx}{\linewidth}{@{}lX@{}}
+ \toprule
+ Representation & Layout in the inspected implementation\\
+ \midrule
+ Fixed-width & Contiguous typed values; ordinary owned storage uses a vector.\\
+ Flat strings & One byte buffer and $n+1$ unsigned 32-bit offsets for $n$ rows.\\
+ Categorical strings & Signed 32-bit codes and a shared string dictionary.\\
+ Booleans & Values packed into 64-bit words.\\
+ Validity & An optional bitmap, separate from the value representation.\\
+ \bottomrule
+ \end{tabularx}
+\end{center}
+For a flat string column, row $i$ occupies bytes
+$[o_i,o_{i+1})$. For a categorical column, its value is $D[c_i]$, where $D$
+is the dictionary. The same integer code in two unrelated dictionaries need
+not mean the same string. This matters when combining chunks or comparing
+encoded keys; a code is meaningful only together with its dictionary.
+These layouts correspond to Arrow's ``Variable-size Binary Layout'' and
+``Dictionary-encoded Layout''~\cite{arrowColumnar}. Arrow's conventional
+binary/string layout uses signed 32-bit offsets (or signed 64-bit offsets
+for its large variant), whereas the Ibex storage described above uses
+unsigned 32-bit offsets. The shared design therefore does not imply that
+every internal buffer is interchangeable without checking its representation.
+
+\subsection{Nulls and sharing}
+An absent validity bitmap means every row is valid. With a bitmap, a set bit
+means valid and a cleared bit means null. This follows Arrow's validity
+convention, including permission to omit the bitmap when there are no
+nulls~\cite{arrowColumnar}. Payload storage and validity are
+distinct: inspecting a stored numeric payload alone does not establish
+whether that row contains a null. The full semantics of null propagation in
+expressions and aggregates remain to be documented separately.
+
+Column entries share their value objects through \code{shared_ptr}. The
+\code{Chunk::mutable_column} accessor copies the value object if it is
+shared before exposing mutable access. The inspected generic column also
+supports immutable external buffers with a shared lifetime owner and detaches
+on mutation. These are specific ownership mechanisms, not a blanket guarantee
+that arbitrary queries or interchange conversions are zero-copy.
+
+\evidence{\src{include/ibex/runtime/interpreter.hpp}, \code{ColumnValue},
+\code{ColumnEntry}, \code{ValidityBitmap}, and \code{Table};
+\src{include/ibex/core/column.hpp}, the generic \code{Column} and its string,
+categorical, and boolean specializations;
+\src{include/ibex/runtime/operator.hpp}, \code{Chunk::mutable_column}.}
+
+\subsection{Row layout is part of the execution contract}
+Tables also carry derived properties describing ordering, a time index, and
+grouping. \code{TableProperties} centralizes their construction and transfer.
+Its \code{RowTransform} classification distinguishes preserving rows,
+selecting a subsequence, reordering rows, and recombining rows. Preserving a
+subsequence can retain ordering; constructing rows from groups or multiple
+inputs cannot simply inherit every input claim.
+
+The time-frame constructor establishes ascending order on its time index.
+Normalization and key-fate rules constrain what survives later operations.
+Thus, time-series execution depends on facts about row layout as well as on
+timestamp values. A detailed account of window boundaries, group boundaries,
+and ordering-sensitive functions is reserved for a subsequent chapter.
+
+\evidence{\src{include/ibex/runtime/table_properties.hpp},
+\code{RowTransform}, \code{TableProperties::derive},
+\code{TableProperties::time_frame}, and \code{normalized}.}
+
+\section{From logical operations to executable work}
+\subsection{Lowering and logical rewrites}
+The whole-program \code{parser::lower} entry performs effect analysis,
+lowers the program, validates column references against known schemas, and
+checks joins. It then applies schema-aware join filter pushdown, semi-join
+pushdown, and inner-to-semi reductions before invoking the default optimizer.
+The default pass manager removes eligible unused pure preamble calls,
+canonicalizes the IR, and annotates pending order requirements.
+
+Two limits to this statement matter. First, static checks use the schemas
+available at that boundary; this is not evidence that all data-source schemas
+are known at parse time. Second, some analyses live in the calling path.
+The REPL also computes required columns and scan predicates for lazy-source
+decoding. A list of passes from \code{optimizer.cpp} alone would consequently
+be an incomplete description of optimization.
+
+Effect summaries constrain which calls may be discarded or reordered.
+\code{is_reorderable}, for example, checks non-I/O effects and conflicts
+between declared I/O resources. The implementation therefore represents
+semantic constraints beyond relational schema when deciding whether a
+transformation is permitted.
+
+\evidence{\src{src/parser/lower.cpp}, \code{lower};
+\src{src/ir/optimizer.cpp}, \code{make_default_pass_manager},
+\code{is_elidable}, and \code{is_reorderable};
+\src{src/repl/repl.cpp}, calls to \code{required_columns},
+\code{scan_predicates}, and \code{decode_demanded_lazy_sources}.}
+
+\subsection{Physical planning and fallback}
+At runtime, \code{build_operator_impl} calls \code{physical::plan_physical}.
+The resulting description can cover a chain of row-local operations or a
+supported stateful operator. The physical plan records such choices as map
+steps, fusion, source kind, parallel mode, and join or aggregate strategy.
+For a migrated plan, the executor consumes this description. Other shapes
+use explicit handling or a materialized fallback through
+\code{build_materialized_fallback}.
+
+Fallback is part of the current architecture. It lets supported pipelines
+and operators coexist with operations that consume whole tables. In
+particular, falling back for a parent need not discard physical execution of
+its children: the fallback builder routes direct children through the
+physical path. A precise account must state both the preferred strategy and
+the conditions that select another strategy.
+
+For joins, the plan distinguishes streaming probe from materializing both
+inputs. Its decline reasons include unsupported key shapes, non-equi
+predicates, null-equality semantics, cardinality assertions, and match
+selection. Textual left and right inputs are recorded separately from
+runtime build/probe orientation. For aggregates, the strategy vocabulary
+includes an adaptive sorted-stream path with a hash fallback, a fused
+left-join count, and whole-input materialization. These are strategy
+boundaries; their detailed algorithms have not yet been audited for this
+document.
+
+\evidence{\src{src/runtime/runtime_entry.cpp}, \code{build_operator_impl};
+\src{src/runtime/physical_plan.hpp}, \code{Plan}, \code{JoinPlan},
+\code{JoinDeclineReason}, and \code{AggregateStrategy};
+\src{tests/test_physical_plan.cpp}, tests for map fusion, join classification,
+aggregate classification, and pipelines over breakers.}
+
+\section{Chunks, pull execution, and materialization}
+The common operator interface is
+\begin{lstlisting}
+next() -> expected, string>
+\end{lstlisting}
+A successful pull produces a chunk or signals end of input; an error carries
+a message. A chunk is a horizontal portion of a table, represented by its
+columns rather than by an array of row objects. It also has sequence and
+source-row-offset fields. A chunk is an interface unit, not a guarantee of
+a particular size: \code{TableSourceOperator} can emit an entire materialized
+table in one chunk.
+
+\code{MaterializeOperator::run} pulls chunks and assembles a \code{Table}.
+The first chunk donates its columns; later chunks are appended with schema
+checks and validity handling. The runtime contract requires an empty input
+to emit an empty chunk carrying its schema, so zero rows do not erase the
+result's column names and types. A dedicated physical-execution test checks
+this behavior for an empty filtered input.
+
+Incrementally draining the final sink avoids retaining every incoming chunk
+as a separate object. It does \emph{not} establish bounded memory for the
+whole query: the final result, retained inputs, hash tables, sort state,
+buffered pipeline results, and materialized fallback inputs may also occupy
+memory. This draft makes no claim about spilling to disk or a global memory
+limit.
+
+\evidence{\src{include/ibex/runtime/operator.hpp}, \code{Operator},
+\code{Chunk}, \code{TableSourceOperator}, and \code{MaterializeOperator};
+\src{src/runtime/CONTRACTS.md}, chunk and materialization contracts;
+\src{tests/test_physical_plan.cpp}, ``Migrated filter keeps the empty input's
+schema carrier''.}
+
+\paragraph{Literature context.}
+Volcano provides the classic reference for composable operators with a
+standard iterator interface~\cite{graefe1994}. Ibex's pull interface is
+comparable at that architectural level, but its transfer unit is a columnar
+chunk. MonetDB/X100 motivates vector-at-a-time processing as a way to
+amortize interpretation overhead while avoiding full-column intermediates
+at every step~\cite{boncz2005}. Ibex's chunk and kernel boundaries provide a
+useful comparison, without establishing identical vector sizes, instruction
+generation, or performance. Here, ``vector'' means a batch of values; it
+does not by itself assert use of hardware SIMD instructions.
+
+\section{A small query through the engine}
+Consider the following query over an already registered table:
+\begin{lstlisting}
+trades[filter price > 15, select { price }];
+\end{lstlisting}
+The physical-plan test for this exact expression establishes that
+\code{Project(Filter(Scan(trades)))} becomes one fused map step with
+\code{FilterProjectGather} capability and a \code{TableScan} source.
+Projection is attached to the filter step, so the physical execution need
+not preserve two independent operators merely because the logical tree
+contains them.
+
+For illustration, suppose the non-null price column is $(10,20,15,30)$.
+The predicate's survivor positions are $(1,3)$ under zero-based indexing,
+and the selected output column is $(20,30)$. The mathematical effect is
+\[
+ S = (i \mid 0\leq i15),\qquad
+ r_j=p_{S_j}.
+\]
+These positions explain the gather operation; this notation does not claim
+that every execution path allocates a survivor-index vector.
+
+The chosen implementation can execute serially or, when eligible, use
+parallel range processing. To share the work, the engine divides the input
+into small ranges of consecutive rows called \emph{morsels}. Each morsel is
+a portion of work that a worker can process independently; for example,
+one worker can filter an earlier range while another filters a later range.
+A morsel describes which input rows to work on, while a chunk is the data
+passed between operators. Processing a morsel can produce a chunk containing
+only its surviving rows.
+
+A separate two-phase filter specialization first
+counts survivors in each morsel, computes prefix offsets, and then writes
+disjoint output slices. If morsel $k$ has $c_k$ survivors, its output begins
+at $b_k=\sum_{j 0.0,
+ update { notional = price * volume }];
+let enriched = eligible join instruments on symbol;
+let totals = enriched[select { volume = sum(volume),
+ notional = sum(notional) },
+ by { symbol, venue }];
+totals[update { vwap = notional / volume }][order symbol];
From a538fa03895133cdd75f5a179040a70b183ebcb2 Mon Sep 17 00:00:00 2001
From: Bob Jansen
Date: Sat, 5 Sep 2026 10:58:17 +0200
Subject: [PATCH 03/42] Lints
---
include/ibex/plugin/schema.hpp | 1 -
libs/adbc/adbc.cpp | 1 -
libs/kafka/kafka_common.hpp | 1 -
src/repl/repl.cpp | 1 -
src/ui/server.cpp | 2 --
tools/ibex_bench.cpp | 11 +++++++----
6 files changed, 7 insertions(+), 10 deletions(-)
diff --git a/include/ibex/plugin/schema.hpp b/include/ibex/plugin/schema.hpp
index e8711a2b..a25719ba 100644
--- a/include/ibex/plugin/schema.hpp
+++ b/include/ibex/plugin/schema.hpp
@@ -28,7 +28,6 @@
#include
#include
#include
-#include
#include
#include
diff --git a/libs/adbc/adbc.cpp b/libs/adbc/adbc.cpp
index 64dbee8c..0bfff5d3 100644
--- a/libs/adbc/adbc.cpp
+++ b/libs/adbc/adbc.cpp
@@ -12,7 +12,6 @@
#include
#include
-#include
#include
#include
diff --git a/libs/kafka/kafka_common.hpp b/libs/kafka/kafka_common.hpp
index 951a3d79..047963d7 100644
--- a/libs/kafka/kafka_common.hpp
+++ b/libs/kafka/kafka_common.hpp
@@ -25,7 +25,6 @@ using ibex::trim;
using ibex::plugin::parse_key_value_options;
using ibex::plugin::table_from_json_payload;
using ibex::plugin::table_row_to_json;
-using ibex::trim;
inline auto parse_kafka_field_kind(std::string_view type_str)
-> std::expected {
diff --git a/src/repl/repl.cpp b/src/repl/repl.cpp
index a9e542f4..0ba000c0 100644
--- a/src/repl/repl.cpp
+++ b/src/repl/repl.cpp
@@ -5,7 +5,6 @@
#include
#include
#include
-#include
#include
#include
#include
diff --git a/src/ui/server.cpp b/src/ui/server.cpp
index b1abf917..079d926f 100644
--- a/src/ui/server.cpp
+++ b/src/ui/server.cpp
@@ -11,8 +11,6 @@
#include
#include
-#include
-
#include
#include
#include
diff --git a/tools/ibex_bench.cpp b/tools/ibex_bench.cpp
index facff715..4c32f682 100644
--- a/tools/ibex_bench.cpp
+++ b/tools/ibex_bench.cpp
@@ -3,7 +3,6 @@
#include
#include
-#include
#include
#include
#include
@@ -3580,6 +3579,8 @@ int main(int argc, char** argv) {
std::vector trade_idx;
trade_idx.reserve(timeframe_rows / 10);
// Fine here
+ // NOLINTNEXTLINE(bugprone-random-generator-seed, cert-msc51-cpp, cert-msc32-c)
+ std::mt19937_64 rng{42};
for (std::size_t i = 0; i < timeframe_rows; ++i) {
if ((i % 10ULL) == 0ULL) {
trade_idx.push_back(i);
@@ -3592,10 +3593,10 @@ int main(int argc, char** argv) {
t_ts.reserve(trade_idx.size());
t_qty.reserve(trade_idx.size());
for (auto i : trade_idx) {
- const auto jitter_ms = static_cast((i * 37ULL) % 999ULL);
+ const auto jitter_ms = static_cast(rng() % 1000ULL);
t_ts.push_back(ibex::Timestamp{(static_cast(i) * 1'000'000'000LL) +
(jitter_ms * 1'000'000LL)});
- t_qty.push_back(static_cast((i * 13ULL) % 99ULL) + 1);
+ t_qty.push_back(static_cast(rng() % 99ULL) + 1);
}
ibex::runtime::Table trades_table;
trades_table.add_column("ts", std::move(t_ts));
@@ -3645,6 +3646,8 @@ int main(int argc, char** argv) {
std::vector trade_idx;
trade_idx.reserve(timeframe_rows / 10);
// Fine here
+ // NOLINTNEXTLINE(bugprone-random-generator-seed, cert-msc51-cpp, cert-msc32-c)
+ std::mt19937_64 rng{42};
for (std::size_t i = 0; i < timeframe_rows; ++i) {
if ((i % 10ULL) == 0ULL) {
trade_idx.push_back(i);
@@ -3658,7 +3661,7 @@ int main(int argc, char** argv) {
t_ts.reserve(trade_idx.size());
t_qty.reserve(trade_idx.size());
for (auto i : trade_idx) {
- const auto jitter_ms = static_cast((i * 37ULL) % 999ULL);
+ const auto jitter_ms = static_cast(rng() % 1000ULL);
t_ts.push_back(ibex::Timestamp{(static_cast(i) * 1'000'000'000LL) +
(jitter_ms * 1'000'000LL)});
t_sym.push_back(sym_names[i % kAsofSymbols]);
From d3a1afaae5a32bdf80b9b93e93cfc001768e53a0 Mon Sep 17 00:00:00 2001
From: Bob Jansen
Date: Sat, 19 Sep 2026 15:21:04 +0200
Subject: [PATCH 04/42] Delint and clean up ibex_bench
---
tools/ibex_bench.cpp | 110 ++++++++++++++++++++++++++-----------------
1 file changed, 67 insertions(+), 43 deletions(-)
diff --git a/tools/ibex_bench.cpp b/tools/ibex_bench.cpp
index 4c32f682..20583894 100644
--- a/tools/ibex_bench.cpp
+++ b/tools/ibex_bench.cpp
@@ -51,15 +51,14 @@ namespace {
auto normalize_input(std::string_view input) -> std::string {
auto normalized = std::string(ibex::trim(input));
- auto last_non_space = normalized.find_last_not_of(" \t\n\r");
- if (last_non_space != std::string::npos && normalized[last_non_space] != ';') {
+ if (!normalized.empty() && normalized.back() != ';') {
normalized.push_back(';');
}
return normalized;
}
auto normalize_suite_name(std::string name) -> std::string {
- std::transform(name.begin(), name.end(), name.begin(), [](unsigned char ch) {
+ std::ranges::transform(name, name.begin(), [](unsigned char ch) {
if (ch == '-') {
return '_';
}
@@ -424,7 +423,7 @@ auto verify_order_head_topk(const ibex::runtime::Table& table, const ibex::runti
for (std::size_t row = 0; row < rows; ++row) {
expected.push_back(double_at(*price_col, row));
}
- std::stable_sort(expected.begin(), expected.end(), std::greater{});
+ std::ranges::stable_sort(expected, std::greater{});
if (expected.size() > k) {
expected.resize(k);
}
@@ -454,7 +453,7 @@ auto verify_order_head_topk_by_symbol(const ibex::runtime::Table& table,
std::vector idx(rows);
std::iota(idx.begin(), idx.end(), std::size_t{0});
- std::stable_sort(idx.begin(), idx.end(), [&](std::size_t lhs, std::size_t rhs) {
+ std::ranges::stable_sort(idx, [&](std::size_t lhs, std::size_t rhs) {
return double_at(*price_col, lhs) > double_at(*price_col, rhs);
});
@@ -499,7 +498,7 @@ auto verify_order_tail_topk(const ibex::runtime::Table& table, const ibex::runti
for (std::size_t row = 0; row < rows; ++row) {
expected.push_back(double_at(*price_col, row));
}
- std::stable_sort(expected.begin(), expected.end(), std::greater{});
+ std::ranges::stable_sort(expected, std::greater{});
if (expected.size() > k) {
expected.erase(expected.begin(), expected.end() - static_cast(k));
}
@@ -529,7 +528,7 @@ auto verify_order_tail_topk_by_symbol(const ibex::runtime::Table& table,
std::vector idx(rows);
std::iota(idx.begin(), idx.end(), std::size_t{0});
- std::stable_sort(idx.begin(), idx.end(), [&](std::size_t lhs, std::size_t rhs) {
+ std::ranges::stable_sort(idx, [&](std::size_t lhs, std::size_t rhs) {
return double_at(*price_col, lhs) > double_at(*price_col, rhs);
});
@@ -588,7 +587,7 @@ auto verify_sort_price(const ibex::runtime::Table& table, const ibex::runtime::T
for (std::size_t row = 0; row < rows; ++row) {
expected.push_back(double_at(*price_col, row));
}
- std::sort(expected.begin(), expected.end());
+ std::ranges::sort(expected);
for (std::size_t i = 0; i < rows; ++i) {
if (std::abs(double_at(*out_price, i) - expected[i]) > 1e-9) {
return false;
@@ -615,7 +614,7 @@ auto verify_sort_symbol_price(const ibex::runtime::Table& table, const ibex::run
}
std::vector idx(rows);
std::iota(idx.begin(), idx.end(), std::size_t{0});
- std::stable_sort(idx.begin(), idx.end(), [&](std::size_t lhs, std::size_t rhs) {
+ std::ranges::stable_sort(idx, [&](std::size_t lhs, std::size_t rhs) {
const auto ls = string_view_at(*symbol_col, lhs);
const auto rs = string_view_at(*symbol_col, rhs);
if (ls != rs) {
@@ -1501,12 +1500,31 @@ auto compute_stats(std::vector times) -> BenchStats {
// Reset the kernel's peak-RSS counter (VmHWM) so the next peak_rss_mb() read
// reflects only the work done since this call. Writing "5" to clear_refs clears
-// the per-process peak. Linux-only; a no-op where /proc/self/clear_refs is
-// unavailable (the subsequent peak read then reports the lifetime peak).
-void reset_peak_rss() {
- if (std::FILE* f = std::fopen("/proc/self/clear_refs", "w")) {
- std::fputs("5\n", f);
- std::fclose(f);
+// the per-process peak. Linux-only. Returns false when the reset did not take
+// (no /proc/self/clear_refs, or the kernel rejected the write); the subsequent
+// peak read then reports the lifetime peak, not the peak of this window.
+[[nodiscard]] auto reset_peak_rss() -> bool {
+ std::FILE* f = std::fopen("/proc/self/clear_refs", "w");
+ if (f == nullptr) {
+ return false;
+ }
+ const bool wrote = std::fputs("5\n", f) >= 0;
+ // fputs only fills the stdio buffer; the kernel validates the value when
+ // fclose flushes it, so fclose is where a rejected reset surfaces.
+ const bool closed = std::fclose(f) == 0;
+ return wrote && closed;
+}
+
+// Start a peak-RSS measurement window. If the reset fails, warn once on
+// stderr (stdout is parsed by bench_ibex.sh) that peak_rss_mb values are
+// lifetime peaks, so a large earlier query inflates every later one.
+void begin_peak_rss_window() {
+ static bool warned = false;
+ if (!reset_peak_rss() && !warned) {
+ warned = true;
+ ibex::formatting::print(stderr,
+ "warning: could not reset peak RSS via /proc/self/clear_refs; "
+ "peak_rss_mb reports the process lifetime peak, not per-query\n");
}
}
@@ -1528,7 +1546,7 @@ auto peak_rss_mb() -> double {
// Print one benchmark result line in the key=value format that bench_ibex.sh
// parses into a TSV row. peak_rss_mb is the absolute VmHWM during the measured
-// iterations (reset via reset_peak_rss() just before the timed loop).
+// iterations (reset via begin_peak_rss_window() just before the timed loop).
void print_bench_line(std::string_view name, std::size_t iters, const BenchStats& s,
std::size_t rows, double peak_mb) {
ibex::formatting::print(
@@ -1551,10 +1569,16 @@ auto pack_filter_micro_word_scalar(const std::uint8_t* mp, std::size_t lim) noex
}
#ifdef __AVX2__
+auto load_filter_micro_vector(const std::uint8_t* src) noexcept -> __m256i {
+ __m256i value;
+ std::memcpy(&value, src, sizeof(value));
+ return value;
+}
+
auto pack_filter_micro_word_avx2(const std::uint8_t* mp) noexcept -> std::uint64_t {
const __m256i zero = _mm256_setzero_si256();
- const __m256i lo = _mm256_loadu_si256(reinterpret_cast(mp));
- const __m256i hi = _mm256_loadu_si256(reinterpret_cast(mp + 32));
+ const __m256i lo = load_filter_micro_vector(mp);
+ const __m256i hi = load_filter_micro_vector(mp + 32);
const auto lo_bits =
static_cast(_mm256_movemask_epi8(_mm256_cmpgt_epi8(lo, zero)));
const auto hi_bits =
@@ -1642,7 +1666,7 @@ auto run_bitmap_kernel_benchmark(std::string_view bench_name, std::size_t rows,
}
}
- reset_peak_rss();
+ begin_peak_rss_window();
std::vector times(iters);
for (std::size_t i = 0; i < iters; ++i) {
auto t0 = std::chrono::steady_clock::now();
@@ -1670,7 +1694,7 @@ auto run_scalar_kernel_benchmark(std::string_view bench_name, std::size_t rows,
run_and_touch();
}
- reset_peak_rss();
+ begin_peak_rss_window();
std::vector times(iters);
for (std::size_t i = 0; i < iters; ++i) {
auto t0 = std::chrono::steady_clock::now();
@@ -1741,7 +1765,7 @@ auto run_benchmark(const BenchQuery& query, const ibex::runtime::TableRegistry&
}
}
std::size_t last_rows = 0;
- reset_peak_rss();
+ begin_peak_rss_window();
std::vector times(iters);
for (std::size_t i = 0; i < iters; ++i) {
auto t0 = std::chrono::steady_clock::now();
@@ -1784,7 +1808,7 @@ auto run_benchmark(const BenchQuery& query, const ibex::runtime::TableRegistry&
}
std::size_t last_rows = 0;
- reset_peak_rss();
+ begin_peak_rss_window();
std::vector times(iters);
for (std::size_t i = 0; i < iters; ++i) {
auto t0 = std::chrono::steady_clock::now();
@@ -1844,7 +1868,7 @@ auto run_benchmark(const BenchQuery& query, const ibex::runtime::TableRegistry&
std::size_t last_rows = 0;
last_result = {};
- reset_peak_rss();
+ begin_peak_rss_window();
std::vector times(iters);
for (std::size_t i = 0; i < iters; ++i) {
last_result = {};
@@ -3780,26 +3804,26 @@ int main(int argc, char** argv) {
ibex::formatting::print("\n-- Decimal vs Int64 vs Float64 benchmarks ({} rows) --\n",
decimal_rows);
const std::vector dec_queries = {
- {"sum_i64", "dec_data[select { s = sum(i) }]"},
- {"sum_f64", "dec_data[select { s = sum(f) }]"},
- {"sum_dec", "dec_data[select { s = sum(d) }]"},
- {"sum_dec38", "dec_data[select { s = sum(w) }]"},
- {"group_sum_i64", "dec_data[select { s = sum(i) }, by k]"},
- {"group_sum_f64", "dec_data[select { s = sum(f) }, by k]"},
- {"group_sum_dec", "dec_data[select { s = sum(d) }, by k]"},
- {"filter_i64", "dec_data[filter i > 25000, select { k }]"},
- {"filter_f64", "dec_data[filter f > 250.0, select { k }]"},
- {"filter_dec", "dec_data[filter d > 250.00, select { k }]"},
- {"mul_const_i64", "dec_data[update { x = i * 3 }]"},
- {"mul_const_f64", "dec_data[update { x = f * 3.0 }]"},
- {"mul_const_dec", "dec_data[update { x = d * 3 }]"},
- {"add_cols_i64", "dec_data[update { x = i + i }]"},
- {"add_cols_f64", "dec_data[update { x = f + f }]"},
- {"add_cols_dec", "dec_data[update { x = d + d }]"},
- {"sort_i64", "dec_data[order i]"},
- {"sort_f64", "dec_data[order f]"},
- {"sort_dec", "dec_data[order d]"},
- {"sort_dec38", "dec_data[order w]"},
+ {.name = "sum_i64", .source = "dec_data[select { s = sum(i) }]"},
+ {.name = "sum_f64", .source = "dec_data[select { s = sum(f) }]"},
+ {.name = "sum_dec", .source = "dec_data[select { s = sum(d) }]"},
+ {.name = "sum_dec38", .source = "dec_data[select { s = sum(w) }]"},
+ {.name = "group_sum_i64", .source = "dec_data[select { s = sum(i) }, by k]"},
+ {.name = "group_sum_f64", .source = "dec_data[select { s = sum(f) }, by k]"},
+ {.name = "group_sum_dec", .source = "dec_data[select { s = sum(d) }, by k]"},
+ {.name = "filter_i64", .source = "dec_data[filter i > 25000, select { k }]"},
+ {.name = "filter_f64", .source = "dec_data[filter f > 250.0, select { k }]"},
+ {.name = "filter_dec", .source = "dec_data[filter d > 250.00, select { k }]"},
+ {.name = "mul_const_i64", .source = "dec_data[update { x = i * 3 }]"},
+ {.name = "mul_const_f64", .source = "dec_data[update { x = f * 3.0 }]"},
+ {.name = "mul_const_dec", .source = "dec_data[update { x = d * 3 }]"},
+ {.name = "add_cols_i64", .source = "dec_data[update { x = i + i }]"},
+ {.name = "add_cols_f64", .source = "dec_data[update { x = f + f }]"},
+ {.name = "add_cols_dec", .source = "dec_data[update { x = d + d }]"},
+ {.name = "sort_i64", .source = "dec_data[order i]"},
+ {.name = "sort_f64", .source = "dec_data[order f]"},
+ {.name = "sort_dec", .source = "dec_data[order d]"},
+ {.name = "sort_dec38", .source = "dec_data[order w]"},
};
for (const auto& query : dec_queries) {
status = run_benchmark(query, dec_tables, warmup_iters, iters, saved_include_parse);
From b45a9f2747a0401e570a9740fd9421a227a46b32 Mon Sep 17 00:00:00 2001
From: Bob Jansen
Date: Sat, 19 Sep 2026 22:09:04 +0200
Subject: [PATCH 05/42] Code and comment quality
---
plans/kernel-pipeline-execution-plan.md | 4 +-
src/parser/lower.cpp | 55 +++++++
src/parser/parser.cpp | 58 +++++++
src/runtime/CMakeLists.txt | 1 -
src/runtime/join_chunked.cpp | 34 ++++-
src/runtime/map_chunked.cpp | 24 +--
src/runtime/physical_executor.cpp | 78 ----------
src/runtime/physical_executor_internal.hpp | 6 -
src/runtime/physical_plan.cpp | 167 ++++++---------------
src/runtime/pipeline_executor.cpp | 50 ++++++
src/runtime/runtime_entry.cpp | 57 ++++++-
11 files changed, 307 insertions(+), 227 deletions(-)
delete mode 100644 src/runtime/physical_executor.cpp
diff --git a/plans/kernel-pipeline-execution-plan.md b/plans/kernel-pipeline-execution-plan.md
index f65df0f1..b87962c0 100644
--- a/plans/kernel-pipeline-execution-plan.md
+++ b/plans/kernel-pipeline-execution-plan.md
@@ -35,7 +35,7 @@ canonicalize table is in `include/ibex/ir/canonicalize.hpp`.
`src/runtime/chunked.cpp` remains the residual streaming operator
implementations and a large set of operator-specific construction rules.
Planning lives in `physical_plan.cpp`, migrated-plan validation and dispatch in
-`physical_executor.cpp`, and generic map/morsel execution in
+`runtime_entry.cpp`, and generic map/morsel execution in
`pipeline_executor.cpp`. Aggregate and streaming inner join have moved to
family-owned translation units, but the remaining responsibilities are still
grown together because
@@ -675,7 +675,7 @@ only when a profile shows it costing wall time.
practical floor). The opt-in `IBEX_PROBE_MORSELS=1` POC retains a
pre-existing SF4 q09 stall in both baseline and extracted trees; it remains
disabled and is a separate correctness follow-up.
- **Physical-plan executor DONE 2026-08-29.** `physical_executor.cpp` owns root
+ **Physical-plan executor DONE 2026-08-29.** `runtime_entry.cpp` owns root
validation, migrated-kind dispatch, path accounting, and
`build_operator_from_physical_plan`; concrete operator factories stay with
their implementations. Correctness: focused physical tests (29 cases, 4.34M
diff --git a/src/parser/lower.cpp b/src/parser/lower.cpp
index c8f82776..21c935ba 100644
--- a/src/parser/lower.cpp
+++ b/src/parser/lower.cpp
@@ -1,6 +1,61 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Bob Jansen
+// Lowering: Program AST (ast.hpp, see parser.cpp) -> IR node tree (ir/node.hpp).
+//
+// Entry points
+// ------------
+// lower() whole program -> one plan for the last expression statement
+// (wrapped in a ProgramNode when there are preamble calls).
+// lower_script() whole script -> ScriptPlan { preamble, shared_bindings,
+// sinks, result } for the batch executor. Table-consuming
+// extern calls (sinks) are only allowed here.
+// lower_expr() one expression against a REPL-supplied LowerContext
+// (bindings, externs, functions, schemas already in scope).
+//
+// Each entry point runs the same four steps:
+// 1. analyze_effects() over the Program (lower_expr's caller has done it).
+// 2. Lowerer, a single-pass AST walker. Declarations (extern / fn) are
+// collected first. Then:
+// - identifier -> the bound plan, cloned, if a `let` bound it,
+// else ScanNode(name). A let-bound table that is
+// referenced repeatedly and expensive to re-run
+// becomes a SharedBinding: materialized once, later
+// uses lower to Scan(name).
+// - call -> ExternCallNode for table-returning externs; table
+// UDFs are inlined (inline_table_udf).
+// - join -> JoinNode.
+// - t[clauses] -> lower_block, described below.
+// Scalar/column expressions inside clauses go through lower_expr_to_ir
+// into ir::Expr. Scalar and aggregate UDF calls are inlined there. A
+// correlated scalar(...) subquery compared in a filter is decorrelated
+// into a join (lower_filter / decorrelate / lower_scalar_subquery).
+// 3. Static checks over the finished tree: check_column_refs, check_joins.
+// 4. Schema-aware rewrites that must run before canonicalize fuses
+// Filter(Join(..)): push_filters_into_joins, push_semi_joins_down,
+// reduce_inner_joins_to_semi. lower() and lower_script() then run
+// ir::optimize_plan. lower_expr() does not, because the REPL runs its own
+// passes on the tree it returns.
+//
+// How a block lowers
+// ------------------
+// lower_block first records every clause in a ClauseState (at most one of
+// each kind) and rejects invalid combinations (select+update, resample
+// without select, dcast without by, ...). It then builds nodes bottom-up over
+// the base plan in a FIXED order, not in source order:
+//
+// filter -> rename -> select (Aggregate if a field calls an aggregate,
+// else Project) | distinct | update -> order -> window -> resample -> melt ->
+// dcast -> cov | corr | transpose -> model -> head | tail
+//
+// `map { }` must be the only clause in its block and lowers to a MapNode.
+// window+select is a rolling projection handled inside the window step, not an
+// aggregation.
+//
+// The AST is not modified. Rewrites that need an edited AST (UDF parameter
+// substitution, map-field expansion) work on clones (clone_expr /
+// clone_clause).
+
#include
#include
#include
diff --git a/src/parser/parser.cpp b/src/parser/parser.cpp
index 5b6e5d2e..6aaacb09 100644
--- a/src/parser/parser.cpp
+++ b/src/parser/parser.cpp
@@ -1,6 +1,64 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Bob Jansen
+// Parser: Ibex source text -> Program AST (ast.hpp).
+//
+// How parsing happens
+// -------------------
+// parse() runs three passes:
+// 1. tokenize() (lexer.cpp) turns the whole source into a token vector up
+// front. Lexing never fails outright: bad input becomes a
+// TokenKind::Error token, which the parser reports when it reaches it.
+// 2. Parser, a hand-written recursive-descent parser over that vector, with
+// one token of lookahead (peek / peek_next) and no backtracking. It stops
+// at the first error: the failing routine stores it in `error_` and
+// returns nullptr / nullopt, every caller propagates that unchanged, and
+// parse_program() turns it into the std::unexpected result. There is no
+// error recovery.
+// 3. analyze_effects() (effects.cpp) checks effect annotations over the
+// finished Program. It can still reject a program that parsed correctly.
+//
+// Expression precedence, loosest to tightest (one routine per level; binary
+// levels loop, so they are left-associative):
+// parse_join a join b on ... (inner/left/right/outer/semi/anti)
+// parse_or ||
+// parse_and &&
+// parse_equality == !=
+// parse_comparison < <= > >=, and postfix `is [not] null`
+// parse_term + -
+// parse_factor * / %
+// parse_unary prefix - ! (- is folded into the literal)
+// parse_postfix expr[clause, ...] and expr as Type / expr as { schema }
+// parse_primary literals, identifiers, calls, (..)/{..} groups, case,
+// [..] arrays, Table(..), stream, ^name, left()/right()/outer()
+//
+// Shape of a parse
+// ----------------
+// Program { vector }
+// Stmt = ExternDecl | FunctionDecl | LetStmt | TupleLetStmt | ExprStmt | ImportDecl
+// Every statement ends in ';' (except FunctionDecl, which ends at its
+// closing '}') and records its start_line/end_line.
+// FunctionDecl bodies are vector (let / tuple-let / expression
+// only; no nested fn, extern or control flow).
+// Expr { variant node }: a tree owned through ExprPtr (unique_ptr).
+// Operators become UnaryExpr / BinaryExpr. Parentheses are kept as
+// GroupExpr rather than dropped.
+// A query is a BlockExpr: a base expression plus a vector
+// (filter, select, update, by, order, window, ...) in source order. So
+// `t[filter x > 0, select {x}]` parses as BlockExpr{ base = t,
+// clauses = [FilterClause, SelectClause] }. Chained brackets nest:
+// `t[..][..]` is a BlockExpr whose base is another BlockExpr. The parser
+// only requires `map { }` to be the last clause of a block. Lowering then
+// requires it to be the only one.
+//
+// The AST is purely syntactic. Names are not resolved and nothing is type- or
+// schema-checked here. Some decisions are made later in lower.cpp, e.g.
+// whether left()/right() is actually inside a join predicate. The parser does
+// a few syntactic desugarings: interpolated backtick strings
+// `a${e}b` become CallExpr "__interp"(a, e, b) (the embedded expressions are
+// re-lexed and parsed by a sub-Parser), `as { .. }` becomes
+// `as DataFrame<{ .. }>`, and negated numeric literals are folded.
+
#include
#include
#include
diff --git a/src/runtime/CMakeLists.txt b/src/runtime/CMakeLists.txt
index 587410cd..e2fa19bb 100644
--- a/src/runtime/CMakeLists.txt
+++ b/src/runtime/CMakeLists.txt
@@ -25,7 +25,6 @@ add_library(ibex_runtime STATIC
lazy_table.cpp
model.cpp
ops.cpp
- physical_executor.cpp
physical_plan.cpp
pipeline.cpp
pipeline_executor.cpp
diff --git a/src/runtime/join_chunked.cpp b/src/runtime/join_chunked.cpp
index 0bbfb0d6..4935b223 100644
--- a/src/runtime/join_chunked.cpp
+++ b/src/runtime/join_chunked.cpp
@@ -1,9 +1,37 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Bob Jansen
-// chunked.cpp — streaming (chunked) operator pipeline: per-chunk operators,
-// rank evaluation, extern-call execution, and build_operator plan construction.
-// Split out of interpreter.cpp; shared declarations live in interpreter_internal.hpp.
+// join_chunked.cpp — the streaming inner hash join (one or two equi-keys, no
+// residual predicate), split into a HashBuild and a HashProbe half.
+//
+// HashBuild build_join_hash_index / build_join_pair_index. Chains every
+// row to the next row with the same key, over a PartitionedHeads
+// head table that workers fill in parallel with no locks and no
+// merge. The result is the immutable JoinHashIndex. Null keys are
+// never indexed or looked up, so null matches nothing.
+// Orientation chosen at RUN time from measured row counts (JoinOrientation):
+// BuildRight when the right side is small (kStreamRightThreshold)
+// or the left is at least as big: index the right side and stream
+// left chunks through it. Only a smaller left can become
+// BuildLeft ("swapped", decided by choose_and_build_single_key):
+// index the left side and scan the right once, emitting in
+// right-scan order. The left side is drained only until it is known to be
+// bigger than the right (BufferedThenStreamSource), so it is
+// never copied in full.
+// HashProbe JoinProbe + JoinProbeOperator / SwappedHashProbeOperator /
+// PrecomputedHashProbeOperator. They read the index through
+// shared_ptr and are picked by the HashProbeInput
+// variant, so a probe never re-decides the orientation. Several
+// probes can share one build (per-worker morsel chains).
+//
+// ChunkedInnerJoinOperator ties the two halves together. It also runs the
+// deferred probe: when the right side is a lazy scan the driver registered
+// (deferred_probe_scan_of), the left side is built first and a
+// DynamicScanFilter over its keys is published into that scan, so decode-time
+// pruning narrows the right side before it is materialized. The make_* entry
+// points used by runtime_entry.cpp are at the bottom of the file and are
+// declared in join_chunked_internal.hpp.
+// Background: plans/kernel-pipeline-execution-plan.md.
#include
#include
diff --git a/src/runtime/map_chunked.cpp b/src/runtime/map_chunked.cpp
index 10e8c026..4c253b3c 100644
--- a/src/runtime/map_chunked.cpp
+++ b/src/runtime/map_chunked.cpp
@@ -66,13 +66,7 @@ struct ChunkIdentity {
return chunk;
}
-/// Per-chunk filter: pulls a chunk from the child, wraps it as a `Table`,
-/// reuses the existing `filter_table` predicate evaluator, and emits the
-/// filtered columns as the next chunk. Chunks that filter to zero rows
-/// are skipped — the operator loops until it has a non-empty chunk or
-/// the child stream ends.
-/// Keeps one zero-row chunk back so an operator that rejects every row still
-/// emits its schema.
+/// Preserves schema for operators that skip zero-row results.
///
/// A stream carries its schema in its chunks, so an operator that emits no chunk
/// emits no schema either: the result materializes as a table with no columns at
@@ -80,10 +74,10 @@ struct ChunkIdentity {
/// a filter for the value it compares — fails with "unknown column" on what is
/// really just an empty input.
///
-/// Row filters are where that bites, since they are what can reject everything.
-/// Each skips its empty chunks (forwarding them would be pure overhead), so this
-/// holds the first one back and releases it at end of stream if nothing else was
-/// ever emitted.
+/// Callers offer empty results as Tables; this helper retains the first one with
+/// columns, together with its chunk identity. At end of stream, release() converts
+/// it back to a zero-row chunk only if emitted() has never been called. The Table
+/// is storage for the empty columns and their metadata; no filtering happens here.
class SchemaCarrier {
public:
/// Offer a zero-row result as the schema of last resort.
@@ -111,6 +105,14 @@ class SchemaCarrier {
bool emitted_ = false;
};
+/// Per-chunk filter: pulls from the child and delegates to kernel::filter_chunk.
+/// Supported predicates run directly on the chunk. The fallback moves its columns
+/// into a Table, builds a column-name index, calls filter_table, and converts the
+/// result back to a chunk while preserving sequence and row offset.
+///
+/// Normally skips zero-row results with columns, saving the first in SchemaCarrier
+/// so an all-rejected stream still emits its schema at end of stream. With
+/// preserve_empty_morsels enabled, forwards those empty chunks immediately instead.
class ChunkedFilterOperator final : public Operator {
public:
ChunkedFilterOperator(OperatorPtr child, const ir::Expr* predicate,
diff --git a/src/runtime/physical_executor.cpp b/src/runtime/physical_executor.cpp
deleted file mode 100644
index dfab1043..00000000
--- a/src/runtime/physical_executor.cpp
+++ /dev/null
@@ -1,78 +0,0 @@
-// SPDX-License-Identifier: AGPL-3.0-only
-// Copyright (C) 2026 Bob Jansen
-
-#include
-#include
-#include
-#include
-
-#include
-#include
-
-#include "interpreter_internal.hpp"
-#include "physical_executor_internal.hpp"
-#include "physical_plan.hpp"
-
-namespace ibex::runtime {
-
-auto build_migrated_physical_operator(const physical::Plan& plan, const ir::Node& node,
- const TableRegistry& registry, const ScalarRegistry* scalars,
- const ExternRegistry* externs, const ExecutionContext& exec,
- ModelResult* model_out)
- -> std::expected {
- if (!plan.migrated) {
- return std::unexpected("physical executor: plan does not migrate its root");
- }
- if (plan.root != &node) {
- return std::unexpected("physical executor: plan root does not match execution root");
- }
-
- using namespace physical_executor_detail;
- if (node.kind() == ir::NodeKind::Head) {
- physical::note_map_pipeline_executed();
- return build_physical_head(node, registry, scalars, externs, exec, model_out);
- }
- if (node.kind() == ir::NodeKind::Tail) {
- physical::note_map_pipeline_executed();
- return build_physical_tail(node, registry, scalars, externs, exec, model_out);
- }
- if (node.kind() == ir::NodeKind::TopK) {
- physical::note_map_pipeline_executed();
- return build_physical_topk(node, registry, scalars, externs, exec, model_out);
- }
- if (node.kind() == ir::NodeKind::FilterHead || node.kind() == ir::NodeKind::FilterTail) {
- physical::note_map_pipeline_executed();
- return build_physical_filter_head_tail(node, registry, scalars, externs, exec, model_out);
- }
- if (node.kind() == ir::NodeKind::Distinct) {
- physical::note_map_pipeline_executed();
- return build_physical_distinct(plan, node, registry, scalars, externs, exec, model_out);
- }
- if (node.kind() == ir::NodeKind::Order) {
- physical::note_map_pipeline_executed();
- return build_physical_order(node, registry, scalars, externs, exec, model_out);
- }
- if (plan.aggregate.describes) {
- physical::note_map_pipeline_executed();
- return build_physical_aggregate(plan, node, registry, scalars, externs, exec, model_out);
- }
- if (plan.join.describes) {
- physical::note_map_pipeline_executed();
- return build_physical_join(plan, node, registry, scalars, externs, exec, model_out);
- }
- if (plan.mode != physical::PipelineMode::MorselParallel || !exec.can_fan_out()) {
- physical::note_map_pipeline_executed();
- }
- return build_physical_map_step(plan, 0, registry, scalars, externs, exec, model_out);
-}
-
-auto build_operator_from_physical_plan(const physical::Plan& plan, const ir::Node& node,
- const TableRegistry& registry, const ScalarRegistry* scalars,
- const ExternRegistry* externs, const ExecutionContext& exec,
- ModelResult* model_out)
- -> std::expected {
- return build_migrated_physical_operator(plan, node, registry, scalars, externs, exec,
- model_out);
-}
-
-} // namespace ibex::runtime
diff --git a/src/runtime/physical_executor_internal.hpp b/src/runtime/physical_executor_internal.hpp
index b5f790ba..3639c1d6 100644
--- a/src/runtime/physical_executor_internal.hpp
+++ b/src/runtime/physical_executor_internal.hpp
@@ -14,12 +14,6 @@
namespace ibex::runtime {
-/// Validate and execute a plan whose root is owned by the physical executor.
-[[nodiscard]] auto build_migrated_physical_operator(
- const physical::Plan& plan, const ir::Node& node, const TableRegistry& registry,
- const ScalarRegistry* scalars, const ExternRegistry* externs, const ExecutionContext& exec,
- ModelResult* model_out) -> std::expected;
-
/// Construction primitives supplied by the operator and pipeline families.
/// The physical executor owns dispatch; these functions keep concrete operator
/// types in the translation units that implement them.
diff --git a/src/runtime/physical_plan.cpp b/src/runtime/physical_plan.cpp
index 404cfbc7..f6db21a5 100644
--- a/src/runtime/physical_plan.cpp
+++ b/src/runtime/physical_plan.cpp
@@ -62,34 +62,22 @@ auto plan_stats() -> PlanStats& {
return stats;
}
-/// The row count below which `ChunkedDistinctOperator` stays serial. It has
-/// lived as a bare `1U << 15U` inside that operator — twice, once per dedup
-/// path. The plan is now the single owner (src/runtime/PARALLELISM.md); the
-/// operator will read it in the follow-up slice.
+/// The row count below which `ChunkedDistinctOperator` stays serial.
constexpr std::size_t kDistinctRowFloor = 1U << 15U;
-/// The most workers the packed-key partition strategy will use, matching the
-/// `std::size_t{64}` cap the operator applies today.
+/// Worker ceiling for the packed-key partition strategy.
constexpr std::size_t kPackedKeyMaxWorkers = 64;
-/// A streaming join's two fan-out floors, matching the private constants in
-/// `chunked.cpp`: `build_partitions`'s `1U << 17U` and `probe_parallel_workers`'s
-/// `1U << 14U`. Both phases share the same `min(budget, pool, 64)` worker cap.
+/// Fan-out floors for streaming join build and probe phases. Both phases
+/// cap workers at the minimum of the compute budget, pool size, and 64.
constexpr std::size_t kJoinBuildRowFloor = 1U << 17U;
constexpr std::size_t kJoinProbeRowFloor = 1U << 14U;
constexpr std::size_t kJoinMaxWorkers = 64;
-/// Hash-aggregate fan-out floors, matching the private constants in
-/// `chunked.cpp`. `kAggPartitionRowFloor` is Discovery's general radix path's
-/// `kDefaultPartitionMinRows` -- the row count below which `try_discover_
-/// partitioned` stays serial. `try_owned`'s lower `kPairOwnedMinRows` (65536)
-/// is not the phase floor: it is the operator-resolved "is the owned
-/// specialization worth it" gate, the same kind of runtime strategy choice the
-/// join operator makes for its build orientation, and it stays in the operator.
-/// `kAggFinalizeRowFloor` matches the `1U << 17U` group-count gate on
-/// `finalize_owned`'s parallel co-ranking merge. Discovery, Accumulation, and
-/// FinalOrdering retain the existing 64-worker ceiling; Emission is bounded by
-/// its output-column count and the shared compute budget.
+/// Hash-aggregate fan-out floors for radix partition discovery and finalization.
+/// The operator separately decides whether partition-owned key maps are worthwhile.
+/// Discovery, Accumulation, and FinalOrdering share a 64-worker ceiling; Emission
+/// is bounded by its output-column count and the shared compute budget.
constexpr std::size_t kAggPartitionRowFloor = 1U << 18U;
constexpr std::size_t kAggFinalizeRowFloor = 1U << 17U;
constexpr std::size_t kAggMaxWorkers = 64;
@@ -326,9 +314,7 @@ auto node_kind_name_impl(ir::NodeKind kind) -> std::string_view {
return "Update";
case ir::NodeKind::Stream:
return "Stream";
- // The map kinds too: a chain can fall back with one of these at its
- // root (`MalformedMapNode`), and an unlabeled bucket is exactly what
- // made the first backlog reading unusable.
+ // Map kinds can also be fallback roots (`MalformedMapNode`).
case ir::NodeKind::Scan:
return "Scan";
case ir::NodeKind::Filter:
@@ -382,14 +368,9 @@ auto kernel_null_policy_name(KernelNullPolicy policy) -> std::string_view {
return "unknown";
}
-/// Whether `node` is a map step this planner lowers. Filter-shaped kinds are
-/// maps unconditionally; an `Update` is a map exactly when the per-kind
-/// switch's own gate says so — no guard, no `by`, no tuple assignment, every
-/// field row-local (`is_row_local_update_expr`). That gate, not
-/// `execution_capability()`, is the authority here: capability encoding also
-/// declines a bare row-local Update, but for *morsel copy-cost* reasons
-/// (updates parallelize inside the operator instead), which is an execution
-/// choice the physical plan must not inherit as a shape decision.
+/// Whether `node` has a row-local map kernel. Kernel eligibility determines
+/// pipeline shape independently of morsel execution eligibility: row-local
+/// updates parallelize inside the operator to avoid morsel copy costs.
auto is_map_step(const ir::Node& node) -> bool {
return map_kernel_capability(node).has_value();
}
@@ -460,16 +441,9 @@ auto fusible_chain_below(const ir::Node& node) -> FusibleChain {
.update = ir::node_cast(below)};
}
-/// Decide the pipeline's execution mode from its own steps. These are the
-/// rules the deleted pipeline analysis applied while walking the IR itself;
-/// deciding them here means the chain is peeled once and its mode travels with
-/// it.
+/// Decide the pipeline's execution mode from its steps.
void resolve_pipeline_mode(Plan& plan) {
- // Search top-down for the outermost run of steps that may run over morsels.
- // Outermost-first is the existing policy, not a new one: when a chain's root
- // was ineligible, the per-kind recursion re-planned one node lower and took
- // the first pipeline it found on the way down. This finds the same run without
- // re-planning anything.
+ // Select the outermost eligible run of steps that can execute over morsels.
SerialOnlyReason reason = SerialOnlyReason::NotParallelMap;
std::size_t index = 0;
while (index < plan.steps.size()) {
@@ -521,19 +495,8 @@ auto plan_physical(const ir::Node& root, const TableRegistry& registry,
const ir::SourceSchemas schemas = planning_source_schemas(registry, source_schemas);
plan_stats().plans_built.fetch_add(1, std::memory_order_relaxed);
- // Describe a join even though the plan does not execute one yet. The plan
- // is meant to be the single description of what a query does; letting it
- // stay silent about 51% of the backlog until the day execution moves would
- // mean the description and the executor land together, untested against
- // each other.
- // Single-implementation breakers: one operator runs each, nothing to
- // classify, no fan-out point. The plan owns construction and `explain
- // physical` names them; the per-kind switch's branch for each is deleted.
- // `Head` is `ChunkedHeadOperator`; `Tail` materializes and calls
- // `tail_table`; `TopK` is `ChunkedOrderedLimitOperator` (a serial
- // bounded-heap select, O(n log k) -- deliberately not parallel, see
- // src/runtime/PARALLELISM.md); `FilterHead` / `FilterTail` are the fused
- // `ChunkedFilter{Head,Tail}Operator`.
+ // These breakers use a single operator with no fan-out phase. TopK uses
+ // a serial bounded-heap selection, O(n log k); Tail materializes its input.
if (root.kind() == ir::NodeKind::Head || root.kind() == ir::NodeKind::Tail ||
root.kind() == ir::NodeKind::TopK || root.kind() == ir::NodeKind::FilterHead ||
root.kind() == ir::NodeKind::FilterTail) {
@@ -542,10 +505,8 @@ auto plan_physical(const ir::Node& root, const TableRegistry& registry,
return plan;
}
if (root.kind() == ir::NodeKind::Distinct) {
- // One fan-out phase. The planner sets the policy (floor, strategy,
- // ceiling) and the estimate; `build_physical_distinct` resolves the
- // worker cap and the operator reads it. See src/runtime/PARALLELISM.md,
- // "Target: parallelism as a plan decision".
+ // The planner sets the dedup policy and estimate; the physical builder
+ // resolves the worker cap for the operator. See src/runtime/PARALLELISM.md.
plan.migrated = true;
plan.source_node = &root;
plan.breaker_phases.push_back(
@@ -556,7 +517,7 @@ auto plan_physical(const ir::Node& root, const TableRegistry& registry,
if (root.kind() == ir::NodeKind::Order) {
// One operator runs every Order (`ChunkedOrderOperator` → `order_table`).
// Its one fan-out point (the radix sort + row gather) is described so
- // `explain physical` is not silent about it; the fan-out itself already
+ // `explain physical` is not silent about it; the fan-out itself
// lives in `sort.cpp` on the shared knobs, so the phase is descriptive
// rather than something the operator reads.
plan.migrated = true;
@@ -567,9 +528,8 @@ auto plan_physical(const ir::Node& root, const TableRegistry& registry,
if (root.kind() == ir::NodeKind::Aggregate) {
const auto& aggregate = ir::node_cast(root);
plan.aggregate = plan_aggregate(aggregate);
- // Streaming and fused aggregates are executed by the plan now.
- // `MaterializeAll` is not: it still falls back and still counts, which
- // is what keeps the backlog measuring the port rather than the label.
+ // Streaming and fused aggregates use physical operators; MaterializeAll
+ // uses the materialized fallback and contributes to fallback statistics.
if (plan.aggregate.strategy != AggregateStrategy::MaterializeAll) {
plan.migrated = true;
plan.source_node = &root;
@@ -603,17 +563,15 @@ auto plan_physical(const ir::Node& root, const TableRegistry& registry,
if (root.kind() == ir::NodeKind::Join) {
const auto& join = ir::node_cast(root);
plan.join = plan_join(join);
- // A streaming join is executed by the plan now: `build_physical_join`
- // builds it, not the per-kind switch. A materializing one is still a
- // fallback and says so, which is why the backlog drops by the streaming
- // joins only -- the ones actually ported.
+ // Streaming joins use physical operators; materializing joins use the
+ // fallback and contribute to fallback statistics.
if (plan.join.strategy == JoinStrategy::StreamingProbe) {
plan.migrated = true;
plan.source_node = &root;
// An inner join is two explicit physical nodes joined by a typed
// build-output edge. Both retain the textual inputs because
// orientation is resolved only after the build has measured them.
- // Semi/anti still uses its separate operator and is not described
+ // Semi/anti uses its separate operator and is not described
// by the inner join's runtime-oriented output type.
if (plan.join.branch != JoinBranch::SemiAnti) {
plan.streaming_join = StreamingJoinNodes{
@@ -632,14 +590,12 @@ auto plan_physical(const ir::Node& root, const TableRegistry& registry,
}
}
- // Peel map kinds top-down. `is_map_step` mirrors the per-kind switch's
- // own routing decisions, so the plan can never admit a step the switch
- // would build differently.
+ // Peel map kinds top-down using the shared kernel capability gates.
const ir::Node* cur = &root;
while (is_map_step(*cur)) {
const auto& children = cur->children();
if (children.size() != 1 || children.front() == nullptr) {
- // Malformed map node: leave it to the existing executor, which
+ // Malformed map node: leave it to the fallback executor, which
// produces the structural error message.
plan.source_node = cur;
plan.reason = FallbackReason::MalformedMapNode;
@@ -677,7 +633,7 @@ auto plan_physical(const ir::Node& root, const TableRegistry& registry,
const MapKernelCapability capability = *step_capability;
const MapKernelFactory factory = map_kernel_factory(capability);
if (factory == nullptr) {
- // Keep a malformed internal dispatch table on the established
+ // Keep a malformed internal dispatch table on the fallback
// executor instead of constructing an invalid physical plan.
plan.steps.clear();
plan.source_node = cur;
@@ -698,16 +654,11 @@ auto plan_physical(const ir::Node& root, const TableRegistry& registry,
plan.reason = FallbackReason::NotMapChain;
return plan;
}
- // A map chain over a breaker. The chain is a pipeline; the breaker is
- // its source, materialized by the existing executor. Constructing it is
- // what the per-kind switch does for this subtree anyway -- the source
- // goes through the public `build_operator` either way -- so this
- // records the shape rather than changing it.
+ // Materialize the breaker subtree as the input to this map pipeline.
source = SourceKind::MaterializedInput;
}
if (plan.steps.empty()) {
- // A bare source: no map work to migrate, and the Scan/ExternCall
- // branches below own its streaming decisions.
+ // A bare source has no map steps; its source operator owns streaming.
plan.reason = FallbackReason::EmptyChain;
return plan;
}
@@ -791,17 +742,12 @@ auto explain_physical(const Plan& plan) -> std::string {
}
return out;
}
- // A migrated breaker at the root -- its own operator, no map steps. Route
- // every such shape here: without it, anything `migrated` with no `steps`
- // (order, distinct, head, a streaming join/aggregate) fell through to the
- // MapPipeline branch below and printed `MapPipeline\n source: TableScan()`
- // -- a lie, since the root is not a scan and there is no pipeline.
+ // A root breaker has its own operator and no map pipeline to explain.
if (plan.steps.empty() && plan.root != nullptr) {
const std::string_view kind = node_kind_name_impl(plan.root->kind());
if (plan.join.describes) {
- // The strategy line, explicit build → probe edge, then each node's
- // fan-out policy. `breaker_phases` remains for untyped breakers;
- // a streaming join no longer hides its dataflow in two labels.
+ // Show the strategy, typed build-to-probe edge, and per-node fan-out
+ // policy. Untyped breakers use `breaker_phases`.
out += "Breaker(Join)\n " + explain_join(plan.join);
if (plan.streaming_join.has_value()) {
out += "\n edge: HashBuild.RuntimeOrientedBuildOutput -> HashProbe.build_input";
@@ -1080,8 +1026,7 @@ auto aggregate_emission_parallelism(RowEstimate estimate) -> BreakerParallelism
void resolve_breaker_parallelism(BreakerParallelism& bp, const ExecutionContext& exec,
std::size_t pool_size) {
- // A phase that names no floor of its own uses the shared knob. distinct's
- // 32768 is a deliberate, now-visible override of it.
+ // Phases without their own floor inherit the shared minimum row count.
if (bp.row_floor == 0) {
bp.row_floor = exec.parallel_min_rows;
}
@@ -1096,8 +1041,7 @@ void resolve_breaker_parallelism(BreakerParallelism& bp, const ExecutionContext&
return;
}
bp.decline = FanOutDecline::None;
- // The one place the worker cap is computed. It used to be open-coded as
- // `std::min({budget, pool_size, 64})` inside each breaker's next().
+ // Resolve the worker cap from the compute budget, pool, and phase ceiling.
std::size_t cap = exec.compute_budget();
if (pool_size != 0) {
cap = std::min(cap, pool_size);
@@ -1214,15 +1158,8 @@ auto plan_join(const ir::JoinNode& join) -> JoinPlan {
out.kind = join.kind();
out.key_count = join.keys().size();
- // The DECISION is relayed, not restated: these are the same three functions
- // the builder branches on. Reimplementing them is what made the first
- // version of this planner wrong about two-key Int64 joins, and
- // `interpreter_internal.hpp` had already written down why -- "a six-clause
- // predicate duplicated across two files, where a later clause added to one
- // copy silently routes a join the operator cannot handle".
- // Ask each gate by name and remember which one answered, in the order the
- // seam used to try them -- semi/anti first, since a semi join with one key
- // satisfies nothing below it.
+ // Use the shared eligibility gates so planning and execution agree on
+ // supported join shapes. Check semi/anti before the inner-join strategies.
if (is_streamable_semi_anti_join(join)) {
out.branch = JoinBranch::SemiAnti;
} else if (is_streamable_inner_join(join)) {
@@ -1277,13 +1214,8 @@ auto plan_join(const ir::JoinNode& join) -> JoinPlan {
out.branch = JoinBranch::None;
return out;
}
- // The two inputs, in textual order. Which one is hashed is decided by the
- // build phase at run time from measured row counts, so the plan records
- // the inputs and not an orientation. Which side *should* build is a cost
- // question this plan still does not answer -- that is q12's diagnosed
- // regression -- but the previous version of these two lines went further
- // than "not answering" and asserted left-probes/right-builds, which the
- // operator contradicts every time it swaps.
+ // Preserve textual input order. The build phase chooses which side to hash
+ // at run time from measured row counts.
out.left_input = join.children()[0].get();
out.right_input = join.children()[1].get();
return out;
@@ -1350,14 +1282,9 @@ auto node_kind_name(ir::NodeKind kind) -> std::string_view {
namespace {
-/// Whether a fallback kind is `accepted` (a permanent `MaterializedCall` — it is
-/// whole-table by nature or not breaker-shaped, so it will never become a
-/// streaming physical node) or `backlog` (a genuine candidate to migrate, but
-/// only once a profile shows it costing wall time — none currently does).
-///
-/// This is the disposition the kernel-pipeline plan's Phase 5 settled on: the
-/// adapter is the end state, not a way-station to zero fallbacks. `Scan` is the
-/// bare-source `EmptyChain` case, not a materialized call at all.
+/// Classify fallbacks as `accepted` materialized operations or `backlog`
+/// candidates for optimization when profiling justifies it. Bare `Scan` sources
+/// use `EmptyChain` and are excluded from this classification by the reporter.
auto fallback_disposition(ir::NodeKind kind) -> std::string_view {
switch (kind) {
case ir::NodeKind::Join: // non-equi / nulls-equal / expect — materializing by design
@@ -1385,7 +1312,7 @@ auto fallback_disposition(ir::NodeKind kind) -> std::string_view {
auto physical_fallback_report() -> std::string {
// Descending by count, each line tagged `accepted` (permanent
- // MaterializedCall) or `backlog` (migrate only when profiled hot).
+ // MaterializedCall) or `backlog` (optimize when profiled hot).
std::vector> rows;
for (std::size_t i = 0; i < kKindSlots; ++i) {
const std::uint64_t n = plan_stats().fallback_by_kind[i].load(std::memory_order_relaxed);
@@ -1411,14 +1338,8 @@ auto physical_fallback_report() -> std::string {
namespace {
-/// `IBEX_PLAN_STATS=1` prints the migration backlog at exit: how much of the
-/// query surface the physical plan describes, and what it does not.
-///
-/// The counters existed before this and nothing read them, which meant the plan
-/// document's own mitigation -- "every fallback explicit, profiled, and covered
-/// by a migration backlog keyed by its measured cost" -- was written down but
-/// not in place, and Phase 4's port order stayed the a-priori guess it was
-/// drafted as.
+/// Setting `IBEX_PLAN_STATS` enables exit-time plan counters and fallback counts
+/// by node kind, tagged with their accepted/backlog disposition.
struct FallbackReporter {
// The struct is only declared const in this file.
bool enabled = std::getenv("IBEX_PLAN_STATS") != nullptr;
diff --git a/src/runtime/pipeline_executor.cpp b/src/runtime/pipeline_executor.cpp
index 984efbb0..ee5fb1d4 100644
--- a/src/runtime/pipeline_executor.cpp
+++ b/src/runtime/pipeline_executor.cpp
@@ -1,6 +1,56 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Bob Jansen
+// pipeline_executor.cpp — the inter-operator parallel executors: Layers A and
+// B of src/runtime/PARALLELISM.md (read it before adding fan-out). Everything
+// here is a pull-based Operator, so a parallel pipeline plugs into the
+// ordinary next() chain. Layer C, the fan-out inside a single operator, lives
+// in the operators themselves.
+//
+// Layer B — morsel pipeline (a physical::Plan's parallel map prefix)
+// build_map_pipeline_parallel (called from map_chunked.cpp) materializes the
+// pipeline's input on the calling thread, then either runs the row-local
+// chain as one whole-table chunk (is_worth_morselizing said no) or splits
+// it into morsels:
+// MorselPipelineOperator one private MorselWorkerChain per worker, fed by
+// a MorselSource (GatherMorselSource copies the
+// morsel's rows, RangeFilterMorselSource evaluates
+// a range-native head filter in place). Results go
+// into an OrderedChunkRing and are released in
+// sequence order.
+// TwoPhaseFilterOperator a lone range-native filter: count survivors per
+// morsel, prefix-sum, then write disjoint slices of
+// one output chunk. No ring, no merge.
+// try_take_join_probe / build_probe_morsel_pipeline put a streaming join's
+// probe at the head of the worker chains (see join_chunked.cpp).
+//
+// Layer A — pipeline overlap across a breaker (called from runtime_entry.cpp)
+// PipelinedScanOperator workers each claim one source unit (e.g. a
+// Parquet row group), decode it, run a private
+// row-local chain over it, and publish into a bounded
+// OrderedChunkRing. The consumer drains the ring in
+// unit order. DeferredScanSourceOperator reads the
+// same sources without a row-local chain: workers
+// decode a window of units at a time, and the calling
+// thread serves them in unit order.
+// PipelinedStageOperator runs a breaker's child on its own scheduler thread
+// (NOT a pool thread) with a two-chunk buffer, so the
+// breaker overlaps with its producer. Only inserted
+// when has_multi_unit_deferred_scan says it can pay.
+//
+// Invariants every executor here keeps:
+// - Output is byte-identical to the serial chain at any core count. Chunks
+// carry `sequence` / `row_offset` and are released strictly in order, and
+// an empty morsel is still emitted because a skipped sequence is a lost slot.
+// - Errors are deterministic: the lowest-sequence failure wins, regardless
+// of which thread failed first.
+// - A worker never deadlocks the pool it runs on. A pool worker parked on a
+// ring runs queued pool tasks while it waits (cooperative_ring_wait), so
+// nested fan-out under it cannot be stranded. Inner parallel paths that
+// check on_worker_pool_thread() run serially inside a task.
+// - Per-worker operators are private copies. IR nodes, registries and the
+// input table are shared and immutable.
+
#include
#include
#include
diff --git a/src/runtime/runtime_entry.cpp b/src/runtime/runtime_entry.cpp
index 665ad9a7..704e79aa 100644
--- a/src/runtime/runtime_entry.cpp
+++ b/src/runtime/runtime_entry.cpp
@@ -2,7 +2,7 @@
// Copyright (C) 2026 Bob Jansen
// runtime_entry.cpp — the operator-build entry point: `build_operator` and its
-// per-NodeKind dispatch, the physical-plan construction sites
+// per-NodeKind dispatch, `build_operator_from_physical_plan`, the construction sites
// (`physical_executor_detail`: join / aggregate / tail), the materialized-call
// fallback adapter for un-migrated kinds, the small streamable-shape predicates,
// and env-driven parallel configuration (`configure_parallel_from_env`,
@@ -771,6 +771,57 @@ auto build_physical_tail(const ir::Node& node, const TableRegistry& registry,
} // namespace physical_executor_detail
+auto build_operator_from_physical_plan(const physical::Plan& plan, const ir::Node& node,
+ const TableRegistry& registry, const ScalarRegistry* scalars,
+ const ExternRegistry* externs, const ExecutionContext& exec,
+ ModelResult* model_out)
+ -> std::expected {
+ if (!plan.migrated) {
+ return std::unexpected("physical executor: plan does not migrate its root");
+ }
+ if (plan.root != &node) {
+ return std::unexpected("physical executor: plan root does not match execution root");
+ }
+
+ using namespace physical_executor_detail;
+ switch (node.kind()) {
+ case ir::NodeKind::Head:
+ physical::note_map_pipeline_executed();
+ return build_physical_head(node, registry, scalars, externs, exec, model_out);
+ case ir::NodeKind::Tail:
+ physical::note_map_pipeline_executed();
+ return build_physical_tail(node, registry, scalars, externs, exec, model_out);
+ case ir::NodeKind::TopK:
+ physical::note_map_pipeline_executed();
+ return build_physical_topk(node, registry, scalars, externs, exec, model_out);
+ case ir::NodeKind::FilterHead:
+ case ir::NodeKind::FilterTail:
+ physical::note_map_pipeline_executed();
+ return build_physical_filter_head_tail(node, registry, scalars, externs, exec,
+ model_out);
+ case ir::NodeKind::Distinct:
+ physical::note_map_pipeline_executed();
+ return build_physical_distinct(plan, node, registry, scalars, externs, exec, model_out);
+ case ir::NodeKind::Order:
+ physical::note_map_pipeline_executed();
+ return build_physical_order(node, registry, scalars, externs, exec, model_out);
+ default:
+ break;
+ }
+ if (plan.aggregate.describes) {
+ physical::note_map_pipeline_executed();
+ return build_physical_aggregate(plan, node, registry, scalars, externs, exec, model_out);
+ }
+ if (plan.join.describes) {
+ physical::note_map_pipeline_executed();
+ return build_physical_join(plan, node, registry, scalars, externs, exec, model_out);
+ }
+ if (plan.mode != physical::PipelineMode::MorselParallel || !exec.can_fan_out()) {
+ physical::note_map_pipeline_executed();
+ }
+ return build_physical_map_step(plan, 0, registry, scalars, externs, exec, model_out);
+}
+
namespace {
auto build_operator_impl(const ir::Node& node, const TableRegistry& registry,
@@ -783,8 +834,8 @@ auto build_operator_impl(const ir::Node& node, const TableRegistry& registry,
// plan-edge mutation tests exercise the same consumer production uses.
const physical::Plan plan = physical::plan_physical(node, registry, externs);
if (plan.migrated) {
- return build_migrated_physical_operator(plan, node, registry, scalars, externs, exec,
- model_out);
+ return build_operator_from_physical_plan(plan, node, registry, scalars, externs, exec,
+ model_out);
}
// Counted in every mode. It used to fire only when the query could not fan
// out, so at two cores or more the backlog read as empty -- a migration
From 645a19b132afea0dd2991edfae06ee7edca9b2ef Mon Sep 17 00:00:00 2001
From: Bob Jansen
Date: Sat, 19 Sep 2026 22:23:39 +0200
Subject: [PATCH 06/42] Aggregate without `by` over an empty input yields one
row
SQL's rule: with no `by` the whole input is one group, even when it is
empty. The result is now always exactly one row: count(), count(col) and
count_distinct(col) are 0, every other aggregate is null. With `by`,
an empty input still has no groups and no rows.
The kernels are unchanged and still return zero rows; the row is added
once at the node level (global_aggregate_of_empty), by interpret_node
for the materialized path and by a wrapper operator for the streaming
one. count(col) is lowered as a Sum over a not-null flag, so AggSpec
gains `is_count` to tell it apart from a real sum; lowering sets it and
codegen carries it through make_agg.
SPEC 7.1 states the rule. Two tests that pinned the old zero-row answer
now expect the row; a parity case covers codegen.
Co-Authored-By: Claude Opus 5
---
SPEC.md | 12 +++++
include/ibex/ir/node.hpp | 3 ++
include/ibex/runtime/ops.hpp | 2 +-
src/codegen/emitter.cpp | 4 +-
src/parser/lower.cpp | 8 +++-
src/runtime/aggregate.cpp | 38 +++++++++++++++
src/runtime/aggregate_chunked.cpp | 9 ++--
src/runtime/interpreter.cpp | 21 +++++++-
src/runtime/interpreter_internal.hpp | 9 ++++
src/runtime/ops.cpp | 7 +--
src/runtime/runtime_entry.cpp | 48 ++++++++++++++++++-
.../parity/cases/empty_global_aggregate.ibex | 5 ++
tests/test_interpreter.cpp | 17 +++++--
13 files changed, 163 insertions(+), 20 deletions(-)
create mode 100644 tests/parity/cases/empty_global_aggregate.ibex
diff --git a/SPEC.md b/SPEC.md
index a1043841..ffeb7968 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -2669,6 +2669,18 @@ field in the `select` clause must satisfy the **aggregation well-formedness
rule**:
> Every column reference in the field expression must either:
+**Empty input.** Without `by`, the whole input is one group, even when it has
+no rows: the result is always exactly one row. Over no rows `count()`,
+`count(col)` and `count_distinct(col)` are `0` and every other aggregate is
+null, and expressions over them follow from that (`mean(x) / 7.0` is null).
+With `by`, groups come from the rows that exist, so an empty input has no
+groups and the result has no rows. This is SQL's rule.
+
+```
+t[filter false, select { n = count(), m = mean(v) }] // 1 row: n = 0, m = null
+t[filter false, select { n = count() }, by g] // 0 rows
+```
+
>
> **(a)** name a grouping key column (listed in the `by` clause), or
>
diff --git a/include/ibex/ir/node.hpp b/include/ibex/ir/node.hpp
index a6701b19..8f8813a6 100644
--- a/include/ibex/ir/node.hpp
+++ b/include/ibex/ir/node.hpp
@@ -494,6 +494,9 @@ struct AggSpec {
ColumnRef column;
std::string alias;
double param = 0.0; ///< Function-specific parameter (e.g. alpha for Ewma).
+ /// `count(col)`, lowered as a Sum over a 0/1 not-null flag. Only the value
+ /// over no rows differs from a plain Sum: a count of nothing is 0, not null.
+ bool is_count = false;
};
/// Rename specification: maps an old column name to a new column name.
diff --git a/include/ibex/runtime/ops.hpp b/include/ibex/runtime/ops.hpp
index 9ed6e362..d6445a2d 100644
--- a/include/ibex/runtime/ops.hpp
+++ b/include/ibex/runtime/ops.hpp
@@ -340,6 +340,6 @@ void stream_append_row(runtime::Table& dst, const runtime::Table& src, std::size
[[nodiscard]] auto make_field(std::string alias, ir::Expr expr) -> ir::FieldSpec;
[[nodiscard]] auto make_agg(ir::AggFunc func, std::string col_name, std::string alias,
- double param = 0.0) -> ir::AggSpec;
+ double param = 0.0, bool is_count = false) -> ir::AggSpec;
} // namespace ibex::ops
diff --git a/src/codegen/emitter.cpp b/src/codegen/emitter.cpp
index 0084f6f1..da283e29 100644
--- a/src/codegen/emitter.cpp
+++ b/src/codegen/emitter.cpp
@@ -440,7 +440,7 @@ auto Emitter::emit_node(const ir::Node& node) -> std::string {
*out_ << "ibex::ops::make_agg("
<< "ibex::ir::AggFunc::" << emit_agg_func(a.func) << ", \""
<< escape_string(a.column.name) << "\", \"" << escape_string(a.alias)
- << "\", " << format_double(a.param) << ")";
+ << "\", " << format_double(a.param) << (a.is_count ? ", true" : "") << ")";
}
*out_ << "});\n";
return var;
@@ -608,7 +608,7 @@ auto Emitter::emit_node(const ir::Node& node) -> std::string {
*out_ << "ibex::ops::make_agg("
<< "ibex::ir::AggFunc::" << emit_agg_func(a.func) << ", \""
<< escape_string(a.column.name) << "\", \"" << escape_string(a.alias)
- << "\", " << format_double(a.param) << ")";
+ << "\", " << format_double(a.param) << (a.is_count ? ", true" : "") << ")";
}
*out_ << "});\n";
return var;
diff --git a/src/parser/lower.cpp b/src/parser/lower.cpp
index 21c935ba..8c2344db 100644
--- a/src/parser/lower.cpp
+++ b/src/parser/lower.cpp
@@ -4260,6 +4260,7 @@ class Lowerer {
.message =
"second argument of ewma() must be a numeric literal (alpha)"});
}
+ .is_count = true,
} else {
return std::unexpected(LowerError{
.message =
@@ -4419,6 +4420,7 @@ class Lowerer {
.message = "second argument of ewma() must be a numeric "
"literal (alpha)"});
}
+ .is_count = true,
} else {
return std::unexpected(
LowerError{.message = "second argument of ewma() must be a "
@@ -4788,8 +4790,10 @@ class Lowerer {
}
const std::string flag = make_temp();
lowered.preagg_updates.push_back(make_count_flag_field(ident->name, flag));
- lowered.aggs.push_back(ir::AggSpec{
- .func = ir::AggFunc::Sum, .column = {.name = flag}, .alias = field.name});
+ lowered.aggs.push_back(ir::AggSpec{.func = ir::AggFunc::Sum,
+ .column = {.name = flag},
+ .alias = field.name,
+ .is_count = true});
continue;
}
if (call->callee == "ewma") {
diff --git a/src/runtime/aggregate.cpp b/src/runtime/aggregate.cpp
index aa05247a..433ff4b7 100644
--- a/src/runtime/aggregate.cpp
+++ b/src/runtime/aggregate.cpp
@@ -101,6 +101,44 @@ auto default_column_for(ExprType type, std::size_t rows) -> ColumnValue {
} // namespace
+auto global_aggregate_of_empty(const std::vector& empty_columns,
+ const std::vector& aggregations)
+ -> std::vector {
+ std::vector row;
+ row.reserve(empty_columns.size());
+ for (const ColumnEntry& entry : empty_columns) {
+ const auto spec = std::ranges::find(aggregations, entry.name, &ir::AggSpec::alias);
+ const bool counts = spec != aggregations.end() &&
+ (spec->func == ir::AggFunc::Count ||
+ spec->func == ir::AggFunc::CountDistinct || spec->is_count);
+ // Copying the empty column keeps its type and metadata (a Decimal's
+ // precision/scale, a Categorical's dictionary) for the one row added.
+ ColumnValue column = *entry.column;
+ std::visit(
+ [&](auto& col) {
+ using Col = std::decay_t;
+ if constexpr (std::is_same_v>) {
+ col.resize(1, 0);
+ } else if constexpr (std::is_same_v>) {
+ // A code needs a dictionary entry to point at, even under
+ // a null: an empty dictionary has no valid code 0.
+ col.push_back(std::string_view{});
+ } else {
+ col.resize(1);
+ }
+ },
+ column);
+ ColumnEntry out{.name = entry.name,
+ .column = std::make_shared(std::move(column)),
+ .validity = std::nullopt};
+ if (!counts) {
+ out.validity = ValidityBitmap(1, false);
+ }
+ row.push_back(std::move(out));
+ }
+ return row;
+}
+
// NOLINTNEXTLINE(readability-function-size)
auto aggregate_table(const Table& input, const std::vector& group_by,
const std::vector& aggregations, const ExecutionContext* exec)
diff --git a/src/runtime/aggregate_chunked.cpp b/src/runtime/aggregate_chunked.cpp
index f12b94a9..61bb2d20 100644
--- a/src/runtime/aggregate_chunked.cpp
+++ b/src/runtime/aggregate_chunked.cpp
@@ -4577,10 +4577,11 @@ class HashAggregateState final {
/// Global aggregate over `rows`, optionally fanned out across workers.
auto process_rows_ungrouped(const std::vector& agg_entries,
std::size_t rows) -> std::optional {
- // An empty input must produce NO group, hence no output row — the
- // generic path got that for free by only creating a group when a row
- // arrived. Creating it up front turned `count()` over an empty table
- // into a 1-row answer.
+ // An empty input produces NO group here, hence no output row, like the
+ // generic path, which only creates a group when a row arrives. The
+ // one row SQL requires for an empty global aggregate (count 0, other
+ // aggregates null) is added once, at the node level
+ // (`global_aggregate_of_empty`), rather than by every kernel.
if (rows == 0) {
return std::nullopt;
}
diff --git a/src/runtime/interpreter.cpp b/src/runtime/interpreter.cpp
index 0185411c..072393ce 100644
--- a/src/runtime/interpreter.cpp
+++ b/src/runtime/interpreter.cpp
@@ -704,6 +704,21 @@ auto interpret_node(const ir::Node& node, const TableRegistry& registry,
if (agg.children().empty()) {
return std::unexpected("aggregate node missing child");
}
+ // With no `by`, an empty input still yields one row (count 0,
+ // everything else null). The kernels return zero rows; fix it here.
+ const auto one_group_if_ungrouped =
+ [&](std::expected result) -> std::expected {
+ if (!result.has_value() || !agg.group_by().empty() || result->rows() != 0 ||
+ result->columns.empty()) {
+ return result;
+ }
+ Table row;
+ for (auto& entry : global_aggregate_of_empty(result->columns, agg.aggregations())) {
+ row.add_column_shared(std::move(entry.name), std::move(entry.column),
+ std::move(entry.validity));
+ }
+ return row;
+ };
// Fast path: Aggregate(Scan) — pass the registry table by const ref to skip the copy.
const ir::Node& child_node = *agg.children().front();
if (child_node.kind() == ir::NodeKind::Scan) {
@@ -712,7 +727,8 @@ auto interpret_node(const ir::Node& node, const TableRegistry& registry,
if (it == registry.end()) {
return std::unexpected("unknown table: " + scan.source_name());
}
- return aggregate_table(it->second, agg.group_by(), agg.aggregations(), &exec);
+ return one_group_if_ungrouped(
+ aggregate_table(it->second, agg.group_by(), agg.aggregations(), &exec));
}
// Same fusion the chunked path takes, from the same resolver: the
// skip-walk and its seven clauses were written out here and there,
@@ -737,7 +753,8 @@ auto interpret_node(const ir::Node& node, const TableRegistry& registry,
if (!child) {
return std::unexpected(child.error());
}
- return aggregate_table(child.value(), agg.group_by(), agg.aggregations(), &exec);
+ return one_group_if_ungrouped(
+ aggregate_table(child.value(), agg.group_by(), agg.aggregations(), &exec));
}
case ir::NodeKind::Resample: {
const auto& rs = ir::node_cast(node);
diff --git a/src/runtime/interpreter_internal.hpp b/src/runtime/interpreter_internal.hpp
index 099c24de..5ada7fd8 100644
--- a/src/runtime/interpreter_internal.hpp
+++ b/src/runtime/interpreter_internal.hpp
@@ -1618,6 +1618,15 @@ inline auto double_to_sortable_u64(double value) -> std::uint64_t {
const std::vector& aggregations,
const ExecutionContext* exec = nullptr)
-> std::expected;
+/// The one row an aggregate with no `by` yields over an empty input (SQL's
+/// rule: the whole input is one group, even when it is empty). `count` and
+/// `count_distinct` are 0; every other aggregate is null. `empty_columns` is
+/// the aggregate's zero-row result, which supplies each column's type. The
+/// kernels only ever produce that zero-row result; the node-level executors
+/// (interpreter.cpp, runtime_entry.cpp) replace it with this row.
+[[nodiscard]] auto global_aggregate_of_empty(const std::vector& empty_columns,
+ const std::vector& aggregations)
+ -> std::vector;
[[nodiscard]] auto parse_aggregate_func(std::string_view name) -> std::optional;
[[nodiscard]] auto aggregate_call_to_spec(const ir::CallExpr& call, std::string alias)
-> std::expected, std::string>;
diff --git a/src/runtime/ops.cpp b/src/runtime/ops.cpp
index e81f0a69..20c655b1 100644
--- a/src/runtime/ops.cpp
+++ b/src/runtime/ops.cpp
@@ -671,12 +671,13 @@ auto make_field(std::string alias, ir::Expr expr) -> ir::FieldSpec {
return ir::FieldSpec{.alias = std::move(alias), .expr = std::move(expr)};
}
-auto make_agg(ir::AggFunc func, std::string col_name, std::string alias, double param)
- -> ir::AggSpec {
+auto make_agg(ir::AggFunc func, std::string col_name, std::string alias, double param,
+ bool is_count) -> ir::AggSpec {
return ir::AggSpec{.func = func,
.column = ir::ColumnRef{.name = std::move(col_name)},
.alias = std::move(alias),
- .param = param};
+ .param = param,
+ .is_count = is_count};
}
// ─── Stream helpers ───────────────────────────────────────────────────────────
diff --git a/src/runtime/runtime_entry.cpp b/src/runtime/runtime_entry.cpp
index 704e79aa..1741181c 100644
--- a/src/runtime/runtime_entry.cpp
+++ b/src/runtime/runtime_entry.cpp
@@ -654,6 +654,47 @@ auto build_physical_join(const physical::Plan& plan, const ir::Node& node,
return std::unexpected("physical join: plan named no streaming branch");
}
+/// An aggregate with no `by` yields one row even over an empty input (see
+/// `global_aggregate_of_empty`). The streaming kernels end such a stream with
+/// only zero-row schema chunks; this appends the row, typed from that schema.
+class OneGroupIfUngroupedOperator final : public Operator {
+ public:
+ OneGroupIfUngroupedOperator(OperatorPtr child, const std::vector* aggregations)
+ : child_(std::move(child)), aggregations_(aggregations) {}
+
+ auto next() -> std::expected, std::string> override {
+ if (done_) {
+ return std::optional{};
+ }
+ auto chunk = child_->next();
+ if (!chunk.has_value()) {
+ return chunk;
+ }
+ if (!chunk->has_value()) {
+ done_ = true;
+ if (saw_rows_ || schema_.empty()) {
+ return chunk;
+ }
+ Chunk row;
+ row.columns = global_aggregate_of_empty(schema_, *aggregations_);
+ return std::optional{std::move(row)};
+ }
+ if ((*chunk)->rows() > 0) {
+ saw_rows_ = true;
+ } else if (!saw_rows_ && schema_.empty()) {
+ schema_ = (*chunk)->columns;
+ }
+ return chunk;
+ }
+
+ private:
+ OperatorPtr child_;
+ const std::vector* aggregations_;
+ std::vector schema_;
+ bool saw_rows_ = false;
+ bool done_ = false;
+};
+
/// Build an aggregate the plan migrated: the streaming operator, or the
/// Join+Aggregate fusion. Phase 4 item 2.
///
@@ -810,7 +851,12 @@ auto build_operator_from_physical_plan(const physical::Plan& plan, const ir::Nod
}
if (plan.aggregate.describes) {
physical::note_map_pipeline_executed();
- return build_physical_aggregate(plan, node, registry, scalars, externs, exec, model_out);
+ auto op = build_physical_aggregate(plan, node, registry, scalars, externs, exec, model_out);
+ const auto& agg = ir::node_cast(node);
+ if (!op.has_value() || !agg.group_by().empty()) {
+ return op;
+ }
+ return std::make_unique(std::move(*op), &agg.aggregations());
}
if (plan.join.describes) {
physical::note_map_pipeline_executed();
diff --git a/tests/parity/cases/empty_global_aggregate.ibex b/tests/parity/cases/empty_global_aggregate.ibex
new file mode 100644
index 00000000..6111bd87
--- /dev/null
+++ b/tests/parity/cases/empty_global_aggregate.ibex
@@ -0,0 +1,5 @@
+// An aggregate with no `by` over an empty input is still one group (SPEC 7.1):
+// one row, counts 0 -- including count(col), which is lowered as a Sum over a
+// not-null flag and carries `is_count` through codegen -- and everything else null.
+let t = Table { g = ["a", "b", "c"], v = [1.0, 2.0, 3.0] };
+t[filter v > 100.0, select { n = count(), nv = count(v), nd = count_distinct(g), s = sum(v), m = mean(v) }];
diff --git a/tests/test_interpreter.cpp b/tests/test_interpreter.cpp
index 753bc11a..00340bb4 100644
--- a/tests/test_interpreter.cpp
+++ b/tests/test_interpreter.cpp
@@ -14287,11 +14287,16 @@ TEST_CASE("Interpret empty results keep their schema") {
}
SECTION("an ungrouped aggregate over an empty input") {
- auto ir = require_ir("t[filter v > 100.0][select { m = mean(v) }];");
+ // SQL's rule: with no `by` the whole input is one group, even when it
+ // is empty -- one row, a count of 0 and a null for everything else.
+ auto ir = require_ir("t[filter v > 100.0][select { m = mean(v), n = count() }];");
auto result = runtime::interpret(*ir, registry);
REQUIRE(result.has_value());
- REQUIRE(result->rows() == 0);
- REQUIRE(column_names(*result) == std::vector{"m"});
+ REQUIRE(result->rows() == 1);
+ REQUIRE(column_names(*result) == std::vector{"m", "n"});
+ REQUIRE(runtime::is_null(*result->find_entry("m"), 0));
+ REQUIRE_FALSE(runtime::is_null(*result->find_entry("n"), 0));
+ REQUIRE(std::get>(*result->find("n"))[0] == 0);
}
SECTION("a grouped aggregate over an empty input") {
@@ -15918,6 +15923,8 @@ TEST_CASE("inner join beneath a declined aggregate runs the collapsed whole-tabl
auto empty_ir = require_ir("(lhs join none on k)[select { m = median(v) }];");
auto empty = runtime::interpret(*empty_ir, registry);
REQUIRE(empty.has_value());
- REQUIRE(empty->rows() == 0);
- CHECK(empty->find("m") != nullptr);
+ // No `by`: the empty join is still one group, so one row with a null median.
+ REQUIRE(empty->rows() == 1);
+ REQUIRE(empty->find("m") != nullptr);
+ REQUIRE(runtime::is_null(*empty->find_entry("m"), 0));
}
From d9d3b0ac7577bd0340f5958d3dcf3d0d691d354d Mon Sep 17 00:00:00 2001
From: Bob Jansen
Date: Sat, 19 Sep 2026 22:23:39 +0200
Subject: [PATCH 07/42] Fix the COUNT bug in scalar() subqueries
A correlated subquery is decorrelated into a grouped aggregate left-joined
onto the outer rows, so an outer key with no inner rows got a null. That
is right for every aggregate except a count, whose value over no rows is
0: `0 == scalar(... count() ...)` dropped rows SQL keeps. A bare
count(), count(col) or count_distinct(col) is now compared through
coalesce(value, 0). A count inside a larger expression (`count() + 1`)
is rejected, since its value over no rows would have to be evaluated
rather than assumed.
The uncorrelated form needed no change of its own: its cross join now
always has the one row an empty aggregate without `by` yields.
SPEC 5.7 no longer claims the old behaviour was SQL's and documents the
rule. Regression tests cover correlated count(), count(col) and
count_distinct, uncorrelated count() and count(col), and the rejection.
Co-Authored-By: Claude Opus 5
---
SPEC.md | 48 +++++++++++--------
src/parser/lower.cpp | 40 ++++++++++++----
.../scalar_subquery_count_unmatched.ibex | 6 +++
tests/test_interpreter.cpp | 48 +++++++++++++++++++
tests/test_lower.cpp | 8 ++++
5 files changed, 123 insertions(+), 27 deletions(-)
create mode 100644 tests/parity/cases/scalar_subquery_count_unmatched.ibex
diff --git a/SPEC.md b/SPEC.md
index ffeb7968..3701eb37 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -2449,10 +2449,22 @@ single row. That is also why a source used by both the outer query and the
subquery should be bound once (`let partsupp = read_parquet(...)`) and named
twice — one binding is one read.
-**Nulls.** When no inner row matches an outer row's captured key, the subquery's
-value is null, and a comparison against null is never true: the row is dropped.
-An uncorrelated subquery whose input is empty likewise keeps no rows. This is
-SQL's scalar-subquery behaviour.
+**Empty inputs.** A subquery over no rows has the value its aggregate has over
+no rows (Section 7.1): `0` for `count()`, `count(col)` and `count_distinct(col)`,
+null for every other aggregate. That holds both for an uncorrelated subquery
+whose input is empty and for an outer row whose captured key matches no inner
+row. A comparison against null is never true, so such a row is dropped. A
+comparison against a count of `0` is an ordinary comparison:
+
+```ibex
+// Parts nobody supplies: kept, because their count is 0, not null.
+part[filter 0 == scalar(supply[filter ps_partkey == outer(p_partkey), select { n = count() }])]
+```
+
+This is SQL's scalar-subquery behaviour. A count must be the whole selected
+column: `select { n = count() + 1 }` is rejected, because a key with no inner
+rows reaches the decorrelated plan as a missing group, and only a bare count's
+value over no rows is known there.
**Result schema.** The subquery's value is not a column of the result. A filter
yields the rows it kept, never a wider table. Naming the columns that stay is
@@ -2475,11 +2487,9 @@ refers to the query enclosing it, and subqueries do not nest.
`scalar` keeps its two-argument form — `scalar(table, column)` extracts a value
from a one-row table (Section 12). Arity tells the two apart.
-An uncorrelated subquery over an empty input, or one whose aggregate is null (an
-empty or all-null group), yields a **null scalar** (Section 6.7); in
-`filter … == scalar(…)` the comparison is then null and the row is dropped
-(Section 3), matching SQL. For a correlated subquery, a captured key with no
-matching inner rows yields null for that key.
+A subquery whose aggregate is null (no rows, or only null values, for anything
+but a count) yields a **null scalar** (Section 6.7); in `filter … == scalar(…)`
+the comparison is then null and the row is dropped (Section 3), matching SQL.
---
@@ -2659,16 +2669,6 @@ The following built-in functions are **aggregate functions**. They consume a
`Numeric` denotes `Int32 | Int64 | Float32 | Float64`.
-All other functions (user-defined externs, built-in scalars) are **scalar
-functions**.
-
-### 7.2 Grouped Select Well-formedness
-
-When `by` and `select` are both present, the block performs aggregation. Each
-field in the `select` clause must satisfy the **aggregation well-formedness
-rule**:
-
-> Every column reference in the field expression must either:
**Empty input.** Without `by`, the whole input is one group, even when it has
no rows: the result is always exactly one row. Over no rows `count()`,
`count(col)` and `count_distinct(col)` are `0` and every other aggregate is
@@ -2681,6 +2681,16 @@ t[filter false, select { n = count(), m = mean(v) }] // 1 row: n = 0, m = null
t[filter false, select { n = count() }, by g] // 0 rows
```
+All other functions (user-defined externs, built-in scalars) are **scalar
+functions**.
+
+### 7.2 Grouped Select Well-formedness
+
+When `by` and `select` are both present, the block performs aggregation. Each
+field in the `select` clause must satisfy the **aggregation well-formedness
+rule**:
+
+> Every column reference in the field expression must either:
>
> **(a)** name a grouping key column (listed in the `by` clause), or
>
diff --git a/src/parser/lower.cpp b/src/parser/lower.cpp
index 8c2344db..211df3ea 100644
--- a/src/parser/lower.cpp
+++ b/src/parser/lower.cpp
@@ -3246,6 +3246,8 @@ class Lowerer {
struct ScalarSubqueryPlan {
ir::NodePtr plan;
std::vector keys;
+ /// The selected column is a count, whose value over no rows is 0.
+ bool counts = false;
};
/// Lower a `filter` predicate over `input`.
@@ -3382,11 +3384,11 @@ class Lowerer {
}
// A correlated subquery joins on its captured keys, so an outer row with
- // no matching group gets a null. An uncorrelated one is a single value
- // broadcast to every row, which is a cross join against its one row —
- // and if the subquery's input was empty it produces no row at all, so
- // the cross join drops every outer row, exactly as comparing against
- // SQL's null scalar would.
+ // no matching group gets a null -- right for every aggregate except a
+ // count, which is patched to 0 below. An uncorrelated one is a single
+ // value broadcast to every row: a cross join against its one row. An
+ // aggregate with no `by` yields that row even over an empty input (a
+ // count of 0, a null otherwise), so the cross join never drops rows.
const bool correlated = !subplan->keys.empty();
std::vector join_keys;
join_keys.reserve(subplan->keys.size());
@@ -3404,6 +3406,13 @@ class Lowerer {
return std::unexpected(lowered_value.error());
}
auto scalar_ref = ir::make_expr_ptr(ir::Expr{.node = ir::ColumnRef{.name = alias}});
+ if (correlated && subplan->counts) {
+ ir::CallExpr coalesce{.callee = "coalesce", .args = {}, .named_args = {}};
+ coalesce.args.push_back(std::move(scalar_ref));
+ coalesce.args.push_back(
+ ir::make_expr_ptr(ir::Expr{.node = ir::Literal{.value = std::int64_t{0}}}));
+ scalar_ref = ir::make_expr_ptr(ir::Expr{.node = std::move(coalesce)});
+ }
auto value_ref = ir::make_expr_ptr(std::move(lowered_value.value()));
if (on_left) {
return ir::Expr{.node = ir::CompareExpr{
@@ -3475,6 +3484,20 @@ class Lowerer {
return std::unexpected(LowerError{
.message = "outer(): a capture may appear only in the subquery's filter clause"});
}
+ // Over no rows a count is 0 and every other aggregate is null. A key
+ // with no inner rows reaches the caller's left join as a missing group,
+ // i.e. null, so a count must be patched back to 0 there. Only a bare
+ // count is: inside a larger expression (`count() + 1`) the value over
+ // no rows would have to be evaluated, not assumed.
+ const Expr& selected = unwrap_group(*aggregate.expr);
+ const bool counts =
+ as_call(selected, "count") != nullptr || as_call(selected, "count_distinct") != nullptr;
+ if (!counts &&
+ (contains_call(selected, "count") || contains_call(selected, "count_distinct"))) {
+ return std::unexpected(
+ LowerError{.message = "scalar(): a count must be the whole selected column, e.g. "
+ "`select { n = count() }`, not part of a larger expression"});
+ }
// The filter is optional: an uncorrelated subquery has nothing to capture,
// and may have nothing to filter either.
@@ -3564,7 +3587,8 @@ class Lowerer {
rename->add_child(std::move(plan));
plan = std::move(rename);
}
- return ScalarSubqueryPlan{.plan = std::move(plan), .keys = std::move(keys)};
+ return ScalarSubqueryPlan{
+ .plan = std::move(plan), .keys = std::move(keys), .counts = counts};
}
/// Read `inner_column == outer(outer_column)` (either way round).
@@ -4236,6 +4260,7 @@ class Lowerer {
.func = ir::AggFunc::Sum,
.column = ir::ColumnRef{.name = flag},
.alias = alias,
+ .is_count = true,
});
temp_columns[alias] = true;
return ir::Expr{.node = ir::ColumnRef{.name = alias}};
@@ -4260,7 +4285,6 @@ class Lowerer {
.message =
"second argument of ewma() must be a numeric literal (alpha)"});
}
- .is_count = true,
} else {
return std::unexpected(LowerError{
.message =
@@ -4396,6 +4420,7 @@ class Lowerer {
.func = ir::AggFunc::Sum,
.column = ir::ColumnRef{.name = flag},
.alias = field.name,
+ .is_count = true,
});
final_columns.push_back(field.name);
continue;
@@ -4420,7 +4445,6 @@ class Lowerer {
.message = "second argument of ewma() must be a numeric "
"literal (alpha)"});
}
- .is_count = true,
} else {
return std::unexpected(
LowerError{.message = "second argument of ewma() must be a "
diff --git a/tests/parity/cases/scalar_subquery_count_unmatched.ibex b/tests/parity/cases/scalar_subquery_count_unmatched.ibex
new file mode 100644
index 00000000..76e65456
--- /dev/null
+++ b/tests/parity/cases/scalar_subquery_count_unmatched.ibex
@@ -0,0 +1,6 @@
+// A correlated scalar() count is 0, not null, for an outer key with no inner
+// rows (SPEC 5.7, the COUNT bug): part 4 has no supply rows and must be kept.
+let parts = Table { p_partkey = [1, 2, 3, 4], p_name = ["nut", "bolt", "screw", "washer"] };
+let supply = Table { ps_partkey = [1, 1, 2, 3], ps_suppkey = [10, 11, 10, 12] };
+parts[filter 0 == scalar(supply[filter ps_partkey == outer(p_partkey), select { n = count(ps_suppkey) }]),
+ select { p_name }];
diff --git a/tests/test_interpreter.cpp b/tests/test_interpreter.cpp
index 00340bb4..cb7a4b2a 100644
--- a/tests/test_interpreter.cpp
+++ b/tests/test_interpreter.cpp
@@ -14335,6 +14335,54 @@ parts[filter p_partkey > scalar(
}
}
+TEST_CASE("Interpret subquery counts a key with no inner rows as 0, not null") {
+ // The COUNT bug: decorrelating into grouped-aggregate + left join leaves an
+ // outer row with no inner rows a null, but a count of nothing is 0. Only
+ // part 4 (washer) has no EU supply rows, so it is the only row whose count
+ // is 0. Every part has two EU suppliers otherwise.
+ const auto names = [](const std::string& query) {
+ auto result = interpret_source(std::string(kSupplySources) + query);
+ const auto* column = std::get_if>(result.find("p_name"));
+ REQUIRE(column != nullptr);
+ std::vector out;
+ out.reserve(column->size());
+ for (std::size_t i = 0; i < column->size(); ++i) {
+ out.emplace_back((*column)[i]);
+ }
+ return out;
+ };
+ const auto correlated = [](const std::string& comparison, const std::string& count) {
+ return "parts[filter " + comparison +
+ " scalar(supply[filter ps_partkey == outer(p_partkey) && ps_region == \"EU\", "
+ "select { n = " +
+ count + " }]), select { p_name }];";
+ };
+ const std::vector washer{"washer"};
+ const std::vector all{"nut", "bolt", "screw", "washer"};
+
+ SECTION("correlated count()") {
+ REQUIRE(names(correlated("0 ==", "count()")) == washer);
+ REQUIRE(names(correlated("2 ==", "count()")) ==
+ std::vector{"nut", "bolt", "screw"});
+ }
+ SECTION("correlated count(col)") {
+ REQUIRE(names(correlated("0 ==", "count(ps_suppkey)")) == washer);
+ }
+ SECTION("correlated count_distinct(col)") {
+ REQUIRE(names(correlated("0 ==", "count_distinct(ps_suppkey)")) == washer);
+ }
+ SECTION("uncorrelated count() over an empty input is 0") {
+ REQUIRE(names(R"(parts[filter 0 == scalar(supply[filter ps_region == "MARS",
+ select { n = count() }]),
+ select { p_name }];)") == all);
+ }
+ SECTION("uncorrelated count(col) over an empty input is 0") {
+ REQUIRE(names(R"(parts[filter 0 == scalar(supply[filter ps_region == "MARS",
+ select { n = count(ps_cost) }]),
+ select { p_name }];)") == all);
+ }
+}
+
// ── substring(s, start[, length]) ────────────────────────────────────────────
// A Unicode-codepoint slice with Polars str.slice semantics: 0-based start that
// may be negative (from the end), optional length (to the end when omitted).
diff --git a/tests/test_lower.cpp b/tests/test_lower.cpp
index 7ce276f1..ff4d5f44 100644
--- a/tests/test_lower.cpp
+++ b/tests/test_lower.cpp
@@ -1381,6 +1381,14 @@ TEST_CASE("Lower rejects unsupported correlated-subquery shapes") {
REQUIRE_FALSE(result.has_value());
REQUIRE(result.error().message.find("one whole side") != std::string::npos);
}
+ SECTION("a count must be the whole selected column") {
+ // A key with no inner rows is patched from null to 0 only for a bare
+ // count; inside a larger expression the empty value would be guessed.
+ auto result = lower_filter("p_partkey == " +
+ subquery("ps_partkey == outer(p_partkey)", "n = count() + 1"));
+ REQUIRE_FALSE(result.has_value());
+ REQUIRE(result.error().message.find("whole selected column") != std::string::npos);
+ }
SECTION("a subquery is not allowed outside a filter") {
auto result =
lower_source(std::string(kCorrelatedSources) + "parts[update { x = " +
From 0eb20b09c001c8b5686fe6a3fccb4e2274ac4ebb Mon Sep 17 00:00:00 2001
From: Bob Jansen
Date: Sat, 19 Sep 2026 22:56:11 +0200
Subject: [PATCH 08/42] Check map { } placement in lowering only
The parser rejected `map { }` anywhere but last in a block, and lowering
then required it to be the only clause (SPEC C25). A block like
`t[filter x > 0, map { .. }]` passed the first rule and failed the
second. Clause combinations are lowering's concern, alongside
select/update exclusivity, so the parser check is gone and the one
remaining error states the actual rule.
Co-Authored-By: Claude Opus 5
---
src/parser/parser.cpp | 11 ++---------
tests/test_fs.cpp | 12 ++++++++----
tests/test_parser.cpp | 7 ++++---
3 files changed, 14 insertions(+), 16 deletions(-)
diff --git a/src/parser/parser.cpp b/src/parser/parser.cpp
index 6aaacb09..4bdea1be 100644
--- a/src/parser/parser.cpp
+++ b/src/parser/parser.cpp
@@ -47,9 +47,8 @@
// (filter, select, update, by, order, window, ...) in source order. So
// `t[filter x > 0, select {x}]` parses as BlockExpr{ base = t,
// clauses = [FilterClause, SelectClause] }. Chained brackets nest:
-// `t[..][..]` is a BlockExpr whose base is another BlockExpr. The parser
-// only requires `map { }` to be the last clause of a block. Lowering then
-// requires it to be the only one.
+// `t[..][..]` is a BlockExpr whose base is another BlockExpr. Which clauses
+// may share a block (e.g. `map { }` must be alone) is checked in lowering.
//
// The AST is purely syntactic. Names are not resolved and nothing is type- or
// schema-checked here. Some decisions are made later in lower.cpp, e.g.
@@ -1674,12 +1673,6 @@ class Parser {
clauses.push_back(std::move(*clause));
} while (match(TokenKind::Comma) && !check(TokenKind::RBracket));
}
- for (std::size_t i = 0; i + 1 < clauses.size(); ++i) {
- if (std::holds_alternative(clauses[i])) {
- error_ = make_error(previous(), "map { } must be the last clause of a block");
- return std::nullopt;
- }
- }
return clauses;
}
diff --git a/tests/test_fs.cpp b/tests/test_fs.cpp
index b277cf6f..c9af962d 100644
--- a/tests/test_fs.cpp
+++ b/tests/test_fs.cpp
@@ -173,13 +173,17 @@ TEST_CASE("map clause: row-wise scalar evaluation", "[fs][map]") {
}
}
-TEST_CASE("map clause: must be the last clause", "[fs][map]") {
+TEST_CASE("map clause: must be the only clause", "[fs][map]") {
auto registry = make_registry();
ibex::repl::ReplSession session(ibex::repl::ReplConfig{}, registry);
REQUIRE(session.execute("let t = Table { x = [1, 2] };").ok);
- const auto r = session.execute("t[map { y = x }, filter y > 0];");
- CHECK_FALSE(r.ok);
- CHECK(r.error.find("last clause") != std::string::npos);
+ // Neither before another clause nor after one: SPEC C25.
+ for (const char* query :
+ {"t[map { y = x }, filter y > 0];", "t[filter x > 0, map { y = x }];"}) {
+ const auto r = session.execute(query);
+ CHECK_FALSE(r.ok);
+ CHECK(r.error.find("only clause") != std::string::npos);
+ }
}
TEST_CASE("map clause: effectful externs run per row (csv round-trip)", "[fs][map]") {
diff --git a/tests/test_parser.cpp b/tests/test_parser.cpp
index 404ba255..a85d615d 100644
--- a/tests/test_parser.cpp
+++ b/tests/test_parser.cpp
@@ -1766,10 +1766,11 @@ files[map { source = path, out = `${dir}/${stem}.parquet` }];
CHECK(map_clause->fields[1].name == "out");
}
-TEST_CASE("Parse rejects map { } that is not the last clause") {
+TEST_CASE("Parse leaves map { } placement to lowering") {
+ // `map { }` must be the only clause of its block, which is a rule about
+ // clause combinations like select/update exclusivity, so lowering checks it.
auto result = parse("t[map { y = x }, filter y > 0];");
- REQUIRE_FALSE(result.has_value());
- CHECK(result.error().message.find("last clause") != std::string::npos);
+ REQUIRE(result.has_value());
}
TEST_CASE("Parse rejects a bare field in map { }") {
From c7e194434648f2856b49fe24f4e086ca10f1fc1a Mon Sep 17 00:00:00 2001
From: Bob Jansen
Date: Sat, 19 Sep 2026 22:56:11 +0200
Subject: [PATCH 09/42] Support min/max over strings; fix first/last over nulls
min and max now accept String and Categorical columns, grouped and
ungrouped, in both the materialized and the streaming aggregate. Strings
compare byte-wise (UTF-8 code point order, as Polars orders them); a
Categorical compares by its text, never its codes. The sorted operator
already hands non-numeric input to the hash operator, and non-numeric
kinds already stay off the partial-merge paths, so both run serially
like string first/last.
Three existing bugs surfaced while testing nulls:
- string first/last on a group with only nulls hit an internal
invariant (append_scalar on an empty slot) in both implementations;
such a cell is now written as a placeholder under its null bit;
- the materialized first/last took the first/last row even when null,
where SPEC 3.5 says first/last non-null value;
- the materialized first/last were always reported valid, so
`update { f = first(x) }, by g` broadcast 0 to an all-null group.
The type-gate error no longer names HashAggregateState.
Co-Authored-By: Claude Opus 5
---
SPEC.md | 4 +-
src/runtime/aggregate.cpp | 53 ++++++++++--
src/runtime/aggregate_chunked.cpp | 60 ++++++++-----
src/runtime/interpreter_internal.hpp | 28 ++++++
tests/test_interpreter.cpp | 123 +++++++++++++++++++++++++++
5 files changed, 241 insertions(+), 27 deletions(-)
diff --git a/SPEC.md b/SPEC.md
index 3701eb37..b341ae7f 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -2656,8 +2656,8 @@ The following built-in functions are **aggregate functions**. They consume a
|-----------------------|--------------------|------------|-------|
| `sum(col)` | `Series` | Same numeric type | Returns null for an all-null group. |
| `mean(col)` | `Series` | `Float64` | |
-| `min(col)` | `Series` | `T` | |
-| `max(col)` | `Series` | `T` | |
+| `min(col)` | `Series` | `T` | `T` is numeric or `String` (including categorical). Strings compare byte-wise, i.e. by UTF-8 code point, so `"Z" < "a"`. |
+| `max(col)` | `Series` | `T` | As `min`. |
| `count()` | (none) | `Int64` | Counts rows in the group, nulls included. |
| `count(col)` | `Series` | `Int64` | Counts the **non-null** values of `col`; `0` (not null) for a group with none. The argument must be a column name. |
| `count_distinct(col)` | `Series` | `Int64` | Counts the number of **distinct non-null** values of `col` in the group; `0` (not null) for a group with none. `T` may be any scalar type. |
diff --git a/src/runtime/aggregate.cpp b/src/runtime/aggregate.cpp
index 433ff4b7..f213cc29 100644
--- a/src/runtime/aggregate.cpp
+++ b/src/runtime/aggregate.cpp
@@ -269,7 +269,6 @@ auto aggregate_table(const Table& input, const std::vector& group
if (item.kind == ExprType::String &&
(agg.func == ir::AggFunc::Sum || agg.func == ir::AggFunc::Mean ||
- agg.func == ir::AggFunc::Min || agg.func == ir::AggFunc::Max ||
agg.func == ir::AggFunc::Median || agg.func == ir::AggFunc::Stddev ||
agg.func == ir::AggFunc::Ewma || agg.func == ir::AggFunc::Quantile ||
agg.func == ir::AggFunc::Skew || agg.func == ir::AggFunc::Kurtosis)) {
@@ -322,8 +321,11 @@ auto aggregate_table(const Table& input, const std::vector& group
slot.count += 1;
continue;
}
+ // Every aggregate that reads a value skips null rows: first/last
+ // are the first/last NON-null value (SPEC 3.5), not the first row.
if ((agg.func == ir::AggFunc::Sum || agg.func == ir::AggFunc::Mean ||
agg.func == ir::AggFunc::Min || agg.func == ir::AggFunc::Max ||
+ agg.func == ir::AggFunc::First || agg.func == ir::AggFunc::Last ||
agg.func == ir::AggFunc::Median || agg.func == ir::AggFunc::Stddev ||
agg.func == ir::AggFunc::Ewma || agg.func == ir::AggFunc::Quantile ||
agg.func == ir::AggFunc::Skew || agg.func == ir::AggFunc::Kurtosis) &&
@@ -409,6 +411,17 @@ auto aggregate_table(const Table& input, const std::vector& group
}
continue;
}
+ if ((agg.func == ir::AggFunc::Min || agg.func == ir::AggFunc::Max) &&
+ slot.kind == ExprType::String) {
+ const std::string_view value = text_cell(column, row);
+ if (!slot.has_value ||
+ text_extreme_replaces(agg.func, value,
+ std::get(slot.text_value))) {
+ slot.text_value = std::string(value);
+ }
+ slot.has_value = true;
+ continue;
+ }
if (slot.kind == ExprType::Int) {
std::int64_t value = 0;
@@ -644,6 +657,9 @@ auto aggregate_table(const Table& input, const std::vector& group
}
if (std::holds_alternative>(*input_col)) {
column = Column{};
+ } else if (agg.func != ir::AggFunc::Sum &&
+ expr_type_for_column(*input_col) == ExprType::String) {
+ column = make_empty_like(*input_col);
} else {
column = Column{};
}
@@ -695,6 +711,8 @@ auto aggregate_table(const Table& input, const std::vector& group
case ir::AggFunc::Max:
if (slot.kind == ExprType::Double) {
append_scalar(*column, slot.double_value);
+ } else if (slot.kind == ExprType::String) {
+ append_text_cell(*column, slot.text_value);
} else {
append_scalar(*column, slot.int_value);
}
@@ -706,7 +724,7 @@ auto aggregate_table(const Table& input, const std::vector& group
} else if (slot.kind == ExprType::Double) {
append_scalar(*column, slot.double_value);
} else {
- append_scalar(*column, slot.text_value);
+ append_text_cell(*column, slot.text_value);
}
break;
// Median/Quantile/Skew/Kurtosis are reduced in the contiguous
@@ -742,6 +760,8 @@ auto aggregate_table(const Table& input, const std::vector& group
case ir::AggFunc::Sum:
case ir::AggFunc::Min:
case ir::AggFunc::Max:
+ case ir::AggFunc::First:
+ case ir::AggFunc::Last:
return slot.has_value;
// Median/Quantile/Skew/Kurtosis are reduced in the contiguous collect
// pass, which records the group's value count in slot.count.
@@ -758,8 +778,6 @@ auto aggregate_table(const Table& input, const std::vector& group
return slot.count >= 4;
case ir::AggFunc::Count:
case ir::AggFunc::CountDistinct:
- case ir::AggFunc::First:
- case ir::AggFunc::Last:
return true;
}
return true;
@@ -893,6 +911,30 @@ auto aggregate_table(const Table& input, const std::vector& group
continue;
}
+ if ((item.func == ir::AggFunc::Min || item.func == ir::AggFunc::Max) &&
+ item.kind == ExprType::String) {
+ // Hold a view per group and copy only the winner: the column
+ // outlives this pass, and most rows lose the comparison.
+ const ColumnValue& column = *agg_columns[agg_i];
+ std::vector acc(n_groups);
+ std::vector found(n_groups, 0U);
+ for (std::size_t row = 0; row < rows; ++row) {
+ const std::uint32_t g = gids[row];
+ const std::string_view value = text_cell(column, row);
+ if (found[g] == 0U || text_extreme_replaces(item.func, value, acc[g])) {
+ acc[g] = value;
+ found[g] = 1U;
+ }
+ }
+ for (std::uint32_t g = 0; g < n_groups; ++g) {
+ if (found[g] != 0U) {
+ slot_for(g).text_value = std::string(acc[g]);
+ slot_for(g).has_value = true;
+ }
+ }
+ continue;
+ }
+
if (item.dbl_col != nullptr) {
const double* data = item.dbl_col->data();
switch (item.func) {
@@ -1313,7 +1355,8 @@ auto aggregate_table(const Table& input, const std::vector& group
for (std::size_t i = 0; i < aggregations.size(); ++i) {
const auto func = aggregations[i].func;
if (func == ir::AggFunc::Sum || func == ir::AggFunc::Mean || func == ir::AggFunc::Min ||
- func == ir::AggFunc::Max || func == ir::AggFunc::Median ||
+ func == ir::AggFunc::Max || func == ir::AggFunc::First ||
+ func == ir::AggFunc::Last || func == ir::AggFunc::Median ||
func == ir::AggFunc::Stddev || func == ir::AggFunc::Ewma ||
func == ir::AggFunc::Quantile || func == ir::AggFunc::Skew ||
func == ir::AggFunc::Kurtosis) {
diff --git a/src/runtime/aggregate_chunked.cpp b/src/runtime/aggregate_chunked.cpp
index 61bb2d20..f836d258 100644
--- a/src/runtime/aggregate_chunked.cpp
+++ b/src/runtime/aggregate_chunked.cpp
@@ -572,18 +572,20 @@ class HashAggregateState final {
}
const ColumnEntry* entry = &chunk.columns[*input_idx];
const ExprType kind = expr_type_for_column(*entry->column);
- const bool first_or_last =
- agg.func == ir::AggFunc::First || agg.func == ir::AggFunc::Last;
- // First/Last also accept String (which covers Column and
- // Column — expr_type_for_column collapses both to
- // String); CountDistinct accepts every scalar kind (fixed-width
- // values are bit-cast, text is kept verbatim); every other function
- // stays numeric-only.
+ const bool keeps_a_value = agg.func == ir::AggFunc::First ||
+ agg.func == ir::AggFunc::Last ||
+ agg.func == ir::AggFunc::Min || agg.func == ir::AggFunc::Max;
+ // First/Last/Min/Max also accept String (which covers
+ // Column and Column — expr_type_for_column
+ // collapses both to String); CountDistinct accepts every scalar kind
+ // (fixed-width values are bit-cast, text is kept verbatim); every
+ // other function stays numeric-only.
const bool supported = kind == ExprType::Int || kind == ExprType::Double ||
agg.func == ir::AggFunc::CountDistinct ||
- (first_or_last && kind == ExprType::String);
+ (keeps_a_value && kind == ExprType::String);
if (!supported) {
- return "HashAggregateState: non-numeric aggregation not supported";
+ return "aggregate of column '" + agg.column.name +
+ "': this function does not support the column's type";
}
agg_entries[i] = entry;
}
@@ -4216,7 +4218,22 @@ class HashAggregateState final {
}
return std::string(std::get>(*entry.column)[row]);
};
- if (plan_[agg_i].func == ir::AggFunc::First) {
+ const ir::AggFunc text_func = plan_[agg_i].func;
+ if (text_func == ir::AggFunc::Min || text_func == ir::AggFunc::Max) {
+ for (std::size_t row = begin; row < rows; ++row) {
+ if (has_nulls && !(*validity)[row])
+ continue;
+ auto& slot = slot_for(gids[row]);
+ ScalarValue& held =
+ text_at((static_cast(gids[row]) * n_aggs_) + agg_i);
+ const std::string_view value = text_cell(*entry.column, row);
+ if (!slot.present() ||
+ text_extreme_replaces(text_func, value, std::get(held))) {
+ held = std::string(value);
+ slot.mark_present();
+ }
+ }
+ } else if (text_func == ir::AggFunc::First) {
for (std::size_t row = begin; row < rows; ++row) {
if (has_nulls && !(*validity)[row])
continue;
@@ -4557,14 +4574,17 @@ class HashAggregateState final {
}
return std::string(std::get>(*entry.column)[row]);
};
- if (func == ir::AggFunc::First) {
+ if (func == ir::AggFunc::Min || func == ir::AggFunc::Max) {
each([&](std::size_t r) {
- if (!slot.present()) {
- text_at(agg_i) = value_at(r);
+ ScalarValue& held = text_at(agg_i);
+ const std::string_view value = text_cell(*entry.column, r);
+ if (!slot.present() ||
+ text_extreme_replaces(func, value, std::get(held))) {
+ held = std::string(value);
slot.mark_present();
}
});
- } else {
+ } else if (func == ir::AggFunc::First) {
each([&](std::size_t r) {
text_at(agg_i) = value_at(r);
slot.mark_present();
@@ -4726,9 +4746,9 @@ class HashAggregateState final {
case ir::AggFunc::Kurtosis:
return EmitSlot::F64;
case ir::AggFunc::Sum:
+ return kind == ExprType::Double ? EmitSlot::F64 : EmitSlot::I64;
case ir::AggFunc::Min:
case ir::AggFunc::Max:
- return kind == ExprType::Double ? EmitSlot::F64 : EmitSlot::I64;
case ir::AggFunc::First:
case ir::AggFunc::Last:
if (kind == ExprType::Double) {
@@ -4835,14 +4855,14 @@ class HashAggregateState final {
column = Column{};
break;
case ir::AggFunc::Sum:
- case ir::AggFunc::Min:
- case ir::AggFunc::Max:
if (plan_[i].kind == ExprType::Double) {
column = Column{};
} else {
column = Column{};
}
break;
+ case ir::AggFunc::Min:
+ case ir::AggFunc::Max:
case ir::AggFunc::First:
case ir::AggFunc::Last:
if (plan_[i].kind == ExprType::Double) {
@@ -5028,8 +5048,6 @@ class HashAggregateState final {
: slot.double_value / static_cast(slot.count));
break;
case ir::AggFunc::Sum:
- case ir::AggFunc::Min:
- case ir::AggFunc::Max:
if (plan_[i].kind == ExprType::Double) {
put_d(g, slot.double_value);
} else {
@@ -5047,6 +5065,8 @@ class HashAggregateState final {
put_d(g, agg_finalize_kurtosis(slot, scratch_for(g, i)[0],
scratch_for(g, i)[2]));
break;
+ case ir::AggFunc::Min:
+ case ir::AggFunc::Max:
case ir::AggFunc::First:
case ir::AggFunc::Last:
if (plan_[i].kind == ExprType::Double) {
@@ -5054,7 +5074,7 @@ class HashAggregateState final {
} else if (plan_[i].kind == ExprType::Int) {
put_i(g, slot.int_value);
} else {
- append_scalar(column, text_store_[(g * n_aggs_) + i]);
+ append_text_cell(column, text_store_[(g * n_aggs_) + i]);
}
break;
default:
diff --git a/src/runtime/interpreter_internal.hpp b/src/runtime/interpreter_internal.hpp
index 5ada7fd8..37241fd9 100644
--- a/src/runtime/interpreter_internal.hpp
+++ b/src/runtime/interpreter_internal.hpp
@@ -706,6 +706,23 @@ struct AggSlotCore {
/// `has_value` inline — so it wants the wide, self-describing slot, while the
/// chunked operator allocating by the million wants the lean one. Sharing a base
/// only forced the wide fields onto the path that cannot afford them.
+/// The text of row `row` of a String or Categorical column (ExprType::String).
+[[nodiscard]] inline auto text_cell(const ColumnValue& column, std::size_t row)
+ -> std::string_view {
+ if (const auto* cat = std::get_if>(&column)) {
+ return (*cat)[row];
+ }
+ return std::get>(column)[row];
+}
+
+/// Min/Max over text: whether `candidate` replaces the held value `current`.
+/// Byte-wise comparison, which for UTF-8 is code-point order (as Polars
+/// orders strings). A Categorical compares by its text, never by its codes.
+[[nodiscard]] inline auto text_extreme_replaces(ir::AggFunc func, std::string_view candidate,
+ std::string_view current) -> bool {
+ return func == ir::AggFunc::Min ? candidate < current : candidate > current;
+}
+
struct AggSlot {
ir::AggFunc func = ir::AggFunc::Sum;
ExprType kind = ExprType::Int;
@@ -1149,6 +1166,17 @@ inline auto append_scalar(ColumnValue& column, const ScalarValue& value) -> void
column);
}
+/// Append a text aggregate's value. A group with no non-null value holds no
+/// text (a null scalar); its cell is masked by the null bit but still has to
+/// exist, so it is written as an empty string.
+inline void append_text_cell(ColumnValue& column, const ScalarValue& value) {
+ if (std::holds_alternative(value)) {
+ append_scalar(column, ScalarValue{std::string{}});
+ return;
+ }
+ append_scalar(column, value);
+}
+
inline auto broadcast_scalar_column(const ScalarValue& value, std::size_t rows) -> ColumnValue {
return std::visit(
[rows](const auto& v) -> ColumnValue {
diff --git a/tests/test_interpreter.cpp b/tests/test_interpreter.cpp
index cb7a4b2a..5eecb78a 100644
--- a/tests/test_interpreter.cpp
+++ b/tests/test_interpreter.cpp
@@ -13154,6 +13154,129 @@ TEST_CASE("Categorical first/last streams via the hash aggregate operator") {
REQUIRE((*la)[1] == "gold");
}
+namespace {
+
+// k = 1: "pear", null, "Apple" k = 2: null, null k = 3: "fig"
+// Byte order puts "Apple" before "pear" (uppercase sorts first).
+auto text_table_with_nulls(bool categorical) -> runtime::Table {
+ runtime::Table t;
+ t.add_column("k", Column{1, 1, 1, 2, 2, 3});
+ const std::vector values{"pear", "", "Apple", "", "", "fig"};
+ runtime::ValidityBitmap valid{true, false, true, false, false, true};
+ if (categorical) {
+ Column col;
+ for (const auto& v : values) {
+ col.push_back(v);
+ }
+ t.add_column("s", std::move(col), std::move(valid));
+ } else {
+ Column col;
+ for (const auto& v : values) {
+ col.push_back(v);
+ }
+ t.add_column("s", std::move(col), std::move(valid));
+ }
+ return t;
+}
+
+// Cell `row` of a String or Categorical result column, or nullopt when null.
+auto text_or_null(const runtime::Table& table, const std::string& name, std::size_t row)
+ -> std::optional {
+ const auto* entry = table.find_entry(name);
+ REQUIRE(entry != nullptr);
+ if (runtime::is_null(*entry, row)) {
+ return std::nullopt;
+ }
+ if (const auto* cat = std::get_if>(entry->column.get())) {
+ return std::string((*cat)[row]);
+ }
+ return std::string(std::get>(*entry->column)[row]);
+}
+
+} // namespace
+
+TEST_CASE("min/max over text order by bytes and skip nulls") {
+ using Cells = std::vector>;
+ for (const bool categorical : {false, true}) {
+ CAPTURE(categorical);
+ runtime::TableRegistry registry;
+ registry.emplace("t", text_table_with_nulls(categorical));
+
+ { // select by: the streaming hash aggregate
+ auto ir = require_ir(
+ "t[select { lo = min(s), hi = max(s), f = first(s), l = last(s) }, by { k }]"
+ "[order { k }];");
+ auto result = runtime::interpret(*ir, registry);
+ REQUIRE(result.has_value());
+ REQUIRE(result->rows() == 3);
+ const auto column = [&](const std::string& name) {
+ return Cells{text_or_null(*result, name, 0), text_or_null(*result, name, 1),
+ text_or_null(*result, name, 2)};
+ };
+ // k = 2 has only nulls: every aggregate is null, not "" or a crash.
+ CHECK(column("lo") == Cells{"Apple", std::nullopt, "fig"});
+ CHECK(column("hi") == Cells{"pear", std::nullopt, "fig"});
+ CHECK(column("f") == Cells{"pear", std::nullopt, "fig"});
+ CHECK(column("l") == Cells{"Apple", std::nullopt, "fig"});
+ }
+ { // update by: the materialized aggregate
+ auto ir = require_ir("t[update { lo = min(s), hi = max(s), f = first(s) }, by { k }];");
+ auto result = runtime::interpret(*ir, registry);
+ REQUIRE(result.has_value());
+ REQUIRE(result->rows() == 6);
+ for (std::size_t row = 0; row < 6; ++row) {
+ CAPTURE(row);
+ const bool all_null_group = row == 3 || row == 4;
+ const bool k3 = row == 5;
+ CHECK(text_or_null(*result, "lo", row) ==
+ (all_null_group ? std::nullopt
+ : std::optional(k3 ? "fig" : "Apple")));
+ CHECK(text_or_null(*result, "hi", row) ==
+ (all_null_group ? std::nullopt
+ : std::optional(k3 ? "fig" : "pear")));
+ CHECK(text_or_null(*result, "f", row) ==
+ (all_null_group ? std::nullopt
+ : std::optional(k3 ? "fig" : "pear")));
+ }
+ }
+ { // no by
+ auto ir = require_ir("t[select { lo = min(s), hi = max(s) }];");
+ auto result = runtime::interpret(*ir, registry);
+ REQUIRE(result.has_value());
+ REQUIRE(result->rows() == 1);
+ CHECK(text_or_null(*result, "lo", 0) == "Apple");
+ CHECK(text_or_null(*result, "hi", 0) == "pear");
+ }
+ }
+}
+
+TEST_CASE("first/last skip nulls and are null for an all-null group") {
+ // The materialized aggregate took the first/last ROW, null or not, and
+ // always reported it valid: an all-null group broadcast 0 through update.
+ runtime::Table t;
+ t.add_column("k", Column{1, 1, 1, 2, 2});
+ t.add_column("x", Column{0.0, 2.5, 0.0, 0.0, 0.0},
+ runtime::ValidityBitmap{false, true, false, false, false});
+ runtime::TableRegistry registry;
+ registry.emplace("t", std::move(t));
+ auto ir = require_ir("t[update { f = first(x), l = last(x) }, by { k }];");
+ auto result = runtime::interpret(*ir, registry);
+ REQUIRE(result.has_value());
+ const auto* f = result->find_entry("f");
+ const auto* l = result->find_entry("l");
+ REQUIRE(f != nullptr);
+ REQUIRE(l != nullptr);
+ for (std::size_t row = 0; row < 3; ++row) {
+ CHECK_FALSE(runtime::is_null(*f, row));
+ CHECK(std::get>(*f->column)[row] == 2.5);
+ CHECK(std::get>(*l->column)[row] == 2.5);
+ }
+ for (std::size_t row = 3; row < 5; ++row) {
+ CHECK(runtime::is_null(*f, row));
+ CHECK(runtime::is_null(*l, row));
+ }
+}
+
TEST_CASE("String first/last accumulates across chunk boundaries on the hash path") {
// No ordering advertised → the hash ChunkedAggregateOperator handles it;
// group 2 spans both chunks, exercising cross-chunk carryover of
From eef34d847a4e14601faa359d9a9375ababf0d04c Mon Sep 17 00:00:00 2001
From: Bob Jansen
Date: Sun, 20 Sep 2026 14:34:55 +0200
Subject: [PATCH 10/42] Allow null as a series-literal element in Table { }
`Table { x = [1, null, 3] }` previously failed with "series literal
elements must be literals": `null` lowers to a `__null` call, and both
series-literal builders accepted only LiteralExpr. A null element now
clears that row's validity bit instead of storing a value.
`null` carries no type, so the column takes the type of its first
non-null element and a null is never a mixed-type error. An all-null
column has no type to take and falls back to Int64, matching `col = []`.
A standalone `let s = [1, null]` is still rejected, with a reason: a
Series binding is a bare column with no validity bitmap to record it.
ConstructColumn gains a `valid` vector parallel to `elements`. Each null
slot holds a placeholder of the column's own type, so the three
consumers read `elements` uniformly and consult `valid` only to build
the bitmap. That invariant is what makes reading the column type off
element 0 safe for a null-leading column; a default-constructed
placeholder would retype `[null, 1.5]` to Int64, so it is stated at the
read site and pinned by tests that put the null first.
Schema inference claimed Nullability::Never for every literal column,
reasoning that the surface language had no null literal to write. That
is a proof later passes may fold `is null` against, so it is now
conditioned on `valid`. The identical comment in nullability.cpp and the
is-null folding in canonicalize.cpp concern literals in expression
position, which a written `null` never lowers to; both stay as they are.
Co-Authored-By: Claude Opus 5
---
SPEC.md | 23 ++
include/ibex/ir/node.hpp | 8 +-
src/codegen/emitter.cpp | 25 +-
src/ir/schema.cpp | 14 +-
src/parser/lower.cpp | 60 ++++
src/repl/repl.cpp | 271 ++++++++++--------
src/runtime/interpreter.cpp | 6 +-
.../parity/cases/construct_null_literals.ibex | 24 ++
tests/test_ir_schema.cpp | 24 ++
tests/test_repl.cpp | 95 ++++++
10 files changed, 423 insertions(+), 127 deletions(-)
create mode 100644 tests/parity/cases/construct_null_literals.ibex
diff --git a/SPEC.md b/SPEC.md
index b341ae7f..c12212d0 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -556,6 +556,10 @@ require a `Series` annotation outside `Table { ... }`; inside a table
constructor, `col = []` produces a zero-row `Int64` series by default.
Duration literals (`1m`, `30s`) are not valid series elements.
+`null` is also a valid element, but only inside a `Table { ... }` column: a
+standalone Series binding is a bare column with no validity bitmap, so it has
+nowhere to record the null. See Section 8.2.
+
`TimeFrame` is a `DataFrame` with the additional invariant that exactly
one column of type `Timestamp` is designated as the time index, and rows are
sorted by that index in ascending order.
@@ -2827,6 +2831,25 @@ All elements within one series literal must have the same literal kind; mixing t
is a lowering error. Duration literals (`1m`, `30s`) are not valid array
elements.
+`null` may appear as an element, producing a null cell — it clears that row's
+validity bit (Section 3.5) rather than storing a value:
+
+```
+Table {
+ symbol = ["AAPL", null, "MSFT"], // String, row 1 null
+ price = [150.0, 140.0, null], // Float64, row 2 null
+}
+```
+
+`null` carries no type of its own, so the column's type comes from its first
+non-null element and a `null` in a column of any other kind is not a type
+mismatch. A column whose elements are *all* `null` has no type to take, and
+falls back to `Int64` — the same default `col = []` uses:
+
+```
+Table { a = [null, null] } // Int64, both rows null
+```
+
### 8.3 Expression Columns
Any expression that produces a **Series** or **Table** may be used as a column
diff --git a/include/ibex/ir/node.hpp b/include/ibex/ir/node.hpp
index 8f8813a6..6fbcc226 100644
--- a/include/ibex/ir/node.hpp
+++ b/include/ibex/ir/node.hpp
@@ -1106,7 +1106,13 @@ class DcastNode final : public Node {
/// extracted from that Table (single-column result, or column named `name`).
struct ConstructColumn {
std::string name;
- std::vector elements; ///< non-empty iff expr_node is null
+ std::vector elements; ///< non-empty iff expr_node is null
+ /// Validity of `elements`, parallel to it. Empty means every element is
+ /// valid — the common case. A `null` element still carries a well-typed
+ /// placeholder in `elements` (the column's type comes from the first
+ /// non-null element), so consumers may read `elements` uniformly and
+ /// consult `valid` only to build the column's validity bitmap.
+ std::vector valid;
std::unique_ptr expr_node; ///< non-null iff elements is empty
};
diff --git a/src/codegen/emitter.cpp b/src/codegen/emitter.cpp
index da283e29..5f2e865f 100644
--- a/src/codegen/emitter.cpp
+++ b/src/codegen/emitter.cpp
@@ -935,6 +935,23 @@ auto Emitter::emit_node(const ir::Node& node) -> std::string {
<< "\", ibex::Column{});\n";
continue;
}
+ // A column with `null` elements is added with an explicit
+ // validity bitmap; without one, add_column marks every row valid.
+ auto emit_validity = [&] {
+ if (col.valid.empty()) {
+ return;
+ }
+ *out_ << ", ibex::runtime::ValidityBitmap{std::vector{";
+ bool first = true;
+ for (const bool v : col.valid) {
+ if (!first) {
+ *out_ << ", ";
+ }
+ first = false;
+ *out_ << (v ? "true" : "false");
+ }
+ *out_ << "}}";
+ };
// Determine column type from first element and emit the add_column call.
std::visit(
[&](const auto& first_val) {
@@ -982,7 +999,9 @@ auto Emitter::emit_node(const ir::Node& node) -> std::string {
<< ", ibex::decimal::kInt64Type}";
}
}
- *out_ << "}));\n";
+ *out_ << "})";
+ emit_validity();
+ *out_ << ");\n";
return;
}
*out_ << " " << var << ".add_column(\"" << escape_string(col.name)
@@ -1017,7 +1036,9 @@ auto Emitter::emit_node(const ir::Node& node) -> std::string {
},
lit.value);
}
- *out_ << "});\n";
+ *out_ << "}";
+ emit_validity();
+ *out_ << ");\n";
},
col.elements[0].value);
}
diff --git a/src/ir/schema.cpp b/src/ir/schema.cpp
index c3c8c73c..3032bac3 100644
--- a/src/ir/schema.cpp
+++ b/src/ir/schema.cpp
@@ -1119,10 +1119,18 @@ auto infer_schema(const Node& node, const SourceSchemas& sources) -> SchemaInfo
std::optional type;
Nullability nulls = Nullability::Maybe;
if (!col.elements.empty()) {
+ // Reading the type off element 0 is safe even when element
+ // 0 is a `null`: lowering fills a null's slot with a
+ // placeholder of the column's own type, so every element
+ // answers `literal_type` the same way. That invariant lives
+ // in `lower_table_expr`; a null's slot holding a
+ // default-constructed Literal instead would silently retype
+ // a null-leading column to Int64.
type = literal_type(col.elements.front());
- // A literal list is written out value by value, and the
- // surface language has no null literal to write.
- nulls = Nullability::Never;
+ // A literal list is written out value by value, so it is
+ // null-free unless it was written with `null` elements —
+ // which are the cleared bits of `valid`.
+ nulls = col.valid.empty() ? Nullability::Never : Nullability::Maybe;
} else if (col.expr_node != nullptr) {
const SchemaInfo sub = infer_schema(*col.expr_node, sources);
if (sub.is_known() && sub.fields().size() == 1) {
diff --git a/src/parser/lower.cpp b/src/parser/lower.cpp
index 211df3ea..acb0f18d 100644
--- a/src/parser/lower.cpp
+++ b/src/parser/lower.cpp
@@ -874,6 +874,29 @@ auto substitute_params(const Expr& expr,
expr.node);
}
+/// True if `expr` is the `null` keyword, which the parser lowers to `__null()`.
+auto is_null_literal(const Expr& expr) -> bool {
+ const auto* call = std::get_if(&expr.node);
+ return call != nullptr && call->callee == "__null" && call->args.empty() &&
+ call->named_args.empty();
+}
+
+/// A zero-valued literal of the same type as `like` — the stand-in stored for a
+/// `null` series element, which the validity bitmap masks out. A Decimal keeps
+/// `like`'s precision and scale so the column's unified type is unaffected.
+auto placeholder_for(const ir::Literal& like) -> ir::Literal {
+ return std::visit(
+ [](const auto& v) -> ir::Literal {
+ using T = std::decay_t;
+ if constexpr (std::is_same_v) {
+ return ir::Literal{.value = DecimalValue{.units = 0, .type = v.type}};
+ } else {
+ return ir::Literal{.value = T{}};
+ }
+ },
+ like.value);
+}
+
auto extract_string_list(const Expr& expr) -> std::optional> {
const auto* array = std::get_if(&expr.node);
if (array == nullptr) {
@@ -1783,12 +1806,24 @@ class Lowerer {
continue;
}
+ // `null` elements have no type of their own, so they are recorded
+ // as holes here and filled with a well-typed placeholder once the
+ // column's type is known (from the first non-null element).
std::vector elements;
+ std::vector valid;
elements.reserve(arr->elements.size());
+ bool any_null = false;
// Determine the type from the first element and validate uniformity.
int type_tag = -1; // 0=int, 1=double, 2=bool, 3=string, 4=Date, 5=Timestamp
for (const auto& elem_ptr : arr->elements) {
+ if (is_null_literal(*elem_ptr)) {
+ any_null = true;
+ valid.resize(elements.size(), true);
+ valid.push_back(false);
+ elements.emplace_back();
+ continue;
+ }
const auto* lit = std::get_if(&elem_ptr->node);
if (lit == nullptr) {
return std::unexpected(LowerError{.message = "Table constructor: column '" +
@@ -1828,11 +1863,35 @@ class Lowerer {
},
lit->value);
elements.push_back(std::move(ir_lit));
+ if (any_null) {
+ valid.push_back(true);
+ }
+ }
+
+ if (any_null) {
+ // Give every hole a value of the column's type, so downstream
+ // consumers can read `elements` without a per-element null
+ // check. An all-null column has no type to take; it defaults to
+ // Int64, matching the `col = []` rule.
+ const auto placeholder = [&] {
+ for (std::size_t i = 0; i < elements.size(); ++i) {
+ if (valid[i]) {
+ return placeholder_for(elements[i]);
+ }
+ }
+ return ir::Literal{.value = std::int64_t{0}};
+ }();
+ for (std::size_t i = 0; i < elements.size(); ++i) {
+ if (!valid[i]) {
+ elements[i] = placeholder;
+ }
+ }
}
construct_cols.push_back(ir::ConstructColumn{
.name = col_def.name,
.elements = std::move(elements),
+ .valid = std::move(valid),
.expr_node = nullptr,
});
}
@@ -5122,6 +5181,7 @@ class Lowerer {
ir::ConstructColumn cc;
cc.name = col.name;
cc.elements = col.elements;
+ cc.valid = col.valid;
if (col.expr_node) {
cc.expr_node = clone_node(*col.expr_node);
}
diff --git a/src/repl/repl.cpp b/src/repl/repl.cpp
index 0ba000c0..85c0fcbf 100644
--- a/src/repl/repl.cpp
+++ b/src/repl/repl.cpp
@@ -1888,26 +1888,54 @@ auto empty_series_for_type(parser::ScalarType type) -> runtime::ColumnValue {
return Column{};
}
+/// A series literal evaluated to a column, plus its validity when the literal
+/// contained `null` elements (nullopt means every element is valid).
+struct SeriesLiteral {
+ runtime::ColumnValue column;
+ std::optional validity;
+};
+
+/// A standalone Series binding is a bare column with no validity bitmap, so a
+/// `null` element has nowhere to live; only a `Table { }` column can hold one.
+constexpr std::string_view kNullSeriesBindingError =
+ "null is only allowed in a Table { } column literal, not a standalone series";
+
+/// True if `expr` is the `null` keyword, which the parser lowers to `__null()`.
+auto is_null_literal_expr(const parser::Expr& expr) -> bool {
+ const auto* call = std::get_if(&expr.node);
+ return call != nullptr && call->callee == "__null" && call->args.empty() &&
+ call->named_args.empty();
+}
+
auto eval_series_literal(const parser::ArrayLiteralExpr& array,
std::optional expected = std::nullopt)
- -> std::expected {
+ -> std::expected {
if (array.elements.empty()) {
if (!expected.has_value()) {
return std::unexpected("empty series literal requires a Series annotation");
}
- return empty_series_for_type(*expected);
+ return SeriesLiteral{.column = empty_series_for_type(*expected), .validity = std::nullopt};
}
- const auto* first_lit = std::get_if(&array.elements.front()->node);
- if (first_lit == nullptr) {
- return std::unexpected("series literal elements must be literals");
- }
+ // `null` elements have no type of their own: the column's type comes from
+ // the first non-null element, and each null is stored as a zero of that
+ // type which the validity bitmap masks out. An all-null literal has no type
+ // to take, so it falls back to Int64 — the `[]` rule.
+ using LiteralValue = decltype(parser::LiteralExpr::value);
+ std::vector values;
+ values.reserve(array.elements.size());
+ std::vector valid;
+ bool any_null = false;
+ std::optional type_index;
- const std::size_t type_index = first_lit->value.index();
- if (type_index == 4) {
- return std::unexpected("duration literals are not valid series elements");
- }
for (const auto& element : array.elements) {
+ if (is_null_literal_expr(*element)) {
+ any_null = true;
+ valid.resize(values.size(), true);
+ valid.push_back(false);
+ values.emplace_back();
+ continue;
+ }
const auto* lit = std::get_if(&element->node);
if (lit == nullptr) {
return std::unexpected("series literal elements must be literals");
@@ -1915,103 +1943,82 @@ auto eval_series_literal(const parser::ArrayLiteralExpr& array,
if (lit->value.index() == 4) {
return std::unexpected("duration literals are not valid series elements");
}
- if (lit->value.index() != type_index) {
+ if (!type_index.has_value()) {
+ type_index = lit->value.index();
+ } else if (lit->value.index() != *type_index) {
return std::unexpected("series literal has mixed element types");
}
+ values.push_back(lit->value);
+ if (any_null) {
+ valid.push_back(true);
+ }
}
- runtime::ColumnValue out;
- switch (type_index) {
- case 0: {
- Column col;
- col.reserve(array.elements.size());
- for (const auto& element : array.elements) {
- col.push_back(
- std::get(std::get(element->node).value));
- }
- out = std::move(col);
- break;
- }
- case 1: {
- Column col;
- col.reserve(array.elements.size());
- for (const auto& element : array.elements) {
- col.push_back(std::get(std::get(element->node).value));
+ std::optional validity;
+ if (any_null) {
+ // Give every hole a value of the column's type, so the builders below
+ // can read `values` without a per-element null check.
+ LiteralValue placeholder{std::int64_t{0}};
+ if (type_index.has_value()) {
+ std::size_t first_valid = 0;
+ while (!valid[first_valid]) {
+ ++first_valid;
}
- out = std::move(col);
- break;
- }
- case 2: {
- Column col;
- col.reserve(array.elements.size());
- for (const auto& element : array.elements) {
- col.push_back(std::get(std::get(element->node).value));
- }
- out = std::move(col);
- break;
- }
- case 3: {
- Column col;
- col.reserve(array.elements.size());
- for (const auto& element : array.elements) {
- const auto& value =
- std::get(std::get(element->node).value);
- col.push_back(std::string_view{value});
- }
- out = std::move(col);
- break;
- }
- case 5: {
- Column col;
- col.reserve(array.elements.size());
- for (const auto& element : array.elements) {
- col.push_back(std::get(std::get(element->node).value));
- }
- out = std::move(col);
- break;
+ placeholder = std::visit(
+ [](const auto& v) -> LiteralValue {
+ using T = std::decay_t;
+ if constexpr (std::is_same_v) {
+ return DecimalValue{.units = 0, .type = v.type};
+ } else {
+ return T{};
+ }
+ },
+ values[first_valid]);
}
- case 6: {
- Column col;
- col.reserve(array.elements.size());
- for (const auto& element : array.elements) {
- col.push_back(
- std::get(std::get(element->node).value));
+ for (std::size_t i = 0; i < values.size(); ++i) {
+ if (!valid[i]) {
+ values[i] = placeholder;
}
- out = std::move(col);
- break;
}
- case 7: {
- // Decimal: one column type for the list, the narrowest holding
- // every element exactly.
- DecimalType unified =
- std::get(
- std::get