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(array.elements.front()->node).value) - .type; - for (const auto& element : array.elements) { - unified = decimal::union_type( - unified, - std::get(std::get(element->node).value) - .type); - } - auto col = runtime::make_decimal_column(unified); - col.reserve(array.elements.size()); - for (const auto& element : array.elements) { - col.push_back(Decimal{runtime::decimal_units_for( - std::get(std::get(element->node).value), - unified)}); - } - out = std::move(col); - break; - } - default: - return std::unexpected("unsupported series literal element type"); + validity = runtime::ValidityBitmap{valid}; } + // An all-null list left every value at the Int64 placeholder, so the visit + // below builds the Int64 column the fallback calls for. + runtime::ColumnValue out = std::visit( + [&](const auto& first_val) -> runtime::ColumnValue { + using T = std::decay_t; + if constexpr (std::is_same_v) { + return Column{}; // excluded above + } else if constexpr (std::is_same_v) { + // Decimal: one column type for the list, the narrowest holding + // every element exactly. + DecimalType unified = first_val.type; + for (const auto& value : values) { + unified = decimal::union_type(unified, std::get(value).type); + } + auto col = runtime::make_decimal_column(unified); + col.reserve(values.size()); + for (const auto& value : values) { + col.push_back(Decimal{ + runtime::decimal_units_for(std::get(value), unified)}); + } + return col; + } else { + Column col; + col.reserve(values.size()); + for (const auto& value : values) { + col.push_back(std::get(value)); + } + return col; + } + }, + values.front()); if (expected.has_value() && !column_type_matches(out, *expected)) { return std::unexpected("series literal has wrong type (expected " + std::string(scalar_type_name(*expected)) + ")"); } - return out; + return SeriesLiteral{.column = std::move(out), .validity = std::move(validity)}; } /// Validates that `table` satisfies the schema declared in `type`. @@ -3007,7 +3014,10 @@ auto eval_expr_value(parser::Expr& expr, runtime::TableRegistry& tables, if (!series) { return std::unexpected(series.error()); } - return EvalValue{std::move(series.value())}; + if (series->validity.has_value()) { + return std::unexpected(std::string{kNullSeriesBindingError}); + } + return EvalValue{std::move(series->column)}; } // String interpolation (`...${expr}...`) lowers to a __interp call; it always // produces a scalar string. @@ -3693,46 +3703,60 @@ auto eval_table_expr(parser::Expr& expr, runtime::TableRegistry& tables, table_expr != nullptr && table_expr->row_count == nullptr) { runtime::Table out; for (const auto& col_def : table_expr->columns) { - std::expected value = std::unexpected(""); - if (const auto* array = std::get_if(&col_def.expr->node); - array != nullptr && array->elements.empty()) { - auto series = eval_series_literal(*array, parser::ScalarType::Int64); + runtime::ColumnValue column; + std::optional validity; + // A series literal is evaluated here rather than through + // eval_expr_value, which yields a bare column: only this path can + // carry the validity a `null` element produces. An empty literal + // takes the Int64 default the table constructor promises. + if (const auto* array = std::get_if(&col_def.expr->node)) { + auto series = eval_series_literal( + *array, array->elements.empty() ? std::optional{parser::ScalarType::Int64} + : std::nullopt); if (!series) { return std::unexpected(series.error()); } - value = EvalValue{std::move(series.value())}; + column = std::move(series->column); + validity = std::move(series->validity); } else { - value = + auto value = eval_expr_value(*col_def.expr, tables, lazy_tables, scalars, columns, models, functions, compile_time_lists, extern_decls, externs); - } - if (!value) { - return std::unexpected(value.error()); - } - - runtime::ColumnValue column; - if (auto* col = std::get_if(&value.value())) { - column = std::move(*col); - } else if (auto* table = std::get_if(&value.value())) { - if (table->columns.size() == 1) { - column = *table->columns.front().column; - } else if (const auto* found = table->find(col_def.name); found != nullptr) { - column = *found; + if (!value) { + return std::unexpected(value.error()); + } + if (auto* col = std::get_if(&value.value())) { + column = std::move(*col); + } else if (auto* table = std::get_if(&value.value())) { + const runtime::ColumnEntry* entry = nullptr; + if (table->columns.size() == 1) { + entry = &table->columns.front(); + } else if (auto it = table->index.find(col_def.name); + it != table->index.end()) { + entry = &table->columns[it->second]; + } else { + return std::unexpected("Table constructor: expression for column '" + + col_def.name + + "' produced a table with no matching column"); + } + column = *entry->column; + validity = entry->validity; } else { return std::unexpected("Table constructor: expression for column '" + col_def.name + - "' produced a table with no matching column"); + "' must evaluate to a Series or DataFrame"); } - } else { - return std::unexpected("Table constructor: expression for column '" + col_def.name + - "' must evaluate to a Series or DataFrame"); } if (!out.columns.empty() && runtime::column_size(column) != out.rows()) { return std::unexpected("Table constructor: column '" + col_def.name + "' length does not match previous columns"); } - out.add_column(col_def.name, std::move(column)); + if (validity.has_value()) { + out.add_column(col_def.name, std::move(column), std::move(*validity)); + } else { + out.add_column(col_def.name, std::move(column)); + } } return out; } @@ -4174,7 +4198,10 @@ auto eval_function_call(parser::CallExpr& call, runtime::TableRegistry& tables, if (!series) { return std::unexpected(series.error()); } - value = EvalValue{std::move(series.value())}; + if (series->validity.has_value()) { + return std::unexpected(std::string{kNullSeriesBindingError}); + } + value = EvalValue{std::move(series->column)}; } else { value = eval_expr_value(*let_stmt.value, local_tables, local_lazy_tables, local_scalars, local_columns, local_models, functions, @@ -4764,7 +4791,11 @@ auto execute_statements(std::vector& statements, runtime::TableReg ibex::formatting::print("error: {}\n", series.error()); return false; } - value = EvalValue{std::move(series.value())}; + if (series->validity.has_value()) { + ibex::formatting::print("error: {}\n", kNullSeriesBindingError); + return false; + } + value = EvalValue{std::move(series->column)}; } else { value = eval_expr_value(*let_stmt.value, tables, lazy_tables, scalars, columns, models, functions, compile_time_lists, diff --git a/src/runtime/interpreter.cpp b/src/runtime/interpreter.cpp index 072393ce..4f6ccda3 100644 --- a/src/runtime/interpreter.cpp +++ b/src/runtime/interpreter.cpp @@ -1399,7 +1399,11 @@ auto interpret_node(const ir::Node& node, const TableRegistry& registry, } }, col.elements[0].value); - result.add_column(col.name, std::move(cv)); + if (col.valid.empty()) { + result.add_column(col.name, std::move(cv)); + } else { + result.add_column(col.name, std::move(cv), ValidityBitmap{col.valid}); + } } // Validate that all columns have the same length. if (!result.columns.empty()) { diff --git a/tests/parity/cases/construct_null_literals.ibex b/tests/parity/cases/construct_null_literals.ibex new file mode 100644 index 00000000..f428ae20 --- /dev/null +++ b/tests/parity/cases/construct_null_literals.ibex @@ -0,0 +1,24 @@ +// `null` as a series-literal element in a `Table { }` column. The column's type +// comes from the first non-null element; an all-null column has no type to take +// and falls back to Int64, matching the `col = []` rule. The interpreter builds +// the validity bitmap directly, the emitter writes it out as an add_column +// argument — this case keeps the two in step. +let t = Table { + i = [1, null, 3], + f = [1.5, null, 2.5], + s = ["a", null, "c"], + b = [true, null, false], + all_null = [null, null, null], + // A null in element 0 is the case that tells a well-typed placeholder + // apart from a default-constructed one: `lead_f` must stay Float64 and + // `lead_s` String, not collapse to Int64. + lead_f = [null, 1.5, 2.5], + lead_s = [null, "b", "c"] +}; + +t; +t[filter { i is null }]; +t[select { flag = i is null, bumped = i + 1 }]; +t[select { total = sum(i), seen = count(f), any_all_null = count(all_null) }]; +t[select { scaled = lead_f * 2.0, tagged = lead_s }]; +t[select { lead_total = sum(lead_f), lead_seen = count(lead_s) }]; diff --git a/tests/test_ir_schema.cpp b/tests/test_ir_schema.cpp index 036ffa53..c6ee1f7f 100644 --- a/tests/test_ir_schema.cpp +++ b/tests/test_ir_schema.cpp @@ -698,6 +698,30 @@ TEST_CASE("schema: a source's nullability survives the row-shaping operators", " } } +TEST_CASE("schema: a Table literal is null-free only without null elements", "[ir][schema]") { + // `Never` here is a proof a later pass may fold `is null` against, so a + // literal list written with `null` elements must not claim it. + auto s = schema_of("Table { a = [1, 2], b = [1, null], c = [null, null] };"); + REQUIRE(s.is_known()); + CHECK(nulls_of(s, "a") == Nullability::Never); + CHECK(nulls_of(s, "b") == Nullability::Maybe); + CHECK(nulls_of(s, "c") == Nullability::Maybe); +} + +TEST_CASE("schema: a leading null does not retype the column", "[ir][schema]") { + // The type is read off element 0, which for these columns is a null. It + // holds a placeholder of the column's own type, so the answer is the type + // the first *written* value implies -- not the Int64 a default-constructed + // placeholder would give. Only an all-null column falls back to Int64. + auto s = schema_of( + "Table { f = [null, 1.5], s = [null, \"a\"], b = [null, true], n = [null, null] };"); + REQUIRE(s.is_known()); + CHECK(type_of(s, "f") == ColumnType::Float64); + CHECK(type_of(s, "s") == ColumnType::String); + CHECK(type_of(s, "b") == ColumnType::Bool); + CHECK(type_of(s, "n") == ColumnType::Int64); +} + TEST_CASE("schema: a filter proves the columns its predicate had to read", "[ir][schema]") { // A null `b` makes `b > 0` null, and null is not true, so no row with a // null `b` survives. `c` is untouched by the predicate and stays unproven. diff --git a/tests/test_repl.cpp b/tests/test_repl.cpp index 2edab1c7..d38f8f29 100644 --- a/tests/test_repl.cpp +++ b/tests/test_repl.cpp @@ -2472,3 +2472,98 @@ capture(ordered[head 3]); CHECK(topk == std::vector{9, 9, 7}); CHECK(topk == sorted_head); } + +namespace { + +/// Records, for one column, whether each row is valid — the one thing a plain +/// value capture cannot see. +auto register_validity_capture(ibex::runtime::ExternRegistry& registry, const std::string& column, + std::vector& captured) -> void { + registry.register_scalar_table_consumer( + "capture", ibex::runtime::ScalarKind::Int, + [&captured, column](const ibex::runtime::Table& table, const ibex::runtime::ExternArgs&) + -> std::expected { + auto it = table.index.find(column); + if (it == table.index.end()) { + return std::unexpected("capture expected a column named " + column); + } + const auto& entry = table.columns[it->second]; + captured.clear(); + for (std::size_t row = 0; row < table.rows(); ++row) { + captured.push_back(!ibex::runtime::is_null(entry, row)); + } + return ibex::runtime::ExternValue{std::int64_t{0}}; + }); +} + +} // namespace + +TEST_CASE("REPL Table literal: a null element makes that cell null", "[repl][null][construct]") { + // `null` has no type of its own, so the column takes the type of its first + // non-null element and the null becomes a cleared validity bit. Both REPL + // paths build the column, so both are checked. + ibex::runtime::ExternRegistry registry; + std::vector valid; + std::vector values; + register_validity_capture(registry, "v", valid); + + SECTION("batch planner path") { + const char* src = R"( +extern fn capture(df: DataFrame) -> Int from "fake.hpp"; +capture(Table { v = [1, null, 3] }); +)"; + REQUIRE(ibex::repl::execute_script(src, registry)); + CHECK(valid == std::vector{true, false, true}); + } + + SECTION("statement-at-a-time path") { + // A `let`-bound table is executed statement by statement. + const char* src = R"( +extern fn capture(df: DataFrame) -> Int from "fake.hpp"; +let t = Table { v = [1, null, 3], s = ["a", null, "c"] }; +let n = scalar(t[select { c = count() }]); +capture(t); +)"; + REQUIRE(ibex::repl::execute_script(src, registry)); + CHECK(valid == std::vector{true, false, true}); + } + + SECTION("null survives arithmetic and is testable with is null") { + register_int_capture(registry, "v", values); + const char* src = R"( +extern fn capture(df: DataFrame) -> Int from "fake.hpp"; +capture(Table { x = [1, null, 3] }[filter { x is not null }, select { v = x + 1 }]); +)"; + REQUIRE(ibex::repl::execute_script(src, registry)); + CHECK(values == std::vector{2, 4}); + } +} + +TEST_CASE("REPL Table literal: an all-null column is an all-null Int64 column", + "[repl][null][construct]") { + // With no non-null element there is no type to take, so the column falls + // back to Int64 — the same default `col = []` uses. + ibex::runtime::ExternRegistry registry; + std::vector valid; + register_validity_capture(registry, "v", valid); + + const char* src = R"( +extern fn capture(df: DataFrame) -> Int from "fake.hpp"; +capture(Table { v = [null, null] }); +)"; + REQUIRE(ibex::repl::execute_script(src, registry)); + CHECK(valid == std::vector{false, false}); +} + +TEST_CASE("REPL a null element still has to agree with the column's type", + "[repl][null][construct]") { + ibex::runtime::ExternRegistry registry; + ibex::repl::ReplConfig config; + config.persistent_history = false; + + // A standalone Series binding is a bare column with no validity bitmap, so + // there is nowhere to record the null. + CHECK_FALSE(ibex::repl::execute_script("let s = [1, null];", registry, config)); + // Nulls do not excuse mixed element types. + CHECK_FALSE(ibex::repl::execute_script("Table { v = [1, null, \"a\"] };", registry, config)); +} From 015430a18ab56a9ed73342f3d8b562edc889bc4b Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Sun, 20 Sep 2026 14:45:40 +0200 Subject: [PATCH 11/42] Support captures that share a column in a correlated subquery `a == outer(x) && b == outer(x)` was rejected as a "duplicate capture", though it is a meaningful query: inner rows where both columns equal that outer row's `x`. So was `a == outer(x) && a == outer(y)`, which matches only where the two outer values agree. Neither was a judgement about the query. Decorrelation grouped the subquery by its inner keys and renamed each inner key to its outer name, so the join could match on one shared name -- and two columns cannot both be named `x`. The guard existed to stop that collision. JoinKey already carries `left` and `right` separately, with `fold_output` to keep a differently-spelled pair as one output column; the rename predates it. Carrying the pair per key expresses the query directly and drops the Rename node from every correlated plan: one outer column can be the `left` of several keys. Group keys are deduplicated, since one inner column captured twice is still one group key, and an exactly repeated capture is now redundant rather than an error. The two lowering tests that pinned the old shape assert the new one; the plan diagram and the parity case header that described the rename are updated. A new parity case covers both forms over data where a plan that dropped the second capture would answer differently, not merely stop erroring. Co-Authored-By: Claude Opus 5 --- SPEC.md | 6 ++ src/parser/lower.cpp | 64 +++++++------ .../cases/correlated_scalar_subquery.ibex | 9 +- .../correlated_shared_outer_capture.ibex | 31 +++++++ tests/test_lower.cpp | 91 ++++++++++++++++--- 5 files changed, 157 insertions(+), 44 deletions(-) create mode 100644 tests/parity/cases/correlated_shared_outer_capture.ibex diff --git a/SPEC.md b/SPEC.md index c12212d0..4b1b218f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2434,6 +2434,12 @@ must be an aggregate. A `filter` may hold *capture equalities* — `inner_column == outer(outer_column)`, in either order — alongside any number of ordinary local predicates. +Captures need not name distinct columns. Two may share the outer column +(`a == outer(x) && b == outer(x)`: inner rows where both columns equal that +outer row's `x`) or the inner one (`a == outer(x) && a == outer(y)`: matching +only where the two outer values agree). An exactly repeated capture is +redundant and ignored. + **Correlated or not.** A subquery that captures with `outer(...)` is evaluated per captured key. One that captures nothing is *uncorrelated*: a single value, the same for every row, evaluated once and broadcast. diff --git a/src/parser/lower.cpp b/src/parser/lower.cpp index acb0f18d..9c4d42b1 100644 --- a/src/parser/lower.cpp +++ b/src/parser/lower.cpp @@ -3304,7 +3304,12 @@ class Lowerer { /// (which are the left-join keys). struct ScalarSubqueryPlan { ir::NodePtr plan; - std::vector keys; + /// Outer-to-inner key pairs for the caller's left join. Carrying both + /// names -- rather than renaming the inner key to the outer one -- is + /// what lets two captures share one outer column (`a == outer(x) && b + /// == outer(x)`): one name cannot be two columns, but one join key's + /// `left` can be repeated across two keys. + std::vector keys; /// The selected column is a count, whose value over no rows is 0. bool counts = false; }; @@ -3320,10 +3325,14 @@ class Lowerer { /// ...]) /// /// Filter(ps_supplycost == __ibex_scalar_0) - /// Join(Left, on p_partkey) + /// Join(Left, on p_partkey = ps_partkey) /// Filter(local)(outer) - /// Rename(ps_partkey -> p_partkey) - /// Aggregate(by ps_partkey: min(ps_supplycost) as __ibex_scalar_0)(inner) + /// Aggregate(by ps_partkey: min(ps_supplycost) as __ibex_scalar_0)(inner) + /// + /// The join reads each side's key under its own name and folds the pair + /// into one output column. An earlier shape renamed the inner key to the + /// outer name and joined on the one name, which could not express two + /// captures sharing an outer column (`a == outer(x) && b == outer(x)`). /// /// The left join is what gives the subquery SQL's scalar semantics: an /// outer row whose key matches no inner group gets a null, and the @@ -3449,13 +3458,8 @@ class Lowerer { // 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()); - for (const auto& key : subplan->keys) { - join_keys.emplace_back(key); - } auto join = builder_.join(correlated ? ir::JoinKind::Left : ir::JoinKind::Cross, - std::move(join_keys)); + std::move(subplan->keys)); join->add_child(std::move(input)); join->add_child(std::move(subplan->plan)); input = std::move(join); @@ -3576,12 +3580,16 @@ class Lowerer { if (!capture.has_value()) { return std::unexpected(capture.error()); } - const bool duplicate = std::ranges::any_of(captures, [&](const CapturedKey& seen) { - return seen.inner == capture->inner || seen.outer == capture->outer; + // Only an exact repeat is dropped, as the redundancy it is. Two + // captures that share just one side are meaningful and supported: + // `a == outer(x) && b == outer(x)` asks for inner rows where both + // columns equal that outer row's `x`, and the join carries one + // `left` per key rather than one shared name. + const bool repeat = std::ranges::any_of(captures, [&](const CapturedKey& seen) { + return seen.inner == capture->inner && seen.outer == capture->outer; }); - if (duplicate) { - return std::unexpected(LowerError{.message = "scalar(): duplicate outer(" + - capture->outer + ") capture"}); + if (repeat) { + continue; } captures.push_back(std::move(capture.value())); } @@ -3614,7 +3622,13 @@ class Lowerer { by.is_braced = true; by.keys.reserve(captures.size()); for (const auto& capture : captures) { - by.keys.push_back(Field{.name = capture.inner, .expr = nullptr}); + // `a == outer(x) && a == outer(y)` captures one inner column + // twice; it is one group key, compared against two outer ones. + const bool grouped_already = std::ranges::any_of( + by.keys, [&](const Field& key) { return key.name == capture.inner; }); + if (!grouped_already) { + by.keys.push_back(Field{.name = capture.inner, .expr = nullptr}); + } } grouped.clauses.emplace_back(std::move(by)); } @@ -3630,21 +3644,13 @@ class Lowerer { } auto plan = std::move(lowered.value()); - // The aggregate keys carry the inner names; the join needs the outer ones. - std::vector renames; - std::vector keys; + // The aggregate keys carry the inner names and the outer query its + // own; the join reads each side natively and folds the pair into one + // output column, which is what the rename here used to achieve. + std::vector keys; keys.reserve(captures.size()); for (const auto& capture : captures) { - if (capture.inner != capture.outer) { - renames.push_back( - ir::RenameSpec{.new_name = capture.outer, .old_name = capture.inner}); - } - keys.push_back(capture.outer); - } - if (!renames.empty()) { - auto rename = builder_.rename(std::move(renames)); - rename->add_child(std::move(plan)); - plan = std::move(rename); + keys.emplace_back(capture.outer, capture.inner, /*fold=*/true); } return ScalarSubqueryPlan{ .plan = std::move(plan), .keys = std::move(keys), .counts = counts}; diff --git a/tests/parity/cases/correlated_scalar_subquery.ibex b/tests/parity/cases/correlated_scalar_subquery.ibex index 441b241c..6497f3ff 100644 --- a/tests/parity/cases/correlated_scalar_subquery.ibex +++ b/tests/parity/cases/correlated_scalar_subquery.ibex @@ -1,7 +1,8 @@ -// A correlated scalar subquery decorrelates into aggregate + rename + left -// join, so the interpreter and the emitted C++ must agree on it. Part 2's -// minimum is tied (both suppliers survive) and part 4's only supplier is US, -// so its EU subquery finds no group at all and the null comparison drops it. +// A correlated scalar subquery decorrelates into an aggregate plus a left join +// keyed on the capture, so the interpreter and the emitted C++ must agree on +// it. Part 2's minimum is tied (both suppliers survive) and part 4's only +// supplier is US, so its EU subquery finds no group at all and the null +// comparison drops it. let parts = Table { p_partkey = [1, 2, 3, 4], p_name = ["nut", "bolt", "screw", "washer"] diff --git a/tests/parity/cases/correlated_shared_outer_capture.ibex b/tests/parity/cases/correlated_shared_outer_capture.ibex new file mode 100644 index 00000000..c5816f54 --- /dev/null +++ b/tests/parity/cases/correlated_shared_outer_capture.ibex @@ -0,0 +1,31 @@ +// Two captures sharing one outer column: `ps_partkey == outer(k) && +// ps_suppkey == outer(k)`. This was rejected as a "duplicate capture" while +// decorrelation renamed each inner key to its outer name -- two columns cannot +// both be named `k`. The join now carries the pair per key, so the same outer +// column can appear as the `left` of more than one key. +// +// The data discriminates: for k = 1 the first capture alone matches cost +// {5.0, 3.0} (min 3.0), and both together match only the row with +// ps_suppkey = 1, cost 5.0. So a plan that dropped the second capture would +// answer 3.0 here. +let keys = Table { k = [1, 2, 3] }; +let supply = Table { + ps_partkey = [1, 1, 2, 2, 3], + ps_suppkey = [1, 2, 2, 3, 9], + ps_cost = [5.0, 3.0, 4.0, 8.0, 1.0] +}; + +keys[filter 5.0 == scalar( + supply[filter ps_partkey == outer(k) && ps_suppkey == outer(k), + select { m = min(ps_cost) }] +)]; + +// The mirror case: one inner column captured against two outer columns, which +// matches only where the two outer values agree. +let pairs = Table { lo = [1, 2], hi = [1, 3] }; +// For (lo=1, hi=1) the group is ps_partkey = 1, whose minimum cost is 3.0; +// (lo=2, hi=3) can match no row at all, so its null comparison drops it. +pairs[filter 3.0 == scalar( + supply[filter ps_partkey == outer(lo) && ps_partkey == outer(hi), + select { m = min(ps_cost) }] +)]; diff --git a/tests/test_lower.cpp b/tests/test_lower.cpp index ff4d5f44..96ed9759 100644 --- a/tests/test_lower.cpp +++ b/tests/test_lower.cpp @@ -1238,17 +1238,16 @@ parts[filter p_partkey == scalar( const auto* join = find_join(*result.value()); REQUIRE(join != nullptr); REQUIRE(join->kind() == ir::JoinKind::Left); - REQUIRE(join->keys() == std::vector{{"p_partkey", "p_partkey"}}); + // Each side keeps its own spelling of the key; the pair folds into one + // output column. Renaming the inner key to the outer name instead would + // make two captures sharing an outer column inexpressible. + REQUIRE(join->keys() == std::vector{{"p_partkey", "ps_partkey", true}}); REQUIRE(join->predicate() == std::nullopt); // a theta join would be a nested loop - // Right side: Rename(ps_partkey -> p_partkey) over the aggregate. - const auto* rename = as_node(join->children()[1].get()); - REQUIRE(rename != nullptr); - REQUIRE(rename->renames().size() == 1); - REQUIRE(rename->renames()[0].old_name == "ps_partkey"); - REQUIRE(rename->renames()[0].new_name == "p_partkey"); + // Right side: the aggregate itself, with no rename in between. + REQUIRE(as_node(join->children()[1].get()) == nullptr); - const auto* aggregate = as_node(rename->children()[0].get()); + const auto* aggregate = as_node(join->children()[1].get()); REQUIRE(aggregate != nullptr); REQUIRE(aggregate->group_by().size() == 1); REQUIRE(aggregate->group_by()[0].name == "ps_partkey"); @@ -1308,6 +1307,78 @@ parts[filter REQUIRE(aliases == std::vector{"__ibex_scalar_0", "__ibex_scalar_1"}); } +TEST_CASE("Lower decorrelates two captures that share one outer column") { + // `a == outer(x) && b == outer(x)` was rejected as a "duplicate capture": + // decorrelation renamed each inner key to its outer name, and two columns + // cannot both be named `p_partkey`. Carrying the pair on the join key + // instead expresses it -- one `left` per key, repeated across keys. + auto result = lower_source(R"( +let parts = Table { p_partkey = [1, 2] }; +let supply = Table { ps_partkey = [1, 1], ps_suppkey = [1, 2], ps_cost = [5.0, 3.0] }; +parts[filter p_partkey == scalar( + supply[filter ps_partkey == outer(p_partkey) && ps_suppkey == outer(p_partkey), + select { m = min(ps_cost) }] +)]; +)"); + REQUIRE(result.has_value()); + + const auto* join = find_join(*result.value()); + REQUIRE(join != nullptr); + REQUIRE(join->kind() == ir::JoinKind::Left); + // One outer column compared against two distinct inner ones. + REQUIRE(join->keys() == std::vector{{"p_partkey", "ps_partkey", true}, + {"p_partkey", "ps_suppkey", true}}); + REQUIRE(join->predicate() == std::nullopt); + + // Both captured columns are group keys: the subquery still runs once. + const auto* aggregate = as_node(join->children()[1].get()); + REQUIRE(aggregate != nullptr); + std::vector group_keys; + for (const auto& key : aggregate->group_by()) { + group_keys.push_back(key.name); + } + REQUIRE(group_keys == std::vector{"ps_partkey", "ps_suppkey"}); +} + +TEST_CASE("Lower groups one inner column captured against two outer columns once") { + // The mirror case: `a == outer(x) && a == outer(y)` is one group key + // compared against two outer columns, and matches only where the two outer + // values agree. Grouping by `a` twice would be a degenerate aggregate. + auto result = lower_source(R"( +let parts = Table { p_partkey = [1, 2], p_altkey = [1, 3] }; +let supply = Table { ps_partkey = [1, 1], ps_cost = [5.0, 3.0] }; +parts[filter p_partkey == scalar( + supply[filter ps_partkey == outer(p_partkey) && ps_partkey == outer(p_altkey), + select { m = min(ps_cost) }] +)]; +)"); + REQUIRE(result.has_value()); + + const auto* join = find_join(*result.value()); + REQUIRE(join != nullptr); + REQUIRE(join->keys() == std::vector{{"p_partkey", "ps_partkey", true}, + {"p_altkey", "ps_partkey", true}}); + + const auto* aggregate = as_node(join->children()[1].get()); + REQUIRE(aggregate != nullptr); + REQUIRE(aggregate->group_by().size() == 1); + REQUIRE(aggregate->group_by()[0].name == "ps_partkey"); +} + +TEST_CASE("Lower treats an exactly repeated capture as redundant, not an error") { + auto result = lower_source(std::string(kCorrelatedSources) + + R"( +parts[filter p_partkey == scalar( + supply[filter ps_partkey == outer(p_partkey) && ps_partkey == outer(p_partkey), + select { m = min(ps_cost) }] +)]; +)"); + REQUIRE(result.has_value()); + const auto* join = find_join(*result.value()); + REQUIRE(join != nullptr); + REQUIRE(join->keys() == std::vector{{"p_partkey", "ps_partkey", true}}); +} + TEST_CASE("Lower avoids a generated name the enclosing query already uses") { auto result = lower_source(R"( let parts = Table { p_partkey = [1, 2], __ibex_scalar_0 = [7, 8] }; @@ -1330,9 +1401,7 @@ parts[filter p_partkey == scalar( const auto* join = find_join(*result.value()); REQUIRE(join != nullptr); - const auto* rename = as_node(join->children()[1].get()); - REQUIRE(rename != nullptr); - const auto* aggregate = as_node(rename->children()[0].get()); + const auto* aggregate = as_node(join->children()[1].get()); REQUIRE(aggregate != nullptr); REQUIRE(aggregate->aggregations()[0].alias == "__ibex_scalar_1"); } From 199f371b46b690c5f48cba5e2a34c8340790fce3 Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Sun, 20 Sep 2026 14:56:16 +0200 Subject: [PATCH 12/42] Pin the folded-key shape a correlated subquery emits Two captures sharing an outer column give the decorrelated join two folded keys with the same `left`. Tracing that shape through the passes that read `fold_output`: - join_reorder only reorders Inner joins, so the Left join decorrelation builds is never reordered; - plan_join_output absorbs both right columns and emits one `k`, but records a folded peer on the single left column and so keeps only the first. The peer is read only for a row missing its own side, which Left and Inner never have on the left -- so this is sound here and would not be for Right or Outer; - nullability consults the peer only for those same kinds, and column_origins declines to assign an origin to a folded key on any non-Inner join; - join_pushdown would build a rename list producing one name twice, which ColumnNameMap::validate rejects. It is not reachable: the residual filter above the join always references the generated scalar column, which is right-only, so the destination is always Above. mapped_join_keys explicitly declines to create a repeated fold target, which leaves decorrelation its only producer. The constraint that it must keep emitting Left is recorded where the keys are built, and the output shape is pinned by a join_output test. Co-Authored-By: Claude Opus 5 --- src/parser/lower.cpp | 11 +++++++++++ tests/test_ir_join_output.cpp | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/parser/lower.cpp b/src/parser/lower.cpp index 9c4d42b1..e0f91e26 100644 --- a/src/parser/lower.cpp +++ b/src/parser/lower.cpp @@ -3647,6 +3647,17 @@ class Lowerer { // The aggregate keys carry the inner names and the outer query its // own; the join reads each side natively and folds the pair into one // output column, which is what the rename here used to achieve. + // + // Two captures sharing an outer column fold two right columns into one + // left output column. `plan_join_output` records a folded peer on the + // single left column and so keeps only the first, which is sound for + // the Left join built below and for an Inner one: the peer is read + // only for a row missing its own side, and neither kind has one on the + // left. It would NOT be sound for a Right or Outer join, where an + // unmatched right row takes its key value from the right and the two + // inner columns need not agree. `mapped_join_keys` declines to create + // this shape for the same family of reasons; decorrelation is its only + // producer, and it must keep emitting Left. std::vector keys; keys.reserve(captures.size()); for (const auto& capture : captures) { diff --git a/tests/test_ir_join_output.cpp b/tests/test_ir_join_output.cpp index 665bf0ae..21bb3527 100644 --- a/tests/test_ir_join_output.cpp +++ b/tests/test_ir_join_output.cpp @@ -123,6 +123,27 @@ TEST_CASE("join output plan: a folded key can preserve a logical label", "[ir][j CHECK((*plan)[0].folded_peer_index == 0); } +TEST_CASE("join output plan: two folded keys sharing one left column", "[ir][join][schema]") { + // The shape a correlated subquery emits for `a == outer(k) && b == + // outer(k)`: one outer column compared against two inner ones. Both right + // columns are absorbed and the output carries a single `k`. Only one peer + // index is recorded, since there is one left column to record it on -- + // sound because the peer is read only for a row missing its own side, + // which a Left or Inner join never has on the left. A Right or Outer join + // with this shape would read one peer and ignore the other, and nothing + // builds one. + const auto plan = + plan_of(JoinKind::Left, {{"k", "ps_partkey", true}, {"k", "ps_suppkey", true}}, + {"k", "tag"}, {"ps_partkey", "ps_suppkey", "m"}); + REQUIRE(plan.has_value()); + REQUIRE(plan->size() == 3); + CHECK((*plan)[0].name == "k"); + CHECK((*plan)[0].side == JoinOutputSide::Left); + CHECK((*plan)[1].name == "tag"); + // Neither inner key survives as its own column. + CHECK((*plan)[2].name == "m"); +} + TEST_CASE("join output plan: semi and anti joins return the left columns only", "[ir][join][schema]") { for (const JoinKind kind : {JoinKind::Semi, JoinKind::Anti}) { From 646def998d71b85ce640f2b799be3aa749c36968 Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Sun, 20 Sep 2026 16:13:46 +0200 Subject: [PATCH 13/42] A semi or anti join that keeps no row still reports its columns A consumer builds its result from the chunks an operator hands it, so an operator that filters every row away and then yields no chunk at all reports a table with no COLUMNS rather than no ROWS. Downstream that is not an empty answer but a missing schema: let a = Table { k = [1, 2], v = [10, 20] }; let b = Table { k = [7, 8] }; (a semi join b on k)[select { n = count(), s = sum(v) }]; error: aggregate: column 'v' not found in input for a column the input plainly has. A filter that keeps nothing has always reported its columns, and an inner join over the same empty input answers 0 -- only the membership joins lost the schema. ChunkedSemiAntiJoinOperator now keeps a row-less copy of the left's layout, taken from the first chunk it sees, and emits it on the way out when it has emitted nothing else. Both paths need it: the streaming one and the swapped one, which buffers the left when the right is too large to set-ify. A left that yields no chunk at all leaves nothing to take a layout from, so that case is unchanged. The tests cover no-key-matches, an already-empty left, an anti join that removes everything, and the swapped path above its 65536-row threshold; each fails without the fix, and a surviving-row case holds the normal path down. Interleaved A/B at SF-8 on eight cores: q21 1.41s -> 1.32s median (drift; the added work is one predictable branch per chunk), q04 0.25s unchanged. Co-Authored-By: Claude Opus 5 --- src/runtime/semi_anti_join.cpp | 48 ++++++++++++++++++++++- tests/test_join.cpp | 69 ++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/src/runtime/semi_anti_join.cpp b/src/runtime/semi_anti_join.cpp index 43dd0a33..5db7f86c 100644 --- a/src/runtime/semi_anti_join.cpp +++ b/src/runtime/semi_anti_join.cpp @@ -76,6 +76,7 @@ class ChunkedSemiAntiJoinOperator final : public Operator { // time went 113ms -> 211ms, which was that copy and nothing else. if (swapped_) { while (swapped_next_ < left_buffered_.size()) { + note_schema(left_buffered_[swapped_next_]); auto filtered = filter_chunk(std::move(left_buffered_[swapped_next_++])); if (!filtered.has_value()) { if (probe_error_.has_value()) { @@ -83,10 +84,11 @@ class ChunkedSemiAntiJoinOperator final : public Operator { } continue; } + emitted_ = true; return std::optional{table_to_chunk(std::move(*filtered))}; } left_buffered_.clear(); - return std::optional{}; + return end_of_stream(); } while (true) { @@ -95,10 +97,11 @@ class ChunkedSemiAntiJoinOperator final : public Operator { return std::unexpected(std::move(chunk_res.error())); } if (!chunk_res.value().has_value()) { - return std::optional{}; + return end_of_stream(); } Table t = chunk_to_table(std::move(*chunk_res.value())); + note_schema(t); auto filtered = filter_chunk(std::move(t)); if (!filtered.has_value()) { // `nullopt` alone means "no rows survive this chunk"; an error @@ -108,11 +111,48 @@ class ChunkedSemiAntiJoinOperator final : public Operator { } continue; } + emitted_ = true; return std::optional{table_to_chunk(std::move(*filtered))}; } } private: + /// Remember the left's column layout, once, so the operator can still say + /// what its output looks like when no row survives. + void note_schema(const Table& left_chunk) { + if (empty_template_.has_value() || left_chunk.columns.empty()) { + return; + } + Table empty; + empty.columns.reserve(left_chunk.columns.size()); + for (const auto& entry : left_chunk.columns) { + empty.add_column(entry.name, make_empty_like(*entry.column)); + } + // Every order-sensitive claim is vacuously true of a row-less table, + // so the left's properties carry over as they are. + empty.set_properties(left_chunk.properties()); + empty_template_ = std::move(empty); + } + + /// End the stream, but never by emitting nothing at all: a consumer builds + /// its result from the chunks it receives, so an operator that filters + /// every row away and then yields no chunk reports a table with no COLUMNS + /// rather than no ROWS. Downstream that is not an empty answer, it is a + /// missing schema -- `aggregate: column 'v' not found in input` for a + /// column the input plainly has. One row-less chunk carrying the left's + /// layout is the difference, and it is emitted only when nothing else was. + /// + /// A left that yields no chunk at all leaves nothing to take a layout from, + /// so that case is unchanged: the schema has to come from somewhere, and + /// the operator's own inputs are the only place it could. + auto end_of_stream() -> std::expected, std::string> { + if (emitted_ || !empty_template_.has_value()) { + return std::optional{}; + } + emitted_ = true; + return std::optional{table_to_chunk(std::move(*empty_template_))}; + } + // Above this many right rows, building a hash set of every right key is the // dominant cost of the whole operator (q04: 3.8M inserts into a robin_hood // set, ~40% of the query). Past it, materialize the left and swap. @@ -936,6 +976,10 @@ class ChunkedSemiAntiJoinOperator final : public Operator { const std::vector* keys_; bool initialized_ = false; bool swapped_ = false; + /// Whether any chunk has been handed downstream, and the left's layout to + /// fall back on when none has. See `end_of_stream`. + bool emitted_ = false; + std::optional empty_template_; /// The left side, buffered as chunks rather than concatenated. std::vector
left_buffered_; std::size_t swapped_next_ = 0; diff --git a/tests/test_join.cpp b/tests/test_join.cpp index 981c631e..03d6d0f7 100644 --- a/tests/test_join.cpp +++ b/tests/test_join.cpp @@ -2431,3 +2431,72 @@ TEST_CASE("join: take takes only first, last or any", "[join][take]") { auto err = interpret_error_at_parse("lhs join rhs on k take some;"); CHECK(err.find("'first', 'last' or 'any'") != std::string::npos); } + +TEST_CASE("join: a semi join that keeps no row still reports its columns", "[join][semi][schema]") { + // A consumer builds its result from the chunks an operator hands it, so an + // operator that filters every row away and then yields no chunk at all + // reports a table with no COLUMNS rather than no ROWS. Downstream that is a + // missing schema, not an empty answer: `sum(v)` failed with "column 'v' not + // found in input" for a column the input plainly has. A plain filter that + // keeps nothing has always reported its columns; so must these. + runtime::TableRegistry tables; + + SECTION("no key matches") { + auto out = interpret_expr(R"( +let a = Table { k = [1, 2], v = [10, 20] }; +let b = Table { k = [7, 8] }; +(a semi join b on k)[select { n = count(), s = sum(v) }]; +)", + tables); + REQUIRE(out.rows() == 1); + CHECK(col_i64(out, "n") == std::vector{0}); + CHECK(runtime::is_null(out.columns[out.index.at("s")], 0)); + } + + SECTION("the left side is already empty") { + auto out = interpret_expr(R"( +let a = Table { k = [1, 2], v = [10, 20] }; +let b = Table { k = [1, 2] }; +(a[filter v > 999] semi join b on k)[select { n = count(), s = sum(v) }]; +)", + tables); + REQUIRE(out.rows() == 1); + CHECK(col_i64(out, "n") == std::vector{0}); + } + + SECTION("an anti join that removes everything") { + auto out = interpret_expr(R"( +let a = Table { k = [1, 2], v = [10, 20] }; +let b = Table { k = [1, 2] }; +(a anti join b on k)[select { n = count(), s = sum(v) }]; +)", + tables); + REQUIRE(out.rows() == 1); + CHECK(col_i64(out, "n") == std::vector{0}); + } + + SECTION("the swapped path, whose right side is over the buffering threshold") { + // Above 65536 right rows the operator buffers the left and filters it + // from the other side -- a second place the last chunk can vanish. + auto out = interpret_expr(R"( +let big = Table(70000)[update { k = 1 }]; +let small = Table { k = [7, 8], v = [10, 20] }; +(small semi join big on k)[select { n = count(), s = sum(v) }]; +)", + tables); + REQUIRE(out.rows() == 1); + CHECK(col_i64(out, "n") == std::vector{0}); + } + + SECTION("a surviving row is still passed through") { + auto out = interpret_expr(R"( +let a = Table { k = [1, 2], v = [10, 20] }; +let b = Table { k = [2] }; +(a semi join b on k)[select { n = count(), s = sum(v) }]; +)", + tables); + REQUIRE(out.rows() == 1); + CHECK(col_i64(out, "n") == std::vector{1}); + CHECK(col_i64(out, "s") == std::vector{20}); + } +} From eb6934ad3b47aa5c0328421775251ebcedf3a455 Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Sun, 20 Sep 2026 16:46:30 +0200 Subject: [PATCH 14/42] Use SchemaCarrier in the semi/anti join instead of its own copy The previous commit gave ChunkedSemiAntiJoinOperator a hand-written schema fallback. `SchemaCarrier` already existed in map_chunked.cpp, where five operators use it for exactly this, and its comment describes the same failure the fix was chasing: "an operator that emits no chunk emits no schema either ... fails with 'unknown column' on what is really just an empty input". The membership joins had simply never adopted it. The copy was also worse. `SchemaCarrier` keeps the held chunk's `ChunkIdentity`; the hand-written one dropped it, so the fallback chunk went downstream with sequence and row offset zeroed. The streaming path now carries the input chunk's identity. The swapped path cannot: it buffers Tables, and the conversion drops identity before the operator sees it, so the default stands and says so. `SchemaCarrier`, `ChunkIdentity`, `chunk_identity_of` and the identity- preserving `table_to_chunk` move from map_chunked.cpp's anonymous namespace to chunk_conversion_internal.hpp, which owns the chunk/table boundary and which both callers already include. `hold` gains `holding()` for a caller whose empty Table costs something to build: `filter_chunk` here reports "nothing survived" as nullopt rather than as an empty table, so there is no zero-row result to hand over and one has to be built from the input's columns -- once, not per chunk. No behaviour change beyond the identity, and 9 fewer lines. Full suite and parity green. Co-Authored-By: Claude Opus 5 --- src/runtime/chunk_conversion_internal.hpp | 62 +++++++++++++++++++++++ src/runtime/map_chunked.cpp | 58 --------------------- src/runtime/semi_anti_join.cpp | 55 ++++++++------------ 3 files changed, 83 insertions(+), 92 deletions(-) diff --git a/src/runtime/chunk_conversion_internal.hpp b/src/runtime/chunk_conversion_internal.hpp index c29c896b..a11cc207 100644 --- a/src/runtime/chunk_conversion_internal.hpp +++ b/src/runtime/chunk_conversion_internal.hpp @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -19,6 +20,67 @@ namespace ibex::runtime { [[nodiscard]] auto chunk_to_table(Chunk chunk) -> Table; [[nodiscard]] auto table_to_chunk(Table table) -> Chunk; +/// Morsel identity survives every one-input/one-output parallel-map operator. +/// It is intentionally separate from Table metadata: sequence/row offset are +/// executor transport state, never user-visible table properties. +struct ChunkIdentity { + std::uint64_t sequence = 0; + std::size_t row_offset = 0; +}; + +[[nodiscard]] inline auto chunk_identity_of(const Chunk& chunk) -> ChunkIdentity { + return ChunkIdentity{.sequence = chunk.sequence, .row_offset = chunk.row_offset}; +} + +[[nodiscard]] inline auto table_to_chunk(Table table, ChunkIdentity identity) -> Chunk { + auto chunk = table_to_chunk(std::move(table)); + chunk.sequence = identity.sequence; + chunk.row_offset = identity.row_offset; + return chunk; +} + +/// 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 +/// all, and anything downstream that names a column — a join looking for its key, +/// a filter for the value it compares — fails with "unknown column" on what is +/// really just an empty input. +/// +/// 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. + void hold(Table&& empty, ChunkIdentity identity = {}) { + if (!held_.has_value() && !empty.columns.empty()) { + held_ = Held{.table = std::move(empty), .identity = identity}; + } + } + /// Whether a schema is already held, for a caller whose empty Table costs + /// something to build and who would otherwise build one per chunk. + [[nodiscard]] auto holding() const noexcept -> bool { return held_.has_value(); } + void emitted() { emitted_ = true; } + /// The held chunk — once, and only if nothing else was ever emitted. + [[nodiscard]] auto release() -> std::optional { + if (emitted_ || !held_.has_value()) { + return std::nullopt; + } + emitted_ = true; + return table_to_chunk(std::move(held_->table), held_->identity); + } + + private: + struct Held { + Table table; + ChunkIdentity identity; + }; + std::optional held_; + bool emitted_ = false; +}; + /// Append `src`'s validity for `src_rows` rows onto `dst`, which currently /// describes `dst_rows` rows. /// diff --git a/src/runtime/map_chunked.cpp b/src/runtime/map_chunked.cpp index 4c253b3c..aa1c1e46 100644 --- a/src/runtime/map_chunked.cpp +++ b/src/runtime/map_chunked.cpp @@ -47,64 +47,6 @@ namespace ibex::runtime { namespace { -/// Morsel identity survives every one-input/one-output parallel-map operator. -/// It is intentionally separate from Table metadata: sequence/row offset are -/// executor transport state, never user-visible table properties. -struct ChunkIdentity { - std::uint64_t sequence = 0; - std::size_t row_offset = 0; -}; - -[[nodiscard]] auto chunk_identity_of(const Chunk& chunk) -> ChunkIdentity { - return ChunkIdentity{.sequence = chunk.sequence, .row_offset = chunk.row_offset}; -} - -[[nodiscard]] auto table_to_chunk(Table table, ChunkIdentity identity) -> Chunk { - auto chunk = table_to_chunk(std::move(table)); - chunk.sequence = identity.sequence; - chunk.row_offset = identity.row_offset; - return chunk; -} - -/// 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 -/// all, and anything downstream that names a column — a join looking for its key, -/// a filter for the value it compares — fails with "unknown column" on what is -/// really just an empty input. -/// -/// 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. - void hold(Table&& empty, ChunkIdentity identity = {}) { - if (!held_.has_value() && !empty.columns.empty()) { - held_ = Held{.table = std::move(empty), .identity = identity}; - } - } - void emitted() { emitted_ = true; } - /// The held chunk — once, and only if nothing else was ever emitted. - [[nodiscard]] auto release() -> std::optional { - if (emitted_ || !held_.has_value()) { - return std::nullopt; - } - emitted_ = true; - return table_to_chunk(std::move(held_->table), held_->identity); - } - - private: - struct Held { - Table table; - ChunkIdentity identity; - }; - std::optional held_; - 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 diff --git a/src/runtime/semi_anti_join.cpp b/src/runtime/semi_anti_join.cpp index 5db7f86c..198c84a5 100644 --- a/src/runtime/semi_anti_join.cpp +++ b/src/runtime/semi_anti_join.cpp @@ -76,7 +76,10 @@ class ChunkedSemiAntiJoinOperator final : public Operator { // time went 113ms -> 211ms, which was that copy and nothing else. if (swapped_) { while (swapped_next_ < left_buffered_.size()) { - note_schema(left_buffered_[swapped_next_]); + // Buffering happened in `initialize`, which converted each + // chunk to a Table and with it dropped the transport identity, + // so the fallback chunk takes the default. + hold_schema(left_buffered_[swapped_next_], ChunkIdentity{}); auto filtered = filter_chunk(std::move(left_buffered_[swapped_next_++])); if (!filtered.has_value()) { if (probe_error_.has_value()) { @@ -84,11 +87,11 @@ class ChunkedSemiAntiJoinOperator final : public Operator { } continue; } - emitted_ = true; + schema_.emitted(); return std::optional{table_to_chunk(std::move(*filtered))}; } left_buffered_.clear(); - return end_of_stream(); + return schema_.release(); } while (true) { @@ -97,11 +100,12 @@ class ChunkedSemiAntiJoinOperator final : public Operator { return std::unexpected(std::move(chunk_res.error())); } if (!chunk_res.value().has_value()) { - return end_of_stream(); + return schema_.release(); } + const auto identity = chunk_identity_of(*chunk_res.value()); Table t = chunk_to_table(std::move(*chunk_res.value())); - note_schema(t); + hold_schema(t, identity); auto filtered = filter_chunk(std::move(t)); if (!filtered.has_value()) { // `nullopt` alone means "no rows survive this chunk"; an error @@ -111,16 +115,19 @@ class ChunkedSemiAntiJoinOperator final : public Operator { } continue; } - emitted_ = true; + schema_.emitted(); return std::optional{table_to_chunk(std::move(*filtered))}; } } private: - /// Remember the left's column layout, once, so the operator can still say - /// what its output looks like when no row survives. - void note_schema(const Table& left_chunk) { - if (empty_template_.has_value() || left_chunk.columns.empty()) { + /// Offer the left's column layout as the schema of last resort, once. + /// `filter_chunk` reports "no rows survived" as nullopt rather than as an + /// empty table, so unlike the map operators there is no zero-row result to + /// hand over -- it is built from the input's columns instead, and only + /// while nothing is held, since building one per chunk would not be free. + void hold_schema(const Table& left_chunk, ChunkIdentity identity) { + if (schema_.holding() || left_chunk.columns.empty()) { return; } Table empty; @@ -131,26 +138,7 @@ class ChunkedSemiAntiJoinOperator final : public Operator { // Every order-sensitive claim is vacuously true of a row-less table, // so the left's properties carry over as they are. empty.set_properties(left_chunk.properties()); - empty_template_ = std::move(empty); - } - - /// End the stream, but never by emitting nothing at all: a consumer builds - /// its result from the chunks it receives, so an operator that filters - /// every row away and then yields no chunk reports a table with no COLUMNS - /// rather than no ROWS. Downstream that is not an empty answer, it is a - /// missing schema -- `aggregate: column 'v' not found in input` for a - /// column the input plainly has. One row-less chunk carrying the left's - /// layout is the difference, and it is emitted only when nothing else was. - /// - /// A left that yields no chunk at all leaves nothing to take a layout from, - /// so that case is unchanged: the schema has to come from somewhere, and - /// the operator's own inputs are the only place it could. - auto end_of_stream() -> std::expected, std::string> { - if (emitted_ || !empty_template_.has_value()) { - return std::optional{}; - } - emitted_ = true; - return std::optional{table_to_chunk(std::move(*empty_template_))}; + schema_.hold(std::move(empty), identity); } // Above this many right rows, building a hash set of every right key is the @@ -976,10 +964,9 @@ class ChunkedSemiAntiJoinOperator final : public Operator { const std::vector* keys_; bool initialized_ = false; bool swapped_ = false; - /// Whether any chunk has been handed downstream, and the left's layout to - /// fall back on when none has. See `end_of_stream`. - bool emitted_ = false; - std::optional
empty_template_; + /// The left's layout, kept so a stream that filters every row away still + /// reports its columns rather than no columns at all. + SchemaCarrier schema_; /// The left side, buffered as chunks rather than concatenated. std::vector
left_buffered_; std::size_t swapped_next_ = 0; From e8fa12ef0da5bc0ed2776df15c5d8de746c5e520 Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Sun, 20 Sep 2026 20:41:47 +0200 Subject: [PATCH 15/42] Aggregate only the subquery groups the outer rows key into A decorrelated subquery grouped the WHOLE inner relation while the left join above it read only the groups the outer rows keyed into. On a q17-shaped query -- a selective outer filter over a large inner table -- that is most of the work. The aggregate's input is now semi-joined against the outer's keys. A semi join keeps every inner row whose key appears in the outer, so each surviving group keeps ALL of its rows and its aggregate is unchanged; the groups it drops could never have been matched. The keys come from the outer as it entered the FIRST decorrelation join, not from the join's output. Every such join is a LEFT join, which neither adds nor removes an outer row, so the captured column holds the same values either way -- but cloning the output would re-run the previous subquery's aggregate to find them, and again for each subquery after it. Taking them from below costs one evaluation of the outer however many subqueries the filter holds. TPC-H q17 written in correlated form, SF-8 on eight cores, interleaved: 1.60s -> 0.60s median, same answer. The same query decorrelated by hand is 0.12s; the rest is the inner relation being scanned again for the subquery and once more for the keys, which sharing a scan would fix and this does not. Not gated on cost. The shape that loses is the inverted one -- a large outer and a small inner -- where cloning the outer buys little. Ranking that needs the source statistics the optimizer is given and the lowerer is not, so a gate belongs with the rewrite moved into a pass, where it would also catch the general case: any left join over an aggregate grouped by the join key, however it was written. Co-Authored-By: Claude Opus 5 --- SPEC.md | 3 +- src/parser/lower.cpp | 80 +++++++++++++++++++++++++++++++++++++++++--- tests/test_lower.cpp | 80 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 6 deletions(-) diff --git a/SPEC.md b/SPEC.md index 4b1b218f..7ed125af 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2455,7 +2455,8 @@ one row, so an uncorrelated subquery can never multiply the rows it filters. **Evaluation.** The subquery is *decorrelated*, never run once per outer row. A correlated one becomes an aggregate grouped by the captured column, left-joined back onto the outer rows; an uncorrelated one becomes a cross join against its -single row. That is also why a source used by both the outer query and the +single row. The correlated form aggregates only the groups the outer rows key +into, not every group of the inner relation. 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. diff --git a/src/parser/lower.cpp b/src/parser/lower.cpp index e0f91e26..079f61d4 100644 --- a/src/parser/lower.cpp +++ b/src/parser/lower.cpp @@ -3327,7 +3327,17 @@ class Lowerer { /// Filter(ps_supplycost == __ibex_scalar_0) /// Join(Left, on p_partkey = ps_partkey) /// Filter(local)(outer) - /// Aggregate(by ps_partkey: min(ps_supplycost) as __ibex_scalar_0)(inner) + /// Aggregate(by ps_partkey: min(ps_supplycost) as __ibex_scalar_0) + /// Join(Semi, on ps_partkey = p_partkey) + /// (inner) + /// Project(p_partkey)(outer) + /// + /// The semi join is the key restriction: the aggregate would otherwise + /// group the whole inner relation while the left join reads only the + /// groups the outer keys into. Its right side is the outer as it entered + /// the first decorrelation join, which is the same set of keys -- a left + /// join neither adds nor removes an outer row -- and costs one evaluation + /// however many subqueries the filter holds. /// /// The join reads each side's key under its own name and folds the pair /// into one output column. An earlier shape renamed the inner key to the @@ -3389,10 +3399,18 @@ class Lowerer { input = std::move(filter); } + // The outer as it enters the FIRST decorrelation join, kept for the + // key restriction each subquery applies to its own input. Every join + // added below is a LEFT join, which neither adds nor removes an outer + // row, so the captured column holds the same values here as it does + // above them -- and taking the keys from here means the second + // subquery does not re-run the first one's aggregate to find them. + ir::NodePtr key_source = clone_node(*input); + std::vector residual; residual.reserve(correlated.size()); for (const auto* conjunct : correlated) { - auto rewritten = decorrelate(*conjunct, input); + auto rewritten = decorrelate(*conjunct, input, *key_source); if (!rewritten.has_value()) { return std::unexpected(rewritten.error()); } @@ -3415,7 +3433,7 @@ class Lowerer { /// Join the subquery in `conjunct` onto `input` and return the comparison /// rewritten against the generated scalar column. - auto decorrelate(const Expr& conjunct, ir::NodePtr& input) + auto decorrelate(const Expr& conjunct, ir::NodePtr& input, const ir::Node& key_source) -> std::expected { const auto* comparison = std::get_if(&conjunct.node); if (comparison == nullptr || !is_compare_op(comparison->op)) { @@ -3446,7 +3464,7 @@ class Lowerer { } const std::string alias = next_scalar_alias(*input); - auto subplan = lower_scalar_subquery(*subquery, *input, alias); + auto subplan = lower_scalar_subquery(*subquery, *input, key_source, alias); if (!subplan.has_value()) { return std::unexpected(subplan.error()); } @@ -3499,7 +3517,7 @@ class Lowerer { /// Without one the subquery is a single value: the aggregate is ungrouped /// and the returned keys are empty, for the caller's cross join. auto lower_scalar_subquery(const CallExpr& call, const ir::Node& outer_input, - const std::string& alias) + const ir::Node& key_source, const std::string& alias) -> std::expected { const Expr& argument = unwrap_group(*call.args.front()); if (contains_call(argument, "scalar")) { @@ -3644,6 +3662,42 @@ class Lowerer { } auto plan = std::move(lowered.value()); + // Restrict the subquery to the keys the outer rows actually use. + // + // Without this the aggregate groups the WHOLE inner relation, while + // the left join below reads only the groups the outer rows key into. + // On a q17-shaped query -- a selective outer filter over a large inner + // table -- that is most of the work: at TPC-H SF-8 the correlated form + // measured 1.6s against 0.12s for the same query decorrelated by hand, + // and the difference is groups nobody reads. + // + // A semi join keeps every inner row whose key appears in the outer, so + // each surviving group keeps ALL of its rows and its aggregate is + // unchanged; groups it drops could never have been matched. The outer + // subtree is evaluated a second time to supply the keys, which is why + // this is worth it only when the outer is the selective side -- the + // shape a correlated subquery is normally written in. + if (!captures.empty()) { + if (auto* agg = find_aggregate(plan.get()); + agg != nullptr && agg->children().size() == 1) { + std::vector semi_keys; + semi_keys.reserve(captures.size()); + std::vector key_columns; + key_columns.reserve(captures.size()); + for (const auto& capture : captures) { + semi_keys.emplace_back(capture.inner, capture.outer); + key_columns.push_back(ir::ColumnRef{.name = capture.outer}); + } + auto outer_keys = builder_.project(std::move(key_columns)); + outer_keys->add_child(clone_node(key_source)); + + auto semi = builder_.join(ir::JoinKind::Semi, std::move(semi_keys)); + semi->add_child(std::move(agg->mutable_children()[0])); + semi->add_child(std::move(outer_keys)); + agg->mutable_children()[0] = std::move(semi); + } + } + // The aggregate keys carry the inner names and the outer query its // own; the join reads each side natively and folds the pair into one // output column, which is what the rename here used to achieve. @@ -3667,6 +3721,22 @@ class Lowerer { .plan = std::move(plan), .keys = std::move(keys), .counts = counts}; } + /// The AggregateNode inside a freshly lowered subquery plan, or null. + /// The grouped block lowers to an Aggregate, sometimes under a Project, so + /// this walks the single-child spine rather than assuming a shape. + static auto find_aggregate(ir::Node* node) -> ir::AggregateNode* { + while (node != nullptr) { + if (node->kind() == ir::NodeKind::Aggregate) { + return &ir::node_cast(*node); + } + if (node->children().size() != 1) { + return nullptr; + } + node = node->mutable_children()[0].get(); + } + return nullptr; + } + /// Read `inner_column == outer(outer_column)` (either way round). static auto parse_capture(const Expr& conjunct) -> std::expected { const auto* equality = std::get_if(&conjunct.node); diff --git a/tests/test_lower.cpp b/tests/test_lower.cpp index 96ed9759..b7d0b856 100644 --- a/tests/test_lower.cpp +++ b/tests/test_lower.cpp @@ -1307,6 +1307,86 @@ parts[filter REQUIRE(aliases == std::vector{"__ibex_scalar_0", "__ibex_scalar_1"}); } +TEST_CASE("Lower restricts a subquery to the keys the outer rows use") { + // Without this the aggregate groups the whole inner relation while the + // left join reads only the groups the outer keys into. A semi join against + // the outer's keys drops the rest; each surviving group keeps ALL its rows, + // so no aggregate changes. + auto result = lower_source(std::string(kCorrelatedSources) + + R"( +parts[filter p_partkey == scalar( + supply[filter ps_partkey == outer(p_partkey), select { m = min(ps_cost) }] +)]; +)"); + REQUIRE(result.has_value()); + + const auto* join = find_join(*result.value()); + REQUIRE(join != nullptr); + REQUIRE(join->kind() == ir::JoinKind::Left); + + // Under the left join's aggregate sits the restriction. + const auto* aggregate = as_node(join->children()[1].get()); + REQUIRE(aggregate != nullptr); + REQUIRE(aggregate->children().size() == 1); + // The restriction sits under the aggregate. Its exact depth is not the + // point -- later passes move projections around it -- so this looks for + // the semi join anywhere below, and checks how it is keyed. + const ir::JoinNode* semi = nullptr; + const auto find_semi = [&](auto&& self, const ir::Node& n) -> void { + if (const auto* j = dynamic_cast(&n); + j != nullptr && j->kind() == ir::JoinKind::Semi) { + semi = j; + return; + } + for (const auto& c : n.children()) { + if (c != nullptr && semi == nullptr) { + self(self, *c); + } + } + }; + find_semi(find_semi, *aggregate); + REQUIRE(semi != nullptr); + // Inner column on the left, the outer's own name on the right. + REQUIRE(semi->keys().size() == 1); + CHECK(semi->keys()[0].left == "ps_partkey"); + CHECK(semi->keys()[0].right == "p_partkey"); +} + +TEST_CASE("Lower takes a subquery's keys from the outer below any earlier join") { + // Two subqueries: the second one's key source must be the outer as it + // entered the FIRST decorrelation join, not the join's output. Every such + // join is a LEFT join, so the captured column holds the same values either + // way -- but cloning the output would re-run the first subquery's + // aggregate to find them, and again for each subquery after it. + auto result = lower_source(std::string(kCorrelatedSources) + + R"( +parts[filter + p_partkey == scalar(supply[filter ps_partkey == outer(p_partkey), select { a = min(ps_cost) }]) + && p_partkey != scalar(supply[filter ps_partkey == outer(p_partkey), select { b = max(ps_cost) }]) +]; +)"); + REQUIRE(result.has_value()); + + // One aggregate per subquery, plus one per restriction -- and no copy of + // an earlier subquery's aggregate, which is what compounding would add. + std::vector aliases; + const auto walk = [&](auto&& self, const ir::Node& node) -> void { + if (const auto* aggregate = dynamic_cast(&node)) { + for (const auto& spec : aggregate->aggregations()) { + aliases.push_back(spec.alias); + } + } + for (const auto& child : node.children()) { + if (child != nullptr) { + self(self, *child); + } + } + }; + walk(walk, *result.value()); + std::ranges::sort(aliases); + REQUIRE(aliases == std::vector{"__ibex_scalar_0", "__ibex_scalar_1"}); +} + TEST_CASE("Lower decorrelates two captures that share one outer column") { // `a == outer(x) && b == outer(x)` was rejected as a "duplicate capture": // decorrelation renamed each inner key to its outer name, and two columns From 833545622b12cb013767997927a9a267e153ad44 Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Sun, 20 Sep 2026 21:07:47 +0200 Subject: [PATCH 16/42] Restrict a subquery's keys only when the outer can be replayed The key restriction collects the outer's keys by evaluating the outer a SECOND time. That is only sound if the second evaluation produces the same rows, and nothing checked it. An outer that draws from an RNG gives one set of keys to the join and a different set to the restriction, so the semi join keeps the groups for the second draw and the rows built from the first match nothing: let o = Table { j = [1, 2, 3] }[update { k = Int64(floor(rand_uniform(1.0, 10.99))) }]; o[filter 0 < scalar(wide[filter a == outer(k), select { m = min(v) }])] Every drawn key exists in `wide`, so all three rows must survive. Transpiled, this answered 0: `rand_uniform` was emitted twice and drawn twice. The interpreter got 3 only because it materializes a `let`, which made this an engine divergence as well as a wrong answer, and no parity case had a nondeterministic outer to catch it. `ir::is_replayable_subplan` decides it, as an allow-list: a node kind nobody has classified is unsafe rather than assumed pure, and so is a call whose callee is not a known built-in, since an extern is left unclassified on purpose. `FnKind::Generator` is refused outright. This costs the optimization on TPC-H q17, 0.60s back to 1.49s. At lowering a reader is still an ExternCall -- `hoist_extern_sources` turns it into a Scan later, in the batch planner -- so the guard cannot tell a parquet read from a plugin and refuses it. That is the honest answer here and the reason to run this as a pass over the planned tree instead, where the scans and the source statistics both exist. Co-Authored-By: Claude Opus 5 --- include/ibex/ir/replayable.hpp | 33 +++++ src/ir/CMakeLists.txt | 1 + src/ir/replayable.cpp | 117 ++++++++++++++++++ src/parser/lower.cpp | 9 +- .../correlated_nondeterministic_outer.ibex | 15 +++ tests/test_lower.cpp | 30 +++++ 6 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 include/ibex/ir/replayable.hpp create mode 100644 src/ir/replayable.cpp create mode 100644 tests/parity/cases/correlated_nondeterministic_outer.ibex diff --git a/include/ibex/ir/replayable.hpp b/include/ibex/ir/replayable.hpp new file mode 100644 index 00000000..4ff0d05a --- /dev/null +++ b/include/ibex/ir/replayable.hpp @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Bob Jansen + +#pragma once + +#include + +namespace ibex::ir { + +/// True if `node`'s subtree may be evaluated a SECOND time and be relied on to +/// produce the same rows. +/// +/// A rewrite that clones a subtree is really asking for this. Cloning is not +/// the cost it looks like -- the clone is evaluated, so anything the subtree +/// does, it does twice, and anything it draws, it draws again. A plan that +/// reads `rand_uniform` into a key column answers one way in the original and +/// another in the copy, and a rewrite that compares the two is then comparing +/// two different tables. +/// +/// The test is an ALLOW-list, so a node kind nobody has thought about is +/// unsafe rather than silently assumed pure, and the same for a call whose +/// callee is not a known built-in: an extern is left unclassified on purpose +/// (see `BuiltinFunctionInfo`) and planning must not assume it is +/// reproducible. `FnKind::Generator` (`rand_*`) is refused outright. +/// +/// This says nothing about COST. A subtree can be perfectly replayable and +/// still far too expensive to evaluate twice; that is the caller's judgement. +[[nodiscard]] auto is_replayable_subplan(const Node& node) -> bool; + +/// True if `expr` may be evaluated a second time for the same reasons. +[[nodiscard]] auto is_replayable_expr(const Expr& expr) -> bool; + +} // namespace ibex::ir diff --git a/src/ir/CMakeLists.txt b/src/ir/CMakeLists.txt index 98a27bc6..3db349f2 100644 --- a/src/ir/CMakeLists.txt +++ b/src/ir/CMakeLists.txt @@ -24,6 +24,7 @@ add_library(ibex_ir STATIC distinct_key_reduction.cpp group_key_reduction.cpp scan_predicates.cpp + replayable.cpp schema.cpp ) add_library(Ibex::ir ALIAS ibex_ir) diff --git a/src/ir/replayable.cpp b/src/ir/replayable.cpp new file mode 100644 index 00000000..fdb92945 --- /dev/null +++ b/src/ir/replayable.cpp @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Bob Jansen + +#include +#include +#include + +#include +#include + +namespace ibex::ir { +namespace { + +auto replayable_call(const CallExpr& call) -> bool { + // An unknown callee is an extern: unclassified on purpose, so nothing may + // be assumed about repeating it. A generator draws from a shared RNG + // stream, so a second evaluation gives different values by design. + const auto kind = fn_kind(call.callee); + if (!kind.has_value() || *kind == FnKind::Generator) { + return false; + } + return std::ranges::all_of(call.args, + [](const ExprPtr& arg) { return is_replayable_expr(*arg); }) && + std::ranges::all_of(call.named_args, [](const NamedArg& named) { + return is_replayable_expr(*named.value); + }); +} + +} // namespace + +auto is_replayable_expr(const Expr& expr) -> bool { + return std::visit( + [](const auto& node) -> bool { + using T = std::decay_t; + if constexpr (std::is_same_v || std::is_same_v) { + return true; + } else if constexpr (std::is_same_v) { + return replayable_call(node); + } else if constexpr (std::is_same_v || std::is_same_v) { + return is_replayable_expr(*node.left) && is_replayable_expr(*node.right); + } else if constexpr (std::is_same_v) { + return is_replayable_expr(*node.left) && + (node.right == nullptr || is_replayable_expr(*node.right)); + } else if constexpr (std::is_same_v) { + return is_replayable_expr(*node.operand); + } else { + // RankExpr and anything added later: unproven, so unsafe. + return false; + } + }, + expr.node); +} + +auto is_replayable_subplan(const Node& node) -> bool { + switch (node.kind()) { + // No expression of their own, and nothing but their input decides + // what they produce. + case NodeKind::Scan: + case NodeKind::Project: + case NodeKind::Rename: + case NodeKind::Distinct: + case NodeKind::Order: + case NodeKind::Head: + case NodeKind::Tail: + case NodeKind::Ascribe: + break; + case NodeKind::Filter: + if (!is_replayable_expr(node_cast(node).predicate())) { + return false; + } + break; + case NodeKind::Join: { + const auto& join = node_cast(node); + if (join.predicate().has_value() && !is_replayable_expr(*join.predicate())) { + return false; + } + break; + } + case NodeKind::Aggregate: + // Group keys and aggregate inputs are column references; the + // function is an AggFunc, not a callee that could be a generator. + break; + case NodeKind::Update: { + const auto& update = node_cast(node); + if (!std::ranges::all_of(update.fields(), [](const FieldSpec& field) { + return is_replayable_expr(field.expr); + })) { + return false; + } + break; + } + case NodeKind::Construct: { + // A literal column's values are literals, but an expression column + // holds a whole sub-plan that `children()` does not report. + const auto& construct = node_cast(node); + if (construct.row_count().has_value() && !is_replayable_expr(*construct.row_count())) { + return false; + } + if (!std::ranges::all_of(construct.columns(), [](const ConstructColumn& column) { + return column.expr_node == nullptr || is_replayable_subplan(*column.expr_node); + })) { + return false; + } + break; + } + default: + // Everything else -- an extern call, a stream, a model fit, a map + // over plugin code -- is either effectful or unclassified. A node + // kind added later lands here too, which is the point. + return false; + } + return std::ranges::all_of(node.children(), [](const NodePtr& child) { + return child == nullptr || is_replayable_subplan(*child); + }); +} + +} // namespace ibex::ir diff --git a/src/parser/lower.cpp b/src/parser/lower.cpp index 079f61d4..0445cb3b 100644 --- a/src/parser/lower.cpp +++ b/src/parser/lower.cpp @@ -66,6 +66,7 @@ #include #include #include +#include #include #include #include @@ -3677,7 +3678,13 @@ class Lowerer { // subtree is evaluated a second time to supply the keys, which is why // this is worth it only when the outer is the selective side -- the // shape a correlated subquery is normally written in. - if (!captures.empty()) { + // Only when the keys can be collected without changing the answer. + // The restriction evaluates the outer a second time, so a plan that + // draws from an RNG or calls out to a plugin would be asked for its + // keys and give a DIFFERENT set than the join above is built from -- + // dropping groups the real outer rows need. It measured as + // `Table(n)[update { k = rand_uniform(..) }]` losing every row. + if (!captures.empty() && ir::is_replayable_subplan(key_source)) { if (auto* agg = find_aggregate(plan.get()); agg != nullptr && agg->children().size() == 1) { std::vector semi_keys; diff --git a/tests/parity/cases/correlated_nondeterministic_outer.ibex b/tests/parity/cases/correlated_nondeterministic_outer.ibex new file mode 100644 index 00000000..77287132 --- /dev/null +++ b/tests/parity/cases/correlated_nondeterministic_outer.ibex @@ -0,0 +1,15 @@ +// A correlated subquery whose OUTER draws from an RNG. The key restriction +// that trims the subquery's groups works by evaluating the outer a second time +// to collect its keys -- which cannot be done here, because the second draw is +// a different one. Transpiled without that guard this answered 0 of 3 rows: +// `rand_uniform` was emitted twice, the semi join kept the groups for the +// second draw's keys, and the rows built from the first draw matched nothing. +// +// Every drawn key is in `wide`, so all three rows must survive however the +// draw falls -- the count is stable even though the values are not. +let wide = Table { a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + v = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }; +let o = Table { j = [1, 2, 3] }[update { k = Int64(floor(rand_uniform(1.0, 10.99))) }]; +o[filter 0 < scalar(wide[filter a == outer(k), select { m = min(v) }])][ + select { n = count() } +]; diff --git a/tests/test_lower.cpp b/tests/test_lower.cpp index b7d0b856..99221a58 100644 --- a/tests/test_lower.cpp +++ b/tests/test_lower.cpp @@ -1352,6 +1352,36 @@ parts[filter p_partkey == scalar( CHECK(semi->keys()[0].right == "p_partkey"); } +TEST_CASE("Lower does not restrict a subquery whose outer cannot be replayed") { + // The restriction collects the outer's keys by evaluating it a SECOND + // time. A plan that draws from an RNG gives a different answer each time, + // so the keys would not be the ones the join above is built from and rows + // would be dropped. Transpiled, this query answered 0 of 3 rows before the + // guard, because `rand_uniform` was emitted twice and drawn twice. + auto result = lower_source(R"( +let wide = Table { a = [1, 2, 3], v = [1, 1, 1] }; +let o = Table { j = [1, 2, 3] }[update { k = Int64(floor(rand_uniform(1.0, 3.99))) }]; +o[filter 0 < scalar(wide[filter a == outer(k), select { m = min(v) }])]; +)"); + REQUIRE(result.has_value()); + + const ir::JoinNode* semi = nullptr; + const auto find_semi = [&](auto&& self, const ir::Node& n) -> void { + if (const auto* j = dynamic_cast(&n); + j != nullptr && j->kind() == ir::JoinKind::Semi) { + semi = j; + return; + } + for (const auto& c : n.children()) { + if (c != nullptr && semi == nullptr) { + self(self, *c); + } + } + }; + find_semi(find_semi, *result.value()); + CHECK(semi == nullptr); +} + TEST_CASE("Lower takes a subquery's keys from the outer below any earlier join") { // Two subqueries: the second one's key source must be the outer as it // entered the FIRST decorrelation join, not the join's output. Every such From a7b5fcc196e9f6ffab8a3835fb7c3b2dac1028f8 Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Sun, 20 Sep 2026 21:33:20 +0200 Subject: [PATCH 17/42] Restrict a probed aggregate as a costed pass, not at lowering The key restriction moves out of decorrelation and into `restrict_aggregates_to_probed_keys`, run over the planned tree. It had to move to work at all: at lowering a reader is still an ExternCall, so the replayability guard could not tell a parquet read from a plugin call and refused every real query -- q17 went back to 1.49s. After `hoist_extern_sources` it is a Scan, and the source statistics exist too. Matching the plan rather than the syntax generalises it. Any Left or Inner join whose right side is an aggregate grouped by the join key qualifies, however the query was written, and a hand-written one does: TPC-H q20 is 0.41s -> 0.33s. The correlated q17 is back to 0.58s from 1.60s. Nothing else in the 22 moved -- q19 looked 12% worse on two samples and was 0.24s either way on six. The gate counts GROUPS, not rows. The saving is groups the aggregate never builds, so the question is whether the probe side keys into fewer distinct values than the aggregate would group. Costing it in rows says the opposite on the query it was written for: q17 reads the same lineitem table on both sides, so by rows neither is smaller, while by distinct keys the probe wants 25k of 400k groups. `distinct_estimate` stops at a join and the probe side usually is one, so the descent is local to this pass rather than a change to a function join ordering costs itself against. `is_replayable_subplan` becomes `clone_replayable_subplan`: the copy and the safety test are now one switch, so a kind that cannot be copied is exactly a kind that must not be replayed and the two cannot drift. Co-Authored-By: Claude Opus 5 --- SPEC.md | 3 +- include/ibex/ir/probed_key_restriction.hpp | 39 ++++ include/ibex/ir/replayable.hpp | 38 +-- src/ir/CMakeLists.txt | 1 + src/ir/probed_key_restriction.cpp | 255 +++++++++++++++++++++ src/ir/replayable.cpp | 123 +++++++--- src/parser/lower.cpp | 93 +------- src/repl/repl.cpp | 6 + tests/CMakeLists.txt | 1 + tests/test_ir_probed_key_restriction.cpp | 149 ++++++++++++ tests/test_lower.cpp | 110 --------- 11 files changed, 575 insertions(+), 243 deletions(-) create mode 100644 include/ibex/ir/probed_key_restriction.hpp create mode 100644 src/ir/probed_key_restriction.cpp create mode 100644 tests/test_ir_probed_key_restriction.cpp diff --git a/SPEC.md b/SPEC.md index 7ed125af..4b1b218f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2455,8 +2455,7 @@ one row, so an uncorrelated subquery can never multiply the rows it filters. **Evaluation.** The subquery is *decorrelated*, never run once per outer row. A correlated one becomes an aggregate grouped by the captured column, left-joined back onto the outer rows; an uncorrelated one becomes a cross join against its -single row. The correlated form aggregates only the groups the outer rows key -into, not every group of the inner relation. That is also why a source used by both the outer query and the +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. diff --git a/include/ibex/ir/probed_key_restriction.hpp b/include/ibex/ir/probed_key_restriction.hpp new file mode 100644 index 00000000..e72b3428 --- /dev/null +++ b/include/ibex/ir/probed_key_restriction.hpp @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Bob Jansen + +#pragma once + +#include +#include + +namespace ibex::ir { + +/// Restrict a grouped aggregate to the keys the join above it probes with. +/// +/// Join(Left|Inner, on k = g, X, Aggregate(by g, ...)) +/// -> +/// Join(Left|Inner, on k = g, X, Aggregate(by g, ...) +/// Join(Semi, on g = k, , Project(k)(X))) +/// +/// The aggregate otherwise groups its WHOLE input while the join reads only +/// the groups `X` keys into. A semi join keeps every input row whose key +/// appears in `X`, so each surviving group keeps ALL of its rows and its +/// aggregate is unchanged; the groups it drops could never have been matched. +/// +/// A decorrelated `scalar(...)` subquery is the shape this was written for, +/// but nothing here knows that: it matches the plan, so a join written by hand +/// against a grouped aggregate gets the same treatment. +/// +/// **What it costs.** `X` is evaluated a second time to supply the keys, so +/// this pays only when `X` keys into fewer distinct values than the aggregate +/// would otherwise group -- the saving is groups never built, so the estimate +/// that decides it counts GROUPS, not rows. Without one for both sides the +/// rewrite is declined rather than guessed at. +/// +/// **What it requires.** `X` must be replayable — evaluated twice and give the +/// same rows. Run this AFTER `hoist_extern_sources`: a reader is an ExternCall +/// until then, indistinguishable from a plugin, and would be refused. +[[nodiscard]] auto restrict_aggregates_to_probed_keys(NodePtr root, const SourceStats& stats) + -> NodePtr; + +} // namespace ibex::ir diff --git a/include/ibex/ir/replayable.hpp b/include/ibex/ir/replayable.hpp index 4ff0d05a..1539ca7b 100644 --- a/include/ibex/ir/replayable.hpp +++ b/include/ibex/ir/replayable.hpp @@ -5,27 +5,37 @@ #include +#include + namespace ibex::ir { -/// True if `node`'s subtree may be evaluated a SECOND time and be relied on to -/// produce the same rows. +/// A deep copy of `node`'s subtree, or null when any part of it must not be +/// evaluated a second time. +/// +/// A rewrite that clones a subtree is really asking whether it may be replayed. +/// Cloning is not the cost it looks like -- the clone is evaluated, so anything +/// the subtree does, it does twice, and anything it draws, it draws again. A +/// plan that reads `rand_uniform` into a key column answers one way in the +/// original and another in the copy, and a rewrite that compares the two is +/// then comparing two different tables. /// -/// A rewrite that clones a subtree is really asking for this. Cloning is not -/// the cost it looks like -- the clone is evaluated, so anything the subtree -/// does, it does twice, and anything it draws, it draws again. A plan that -/// reads `rand_uniform` into a key column answers one way in the original and -/// another in the copy, and a rewrite that compares the two is then comparing -/// two different tables. +/// Copying and the safety test are ONE switch, so a kind that cannot be copied +/// is exactly a kind that must not be replayed and the two cannot drift apart. +/// It is an allow-list: a node kind nobody has classified is refused rather +/// than silently assumed pure, and so is a call whose callee is not a known +/// built-in, since an extern is left unclassified on purpose (see +/// `BuiltinFunctionInfo`) and planning must not assume it is reproducible. +/// `FnKind::Generator` (`rand_*`) is refused outright. /// -/// The test is an ALLOW-list, so a node kind nobody has thought about is -/// unsafe rather than silently assumed pure, and the same for a call whose -/// callee is not a known built-in: an extern is left unclassified on purpose -/// (see `BuiltinFunctionInfo`) and planning must not assume it is -/// reproducible. `FnKind::Generator` (`rand_*`) is refused outright. +/// Note that a reader is an `ExternCall` until `hoist_extern_sources` turns it +/// into a `Scan`, so a caller running before that point will be refused a plan +/// that only reads a file. /// /// This says nothing about COST. A subtree can be perfectly replayable and /// still far too expensive to evaluate twice; that is the caller's judgement. -[[nodiscard]] auto is_replayable_subplan(const Node& node) -> bool; +/// +/// `next` supplies fresh node ids and is advanced. +[[nodiscard]] auto clone_replayable_subplan(const Node& node, std::uint64_t& next) -> NodePtr; /// True if `expr` may be evaluated a second time for the same reasons. [[nodiscard]] auto is_replayable_expr(const Expr& expr) -> bool; diff --git a/src/ir/CMakeLists.txt b/src/ir/CMakeLists.txt index 3db349f2..c2aeda8f 100644 --- a/src/ir/CMakeLists.txt +++ b/src/ir/CMakeLists.txt @@ -24,6 +24,7 @@ add_library(ibex_ir STATIC distinct_key_reduction.cpp group_key_reduction.cpp scan_predicates.cpp + probed_key_restriction.cpp replayable.cpp schema.cpp ) diff --git a/src/ir/probed_key_restriction.cpp b/src/ir/probed_key_restriction.cpp new file mode 100644 index 00000000..b2f529b2 --- /dev/null +++ b/src/ir/probed_key_restriction.cpp @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Bob Jansen + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace ibex::ir { +namespace { + +void collect_max_id(const Node& node, std::uint64_t& max) { + max = std::max(max, node.id().value); + for (const auto& child : node.children()) { + if (child != nullptr) { + collect_max_id(*child, max); + } + } + if (node.kind() == NodeKind::Program) { + const auto& program = node_cast(node); + for (const auto& pre : program.preamble()) { + if (pre != nullptr) { + collect_max_id(*pre, max); + } + } + collect_max_id(program.main_node(), max); + } +} + +/// The join keys that name one of the aggregate's group keys on the right. +/// +/// Only those can be restricted: the semi join runs against the aggregate's +/// INPUT, where a group key still exists under its own name, while an +/// aggregate's OUTPUT column does not exist below it at all. A join that keys +/// on an aggregated value therefore contributes nothing here, and a join that +/// keys on both restricts by the group keys alone -- which is sound, since +/// restricting by fewer keys only keeps more groups. +auto restrictable_keys(const JoinNode& join, const AggregateNode& aggregate) + -> std::vector { + std::vector keys; + for (const auto& key : join.keys()) { + const bool is_group_key = std::ranges::any_of( + aggregate.group_by(), [&](const ColumnRef& group) { return group.name == key.right; }); + if (is_group_key) { + keys.push_back(key); + } + } + return keys; +} + +/// The aggregate the join's right side feeds from, or null. +/// +/// Projection pushdown leaves a Project between the join and the aggregate, so +/// the aggregate is not always the direct child. Only nodes that pass every +/// row through AND leave column names alone are crossed: a Project selects but +/// does not rename, so a group key reaching the join still carries the +/// aggregate's own name for it. A Rename would break that correspondence and +/// stops the descent. +auto aggregate_below(Node& node, const std::vector& key_names) -> AggregateNode* { + Node* current = &node; + while (current != nullptr) { + if (current->kind() == NodeKind::Aggregate) { + return &node_cast(*current); + } + if (current->children().size() != 1) { + return nullptr; + } + switch (current->kind()) { + case NodeKind::Project: + case NodeKind::Ascribe: + break; + case NodeKind::Update: { + // An arithmetic wrapper around the aggregate's value -- q17's + // `0.2 * mean(..)` is one -- passes every row through and + // leaves the group key alone. One that WRITES a key column + // does not, and the name reaching the join would no longer be + // the aggregate's. + const auto& update = node_cast(*current); + const bool writes_a_key = + std::ranges::any_of(update.fields(), + [&](const FieldSpec& field) { + return std::ranges::find(key_names, field.alias) != + key_names.end(); + }) || + std::ranges::any_of(update.tuple_fields(), [&](const TupleFieldSpec& tuple) { + return std::ranges::any_of(tuple.aliases, [&](const std::string& alias) { + return std::ranges::find(key_names, alias) != key_names.end(); + }); + }); + if (writes_a_key) { + return nullptr; + } + break; + } + default: + return nullptr; + } + current = current->mutable_children()[0].get(); + } + return nullptr; +} + +/// True if `input` is already the restriction this pass would add. +auto already_restricted(const Node& input, const std::vector& keys) -> bool { + if (input.kind() != NodeKind::Join) { + return false; + } + const auto& join = node_cast(input); + if (join.kind() != JoinKind::Semi || join.keys().size() != keys.size()) { + return false; + } + return std::ranges::all_of(keys, [&](const JoinKey& key) { + return std::ranges::any_of(join.keys(), [&](const JoinKey& present) { + return present.left == key.right && present.right == key.left; + }); + }); +} + +/// How many distinct values of `column` the probe side can contribute. +/// +/// `distinct_estimate` follows row-wise operators but stops at a join, and the +/// probe side of a correlated subquery is usually a join. Descending is sound +/// as an upper bound: an equijoin's output holds no more distinct values of a +/// key than its SMALLER side, a filter only removes values, and no operator +/// here invents one. Being wrong costs a rewrite that was worth doing, or one +/// that was marginal -- never a wrong answer, since this only decides whether +/// to optimize. +auto probed_distinct(const Node& node, const std::string& column, const SourceStats& stats) + -> std::optional { + if (auto direct = distinct_estimate(node, column, stats); direct.has_value()) { + return direct; + } + std::optional best; + for (const auto& child : node.children()) { + if (child == nullptr) { + continue; + } + if (auto from_child = probed_distinct(*child, column, stats); from_child.has_value()) { + best = best.has_value() ? std::min(*best, *from_child) : *from_child; + } + } + return best; +} + +/// Whether restricting pays. +/// +/// The saving is groups the aggregate never builds, so the question is a count +/// of GROUPS, not of rows: the probe side must key into fewer distinct values +/// than the aggregate would otherwise group. Costing this in rows says the +/// opposite on the query it was written for -- q17 reads the same lineitem +/// table on both sides, so by rows neither side is smaller, while by distinct +/// keys the probe wants 25k of 400k groups. +/// +/// An estimate neither side can supply is not a licence to guess: the rewrite +/// is declined, since the alternative is paying for a second evaluation of a +/// probe side that may dwarf what it saves. +auto restriction_pays(const Node& probe_side, const Node& aggregate_input, + const std::vector& keys, const SourceStats& stats) -> bool { + for (const auto& key : keys) { + const auto probed = probed_distinct(probe_side, key.left, stats); + const auto groups = distinct_estimate(aggregate_input, key.right, stats); + if (probed.has_value() && groups.has_value() && *probed < *groups) { + return true; + } + } + return false; +} + +void rewrite(Node& node, const SourceStats& stats, std::uint64_t& next) { + for (const auto& child : node.children()) { + if (child != nullptr) { + rewrite(*child, stats, next); + } + } + if (node.kind() != NodeKind::Join) { + return; + } + auto& join = node_cast(node); + // Left and Inner only. A Right or Outer join emits rows the probe side + // never matched, and their aggregate values would then depend on groups + // the restriction had removed. + if (join.kind() != JoinKind::Left && join.kind() != JoinKind::Inner) { + return; + } + if (join.children().size() != 2 || join.children()[1] == nullptr) { + return; + } + std::vector key_names; + key_names.reserve(join.keys().size()); + for (const auto& key : join.keys()) { + key_names.push_back(key.right); + } + AggregateNode* found = aggregate_below(*join.mutable_children()[1], key_names); + if (found == nullptr) { + return; + } + AggregateNode& aggregate = *found; + if (aggregate.children().size() != 1 || aggregate.children()[0] == nullptr) { + return; + } + const std::vector keys = restrictable_keys(join, aggregate); + if (keys.empty() || already_restricted(*aggregate.children()[0], keys)) { + return; + } + const Node& probe_side = *join.children()[0]; + if (!restriction_pays(probe_side, *aggregate.children()[0], keys, stats)) { + return; + } + auto probe_clone = clone_replayable_subplan(probe_side, next); + if (probe_clone == nullptr) { + return; + } + + std::vector key_columns; + std::vector semi_keys; + key_columns.reserve(keys.size()); + semi_keys.reserve(keys.size()); + for (const auto& key : keys) { + key_columns.push_back(ColumnRef{.name = key.left}); + // The aggregate's input on the left, the probe side's own name on the + // right -- the mirror of the join above. + semi_keys.emplace_back(key.right, key.left); + } + auto key_projection = std::make_unique(NodeId{next++}, std::move(key_columns)); + key_projection->add_child(std::move(probe_clone)); + + auto semi = std::make_unique(NodeId{next++}, JoinKind::Semi, std::move(semi_keys)); + semi->add_child(std::move(aggregate.mutable_children()[0])); + semi->add_child(std::move(key_projection)); + aggregate.mutable_children()[0] = std::move(semi); +} + +} // namespace + +auto restrict_aggregates_to_probed_keys(NodePtr root, const SourceStats& stats) -> NodePtr { + if (root == nullptr) { + return root; + } + std::uint64_t next = 0; + collect_max_id(*root, next); + ++next; + rewrite(*root, stats, next); + return root; +} + +} // namespace ibex::ir diff --git a/src/ir/replayable.cpp b/src/ir/replayable.cpp index fdb92945..24591154 100644 --- a/src/ir/replayable.cpp +++ b/src/ir/replayable.cpp @@ -51,67 +51,118 @@ auto is_replayable_expr(const Expr& expr) -> bool { expr.node); } -auto is_replayable_subplan(const Node& node) -> bool { +namespace { + +/// Copy one node's own state, without children. Returns null for a kind that +/// is not on the allow-list. +/// +/// This IS the allow-list: the safety question ("may this be evaluated twice") +/// and the mechanical one ("can this be copied") are answered by one switch, so +/// they cannot drift apart. A kind added later falls to `default` and is +/// refused, which is the safe answer to both. +auto clone_node_shallow(const Node& node, std::uint64_t& next) -> NodePtr { + const NodeId id{next++}; switch (node.kind()) { - // No expression of their own, and nothing but their input decides - // what they produce. - case NodeKind::Scan: + case NodeKind::Scan: { + const auto& scan = node_cast(node); + auto clone = std::make_unique(id, scan.source_name()); + if (const auto& ascribed = scan.ascribed_schema(); ascribed.has_value()) { + clone->set_ascribed_schema(ascribed->fields, ascribed->open); + } + return clone; + } + case NodeKind::Filter: { + const auto& filter = node_cast(node); + if (!is_replayable_expr(filter.predicate())) { + return nullptr; + } + return std::make_unique(id, filter.predicate()); + } case NodeKind::Project: + return std::make_unique(id, node_cast(node).columns()); case NodeKind::Rename: + return std::make_unique(id, node_cast(node).renames()); case NodeKind::Distinct: + return std::make_unique(id); case NodeKind::Order: - case NodeKind::Head: - case NodeKind::Tail: - case NodeKind::Ascribe: - break; - case NodeKind::Filter: - if (!is_replayable_expr(node_cast(node).predicate())) { - return false; + return std::make_unique(id, node_cast(node).keys()); + case NodeKind::Head: { + const auto& head = node_cast(node); + if (!is_replayable_expr(head.count_expr())) { + return nullptr; } - break; + return std::make_unique(id, head.count_expr(), head.group_by()); + } + case NodeKind::Tail: { + const auto& tail = node_cast(node); + if (!is_replayable_expr(tail.count_expr())) { + return nullptr; + } + return std::make_unique(id, tail.count_expr(), tail.group_by()); + } + case NodeKind::Ascribe: { + const auto& ascribe = node_cast(node); + return std::make_unique(id, ascribe.schema(), ascribe.open()); + } case NodeKind::Join: { const auto& join = node_cast(node); if (join.predicate().has_value() && !is_replayable_expr(*join.predicate())) { - return false; + return nullptr; } - break; + auto clone = std::make_unique(id, join.kind(), join.keys(), join.predicate(), + join.suffix(), join.null_match(), join.expect(), + join.take()); + clone->set_pending_order(join.pending_order()); + return clone; } - case NodeKind::Aggregate: + case NodeKind::Aggregate: { // Group keys and aggregate inputs are column references; the // function is an AggFunc, not a callee that could be a generator. - break; + const auto& aggregate = node_cast(node); + return std::make_unique(id, aggregate.group_by(), + aggregate.aggregations()); + } case NodeKind::Update: { const auto& update = node_cast(node); + // A tuple field owns a sub-plan through a move-only handle, so the + // node cannot be copied at all while it has any. + if (!update.tuple_fields().empty()) { + return nullptr; + } if (!std::ranges::all_of(update.fields(), [](const FieldSpec& field) { return is_replayable_expr(field.expr); })) { - return false; + return nullptr; } - break; - } - case NodeKind::Construct: { - // A literal column's values are literals, but an expression column - // holds a whole sub-plan that `children()` does not report. - const auto& construct = node_cast(node); - if (construct.row_count().has_value() && !is_replayable_expr(*construct.row_count())) { - return false; - } - if (!std::ranges::all_of(construct.columns(), [](const ConstructColumn& column) { - return column.expr_node == nullptr || is_replayable_subplan(*column.expr_node); - })) { - return false; - } - break; + return std::make_unique(id, update.fields(), std::vector{}, + update.group_by()); } default: // Everything else -- an extern call, a stream, a model fit, a map // over plugin code -- is either effectful or unclassified. A node // kind added later lands here too, which is the point. - return false; + return nullptr; + } +} + +} // namespace + +auto clone_replayable_subplan(const Node& node, std::uint64_t& next) -> NodePtr { + auto clone = clone_node_shallow(node, next); + if (clone == nullptr) { + return nullptr; + } + for (const auto& child : node.children()) { + if (child == nullptr) { + continue; + } + auto child_clone = clone_replayable_subplan(*child, next); + if (child_clone == nullptr) { + return nullptr; + } + clone->add_child(std::move(child_clone)); } - return std::ranges::all_of(node.children(), [](const NodePtr& child) { - return child == nullptr || is_replayable_subplan(*child); - }); + return clone; } } // namespace ibex::ir diff --git a/src/parser/lower.cpp b/src/parser/lower.cpp index 0445cb3b..691d62f4 100644 --- a/src/parser/lower.cpp +++ b/src/parser/lower.cpp @@ -66,7 +66,6 @@ #include #include #include -#include #include #include #include @@ -3328,17 +3327,15 @@ class Lowerer { /// Filter(ps_supplycost == __ibex_scalar_0) /// Join(Left, on p_partkey = ps_partkey) /// Filter(local)(outer) - /// Aggregate(by ps_partkey: min(ps_supplycost) as __ibex_scalar_0) - /// Join(Semi, on ps_partkey = p_partkey) - /// (inner) - /// Project(p_partkey)(outer) + /// Aggregate(by ps_partkey: min(ps_supplycost) as __ibex_scalar_0)(inner) /// - /// The semi join is the key restriction: the aggregate would otherwise - /// group the whole inner relation while the left join reads only the - /// groups the outer keys into. Its right side is the outer as it entered - /// the first decorrelation join, which is the same set of keys -- a left - /// join neither adds nor removes an outer row -- and costs one evaluation - /// however many subqueries the filter holds. + /// The aggregate groups the WHOLE inner relation here, though the join + /// reads only the groups the outer keys into. + /// `ir::restrict_aggregates_to_probed_keys` trims it, as a pass over the + /// planned tree rather than a step here: it needs the scans + /// `hoist_extern_sources` leaves behind to tell a file read from a plugin + /// call, and the source statistics to cost the second evaluation of the + /// outer that collecting the keys costs. Neither exists at lowering. /// /// The join reads each side's key under its own name and folds the pair /// into one output column. An earlier shape renamed the inner key to the @@ -3400,18 +3397,10 @@ class Lowerer { input = std::move(filter); } - // The outer as it enters the FIRST decorrelation join, kept for the - // key restriction each subquery applies to its own input. Every join - // added below is a LEFT join, which neither adds nor removes an outer - // row, so the captured column holds the same values here as it does - // above them -- and taking the keys from here means the second - // subquery does not re-run the first one's aggregate to find them. - ir::NodePtr key_source = clone_node(*input); - std::vector residual; residual.reserve(correlated.size()); for (const auto* conjunct : correlated) { - auto rewritten = decorrelate(*conjunct, input, *key_source); + auto rewritten = decorrelate(*conjunct, input); if (!rewritten.has_value()) { return std::unexpected(rewritten.error()); } @@ -3434,7 +3423,7 @@ class Lowerer { /// Join the subquery in `conjunct` onto `input` and return the comparison /// rewritten against the generated scalar column. - auto decorrelate(const Expr& conjunct, ir::NodePtr& input, const ir::Node& key_source) + auto decorrelate(const Expr& conjunct, ir::NodePtr& input) -> std::expected { const auto* comparison = std::get_if(&conjunct.node); if (comparison == nullptr || !is_compare_op(comparison->op)) { @@ -3465,7 +3454,7 @@ class Lowerer { } const std::string alias = next_scalar_alias(*input); - auto subplan = lower_scalar_subquery(*subquery, *input, key_source, alias); + auto subplan = lower_scalar_subquery(*subquery, *input, alias); if (!subplan.has_value()) { return std::unexpected(subplan.error()); } @@ -3518,7 +3507,7 @@ class Lowerer { /// Without one the subquery is a single value: the aggregate is ungrouped /// and the returned keys are empty, for the caller's cross join. auto lower_scalar_subquery(const CallExpr& call, const ir::Node& outer_input, - const ir::Node& key_source, const std::string& alias) + const std::string& alias) -> std::expected { const Expr& argument = unwrap_group(*call.args.front()); if (contains_call(argument, "scalar")) { @@ -3663,48 +3652,6 @@ class Lowerer { } auto plan = std::move(lowered.value()); - // Restrict the subquery to the keys the outer rows actually use. - // - // Without this the aggregate groups the WHOLE inner relation, while - // the left join below reads only the groups the outer rows key into. - // On a q17-shaped query -- a selective outer filter over a large inner - // table -- that is most of the work: at TPC-H SF-8 the correlated form - // measured 1.6s against 0.12s for the same query decorrelated by hand, - // and the difference is groups nobody reads. - // - // A semi join keeps every inner row whose key appears in the outer, so - // each surviving group keeps ALL of its rows and its aggregate is - // unchanged; groups it drops could never have been matched. The outer - // subtree is evaluated a second time to supply the keys, which is why - // this is worth it only when the outer is the selective side -- the - // shape a correlated subquery is normally written in. - // Only when the keys can be collected without changing the answer. - // The restriction evaluates the outer a second time, so a plan that - // draws from an RNG or calls out to a plugin would be asked for its - // keys and give a DIFFERENT set than the join above is built from -- - // dropping groups the real outer rows need. It measured as - // `Table(n)[update { k = rand_uniform(..) }]` losing every row. - if (!captures.empty() && ir::is_replayable_subplan(key_source)) { - if (auto* agg = find_aggregate(plan.get()); - agg != nullptr && agg->children().size() == 1) { - std::vector semi_keys; - semi_keys.reserve(captures.size()); - std::vector key_columns; - key_columns.reserve(captures.size()); - for (const auto& capture : captures) { - semi_keys.emplace_back(capture.inner, capture.outer); - key_columns.push_back(ir::ColumnRef{.name = capture.outer}); - } - auto outer_keys = builder_.project(std::move(key_columns)); - outer_keys->add_child(clone_node(key_source)); - - auto semi = builder_.join(ir::JoinKind::Semi, std::move(semi_keys)); - semi->add_child(std::move(agg->mutable_children()[0])); - semi->add_child(std::move(outer_keys)); - agg->mutable_children()[0] = std::move(semi); - } - } - // The aggregate keys carry the inner names and the outer query its // own; the join reads each side natively and folds the pair into one // output column, which is what the rename here used to achieve. @@ -3728,22 +3675,6 @@ class Lowerer { .plan = std::move(plan), .keys = std::move(keys), .counts = counts}; } - /// The AggregateNode inside a freshly lowered subquery plan, or null. - /// The grouped block lowers to an Aggregate, sometimes under a Project, so - /// this walks the single-child spine rather than assuming a shape. - static auto find_aggregate(ir::Node* node) -> ir::AggregateNode* { - while (node != nullptr) { - if (node->kind() == ir::NodeKind::Aggregate) { - return &ir::node_cast(*node); - } - if (node->children().size() != 1) { - return nullptr; - } - node = node->mutable_children()[0].get(); - } - return nullptr; - } - /// Read `inner_column == outer(outer_column)` (either way round). static auto parse_capture(const Expr& conjunct) -> std::expected { const auto* equality = std::get_if(&conjunct.node); diff --git a/src/repl/repl.cpp b/src/repl/repl.cpp index 85c0fcbf..694ce1d3 100644 --- a/src/repl/repl.cpp +++ b/src/repl/repl.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -5131,6 +5132,11 @@ void collect_shared_plan_max_id(const ir::Node& node, std::uint64_t& out) { rewritten = ir::reorder_inner_joins_for_order_insensitive_root(std::move(rewritten), source_stats); } + // After the reorder, so it sees which side of each join actually probes, + // and after hoisting, which is what turns a reader into the Scan this can + // recognise as replayable. It re-evaluates the probe side to collect keys, + // so it is costed against `source_stats` and declines without an estimate. + rewritten = ir::restrict_aggregates_to_probed_keys(std::move(rewritten), source_stats); std::set lazy_names; for (const auto& [name, lazy] : lazy_sources) { lazy_names.insert(name); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a03eac78..5a9826fe 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -27,6 +27,7 @@ set(IBEX_TEST_SOURCES test_ir_join_pushdown.cpp test_ir_count_distinct_reduction.cpp test_ir_join_semi_reduction.cpp + test_ir_probed_key_restriction.cpp test_ir_mapped_join_keys.cpp test_ir_join_order.cpp test_ir_join_output.cpp diff --git a/tests/test_ir_probed_key_restriction.cpp b/tests/test_ir_probed_key_restriction.cpp new file mode 100644 index 00000000..af83c6ba --- /dev/null +++ b/tests/test_ir_probed_key_restriction.cpp @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Bob Jansen + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +using namespace ibex; + +namespace { + +auto make_scan(ir::NodeId id, std::string name) -> ir::NodePtr { + return std::make_unique(id, std::move(name)); +} + +/// `facts` is the big table the aggregate groups; `probes` is the small one +/// the join keys into. The statistics say `facts` holds 400k distinct `k` and +/// `probes` 25 -- the shape the restriction exists for. +auto stats() -> ir::SourceStats { + ir::SourceStats s; + s.rows["facts"] = 1'000'000; + s.rows["probes"] = 25; + s.schemas["facts"] = ir::SchemaInfo::known( + {{.name = "k", .type = ir::ColumnType::Int64, .nulls = ir::Nullability::Maybe}, + {.name = "v", .type = ir::ColumnType::Int64, .nulls = ir::Nullability::Maybe}}); + s.schemas["probes"] = ir::SchemaInfo::known( + {{.name = "k", .type = ir::ColumnType::Int64, .nulls = ir::Nullability::Maybe}}); + s.distinct["facts"]["k"] = 400'000; + s.distinct["probes"]["k"] = 25; + return s; +} + +auto aggregate_over_facts() -> ir::NodePtr { + auto agg = std::make_unique( + ir::NodeId{2}, std::vector{ir::ColumnRef{.name = "k"}}, + std::vector{ir::AggSpec{ + .func = ir::AggFunc::Min, .column = ir::ColumnRef{.name = "v"}, .alias = "m"}}); + agg->add_child(make_scan(ir::NodeId{1}, "facts")); + return agg; +} + +/// Join(kind, on k, , Aggregate(by k) over facts). +auto plan(ir::JoinKind kind, ir::NodePtr probe) -> ir::NodePtr { + auto join = std::make_unique(ir::NodeId{10}, kind, + std::vector{ir::JoinKey{"k"}}); + join->add_child(std::move(probe)); + join->add_child(aggregate_over_facts()); + return join; +} + +/// The Semi join the pass inserts, or null. +auto restriction_of(const ir::Node& root) -> const ir::JoinNode* { + if (const auto* join = dynamic_cast(&root); + join != nullptr && join->kind() == ir::JoinKind::Semi) { + return join; + } + for (const auto& child : root.children()) { + if (child != nullptr) { + if (const auto* found = restriction_of(*child)) { + return found; + } + } + } + return nullptr; +} + +} // namespace + +TEST_CASE("probed key restriction: a join over a grouped aggregate restricts it", + "[ir][probed-keys]") { + auto root = ir::restrict_aggregates_to_probed_keys( + plan(ir::JoinKind::Left, make_scan(ir::NodeId{3}, "probes")), stats()); + const auto* semi = restriction_of(*root); + REQUIRE(semi != nullptr); + // The aggregate's input on the left, the probe side's name on the right. + REQUIRE(semi->keys().size() == 1); + CHECK(semi->keys()[0].left == "k"); + CHECK(semi->keys()[0].right == "k"); + // It sits under the aggregate, not above it: restricting the aggregate's + // OUTPUT would drop groups the join still has to see as unmatched. + const auto* aggregate = dynamic_cast(root->children()[1].get()); + REQUIRE(aggregate != nullptr); + CHECK(aggregate->children()[0]->kind() == ir::NodeKind::Join); +} + +TEST_CASE("probed key restriction: it is idempotent", "[ir][probed-keys]") { + auto once = ir::restrict_aggregates_to_probed_keys( + plan(ir::JoinKind::Left, make_scan(ir::NodeId{3}, "probes")), stats()); + // A second run must not wrap the restriction in another one. + auto twice = ir::restrict_aggregates_to_probed_keys(std::move(once), stats()); + const auto* aggregate = dynamic_cast(twice->children()[1].get()); + REQUIRE(aggregate != nullptr); + const auto* semi = dynamic_cast(aggregate->children()[0].get()); + REQUIRE(semi != nullptr); + CHECK(semi->kind() == ir::JoinKind::Semi); + CHECK(semi->children()[0]->kind() == ir::NodeKind::Scan); // not another Semi +} + +TEST_CASE("probed key restriction: declined when the probe keys into as much as it saves", + "[ir][probed-keys]") { + // The probe side is the fact table itself, so it keys into every group the + // aggregate would build and the restriction removes nothing -- while still + // paying for a second evaluation of the probe side. + auto root = ir::restrict_aggregates_to_probed_keys( + plan(ir::JoinKind::Left, make_scan(ir::NodeId{3}, "facts")), stats()); + CHECK(restriction_of(*root) == nullptr); +} + +TEST_CASE("probed key restriction: declined without statistics", "[ir][probed-keys]") { + // No distinct counts, so nothing says the restriction removes any group. + // Declining costs a rewrite; guessing costs a second evaluation of a probe + // side that may dwarf what it saves. + auto root = ir::restrict_aggregates_to_probed_keys( + plan(ir::JoinKind::Left, make_scan(ir::NodeId{3}, "probes")), ir::SourceStats{}); + CHECK(restriction_of(*root) == nullptr); +} + +TEST_CASE("probed key restriction: only Left and Inner joins", "[ir][probed-keys]") { + // A Right or Outer join emits rows the probe side never matched, whose + // aggregate values would then depend on groups the restriction removed. + for (const ir::JoinKind kind : {ir::JoinKind::Right, ir::JoinKind::Outer}) { + auto root = ir::restrict_aggregates_to_probed_keys( + plan(kind, make_scan(ir::NodeId{3}, "probes")), stats()); + CHECK(restriction_of(*root) == nullptr); + } + auto inner = ir::restrict_aggregates_to_probed_keys( + plan(ir::JoinKind::Inner, make_scan(ir::NodeId{3}, "probes")), stats()); + CHECK(restriction_of(*inner) != nullptr); +} + +TEST_CASE("probed key restriction: declined when the probe side cannot be replayed", + "[ir][probed-keys]") { + // Collecting the keys evaluates the probe side a second time. An extern + // call is unclassified on purpose, so nothing says a second call answers + // the same way -- and a plan that draws from an RNG certainly does not. + auto extern_probe = std::make_unique(ir::NodeId{3}, "read_something", + std::vector{}); + auto root = ir::restrict_aggregates_to_probed_keys( + plan(ir::JoinKind::Left, std::move(extern_probe)), stats()); + CHECK(restriction_of(*root) == nullptr); +} diff --git a/tests/test_lower.cpp b/tests/test_lower.cpp index 99221a58..96ed9759 100644 --- a/tests/test_lower.cpp +++ b/tests/test_lower.cpp @@ -1307,116 +1307,6 @@ parts[filter REQUIRE(aliases == std::vector{"__ibex_scalar_0", "__ibex_scalar_1"}); } -TEST_CASE("Lower restricts a subquery to the keys the outer rows use") { - // Without this the aggregate groups the whole inner relation while the - // left join reads only the groups the outer keys into. A semi join against - // the outer's keys drops the rest; each surviving group keeps ALL its rows, - // so no aggregate changes. - auto result = lower_source(std::string(kCorrelatedSources) + - R"( -parts[filter p_partkey == scalar( - supply[filter ps_partkey == outer(p_partkey), select { m = min(ps_cost) }] -)]; -)"); - REQUIRE(result.has_value()); - - const auto* join = find_join(*result.value()); - REQUIRE(join != nullptr); - REQUIRE(join->kind() == ir::JoinKind::Left); - - // Under the left join's aggregate sits the restriction. - const auto* aggregate = as_node(join->children()[1].get()); - REQUIRE(aggregate != nullptr); - REQUIRE(aggregate->children().size() == 1); - // The restriction sits under the aggregate. Its exact depth is not the - // point -- later passes move projections around it -- so this looks for - // the semi join anywhere below, and checks how it is keyed. - const ir::JoinNode* semi = nullptr; - const auto find_semi = [&](auto&& self, const ir::Node& n) -> void { - if (const auto* j = dynamic_cast(&n); - j != nullptr && j->kind() == ir::JoinKind::Semi) { - semi = j; - return; - } - for (const auto& c : n.children()) { - if (c != nullptr && semi == nullptr) { - self(self, *c); - } - } - }; - find_semi(find_semi, *aggregate); - REQUIRE(semi != nullptr); - // Inner column on the left, the outer's own name on the right. - REQUIRE(semi->keys().size() == 1); - CHECK(semi->keys()[0].left == "ps_partkey"); - CHECK(semi->keys()[0].right == "p_partkey"); -} - -TEST_CASE("Lower does not restrict a subquery whose outer cannot be replayed") { - // The restriction collects the outer's keys by evaluating it a SECOND - // time. A plan that draws from an RNG gives a different answer each time, - // so the keys would not be the ones the join above is built from and rows - // would be dropped. Transpiled, this query answered 0 of 3 rows before the - // guard, because `rand_uniform` was emitted twice and drawn twice. - auto result = lower_source(R"( -let wide = Table { a = [1, 2, 3], v = [1, 1, 1] }; -let o = Table { j = [1, 2, 3] }[update { k = Int64(floor(rand_uniform(1.0, 3.99))) }]; -o[filter 0 < scalar(wide[filter a == outer(k), select { m = min(v) }])]; -)"); - REQUIRE(result.has_value()); - - const ir::JoinNode* semi = nullptr; - const auto find_semi = [&](auto&& self, const ir::Node& n) -> void { - if (const auto* j = dynamic_cast(&n); - j != nullptr && j->kind() == ir::JoinKind::Semi) { - semi = j; - return; - } - for (const auto& c : n.children()) { - if (c != nullptr && semi == nullptr) { - self(self, *c); - } - } - }; - find_semi(find_semi, *result.value()); - CHECK(semi == nullptr); -} - -TEST_CASE("Lower takes a subquery's keys from the outer below any earlier join") { - // Two subqueries: the second one's key source must be the outer as it - // entered the FIRST decorrelation join, not the join's output. Every such - // join is a LEFT join, so the captured column holds the same values either - // way -- but cloning the output would re-run the first subquery's - // aggregate to find them, and again for each subquery after it. - auto result = lower_source(std::string(kCorrelatedSources) + - R"( -parts[filter - p_partkey == scalar(supply[filter ps_partkey == outer(p_partkey), select { a = min(ps_cost) }]) - && p_partkey != scalar(supply[filter ps_partkey == outer(p_partkey), select { b = max(ps_cost) }]) -]; -)"); - REQUIRE(result.has_value()); - - // One aggregate per subquery, plus one per restriction -- and no copy of - // an earlier subquery's aggregate, which is what compounding would add. - std::vector aliases; - const auto walk = [&](auto&& self, const ir::Node& node) -> void { - if (const auto* aggregate = dynamic_cast(&node)) { - for (const auto& spec : aggregate->aggregations()) { - aliases.push_back(spec.alias); - } - } - for (const auto& child : node.children()) { - if (child != nullptr) { - self(self, *child); - } - } - }; - walk(walk, *result.value()); - std::ranges::sort(aliases); - REQUIRE(aliases == std::vector{"__ibex_scalar_0", "__ibex_scalar_1"}); -} - TEST_CASE("Lower decorrelates two captures that share one outer column") { // `a == outer(x) && b == outer(x)` was rejected as a "duplicate capture": // decorrelation renamed each inner key to its outer name, and two columns From 43d49ae4df397140681025c32a81b270e5e8497f Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Sun, 20 Sep 2026 21:46:08 +0200 Subject: [PATCH 18/42] Keep a row-count frame's size when a let is inlined `Table(n)` holds its size in `row_count` and has no columns at all, but `clone_node` rebuilt a Construct from its COLUMNS. Whole-program lowering inlines a `let` by cloning, so the clone was an empty frame: let scaffold = Table(3)[update { k = 7 }]; scaffold; printed three rows under `ibex_eval` and none transpiled. Written without the `let` it was always correct, which is why the existing `construct_deferred_row_count` case never showed it. The guard is a lowering test, not a parity case. `structured_runner` builds its reference by calling `parser::lower` as well, so the two sides share one lowered plan and a bug in lowering moves both together: a parity case written for this passed before the fix and after it. What diverged was `ibex_eval`, which plans through the pipeline in repl.cpp, against the transpiler. The case added here is kept for the emitter coverage it does give, and says plainly what it does not. Found while auditing an unrelated wrong answer, where this was the real cause of a transpiled query returning 0 rows and sent me looking in the wrong place for a while. Separately and not fixed: a `let` over `Table(n)` that is USED twice becomes a shared source and aborts with "ScanNode cannot be emitted". That one is loud, is not a wrong answer, and predates this. Co-Authored-By: Claude Opus 5 --- src/parser/lower.cpp | 8 ++++- .../parity/cases/construct_row_count_let.ibex | 24 +++++++++++++ tests/test_lower.cpp | 35 +++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 tests/parity/cases/construct_row_count_let.ibex diff --git a/src/parser/lower.cpp b/src/parser/lower.cpp index 691d62f4..00f37159 100644 --- a/src/parser/lower.cpp +++ b/src/parser/lower.cpp @@ -5351,8 +5351,14 @@ class Lowerer { break; } case ir::NodeKind::Construct: { + // `Table(n)` keeps its count in `row_count` with NO columns, so + // rebuilding one from its columns alone produces an empty frame + // rather than an n-row one. A `let` is inlined by cloning, so + // `let t = Table(3)[update { k = 7 }]` lost its three rows. const auto& cn = ir::node_cast(node); - clone = builder_.construct(clone_construct_columns(cn.columns())); + clone = cn.row_count().has_value() + ? builder_.construct_rows(*cn.row_count()) + : builder_.construct(clone_construct_columns(cn.columns())); break; } case ir::NodeKind::Stream: { diff --git a/tests/parity/cases/construct_row_count_let.ibex b/tests/parity/cases/construct_row_count_let.ibex new file mode 100644 index 00000000..e5b00e52 --- /dev/null +++ b/tests/parity/cases/construct_row_count_let.ibex @@ -0,0 +1,24 @@ +// `Table(n)` bound to a `let`, through the shapes the row count has to survive. +// +// This does NOT guard the bug it was written for. Whole-program lowering +// inlines a binding by cloning it, and the clone rebuilt a Construct from its +// COLUMNS -- which a row-count frame has none of, keeping its size in +// `row_count` instead -- so the clone was an empty frame. Parity cannot see +// that: the reference side here runs `parser::lower` too, so both engines lost +// the rows together and agreed on zero. `Lower keeps a row-count frame's size +// when a let is inlined` in test_lower.cpp is what catches it. +// +// What this does cover is the emitter and the interpreter agreeing on these +// shapes once lowering is right. +let scaffold = Table(3)[update { k = 7 }]; +scaffold; +scaffold[select { n = count(), total = sum(k) }]; + +// The same through a second update, where the row count has to survive the +// first one to reach the second. +let widened = Table(4)[update { a = 1 }][update { b = a + 1 }]; +widened[select { rows = count(), sum_b = sum(b) }]; + +// A bare row-count frame keeps its rows with no columns at all. +let empty_frame = Table(2); +empty_frame; diff --git a/tests/test_lower.cpp b/tests/test_lower.cpp index 96ed9759..80602890 100644 --- a/tests/test_lower.cpp +++ b/tests/test_lower.cpp @@ -1216,6 +1216,41 @@ auto find_join(const ir::Node& node) -> const ir::JoinNode* { } // namespace +TEST_CASE("Lower keeps a row-count frame's size when a let is inlined") { + // `Table(n)` holds its size in `row_count` and has no columns at all, so a + // clone rebuilt from its COLUMNS is an empty frame. Whole-program lowering + // inlines a `let` by cloning, which silently cost `let t = Table(3)` its + // three rows -- the transpiled program printed none. + // + // Parity cannot catch this: its reference side lowers the same way, so both + // engines lose the rows together and agree. It has to be checked here. + auto result = lower_source(R"( +let scaffold = Table(3)[update { k = 7 }]; +scaffold; +)"); + REQUIRE(result.has_value()); + + const ir::ConstructNode* construct = nullptr; + const auto find_construct = [&](auto&& self, const ir::Node& node) -> void { + if (const auto* c = dynamic_cast(&node)) { + construct = c; + return; + } + for (const auto& child : node.children()) { + if (child != nullptr && construct == nullptr) { + self(self, *child); + } + } + }; + find_construct(find_construct, *result.value()); + REQUIRE(construct != nullptr); + REQUIRE(construct->columns().empty()); // a row-count frame carries none + REQUIRE(construct->row_count().has_value()); + const auto* count = std::get_if(&construct->row_count()->node); + REQUIRE(count != nullptr); + CHECK(std::get(count->value) == 3); +} + TEST_CASE("Lower decorrelates a scalar subquery into an aggregate plus a left join") { auto result = lower_source(std::string(kCorrelatedSources) + R"( From 9b1cd8a6827b4c58d66be1d6ecd3c14119f0c847 Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Sun, 20 Sep 2026 21:59:04 +0200 Subject: [PATCH 19/42] Preserve typed columns for zero-row head limits --- src/runtime/map_chunked.cpp | 21 ++++++++++----------- tests/test_interpreter.cpp | 26 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/runtime/map_chunked.cpp b/src/runtime/map_chunked.cpp index aa1c1e46..cc30cd90 100644 --- a/src/runtime/map_chunked.cpp +++ b/src/runtime/map_chunked.cpp @@ -206,10 +206,6 @@ class ChunkedFilterHeadOperator final : public Operator { return std::optional{}; } while (true) { - if (remaining_ == 0) { - done_ = true; - return std::optional{}; - } auto chunk_res = child_->next(); if (!chunk_res.has_value()) { return std::unexpected(std::move(chunk_res.error())); @@ -218,6 +214,14 @@ class ChunkedFilterHeadOperator final : public Operator { done_ = true; return schema_.release(); } + if (count_ == 0) { + // Pull once for the schema, without evaluating the predicate. + done_ = true; + const auto identity = chunk_identity_of(*chunk_res.value()); + const Table input = chunk_to_table(std::move(*chunk_res.value())); + return std::optional{ + table_to_chunk(gather_rows(input, std::vector{}), identity)}; + } auto out = kernel::filter_limit_chunk(std::move(*chunk_res.value()), *predicate_, remaining_, scalars_); if (!out.has_value()) { @@ -232,7 +236,6 @@ class ChunkedFilterHeadOperator final : public Operator { if (remaining_ == 0) { done_ = true; } - (void)count_; schema_.emitted(); return std::optional{std::move(out.value())}; } @@ -537,11 +540,6 @@ class ChunkedHeadOperator final : public Operator { if (done_) { return std::optional{}; } - if (count_ == 0 && group_by_->empty()) { - done_ = true; - return std::optional{}; - } - while (true) { auto chunk_res = child_->next(); if (!chunk_res.has_value()) { @@ -555,9 +553,10 @@ class ChunkedHeadOperator final : public Operator { Chunk chunk = std::move(*chunk_res.value()); if (count_ == 0) { done_ = true; + const auto identity = chunk_identity_of(chunk); const Table t = chunk_to_table(std::move(chunk)); const std::vector idx; - return std::optional{table_to_chunk(gather_rows(t, idx))}; + return std::optional{table_to_chunk(gather_rows(t, idx), identity)}; } if (group_by_->empty()) { diff --git a/tests/test_interpreter.cpp b/tests/test_interpreter.cpp index 5eecb78a..9b083021 100644 --- a/tests/test_interpreter.cpp +++ b/tests/test_interpreter.cpp @@ -16099,3 +16099,29 @@ TEST_CASE("inner join beneath a declined aggregate runs the collapsed whole-tabl REQUIRE(empty->find("m") != nullptr); REQUIRE(runtime::is_null(*empty->find_entry("m"), 0)); } + +TEST_CASE("zero head retains typed columns", "[schema][head]") { + runtime::Table table; + table.add_column("k", Column{1, 2}); + table.add_column("v", Column{10.0, 20.0}); + const runtime::TableRegistry registry{{"t", table}}; + for (const char* source : + {"t[head 0];", "t[filter v > 0.0, head 0];", "t[filter v > 100.0, head 0];"}) { + INFO(source); + auto tree = require_ir(source); + auto result = runtime::interpret(*tree, registry); + REQUIRE(result.has_value()); + CHECK(result->rows() == 0); + REQUIRE(result->columns.size() == 2); + CHECK(result->columns[0].name == "k"); + CHECK(result->columns[1].name == "v"); + CHECK(std::holds_alternative>(*result->find("k"))); + CHECK(std::holds_alternative>(*result->find("v"))); + auto aggregate = require_ir("empty[select { n = count(), s = sum(v) }];"); + auto out = runtime::interpret(*aggregate, runtime::TableRegistry{{"empty", *result}}); + REQUIRE(out.has_value()); + REQUIRE(out->rows() == 1); + CHECK(std::get>(*out->find("n"))[0] == 0); + CHECK(runtime::is_null(*out->find_entry("s"), 0)); + } +} From d013939164dedd3069ec1b3e8158622e63c50347 Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Sun, 20 Sep 2026 22:06:33 +0200 Subject: [PATCH 20/42] Preserve typed columns for zero-row fused tail limits --- src/runtime/map_chunked.cpp | 7 +++++++ tests/test_chunked_sources.cpp | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/runtime/map_chunked.cpp b/src/runtime/map_chunked.cpp index cc30cd90..b8a0dc27 100644 --- a/src/runtime/map_chunked.cpp +++ b/src/runtime/map_chunked.cpp @@ -276,6 +276,13 @@ class ChunkedFilterTailOperator final : public Operator { if (!chunk_res.value().has_value()) { break; } + if (count_ == 0) { + done_ = true; + const auto identity = chunk_identity_of(*chunk_res.value()); + const Table input = chunk_to_table(std::move(*chunk_res.value())); + return std::optional{ + table_to_chunk(gather_rows(input, std::vector{}), identity)}; + } auto filtered = kernel::filter_chunk(std::move(*chunk_res.value()), *predicate_, scalars_); if (!filtered.has_value()) { diff --git a/tests/test_chunked_sources.cpp b/tests/test_chunked_sources.cpp index b835e9c7..e1e844e0 100644 --- a/tests/test_chunked_sources.cpp +++ b/tests/test_chunked_sources.cpp @@ -978,3 +978,23 @@ TEST_CASE("chunked aggregate: clustered integer counts merge runs across chunks" FAIL(mismatch->message()); } } + +TEST_CASE("zero fused tail retains typed columns", "[schema][tail]") { + for (const char* grain : {"1", "16"}) { + ChunkGrainGuard guard(grain); + for (const char* predicate : {"v > 0", "v > 100"}) { + INFO(grain << ": " << predicate); + runtime::Table table; + table.add_column("v", Column{10, 20}); + auto out = run(std::string("t[filter ") + predicate + ", tail 0];", {{"t", table}}); + CHECK(out.rows() == 0); + REQUIRE(out.columns.size() == 1); + CHECK(out.columns[0].name == "v"); + CHECK(std::holds_alternative>(*out.find("v"))); + auto total = run("t[select { n = count(), s = sum(v) }];", {{"t", out}}); + REQUIRE(total.rows() == 1); + CHECK(std::get>(*total.find("n"))[0] == 0); + CHECK(runtime::is_null(*total.find_entry("s"), 0)); + } + } +} From 5eb8daac732d816a635b1fa9c323e0d9be2adb1f Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Sun, 20 Sep 2026 22:06:33 +0200 Subject: [PATCH 21/42] Carry schema through ordering an empty stream --- src/runtime/ordered_chunked.cpp | 12 +++++++++++- tests/test_chunked_sources.cpp | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/runtime/ordered_chunked.cpp b/src/runtime/ordered_chunked.cpp index 80e837d7..128b7d21 100644 --- a/src/runtime/ordered_chunked.cpp +++ b/src/runtime/ordered_chunked.cpp @@ -75,7 +75,7 @@ class ChunkedOrderOperator final : public Operator { sorted_result_.reset(); return std::optional{std::move(out)}; } - return std::optional{}; + return schema_.release(); } private: @@ -92,6 +92,14 @@ class ChunkedOrderOperator final : public Operator { } Chunk chunk = std::move(*chunk_res.value()); if (chunk.rows() == 0) { + if (!schema_.holding()) { + const auto identity = chunk_identity_of(chunk); + auto empty = order_table(chunk_to_table(std::move(chunk)), *keys_, *exec_); + if (!empty.has_value()) { + return std::unexpected(std::move(empty.error())); + } + schema_.hold(std::move(*empty), identity); + } continue; } if (resolved_keys_.empty()) { @@ -134,6 +142,7 @@ class ChunkedOrderOperator final : public Operator { } } } + schema_.emitted(); buffered_.push_back(std::move(chunk)); } @@ -388,6 +397,7 @@ class ChunkedOrderOperator final : public Operator { const std::vector* keys_; const ExecutionContext* exec_; Mode mode_ = Mode::Ingest; + SchemaCarrier schema_; std::vector buffered_; std::vector resolved_keys_; std::vector prev_last_; diff --git a/tests/test_chunked_sources.cpp b/tests/test_chunked_sources.cpp index e1e844e0..c742269a 100644 --- a/tests/test_chunked_sources.cpp +++ b/tests/test_chunked_sources.cpp @@ -998,3 +998,23 @@ TEST_CASE("zero fused tail retains typed columns", "[schema][tail]") { } } } + +TEST_CASE("empty order retains typed columns", "[schema][order]") { + runtime::Table table; + table.add_column("v", Column{}); + table.add_column("s", Column{}); + for (const char* query : {"t[order v];", "t[order s desc];", "t[order];"}) { + INFO(query); + auto out = run(query, {{"t", table}}); + CHECK(out.rows() == 0); + REQUIRE(out.columns.size() == 2); + CHECK(out.columns[0].name == "v"); + CHECK(out.columns[1].name == "s"); + CHECK(std::holds_alternative>(*out.find("v"))); + CHECK(std::holds_alternative>(*out.find("s"))); + auto total = run("t[select { n = count(), s = sum(v) }];", {{"t", out}}); + REQUIRE(total.rows() == 1); + CHECK(std::get>(*total.find("n"))[0] == 0); + CHECK(runtime::is_null(*total.find_entry("s"), 0)); + } +} From 7163a471afdd1a89d023c3240e6b888c2d5b9366 Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Sun, 20 Sep 2026 22:06:33 +0200 Subject: [PATCH 22/42] Pass empty schema chunks through distinct --- src/runtime/distinct_chunked.cpp | 5 +++++ tests/test_chunked_sources.cpp | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/runtime/distinct_chunked.cpp b/src/runtime/distinct_chunked.cpp index 4e677a4a..6abd8575 100644 --- a/src/runtime/distinct_chunked.cpp +++ b/src/runtime/distinct_chunked.cpp @@ -69,6 +69,11 @@ class ChunkedDistinctOperator final : public Operator { return std::optional{}; } + // An empty input still carries typed columns and metadata. No + // deduplication is needed, and returning it preserves its identity. + if (chunk_res.value()->rows() == 0) { + return std::move(chunk_res.value()); + } Table t = chunk_to_table(std::move(*chunk_res.value())); if (t.columns.empty()) { // `distinct` keeps the first occurrence of each row in input order and diff --git a/tests/test_chunked_sources.cpp b/tests/test_chunked_sources.cpp index c742269a..6c73e5de 100644 --- a/tests/test_chunked_sources.cpp +++ b/tests/test_chunked_sources.cpp @@ -1018,3 +1018,21 @@ TEST_CASE("empty order retains typed columns", "[schema][order]") { CHECK(runtime::is_null(*total.find_entry("s"), 0)); } } + +TEST_CASE("empty distinct retains typed columns", "[schema][distinct]") { + runtime::Table table; + table.add_column("k", Column{}); + table.add_column("v", Column{}); + table.add_column("s", Column{}); + for (const char* query : {"t[distinct k];", "t[distinct { k, v }];", "t[distinct { k, s }];"}) { + INFO(query); + auto out = run(query, {{"t", table}}); + CHECK(out.rows() == 0); + REQUIRE(out.find("k") != nullptr); + CHECK(std::holds_alternative>(*out.find("k"))); + auto total = run("t[select { n = count(), s = sum(k) }];", {{"t", out}}); + REQUIRE(total.rows() == 1); + CHECK(std::get>(*total.find("n"))[0] == 0); + CHECK(runtime::is_null(*total.find_entry("s"), 0)); + } +} From 669c1b201e76d3fe037dde5f06192d600311b7f4 Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Sun, 20 Sep 2026 22:06:33 +0200 Subject: [PATCH 23/42] Emit empty swapped and precomputed inner join results --- src/runtime/join_chunked.cpp | 12 ------------ tests/test_chunked_sources.cpp | 24 ++++++++++++++++++++++++ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/runtime/join_chunked.cpp b/src/runtime/join_chunked.cpp index 4935b223..43087255 100644 --- a/src/runtime/join_chunked.cpp +++ b/src/runtime/join_chunked.cpp @@ -1897,9 +1897,6 @@ class ChunkedInnerJoinOperator final : public Operator { return std::optional{}; } swapped_emitted_ = true; - if (precomputed_output_.rows() == 0) { - return std::optional{}; - } return std::optional{table_to_chunk(std::move(precomputed_output_))}; } @@ -1916,9 +1913,6 @@ class ChunkedInnerJoinOperator final : public Operator { if (!out.has_value()) { return std::unexpected(std::move(out.error())); } - if (out->rows() == 0) { - return std::optional{}; - } return std::optional{table_to_chunk(std::move(*out))}; } @@ -2877,9 +2871,6 @@ class SwappedHashProbeOperator final : public Operator { if (!out.has_value()) { return std::unexpected(std::move(out.error())); } - if (out->rows() == 0) { - return std::optional{}; - } return std::optional{table_to_chunk(std::move(*out))}; } @@ -2901,9 +2892,6 @@ class PrecomputedHashProbeOperator final : public Operator { return std::optional{}; } emitted_ = true; - if (output_.rows() == 0) { - return std::optional{}; - } return std::optional{table_to_chunk(std::move(output_))}; } diff --git a/tests/test_chunked_sources.cpp b/tests/test_chunked_sources.cpp index 6c73e5de..9c09cece 100644 --- a/tests/test_chunked_sources.cpp +++ b/tests/test_chunked_sources.cpp @@ -1036,3 +1036,27 @@ TEST_CASE("empty distinct retains typed columns", "[schema][distinct]") { CHECK(runtime::is_null(*total.find_entry("s"), 0)); } } + +TEST_CASE("empty swapped inner join retains typed columns", "[schema][join]") { + runtime::Table left; + left.add_column("k", Column{1, 2}); + left.add_column("v", Column{10.0, 20.0}); + runtime::Table right; + right.add_column("k", Column{std::vector(70'000, 7)}); + right.add_column("s", Column{std::vector(70'000, "x")}); + for (const char* grain : {"0", "1024"}) { + ChunkGrainGuard guard(grain); + auto out = run("a join b on k;", {{"a", left}, {"b", right}}); + CHECK(out.rows() == 0); + REQUIRE(out.columns.size() == 3); + REQUIRE(out.find("k") != nullptr); + REQUIRE(out.find("v") != nullptr); + REQUIRE(out.find("s") != nullptr); + CHECK(std::holds_alternative>(*out.find("v"))); + CHECK(std::holds_alternative>(*out.find("s"))); + auto total = run("t[select { n = count(), s = sum(v) }];", {{"t", out}}); + REQUIRE(total.rows() == 1); + CHECK(std::get>(*total.find("n"))[0] == 0); + CHECK(runtime::is_null(*total.find_entry("s"), 0)); + } +} From d9452de68d1a04c89bc60a21a66519d7889a744f Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Sun, 20 Sep 2026 22:06:33 +0200 Subject: [PATCH 24/42] Emit the declared CSV schema for an empty file --- libs/csv/csv.hpp | 14 ++++++++++++-- tests/test_csv.cpp | 31 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/libs/csv/csv.hpp b/libs/csv/csv.hpp index 9cd66282..8fb5ce7d 100644 --- a/libs/csv/csv.hpp +++ b/libs/csv/csv.hpp @@ -542,7 +542,7 @@ class ChunkedCsvSourceOperator final : public ibex::runtime::Operator { } auto next() -> std::expected, std::string> override { - if (pos_ >= end_) { + if (pos_ >= end_ && emitted_) { return std::optional{}; } @@ -666,7 +666,7 @@ class ChunkedCsvSourceOperator final : public ibex::runtime::Operator { source_->advise_dontneed(chunk_start, pos_); - if (rows_read == 0) { + if (rows_read == 0 && emitted_) { return std::optional{}; } @@ -701,6 +701,8 @@ class ChunkedCsvSourceOperator final : public ibex::runtime::Operator { } chunk.add_column(col_names_[c], std::move(column)); } + // The first chunk carries the declared schema even for an empty file. + emitted_ = true; total_rows_ += rows_read; return std::optional{std::move(chunk)}; } @@ -715,6 +717,7 @@ class ChunkedCsvSourceOperator final : public ibex::runtime::Operator { char delimiter_; std::size_t rows_per_chunk_; std::size_t total_rows_ = 0; + bool emitted_ = false; std::vector>> shared_dicts_; std::vector::index_map>> shared_indices_; }; @@ -982,6 +985,13 @@ inline auto read_csv_with_options(std::string_view path, const CsvReadOptions& o col_names.push_back("col" + std::to_string(i + 1)); } } + } else if (!options.has_header) { + // With no row to discover columns from, the supplied schema is the + // layout. Feed it through the ordinary typed column builders below. + for (std::size_t i = 0; i < options.schema.entries.size(); ++i) { + col_names.push_back( + options.schema.entries[i].name.value_or("col" + std::to_string(i + 1))); + } } std::size_t n_cols = col_names.size(); diff --git a/tests/test_csv.cpp b/tests/test_csv.cpp index 2e67773d..c17ecb67 100644 --- a/tests/test_csv.cpp +++ b/tests/test_csv.cpp @@ -554,3 +554,34 @@ TEST_CASE("Write CSV - empty table writes only header") { REQUIRE(line == "a,b"); REQUIRE_FALSE(std::getline(f, line)); // no data rows } + +TEST_CASE("empty chunked CSV retains typed columns", "[schema][csv]") { + const auto path = tmp("ibex_test_empty_chunked_schema.csv"); + write_csv(path, ""); + const auto eager = read_csv(path.string(), "", ",", false, "v:i64,s:string,d:date,c:cat"); + REQUIRE(eager.columns.size() == 4); + CHECK(eager.rows() == 0); + CHECK(std::holds_alternative>(*eager.find("v"))); + CHECK(std::holds_alternative>(*eager.find("s"))); + CHECK(std::holds_alternative>(*eager.find("d"))); + CHECK(std::holds_alternative>(*eager.find("c"))); + using Kind = ibex::csv::detail::CsvColumnKind; + ibex::csv::detail::ChunkedCsvSourceOperator op( + path.string(), {"v", "s", "d", "c"}, + {Kind::Int, Kind::String, Kind::Date, Kind::Categorical}, ',', 64); + auto first = op.next(); + REQUIRE(first.has_value()); + REQUIRE(first->has_value()); + CHECK((**first).rows() == 0); + REQUIRE((**first).columns.size() == 4); + CHECK((**first).columns[0].name == "v"); + CHECK(std::holds_alternative>(*(**first).columns[0].column)); + CHECK(std::holds_alternative>(*(**first).columns[1].column)); + CHECK(std::holds_alternative>(*(**first).columns[2].column)); + CHECK(std::holds_alternative>(*(**first).columns[3].column)); + for (int i = 0; i < 2; ++i) { + auto end = op.next(); + REQUIRE(end.has_value()); + CHECK_FALSE(end->has_value()); + } +} From 4f4abdca031e41a54ec9a532e6a0068932d1816d Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Sun, 20 Sep 2026 22:06:33 +0200 Subject: [PATCH 25/42] Use SchemaCarrier to preserve empty join probe identity --- src/runtime/join_chunked.cpp | 22 ++++++---------------- tests/test_chunked_sources.cpp | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/src/runtime/join_chunked.cpp b/src/runtime/join_chunked.cpp index 43087255..6ff49f3c 100644 --- a/src/runtime/join_chunked.cpp +++ b/src/runtime/join_chunked.cpp @@ -1721,12 +1721,7 @@ class JoinProbeOperator final : public Operator { return std::unexpected(std::move(chunk_res.error())); } if (!chunk_res.value().has_value()) { - if (!emitted_nonempty_ && empty_schema_.has_value()) { - auto schema = std::move(*empty_schema_); - empty_schema_.reset(); - return std::optional{table_to_chunk(std::move(schema))}; - } - return std::optional{}; + return schema_.release(); } Chunk input = std::move(*chunk_res.value()); // A morsel's identity travels with it. `sequence` and `row_offset` @@ -1735,8 +1730,7 @@ class JoinProbeOperator final : public Operator { // both change the row count, and neither changes which morsel it // is answering for. The ordered ring rejects a chunk that arrives // without them. - const std::uint64_t sequence = input.sequence; - const std::size_t row_offset = input.row_offset; + const auto identity = chunk_identity_of(input); auto out = probe_.probe_chunk_against_right(chunk_to_table(std::move(input))); if (!out.has_value()) { return std::unexpected(std::move(out.error())); @@ -1745,14 +1739,11 @@ class JoinProbeOperator final : public Operator { // Keep the planned empty table as a schema carrier. A join // with no matches still has its left and right output columns; // without this, a materializing sink sees no chunks at all. - empty_schema_ = std::move(*out); + schema_.hold(std::move(*out), identity); continue; } - emitted_nonempty_ = true; - Chunk result = table_to_chunk(std::move(*out)); - result.sequence = sequence; - result.row_offset = row_offset; - return std::optional{std::move(result)}; + schema_.emitted(); + return std::optional{table_to_chunk(std::move(*out), identity)}; } } @@ -1760,8 +1751,7 @@ class JoinProbeOperator final : public Operator { OperatorPtr child_; JoinProbe probe_; bool preserve_empty_ = false; - std::optional
empty_schema_; - bool emitted_nonempty_ = false; + SchemaCarrier schema_; }; auto make_probe_factory(JoinProbe probe) -> JoinProbeFactory { diff --git a/tests/test_chunked_sources.cpp b/tests/test_chunked_sources.cpp index 9c09cece..87807de1 100644 --- a/tests/test_chunked_sources.cpp +++ b/tests/test_chunked_sources.cpp @@ -43,6 +43,8 @@ #include #include +#include "join_chunked_internal.hpp" + using namespace ibex; namespace { @@ -1060,3 +1062,34 @@ TEST_CASE("empty swapped inner join retains typed columns", "[schema][join]") { CHECK(runtime::is_null(*total.find_entry("s"), 0)); } } + +TEST_CASE("empty join probe retains the first chunk identity", "[schema][join][identity]") { + runtime::Table right; + right.add_column("k", Column{7}); + std::vector inputs; + for (std::size_t i = 0; i < 2; ++i) { + runtime::Chunk chunk; + chunk.add_column("k", Column{1}); + chunk.add_column("v", Column{10.0}); + chunk.sequence = 42 + i; + chunk.row_offset = 100 + i; + inputs.push_back(std::move(chunk)); + } + const std::vector keys{"k"}; + runtime::ExecutionContext exec; + exec.parallel_threads = 1; + auto op = runtime::make_chunked_inner_join_operator( + std::make_unique(std::move(inputs)), std::move(right), &keys, exec); + auto first = op->next(); + REQUIRE(first.has_value()); + REQUIRE(first->has_value()); + CHECK((**first).rows() == 0); + CHECK((**first).columns.size() == 2); + CHECK((**first).sequence == 42); + CHECK((**first).row_offset == 100); + for (int i = 0; i < 2; ++i) { + auto end = op->next(); + REQUIRE(end.has_value()); + CHECK_FALSE(end->has_value()); + } +} From 53e0300d88440904b47d061c77a3abe9fc85b9e6 Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Sun, 20 Sep 2026 22:48:30 +0200 Subject: [PATCH 26/42] Inline shared let bindings in whole-program lowering `lower_program` asks `lower_script` to share a `let` referenced from several table positions: the plan comes back separately in `shared_bindings` and every reference is left as a `Scan(name)` for the batch executor to resolve through its registry. It then handled `sinks` (a clear error) and `preamble` (spliced in) -- and dropped the shared bindings on the floor. The scans dangled, and the emitter aborted: let scaffold = Table(3)[update { k = 7 }]; (scaffold join scaffold on k)[select { n = count() }]; ibex_compile: ScanNode cannot be emitted `lower()` has to return one self-contained tree, so the plans are now spliced back in at their references, in declaration order, across the preamble, the result and later bindings' plans. A shared binding has two or more references by construction, so all but one are clones -- and a clone is a second evaluation. Clones go through `clone_replayable_subplan`, so a binding drawing from `rand_uniform()` is a lowering error naming the binding rather than a compiled program quietly disagreeing with `ibex_eval`, which materializes it once. `Construct` was missing from the replayable allow-list, which refused even `Table(3)[update { k = 7 }]`; both its forms are handled now. The walk for the largest node id existed in three copies, so it becomes one `ir::max_node_id` beside the clone whose id counter needs it. The two `test_lower.cpp` cases were run against the unfixed tree first: without the splice the plan keeps two dangling scans and no Construct, and the `rand_uniform()` case returns a plan where an error belongs. 1946 tests green, parity 44/44 exit 0. Co-Authored-By: Claude Opus 5 --- include/ibex/ir/node.hpp | 12 ++ include/ibex/ir/replayable.hpp | 8 + src/ir/count_distinct_reduction.cpp | 23 +-- src/ir/probed_key_restriction.cpp | 22 +-- src/ir/replayable.cpp | 45 +++++ src/parser/lower.cpp | 156 ++++++++++++++++++ .../cases/shared_binding_double_use.ibex | 19 +++ tests/test_lower.cpp | 49 ++++++ 8 files changed, 292 insertions(+), 42 deletions(-) create mode 100644 tests/parity/cases/shared_binding_double_use.ibex diff --git a/include/ibex/ir/node.hpp b/include/ibex/ir/node.hpp index 6fbcc226..7500a5a4 100644 --- a/include/ibex/ir/node.hpp +++ b/include/ibex/ir/node.hpp @@ -827,6 +827,12 @@ class UpdateNode final : public Node { [[nodiscard]] auto tuple_fields() const noexcept -> const std::vector& { return tuple_fields_; } + /// A tuple field's `source` is a nested plan that hangs off the field + /// rather than off `children()`, so a pass that rewrites subplans needs to + /// reach it here. + [[nodiscard]] auto mutable_tuple_fields() noexcept -> std::vector& { + return tuple_fields_; + } [[nodiscard]] auto group_by() const noexcept -> const std::vector& { return group_by_; } @@ -1135,6 +1141,12 @@ class ConstructNode final : public Node { [[nodiscard]] auto columns() const noexcept -> const std::vector& { return columns_; } + /// A column's `expr_node` is a nested plan that hangs off the column rather + /// than off `children()`, so a pass that rewrites subplans needs to reach + /// it here. + [[nodiscard]] auto mutable_columns() noexcept -> std::vector& { + return columns_; + } /// Non-null for the `Table(n)` form; null for the `Table { ... }` literal form. [[nodiscard]] auto row_count() const noexcept -> const std::optional& { diff --git a/include/ibex/ir/replayable.hpp b/include/ibex/ir/replayable.hpp index 1539ca7b..263ecc53 100644 --- a/include/ibex/ir/replayable.hpp +++ b/include/ibex/ir/replayable.hpp @@ -40,4 +40,12 @@ namespace ibex::ir { /// True if `expr` may be evaluated a second time for the same reasons. [[nodiscard]] auto is_replayable_expr(const Expr& expr) -> bool; +/// The largest node id anywhere in `node`'s subtree, including a Program's +/// preamble and main node. +/// +/// It lives beside the clone because every caller of one needs the other: +/// `clone_replayable_subplan` hands out fresh ids from a counter, and the +/// counter has to start past every id already in the plan. +[[nodiscard]] auto max_node_id(const Node& node) -> std::uint64_t; + } // namespace ibex::ir diff --git a/src/ir/count_distinct_reduction.cpp b/src/ir/count_distinct_reduction.cpp index 891f4201..e0e27ac9 100644 --- a/src/ir/count_distinct_reduction.cpp +++ b/src/ir/count_distinct_reduction.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -16,24 +17,6 @@ namespace ibex::ir { namespace { -void collect_max_id(const Node& node, std::uint64_t& max_id) { - max_id = std::max(max_id, node.id().value); - for (const auto& child : node.children()) { - if (child) { - collect_max_id(*child, max_id); - } - } - if (node.kind() == NodeKind::Program) { - const auto& prog = node_cast(node); - for (const auto& pre : prog.preamble()) { - if (pre) { - collect_max_id(*pre, max_id); - } - } - collect_max_id(prog.main_node(), max_id); - } -} - /// `node` is an Aggregate; return its fused replacement, or `node` unchanged. auto try_fuse(NodePtr node, std::uint64_t& next_id, const SourceSchemas& sources) -> NodePtr { const auto& agg = node_cast(*node); @@ -126,9 +109,7 @@ auto fuse_distinct_count_to_count_distinct(NodePtr root, const SourceSchemas& so if (!root) { return root; } - std::uint64_t next_id = 0; - collect_max_id(*root, next_id); - ++next_id; + std::uint64_t next_id = max_node_id(*root) + 1; return walk(std::move(root), next_id, sources); } diff --git a/src/ir/probed_key_restriction.cpp b/src/ir/probed_key_restriction.cpp index b2f529b2..b1b9006c 100644 --- a/src/ir/probed_key_restriction.cpp +++ b/src/ir/probed_key_restriction.cpp @@ -18,24 +18,6 @@ namespace ibex::ir { namespace { -void collect_max_id(const Node& node, std::uint64_t& max) { - max = std::max(max, node.id().value); - for (const auto& child : node.children()) { - if (child != nullptr) { - collect_max_id(*child, max); - } - } - if (node.kind() == NodeKind::Program) { - const auto& program = node_cast(node); - for (const auto& pre : program.preamble()) { - if (pre != nullptr) { - collect_max_id(*pre, max); - } - } - collect_max_id(program.main_node(), max); - } -} - /// The join keys that name one of the aggregate's group keys on the right. /// /// Only those can be restricted: the semi join runs against the aggregate's @@ -245,9 +227,7 @@ auto restrict_aggregates_to_probed_keys(NodePtr root, const SourceStats& stats) if (root == nullptr) { return root; } - std::uint64_t next = 0; - collect_max_id(*root, next); - ++next; + std::uint64_t next = max_node_id(*root) + 1; rewrite(*root, stats, next); return root; } diff --git a/src/ir/replayable.cpp b/src/ir/replayable.cpp index 24591154..ddc262fd 100644 --- a/src/ir/replayable.cpp +++ b/src/ir/replayable.cpp @@ -122,6 +122,32 @@ auto clone_node_shallow(const Node& node, std::uint64_t& next) -> NodePtr { return std::make_unique(id, aggregate.group_by(), aggregate.aggregations()); } + case NodeKind::Construct: { + const auto& construct = node_cast(node); + if (const auto& rows = construct.row_count(); rows.has_value()) { + // `Table(n)`: no columns at all, the size lives here. Copying + // it from its columns is what broke inlining a `let` over one. + if (!is_replayable_expr(*rows)) { + return nullptr; + } + return std::make_unique(id, *rows); + } + // A literal column is just data. A column built from a nested plan + // owns it through a move-only handle, so -- as with a tuple field + // -- the node cannot be copied while it has one. + std::vector columns; + columns.reserve(construct.columns().size()); + for (const auto& column : construct.columns()) { + if (column.expr_node != nullptr) { + return nullptr; + } + columns.push_back(ConstructColumn{.name = column.name, + .elements = column.elements, + .valid = column.valid, + .expr_node = nullptr}); + } + return std::make_unique(id, std::move(columns)); + } case NodeKind::Update: { const auto& update = node_cast(node); // A tuple field owns a sub-plan through a move-only handle, so the @@ -165,4 +191,23 @@ auto clone_replayable_subplan(const Node& node, std::uint64_t& next) -> NodePtr return clone; } +auto max_node_id(const Node& node) -> std::uint64_t { + std::uint64_t max = node.id().value; + for (const auto& child : node.children()) { + if (child != nullptr) { + max = std::max(max, max_node_id(*child)); + } + } + if (node.kind() == NodeKind::Program) { + const auto& program = node_cast(node); + for (const auto& pre : program.preamble()) { + if (pre != nullptr) { + max = std::max(max, max_node_id(*pre)); + } + } + max = std::max(max, max_node_id(program.main_node())); + } + return max; +} + } // namespace ibex::ir diff --git a/src/parser/lower.cpp b/src/parser/lower.cpp index 00f37159..526fa5a1 100644 --- a/src/parser/lower.cpp +++ b/src/parser/lower.cpp @@ -66,6 +66,7 @@ #include #include #include +#include #include #include #include @@ -1428,6 +1429,158 @@ auto build_optimization_context(const EffectAnalysis& analysis) -> ir::Optimizat return context; } +/// How many times `name` is scanned anywhere in `node`'s subtree. +auto count_scans_of(const ir::Node& node, const std::string& name) -> std::size_t { + std::size_t count = 0; + if (node.kind() == ir::NodeKind::Scan && + ir::node_cast(node).source_name() == name) { + ++count; + } + for (const auto& child : node.children()) { + if (child != nullptr) { + count += count_scans_of(*child, name); + } + } + // Two node kinds own a nested plan that hangs off a field rather than off + // children(): a Construct column (`Table { c = binding[...] }`) and an + // Update tuple field. `splice_shared_plan` walks exactly the same set, so + // the count and the replacement cannot disagree. + if (node.kind() == ir::NodeKind::Construct) { + for (const auto& column : ir::node_cast(node).columns()) { + if (column.expr_node != nullptr) { + count += count_scans_of(*column.expr_node, name); + } + } + } + if (node.kind() == ir::NodeKind::Update) { + for (const auto& field : ir::node_cast(node).tuple_fields()) { + if (field.source != nullptr) { + count += count_scans_of(*field.source, name); + } + } + } + return count; +} + +/// Replace every `Scan(name)` in `node` with `plan`. +/// +/// `remaining` counts the scans still to be replaced across the whole script. +/// The last one takes `plan` itself; each earlier one gets a clone, and a clone +/// is a second evaluation, so it goes through `clone_replayable_subplan`. A +/// refused clone sets `failed` and stops the walk. +auto splice_shared_plan(ir::NodePtr node, const std::string& name, ir::NodePtr& plan, + std::size_t& remaining, std::uint64_t& next, bool& failed) -> ir::NodePtr { + if (node == nullptr || failed) { + return node; + } + if (node->kind() == ir::NodeKind::Scan && + ir::node_cast(*node).source_name() == name) { + --remaining; + if (remaining == 0) { + return std::move(plan); + } + auto clone = ir::clone_replayable_subplan(*plan, next); + if (clone == nullptr) { + failed = true; + return node; + } + return clone; + } + for (auto& child : node->mutable_children()) { + child = splice_shared_plan(std::move(child), name, plan, remaining, next, failed); + } + if (node->kind() == ir::NodeKind::Construct) { + for (auto& column : ir::node_cast(*node).mutable_columns()) { + if (column.expr_node != nullptr) { + column.expr_node = splice_shared_plan(std::move(column.expr_node), name, plan, + remaining, next, failed); + } + } + } + if (node->kind() == ir::NodeKind::Update) { + for (auto& field : ir::node_cast(*node).mutable_tuple_fields()) { + if (field.source != nullptr) { + field.source = splice_shared_plan(std::move(field.source), name, plan, remaining, + next, failed); + } + } + } + return node; +} + +/// Splice each shared binding's plan back in where its references scan it. +/// +/// `lower_script` shares a `let` that the batch executor materializes once, +/// leaving every reference to it as a `Scan(name)` the executor resolves +/// through its registry. `lower()` has to hand back ONE self-contained tree and +/// its callers -- the emitter, the parity runner -- have no such registry: the +/// emitter aborts with "ScanNode cannot be emitted". The plans used to be +/// dropped on the floor here, so `let t = Table(3)[update { k = 7 }]; +/// (t join t on k)` aborted the compiler. +/// +/// A shared binding has two or more references by construction, so all but one +/// are clones, and a clone is a second evaluation. A binding drawing from +/// `rand_uniform()` would then answer differently per reference, while the +/// batch executor -- materializing once -- answers the same everywhere. That is +/// an error here rather than a quiet divergence between the two engines. +/// +/// Bindings are spliced in declaration order, into the preamble, the result and +/// each other's plans, since a later binding may reference an earlier one. +auto inline_shared_bindings(ScriptPlan& plan) -> std::optional { + std::uint64_t next = 0; + const auto note_ids = [&next](const ir::NodePtr& node) { + if (node != nullptr) { + next = std::max(next, ir::max_node_id(*node)); + } + }; + for (const auto& node : plan.preamble) { + note_ids(node); + } + for (const auto& shared : plan.shared_bindings) { + note_ids(shared.plan); + } + note_ids(plan.result); + ++next; + + for (std::size_t i = 0; i < plan.shared_bindings.size(); ++i) { + auto& shared = plan.shared_bindings[i]; + const auto count_in = [&shared](const ir::NodePtr& node) -> std::size_t { + return node == nullptr ? 0 : count_scans_of(*node, shared.name); + }; + std::size_t remaining = count_in(plan.result); + for (const auto& node : plan.preamble) { + remaining += count_in(node); + } + for (std::size_t j = i + 1; j < plan.shared_bindings.size(); ++j) { + remaining += count_in(plan.shared_bindings[j].plan); + } + if (remaining == 0) { + // Every reference was rewritten away; the binding is dead. + continue; + } + bool failed = false; + for (auto& node : plan.preamble) { + node = splice_shared_plan(std::move(node), shared.name, shared.plan, remaining, next, + failed); + } + for (std::size_t j = i + 1; j < plan.shared_bindings.size(); ++j) { + plan.shared_bindings[j].plan = + splice_shared_plan(std::move(plan.shared_bindings[j].plan), shared.name, + shared.plan, remaining, next, failed); + } + plan.result = splice_shared_plan(std::move(plan.result), shared.name, shared.plan, + remaining, next, failed); + if (failed) { + return LowerError{.message = "let '" + shared.name + + "' is referenced more than once but cannot be " + "evaluated twice: it draws from a generator or an " + "unclassified extern"}; + } + } + plan.shared_bindings.clear(); + return std::nullopt; +} + class Lowerer { public: explicit Lowerer( @@ -1692,6 +1845,9 @@ class Lowerer { return std::unexpected( LowerError{.message = "table-consuming extern calls require lower_script()"}); } + if (auto err = inline_shared_bindings(*plan); err.has_value()) { + return std::unexpected(*err); + } if (!plan->preamble.empty()) { return builder_.program(std::move(plan->preamble), std::move(plan->result)); } diff --git a/tests/parity/cases/shared_binding_double_use.ibex b/tests/parity/cases/shared_binding_double_use.ibex new file mode 100644 index 00000000..472404bc --- /dev/null +++ b/tests/parity/cases/shared_binding_double_use.ibex @@ -0,0 +1,19 @@ +// A `let` referenced from two table positions and expensive enough that the +// batch executor materializes it once. The compiled path has no registry to +// resolve such a binding against, so whole-program lowering splices the plan +// back in at each reference -- it used to drop it, and the emitter aborted with +// "ScanNode cannot be emitted". +// +// Unlike a lowering bug, this one IS visible here: a dropped plan leaves a +// dangling scan the emitter refuses outright rather than an answer both sides +// agree on. +let scaffold = Table(3)[update { k = 7 }]; +(scaffold join scaffold on k)[select { n = count() }]; + +// The same binding reached from two separate statements rather than from two +// arms of one expression. Both sides of the join carry `v`, so one is renamed +// away first -- the point here is the shared binding, not name collision. +let base = Table { k = [1, 2, 3], v = [10, 20, 30] }; +let doubled = base[update { w = v * 2 }][filter w > 20][select { k = k, w = w }]; +doubled[select { n = count() }]; +(doubled join base on k)[order { k asc }]; diff --git a/tests/test_lower.cpp b/tests/test_lower.cpp index 80602890..e02c3920 100644 --- a/tests/test_lower.cpp +++ b/tests/test_lower.cpp @@ -1251,6 +1251,55 @@ scaffold; CHECK(std::get(count->value) == 3); } +TEST_CASE("Lower inlines a shared binding referenced from several places") { + // A `let` referenced twice and expensive enough to be worth materializing + // is SHARED: `lower_script` hands the plan back separately and leaves every + // reference as a `Scan(name)` for the batch executor to resolve through its + // registry. Whole-program `lower()` has no registry and must return one + // self-contained tree, so it splices the plan back in. It used to drop the + // plan instead, and the emitter aborted on the dangling scan with + // "ScanNode cannot be emitted". + auto result = lower_source(R"( +let scaffold = Table(3)[update { k = 7 }]; +(scaffold join scaffold on k)[select { n = count() }]; +)"); + REQUIRE(result.has_value()); + + std::size_t scans = 0; + std::size_t constructs = 0; + const auto count_kinds = [&](auto&& self, const ir::Node& node) -> void { + if (node.kind() == ir::NodeKind::Scan) { + ++scans; + } + if (node.kind() == ir::NodeKind::Construct) { + ++constructs; + } + for (const auto& child : node.children()) { + if (child != nullptr) { + self(self, *child); + } + } + }; + count_kinds(count_kinds, *result.value()); + // Nothing left for a registry to resolve, and both sides of the join have + // their own copy of the frame. + CHECK(scans == 0); + CHECK(constructs == 2); +} + +TEST_CASE("Lower refuses to inline a shared binding it cannot evaluate twice") { + // Splicing gives every reference but one a clone, and a clone is a second + // evaluation. The batch executor materializes a shared binding ONCE, so a + // binding that draws fresh values per evaluation would answer differently + // under the two engines. That is an error, not a silent divergence. + auto result = lower_source(R"( +let noisy = Table(3)[update { k = rand_uniform() }]; +(noisy join noisy on k)[select { n = count() }]; +)"); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().message.find("noisy") != std::string::npos); +} + TEST_CASE("Lower decorrelates a scalar subquery into an aggregate plus a left join") { auto result = lower_source(std::string(kCorrelatedSources) + R"( From c67c045c105195a82fda3ec6121b38e4165c72f2 Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Mon, 21 Sep 2026 08:06:21 +0200 Subject: [PATCH 27/42] Switch to member operator==, it is used --- src/runtime/join_chunked.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/runtime/join_chunked.cpp b/src/runtime/join_chunked.cpp index 6ff49f3c..cbcecca0 100644 --- a/src/runtime/join_chunked.cpp +++ b/src/runtime/join_chunked.cpp @@ -237,7 +237,7 @@ struct JoinHashIndex { struct PairKey { std::uint64_t a = 0; std::uint64_t b = 0; - [[nodiscard]] friend auto operator==(const PairKey&, const PairKey&) -> bool = default; + [[nodiscard]] auto operator==(const PairKey&) const -> bool = default; }; struct PairKeyHash { auto operator()(const PairKey& key) const noexcept -> std::size_t { @@ -1818,7 +1818,7 @@ struct PrecomputedHashProbeInput { using HashProbeInput = std::variant; -/// Inner hash join for single-key no-predicate joins. +/// Inner hash join for no-predicate joins on a single key or two Int64 keys. /// /// Two execution modes: /// - Stream: right is small (<= kStreamRightThreshold). Build a chained From bd909c330da4c9d429f455e872ced196aba769a0 Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Mon, 21 Sep 2026 08:45:48 +0200 Subject: [PATCH 28/42] Fix aggregate edge cases and reuse scalar subqueries --- src/parser/lower.cpp | 191 ++++++++++++-- src/runtime/aggregate.cpp | 17 +- src/runtime/aggregate_chunked.cpp | 100 ++++++-- tests/CMakeLists.txt | 1 + .../cases/repeated_scalar_subquery.ibex | 10 + tests/test_aggregate_regressions.cpp | 233 ++++++++++++++++++ tests/test_interpreter.cpp | 32 +++ tests/test_lower.cpp | 82 ++++++ 8 files changed, 616 insertions(+), 50 deletions(-) create mode 100644 tests/parity/cases/repeated_scalar_subquery.ibex create mode 100644 tests/test_aggregate_regressions.cpp diff --git a/src/parser/lower.cpp b/src/parser/lower.cpp index 526fa5a1..99edae16 100644 --- a/src/parser/lower.cpp +++ b/src/parser/lower.cpp @@ -73,6 +73,7 @@ #include #include +#include #include #include #include @@ -657,6 +658,133 @@ auto unwrap_group(const Expr& expr) -> const Expr& { return *current; } +/// Conservative structural identity for scalar-subquery reuse. Parentheses do +/// not matter; names, literal types and every supported clause field do. An +/// unhandled AST form simply misses this optimization. Purity is checked on +/// the lowered plan separately, so matching syntax never implies safe reuse. +auto same_subquery_expr(const Expr* lhs, const Expr* rhs) -> bool; + +auto same_subquery_fields(const std::vector& lhs, const std::vector& rhs) -> bool { + return std::ranges::equal(lhs, rhs, [](const Field& a, const Field& b) { + return a.name == b.name && same_subquery_expr(a.expr.get(), b.expr.get()); + }); +} + +auto same_subquery_clause(const Clause& lhs, const Clause& rhs) -> bool { + return std::visit( + [&](const auto& a) -> bool { + using T = std::decay_t; + const auto* b = std::get_if(&rhs); + if (b == nullptr) + return false; + if constexpr (std::is_same_v) { + return same_subquery_expr(a.predicate.get(), b->predicate.get()); + } else if constexpr (std::is_same_v || + std::is_same_v) { + if (!a.tuple_fields.empty() || !b->tuple_fields.empty() || !a.map_fields.empty() || + !b->map_fields.empty()) + return false; + if constexpr (std::is_same_v) { + if (!same_subquery_expr(a.merge_expr.get(), b->merge_expr.get()) || + !same_subquery_expr(a.guard.get(), b->guard.get())) + return false; + } + return same_subquery_fields(a.fields, b->fields); + } else if constexpr (std::is_same_v || + std::is_same_v) { + return same_subquery_fields(a.fields, b->fields); + } else if constexpr (std::is_same_v) { + return same_subquery_fields(a.keys, b->keys); + } else if constexpr (std::is_same_v) { + return std::ranges::equal(a.keys, b->keys, + [](const OrderKey& x, const OrderKey& y) { + return x.name == y.name && x.ascending == y.ascending; + }); + } else if constexpr (std::is_same_v || std::is_same_v) { + return same_subquery_expr(a.count.get(), b->count.get()); + } else { + return false; + } + }, + lhs); +} + +auto same_subquery_expr(const Expr* lhs, const Expr* rhs) -> bool { + if (lhs == nullptr || rhs == nullptr) + return lhs == rhs; + const Expr& left = unwrap_group(*lhs); + const Expr& right = unwrap_group(*rhs); + return std::visit( + [&](const auto& a) -> bool { + using T = std::decay_t; + const auto* b = std::get_if(&right.node); + if (b == nullptr) + return false; + if constexpr (std::is_same_v) { + return a.name == b->name && a.lexical == b->lexical; + } else if constexpr (std::is_same_v) { + return std::visit( + [&](const auto& value) -> bool { + using V = std::decay_t; + const auto* other = std::get_if(&b->value); + if (other == nullptr) + return false; + if constexpr (std::is_same_v) { + return value.text == other->text; + } else if constexpr (std::is_same_v) { + return std::bit_cast(value) == + std::bit_cast(*other); + } else { + return value == *other; + } + }, + a.value); + } else if constexpr (std::is_same_v) { + return a.callee == b->callee && + std::ranges::equal(a.args, b->args, + [](const ExprPtr& x, const ExprPtr& y) { + return same_subquery_expr(x.get(), y.get()); + }) && + std::ranges::equal( + a.named_args, b->named_args, [](const NamedArg& x, const NamedArg& y) { + return x.name == y.name && + same_subquery_expr(x.value.get(), y.value.get()); + }); + } else if constexpr (std::is_same_v) { + return a.op == b->op && same_subquery_expr(a.expr.get(), b->expr.get()); + } else if constexpr (std::is_same_v) { + return a.op == b->op && same_subquery_expr(a.left.get(), b->left.get()) && + same_subquery_expr(a.right.get(), b->right.get()); + } else if constexpr (std::is_same_v) { + return same_subquery_expr(a.base.get(), b->base.get()) && + std::ranges::equal(a.clauses, b->clauses, same_subquery_clause); + } else if constexpr (std::is_same_v) { + return a.kind == b->kind && a.keys == b->keys && a.suffix == b->suffix && + a.null_match == b->null_match && a.expect == b->expect && + a.take == b->take && same_subquery_expr(a.left.get(), b->left.get()) && + same_subquery_expr(a.right.get(), b->right.get()) && + a.predicate.has_value() == b->predicate.has_value() && + (!a.predicate.has_value() || + same_subquery_expr(a.predicate->get(), b->predicate->get())); + } else if constexpr (std::is_same_v) { + return std::ranges::equal(a.elements, b->elements, + [](const ExprPtr& x, const ExprPtr& y) { + return same_subquery_expr(x.get(), y.get()); + }); + } else if constexpr (std::is_same_v) { + return same_subquery_expr(a.row_count.get(), b->row_count.get()) && + std::ranges::equal(a.columns, b->columns, + [](const TableColumnDef& x, const TableColumnDef& y) { + return x.name == y.name && + same_subquery_expr(x.expr.get(), y.expr.get()); + }); + } else { + return false; + } + }, + left.node); +} + /// The call `expr` is, if it calls `callee`; null otherwise. auto as_call(const Expr& expr, std::string_view callee) -> const CallExpr* { const auto* call = std::get_if(&unwrap_group(expr).node); @@ -3470,11 +3598,18 @@ class Lowerer { bool counts = false; }; + struct ReusableScalarSubquery { + const Expr* argument; + std::string alias; + bool coalesce_count; + }; + /// Lower a `filter` predicate over `input`. /// /// A predicate with no `scalar(...)` subquery becomes a plain FilterNode. /// One with subqueries is decorrelated: each subquery is evaluated once, as /// an aggregate over its captured key, and left-joined to the outer rows. + /// Identical repeatable subqueries in this filter share that result column. /// The subquery must never be run per outer row. /// /// filter local && ps_supplycost == scalar(inner[filter ps_partkey == outer(p_partkey), @@ -3554,9 +3689,10 @@ class Lowerer { } std::vector residual; + std::vector reusable; residual.reserve(correlated.size()); for (const auto* conjunct : correlated) { - auto rewritten = decorrelate(*conjunct, input); + auto rewritten = decorrelate(*conjunct, input, reusable); if (!rewritten.has_value()) { return std::unexpected(rewritten.error()); } @@ -3579,7 +3715,8 @@ class Lowerer { /// Join the subquery in `conjunct` onto `input` and return the comparison /// rewritten against the generated scalar column. - auto decorrelate(const Expr& conjunct, ir::NodePtr& input) + auto decorrelate(const Expr& conjunct, ir::NodePtr& input, + std::vector& reusable) -> std::expected { const auto* comparison = std::get_if(&conjunct.node); if (comparison == nullptr || !is_compare_op(comparison->op)) { @@ -3609,31 +3746,45 @@ class Lowerer { .message = "outer(): a capture is only valid inside a scalar(...) subquery"}); } - const std::string alias = next_scalar_alias(*input); - auto subplan = lower_scalar_subquery(*subquery, *input, alias); - if (!subplan.has_value()) { - return std::unexpected(subplan.error()); + std::string alias; + bool coalesce_count = false; + const auto found = std::ranges::find_if(reusable, [&](const ReusableScalarSubquery& prior) { + return same_subquery_expr(prior.argument, subquery->args.front().get()); + }); + if (found != reusable.end()) { + alias = found->alias; + coalesce_count = found->coalesce_count; + } else { + alias = next_scalar_alias(*input); + auto subplan = lower_scalar_subquery(*subquery, *input, alias); + if (!subplan.has_value()) { + return std::unexpected(subplan.error()); + } + const bool correlated = !subplan->keys.empty(); + coalesce_count = correlated && subplan->counts; + // Reuse only repeatable plans. In particular, identical calls to a + // generator or an extern must keep their independent evaluations. + std::uint64_t unused_id = 0; + if (ir::clone_replayable_subplan(*subplan->plan, unused_id) != nullptr) { + reusable.push_back({subquery->args.front().get(), alias, coalesce_count}); + } + + // Missing correlated groups are null (coalesced to 0 for counts). + // An uncorrelated aggregate always has one row, even over an empty + // input, so a cross join broadcasts it without dropping outer rows. + auto join = builder_.join(correlated ? ir::JoinKind::Left : ir::JoinKind::Cross, + std::move(subplan->keys)); + join->add_child(std::move(input)); + join->add_child(std::move(subplan->plan)); + input = std::move(join); } - // A correlated subquery joins on its captured keys, so an outer row with - // 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(); - auto join = builder_.join(correlated ? ir::JoinKind::Left : ir::JoinKind::Cross, - std::move(subplan->keys)); - join->add_child(std::move(input)); - join->add_child(std::move(subplan->plan)); - input = std::move(join); - auto lowered_value = lower_expr_to_ir(value); if (!lowered_value.has_value()) { return std::unexpected(lowered_value.error()); } auto scalar_ref = ir::make_expr_ptr(ir::Expr{.node = ir::ColumnRef{.name = alias}}); - if (correlated && subplan->counts) { + if (coalesce_count) { ir::CallExpr coalesce{.callee = "coalesce", .args = {}, .named_args = {}}; coalesce.args.push_back(std::move(scalar_ref)); coalesce.args.push_back( diff --git a/src/runtime/aggregate.cpp b/src/runtime/aggregate.cpp index f213cc29..b3255500 100644 --- a/src/runtime/aggregate.cpp +++ b/src/runtime/aggregate.cpp @@ -285,8 +285,12 @@ auto aggregate_table(const Table& input, const std::vector& group has_complex_agg = true; numeric_only = false; } else if (agg.func == ir::AggFunc::First || agg.func == ir::AggFunc::Last) { - // numeric First/Last are handled in the fast path; only fall back for strings - if (item.kind == ExprType::String) { + // Numeric First/Last use fixed-width slots. Boolean values need + // the generic scalar path, including when no other aggregate does. + if (item.kind == ExprType::Bool) { + has_complex_agg = true; + } + if (item.kind != ExprType::Int && item.kind != ExprType::Double) { numeric_only = false; } } else if (agg.func != ir::AggFunc::Count && agg.func != ir::AggFunc::Sum && @@ -723,6 +727,9 @@ auto aggregate_table(const Table& input, const std::vector& group append_scalar(*column, slot.int_value); } else if (slot.kind == ExprType::Double) { append_scalar(*column, slot.double_value); + } else if (slot.kind == ExprType::Bool) { + append_scalar(*column, + slot.has_value ? slot.text_value : ScalarValue{false}); } else { append_text_cell(*column, slot.text_value); } @@ -1179,8 +1186,10 @@ auto aggregate_table(const Table& input, const std::vector& group if (const auto* ic = std::get_if>(&col)) { count_with([&](std::size_t row) { return (*ic)[row]; }); } else if (const auto* dc = std::get_if>(&col)) { - count_with( - [&](std::size_t row) { return std::bit_cast((*dc)[row]); }); + count_with([&](std::size_t row) { + const double value = (*dc)[row]; + return std::bit_cast(value == 0.0 ? 0.0 : value); + }); } else if (const auto* bc = std::get_if>(&col)) { count_with( [&](std::size_t row) -> std::uint8_t { return (*bc)[row] ? 1U : 0U; }); diff --git a/src/runtime/aggregate_chunked.cpp b/src/runtime/aggregate_chunked.cpp index f836d258..b4c94586 100644 --- a/src/runtime/aggregate_chunked.cpp +++ b/src/runtime/aggregate_chunked.cpp @@ -579,10 +579,13 @@ class HashAggregateState final { // 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 || - (keeps_a_value && kind == ExprType::String); + // other function stays numeric-only. Boolean First/Last use integer slots. + const bool supported = + kind == ExprType::Int || kind == ExprType::Double || + agg.func == ir::AggFunc::CountDistinct || + (keeps_a_value && kind == ExprType::String) || + ((agg.func == ir::AggFunc::First || agg.func == ir::AggFunc::Last) && + kind == ExprType::Bool); if (!supported) { return "aggregate of column '" + agg.column.name + "': this function does not support the column's type"; @@ -3583,6 +3586,8 @@ class HashAggregateState final { slot.double_value = std::get>(*entry.column)[row]; } else if (plan_[a].kind == ExprType::Int) { slot.int_value = std::get>(*entry.column)[row]; + } else if (plan_[a].kind == ExprType::Bool) { + slot.int_value = std::get>(*entry.column)[row] ? 1 : 0; } else { std::string value; if (plan_[a].categorical) { @@ -3934,9 +3939,10 @@ class HashAggregateState final { const ExprType kind = plan_[a].kind; const auto bits_at = [&](std::size_t row) -> std::uint64_t { switch (kind) { - case ExprType::Double: - return std::bit_cast( - std::get>(*entry.column).data()[row]); + case ExprType::Double: { + const double value = std::get>(*entry.column).data()[row]; + return std::bit_cast(value == 0.0 ? 0.0 : value); + } case ExprType::Date: return static_cast( std::get>(*entry.column)[row].days); @@ -4040,7 +4046,19 @@ class HashAggregateState final { entry.validity.has_value() ? &*entry.validity : nullptr; const bool has_nulls = validity != nullptr; - if (plan_[agg_i].kind == ExprType::Double) { + if (plan_[agg_i].kind == ExprType::Bool) { + const auto& values = std::get>(*entry.column); + for (std::size_t row = begin; row < rows; ++row) { + if (has_nulls && !(*validity)[row]) { + continue; + } + auto& slot = slot_for(gids[row]); + if (plan_[agg_i].func == ir::AggFunc::Last || !slot.present()) { + slot.int_value = values[row] ? 1 : 0; + slot.mark_present(); + } + } + } else if (plan_[agg_i].kind == ExprType::Double) { const double* data = std::get>(*entry.column).data(); switch (plan_[agg_i].func) { case ir::AggFunc::Sum: @@ -4443,7 +4461,15 @@ class HashAggregateState final { } }; - if (plan_[agg_i].kind == ExprType::Double) { + if (plan_[agg_i].kind == ExprType::Bool) { + const auto& values = std::get>(*entry.column); + each([&](std::size_t row) { + if (func == ir::AggFunc::Last || !slot.present()) { + slot.int_value = values[row] ? 1 : 0; + slot.mark_present(); + } + }); + } else if (plan_[agg_i].kind == ExprType::Double) { const double* data = std::get>(*entry.column).data(); switch (func) { case ir::AggFunc::Sum: @@ -4869,6 +4895,8 @@ class HashAggregateState final { column = Column{}; } else if (plan_[i].kind == ExprType::Int) { column = Column{}; + } else if (plan_[i].kind == ExprType::Bool) { + column = Column{}; } else if (plan_[i].categorical) { column = Column{}; } else { @@ -5073,6 +5101,8 @@ class HashAggregateState final { put_d(g, slot.double_value); } else if (plan_[i].kind == ExprType::Int) { put_i(g, slot.int_value); + } else if (plan_[i].kind == ExprType::Bool) { + std::get>(column).push_back(slot.int_value != 0); } else { append_text_cell(column, text_store_[(g * n_aggs_) + i]); } @@ -5659,7 +5689,7 @@ class ChunkedSortedAggregateOperator final : public Operator { if (chunk_res.value()->rows() == 0) { // Empty, but it still carries the input's columns and their // types. Keep the first one in case no chunk ever has rows. - if (!schema_only.has_value() && !chunk_res.value()->columns.empty()) { + if (!schema_only.has_value()) { schema_only = std::move(*chunk_res.value()); } continue; @@ -5708,8 +5738,8 @@ class ChunkedSortedAggregateOperator final : public Operator { // The input is grouped-contiguous iff the first |group_by| ordering keys // are exactly the group_by columns (as a set; direction and intra-prefix - // order don't matter for contiguity). Nullable group keys fall back, since - // the streaming key compare ignores validity. + // order don't matter for contiguity). Initially nullable keys use the hash + // path; sorted comparisons also handle validity introduced in later chunks. [[nodiscard]] auto sorted_on_group_by(const Chunk& chunk) const -> bool { if (!columns_.has_value() || group_by_->empty()) { return false; // unbound, or a global aggregate: let the hash path handle it @@ -5718,10 +5748,12 @@ class ChunkedSortedAggregateOperator final : public Operator { return false; } const auto& ordering = *chunk.ordering(); - for (std::size_t i = 0; i < group_by_->size(); ++i) { + std::vector matched(group_by_->size(), false); + for (const auto& g : *group_by_) { bool in_group = false; - for (const auto& g : *group_by_) { - if (g.name == ordering[i].name) { + for (std::size_t i = 0; i < group_by_->size(); ++i) { + if (!matched[i] && g.name == ordering[i].name) { + matched[i] = true; in_group = true; break; } @@ -5735,7 +5767,7 @@ class ChunkedSortedAggregateOperator final : public Operator { }); } - // Non-numeric First/Last (string/categorical) has no group-at-a-time + // Non-numeric First/Last/Min/Max has no group-at-a-time // implementation here — route it to the hash operator, which handles any // type. Numeric First/Last streams natively (see accumulate_typed). [[nodiscard]] auto needs_hash_fallback(const Chunk& first) const -> bool { @@ -5756,7 +5788,8 @@ class ChunkedSortedAggregateOperator final : public Operator { std::holds_alternative>(*first.columns[*input_idx].column)) { return true; } - if ((agg.func != ir::AggFunc::First && agg.func != ir::AggFunc::Last) || + if ((agg.func != ir::AggFunc::First && agg.func != ir::AggFunc::Last && + agg.func != ir::AggFunc::Min && agg.func != ir::AggFunc::Max) || !input_idx.has_value()) { continue; } @@ -5901,10 +5934,10 @@ class ChunkedSortedAggregateOperator final : public Operator { if (!columns_.has_value()) { return "ChunkedSortedAggregateOperator: column mapping not bound"; } - std::vector key_cols; + std::vector key_cols; key_cols.reserve(group_by_->size()); for (const std::size_t index : columns_->group_by) { - key_cols.push_back(chunk.columns[index].column.get()); + key_cols.push_back(&chunk.columns[index]); } std::vector agg_entries(n_aggs_, nullptr); for (std::size_t i = 0; i < n_aggs_; ++i) { @@ -5939,11 +5972,13 @@ class ChunkedSortedAggregateOperator final : public Operator { return std::nullopt; } - void start_group(const std::vector& key_cols, std::size_t row) { + void start_group(const std::vector& key_cols, std::size_t row) { open_key_.clear(); open_key_.reserve(key_cols.size()); + open_key_null_.clear(); for (const auto* col : key_cols) { - open_key_.push_back(scalar_from_column(*col, row)); + open_key_.push_back(scalar_from_column(*col->column, row)); + open_key_null_.push_back(is_null(*col, row)); } std::ranges::fill(cur_slots_, AggSlotCore{}); std::ranges::fill(cur_scratch_, 0.0); @@ -5953,10 +5988,12 @@ class ChunkedSortedAggregateOperator final : public Operator { // Whether `row` continues the currently open group. Only called at run // anchors (group boundaries and chunk starts), so the scalar build is // paid per group, not per row. - [[nodiscard]] auto row_matches_open(const std::vector& key_cols, + [[nodiscard]] auto row_matches_open(const std::vector& key_cols, std::size_t row) const -> bool { for (std::size_t i = 0; i < key_cols.size(); ++i) { - if (scalar_from_column(*key_cols[i], row) != open_key_[i]) { + const bool null = is_null(*key_cols[i], row); + if (null != open_key_null_[i] || + (!null && scalar_from_column(*key_cols[i]->column, row) != open_key_[i])) { return false; } } @@ -5980,10 +6017,13 @@ class ChunkedSortedAggregateOperator final : public Operator { col); } - [[nodiscard]] static auto cells_equal(const std::vector& key_cols, + [[nodiscard]] static auto cells_equal(const std::vector& key_cols, std::size_t a, std::size_t b) -> bool { - return std::ranges::all_of(key_cols, - [a, b](const auto* col) { return cell_equal(*col, a, b); }); + return std::ranges::all_of(key_cols, [a, b](const auto* col) { + const bool a_null = is_null(*col, a); + const bool b_null = is_null(*col, b); + return a_null == b_null && (a_null || cell_equal(*col->column, a, b)); + }); } // Accumulate the contiguous row range [start, end) — all one group — into @@ -6124,6 +6164,13 @@ class ChunkedSortedAggregateOperator final : public Operator { void close_group() { for (std::size_t i = 0; i < group_by_->size(); ++i) { append_scalar(*out_columns_[i].column, open_key_[i]); + auto& validity = out_columns_[i].validity; + if (open_key_null_[i] && !validity.has_value()) { + validity = ValidityBitmap(pending_rows_, true); + } + if (validity.has_value()) { + validity->push_back(!open_key_null_[i]); + } } for (std::size_t i = 0; i < n_aggs_; ++i) { ColumnValue& column = *out_columns_[group_by_->size() + i].column; @@ -6227,6 +6274,7 @@ class ChunkedSortedAggregateOperator final : public Operator { static constexpr std::size_t kMomentScratch = 3; std::vector cur_scratch_; std::vector open_key_; + std::vector open_key_null_; // Output buffers for closed groups awaiting emission. std::vector out_columns_; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5a9826fe..cf9bc56d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -7,6 +7,7 @@ add_library(ibex_test_warning_suppressions INTERFACE) set(IBEX_TEST_SOURCES catch_string_view_apple.cpp + test_aggregate_regressions.cpp test_adbc_options.cpp test_arrow_c_data.cpp test_args.cpp diff --git a/tests/parity/cases/repeated_scalar_subquery.ibex b/tests/parity/cases/repeated_scalar_subquery.ibex new file mode 100644 index 00000000..79264fef --- /dev/null +++ b/tests/parity/cases/repeated_scalar_subquery.ibex @@ -0,0 +1,10 @@ +// Both comparisons reuse each scalar result. Missing correlated groups must +// still read as count 0, and an empty uncorrelated count must broadcast 0. +let parts = Table { k = [1, 2, 3], name = ["one", "two", "three"] }; +let supply = Table { k = [1, 1], v = [5, 7] }; +parts[filter + 0 == scalar(supply[filter k == outer(k), select { n = count() }]) && + scalar(supply[filter k == outer(k), select { n = count() }]) < 1 && + 0 == scalar(supply[filter v < 0, select { n = count() }]) && + scalar(supply[filter v < 0, select { n = count() }]) < 1 +]; diff --git a/tests/test_aggregate_regressions.cpp b/tests/test_aggregate_regressions.cpp new file mode 100644 index 00000000..21d0ce6d --- /dev/null +++ b/tests/test_aggregate_regressions.cpp @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Bob Jansen +#include +#include + +#include +#include + +#include "../src/runtime/aggregate_chunked_internal.hpp" +#include "../src/runtime/interpreter_internal.hpp" + +using namespace ibex; + +namespace { +class AggregateChunks final : public runtime::Operator { + public: + explicit AggregateChunks(std::vector chunks, bool require_release = false) + : chunks_(std::move(chunks)), require_release_(require_release) {} + auto next() -> std::expected, std::string> override { + if (require_release_ && !previous_.expired()) { + return std::unexpected("aggregate retained an input chunk instead of reducing it"); + } + if (pos_ == chunks_.size()) + return std::optional{}; + if (require_release_) + previous_ = chunks_[pos_].columns[1].column; + return std::optional{std::move(chunks_[pos_++])}; + } + + private: + std::vector chunks_; + std::size_t pos_ = 0; + bool require_release_ = false; + std::weak_ptr previous_; +}; +auto aggregate_chunks(std::vector chunks, const std::vector& keys, + const std::vector& aggs, bool require_release = false) { + runtime::ExecutionContext exec; + exec.parallel_threads = 1; + return runtime::MaterializeOperator( + runtime::make_chunked_aggregate_operator( + std::make_unique(std::move(chunks), require_release), &keys, + &aggs, exec, {}, std::nullopt)) + .run(); +} +void sorted(runtime::Chunk& c, std::vector keys = {{.name = "k"}}) { + c.set_properties(runtime::TableProperties::sorted_by(std::move(keys))); +} +auto ints(const runtime::Table& t, const char* name) -> const Column& { + return std::get>(*t.find(name)); +} +} // namespace + +TEST_CASE("duplicate ordering keys do not imply grouping contiguity", "[aggregate][audit]") { + runtime::Chunk c; + c.add_column("k", Column{1, 1, 1}); + c.add_column("v", Column{1, 2, 1}); + sorted(c, {{.name = "k"}, {.name = "k"}}); + auto out = aggregate_chunks({c}, {{.name = "k"}, {.name = "v"}}, + {{.func = ir::AggFunc::Count, .column = {}, .alias = "n"}}); + REQUIRE(out.has_value()); + REQUIRE(out->rows() == 2); + REQUIRE(ints(*out, "n")[0] == 2); +} + +TEST_CASE("sorted aggregate preserves null keys introduced in later chunks", "[aggregate][audit]") { + runtime::Chunk first; + first.add_column("k", Column{0}); + first.add_column("v", Column{10}); + sorted(first); + runtime::Chunk later; + later.add_column("k", Column{0, 0}); + later.columns[0].validity = runtime::ValidityBitmap(2, false); + later.add_column("v", Column{20, 30}); + sorted(later); + std::size_t preceding_groups = 0; + SECTION("null begins at chunk boundary") {} + SECTION("null begins after an output batch") { + Column keys; + Column values; + preceding_groups = 8192; + for (std::int64_t k = -8192; k <= 0; ++k) { + keys.push_back(k); + values.push_back(10); + } + first.columns.clear(); + first.add_column("k", std::move(keys)); + first.add_column("v", std::move(values)); + } + SECTION("null begins inside later chunk") { + later.columns[0].validity->set(0, true); + } + runtime::Chunk tail; + tail.add_column("k", Column{0}); + tail.columns[0].validity = runtime::ValidityBitmap(1, false); + tail.add_column("v", Column{50}); + sorted(tail); + const bool first_later_valid = (*later.columns[0].validity)[0]; + auto out = + aggregate_chunks({first, later, tail}, {{.name = "k"}}, + {{.func = ir::AggFunc::Sum, .column = {.name = "v"}, .alias = "s"}}); + REQUIRE(out.has_value()); + REQUIRE(out->rows() == preceding_groups + 2); + REQUIRE_FALSE(runtime::is_null(*out->find_entry("k"), preceding_groups)); + REQUIRE(runtime::is_null(*out->find_entry("k"), preceding_groups + 1)); + REQUIRE(ints(*out, "s")[preceding_groups] == (first_later_valid ? 30 : 10)); + REQUIRE(ints(*out, "s")[preceding_groups + 1] == (first_later_valid ? 80 : 100)); +} + +TEST_CASE("sorted string extrema use the supported aggregate path", "[aggregate][audit]") { + runtime::Chunk c; + c.add_column("k", Column{1, 1, 2}); + c.add_column("v", Column{"b", "a", "c"}); + sorted(c); + auto out = + aggregate_chunks({c}, {{.name = "k"}}, + {{.func = ir::AggFunc::Min, .column = {.name = "v"}, .alias = "lo"}, + {.func = ir::AggFunc::Max, .column = {.name = "v"}, .alias = "hi"}}); + REQUIRE(out.has_value()); + REQUIRE(std::get>(*out->find("lo"))[0] == "a"); + REQUIRE(std::get>(*out->find("hi"))[0] == "b"); +} + +TEST_CASE("boolean first and last work without another materializing aggregate", + "[aggregate][audit]") { + runtime::Chunk c; + c.add_column("k", Column{1, 1, 2}); + c.add_column("v", Column{true, false, true}); + SECTION("hash") {} + SECTION("sorted") { + sorted(c); + } + auto out = + aggregate_chunks({c}, {{.name = "k"}}, + {{.func = ir::AggFunc::First, .column = {.name = "v"}, .alias = "f"}, + {.func = ir::AggFunc::Last, .column = {.name = "v"}, .alias = "l"}}); + REQUIRE(out.has_value()); + REQUIRE(std::get>(*out->find("f"))[0]); + REQUIRE_FALSE(std::get>(*out->find("l"))[0]); +} + +TEST_CASE("count distinct treats signed zeros as equal", "[aggregate][audit]") { + runtime::Table t; + t.add_column("k", Column{1, 1, 1}); + t.add_column("v", Column{0.0, -0.0, 1.0}); + std::vector keys{{.name = "k"}}; + std::vector aggs{ + {.func = ir::AggFunc::CountDistinct, .column = {.name = "v"}, .alias = "n"}}; + SECTION("streaming") { + runtime::Chunk c; + c.columns = std::move(t.columns); + auto out = aggregate_chunks({c}, keys, aggs); + REQUIRE(out.has_value()); + REQUIRE(ints(*out, "n")[0] == 2); + } + SECTION("materialized") { + auto out = runtime::aggregate_table(t, keys, aggs); + REQUIRE(out.has_value()); + REQUIRE(ints(*out, "n")[0] == 2); + } +} + +TEST_CASE("global count keeps an empty columnless input", "[aggregate][audit]") { + auto parsed = parser::parse("Table(0)[select { n=count() }];"); + REQUIRE(parsed.has_value()); + auto lowered = parser::lower(*parsed); + REQUIRE(lowered.has_value()); + auto out = runtime::interpret(**lowered, {}); + REQUIRE(out.has_value()); + REQUIRE(out->rows() == 1); + REQUIRE(ints(*out, "n")[0] == 0); +} + +TEST_CASE("Boolean first last skip nulls and preserve all-null groups", + "[aggregate][audit][bool]") { + const bool global = GENERATE(false, true); + const bool all_null = GENERATE(false, true); + runtime::Table t; + t.add_column("k", Column{1, 1, 1, 1, 2, 2}); + t.add_column("v", Column{false, true, false, true, true, false}); + t.columns[1].validity = runtime::ValidityBitmap(6, false); + if (!all_null) { + t.columns[1].validity->set(1, true); + t.columns[1].validity->set(2, true); + } + std::vector keys; + if (!global) + keys.push_back({.name = "k"}); + std::vector aggs{ + {.func = ir::AggFunc::First, .column = {.name = "v"}, .alias = "f"}, + {.func = ir::AggFunc::Last, .column = {.name = "v"}, .alias = "l"}}; + std::expected out; + SECTION("streaming across chunks") { + const bool ordered = GENERATE(false, true); + std::vector chunks; + for (std::size_t begin = 0; begin < 6; begin += 2) { + runtime::Chunk c; + Column k; + Column v; + for (std::size_t row = begin; row < begin + 2; ++row) { + k.push_back(ints(t, "k")[row]); + v.push_back(std::get>(*t.find("v"))[row]); + } + c.add_column("k", std::move(k)); + c.add_column("v", std::move(v)); + c.columns[1].validity = runtime::ValidityBitmap(2, false); + for (std::size_t row = 0; row < 2; ++row) { + c.columns[1].validity->set(row, (*t.columns[1].validity)[begin + row]); + } + if (ordered) + sorted(c); + chunks.push_back(std::move(c)); + } + // A materializing fallback keeps the first column alive while fetching + // the next chunk; streaming must release it after reducing its rows. + out = aggregate_chunks(std::move(chunks), keys, aggs, true); + } + SECTION("materialized") { + out = runtime::aggregate_table(t, keys, aggs); + } + REQUIRE(out.has_value()); + REQUIRE(out->rows() == (global ? 1 : 2)); + REQUIRE(runtime::is_null(*out->find_entry("f"), 0) == all_null); + REQUIRE(runtime::is_null(*out->find_entry("l"), 0) == all_null); + if (!all_null) { + REQUIRE(std::get>(*out->find("f"))[0]); + REQUIRE_FALSE(std::get>(*out->find("l"))[0]); + } + if (!global) { + REQUIRE(runtime::is_null(*out->find_entry("f"), 1)); + REQUIRE(runtime::is_null(*out->find_entry("l"), 1)); + } +} diff --git a/tests/test_interpreter.cpp b/tests/test_interpreter.cpp index 9b083021..97104d5f 100644 --- a/tests/test_interpreter.cpp +++ b/tests/test_interpreter.cpp @@ -16125,3 +16125,35 @@ TEST_CASE("zero head retains typed columns", "[schema][head]") { CHECK(runtime::is_null(*out->find_entry("s"), 0)); } } + +TEST_CASE("Repeated scalar subqueries preserve missing and empty group semantics", + "[scalar_reuse]") { + std::string predicate; + std::vector expected; + SECTION("correlated minimum") { + const std::string q = + "scalar(supply[filter ps_partkey == outer(p_partkey) && ps_region == \"EU\", select { " + "m = min(ps_cost) }])"; + predicate = q + " > 0.0 && " + q + " < 5.0"; + expected = {1, 3}; + } + SECTION("correlated count supplies zero on both uses") { + const std::string q = + "scalar(supply[filter ps_partkey == outer(p_partkey) && ps_region == \"EU\", select { " + "n = count() }])"; + predicate = "0 == " + q + " && " + q + " < 1"; + expected = {4}; + } + SECTION("empty uncorrelated count") { + const std::string q = "scalar(supply[filter ps_cost < 0.0, select { n = count() }])"; + predicate = "0 == " + q + " && " + q + " < 1"; + expected = {1, 2, 3, 4}; + } + SECTION("empty uncorrelated minimum") { + const std::string q = "scalar(supply[filter ps_cost < 0.0, select { m = min(ps_cost) }])"; + predicate = "0.0 < " + q + " && " + q + " < 1.0"; + } + auto out = interpret_source(std::string(kSupplySources) + "parts[filter " + predicate + "];"); + REQUIRE(out.columns.size() == 2); + REQUIRE(int_column(out, "p_partkey") == expected); +} diff --git a/tests/test_lower.cpp b/tests/test_lower.cpp index e02c3920..6f76253e 100644 --- a/tests/test_lower.cpp +++ b/tests/test_lower.cpp @@ -1634,3 +1634,85 @@ TEST_CASE("Lower rejects left(...) / right(...) outside a join predicate") { CHECK(result.error().message.find("only valid in a join predicate") != std::string::npos); } } + +TEST_CASE("Lower reuses identical scalar subqueries within a filter", "[lower][scalar_reuse]") { + std::string subquery; + SECTION("correlated") { + subquery = + "scalar(supply[filter ps_partkey == outer(p_partkey), select { m = min(ps_cost) }])"; + } + SECTION("uncorrelated") { + subquery = "scalar(supply[select { m = min(ps_cost) }])"; + } + SECTION("joined inner table") { + subquery = + "scalar((supply join Table { key = [1, 2] } on { ps_partkey = key })" + "[filter ps_partkey == outer(p_partkey), select { m = min(ps_cost) }])"; + } + auto result = lower_source(std::string(kCorrelatedSources) + "parts[filter p_partkey < " + + subquery + " && (" + subquery + ") > p_partkey];"); + REQUIRE(result.has_value()); + std::vector kinds; + collect_kinds(**result, kinds); + REQUIRE(std::ranges::count(kinds, ir::NodeKind::Aggregate) == 1); + REQUIRE(std::ranges::count(kinds, ir::NodeKind::Join) == + (subquery.find(" join ") == std::string::npos ? 1 : 2)); +} + +TEST_CASE("Lower does not reuse volatile scalar subqueries", "[lower][scalar_reuse]") { + const std::string subquery = + "scalar(supply[filter rand_uniform(0.0, 1.0) > 0.5, select { m = min(ps_cost) }])"; + auto result = lower_source(std::string(kCorrelatedSources) + "parts[filter p_partkey < " + + subquery + " && p_partkey < " + subquery + "];"); + REQUIRE(result.has_value()); + std::vector kinds; + collect_kinds(**result, kinds); + REQUIRE(std::ranges::count(kinds, ir::NodeKind::Aggregate) == 2); +} + +TEST_CASE("Lower keeps different scalar subqueries independent", "[lower][scalar_reuse]") { + const std::string first = + "scalar(supply[filter ps_partkey == outer(p_partkey), select { m = min(ps_cost) }])"; + std::string second; + SECTION("aggregate differs") { + second = + "scalar(supply[filter ps_partkey == outer(p_partkey), select { m = max(ps_cost) }])"; + } + SECTION("local filter differs") { + second = + "scalar(supply[filter ps_partkey == outer(p_partkey) && ps_cost > 4.0, select { m = " + "min(ps_cost) }])"; + } + SECTION("capture differs") { + second = "scalar(supply[filter ps_partkey == outer(other), select { m = min(ps_cost) }])"; + } + auto result = lower_source(std::string(kCorrelatedSources) + + "parts[update { other = p_partkey + 1 }][filter p_partkey < " + + first + " && p_partkey < " + second + "];"); + REQUIRE(result.has_value()); + std::vector kinds; + collect_kinds(**result, kinds); + REQUIRE(std::ranges::count(kinds, ir::NodeKind::Aggregate) == 2); +} + +TEST_CASE("Lower does not reuse scalar subqueries calling externs", "[lower][scalar_reuse]") { + const std::string subquery = + "scalar(supply[filter sample(ps_cost) > 0.5, select { m = min(ps_cost) }])"; + auto result = lower_source("extern fn sample(v: Float64) -> Float64 from \"sample.hpp\";\n" + + std::string(kCorrelatedSources) + "parts[filter p_partkey < " + + subquery + " && p_partkey < " + subquery + "];"); + REQUIRE(result.has_value()); + std::vector kinds; + collect_kinds(**result, kinds); + REQUIRE(std::ranges::count(kinds, ir::NodeKind::Aggregate) == 2); +} + +TEST_CASE("Scalar subquery reuse is scoped to one filter", "[lower][scalar_reuse]") { + const std::string q = "scalar(supply[select { m = min(ps_cost) }])"; + auto result = lower_source(std::string(kCorrelatedSources) + "parts[filter p_partkey < " + q + + "][filter p_partkey < " + q + "];"); + REQUIRE(result.has_value()); + std::vector kinds; + collect_kinds(**result, kinds); + REQUIRE(std::ranges::count(kinds, ir::NodeKind::Aggregate) == 2); +} From d0a9c58c5e61911ff4410c86fa6fd33d3f812e59 Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Mon, 21 Sep 2026 12:12:06 +0200 Subject: [PATCH 29/42] Fix clang-tidy --- src/codegen/emitter.cpp | 1 + src/ir/probed_key_restriction.cpp | 11 ++++------- src/ir/replayable.cpp | 5 +++++ src/runtime/aggregate.cpp | 1 + src/runtime/map_chunked.cpp | 1 - 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/codegen/emitter.cpp b/src/codegen/emitter.cpp index 5f2e865f..f6e0db40 100644 --- a/src/codegen/emitter.cpp +++ b/src/codegen/emitter.cpp @@ -283,6 +283,7 @@ auto Emitter::fresh_var() -> std::string { return "t" + std::to_string(tmp_counter_++); } +// NOLINTNEXTLINE(readability-function-size): one exhaustive dispatcher keeps IR emission local. auto Emitter::emit_node(const ir::Node& node) -> std::string { const auto require_single_child = [](const ir::Node& parent, std::string_view node_name) -> const ir::Node& { diff --git a/src/ir/probed_key_restriction.cpp b/src/ir/probed_key_restriction.cpp index b1b9006c..d94d2699 100644 --- a/src/ir/probed_key_restriction.cpp +++ b/src/ir/probed_key_restriction.cpp @@ -5,9 +5,9 @@ #include #include #include -#include #include +#include #include #include #include @@ -147,14 +147,11 @@ auto probed_distinct(const Node& node, const std::string& column, const SourceSt /// probe side that may dwarf what it saves. auto restriction_pays(const Node& probe_side, const Node& aggregate_input, const std::vector& keys, const SourceStats& stats) -> bool { - for (const auto& key : keys) { + return std::ranges::any_of(keys, [&](const JoinKey& key) { const auto probed = probed_distinct(probe_side, key.left, stats); const auto groups = distinct_estimate(aggregate_input, key.right, stats); - if (probed.has_value() && groups.has_value() && *probed < *groups) { - return true; - } - } - return false; + return probed.has_value() && groups.has_value() && *probed < *groups; + }); } void rewrite(Node& node, const SourceStats& stats, std::uint64_t& next) { diff --git a/src/ir/replayable.cpp b/src/ir/replayable.cpp index ddc262fd..3494ab42 100644 --- a/src/ir/replayable.cpp +++ b/src/ir/replayable.cpp @@ -6,7 +6,12 @@ #include #include +#include +#include +#include +#include #include +#include namespace ibex::ir { namespace { diff --git a/src/runtime/aggregate.cpp b/src/runtime/aggregate.cpp index b3255500..03379e08 100644 --- a/src/runtime/aggregate.cpp +++ b/src/runtime/aggregate.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include diff --git a/src/runtime/map_chunked.cpp b/src/runtime/map_chunked.cpp index b8a0dc27..894682c4 100644 --- a/src/runtime/map_chunked.cpp +++ b/src/runtime/map_chunked.cpp @@ -20,7 +20,6 @@ #include #include #include -#include #include #include #include From 1fd9c0661751845df11bf09eec0497992281a0c4 Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Mon, 21 Sep 2026 17:27:27 +0200 Subject: [PATCH 30/42] Fix join semantics bugs in reorder, pushdown and predicate joins - Share is_reorderable_inner_join between costing and rebuild so join reordering declines take/nulls/expect/suffix clauses anywhere in a chain - Skip filter and semi/anti pushdown through joins with take first/last/any or cardinality assertions (would pick a different match or hide a violation) - Non-equi join predicates now carry validity for both sides so a null's payload never compares as a real value - Add regression tests and parity cases Co-Authored-By: Claude Sonnet 5 --- include/ibex/ir/join_order.hpp | 5 + src/ir/join_order.cpp | 15 +- src/ir/join_pushdown.cpp | 10 ++ src/ir/join_reorder.cpp | 10 +- src/runtime/join.cpp | 12 +- tests/parity/cases/anti_join_after_take.ibex | 5 + .../cases/nullable_predicate_outer_join.ibex | 6 + tests/test_ir_join_reorder.cpp | 58 +++++++ tests/test_join.cpp | 150 ++++++++++++++++++ 9 files changed, 255 insertions(+), 16 deletions(-) create mode 100644 tests/parity/cases/anti_join_after_take.ibex create mode 100644 tests/parity/cases/nullable_predicate_outer_join.ibex diff --git a/include/ibex/ir/join_order.hpp b/include/ibex/ir/join_order.hpp index d5d46571..aca5f141 100644 --- a/include/ibex/ir/join_order.hpp +++ b/include/ibex/ir/join_order.hpp @@ -13,6 +13,11 @@ namespace ibex::ir { +/// Shared eligibility for costing and rebuilding a left-deep join chain. +/// Only default equijoins can be rearranged without changing match selection, +/// null matching, cardinality checks, or output naming. +[[nodiscard]] auto is_reorderable_inner_join(const JoinNode& join) -> bool; + /// A cost-driven order over the leaf relations of a left-deep inner equijoin /// chain. Indices refer to the chain's original left-to-right leaf order. /// `nullopt` means the tree is not a safe/known join graph yet, OR that some diff --git a/src/ir/join_order.cpp b/src/ir/join_order.cpp index 411e9cad..c77cef68 100644 --- a/src/ir/join_order.cpp +++ b/src/ir/join_order.cpp @@ -16,6 +16,16 @@ #include namespace ibex::ir { + +auto is_reorderable_inner_join(const JoinNode& join) -> bool { + return join.kind() == JoinKind::Inner && !join.predicate().has_value() && + !join.keys().empty() && join_keys_are_folded(join.keys()) && + join.take() == MatchSelection::All && join.null_match() == NullMatch::Never && + !join.expect().asserts_anything() && !join.suffix().present && + join.children().size() == 2 && join.children()[0] != nullptr && + join.children()[1] != nullptr && join.children()[1]->kind() != NodeKind::Join; +} + namespace { struct Relation { @@ -130,10 +140,7 @@ auto collect_left_deep(const Node& node, const SourceStats& stats, std::vectorkind() == NodeKind::Join) { + if (!is_reorderable_inner_join(join)) { return false; } if (!collect_left_deep(*join.children()[0], stats, relations, edges, all_filters_sampled) || diff --git a/src/ir/join_pushdown.cpp b/src/ir/join_pushdown.cpp index c3286b48..036414fd 100644 --- a/src/ir/join_pushdown.cpp +++ b/src/ir/join_pushdown.cpp @@ -152,6 +152,12 @@ auto rewrite_filter_over_join(NodePtr node, const SourceSchemas& sources) -> Nod auto& filter = node_cast(*node); auto& join = node_cast(*node->mutable_children().front()); const JoinKind kind = join.kind(); + // Filtering candidates before match selection can select a different + // right row instead of discarding the already-selected result. Filtering + // before a cardinality assertion can hide duplicate matches as well. + if (join.take() != MatchSelection::All || join.expect().asserts_anything()) { + return node; + } if (kind != JoinKind::Inner && kind != JoinKind::Left && kind != JoinKind::Right && kind != JoinKind::Semi && kind != JoinKind::Anti) { return node; // Outer/Cross/Asof: see the header's safety table. @@ -298,7 +304,11 @@ auto rewrite_semi_over_join(NodePtr node, const SourceSchemas& sources) -> NodeP auto& inner = node_cast(*outer.mutable_children()[0]); // Only an equi Inner join is safe to push through: Left/Right/Outer decide // which left rows survive, which the semi/anti filter would then race. + // Membership must be tested after choosing the match, too: pushing it + // into the right input can replace a rejected first/last match or hide + // duplicate matches that a cardinality assertion must reject. if (inner.kind() != JoinKind::Inner || inner.predicate().has_value() || + inner.take() != MatchSelection::All || inner.expect().asserts_anything() || inner.children().size() != 2 || inner.children()[0] == nullptr || inner.children()[1] == nullptr) { return node; diff --git a/src/ir/join_reorder.cpp b/src/ir/join_reorder.cpp index 30ed83e4..8886a289 100644 --- a/src/ir/join_reorder.cpp +++ b/src/ir/join_reorder.cpp @@ -68,10 +68,7 @@ auto scan_left_deep(const Node& node, std::vector& leaves, std::vec // `Edge::keys` are logical folded-output names. A mapped key that // `normalize_mapped_join_keys` could not safely fold keeps the order its // author wrote. - if (join.kind() != JoinKind::Inner || join.predicate().has_value() || join.keys().empty() || - !join_keys_are_folded(join.keys()) || join.children().size() != 2 || - join.children()[0] == nullptr || join.children()[1] == nullptr || - join.children()[1]->kind() == NodeKind::Join) { + if (!is_reorderable_inner_join(join)) { return false; } if (!scan_left_deep(*join.children()[0], leaves, edges, leaf_key_names)) { @@ -94,10 +91,7 @@ auto take_left_deep(NodePtr node, std::vector& leaves, std::vector(node.get()); // Must stay in lockstep with `scan_left_deep` above, including its reason // for rejecting mapped keys. - if (join->kind() != JoinKind::Inner || join->predicate().has_value() || join->keys().empty() || - !join_keys_are_folded(join->keys()) || join->mutable_children().size() != 2 || - join->mutable_children()[0] == nullptr || join->mutable_children()[1] == nullptr || - join->mutable_children()[1]->kind() == NodeKind::Join) { + if (!is_reorderable_inner_join(*join)) { return false; } NodePtr left = std::move(join->mutable_children()[0]); diff --git a/src/runtime/join.cpp b/src/runtime/join.cpp index 52a774f9..11479e79 100644 --- a/src/runtime/join.cpp +++ b/src/runtime/join.cpp @@ -1138,14 +1138,13 @@ auto join_table_impl(const Table& left, const Table& right, ir::JoinKind kind, // capture a reference meant for the left one; the predicate's column // references are rewritten to match, once, before the row loop. struct NLJRightCol { - const ColumnValue* column = nullptr; + const ColumnEntry* entry = nullptr; std::string batch_name; }; std::vector nlj_right; nlj_right.reserve(right.columns.size()); for (const auto& entry : right.columns) { - nlj_right.push_back( - {.column = entry.column.get(), .batch_name = nlj_right_batch_name(entry.name)}); + nlj_right.push_back({.entry = &entry, .batch_name = nlj_right_batch_name(entry.name)}); } // Resolved once, above, so its diagnostics precede the output plan's. @@ -1173,9 +1172,14 @@ auto join_table_impl(const Table& left, const Table& right, ir::JoinKind kind, // output name here was invisible until a suffix clause made // the two differ, and then `left(v)` could not find `v`. batch.add_column(column.name, std::move(col)); + // Broadcast validity along with the value. A null's stored + // payload must not become a real value in the predicate. + if (is_null(column, l)) { + batch.columns.back().validity = ValidityBitmap(n_right, false); + } } for (const auto& item : nlj_right) { - batch.add_column(item.batch_name, *item.column); + batch.add_column_from(item.batch_name, *item.entry); } auto mask_res = diff --git a/tests/parity/cases/anti_join_after_take.ibex b/tests/parity/cases/anti_join_after_take.ibex new file mode 100644 index 00000000..43f88ae3 --- /dev/null +++ b/tests/parity/cases/anti_join_after_take.ibex @@ -0,0 +1,5 @@ +// Reject the selected match, without selecting a replacement from b. +let a = Table { k = [1, 2] }; +let b = Table { k = [1, 1, 2], v = [10, 20, 30] }; +let z = Table { v = [10] }; +((a join b[order { v asc }] on k take first) anti join z on v)[order { k asc }]; diff --git a/tests/parity/cases/nullable_predicate_outer_join.ibex b/tests/parity/cases/nullable_predicate_outer_join.ibex new file mode 100644 index 00000000..5717d6eb --- /dev/null +++ b/tests/parity/cases/nullable_predicate_outer_join.ibex @@ -0,0 +1,6 @@ +// A null on either side must not compare as its underlying zero payload. +let ids = Table { id = [1, 2] }; +let values = Table { id = [1], x = [0] }; +let l = (ids left join values on id)[select { lid = id, x }]; +let r = (ids left join values on id)[select { rid = id, y = x }]; +(l outer join r on x == y)[order { lid asc, rid asc }]; diff --git a/tests/test_ir_join_reorder.cpp b/tests/test_ir_join_reorder.cpp index f4443f4e..d2cc6caf 100644 --- a/tests/test_ir_join_reorder.cpp +++ b/tests/test_ir_join_reorder.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -90,6 +91,63 @@ TEST_CASE("join reorder changes an explicitly order-insensitive root chain", "[i CHECK(scan_name(*out->children()[1]) == "customer"); } +TEST_CASE("join reorder declines semantic clauses anywhere in a chain", + "[ir][join_reorder][regression]") { + for (int clause = 0; clause < 8; ++clause) { + for (const bool on_inner : {false, true}) { + ir::Builder builder; + const auto make = [&](bool guarded, const std::string& key) { + ir::MatchSelection take = ir::MatchSelection::All; + ir::NullMatch nulls = ir::NullMatch::Never; + ir::JoinExpect expect; + ir::JoinSuffixPolicy suffix; + if (guarded) { + switch (clause) { + case 0: + take = ir::MatchSelection::First; + break; + case 1: + take = ir::MatchSelection::Last; + break; + case 2: + take = ir::MatchSelection::Any; + break; + case 3: + nulls = ir::NullMatch::Equal; + break; + case 4: + expect.left = ir::JoinMultiplicity::One; + break; + case 5: + expect.right = ir::JoinMultiplicity::One; + break; + case 6: + expect.left = ir::JoinMultiplicity::One; + expect.right = ir::JoinMultiplicity::One; + break; + case 7: + suffix = {.present = true, .left = "_l", .right = "_r"}; + break; + } + } + return std::make_unique( + ir::NodeId{guarded ? 100U : 101U}, ir::JoinKind::Inner, + std::vector{key}, std::nullopt, suffix, nulls, expect, take); + }; + auto first = make(on_inner, "c_custkey"); + first->add_child(builder.scan("customer")); + first->add_child(builder.scan("orders")); + auto chain = make(!on_inner, "o_orderkey"); + chain->add_child(std::move(first)); + chain->add_child(builder.scan("lineitem")); + CHECK_FALSE(ir::choose_inner_join_order(*chain, misordered_stats()).has_value()); + auto out = ir::reorder_inner_joins_for_order_insensitive_root(std::move(chain), + misordered_stats()); + CHECK(scan_name(*out->children()[1]) == "lineitem"); + } + } +} + TEST_CASE("binding order proof requires every use to erase encounter order", "[ir][join_reorder]") { ir::Builder builder; auto sum = builder.aggregate( diff --git a/tests/test_join.cpp b/tests/test_join.cpp index 03d6d0f7..f85c81ce 100644 --- a/tests/test_join.cpp +++ b/tests/test_join.cpp @@ -3,6 +3,8 @@ #include #include +#include +#include #include #include #include @@ -92,6 +94,154 @@ auto interpret_error_at_parse(std::string_view src) -> std::string { } // namespace +TEST_CASE("join: nullable predicates preserve unmatched rows for every kind", + "[join][non-equijoin][regression]") { + for (const std::string kind : {"", "left ", "right ", "outer ", "semi ", "anti "}) { + for (const bool null_left : {false, true}) { + for (const bool null_right : {false, true}) { + CAPTURE(kind, null_left, null_right); + runtime::Table lhs; + lhs.add_column("lid", Column{1}); + lhs.add_column("x", Column{0}, + runtime::ValidityBitmap(1, !null_left)); + runtime::Table rhs; + rhs.add_column("rid", Column{2}); + rhs.add_column("y", Column{1}, + runtime::ValidityBitmap(1, !null_right)); + runtime::TableRegistry tables; + tables.emplace("lhs", std::move(lhs)); + tables.emplace("rhs", std::move(rhs)); + const auto out = interpret_expr("lhs " + kind + "join rhs on x < y;", tables); + const bool matched = !null_left && !null_right; + const bool membership = kind == "semi " || kind == "anti "; + const std::size_t expected = + matched + ? (kind == "anti " ? 0 : 1) + : (kind == "outer " + ? 2 + : (kind == "left " || kind == "right " || kind == "anti " ? 1 : 0)); + REQUIRE(out.rows() == expected); + REQUIRE(out.columns.size() == (membership ? 2 : 4)); + if (!matched && expected != 0 && !membership) { + for (std::size_t row = 0; row < out.rows(); ++row) { + CHECK(runtime::is_null(*out.find_entry("lid"), row) != + runtime::is_null(*out.find_entry("rid"), row)); + } + } + } + } + } +} + +TEST_CASE("join: pushdown preserves match selection before membership and filters", + "[join][ir][join_pushdown][regression]") { + runtime::Table a; + a.add_column("k", Column{1}); + runtime::Table b; + b.add_column("k", Column{1, 1}); + b.add_column("v", Column{10, 20}); + runtime::Table z; + z.add_column("v", Column{10}); + runtime::TableRegistry tables; + tables.emplace("a", std::move(a)); + tables.emplace("b", std::move(b)); + tables.emplace("z", std::move(z)); + ir::SourceSchemas schemas; + schemas.emplace("a", ir::SchemaInfo::known({{.name = "k", .type = ir::ColumnType::Int64}})); + schemas.emplace("b", ir::SchemaInfo::known({{.name = "k", .type = ir::ColumnType::Int64}, + {.name = "v", .type = ir::ColumnType::Int64}})); + schemas.emplace("z", ir::SchemaInfo::known({{.name = "v", .type = ir::ColumnType::Int64}})); + for (const std::string take : {"first", "last"}) { + for (const std::string tail : + {" anti join z on v", " semi join z on v", "[filter v == 10]", "[filter v == 20]"}) { + const std::string src = + "(a join b[order { v asc }] on k take " + take + ")" + tail + ";"; + CAPTURE(src); + const auto expected = interpret_expr(src, tables); + auto parsed = parser::parse(src); + REQUIRE(parsed.has_value()); + auto lowered = parser::lower(*parsed); + REQUIRE(lowered.has_value()); + auto optimized = ir::push_filters_into_joins(std::move(*lowered), schemas); + optimized = ir::push_semi_joins_down(std::move(optimized), schemas); + const auto actual = runtime::interpret(*optimized, tables, nullptr, nullptr); + REQUIRE(actual.has_value()); + CHECK(col_i64(*actual, "v") == col_i64(expected, "v")); + } + } +} + +TEST_CASE("join: reordering preserves match selection null policy and assertions", + "[join][join_reorder][regression]") { + ir::SourceStats stats; + stats.schemas = { + {"a", ir::SchemaInfo::known({{.name = "k", .type = ir::ColumnType::Int64}})}, + {"b", ir::SchemaInfo::known({{.name = "k", .type = ir::ColumnType::Int64}, + {.name = "bid", .type = ir::ColumnType::Int64}})}, + {"c", ir::SchemaInfo::known({{.name = "bid", .type = ir::ColumnType::Int64}})}}; + stats.rows = {{"a", 6}, {"b", 7}, {"c", 1}}; + stats.distinct = {{"a", {{"k", 6}}}, {"b", {{"k", 6}, {"bid", 7}}}, {"c", {{"bid", 1}}}}; + for (const std::string clause : {"take first", "take last", "nulls equal", "expect n:1"}) { + CAPTURE(clause); + runtime::Table a, b, c; + runtime::ValidityBitmap av(6, true), bv(7, true); + if (clause == "nulls equal") { + av.set(0, false); + bv.set(0, false); + bv.set(1, false); + } + a.add_column("k", Column{1, 2, 3, 4, 5, 6}, av); + b.add_column("k", Column{1, 1, 2, 3, 4, 5, 6}, bv); + b.add_column("bid", Column{11, 12, 20, 30, 40, 50, 60}); + c.add_column("bid", Column{clause == "take last" ? 11 : 12}); + runtime::TableRegistry tables{ + {"a", std::move(a)}, {"b", std::move(b)}, {"c", std::move(c)}}; + const std::string src = "((a join b[order { bid asc }] on k " + clause + + ") join c on bid)[select { n = count() }];"; + auto parsed = parser::parse(src); + REQUIRE(parsed.has_value()); + auto lowered = parser::lower(*parsed); + REQUIRE(lowered.has_value()); + auto optimized = ir::reorder_inner_joins_for_aggregates(std::move(*lowered), stats); + auto out = runtime::interpret(*optimized, tables); + if (clause == "expect n:1") { + REQUIRE_FALSE(out.has_value()); + CHECK(out.error().find("matches more than one") != std::string::npos); + } else { + REQUIRE(out.has_value()); + CHECK(col_i64(*out, "n") == std::vector{clause == "nulls equal" ? 1 : 0}); + } + } +} + +TEST_CASE("join: pushdown cannot hide cardinality violations", + "[join][join_pushdown][regression]") { + runtime::Table a, b, z; + a.add_column("k", Column{1}); + b.add_column("k", Column{1, 1}); + b.add_column("v", Column{10, 20}); + z.add_column("v", Column{10}); + runtime::TableRegistry tables{{"a", std::move(a)}, {"b", std::move(b)}, {"z", std::move(z)}}; + ir::SourceSchemas schemas{ + {"a", ir::SchemaInfo::known({{.name = "k", .type = ir::ColumnType::Int64}})}, + {"b", ir::SchemaInfo::known({{.name = "k", .type = ir::ColumnType::Int64}, + {.name = "v", .type = ir::ColumnType::Int64}})}, + {"z", ir::SchemaInfo::known({{.name = "v", .type = ir::ColumnType::Int64}})}}; + for (const std::string tail : + {"[filter v == 10]", "[filter k == 2]", " semi join z on v", " anti join z on v"}) { + CAPTURE(tail); + auto parsed = parser::parse("(a join b on k expect n:1)" + tail + ";"); + REQUIRE(parsed.has_value()); + auto lowered = parser::lower(*parsed); + REQUIRE(lowered.has_value()); + auto optimized = ir::push_filters_into_joins(std::move(*lowered), schemas); + optimized = ir::push_semi_joins_down(std::move(optimized), schemas); + auto out = runtime::interpret(*optimized, tables); + REQUIRE_FALSE(out.has_value()); + CHECK(out.error().find("matches more than one") != std::string::npos); + } +} + TEST_CASE("join: inner join on single key", "[join]") { runtime::Table lhs; lhs.add_column("id", Column{1, 2, 3}); From 75dc2ca143e39a1b2b5e965e00e6a4fbbeabfa33 Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Mon, 21 Sep 2026 17:29:56 +0200 Subject: [PATCH 31/42] Skip probed-key restriction under nulls equal joins The restriction inserts a `nulls never` semi join under the aggregate, which would drop the null-keyed group a `nulls equal` join must still match. Co-Authored-By: Claude Sonnet 5 --- src/ir/probed_key_restriction.cpp | 5 +++++ tests/test_ir_probed_key_restriction.cpp | 14 ++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/ir/probed_key_restriction.cpp b/src/ir/probed_key_restriction.cpp index d94d2699..55494311 100644 --- a/src/ir/probed_key_restriction.cpp +++ b/src/ir/probed_key_restriction.cpp @@ -170,6 +170,11 @@ void rewrite(Node& node, const SourceStats& stats, std::uint64_t& next) { if (join.kind() != JoinKind::Left && join.kind() != JoinKind::Inner) { return; } + // The restriction is a `nulls never` semi join, so it would drop a + // null-keyed group the probe side's null key is entitled to match. + if (join.null_match() != NullMatch::Never) { + return; + } if (join.children().size() != 2 || join.children()[1] == nullptr) { return; } diff --git a/tests/test_ir_probed_key_restriction.cpp b/tests/test_ir_probed_key_restriction.cpp index af83c6ba..50aaff53 100644 --- a/tests/test_ir_probed_key_restriction.cpp +++ b/tests/test_ir_probed_key_restriction.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -136,6 +137,19 @@ TEST_CASE("probed key restriction: only Left and Inner joins", "[ir][probed-keys CHECK(restriction_of(*inner) != nullptr); } +TEST_CASE("probed key restriction: declined when null keys match each other", + "[ir][probed-keys][regression]") { + // The inserted semi join uses `nulls never`, which would remove the + // aggregate's null-keyed group that a `nulls equal` join must still match. + auto join = std::make_unique( + ir::NodeId{10}, ir::JoinKind::Left, std::vector{ir::JoinKey{"k"}}, + std::nullopt, ir::JoinSuffixPolicy{}, ir::NullMatch::Equal); + join->add_child(make_scan(ir::NodeId{3}, "probes")); + join->add_child(aggregate_over_facts()); + auto root = ir::restrict_aggregates_to_probed_keys(std::move(join), stats()); + CHECK(restriction_of(*root) == nullptr); +} + TEST_CASE("probed key restriction: declined when the probe side cannot be replayed", "[ir][probed-keys]") { // Collecting the keys evaluates the probe side a second time. An extern From ba1d7ac19725c5e1c3ae040ac56e8a8034c77a9d Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Mon, 21 Sep 2026 17:33:49 +0200 Subject: [PATCH 32/42] Keep right rows that lose every match under `take`; guard deferred probe - `take first/last/any` collapses matches per left row, which on right and outer joins silently dropped a right row whose matches all went to other left rows. It is now emitted null-padded, as an unmatched right row is. SPEC.md states this. - The deferred-probe pass declines `nulls equal` joins; its dynamic filter over the build keys does not model null-to-null matches. The two shape tests in scan_predicates now share one predicate. Co-Authored-By: Claude Sonnet 5 --- SPEC.md | 3 + src/ir/scan_predicates.cpp | 13 ++-- src/runtime/join.cpp | 26 +++++++- .../right_join_take_keeps_right_rows.ibex | 4 ++ tests/test_ir_required_columns.cpp | 10 ++++ tests/test_join.cpp | 59 +++++++++++++++++++ 6 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 tests/parity/cases/right_join_take_keeps_right_rows.ibex diff --git a/SPEC.md b/SPEC.md index 4b1b218f..bbc0d32d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2309,6 +2309,9 @@ trades join quotes[order { ts asc }] on symbol take first row — that is a property of this implementation, not a promise. - A row with no match has nothing to choose between, so an outer join's padded rows are unaffected. +- `take` chooses pairs; it never changes which rows a join preserves. On a + `right` or `outer` join, a right row whose every match was taken by other left + rows is emitted null-padded, exactly as an unmatched right row is. - `expect` describes how the inputs match, so it is checked **before** `take` drops anything. Otherwise `take first` would satisfy every `expect n:1` by construction. diff --git a/src/ir/scan_predicates.cpp b/src/ir/scan_predicates.cpp index 1ce4de9f..f37a3eae 100644 --- a/src/ir/scan_predicates.cpp +++ b/src/ir/scan_predicates.cpp @@ -427,9 +427,13 @@ auto build_side_worth_deferring(const JoinNode& join, const std::string& probe_s /// The inner-join / key-arity / no-predicate shape a deferrable probe needs. auto is_probe_shaped_join(const JoinNode& join) -> bool { + // `nulls equal` lets a null build key match a null probe key, which the + // dynamic filter over the build side's keys would have to admit; the + // deferred path does not model that, so such a join reads its probe whole. return join.kind() == JoinKind::Inner && (join.keys().size() == 1 || join.keys().size() == 2) && - !join.predicate().has_value() && join.children().size() == 2 && - join.children()[0] != nullptr && join.children()[1] != nullptr; + join.null_match() == NullMatch::Never && !join.predicate().has_value() && + join.children().size() == 2 && join.children()[0] != nullptr && + join.children()[1] != nullptr; } /// Replace the single `Scan` at the bottom of a verified probe chain (only @@ -595,10 +599,7 @@ void collect_deferrable(const Node& node, const std::set& sources, // is the POC for whether scan-altitude pruning is the lever at all // before extending them (plans/parallelism-overview.md's "stream // multi-key joins" follow-up, TPC-H q09's lineitem join). - if (join.kind() == JoinKind::Inner && - (join.keys().size() == 1 || join.keys().size() == 2) && !join.predicate().has_value() && - join.children().size() == 2 && join.children()[0] != nullptr && - join.children()[1] != nullptr) { + if (is_probe_shaped_join(join)) { if (auto match = match_probe_chain(*join.children()[1], join.keys().front().right); match.has_value() && sources.contains(match->first)) { if (const auto count = counts.find(match->first); diff --git a/src/runtime/join.cpp b/src/runtime/join.cpp index 11479e79..b8563bfb 100644 --- a/src/runtime/join.cpp +++ b/src/runtime/join.cpp @@ -849,7 +849,9 @@ auto join_table_impl(const Table& left, const Table& right, ir::JoinKind kind, /// Rewrite the emitted pairs so each left row keeps one match. Rows with no /// match (an outer join's padding) are left alone: there is nothing to - /// choose between. + /// choose between. A preserved right row that loses all its matches to + /// other left rows is padded, since a right or outer join keeps every + /// right row. auto apply_take = [&](MutableJoinIndices indices) { auto& left_idx = *indices.left; auto& right_idx = *indices.right; @@ -875,6 +877,10 @@ auto join_table_impl(const Table& left, const Table& right, ir::JoinKind kind, return; // nothing had a second match } std::vector emitted(n_left, 0U); + std::vector right_kept; + if (preserve_right_rows) { + right_kept.assign(n_right, 0U); + } std::vector out_left; std::vector out_right; std::vector out_key; @@ -894,6 +900,24 @@ auto join_table_impl(const Table& left, const Table& right, ir::JoinKind kind, if (!key_right_idx.empty()) { out_key.push_back(key_right_idx[i]); } + if (preserve_right_rows && r != kNull) { + right_kept[r] = 1U; + } + } + // `take` picks pairs; it does not decide which rows a right or outer + // join preserves. A right row whose every match went to some other + // left row now has no pair, so it is emitted as the unmatched row it + // has become, exactly as if it had never matched. + if (preserve_right_rows) { + for (std::size_t r = 0; r < n_right; ++r) { + if (right_kept[r] != 0U) { + continue; + } + out_key.resize(out_left.size(), kNull); + out_left.push_back(kNull); + out_right.push_back(r); + out_key.push_back(r); + } } left_idx = std::move(out_left); right_idx = std::move(out_right); diff --git a/tests/parity/cases/right_join_take_keeps_right_rows.ibex b/tests/parity/cases/right_join_take_keeps_right_rows.ibex new file mode 100644 index 00000000..172ccbbb --- /dev/null +++ b/tests/parity/cases/right_join_take_keeps_right_rows.ibex @@ -0,0 +1,4 @@ +// Both k = 2 left rows pick the same right row; the other one is padded, not dropped. +let a = Table { k = [1, 2, 2, 3], x = [1, 2, 3, 4] }; +let b = Table { k = [2, 2, 3, 4], y = [10, 20, 30, 40] }; +(a outer join b[order { y asc }] on k take first)[order { y asc, x asc }]; diff --git a/tests/test_ir_required_columns.cpp b/tests/test_ir_required_columns.cpp index 3701ac1e..fa22291c 100644 --- a/tests/test_ir_required_columns.cpp +++ b/tests/test_ir_required_columns.cpp @@ -436,6 +436,16 @@ TEST_CASE("deferrable_probe_scans: bare right-side scan of an inner join is elig CHECK_FALSE(ir::deferrable_probe_scans(*plan, {"build"}).contains("build")); } +TEST_CASE("deferrable_probe_scans: a `nulls equal` join is declined", + "[ir][scan_predicates][deferred_scan][regression]") { + auto join = std::make_unique( + ir::NodeId{20}, ir::JoinKind::Inner, std::vector{ir::JoinKey{"id"}}, + std::nullopt, ir::JoinSuffixPolicy{}, ir::NullMatch::Equal); + join->add_child(make_scan("build")); + join->add_child(std::make_unique(ir::NodeId{2}, "t")); + CHECK_FALSE(ir::deferrable_probe_scans(*join, {"t"}).contains("t")); +} + TEST_CASE("deferrable_probe_scans: an unfiltered build side is declined", "[ir][scan_predicates][deferred_scan]") { // The row-count gate alone says yes -- 1.5M build rows against 12M probe diff --git a/tests/test_join.cpp b/tests/test_join.cpp index f85c81ce..045127e0 100644 --- a/tests/test_join.cpp +++ b/tests/test_join.cpp @@ -15,6 +15,7 @@ #include #include +#include #include #include #include @@ -214,6 +215,64 @@ TEST_CASE("join: reordering preserves match selection null policy and assertions } } +TEST_CASE("join: take keeps right rows that lose every match on right and outer joins", + "[join][take][regression]") { + // Both k=2 left rows pick the same right row, so the other k=2 right row + // has no selected pair. A right or outer join preserves every right row, + // so it must come back padded rather than vanish. + const auto make_tables = [] { + runtime::Table a; + a.add_column("k", Column{1, 2, 2, 3}); + a.add_column("x", Column{1, 2, 3, 4}); + runtime::Table b; + b.add_column("k", Column{2, 2, 3, 4}); + b.add_column("y", Column{10, 20, 30, 40}); + runtime::TableRegistry tables; + tables.emplace("a", std::move(a)); + tables.emplace("b", std::move(b)); + return tables; + }; + struct Case { + std::string kind; + std::string take; + std::int64_t picked; + std::int64_t dropped; + }; + for (const auto& c : {Case{"right", "first", 10, 20}, Case{"right", "last", 20, 10}, + Case{"outer", "first", 10, 20}, Case{"outer", "last", 20, 10}}) { + CAPTURE(c.kind, c.take); + auto tables = make_tables(); + const auto out = interpret_expr("(a " + c.kind + " join b[order { y asc }] on k take " + + c.take + ")[order { y asc, x asc }];", + tables); + // Every right row is present exactly once as a pair or as padding. + auto ys = col_i64(out, "y"); + std::vector present; + for (std::size_t row = 0; row < out.rows(); ++row) { + if (!runtime::is_null(*out.find_entry("y"), row)) { + present.push_back(ys[row]); + } + } + std::ranges::sort(present); + // `picked` serves both k=2 left rows; `dropped` appears once, padded. + std::vector expected{10, 20, 20, 30, 40}; + if (c.picked == 10) { + expected = {10, 10, 20, 30, 40}; + } + CHECK(present == expected); + std::size_t dropped_rows = 0; + for (std::size_t row = 0; row < out.rows(); ++row) { + if (!runtime::is_null(*out.find_entry("y"), row) && ys[row] == c.dropped) { + ++dropped_rows; + CHECK(runtime::is_null(*out.find_entry("x"), row)); + CHECK(col_i64(out, "k")[row] == 2); // the folded key comes from the right row + } + } + CHECK(dropped_rows == 1); + CHECK(out.rows() == (c.kind == "outer" ? 6U : 5U)); + } +} + TEST_CASE("join: pushdown cannot hide cardinality violations", "[join][join_pushdown][regression]") { runtime::Table a, b, z; From 90f2d8ba45220248dd75c7729d35010b8c57f22c Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Mon, 21 Sep 2026 17:50:55 +0200 Subject: [PATCH 33/42] Correct the deferred-probe guard's rationale; pin it end to end The previous commit said the dynamic filter mishandles null keys under `nulls equal`. Tracing the runtime shows it never gets the chance: the build-key filter is published only by the streaming inner-join operators, and `is_streamable_inner_join` / `is_streamable_pair_int_join` already exclude `nulls equal`, `take` and `expect`. Such a join takes the materialized path, where the deferred scan's filter is never marked ready and the scan decodes unfiltered. With the guard removed the answer is still correct (verified on a real parquet repro and the e2e test below), so the guard is defense in depth, not a bug fix. - Reword the comment to say that, and make the plan-time shape test mirror all three runtime exclusions instead of only `nulls equal`. - Add an e2e lazy test running a `nulls equal` and a `nulls never` join over a deferrable probe with null keys on both sides; the lazy test source now carries column validity through its decode. Co-Authored-By: Claude Sonnet 5 --- src/ir/scan_predicates.cpp | 12 ++-- tests/test_e2e_lazy_queries.cpp | 93 +++++++++++++++++++++++++++++- tests/test_ir_required_columns.cpp | 24 +++++--- 3 files changed, 115 insertions(+), 14 deletions(-) diff --git a/src/ir/scan_predicates.cpp b/src/ir/scan_predicates.cpp index f37a3eae..7dbdb963 100644 --- a/src/ir/scan_predicates.cpp +++ b/src/ir/scan_predicates.cpp @@ -427,11 +427,15 @@ auto build_side_worth_deferring(const JoinNode& join, const std::string& probe_s /// The inner-join / key-arity / no-predicate shape a deferrable probe needs. auto is_probe_shaped_join(const JoinNode& join) -> bool { - // `nulls equal` lets a null build key match a null probe key, which the - // dynamic filter over the build side's keys would have to admit; the - // deferred path does not model that, so such a join reads its probe whole. + // The runtime publishes a build-key filter only from its streaming inner + // joins, which take a plain `nulls never`, unasserted, take-all join: the + // filter skips null keys and cannot see which match `take` or `expect` + // would pick. A join outside that set never publishes one, so registering + // its probe as deferred would only rename the scan and skip nothing. Keep + // this in step with `is_streamable_inner_join` in runtime_entry.cpp. return join.kind() == JoinKind::Inner && (join.keys().size() == 1 || join.keys().size() == 2) && - join.null_match() == NullMatch::Never && !join.predicate().has_value() && + join.null_match() == NullMatch::Never && join.take() == MatchSelection::All && + !join.expect().asserts_anything() && !join.predicate().has_value() && join.children().size() == 2 && join.children()[0] != nullptr && join.children()[1] != nullptr; } diff --git a/tests/test_e2e_lazy_queries.cpp b/tests/test_e2e_lazy_queries.cpp index 234e0995..3ecc90f3 100644 --- a/tests/test_e2e_lazy_queries.cpp +++ b/tests/test_e2e_lazy_queries.cpp @@ -86,11 +86,20 @@ void register_lazy_source(ExternRegistry& registry, std::string name, Table data const Selection& want = selection == nullptr ? all : *selection; Table out; for (const auto& col_name : names) { - const auto* col = data.find(col_name); - if (col == nullptr) { + const auto* entry = data.find_entry(col_name); + if (entry == nullptr) { return std::unexpected("lazy source: unknown column " + col_name); } - out.add_column(col_name, slice_column(*col, want)); + if (entry->validity.has_value()) { + ibex::runtime::ValidityBitmap validity(want.size(), true); + for (std::size_t i = 0; i < want.size(); ++i) { + validity.set(i, (*entry->validity)[want[i]]); + } + out.add_column(col_name, slice_column(*entry->column, want), + std::move(validity)); + } else { + out.add_column(col_name, slice_column(*entry->column, want)); + } } out.logical_rows = want.size(); return out; @@ -245,6 +254,84 @@ result; std::set>{{1, 350}, {3, 400}}); } +TEST_CASE("e2e lazy: `nulls equal` join over a deferrable probe keeps null-keyed rows", + "[e2e][lazy][join][regression]") { + // A selective build side over a much larger probe source is the shape the + // deferred-probe pass targets. Its key filter skips null keys, which is + // right for `nulls never` and would lose every null-to-null pair here, so + // a `nulls equal` join must not be narrowed by it. + constexpr std::size_t probe_rows = 4000; + constexpr std::size_t build_rows = 400; + Table probe; + Table build; + { + Column id; + Column v; + ibex::runtime::ValidityBitmap valid(probe_rows, true); + for (std::size_t i = 0; i < probe_rows; ++i) { + id.push_back(static_cast(i % 100)); + v.push_back(static_cast(i)); + valid.set(i, i % 10 != 0); + } + probe.add_column("id", std::move(id), valid); + probe.add_column("v", std::move(v)); + } + { + Column id; + Column flag; + ibex::runtime::ValidityBitmap valid(build_rows, true); + for (std::size_t i = 0; i < build_rows; ++i) { + id.push_back(static_cast(i % 5)); + flag.push_back(i < 8 ? 1 : 0); + valid.set(i, i % 4 != 0); + } + build.add_column("id", std::move(id), valid); + build.add_column("flag", std::move(flag)); + } + + // The 8 flagged build rows carry keys null,1,2,3,null,0,1,2. Probe keys + // are null on every tenth row and i % 100 otherwise. + std::int64_t expected_equal = 0; + std::int64_t expected_never = 0; + for (std::size_t i = 0; i < probe_rows; ++i) { + const bool probe_null = i % 10 == 0; + const auto key = static_cast(i % 100); + for (std::size_t b = 0; b < 8; ++b) { + const bool build_null = b % 4 == 0; + const auto build_key = static_cast(b % 5); + if (probe_null && build_null) { + ++expected_equal; + } else if (!probe_null && !build_null && key == build_key) { + ++expected_equal; + ++expected_never; + } + } + } + REQUIRE(expected_equal > expected_never); + + for (const bool nulls_equal : {true, false}) { + CAPTURE(nulls_equal); + const std::string src = std::string(R"( +extern fn read_build() -> DataFrame from "x.hpp"; +extern fn read_probe() -> DataFrame from "x.hpp"; +extern fn capture(df: DataFrame) -> Int from "x.hpp"; + +let build = read_build(); +let probe = read_probe(); +let small = build[filter flag == 1, select { id }]; +let joined = small join probe on id)") + + (nulls_equal ? " nulls equal" : "") + + R"(; +let result = joined[select { n = count() }]; +capture(result); +result; +)"; + Table out = run_lazy_script(src.c_str(), {{"read_build", build}, {"read_probe", probe}}); + REQUIRE(i64(out, "n") == + std::vector{nulls_equal ? expected_equal : expected_never}); + } +} + TEST_CASE("e2e lazy: deferred probe against a self-referenced source", "[e2e][lazy]") { // q18 shape: lineitem drives an aggregate AND is the probe side of the main // join. `isolate_deferrable_probe_scans` renames only the probe occurrence; diff --git a/tests/test_ir_required_columns.cpp b/tests/test_ir_required_columns.cpp index fa22291c..c313a83d 100644 --- a/tests/test_ir_required_columns.cpp +++ b/tests/test_ir_required_columns.cpp @@ -436,14 +436,24 @@ TEST_CASE("deferrable_probe_scans: bare right-side scan of an inner join is elig CHECK_FALSE(ir::deferrable_probe_scans(*plan, {"build"}).contains("build")); } -TEST_CASE("deferrable_probe_scans: a `nulls equal` join is declined", +TEST_CASE("deferrable_probe_scans: joins the streaming path declines are not deferred", "[ir][scan_predicates][deferred_scan][regression]") { - auto join = std::make_unique( - ir::NodeId{20}, ir::JoinKind::Inner, std::vector{ir::JoinKey{"id"}}, - std::nullopt, ir::JoinSuffixPolicy{}, ir::NullMatch::Equal); - join->add_child(make_scan("build")); - join->add_child(std::make_unique(ir::NodeId{2}, "t")); - CHECK_FALSE(ir::deferrable_probe_scans(*join, {"t"}).contains("t")); + // The runtime publishes the build-key filter only from its streaming inner + // joins, which exclude these clauses; deferring their probe would pay for + // the registration and never filter. + for (int clause = 0; clause < 3; ++clause) { + CAPTURE(clause); + ir::NullMatch nulls = clause == 0 ? ir::NullMatch::Equal : ir::NullMatch::Never; + ir::JoinExpect expect; + expect.right = clause == 1 ? ir::JoinMultiplicity::One : ir::JoinMultiplicity::Many; + const auto take = clause == 2 ? ir::MatchSelection::Any : ir::MatchSelection::All; + auto join = std::make_unique( + ir::NodeId{20}, ir::JoinKind::Inner, std::vector{ir::JoinKey{"id"}}, + std::nullopt, ir::JoinSuffixPolicy{}, nulls, expect, take); + join->add_child(make_scan("build")); + join->add_child(std::make_unique(ir::NodeId{2}, "t")); + CHECK_FALSE(ir::deferrable_probe_scans(*join, {"t"}).contains("t")); + } } TEST_CASE("deferrable_probe_scans: an unfiltered build side is declined", From 2883f486e8cda0efb85ce797f2c2968e82d5da43 Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Mon, 21 Sep 2026 18:05:43 +0200 Subject: [PATCH 34/42] Stop pruning UINT64 row groups on inverted footer ranges A UINT64 column keeps its footer min/max in unsigned order, while the fused key scan and the static-range filter compare the same bits as signed int64. A row group spanning 2^63 read back as min > max, so `group_max < lo` held and the whole group was skipped: `t[filter k >= 5 && k <= 7, select { n = count() }]` returned 0 on a file whose k column held 0..99998 plus 2^63+3, where the int64 twin returns 3. Pruning and the whole-file span now trust a footer range only when it is ordered as signed int64 (libs/parquet/stats_range.hpp, unit-tested without Arrow). The planner's merge_column_stats gets the same guard so it does not derive a span from an inverted range. Co-Authored-By: Claude Sonnet 5 --- libs/parquet/parquet.hpp | 8 +++++- libs/parquet/stats_range.hpp | 29 +++++++++++++++++++++ tests/CMakeLists.txt | 1 + tests/test_parquet_stats_range.cpp | 42 ++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 libs/parquet/stats_range.hpp create mode 100644 tests/test_parquet_stats_range.cpp diff --git a/libs/parquet/parquet.hpp b/libs/parquet/parquet.hpp index 3dba99de..7f1d47f2 100644 --- a/libs/parquet/parquet.hpp +++ b/libs/parquet/parquet.hpp @@ -77,6 +77,7 @@ #include #include "dictionary_policy.hpp" +#include "stats_range.hpp" namespace { @@ -2347,6 +2348,10 @@ inline auto merge_column_stats(const parquet::FileMetaData& metadata, int leaf_i range_known = false; // not an integer column continue; } + if (!ibex::parquet_stats::usable_signed_range(chunk_low, chunk_high)) { + range_known = false; + continue; + } if (group == 0 || chunk_low < low) { low = chunk_low; } @@ -2593,7 +2598,8 @@ inline auto filtered_key_scan_groups(const parquet::FileMetaData& metadata, int static_cast&>(*stats); const auto group_min = static_cast(typed_stats.min()); const auto group_max = static_cast(typed_stats.max()); - skip = group_max < *filter.min || group_min > *filter.max; + skip = ibex::parquet_stats::group_excluded(group_min, group_max, *filter.min, + *filter.max); } } } diff --git a/libs/parquet/stats_range.hpp b/libs/parquet/stats_range.hpp new file mode 100644 index 00000000..c556bc1d --- /dev/null +++ b/libs/parquet/stats_range.hpp @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Bob Jansen + +#pragma once + +#include + +namespace ibex::parquet_stats { + +/// A footer min/max read as signed int64, or nothing usable. +/// +/// Unsigned columns store their range in UNSIGNED order while the scans compare +/// the same bits as signed int64. A row group whose values span 2^63 therefore +/// reads back with min > max, and any decision taken from it (pruning the +/// group, deriving a span) would be about values the column does not hold. +[[nodiscard]] constexpr auto usable_signed_range(std::int64_t min, std::int64_t max) noexcept + -> bool { + return min <= max; +} + +/// Whether a row group whose footer range is [min, max] provably holds no key +/// inside the inclusive interval [lo, hi]. False whenever the range cannot be +/// trusted, so an unusable footer costs a scan and never a row. +[[nodiscard]] constexpr auto group_excluded(std::int64_t min, std::int64_t max, std::int64_t lo, + std::int64_t hi) noexcept -> bool { + return usable_signed_range(min, max) && (max < lo || min > hi); +} + +} // namespace ibex::parquet_stats diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index cf9bc56d..cd99321f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -40,6 +40,7 @@ set(IBEX_TEST_SOURCES test_ir_pending_order.cpp test_ir_column_origins.cpp test_parquet_dictionary_policy.cpp + test_parquet_stats_range.cpp test_ir_distinct_key_reduction.cpp test_ir_group_key_reduction.cpp test_ir_required_columns.cpp diff --git a/tests/test_parquet_stats_range.cpp b/tests/test_parquet_stats_range.cpp new file mode 100644 index 00000000..d62c6b8a --- /dev/null +++ b/tests/test_parquet_stats_range.cpp @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Bob Jansen + +#include + +#include +#include + +#include "../libs/parquet/stats_range.hpp" + +namespace stats = ibex::parquet_stats; + +TEST_CASE("parquet stats: a group is excluded only when its range misses the filter", + "[parquet][stats]") { + CHECK(stats::group_excluded(0, 10, 20, 30)); // entirely below + CHECK(stats::group_excluded(40, 50, 20, 30)); // entirely above + CHECK_FALSE(stats::group_excluded(0, 10, 10, 30)); + CHECK_FALSE(stats::group_excluded(0, 100, 20, 30)); +} + +TEST_CASE("parquet stats: an unsigned range spanning 2^63 is never trusted", + "[parquet][stats][regression]") { + // A UINT64 row group holding 0..99998 and 2^63+3 keeps its footer range in + // unsigned order, so read as int64 it is min = 0, max = INT64_MIN + 3. The + // scan sees the same bits as the same signed values, so key 5 is present; + // pruning on the inverted range would drop it. + const std::int64_t max = std::numeric_limits::min() + 3; + CHECK_FALSE(stats::usable_signed_range(0, max)); + CHECK_FALSE(stats::group_excluded(0, max, 5, 7)); + CHECK_FALSE(stats::group_excluded(0, max, std::numeric_limits::min() + 3, + std::numeric_limits::min() + 3)); +} + +TEST_CASE("parquet stats: a range wholly above 2^63 still orders consistently", + "[parquet][stats]") { + // All values >= 2^63 are negative as int64 and still ordered by their bits. + const auto low = std::numeric_limits::min() + 1; + const auto high = std::numeric_limits::min() + 9; + CHECK(stats::usable_signed_range(low, high)); + CHECK(stats::group_excluded(low, high, 0, 100)); + CHECK_FALSE(stats::group_excluded(low, high, low, low)); +} From c143f25642ff99746416c043ca5bacc86d6abe67 Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Mon, 21 Sep 2026 18:43:03 +0200 Subject: [PATCH 35/42] Type-check static range filters against the column they scan `integer_literal` accepted a Date literal without knowing the column, so `t[filter k > date"2020-01-01" && k < date"2030-01-01"]` on an Int column was answered from raw int64 bits (0 rows) by the reader's fast path, while the ordinary filter raises "cannot compare date and non-date". Move `static_range_filter` into its own TU taking the source schema, and accept a Date literal only against a Date column and an Int literal only against an Int or Date column (the ordinary filter compares a date with a day count too). Anything else, including Double and String columns, is left to the ordinary path up front instead of being declined later by the reader. Co-Authored-By: Claude Sonnet 5 --- src/runtime/CMakeLists.txt | 1 + src/runtime/lazy_table.cpp | 89 +----------------- src/runtime/static_range_filter.cpp | 134 ++++++++++++++++++++++++++++ src/runtime/static_range_filter.hpp | 30 +++++++ tests/CMakeLists.txt | 1 + tests/test_static_range_filter.cpp | 109 ++++++++++++++++++++++ 6 files changed, 278 insertions(+), 86 deletions(-) create mode 100644 src/runtime/static_range_filter.cpp create mode 100644 src/runtime/static_range_filter.hpp create mode 100644 tests/test_static_range_filter.cpp diff --git a/src/runtime/CMakeLists.txt b/src/runtime/CMakeLists.txt index e2fa19bb..430a87ce 100644 --- a/src/runtime/CMakeLists.txt +++ b/src/runtime/CMakeLists.txt @@ -23,6 +23,7 @@ add_library(ibex_runtime STATIC kernel_filter.cpp kernel_update.cpp lazy_table.cpp + static_range_filter.cpp model.cpp ops.cpp physical_plan.cpp diff --git a/src/runtime/lazy_table.cpp b/src/runtime/lazy_table.cpp index 18fb36c8..471fffe8 100644 --- a/src/runtime/lazy_table.cpp +++ b/src/runtime/lazy_table.cpp @@ -28,6 +28,7 @@ #include "execution_profile_internal.hpp" #include "runtime_internal.hpp" +#include "static_range_filter.hpp" namespace ibex::runtime { @@ -422,90 +423,6 @@ auto as_like_predicate(const ir::Expr& expr, bool negated) StringScanFilter{.pattern = std::move(*compiled), .negated = negated}}; } -auto inverted_compare(ir::CompareOp op) -> ir::CompareOp { - switch (op) { - case ir::CompareOp::Lt: - return ir::CompareOp::Gt; - case ir::CompareOp::Le: - return ir::CompareOp::Ge; - case ir::CompareOp::Gt: - return ir::CompareOp::Lt; - case ir::CompareOp::Ge: - return ir::CompareOp::Le; - default: - return op; - } -} - -auto integer_literal(const ir::Expr& expr) -> std::optional { - const auto* literal = std::get_if(&expr.node); - if (literal == nullptr) - return std::nullopt; - if (const auto* integer = std::get_if(&literal->value)) - return *integer; - if (const auto* date = std::get_if(&literal->value)) - return date->days; - return std::nullopt; -} - -/// A conjunction made solely of literal comparisons on one integer-like source -/// column. The result is an inclusive interval accepted by DynamicScanFilter. -auto static_range_filter(const std::vector& conjuncts) - -> std::optional> { - if (conjuncts.empty()) - return std::nullopt; - std::optional name; - DynamicScanFilter filter; - for (const auto& expr : conjuncts) { - const auto* comparison = std::get_if(&expr.node); - if (comparison == nullptr || comparison->left == nullptr || comparison->right == nullptr || - comparison->op == ir::CompareOp::Ne) - return std::nullopt; - const auto* column = ir::as_column_ref(*comparison->left); - auto value = integer_literal(*comparison->right); - auto op = comparison->op; - if (column == nullptr || !value.has_value()) { - column = ir::as_column_ref(*comparison->right); - value = integer_literal(*comparison->left); - op = inverted_compare(op); - } - if (column == nullptr || column->lexical || !value.has_value()) - return std::nullopt; - if (name.has_value() && *name != column->name) - return std::nullopt; - name = column->name; - switch (op) { - case ir::CompareOp::Eq: - filter.min = filter.min.has_value() ? std::max(*filter.min, *value) : *value; - filter.max = filter.max.has_value() ? std::min(*filter.max, *value) : *value; - break; - case ir::CompareOp::Le: - filter.max = filter.max.has_value() ? std::min(*filter.max, *value) : *value; - break; - case ir::CompareOp::Ge: - filter.min = filter.min.has_value() ? std::max(*filter.min, *value) : *value; - break; - case ir::CompareOp::Lt: - if (*value == std::numeric_limits::min()) - return std::nullopt; - --*value; - filter.max = filter.max.has_value() ? std::min(*filter.max, *value) : *value; - break; - case ir::CompareOp::Gt: - if (*value == std::numeric_limits::max()) - return std::nullopt; - ++*value; - filter.min = filter.min.has_value() ? std::max(*filter.min, *value) : *value; - break; - case ir::CompareOp::Ne: - return std::nullopt; - } - } - if (!name.has_value()) - return std::nullopt; - return std::pair{std::move(*name), std::move(filter)}; -} - } // namespace auto LazyTable::fusable_string_conjuncts(const std::vector& conjuncts, @@ -662,7 +579,7 @@ auto LazyTable::project_where(const std::set& names, // This is intentionally all-or-nothing: a source decline falls through to // the established decode-and-filter path unchanged. if (reader_factory_ && !conjuncts.empty()) { - if (auto range = static_range_filter(conjuncts); + if (auto range = static_range_filter(conjuncts, schema_); range.has_value() && !names.contains(range->first) && !cache_.contains(range->first)) { auto scan = scan_key_filter(range->first, range->second, nullptr, exec); if (!scan) @@ -962,7 +879,7 @@ auto LazyTable::project_where_unit(const std::set& names, } if (reader_factory_ && !conjuncts.empty()) { - if (auto range = static_range_filter(conjuncts); + if (auto range = static_range_filter(conjuncts, schema_); range.has_value() && !names.contains(range->first) && !cache_.contains(range->first)) { auto scan = scan_key_filter(range->first, range->second, &unit, exec); if (!scan) diff --git a/src/runtime/static_range_filter.cpp b/src/runtime/static_range_filter.cpp new file mode 100644 index 00000000..7df3b6d9 --- /dev/null +++ b/src/runtime/static_range_filter.cpp @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Bob Jansen + +#include "static_range_filter.hpp" + +#include +#include +#include + +#include +#include +#include +#include + +namespace ibex::runtime { + +namespace { + +auto inverted_compare(ir::CompareOp op) -> ir::CompareOp { + switch (op) { + case ir::CompareOp::Lt: + return ir::CompareOp::Gt; + case ir::CompareOp::Le: + return ir::CompareOp::Ge; + case ir::CompareOp::Gt: + return ir::CompareOp::Lt; + case ir::CompareOp::Ge: + return ir::CompareOp::Le; + default: + return op; + } +} + +struct IntegerLiteral { + std::int64_t value = 0; + bool is_date = false; +}; + +auto integer_literal(const ir::Expr& expr) -> std::optional { + const auto* literal = std::get_if(&expr.node); + if (literal == nullptr) { + return std::nullopt; + } + if (const auto* integer = std::get_if(&literal->value)) { + return IntegerLiteral{.value = *integer, .is_date = false}; + } + if (const auto* date = std::get_if(&literal->value)) { + return IntegerLiteral{.value = date->days, .is_date = true}; + } + return std::nullopt; +} + +/// Whether the ordinary filter compares these operands, so answering the +/// comparison from raw int64 bits cannot change what the query means. +auto comparable(const Table& schema, const std::string& column, bool date_literal) -> bool { + const auto* entry = schema.find_entry(column); + if (entry == nullptr) { + return false; + } + if (std::holds_alternative>(*entry->column)) { + return true; + } + return !date_literal && std::holds_alternative>(*entry->column); +} + +} // namespace + +auto static_range_filter(const std::vector& conjuncts, const Table& schema) + -> std::optional> { + if (conjuncts.empty()) { + return std::nullopt; + } + std::optional name; + bool any_date_literal = false; + DynamicScanFilter filter; + for (const auto& expr : conjuncts) { + const auto* comparison = std::get_if(&expr.node); + if (comparison == nullptr || comparison->left == nullptr || comparison->right == nullptr || + comparison->op == ir::CompareOp::Ne) { + return std::nullopt; + } + const auto* column = ir::as_column_ref(*comparison->left); + auto literal = integer_literal(*comparison->right); + auto op = comparison->op; + if (column == nullptr || !literal.has_value()) { + column = ir::as_column_ref(*comparison->right); + literal = integer_literal(*comparison->left); + op = inverted_compare(op); + } + if (column == nullptr || column->lexical || !literal.has_value()) { + return std::nullopt; + } + if (name.has_value() && *name != column->name) { + return std::nullopt; + } + name = column->name; + any_date_literal = any_date_literal || literal->is_date; + std::int64_t value = literal->value; + switch (op) { + case ir::CompareOp::Eq: + filter.min = filter.min.has_value() ? std::max(*filter.min, value) : value; + filter.max = filter.max.has_value() ? std::min(*filter.max, value) : value; + break; + case ir::CompareOp::Le: + filter.max = filter.max.has_value() ? std::min(*filter.max, value) : value; + break; + case ir::CompareOp::Ge: + filter.min = filter.min.has_value() ? std::max(*filter.min, value) : value; + break; + case ir::CompareOp::Lt: + if (value == std::numeric_limits::min()) { + return std::nullopt; + } + --value; + filter.max = filter.max.has_value() ? std::min(*filter.max, value) : value; + break; + case ir::CompareOp::Gt: + if (value == std::numeric_limits::max()) { + return std::nullopt; + } + ++value; + filter.min = filter.min.has_value() ? std::max(*filter.min, value) : value; + break; + case ir::CompareOp::Ne: + return std::nullopt; + } + } + if (!name.has_value() || !comparable(schema, *name, any_date_literal)) { + return std::nullopt; + } + return std::pair{std::move(*name), std::move(filter)}; +} + +} // namespace ibex::runtime diff --git a/src/runtime/static_range_filter.hpp b/src/runtime/static_range_filter.hpp new file mode 100644 index 00000000..27c8104c --- /dev/null +++ b/src/runtime/static_range_filter.hpp @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Bob Jansen + +#pragma once + +#include +#include + +#include +#include +#include +#include + +namespace ibex::runtime { + +/// A conjunction made solely of literal comparisons on one integer-like source +/// column, as an inclusive interval a reader can decide while it decodes the +/// key. nullopt means "not this shape", and the ordinary filter path stands. +/// +/// `schema` types the column, because the reader compares raw int64 bits and so +/// must agree with what the ordinary filter would do with the same operands: an +/// Int literal is compared with an Int or Date column, a Date literal only with +/// a Date column. Anything else (a Date literal against an Int column, a +/// Double or String column) is left to the ordinary path, which reports the +/// type error or evaluates the comparison itself, rather than being answered +/// here as though the operands were compatible. +[[nodiscard]] auto static_range_filter(const std::vector& conjuncts, const Table& schema) + -> std::optional>; + +} // namespace ibex::runtime diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index cd99321f..92ca4219 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -41,6 +41,7 @@ set(IBEX_TEST_SOURCES test_ir_column_origins.cpp test_parquet_dictionary_policy.cpp test_parquet_stats_range.cpp + test_static_range_filter.cpp test_ir_distinct_key_reduction.cpp test_ir_group_key_reduction.cpp test_ir_required_columns.cpp diff --git a/tests/test_static_range_filter.cpp b/tests/test_static_range_filter.cpp new file mode 100644 index 00000000..eeab3c65 --- /dev/null +++ b/tests/test_static_range_filter.cpp @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Bob Jansen + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +#include "static_range_filter.hpp" + +using namespace ibex; + +namespace { + +auto col(const std::string& name) -> ir::Expr { + return ir::Expr{ir::ColumnRef{.name = name}}; +} + +auto lit(std::int64_t value) -> ir::Expr { + return ir::Expr{ir::Literal{.value = value}}; +} + +auto date_lit(std::int32_t days) -> ir::Expr { + return ir::Expr{ir::Literal{.value = Date{.days = days}}}; +} + +auto compare(ir::CompareOp op, ir::Expr left, ir::Expr right) -> ir::Expr { + return ir::Expr{ir::CompareExpr{.op = op, + .left = ir::make_expr_ptr(std::move(left)), + .right = ir::make_expr_ptr(std::move(right))}}; +} + +auto schema() -> runtime::Table { + runtime::Table t; + t.add_column("i", Column{}); + t.add_column("d", Column{}); + t.add_column("x", Column{}); + t.add_column("s", Column{}); + return t; +} + +auto range_of(const std::vector& conjuncts) { + return runtime::static_range_filter(conjuncts, schema()); +} + +} // namespace + +TEST_CASE("static range: literal bounds on an integer column become an interval", + "[runtime][static_range]") { + const auto range = range_of({compare(ir::CompareOp::Ge, col("i"), lit(5)), + compare(ir::CompareOp::Lt, col("i"), lit(10))}); + REQUIRE(range.has_value()); + CHECK(range->first == "i"); + CHECK(range->second.min == 5); + CHECK(range->second.max == 9); +} + +TEST_CASE("static range: a literal on the left flips the comparison", "[runtime][static_range]") { + const auto range = range_of({compare(ir::CompareOp::Lt, lit(5), col("i"))}); + REQUIRE(range.has_value()); + CHECK(range->second.min == 6); + CHECK_FALSE(range->second.max.has_value()); +} + +TEST_CASE("static range: a Date column takes Date and Int literals", "[runtime][static_range]") { + const auto with_date = range_of({compare(ir::CompareOp::Ge, col("d"), date_lit(18000)), + compare(ir::CompareOp::Le, col("d"), date_lit(18010))}); + REQUIRE(with_date.has_value()); + CHECK(with_date->second.min == 18000); + CHECK(with_date->second.max == 18010); + // The ordinary filter compares a date with an integer day count too. + CHECK(range_of({compare(ir::CompareOp::Gt, col("d"), lit(18500))}).has_value()); +} + +TEST_CASE("static range: a Date literal against a non-Date column is left to the filter", + "[runtime][static_range][regression]") { + // The ordinary filter rejects this with "cannot compare date and non-date". + // Answering it from raw int64 bits would return an empty result instead of + // the error. + CHECK_FALSE(range_of({compare(ir::CompareOp::Gt, col("i"), date_lit(18000)), + compare(ir::CompareOp::Lt, col("i"), date_lit(19000))}) + .has_value()); + CHECK_FALSE(range_of({compare(ir::CompareOp::Gt, col("i"), lit(5)), + compare(ir::CompareOp::Lt, col("i"), date_lit(19000))}) + .has_value()); +} + +TEST_CASE("static range: only integer-like columns qualify", "[runtime][static_range]") { + CHECK_FALSE(range_of({compare(ir::CompareOp::Gt, col("x"), lit(5))}).has_value()); + CHECK_FALSE(range_of({compare(ir::CompareOp::Gt, col("s"), lit(5))}).has_value()); + CHECK_FALSE(range_of({compare(ir::CompareOp::Gt, col("missing"), lit(5))}).has_value()); +} + +TEST_CASE("static range: declines shapes the interval cannot express", "[runtime][static_range]") { + CHECK_FALSE(range_of({compare(ir::CompareOp::Ne, col("i"), lit(5))}).has_value()); + CHECK_FALSE(range_of({compare(ir::CompareOp::Gt, col("i"), lit(5)), + compare(ir::CompareOp::Gt, col("d"), lit(5))}) + .has_value()); + CHECK_FALSE(range_of({compare(ir::CompareOp::Lt, col("i"), lit(INT64_MIN))}).has_value()); + CHECK_FALSE(range_of({compare(ir::CompareOp::Gt, col("i"), lit(INT64_MAX))}).has_value()); + CHECK_FALSE(range_of({}).has_value()); +} From 81bcf26e7002410aeaadd772c8aa21444604480a Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Mon, 21 Sep 2026 19:05:51 +0200 Subject: [PATCH 36/42] Broaden the reader's literal-range scan and prune on one-sided bounds The fused key scan answered a filter only when it was entirely literal comparisons on one integer column that the query did not also output. Two common shapes fell to decoding whole predicate columns: a time range whose key is also selected, and a range plus any other conjunct. And `ts >= X`, having no upper bound, pruned no row groups at all. - `split_static_range` absorbs the comparisons on the first answerable integer column and hands back the remaining conjuncts; the source narrows the fused selection with them (as join_key_selection already does) instead of declining the whole predicate. When the range leaves no rows, the remainder is still evaluated over the zero-row schema table so a type error in it is reported as on the ordinary path -- declining there instead sent every empty streamed unit back to the slow path. - Row-group pruning needs only one bound. - The reader declines up front when row groups wholly inside the interval hold more than `footer_pass_rate_limit` of the rows (0.75; 0.5 when the key is also output and gets decoded twice), from footer statistics alone. Without it wide ranges regressed: 36 -> 54 ms at 90% pass with the key in the output. - A dynamic membership filter keeps the old exact shape, so the filter this path would drop is still applied. Release, 10M rows, 50 row groups, sorted key, 8 cores, interleaved A/B, min of 7 (ms, before -> after): 1% range with key output 32 -> 17; range plus extra conjunct 42 -> 17; `ts >= X` tail 31 -> 19; 40% range with key output 37 -> 27; unfiltered/wide/unsorted-key controls within noise. Outputs identical. Co-Authored-By: Claude Sonnet 5 --- include/ibex/runtime/interpreter.hpp | 6 + include/ibex/runtime/lazy_table.hpp | 14 +++ libs/parquet/parquet.hpp | 66 ++++++++++- libs/parquet/stats_range.hpp | 7 ++ src/runtime/lazy_table.cpp | 127 ++++++++++++++------- src/runtime/static_range_filter.cpp | 159 +++++++++++++++++---------- src/runtime/static_range_filter.hpp | 30 +++-- tests/test_parquet_stats_range.cpp | 24 ++++ tests/test_static_range_filter.cpp | 99 ++++++++++++----- 9 files changed, 393 insertions(+), 139 deletions(-) diff --git a/include/ibex/runtime/interpreter.hpp b/include/ibex/runtime/interpreter.hpp index 68ebd210..8428b584 100644 --- a/include/ibex/runtime/interpreter.hpp +++ b/include/ibex/runtime/interpreter.hpp @@ -509,6 +509,12 @@ struct DynamicScanFilter { std::vector in_list; /// Approximate membership; false positives only. std::optional bloom; + /// A range-only filter (no `bloom`) is not worth a fused scan when the + /// interval leaves most rows in place, and the source can tell from its + /// footer before reading a page: it declines when row groups lying wholly + /// inside [min, max] hold more than this fraction of the rows. Lower when + /// the caller will decode the key again for the rows that pass. + double footer_pass_rate_limit = 0.75; [[nodiscard]] auto has_membership() const noexcept -> bool { return bloom.has_value(); } diff --git a/include/ibex/runtime/lazy_table.hpp b/include/ibex/runtime/lazy_table.hpp index aba04d43..296aedc7 100644 --- a/include/ibex/runtime/lazy_table.hpp +++ b/include/ibex/runtime/lazy_table.hpp @@ -339,6 +339,20 @@ class LazyTable { [[nodiscard]] auto scan_key_filter(const std::string& key, const DynamicScanFilter& filter, const SourceUnit* unit, const ExecutionContext& exec) -> std::expected, std::string>; + /// Rows of `unit` (the whole source when null) that satisfy `conjuncts`, + /// decided inside the reader's decoder: the literal range on one integer + /// column is a fused key scan (footer statistics prune whole row groups), + /// and any conjuncts it does not express narrow the result. nullopt means + /// this source cannot answer and the ordinary path stands. With + /// `exact_shape_only` (a dynamic membership filter also applies, which + /// this path would drop) only a predicate that is entirely such a range over + /// a column `names` does not need is taken. + [[nodiscard]] auto static_range_selection(const std::vector& conjuncts, + const std::set& names, + bool exact_shape_only, const SourceUnit* unit, + const ExecutionContext& exec, + const ScalarRegistry* scalars) + -> std::expected, std::string>; /// The conjunct columns eligible for staging through a selection; nullopt /// when any of them is variable-width and the staged path must be declined. [[nodiscard]] auto stageable_conjunct_columns(const std::vector& conjuncts) const diff --git a/libs/parquet/parquet.hpp b/libs/parquet/parquet.hpp index 7f1d47f2..4868e81a 100644 --- a/libs/parquet/parquet.hpp +++ b/libs/parquet/parquet.hpp @@ -54,6 +54,7 @@ #include #endif #include +#include #include #include #include @@ -2588,7 +2589,9 @@ inline auto filtered_key_scan_groups(const parquet::FileMetaData& metadata, int for (int group = 0; group < metadata.num_row_groups(); ++group) { const auto group_rows = static_cast(metadata.RowGroup(group)->num_rows()); bool skip = false; - if (filter.min.has_value() && filter.max.has_value()) { + // One bound is enough to prune: `ts >= X` over a sorted key skips every + // group below X without a max to compare against. + if (filter.min.has_value() || filter.max.has_value()) { const auto chunk = metadata.RowGroup(group)->ColumnChunk(leaf_index); if (chunk->is_stats_set()) { const auto stats = chunk->statistics(); @@ -2598,8 +2601,10 @@ inline auto filtered_key_scan_groups(const parquet::FileMetaData& metadata, int static_cast&>(*stats); const auto group_min = static_cast(typed_stats.min()); const auto group_max = static_cast(typed_stats.max()); - skip = ibex::parquet_stats::group_excluded(group_min, group_max, *filter.min, - *filter.max); + skip = ibex::parquet_stats::group_excluded( + group_min, group_max, + filter.min.value_or(std::numeric_limits::min()), + filter.max.value_or(std::numeric_limits::max())); } } } @@ -2611,6 +2616,50 @@ inline auto filtered_key_scan_groups(const parquet::FileMetaData& metadata, int return groups; } +/// Fraction of the rows in scope (the file, or `unit`'s row group) that sit in +/// row groups whose footer range lies wholly inside the filter's interval, i.e. +/// rows the interval cannot reject. Nulls are ignored: they only make the true +/// pass rate lower, and this decides whether to skip a scan, never which rows +/// pass. +template +inline auto footer_covered_fraction(const parquet::FileMetaData& metadata, int leaf_index, + const ibex::runtime::DynamicScanFilter& filter, + const ibex::runtime::SourceUnit* unit) -> double { + if (!filter.min.has_value() && !filter.max.has_value()) { + return 0.0; + } + const auto lo = filter.min.value_or(std::numeric_limits::min()); + const auto hi = filter.max.value_or(std::numeric_limits::max()); + std::size_t total = 0; + std::size_t covered = 0; + std::size_t base = 0; + for (int group = 0; group < metadata.num_row_groups(); ++group) { + const auto group_rows = static_cast(metadata.RowGroup(group)->num_rows()); + const bool in_scope = + unit == nullptr || (base >= unit->start && base < unit->start + unit->rows); + base += group_rows; + if (!in_scope) { + continue; + } + total += group_rows; + const auto chunk = metadata.RowGroup(group)->ColumnChunk(leaf_index); + if (!chunk->is_stats_set()) { + continue; + } + const auto stats = chunk->statistics(); + if (stats == nullptr || !stats->HasMinMax() || stats->physical_type() != DType::type_num) { + continue; + } + const auto& typed_stats = static_cast&>(*stats); + if (ibex::parquet_stats::group_covered(static_cast(typed_stats.min()), + static_cast(typed_stats.max()), lo, + hi)) { + covered += group_rows; + } + } + return total == 0 ? 0.0 : static_cast(covered) / static_cast(total); +} + /// The abandon rule itself, over totals accumulated in file order. Both the /// serial and the parallel driver ask this same question at the same points, /// so they cannot drift apart. @@ -3263,6 +3312,17 @@ class ParquetLazySourceReader final : public ibex::runtime::LazySourceReader { ->Column(leaf_index) ->physical_type(); const auto& metadata = *reader_->parquet_reader()->metadata(); + // A range that leaves most rows in place is answered by the ordinary + // path without paying for a scan to find that out. Only for a + // range-only filter: a Bloom can reject inside the interval. + if (!filter.bloom.has_value() && + (physical == parquet::Type::INT64 + ? footer_covered_fraction(metadata, leaf_index, filter, + unit) + : footer_covered_fraction( + metadata, leaf_index, filter, unit)) > filter.footer_pass_rate_limit) { + return std::optional{}; + } const auto target = scan_shard_target(unit, exec); if (physical == parquet::Type::INT64) { const auto groups = restrict_to_unit( diff --git a/libs/parquet/stats_range.hpp b/libs/parquet/stats_range.hpp index c556bc1d..7fc620d4 100644 --- a/libs/parquet/stats_range.hpp +++ b/libs/parquet/stats_range.hpp @@ -26,4 +26,11 @@ namespace ibex::parquet_stats { return usable_signed_range(min, max) && (max < lo || min > hi); } +/// Whether every value of a row group whose footer range is [min, max] lies +/// inside the inclusive interval [lo, hi]. False for an unusable range. +[[nodiscard]] constexpr auto group_covered(std::int64_t min, std::int64_t max, std::int64_t lo, + std::int64_t hi) noexcept -> bool { + return usable_signed_range(min, max) && min >= lo && max <= hi; +} + } // namespace ibex::parquet_stats diff --git a/src/runtime/lazy_table.cpp b/src/runtime/lazy_table.cpp index 471fffe8..c15d70c4 100644 --- a/src/runtime/lazy_table.cpp +++ b/src/runtime/lazy_table.cpp @@ -574,29 +574,30 @@ auto LazyTable::project_where(const std::set& names, return project(names, exec); } - // A predicate-only literal range can be decided by a reader while it - // decodes the key, leaving only the selected payload columns to decode. - // This is intentionally all-or-nothing: a source decline falls through to - // the established decode-and-filter path unchanged. + // A literal range on one integer column can be decided by the reader while + // it decodes that key, and its footer statistics skip whole row groups; what + // the range does not express narrows the result afterwards. A source that + // declines falls through to the established decode-and-filter path. if (reader_factory_ && !conjuncts.empty()) { - if (auto range = static_range_filter(conjuncts, schema_); - range.has_value() && !names.contains(range->first) && !cache_.contains(range->first)) { - auto scan = scan_key_filter(range->first, range->second, nullptr, exec); - if (!scan) - return std::unexpected(scan.error()); - if (scan->has_value()) { - const Selection& selected = **scan; - const bool all_rows = selected.size() == rows_; - std::vector wanted; - for (const auto& field : schema_.columns) - if (names.contains(field.name)) - wanted.push_back(field.name); - auto decoded = - decode_columns(wanted, all_rows ? nullptr : &selected, nullptr, exec); - if (!decoded) - return std::unexpected(decoded.error()); - return std::move(*decoded); + auto answered = + static_range_selection(conjuncts, names, membership, nullptr, exec, scalars); + if (!answered) { + return std::unexpected(answered.error()); + } + if (answered->has_value()) { + const Selection& selected = **answered; + const bool all_rows = selected.size() == rows_; + std::vector wanted; + for (const auto& field : schema_.columns) { + if (names.contains(field.name)) { + wanted.push_back(field.name); + } } + auto decoded = decode_columns(wanted, all_rows ? nullptr : &selected, nullptr, exec); + if (!decoded) { + return std::unexpected(decoded.error()); + } + return std::move(*decoded); } } @@ -879,23 +880,24 @@ auto LazyTable::project_where_unit(const std::set& names, } if (reader_factory_ && !conjuncts.empty()) { - if (auto range = static_range_filter(conjuncts, schema_); - range.has_value() && !names.contains(range->first) && !cache_.contains(range->first)) { - auto scan = scan_key_filter(range->first, range->second, &unit, exec); - if (!scan) - return std::unexpected(scan.error()); - if (scan->has_value()) { - const Selection& selected = **scan; - const bool all_rows = selected.size() == unit.rows; - std::vector wanted; - for (const auto& field : schema_.columns) - if (names.contains(field.name)) - wanted.push_back(field.name); - auto decoded = decode_columns(wanted, all_rows ? nullptr : &selected, &unit, exec); - if (!decoded) - return std::unexpected(decoded.error()); - return std::move(*decoded); + auto answered = static_range_selection(conjuncts, names, membership, &unit, exec, scalars); + if (!answered) { + return std::unexpected(answered.error()); + } + if (answered->has_value()) { + const Selection& selected = **answered; + const bool all_rows = selected.size() == unit.rows; + std::vector wanted; + for (const auto& field : schema_.columns) { + if (names.contains(field.name)) { + wanted.push_back(field.name); + } + } + auto decoded = decode_columns(wanted, all_rows ? nullptr : &selected, &unit, exec); + if (!decoded) { + return std::unexpected(decoded.error()); } + return std::move(*decoded); } } @@ -1254,6 +1256,57 @@ auto LazyTable::stageable_conjunct_columns(const std::vector& conjunct /// /// nullopt = the conjuncts reference no column of this source, which this shape /// cannot stage; the caller keeps its whole-column path. +auto LazyTable::static_range_selection(const std::vector& conjuncts, + const std::set& names, bool exact_shape_only, + const SourceUnit* unit, const ExecutionContext& exec, + const ScalarRegistry* scalars) + -> std::expected, std::string> { + auto range = split_static_range(conjuncts, schema_); + if (!range.has_value() || cache_.contains(range->column)) { + return std::optional{}; // a cached key is cheaper tested in memory + } + if (exact_shape_only && (!range->rest.empty() || names.contains(range->column))) { + return std::optional{}; + } + // Decided before scanning: an unstageable remainder would waste the scan. + if (!range->rest.empty() && !stageable_conjunct_columns(range->rest).has_value()) { + return std::optional{}; + } + // When the key is also an output column it is decoded a second time for the + // rows that pass, so the scan has to reject more to pay for itself. + if (names.contains(range->column)) { + range->filter.footer_pass_rate_limit = 0.5; + } + auto scan = scan_key_filter(range->column, range->filter, unit, exec); + if (!scan) { + return std::unexpected(scan.error()); + } + if (!scan->has_value()) { + return std::optional{}; // unsupported type, or nothing rejected + } + Selection selected = std::move(**scan); + if (range->rest.empty()) { + return std::optional{std::move(selected)}; + } + // The range left no row to evaluate the remainder on, and most streamed + // units are like this. The ordinary path evaluates every conjunct over the + // whole column, so it reports a type error in one that the range emptied; + // evaluating them over the zero-row schema table reports it here too, + // without decoding anything. + if (selected.empty()) { + auto checked = filter_selection(schema_, range->rest, exec, scalars); + if (!checked) { + return std::unexpected(checked.error()); + } + return std::optional{std::move(selected)}; + } + auto narrowed = narrow_selection(selected, range->rest, exec, scalars); + if (!narrowed) { + return std::unexpected(narrowed.error()); + } + return narrowed; +} + auto LazyTable::narrow_selection(const Selection& selected, const std::vector& conjuncts, const ExecutionContext& exec, const ScalarRegistry* scalars) -> std::expected, std::string> { diff --git a/src/runtime/static_range_filter.cpp b/src/runtime/static_range_filter.cpp index 7df3b6d9..a3f11443 100644 --- a/src/runtime/static_range_filter.cpp +++ b/src/runtime/static_range_filter.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include namespace ibex::runtime { @@ -50,85 +51,123 @@ auto integer_literal(const ir::Expr& expr) -> std::optional { return std::nullopt; } +/// One `column literal` comparison, normalized so the column is on the +/// left. +struct Term { + std::string column; + ir::CompareOp op = ir::CompareOp::Eq; + IntegerLiteral literal; +}; + +auto as_term(const ir::Expr& expr) -> std::optional { + const auto* comparison = std::get_if(&expr.node); + if (comparison == nullptr || comparison->left == nullptr || comparison->right == nullptr) { + return std::nullopt; + } + const auto* column = ir::as_column_ref(*comparison->left); + auto literal = integer_literal(*comparison->right); + auto op = comparison->op; + if (column == nullptr || !literal.has_value()) { + column = ir::as_column_ref(*comparison->right); + literal = integer_literal(*comparison->left); + op = inverted_compare(op); + } + if (column == nullptr || column->lexical || !literal.has_value()) { + return std::nullopt; + } + return Term{.column = column->name, .op = op, .literal = *literal}; +} + /// Whether the ordinary filter compares these operands, so answering the /// comparison from raw int64 bits cannot change what the query means. -auto comparable(const Table& schema, const std::string& column, bool date_literal) -> bool { - const auto* entry = schema.find_entry(column); +auto comparable(const Table& schema, const Term& term) -> bool { + const auto* entry = schema.find_entry(term.column); if (entry == nullptr) { return false; } if (std::holds_alternative>(*entry->column)) { return true; } - return !date_literal && std::holds_alternative>(*entry->column); + return !term.literal.is_date && std::holds_alternative>(*entry->column); +} + +/// Fold `term` into `filter`. False when the interval cannot express it (`!=`, +/// or a strict bound at the int64 extreme), and the term stays a conjunct. +auto absorb(const Term& term, DynamicScanFilter& filter) -> bool { + std::int64_t value = term.literal.value; + switch (term.op) { + case ir::CompareOp::Eq: + filter.min = filter.min.has_value() ? std::max(*filter.min, value) : value; + filter.max = filter.max.has_value() ? std::min(*filter.max, value) : value; + return true; + case ir::CompareOp::Le: + filter.max = filter.max.has_value() ? std::min(*filter.max, value) : value; + return true; + case ir::CompareOp::Ge: + filter.min = filter.min.has_value() ? std::max(*filter.min, value) : value; + return true; + case ir::CompareOp::Lt: + if (value == std::numeric_limits::min()) { + return false; + } + --value; + filter.max = filter.max.has_value() ? std::min(*filter.max, value) : value; + return true; + case ir::CompareOp::Gt: + if (value == std::numeric_limits::max()) { + return false; + } + ++value; + filter.min = filter.min.has_value() ? std::max(*filter.min, value) : value; + return true; + case ir::CompareOp::Ne: + return false; + } + return false; } } // namespace -auto static_range_filter(const std::vector& conjuncts, const Table& schema) - -> std::optional> { - if (conjuncts.empty()) { - return std::nullopt; +auto split_static_range(const std::vector& conjuncts, const Table& schema) + -> std::optional { + std::vector> terms; + terms.reserve(conjuncts.size()); + for (const auto& conjunct : conjuncts) { + terms.push_back(as_term(conjunct)); } - std::optional name; - bool any_date_literal = false; - DynamicScanFilter filter; - for (const auto& expr : conjuncts) { - const auto* comparison = std::get_if(&expr.node); - if (comparison == nullptr || comparison->left == nullptr || comparison->right == nullptr || - comparison->op == ir::CompareOp::Ne) { - return std::nullopt; - } - const auto* column = ir::as_column_ref(*comparison->left); - auto literal = integer_literal(*comparison->right); - auto op = comparison->op; - if (column == nullptr || !literal.has_value()) { - column = ir::as_column_ref(*comparison->right); - literal = integer_literal(*comparison->left); - op = inverted_compare(op); - } - if (column == nullptr || column->lexical || !literal.has_value()) { - return std::nullopt; - } - if (name.has_value() && *name != column->name) { - return std::nullopt; + + // The first column the reader can answer. + std::optional column; + for (const auto& term : terms) { + if (term.has_value() && comparable(schema, *term)) { + column = term->column; + break; } - name = column->name; - any_date_literal = any_date_literal || literal->is_date; - std::int64_t value = literal->value; - switch (op) { - case ir::CompareOp::Eq: - filter.min = filter.min.has_value() ? std::max(*filter.min, value) : value; - filter.max = filter.max.has_value() ? std::min(*filter.max, value) : value; - break; - case ir::CompareOp::Le: - filter.max = filter.max.has_value() ? std::min(*filter.max, value) : value; - break; - case ir::CompareOp::Ge: - filter.min = filter.min.has_value() ? std::max(*filter.min, value) : value; - break; - case ir::CompareOp::Lt: - if (value == std::numeric_limits::min()) { - return std::nullopt; - } - --value; - filter.max = filter.max.has_value() ? std::min(*filter.max, value) : value; - break; - case ir::CompareOp::Gt: - if (value == std::numeric_limits::max()) { - return std::nullopt; - } - ++value; - filter.min = filter.min.has_value() ? std::max(*filter.min, value) : value; - break; - case ir::CompareOp::Ne: + } + if (!column.has_value()) { + return std::nullopt; + } + + StaticRange out; + out.column = *column; + bool absorbed_any = false; + for (std::size_t i = 0; i < conjuncts.size(); ++i) { + const auto& term = terms[i]; + if (term.has_value() && term->column == *column) { + if (!comparable(schema, *term)) { return std::nullopt; + } + if (absorb(*term, out.filter)) { + absorbed_any = true; + continue; + } } + out.rest.push_back(conjuncts[i]); } - if (!name.has_value() || !comparable(schema, *name, any_date_literal)) { + if (!absorbed_any) { return std::nullopt; } - return std::pair{std::move(*name), std::move(filter)}; + return out; } } // namespace ibex::runtime diff --git a/src/runtime/static_range_filter.hpp b/src/runtime/static_range_filter.hpp index 27c8104c..9786ba37 100644 --- a/src/runtime/static_range_filter.hpp +++ b/src/runtime/static_range_filter.hpp @@ -8,23 +8,35 @@ #include #include -#include #include namespace ibex::runtime { -/// A conjunction made solely of literal comparisons on one integer-like source -/// column, as an inclusive interval a reader can decide while it decodes the -/// key. nullopt means "not this shape", and the ordinary filter path stands. +/// The literal-range part of a conjunction, as an inclusive interval a reader +/// can decide while it decodes one integer-like key, plus every conjunct that +/// interval does not express. +struct StaticRange { + std::string column; + DynamicScanFilter filter; + /// Conjuncts left over, in their original order. Empty when the interval is + /// the whole predicate. + std::vector rest; +}; + +/// Split `conjuncts` into the comparisons of one integer-like column against +/// literals and the rest. The column is the first one with such a comparison +/// that the reader can answer; its other comparisons are folded into the same +/// interval. nullopt means no such column, and the ordinary filter path stands. /// /// `schema` types the column, because the reader compares raw int64 bits and so /// must agree with what the ordinary filter would do with the same operands: an /// Int literal is compared with an Int or Date column, a Date literal only with /// a Date column. Anything else (a Date literal against an Int column, a -/// Double or String column) is left to the ordinary path, which reports the -/// type error or evaluates the comparison itself, rather than being answered -/// here as though the operands were compatible. -[[nodiscard]] auto static_range_filter(const std::vector& conjuncts, const Table& schema) - -> std::optional>; +/// Double or String column) is never absorbed into the interval. When a column +/// is chosen and another comparison on it has incompatible operands the whole +/// split declines, so the ordinary path can report the type error rather than +/// have it hidden by the rows the interval already removed. +[[nodiscard]] auto split_static_range(const std::vector& conjuncts, const Table& schema) + -> std::optional; } // namespace ibex::runtime diff --git a/tests/test_parquet_stats_range.cpp b/tests/test_parquet_stats_range.cpp index d62c6b8a..3cb19c72 100644 --- a/tests/test_parquet_stats_range.cpp +++ b/tests/test_parquet_stats_range.cpp @@ -40,3 +40,27 @@ TEST_CASE("parquet stats: a range wholly above 2^63 still orders consistently", CHECK(stats::group_excluded(low, high, 0, 100)); CHECK_FALSE(stats::group_excluded(low, high, low, low)); } + +TEST_CASE("parquet stats: an unbounded side never excludes a group", "[parquet][stats]") { + // `ts >= 100` has no upper bound and `ts <= 5` no lower one; the scan passes + // the missing side as the int64 extreme. + constexpr auto lo = std::numeric_limits::min(); + constexpr auto hi = std::numeric_limits::max(); + CHECK(stats::group_excluded(0, 50, 100, hi)); // whole group below the bound + CHECK_FALSE(stats::group_excluded(0, 150, 100, hi)); + CHECK(stats::group_excluded(10, 50, lo, 5)); // whole group above the bound + CHECK_FALSE(stats::group_excluded(0, 50, lo, 5)); + CHECK_FALSE(stats::group_excluded(0, 50, lo, hi)); +} + +TEST_CASE("parquet stats: a group is covered only when its whole range is inside the interval", + "[parquet][stats]") { + CHECK(stats::group_covered(10, 20, 0, 100)); + CHECK(stats::group_covered(0, 100, 0, 100)); + CHECK_FALSE(stats::group_covered(-1, 20, 0, 100)); + CHECK_FALSE(stats::group_covered(10, 101, 0, 100)); + // An inverted (unsigned, 2^63-spanning) range is never trusted either way. + CHECK_FALSE(stats::group_covered(0, std::numeric_limits::min() + 3, + std::numeric_limits::min(), + std::numeric_limits::max())); +} diff --git a/tests/test_static_range_filter.cpp b/tests/test_static_range_filter.cpp index eeab3c65..5d25b66b 100644 --- a/tests/test_static_range_filter.cpp +++ b/tests/test_static_range_filter.cpp @@ -46,37 +46,38 @@ auto schema() -> runtime::Table { return t; } -auto range_of(const std::vector& conjuncts) { - return runtime::static_range_filter(conjuncts, schema()); +auto split(const std::vector& conjuncts) { + return runtime::split_static_range(conjuncts, schema()); } } // namespace TEST_CASE("static range: literal bounds on an integer column become an interval", "[runtime][static_range]") { - const auto range = range_of({compare(ir::CompareOp::Ge, col("i"), lit(5)), - compare(ir::CompareOp::Lt, col("i"), lit(10))}); + const auto range = split({compare(ir::CompareOp::Ge, col("i"), lit(5)), + compare(ir::CompareOp::Lt, col("i"), lit(10))}); REQUIRE(range.has_value()); - CHECK(range->first == "i"); - CHECK(range->second.min == 5); - CHECK(range->second.max == 9); + CHECK(range->column == "i"); + CHECK(range->filter.min == 5); + CHECK(range->filter.max == 9); + CHECK(range->rest.empty()); } TEST_CASE("static range: a literal on the left flips the comparison", "[runtime][static_range]") { - const auto range = range_of({compare(ir::CompareOp::Lt, lit(5), col("i"))}); + const auto range = split({compare(ir::CompareOp::Lt, lit(5), col("i"))}); REQUIRE(range.has_value()); - CHECK(range->second.min == 6); - CHECK_FALSE(range->second.max.has_value()); + CHECK(range->filter.min == 6); + CHECK_FALSE(range->filter.max.has_value()); } TEST_CASE("static range: a Date column takes Date and Int literals", "[runtime][static_range]") { - const auto with_date = range_of({compare(ir::CompareOp::Ge, col("d"), date_lit(18000)), - compare(ir::CompareOp::Le, col("d"), date_lit(18010))}); + const auto with_date = split({compare(ir::CompareOp::Ge, col("d"), date_lit(18000)), + compare(ir::CompareOp::Le, col("d"), date_lit(18010))}); REQUIRE(with_date.has_value()); - CHECK(with_date->second.min == 18000); - CHECK(with_date->second.max == 18010); + CHECK(with_date->filter.min == 18000); + CHECK(with_date->filter.max == 18010); // The ordinary filter compares a date with an integer day count too. - CHECK(range_of({compare(ir::CompareOp::Gt, col("d"), lit(18500))}).has_value()); + CHECK(split({compare(ir::CompareOp::Gt, col("d"), lit(18500))}).has_value()); } TEST_CASE("static range: a Date literal against a non-Date column is left to the filter", @@ -84,26 +85,64 @@ TEST_CASE("static range: a Date literal against a non-Date column is left to the // The ordinary filter rejects this with "cannot compare date and non-date". // Answering it from raw int64 bits would return an empty result instead of // the error. - CHECK_FALSE(range_of({compare(ir::CompareOp::Gt, col("i"), date_lit(18000)), - compare(ir::CompareOp::Lt, col("i"), date_lit(19000))}) + CHECK_FALSE(split({compare(ir::CompareOp::Gt, col("i"), date_lit(18000)), + compare(ir::CompareOp::Lt, col("i"), date_lit(19000))}) .has_value()); - CHECK_FALSE(range_of({compare(ir::CompareOp::Gt, col("i"), lit(5)), - compare(ir::CompareOp::Lt, col("i"), date_lit(19000))}) + // Even when another comparison on the same column would be fine by itself, + // the split declines whole so the error is not hidden by the rows it removed. + CHECK_FALSE(split({compare(ir::CompareOp::Gt, col("i"), lit(5)), + compare(ir::CompareOp::Lt, col("i"), date_lit(19000))}) .has_value()); } TEST_CASE("static range: only integer-like columns qualify", "[runtime][static_range]") { - CHECK_FALSE(range_of({compare(ir::CompareOp::Gt, col("x"), lit(5))}).has_value()); - CHECK_FALSE(range_of({compare(ir::CompareOp::Gt, col("s"), lit(5))}).has_value()); - CHECK_FALSE(range_of({compare(ir::CompareOp::Gt, col("missing"), lit(5))}).has_value()); + CHECK_FALSE(split({compare(ir::CompareOp::Gt, col("x"), lit(5))}).has_value()); + CHECK_FALSE(split({compare(ir::CompareOp::Gt, col("s"), lit(5))}).has_value()); + CHECK_FALSE(split({compare(ir::CompareOp::Gt, col("missing"), lit(5))}).has_value()); + CHECK_FALSE(split({}).has_value()); } -TEST_CASE("static range: declines shapes the interval cannot express", "[runtime][static_range]") { - CHECK_FALSE(range_of({compare(ir::CompareOp::Ne, col("i"), lit(5))}).has_value()); - CHECK_FALSE(range_of({compare(ir::CompareOp::Gt, col("i"), lit(5)), - compare(ir::CompareOp::Gt, col("d"), lit(5))}) - .has_value()); - CHECK_FALSE(range_of({compare(ir::CompareOp::Lt, col("i"), lit(INT64_MIN))}).has_value()); - CHECK_FALSE(range_of({compare(ir::CompareOp::Gt, col("i"), lit(INT64_MAX))}).has_value()); - CHECK_FALSE(range_of({}).has_value()); +TEST_CASE("static range: what the interval cannot express is handed back in order", + "[runtime][static_range]") { + const auto other = compare(ir::CompareOp::Gt, col("x"), ir::Expr{ir::Literal{.value = 0.5}}); + const auto range = split({compare(ir::CompareOp::Ge, col("i"), lit(5)), other, + compare(ir::CompareOp::Ne, col("i"), lit(7)), + compare(ir::CompareOp::Lt, col("i"), lit(10))}); + REQUIRE(range.has_value()); + CHECK(range->column == "i"); + CHECK(range->filter.min == 5); + CHECK(range->filter.max == 9); + CHECK(range->rest.size() == 2); // the double comparison, then `i != 7` +} + +TEST_CASE("static range: the first answerable column wins and the others stay conjuncts", + "[runtime][static_range]") { + const auto range = split({compare(ir::CompareOp::Gt, col("x"), lit(1)), + compare(ir::CompareOp::Ge, col("d"), lit(100)), + compare(ir::CompareOp::Gt, col("i"), lit(5))}); + REQUIRE(range.has_value()); + CHECK(range->column == "d"); + CHECK(range->rest.size() == 2); +} + +TEST_CASE("static range: nothing absorbed means no split", "[runtime][static_range]") { + CHECK_FALSE(split({compare(ir::CompareOp::Ne, col("i"), lit(5))}).has_value()); + CHECK_FALSE(split({compare(ir::CompareOp::Lt, col("i"), lit(INT64_MIN))}).has_value()); + CHECK_FALSE(split({compare(ir::CompareOp::Gt, col("i"), lit(INT64_MAX))}).has_value()); +} + +TEST_CASE("static range: a zero-row schema table still reports a type error in a conjunct", + "[runtime][static_range][regression]") { + // `static_range_selection` evaluates the conjuncts the interval leaves over + // this typed, empty table when the interval selected no rows at all, so a + // type error a range removed every row for is still reported. + const auto empty = schema(); + const runtime::ExecutionContext exec; + const auto bad = runtime::filter_selection( + empty, {compare(ir::CompareOp::Gt, col("i"), date_lit(18000))}, exec, nullptr); + CHECK_FALSE(bad.has_value()); + const auto fine = runtime::filter_selection( + empty, {compare(ir::CompareOp::Gt, col("i"), lit(5))}, exec, nullptr); + REQUIRE(fine.has_value()); + CHECK(fine->empty()); } From 011a55e38cbf2cb6caff0af762bd380b14632fe4 Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Mon, 21 Sep 2026 21:20:14 +0200 Subject: [PATCH 37/42] Answer double literals against int columns from the reader's range scan `ts > 4000000.5` on an Int column fell to decoding whole predicate columns, because only Int and Date literals were absorbed. A Double literal against an Int column is now folded into the interval as the integer bound it is equivalent to (`> 5.5` is `>= 6`, `<= 5.5` is `<= 5`, a fractional `==` is an empty interval that prunes every group). The translation is exact below 2^53: the derived bounds are representable and rounding an int64 to double is monotone, so comparing the column with the literal agrees with comparing it with the bound whether the comparison is exact or goes through double. Non-finite literals and magnitudes at or above 2^53 stay ordinary conjuncts, as does a Double literal against a Date column. Release, 10M rows, 8 cores, interleaved A/B, min of 7 (ms, before -> after): 1% range 34 -> 16; 40% range with key output 36 -> 29; one-sided tail 31 -> 16; fractional equality 32 -> 11. Integer-literal and unfiltered controls unchanged; outputs identical. Co-Authored-By: Claude Sonnet 5 --- src/runtime/static_range_filter.cpp | 94 +++++++++++++++++++++++++---- src/runtime/static_range_filter.hpp | 7 ++- tests/test_static_range_filter.cpp | 71 ++++++++++++++++++++++ 3 files changed, 157 insertions(+), 15 deletions(-) diff --git a/src/runtime/static_range_filter.cpp b/src/runtime/static_range_filter.cpp index a3f11443..2cce6ddc 100644 --- a/src/runtime/static_range_filter.cpp +++ b/src/runtime/static_range_filter.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -32,21 +33,27 @@ auto inverted_compare(ir::CompareOp op) -> ir::CompareOp { } } -struct IntegerLiteral { - std::int64_t value = 0; - bool is_date = false; +/// A literal the interval can be built from. +struct RangeLiteral { + enum class Kind : std::uint8_t { Int, Date, Double }; + Kind kind = Kind::Int; + std::int64_t value = 0; // Int and Date + double real = 0.0; // Double }; -auto integer_literal(const ir::Expr& expr) -> std::optional { +auto range_literal(const ir::Expr& expr) -> std::optional { const auto* literal = std::get_if(&expr.node); if (literal == nullptr) { return std::nullopt; } if (const auto* integer = std::get_if(&literal->value)) { - return IntegerLiteral{.value = *integer, .is_date = false}; + return RangeLiteral{.kind = RangeLiteral::Kind::Int, .value = *integer}; } if (const auto* date = std::get_if(&literal->value)) { - return IntegerLiteral{.value = date->days, .is_date = true}; + return RangeLiteral{.kind = RangeLiteral::Kind::Date, .value = date->days}; + } + if (const auto* real = std::get_if(&literal->value)) { + return RangeLiteral{.kind = RangeLiteral::Kind::Double, .real = *real}; } return std::nullopt; } @@ -56,7 +63,7 @@ auto integer_literal(const ir::Expr& expr) -> std::optional { struct Term { std::string column; ir::CompareOp op = ir::CompareOp::Eq; - IntegerLiteral literal; + RangeLiteral literal; }; auto as_term(const ir::Expr& expr) -> std::optional { @@ -65,11 +72,11 @@ auto as_term(const ir::Expr& expr) -> std::optional { return std::nullopt; } const auto* column = ir::as_column_ref(*comparison->left); - auto literal = integer_literal(*comparison->right); + auto literal = range_literal(*comparison->right); auto op = comparison->op; if (column == nullptr || !literal.has_value()) { column = ir::as_column_ref(*comparison->right); - literal = integer_literal(*comparison->left); + literal = range_literal(*comparison->left); op = inverted_compare(op); } if (column == nullptr || column->lexical || !literal.has_value()) { @@ -79,21 +86,82 @@ auto as_term(const ir::Expr& expr) -> std::optional { } /// Whether the ordinary filter compares these operands, so answering the -/// comparison from raw int64 bits cannot change what the query means. +/// comparison from raw int64 bits cannot change what the query means. A Date +/// literal needs a Date column and a Double literal an Int column; an Int +/// literal is compared with either. auto comparable(const Table& schema, const Term& term) -> bool { const auto* entry = schema.find_entry(term.column); if (entry == nullptr) { return false; } - if (std::holds_alternative>(*entry->column)) { - return true; + const bool is_date = std::holds_alternative>(*entry->column); + const bool is_int = std::holds_alternative>(*entry->column); + switch (term.literal.kind) { + case RangeLiteral::Kind::Int: + return is_date || is_int; + case RangeLiteral::Kind::Date: + return is_date; + case RangeLiteral::Kind::Double: + return is_int; + } + return false; +} + +/// Below this magnitude every integer bound derived from a double literal is +/// exactly representable, and rounding an int64 to double is monotone, so +/// comparing the column with the literal agrees with comparing it with the +/// derived integer bound whether the comparison is exact or goes through +/// double. Beyond it the literal stays an ordinary conjunct. +constexpr double kExactDoubleLimit = 9007199254740992.0; // 2^53 + +/// Fold a comparison against a double literal into `filter` as integer bounds. +auto absorb_real(ir::CompareOp op, double x, DynamicScanFilter& filter) -> bool { + if (!std::isfinite(x) || std::fabs(x) >= kExactDoubleLimit) { + return false; + } + const auto floor_x = static_cast(std::floor(x)); + const auto ceil_x = static_cast(std::ceil(x)); + const auto raise_min = [&](std::int64_t v) { + filter.min = filter.min.has_value() ? std::max(*filter.min, v) : v; + }; + const auto lower_max = [&](std::int64_t v) { + filter.max = filter.max.has_value() ? std::min(*filter.max, v) : v; + }; + switch (op) { + case ir::CompareOp::Gt: + raise_min(floor_x + 1); + return true; + case ir::CompareOp::Ge: + raise_min(ceil_x); + return true; + case ir::CompareOp::Lt: + lower_max(ceil_x - 1); + return true; + case ir::CompareOp::Le: + lower_max(floor_x); + return true; + case ir::CompareOp::Eq: + if (floor_x == ceil_x) { + raise_min(floor_x); + lower_max(floor_x); + } else { + // No integer equals a fractional literal: an empty interval. + filter.min = std::numeric_limits::max(); + filter.max = std::numeric_limits::min(); + } + return true; + case ir::CompareOp::Ne: + return false; } - return !term.literal.is_date && std::holds_alternative>(*entry->column); + return false; } /// Fold `term` into `filter`. False when the interval cannot express it (`!=`, /// or a strict bound at the int64 extreme), and the term stays a conjunct. auto absorb(const Term& term, DynamicScanFilter& filter) -> bool { + if (term.literal.kind == RangeLiteral::Kind::Double) { + return absorb_real(term.op, term.literal.real, filter); + } std::int64_t value = term.literal.value; switch (term.op) { case ir::CompareOp::Eq: diff --git a/src/runtime/static_range_filter.hpp b/src/runtime/static_range_filter.hpp index 9786ba37..87fa7545 100644 --- a/src/runtime/static_range_filter.hpp +++ b/src/runtime/static_range_filter.hpp @@ -31,8 +31,11 @@ struct StaticRange { /// `schema` types the column, because the reader compares raw int64 bits and so /// must agree with what the ordinary filter would do with the same operands: an /// Int literal is compared with an Int or Date column, a Date literal only with -/// a Date column. Anything else (a Date literal against an Int column, a -/// Double or String column) is never absorbed into the interval. When a column +/// a Date column, and a Double literal with an Int column, where it becomes the +/// integer bound it is equivalent to (`i > 5.5` is `i >= 6`; a fractional `==` +/// is an empty interval; a literal too large to stay exact is left alone). +/// Anything else (a Date literal against an Int column, a Double or String +/// column) is never absorbed into the interval. When a column /// is chosen and another comparison on it has incompatible operands the whole /// split declines, so the ordinary path can report the type error rather than /// have it hidden by the rows the interval already removed. diff --git a/tests/test_static_range_filter.cpp b/tests/test_static_range_filter.cpp index 5d25b66b..d1cf57be 100644 --- a/tests/test_static_range_filter.cpp +++ b/tests/test_static_range_filter.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -146,3 +147,73 @@ TEST_CASE("static range: a zero-row schema table still reports a type error in a REQUIRE(fine.has_value()); CHECK(fine->empty()); } + +namespace { + +auto real(double x) -> ir::Expr { + return ir::Expr{ir::Literal{.value = x}}; +} + +auto bounds_of(ir::CompareOp op, double x) { + return split({compare(op, col("i"), real(x))}); +} + +} // namespace + +TEST_CASE("static range: a double literal against an int column becomes integer bounds", + "[runtime][static_range]") { + const auto gt = bounds_of(ir::CompareOp::Gt, 5.5); + REQUIRE(gt.has_value()); + CHECK(gt->filter.min == 6); + CHECK(bounds_of(ir::CompareOp::Ge, 5.5)->filter.min == 6); + CHECK(bounds_of(ir::CompareOp::Lt, 5.5)->filter.max == 5); + CHECK(bounds_of(ir::CompareOp::Le, 5.5)->filter.max == 5); + // An integral double is the integer itself. + CHECK(bounds_of(ir::CompareOp::Gt, 5.0)->filter.min == 6); + CHECK(bounds_of(ir::CompareOp::Ge, 5.0)->filter.min == 5); + CHECK(bounds_of(ir::CompareOp::Lt, 5.0)->filter.max == 4); + CHECK(bounds_of(ir::CompareOp::Le, 5.0)->filter.max == 5); + const auto eq = bounds_of(ir::CompareOp::Eq, 5.0); + CHECK(eq->filter.min == 5); + CHECK(eq->filter.max == 5); + // Negative literals round toward the correct side. + CHECK(bounds_of(ir::CompareOp::Gt, -5.5)->filter.min == -5); + CHECK(bounds_of(ir::CompareOp::Ge, -5.5)->filter.min == -5); + CHECK(bounds_of(ir::CompareOp::Lt, -5.5)->filter.max == -6); + CHECK(bounds_of(ir::CompareOp::Le, -5.5)->filter.max == -6); +} + +TEST_CASE("static range: a fractional equality is an empty interval", "[runtime][static_range]") { + const auto eq = bounds_of(ir::CompareOp::Eq, 5.5); + REQUIRE(eq.has_value()); + CHECK(*eq->filter.min > *eq->filter.max); + // Later bounds cannot reopen it. + const auto later = split({compare(ir::CompareOp::Eq, col("i"), real(5.5)), + compare(ir::CompareOp::Ge, col("i"), lit(0)), + compare(ir::CompareOp::Le, col("i"), lit(10))}); + REQUIRE(later.has_value()); + CHECK(*later->filter.min > *later->filter.max); +} + +TEST_CASE("static range: a double literal combines with integer bounds and flips on the left", + "[runtime][static_range]") { + const auto range = split({compare(ir::CompareOp::Lt, real(4.5), col("i")), + compare(ir::CompareOp::Lt, col("i"), lit(10))}); + REQUIRE(range.has_value()); + CHECK(range->filter.min == 5); + CHECK(range->filter.max == 9); + CHECK(range->rest.empty()); +} + +TEST_CASE("static range: double literals that cannot stay exact are conjuncts", + "[runtime][static_range]") { + // 2^53 - 1 is the last magnitude below the exactness limit. + CHECK(bounds_of(ir::CompareOp::Gt, 9007199254740991.0).has_value()); + CHECK_FALSE(bounds_of(ir::CompareOp::Gt, 9007199254740992.0).has_value()); + CHECK_FALSE(bounds_of(ir::CompareOp::Gt, 1e30).has_value()); + CHECK_FALSE(bounds_of(ir::CompareOp::Lt, -1e30).has_value()); + CHECK_FALSE(bounds_of(ir::CompareOp::Gt, std::numeric_limits::infinity()).has_value()); + CHECK_FALSE(bounds_of(ir::CompareOp::Gt, std::numeric_limits::quiet_NaN()).has_value()); + // A double literal is never compared with a Date column by the interval. + CHECK_FALSE(split({compare(ir::CompareOp::Gt, col("d"), real(5.5))}).has_value()); +} From 54d0b445fdf1c657f1da300a8daece69ad4b4cd5 Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Tue, 22 Sep 2026 07:24:53 +0200 Subject: [PATCH 38/42] Remove double include --- tools/ibex_bench.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/tools/ibex_bench.cpp b/tools/ibex_bench.cpp index 20583894..a0e68a19 100644 --- a/tools/ibex_bench.cpp +++ b/tools/ibex_bench.cpp @@ -10,8 +10,6 @@ #include -#include - // When jemalloc is linked, prevent large allocations from being returned to the OS // between benchmark iterations. By default jemalloc decays dirty pages after 10 s, // but on WSL2 huge allocations (>= 2 MB) are munmap'd immediately on free, so every From a23c029eaff4c68cd6001246adc46eae85e244fd Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Tue, 22 Sep 2026 08:15:41 +0200 Subject: [PATCH 39/42] Align normalize_input with the one in ibex_bench --- src/repl/repl.cpp | 5 ++--- tests/test_repl.cpp | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/repl/repl.cpp b/src/repl/repl.cpp index 694ce1d3..c8cfddea 100644 --- a/src/repl/repl.cpp +++ b/src/repl/repl.cpp @@ -5757,9 +5757,8 @@ void print_physical_explain(parser::Expr& expr, const runtime::TableRegistry& ta } // namespace auto normalize_input(std::string_view input) -> std::string { - std::string normalized(input); - auto last_non_space = normalized.find_last_not_of(" \t\r\n"); - if (last_non_space != std::string::npos && normalized[last_non_space] != ';') { + auto normalized = std::string(ibex::trim(input)); + if (!normalized.empty() && normalized.back() != ';') { normalized.push_back(';'); } return normalized; diff --git a/tests/test_repl.cpp b/tests/test_repl.cpp index d38f8f29..c55ed8e2 100644 --- a/tests/test_repl.cpp +++ b/tests/test_repl.cpp @@ -40,7 +40,7 @@ using ibex::repl::normalize_input; TEST_CASE("REPL normalizes implicit semicolons") { REQUIRE(normalize_input("1+1") == "1+1;"); REQUIRE(normalize_input("let x = 1;") == "let x = 1;"); - REQUIRE(normalize_input(" 1+1 ") == " 1+1 ;"); + REQUIRE(normalize_input(" 1+1 ") == "1+1;"); REQUIRE(normalize_input("").empty()); } From 69aa3181fbd90ab03aa864c89080318ad151ce29 Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Tue, 22 Sep 2026 08:20:23 +0200 Subject: [PATCH 40/42] Infer empty CSV fields as null in numeric columns A bare empty field in an otherwise-numeric column previously failed the int64/double inference probes outright, demoting the whole column to String (and, downstream, making count() include the blank as a value and sum() fail) even when no null spec was passed. The probes now treat an empty field as null when the column resolves to a numeric type, using a local copy of the validity bitmap so the empty-as-null bits never leak into a column that ends up String instead (where empty fields keep reading as "" as before). Co-Authored-By: Claude Sonnet 5 --- SPEC.md | 5 ++++- libs/csv/csv.hpp | 50 ++++++++++++++++++++++++++++++++++++++++------ tests/test_csv.cpp | 43 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 7 deletions(-) diff --git a/SPEC.md b/SPEC.md index bbc0d32d..90579299 100644 --- a/SPEC.md +++ b/SPEC.md @@ -3575,7 +3575,10 @@ let iris = read_csv("iris.csv"); ``` `read_csv` infers column types from the input file (Int64, Float64, or String -per column). The resulting schema is implementation-defined. +per column). The resulting schema is implementation-defined. A bare empty +field (`a,,c`) reads as null rather than forcing an otherwise-numeric column +to String; a column with no non-empty values, or one with a non-numeric +value in any row, still infers as String, with empty fields kept as `""`. The standard CLI, REPL, and Python hosts link the first-party Parquet backend directly; an optional thin compatibility plugin delegates to that same backend. diff --git a/libs/csv/csv.hpp b/libs/csv/csv.hpp index 8fb5ce7d..484a443d 100644 --- a/libs/csv/csv.hpp +++ b/libs/csv/csv.hpp @@ -1370,17 +1370,38 @@ inline auto read_csv_with_options(std::string_view path, const CsvReadOptions& o } // Try int64 with a fused build pass: if every non-null value parses, - // we keep the built column directly. + // we keep the built column directly. A bare empty field reads as + // null in an otherwise-numeric column, even without an explicit + // null spec (""), rather than forcing the whole column to + // String; a column that instead resolves to String below leaves + // empty fields as empty strings, unaffected by this. The probe + // works on a local copy of the validity bitmap so a failed probe + // (or the double probe that follows) never leaks empty-as-null + // bits into a column that ends up String. if (n > 0) { ibex::Column int_col; int_col.reserve(n); bool all_int = true; bool any_valid = false; + bool any_empty = false; + ibex::runtime::ValidityBitmap probe_validity; for (std::size_t i = 0; i < n; ++i) { if (is_null(i)) { int_col.push_back(0); continue; } + if (vals[i].empty()) { + if (!any_empty) { + probe_validity = has_nulls ? validity : ibex::runtime::ValidityBitmap{}; + if (!has_nulls) { + probe_validity.assign(n, true); + } + any_empty = true; + } + probe_validity.set(i, false); + int_col.push_back(0); + continue; + } std::int64_t iv{}; if (!csv_try_int(vals[i], iv)) { all_int = false; @@ -1390,8 +1411,9 @@ inline auto read_csv_with_options(std::string_view path, const CsvReadOptions& o any_valid = true; } if (all_int && any_valid) { - if (has_nulls) { - table.add_column(name, std::move(int_col), std::move(validity)); + if (has_nulls || any_empty) { + table.add_column(name, std::move(int_col), + any_empty ? std::move(probe_validity) : std::move(validity)); } else { table.add_column(name, std::move(int_col)); } @@ -1399,17 +1421,32 @@ inline auto read_csv_with_options(std::string_view path, const CsvReadOptions& o } } - // Try double with a fused build pass. + // Try double with a fused build pass. Same empty-as-null treatment + // as the int64 probe above, also on a local validity copy. if (n > 0) { ibex::Column dbl_col; dbl_col.reserve(n); bool all_double = true; bool any_valid = false; + bool any_empty = false; + ibex::runtime::ValidityBitmap probe_validity; for (std::size_t i = 0; i < n; ++i) { if (is_null(i)) { dbl_col.push_back(0.0); continue; } + if (vals[i].empty()) { + if (!any_empty) { + probe_validity = has_nulls ? validity : ibex::runtime::ValidityBitmap{}; + if (!has_nulls) { + probe_validity.assign(n, true); + } + any_empty = true; + } + probe_validity.set(i, false); + dbl_col.push_back(0.0); + continue; + } double dv{}; if (!csv_try_double(vals[i], dv)) { all_double = false; @@ -1419,8 +1456,9 @@ inline auto read_csv_with_options(std::string_view path, const CsvReadOptions& o any_valid = true; } if (all_double && any_valid) { - if (has_nulls) { - table.add_column(name, std::move(dbl_col), std::move(validity)); + if (has_nulls || any_empty) { + table.add_column(name, std::move(dbl_col), + any_empty ? std::move(probe_validity) : std::move(validity)); } else { table.add_column(name, std::move(dbl_col)); } diff --git a/tests/test_csv.cpp b/tests/test_csv.cpp index c17ecb67..3e0118cc 100644 --- a/tests/test_csv.cpp +++ b/tests/test_csv.cpp @@ -199,6 +199,49 @@ TEST_CASE("Read CSV - mixed numeric/non-numeric falls back to string") { REQUIRE(get_string_at(table, "price", 2) == "N/A"); } +TEST_CASE("Read CSV - bare empty field in numeric column infers as null, not string") { + auto path = tmp("ibex_test_empty_numeric.csv"); + // No null spec passed: an empty cell in an otherwise-double column must + // still infer as Float64 with a null, not demote the column to String. + // A second, always-populated column keeps the middle row from being a + // blank line (which the parser treats as a row separator, not a row of + // empty fields). + write_csv(path, "price,tag\n1.5,a\n,b\n2.5,c\n"); + + auto table = read_csv(path.string()); + REQUIRE(table.rows() == 3); + const auto* prices = std::get_if>(table.find("price")); + REQUIRE(prices != nullptr); + REQUIRE((*prices)[0] == Catch::Approx(1.5)); + REQUIRE_FALSE(is_null_at(table, "price", 0)); + REQUIRE(is_null_at(table, "price", 1)); + REQUIRE_FALSE(is_null_at(table, "price", 2)); + REQUIRE((*prices)[2] == Catch::Approx(2.5)); +} + +TEST_CASE("Read CSV - bare empty field in int column infers as nullable Int64") { + auto path = tmp("ibex_test_empty_int.csv"); + write_csv(path, "qty,tag\n10,a\n,b\n30,c\n"); + + auto table = read_csv(path.string()); + REQUIRE(table.rows() == 3); + const auto* qtys = std::get_if>(table.find("qty")); + REQUIRE(qtys != nullptr); + REQUIRE((*qtys)[0] == 10); + REQUIRE(is_null_at(table, "qty", 1)); + REQUIRE((*qtys)[2] == 30); +} + +TEST_CASE("Read CSV - column of only empty fields still falls back to string") { + auto path = tmp("ibex_test_all_empty.csv"); + write_csv(path, "a,tag\n,x\n,y\n"); + + auto table = read_csv(path.string()); + REQUIRE(table.rows() == 2); + REQUIRE(get_string_at(table, "a", 0) == ""); + REQUIRE(get_string_at(table, "a", 1) == ""); +} + TEST_CASE("Read CSV - single data row") { auto path = tmp("ibex_test_onerow.csv"); write_csv(path, "a,b\n42,hello\n"); From e5038dc8fd7bc26cd8f46a664873002712a08da7 Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Tue, 22 Sep 2026 08:28:25 +0200 Subject: [PATCH 41/42] Better and hpp linting --- .clang-tidy | 19 +++++++++++++++++++ .clangd | 1 + .github/workflows/ci.yml | 17 +++++++++++------ include/ibex/format.hpp | 2 +- include/ibex/ir/expr_predicates.hpp | 7 +++++++ include/ibex/ir/node.hpp | 5 +++++ include/ibex/parser/scalar_bindings.hpp | 9 +++++---- include/ibex/runtime/interpreter.hpp | 12 ++++++++++-- include/ibex/runtime/lazy_table.hpp | 5 +++++ include/ibex/runtime/query_lease.hpp | 2 +- include/ibex/runtime/worker_pool.hpp | 4 ++-- src/repl/repl.cpp | 8 ++++---- src/runtime/lazy_table.cpp | 2 -- src/runtime/static_range_filter.cpp | 8 ++++++-- 14 files changed, 77 insertions(+), 24 deletions(-) diff --git a/.clang-tidy b/.clang-tidy index f283e0fa..7a9e793c 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -13,6 +13,7 @@ Checks: > -cppcoreguidelines-avoid-do-while, -readability-trailing-comma, -misc-non-private-member-variables-in-classes, + -cppcoreguidelines-non-private-member-variables-in-classes, -readability-implicit-bool-conversion, -misc-no-recursion, -readability-function-cognitive-complexity, @@ -28,6 +29,24 @@ Checks: > -cppcoreguidelines-pro-type-union-access, -cppcoreguidelines-pro-type-vararg, -misc-use-internal-linkage +# cppcoreguidelines-pro-bounds-avoid-unchecked-container-access and +# readability-trailing-comma: unknown to the standalone clang-tidy-20/18 apt +# packages CI uses (excluding them there is a harmless no-op), but real, +# firing checks under clangd's bundled clang-tidy despite reporting the same +# "20.1.2" version string -- same package version, different check registry. +# Confirmed live in the editor: without these, operator[] bounds-access noise +# floods every file, contradicting the already-excluded pro-bounds-* siblings. +# Do not remove based on `clang-tidy-20 --list-checks` alone. +# Without this, clang-tidy parses included headers but only reports +# diagnostics located in the main file (the .cpp) being compiled, so issues +# inside our own headers were silently dropped in CI even though every +# header is pulled in by some TU. Match only our own headers, not +# FetchContent'd third-party ones under build/. +# NOTE: as of clang-tidy-20, setting this here is not sufficient on its own — +# --dump-config shows it loaded, but it has no effect on `-p ` +# invocations unless the identical value is ALSO passed via --header-filter +# on the command line. CI's clang-tidy step does that; keep both in sync. +HeaderFilterRegex: '^.*/include/ibex/.*\.hpp$' CheckOptions: # libstdc++ implementation headers are not portable include providers. # Prefer the public standard-library header for every standard declaration. diff --git a/.clangd b/.clangd index c409171e..93788cd4 100644 --- a/.clangd +++ b/.clangd @@ -23,6 +23,7 @@ Diagnostics: - cppcoreguidelines-avoid-do-while - readability-trailing-comma - misc-non-private-member-variables-in-classes + - cppcoreguidelines-non-private-member-variables-in-classes - readability-implicit-bool-conversion - misc-no-recursion - readability-function-cognitive-complexity diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d37e79d6..81f6be0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -242,15 +242,20 @@ jobs: - name: Run clang-tidy # Analyze every project translation unit and treat every diagnostic as - # an error. Headers are covered transitively by their including TUs. + # an error. clang-tidy parses every included header too, but only + # *reports* diagnostics located in the main file (the .cpp) unless + # --header-filter matches the header's path — and, at least as of + # clang-tidy-20, that filter is NOT picked up from .clang-tidy's + # HeaderFilterRegex on its own (--dump-config shows it loaded, but it + # has no effect unless the same value is also passed on the command + # line here). Keep this regex in sync with .clang-tidy. run: | if command -v clang-tidy-20 >/dev/null 2>&1; then TIDY=clang-tidy-20; else TIDY=clang-tidy-18; fi - # Run one clang-tidy process per core. Analyze only our .cpp TUs; - # headers are covered transitively via the TUs that include them, so - # tidying *.hpp standalone is redundant (and was a big chunk of the - # runtime). -n1 so each file is its own job for -P to parallelize. + # Run one clang-tidy process per core. -n1 so each file is its own + # job for -P to parallelize. find src -name '*.cpp' -print0 | \ - xargs -0 -P "$(nproc)" -n1 "$TIDY" -p build --warnings-as-errors='*' + xargs -0 -P "$(nproc)" -n1 "$TIDY" -p build --warnings-as-errors='*' \ + --header-filter='^.*/include/ibex/.*\.hpp$' format-check: runs-on: ubuntu-24.04 diff --git a/include/ibex/format.hpp b/include/ibex/format.hpp index c655d0a8..2ae65022 100644 --- a/include/ibex/format.hpp +++ b/include/ibex/format.hpp @@ -25,7 +25,7 @@ auto format(format_string pattern, Args&&... args) -> std::string { template void print(std::FILE* stream, format_string pattern, Args&&... args) { const auto text = std::format(pattern, std::forward(args)...); - std::fputs(text.c_str(), stream); + (void)std::fputs(text.c_str(), stream); } template diff --git a/include/ibex/ir/expr_predicates.hpp b/include/ibex/ir/expr_predicates.hpp index 44ece918..9207798a 100644 --- a/include/ibex/ir/expr_predicates.hpp +++ b/include/ibex/ir/expr_predicates.hpp @@ -61,6 +61,13 @@ enum class NullBehavior : std::uint8_t { /// lowering context. Unknown functions are deliberately unclassified: planning /// must not assume that arbitrary plugin code is row-local, deterministic, or /// safe to duplicate. +/// +/// `kind` has no default member initializer, deliberately: every entry in the +/// classification table below sets it explicitly via designated-initializer +/// syntax, and a default would let a future entry silently omit it (see the +/// rationale on `JoinExpect` in node.hpp for the concrete bug that pattern +/// caused elsewhere). +// NOLINTNEXTLINE(cppcoreguidelines-pro-type-member-init) struct BuiltinFunctionInfo { FnKind kind; /// Only meaningful for `kind == Scalar`; see `NullBehavior`. diff --git a/include/ibex/ir/node.hpp b/include/ibex/ir/node.hpp index 7500a5a4..9943eaea 100644 --- a/include/ibex/ir/node.hpp +++ b/include/ibex/ir/node.hpp @@ -232,10 +232,12 @@ struct DeferredScalarBinding { ///< tmp_name }; +// NOLINTNEXTLINE(readability-redundant-inline-specifier) -- out-of-class, not implicitly inline. inline ExprPtr::ExprPtr() = default; inline ExprPtr::ExprPtr(std::nullptr_t) noexcept {} +// NOLINTNEXTLINE(readability-redundant-inline-specifier) -- see ExprPtr() above inline ExprPtr::~ExprPtr() = default; inline ExprPtr::ExprPtr(std::unique_ptr ptr) noexcept : ptr_(std::move(ptr)) {} @@ -245,6 +247,7 @@ inline ExprPtr::ExprPtr(Expr expr) : ptr_(std::make_unique(std::move(expr) inline ExprPtr::ExprPtr(const ExprPtr& other) : ptr_(other.ptr_ ? std::make_unique(*other.ptr_) : nullptr) {} +// NOLINTNEXTLINE(readability-redundant-inline-specifier) -- see ExprPtr() above inline ExprPtr::ExprPtr(ExprPtr&&) noexcept = default; inline auto ExprPtr::operator=(const ExprPtr& other) -> ExprPtr& { @@ -254,6 +257,7 @@ inline auto ExprPtr::operator=(const ExprPtr& other) -> ExprPtr& { return *this; } +// NOLINTNEXTLINE(readability-redundant-inline-specifier) -- see ExprPtr() above inline auto ExprPtr::operator=(ExprPtr&&) noexcept -> ExprPtr& = default; [[nodiscard]] inline auto make_expr_ptr(Expr expr) -> ExprPtr { @@ -440,6 +444,7 @@ struct JoinKey { /// side's name alone. Suffixes apply to collisions only, never to every /// column. /// No member has a default, for the reason given on `JoinExpect`. +// NOLINTNEXTLINE(cppcoreguidelines-pro-type-member-init) struct JoinSuffixPolicy { bool present; std::string left; diff --git a/include/ibex/parser/scalar_bindings.hpp b/include/ibex/parser/scalar_bindings.hpp index 2d83dda2..2d9feb9e 100644 --- a/include/ibex/parser/scalar_bindings.hpp +++ b/include/ibex/parser/scalar_bindings.hpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -254,12 +255,12 @@ struct DeferredWrap { std::string tmp = "__ibex_scalar_src_" + std::to_string(counter++); ir::Expr residual{.node = ir::ColumnRef{.name = tmp, .lexical = true}}; // Rebuild the wrapper layers inside-out. - for (auto it = wraps.rbegin(); it != wraps.rend(); ++it) { + for (auto& wrap : wraps | std::views::reverse) { ir::CallExpr call; - call.callee = it->callee; + call.callee = wrap.callee; call.args.emplace_back(std::move(residual)); - if (it->extra_arg.has_value()) { - call.args.emplace_back(ir::Expr{.node = std::move(*it->extra_arg)}); + if (wrap.extra_arg.has_value()) { + call.args.emplace_back(ir::Expr{.node = std::move(*wrap.extra_arg)}); } residual = ir::Expr{.node = std::move(call)}; } diff --git a/include/ibex/runtime/interpreter.hpp b/include/ibex/runtime/interpreter.hpp index 8428b584..f7853ee0 100644 --- a/include/ibex/runtime/interpreter.hpp +++ b/include/ibex/runtime/interpreter.hpp @@ -329,6 +329,7 @@ class ValidityBitmap { if (is_external()) { return external_data_; } + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) return reinterpret_cast(words_.data()); } [[nodiscard]] auto buffer_offset() const noexcept -> size_type { @@ -342,6 +343,7 @@ class ValidityBitmap { return words_.data(); } [[nodiscard]] auto words_data() const noexcept -> const word_type* { + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) return reinterpret_cast(buffer_data()); } }; @@ -359,7 +361,9 @@ struct ColumnEntry { return entry.validity.has_value() && !(*entry.validity)[row]; } -/// Returns the number of elements in a type-erased column. +/// Returns the number of elements in a type-erased column. Every Column move is +/// noexcept, so ColumnValue never becomes valueless-by-exception; std::visit cannot throw. +// NOLINTNEXTLINE(bugprone-exception-escape) [[nodiscard]] inline auto column_size(const ColumnValue& column) noexcept -> std::size_t { return std::visit([](const auto& col) { return col.size(); }, column); } @@ -531,7 +535,7 @@ struct DynamicScanFilter { if (!bloom->contains(key)) { return false; } - return in_list.empty() || std::binary_search(in_list.begin(), in_list.end(), key); + return in_list.empty() || std::ranges::binary_search(in_list, key); } }; @@ -688,6 +692,10 @@ struct ExecutionContext { /// configure_parallel_from_env() when IBEX_PROFILE_OPERATORS is present. /// Shared ownership keeps the report alive across streamed operators and /// prints it when the query's last execution context/operator releases it. + /// The explicit {} keeps this field silenced under + /// -Wmissing-designated-field-initializers, matching every other field here; see the + /// note on JoinExpect (node.hpp) for why that matters. + // NOLINTNEXTLINE(readability-redundant-member-init) std::shared_ptr execution_profile{}; /// Morsel row-grain for the pipeline source when `parallel` is set. The input diff --git a/include/ibex/runtime/lazy_table.hpp b/include/ibex/runtime/lazy_table.hpp index 296aedc7..1a6201b7 100644 --- a/include/ibex/runtime/lazy_table.hpp +++ b/include/ibex/runtime/lazy_table.hpp @@ -68,6 +68,11 @@ struct StringScanFilter { /// each product owns its decoder cursor and other mutable backend state. class LazySourceReader { public: + LazySourceReader() = default; + LazySourceReader(const LazySourceReader&) = delete; + LazySourceReader(LazySourceReader&&) = delete; + auto operator=(const LazySourceReader&) -> LazySourceReader& = delete; + auto operator=(LazySourceReader&&) -> LazySourceReader& = delete; virtual ~LazySourceReader() = default; /// The source's streaming decomposition, in ascending row order and diff --git a/include/ibex/runtime/query_lease.hpp b/include/ibex/runtime/query_lease.hpp index c29520b2..260cfe63 100644 --- a/include/ibex/runtime/query_lease.hpp +++ b/include/ibex/runtime/query_lease.hpp @@ -41,7 +41,7 @@ auto release_query_execution() noexcept -> void; /// exception. class QueryExecutionLease { public: - QueryExecutionLease() noexcept { held_ = detail::try_claim_query_execution(); } + QueryExecutionLease() noexcept : held_(detail::try_claim_query_execution()) {} QueryExecutionLease(const QueryExecutionLease&) = delete; QueryExecutionLease(QueryExecutionLease&&) = delete; auto operator=(const QueryExecutionLease&) -> QueryExecutionLease& = delete; diff --git a/include/ibex/runtime/worker_pool.hpp b/include/ibex/runtime/worker_pool.hpp index 4364a6f1..a1b071db 100644 --- a/include/ibex/runtime/worker_pool.hpp +++ b/include/ibex/runtime/worker_pool.hpp @@ -57,7 +57,7 @@ class WorkerPool { Batch(const Batch&) = delete; auto operator=(const Batch&) -> Batch& = delete; Batch(Batch&&) noexcept; - auto operator=(Batch&&) noexcept -> Batch&; + auto operator=(Batch&& /*other*/) noexcept -> Batch&; ~Batch(); /// Blocks until every worker body has returned. Rethrows the first @@ -84,7 +84,7 @@ class WorkerPool { TaskGroup(const TaskGroup&) = delete; auto operator=(const TaskGroup&) -> TaskGroup& = delete; TaskGroup(TaskGroup&&) noexcept; - auto operator=(TaskGroup&&) noexcept -> TaskGroup&; + auto operator=(TaskGroup&& /*other*/) noexcept -> TaskGroup&; ~TaskGroup(); /// Queue one task. Tasks may run concurrently and in any order. diff --git a/src/repl/repl.cpp b/src/repl/repl.cpp index c8cfddea..ae5aaae4 100644 --- a/src/repl/repl.cpp +++ b/src/repl/repl.cpp @@ -526,8 +526,8 @@ void add_static_candidates(std::vector& candidates) { } auto unique_sorted(std::vector candidates) -> std::vector { - std::sort(candidates.begin(), candidates.end()); - candidates.erase(std::unique(candidates.begin(), candidates.end()), candidates.end()); + std::ranges::sort(candidates); + candidates.erase(std::ranges::unique(candidates).begin(), candidates.end()); return candidates; } @@ -535,8 +535,8 @@ auto any_prefix_match(const std::vector& candidates, std::string_vi if (prefix.empty()) { return false; } - return std::any_of(candidates.begin(), candidates.end(), - [&](const auto& candidate) { return candidate.starts_with(prefix); }); + return std::ranges::any_of( + candidates, [&](const auto& candidate) { return candidate.starts_with(prefix); }); } auto completion_generator(const char* text, int state) -> char* { diff --git a/src/runtime/lazy_table.cpp b/src/runtime/lazy_table.cpp index c15d70c4..1c1d0fb4 100644 --- a/src/runtime/lazy_table.cpp +++ b/src/runtime/lazy_table.cpp @@ -2,7 +2,6 @@ // Copyright (C) 2026 Bob Jansen #include -#include #include #include #include @@ -15,7 +14,6 @@ #include #include #include -#include #include #include #include diff --git a/src/runtime/static_range_filter.cpp b/src/runtime/static_range_filter.cpp index 2cce6ddc..565c390d 100644 --- a/src/runtime/static_range_filter.cpp +++ b/src/runtime/static_range_filter.cpp @@ -5,14 +5,18 @@ #include #include -#include +#include +#include #include #include +#include #include #include -#include +#include +#include #include +#include namespace ibex::runtime { From c4c6925a525019bbde7030c34545361230190a1a Mon Sep 17 00:00:00 2001 From: Bob Jansen Date: Tue, 22 Sep 2026 19:23:49 +0200 Subject: [PATCH 42/42] Ensure intialisation --- tests/test_ir_required_columns.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_ir_required_columns.cpp b/tests/test_ir_required_columns.cpp index c313a83d..9c347fe9 100644 --- a/tests/test_ir_required_columns.cpp +++ b/tests/test_ir_required_columns.cpp @@ -444,8 +444,9 @@ TEST_CASE("deferrable_probe_scans: joins the streaming path declines are not def for (int clause = 0; clause < 3; ++clause) { CAPTURE(clause); ir::NullMatch nulls = clause == 0 ? ir::NullMatch::Equal : ir::NullMatch::Never; - ir::JoinExpect expect; - expect.right = clause == 1 ? ir::JoinMultiplicity::One : ir::JoinMultiplicity::Many; + ir::JoinExpect expect{ + .left = ir::JoinMultiplicity::Many, + .right = clause == 1 ? ir::JoinMultiplicity::One : ir::JoinMultiplicity::Many}; const auto take = clause == 2 ? ir::MatchSelection::Any : ir::MatchSelection::All; auto join = std::make_unique( ir::NodeId{20}, ir::JoinKind::Inner, std::vector{ir::JoinKey{"id"}},