Skip to content

Commit e05de15

Browse files
ahrzbclaude
authored andcommitted
feat: projection chains, scalar subqueries, the fit plan, and the corpus metric
A query is now a chain of strict projections — CTEs and derived tables over __THIS__ — flattened by substitution into one serving projection. Fitting is an explicit DAG: Marginalized.plan is a topologically ordered list of named FitSteps (__CF_LEVEL_{i}__ materializes a projection level with its windows and keys; __CF_PARAMS_{n}__ collapses params or runs a scalar subquery verbatim), so nested aggregations that depend on each other execute correctly and every intermediate is inspectable by name. Scalar and EXISTS subqueries over __THIS__ are admitted and run verbatim at fit time — provably uncorrelated, since FROM __THIS__ carries no alias and no other relation is in scope, there is no syntax to reference the outer row. IN/ANY stay refused (membership functions, not scalars), as do prepared-statement parameters and ambiguous lateral aliases (a latent loop-2 hole: DuckDB prefers the column when both exist, which is undecidable without a schema — refused with a disambiguation hint, benign same-name column aliases exempted). The progression metric: corpus replay with a pinned scoreboard, three outcomes (MARGINALIZED = round-trip bit-exact, REFUSED = named error, FAILED = asserted empty). Half mined from DuckDB's own window test suite (empsalary, replayed against the suite's ten rows): 11/22 marginalized, 11 cleanly refused. Half curated: 39 supported families against 17 named refusal families. Widening a loop edits the pins in a reviewable diff. Gate: 2000-case seeded fuzz bit-exact over the widened generator (CTE wraps, second aggregation levels, scalar subqueries); root pytest 466+1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 0dfc23b commit e05de15

9 files changed

Lines changed: 1309 additions & 281 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Projection chains, scalar subqueries, and the fit plan
2+
3+
**Date:** 2026-07-29
4+
**Status:** loop 3 of SQLProjection marginalization; extends
5+
[loop 1](2026-07-29-sql-projection-marginalization-design.md) and
6+
[loop 2](2026-07-29-window-widening-design.md).
7+
8+
## The fit plan (AmirHossein's DAG point)
9+
10+
Once one aggregation can depend on another — a CTE that centers a column, a
11+
second level that aggregates the centered values — fit execution is a DAG,
12+
not a pair of queries. `Marginalized` therefore now carries an explicit,
13+
topologically ordered plan:
14+
15+
```python
16+
@dataclass(frozen=True)
17+
class FitStep:
18+
name: str # the table this step registers
19+
sql: str # runs against previously registered names
20+
reads: tuple[str, ...] # the tables it consumes (the DAG edges)
21+
22+
Marginalized(serving_sql, plan: tuple[FitStep, ...], params: tuple[ParamsSpec, ...])
23+
```
24+
25+
`fit()` is now a five-line executor: register `__THIS__`, run each step in
26+
order, register its result under `step.name`, keep the params tables at the
27+
end. Every intermediate is inspectable by name, every step is a plain SQL
28+
string you can run by hand, and `SQLProjection.plan` exposes the whole thing —
29+
debuggability is the point, not a by-product. `ParamsSpec` shrinks to
30+
`(name, keys)`; the SQL lives in the plan.
31+
32+
Step naming: `__CF_LEVEL_{i}__` for a projection level's windows table,
33+
`__CF_PARAMS_{n}__` for every params table (keyset collapses and scalar
34+
subqueries alike).
35+
36+
## Projection chains: CTEs and derived tables
37+
38+
A query is now a **chain of strict projections**: the final SELECT over a
39+
CTE or derived table, which projects over another, down to `__THIS__`. Each
40+
level obeys the loop-1 structural rules (no WHERE/GROUP BY/modifiers) and the
41+
loop-2 window rules, applied per level.
42+
43+
- **Fit** materializes each level: `__CF_LEVEL_{i}__` = the level's original
44+
select items under their original output names (the next level's input,
45+
verbatim) *plus* that level's distinct windows as `__cf_wN` and key
46+
expressions as `__cf_kM`, reading from the previous level's table. Params
47+
collapse per keyset exactly as loop 2 — the same query serves as both the
48+
relation and the windows table.
49+
- **Serving** flattens: each level's column references are substituted with
50+
the *rewritten* expressions of the level below (β-reduction), so
51+
`serving_sql` is one flat projection over `__THIS__` joined to every params
52+
table. A level-2 params join can reference level-1 params columns in its
53+
key expression — the left-deep join chain is in creation order, so
54+
everything referenced is already in scope. Correctness composes by
55+
induction: each level's round-trip invariant says its rewritten form equals
56+
its original form on the training set.
57+
- CTE column aliases (`WITH a(z) AS …`) and derived-table aliases rename the
58+
level's outputs; the source's name/alias is a valid qualifier in the
59+
consumer.
60+
- Refused, named: recursive CTEs, unused CTEs, a CTE referenced by a scalar
61+
subquery, duplicate output names in a non-final level, `*` with
62+
EXCLUDE/REPLACE in a non-final level, struct-field access through a
63+
projected expression. (`MATERIALIZED` hints do not survive DuckDB's
64+
serializer, so they cannot be — and are not — distinguished.)
65+
- A bare star in a non-final level passes base columns through; a star in the
66+
final level over a CTE expands to the source's outputs.
67+
68+
**Lateral-alias safety (latent loop-2 hole, fixed):** DuckDB lets a select
69+
item reference an earlier item's alias, and prefers the table column when
70+
both exist — undecidable without a schema. A bare reference that matches an
71+
earlier alias in the same level is refused with a disambiguation hint
72+
(qualify as `__THIS__.x` or rename the alias).
73+
74+
## Scalar subqueries: the strongest form of "run the original"
75+
76+
`FROM __THIS__` may carry no alias and no other relations are in scope, so a
77+
subquery inside a projection level **has no syntax to reference the outer
78+
row** — every subquery is provably uncorrelated. Any `SCALAR` or `EXISTS`
79+
subquery whose base tables are all `__THIS__` (its own internal CTEs
80+
included; WHERE/GROUP BY/anything inside is fine — it is a scalar
81+
computation, not a projection) therefore becomes one fit step run
82+
**verbatim**:
83+
84+
```sql
85+
-- SELECT x / (SELECT max(x) FROM __THIS__) AS xn FROM __THIS__ becomes
86+
plan: __CF_PARAMS_0__ = SELECT (SELECT max(x) FROM __THIS__) AS __cf_a0
87+
serving: SELECT (__cf_t.x / __cf_p0.__cf_a0) AS xn FROM __THIS__ AS __cf_t
88+
LEFT JOIN __CF_PARAMS_0__ AS __cf_p0 ON ((1 = 1))
89+
```
90+
91+
Verbatim execution is bit-exact by construction — there is nothing to
92+
re-derive. `IN`/`ANY`/`ALL` subqueries stay refused (their value varies per
93+
row: a membership *function*, not a scalar). Prepared-statement parameters
94+
(`$1`) get a named refusal instead of a confusing fit-time error.
95+
96+
## The progression metric: corpus replay
97+
98+
Loop 3 adds the house metric — a corpus replayed through three outcomes,
99+
zero failures required, with **pinned counts** whose diffs over time are the
100+
progression record (Confit's 550/678, for marginalization):
101+
102+
- **MARGINALIZED**`marginalize` accepts and the training-set round-trip
103+
invariant holds bit-exactly.
104+
- **REFUSED** — a named `MarginalizeError`.
105+
- **FAILED** — anything else: a gate mismatch, an unexpected exception. The
106+
corpus test asserts this list is empty.
107+
108+
The corpus has two halves: window queries **mined from DuckDB's own test
109+
suite** (`duckdb/test/sql/window/*.test`, the `empsalary` queries, replayed
110+
against the suite's own ten rows; query-level ORDER BY — test scaffolding,
111+
refused by design — is stripped during mining), and a curated set covering
112+
every supported and refused family from all three loops. The scoreboard is a
113+
pinned `{"marginalized": N, "refused": M}` per half; widening a future loop
114+
means editing those pins upward in a reviewable diff.

packages/sql-transform/README.md

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,33 +21,60 @@ p.serving_sql
2121
# ON (__cf_t.country IS NOT DISTINCT FROM __cf_p0.country)
2222
p.params
2323
# {"__CF_PARAMS_0__": <pyarrow.Table: country, __cf_a0, __cf_a1>}
24+
p.plan
25+
# (FitStep(name='__CF_LEVEL_0__', sql='SELECT ...', reads=('__THIS__',)),
26+
# FitStep(name='__CF_PARAMS_0__', sql='SELECT DISTINCT ...', reads=('__CF_LEVEL_0__',)))
2427
```
2528

29+
Fitting is an explicit **plan** — a topologically ordered DAG of named SQL
30+
steps (nested aggregations across CTE levels genuinely depend on each other).
31+
Every intermediate is inspectable by name and every step is plain SQL you can
32+
run by hand.
33+
2634
The serving half (`infer`/`infer_batch` through [Confit](../confit)) is a later
2735
loop and still raises `NotImplementedError`.
2836

2937
## The contract
3038

31-
Strict projection: `SELECT <exprs> FROM __THIS__`, where the window surface is
32-
wide: any aggregate (including `FILTER`, `DISTINCT`, ordered arguments, and
33-
order-sensitive ones), running windows and RANGE/GROUPS frames (the order
34-
values join the key set), the rank family, `first_value`/`last_value`/
35-
`nth_value`, expression partition keys, and named windows. The rule deciding
36-
it all: a window is marginalizable iff its value is a function of row-visible
37-
values. What depends on **physical row position**`row_number`, `ntile`,
38-
`lag`/`lead`, bounded ROWS frames, `EXCLUDE` — is refused, as is everything
39-
non-projection (WHERE, GROUP BY, joins, subqueries, CTEs), each with a named
40-
error. Serve-or-refuse, no third mode.
39+
A **chain of strict projections**: the final SELECT over a CTE or derived
40+
table, projecting over another, down to `__THIS__` — flattened into one
41+
serving projection. The window surface is wide: any aggregate (including
42+
`FILTER`, `DISTINCT`, ordered arguments, and order-sensitive ones), running
43+
windows and RANGE/GROUPS frames (the order values join the key set), the rank
44+
family, `first_value`/`last_value`/`nth_value`, expression partition keys,
45+
named windows — plus scalar and `EXISTS` subqueries over `__THIS__`, which
46+
are provably uncorrelated (there is no syntax to reach the outer row) and run
47+
verbatim at fit time.
48+
49+
The rule deciding it all: a window is marginalizable iff its value is a
50+
function of row-visible values. What depends on **physical row position**
51+
`row_number`, `ntile`, `lag`/`lead`, bounded ROWS frames, `EXCLUDE` — is
52+
refused, as is everything non-projection (WHERE, GROUP BY, joins, set
53+
operations, `IN`-subqueries), each with a named error. Serve-or-refuse, no
54+
third mode.
4155

4256
Parsing is DuckDB's own (`json_serialize_sql` / `json_deserialize_sql`): the
4357
oracle's grammar and the oracle's printer, no second SQL dialect anywhere.
4458

45-
The correctness gate is differential: for every accepted projection, the
46-
original SQL over training data must be **bit-exact** with `serving_sql`
47-
joined against the fitted params — both executed by DuckDB (single-threaded:
48-
DuckDB's parallel float window aggregation is not bit-deterministic even
49-
against itself). NULL partition keys are one partition and join back via
50-
`IS NOT DISTINCT FROM`; join multiplicity is provable, not tested.
59+
## The gate and the metric
60+
61+
For every accepted query, the training-set round-trip invariant must hold
62+
**bit-exactly**: fit + transform on the training set equals the original
63+
query with `__THIS__` pointing at the training set — both executed by DuckDB
64+
(single-threaded: DuckDB's parallel float window aggregation is not
65+
bit-deterministic even against itself). NULL partition keys are one partition
66+
and join back via `IS NOT DISTINCT FROM`; join multiplicity is provable, not
67+
tested.
68+
69+
Progression is measured by **corpus replay** (`_corpus_test.py`): queries
70+
mined from DuckDB's own window test suite plus a curated family list, each
71+
either MARGINALIZED (gate passes), REFUSED (named error), or FAILED (always a
72+
bug; asserted empty). The pinned scoreboard — currently **11/22 mined**
73+
marginalized and **39 curated** families supported against **17** named
74+
refusal families — is the progression record; widening a loop edits it
75+
upward in a reviewable diff.
5176

52-
See the design spec:
53-
[2026-07-29-sql-projection-marginalization-design.md](../../docs/superpowers/specs/2026-07-29-sql-projection-marginalization-design.md).
77+
Design specs, in order:
78+
[loop 1 — marginalization](../../docs/superpowers/specs/2026-07-29-sql-projection-marginalization-design.md),
79+
[loop 2 — window widening](../../docs/superpowers/specs/2026-07-29-window-widening-design.md),
80+
[loop 3 — chains and the fit plan](../../docs/superpowers/specs/2026-07-29-projection-chains-fit-plan-design.md).

packages/sql-transform/sql_transform/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from __future__ import annotations
1010

1111
from sql_transform._marginalize import (
12+
FitStep,
1213
Marginalized,
1314
MarginalizeError,
1415
ParamsSpec,
@@ -17,6 +18,7 @@
1718
from sql_transform._projection import SQLProjection
1819

1920
__all__ = [
21+
"FitStep",
2022
"Marginalized",
2123
"MarginalizeError",
2224
"ParamsSpec",

0 commit comments

Comments
 (0)