Skip to content

Commit e9df096

Browse files
ahrzbclaude
andcommitted
docs: spec for SQLProjection marginalization over __THIS__
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 49f3b6e commit e9df096

1 file changed

Lines changed: 181 additions & 0 deletions

File tree

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
# SQLProjection: marginalization over `__THIS__`
2+
3+
**Date:** 2026-07-29
4+
**Status:** validated with AmirHossein (dialogue + spike), narrow slice of
5+
[DRAFT-20](../../../backlog/drafts/draft-20%20-%20Serving-pipelines-in-SQL-marginalized-aggregates-fitted-artifacts-and-the-leakage-question.md)
6+
**Scope:** rename `SQLTransform``SQLProjection`; implement bulletproof
7+
marginalization. **No inference wiring**`infer`/`infer_batch`/`backend`/
8+
`boundary` keep raising `NotImplementedError`.
9+
10+
## What marginalization is
11+
12+
Aggregates over `__THIS__` are the static half of a binding-time analysis. At
13+
fit time they are computed once over the training table, materialized into wide
14+
params tables, and the SQL is rewritten to join them instead of recomputing:
15+
16+
```sql
17+
-- input (one text, written by the user)
18+
SELECT (age - avg(age) OVER (PARTITION BY country))
19+
/ stddev_samp(age) OVER (PARTITION BY country) AS age_z,
20+
fare - avg(fare) OVER () AS fare_c
21+
FROM __THIS__
22+
```
23+
24+
```sql
25+
-- serving_sql (generated)
26+
SELECT (t.age - p0.__cf_a0) / p0.__cf_a1 AS age_z,
27+
t.fare - p1.__cf_a0 AS fare_c
28+
FROM __THIS__ AS t
29+
LEFT JOIN __CF_PARAMS_0__ AS p0 ON t.country IS NOT DISTINCT FROM p0.country
30+
CROSS JOIN __CF_PARAMS_1__ AS p1
31+
```
32+
33+
```sql
34+
-- params[0].fit_sql (keys = [country])
35+
SELECT country, avg(age) AS __cf_a0, stddev_samp(age) AS __cf_a1
36+
FROM __THIS__ GROUP BY country
37+
-- params[1].fit_sql (keys = [])
38+
SELECT avg(fare) AS __cf_a0 FROM __THIS__
39+
```
40+
41+
`fit(train)` runs each `fit_sql` over the training table via DuckDB and stores
42+
the resulting tables. Serving (a later slice) hands `serving_sql` + params to
43+
Confit as static tables.
44+
45+
## Architecture: parse with the oracle
46+
47+
Pure Python, entirely inside `sql_transform`. **Confit is untouched** — it is
48+
the serving library and stays that way.
49+
50+
The parser is DuckDB's own, via `json_serialize_sql` / `json_deserialize_sql`:
51+
parse SQL → JSON AST, rewrite the JSON in Python, let DuckDB print the SQL
52+
back. One grammar, and it is the oracle's. Spike-validated on 1.5.5:
53+
54+
- Window aggregates round-trip; swapping a `WINDOW` node for a `COLUMN_REF`
55+
node in place deserializes to exactly the intended rewritten SQL.
56+
- DuckDB-isms parse natively **and normalize away** (`SELECT x: age + 1`
57+
`SELECT (age + 1) AS x`), so `serving_sql` comes out in vanilla form —
58+
conservative SQL for Confit's frontend later.
59+
- The parser classifies for us: `type: WINDOW_AGGREGATE` vs
60+
`WINDOW_ROW_NUMBER` etc.; `FILTER` appears as `filter_expr`; the top-level
61+
select node exposes `where_clause` / `group_expressions` / `having` /
62+
`qualify` / `sample` / `cte_map` — every structural refusal is a field check.
63+
- Parse errors are structured: `{error_type, error_message, position}`.
64+
65+
**Known cost:** the JSON AST is a DuckDB-internal format, not a stable API. The
66+
node shapes we rely on get pinned with executed examples against DuckDB 1.5.5
67+
(pins-first, like everything else); a version bump reruns the pins. If a
68+
generated `serving_sql` ever drifts outside what Confit accepts, Confit's
69+
refuse-at-build contract catches it loudly at wiring time — the backstop exists
70+
regardless of parser.
71+
72+
`duckdb>=1.5.5` moves from the dev group to a real `sql-transform` dependency
73+
(fit needs it; serving never will).
74+
75+
## Rewrite rules
76+
77+
1. **One params table per distinct partition-key set**, in first-appearance
78+
order: `__CF_PARAMS_0__`, `__CF_PARAMS_1__`, … Aggregate expressions that
79+
are structurally equal (same serialized AST node after DuckDB
80+
normalization) within a key set dedupe to one column.
81+
2. **Join predicate is `IS NOT DISTINCT FROM`, never `USING`/`=`.** Window
82+
`PARTITION BY` groups NULL keys into one partition; equality joins would
83+
drop them. `OVER ()` (empty key set) becomes `CROSS JOIN` against a
84+
one-row table.
85+
3. **Multiplicity by construction:** `fit_sql` is `GROUP BY keys` ⇒ keys are
86+
unique ⇒ LEFT JOIN matches ≤ 1 ⇒ exactly one row out per row in. Map shape
87+
is proven, not tested.
88+
4. **`__CF_` is a reserved prefix** (params tables `__CF_PARAMS_N__`, columns
89+
`__cf_aN`). Input SQL containing it (case-insensitive) is refused — same
90+
idiom as Confit's `__glob_pat` reservation.
91+
5. `__THIS__` gains the alias `t` in `serving_sql`; base-column references are
92+
qualified through it.
93+
94+
## Accepted surface and refusals
95+
96+
Strict projection: `SELECT <exprs> FROM __THIS__`, nothing else. Every
97+
refusal raises a named, positioned error — serve-or-refuse, no third mode.
98+
99+
**Marginalizable:** `agg(expr…) OVER (PARTITION BY col, …)` and
100+
`agg(expr…) OVER ()`, where:
101+
102+
- `agg` is on the **allowlist** (grown one measured entry at a time, like the
103+
corpus): `avg`, `sum`, `count`, `min`, `max`, `stddev`, `stddev_pop`,
104+
`stddev_samp`, `var_pop`, `var_samp`, `variance`, `median`. Order-sensitive
105+
aggregates (`first`, `last`, `string_agg`, `array_agg`, …) are refused by
106+
absence: their per-group value is nondeterministic, which would make both
107+
fit and the differential gate flaky.
108+
- Partition keys are plain `__THIS__` columns (expressions: next loop).
109+
- Aggregate arguments are arbitrary expressions over `__THIS__` columns —
110+
computed training-side, no restriction beyond what DuckDB accepts.
111+
112+
**Refused by name:**
113+
114+
| construct | why |
115+
|---|---|
116+
| `OVER (ORDER BY …)`, frames | running window, not a per-group constant |
117+
| pure window functions (`row_number`, `lag`, `rank`, …) | position-dependent; classified by the parser's `type` tag |
118+
| bare aggregate without `OVER` | not a projection |
119+
| `FILTER (WHERE …)`, `DISTINCT` inside aggregate | next loops, refuse for now |
120+
| `PARTITION BY <expression>` | v0: plain columns only |
121+
| WHERE / GROUP BY / HAVING / QUALIFY / SAMPLE | not a projection |
122+
| joins, subqueries, CTEs, set operations | static tables are the named next loop |
123+
| `ORDER BY` / `LIMIT` at query level | meaningless row-at-a-time |
124+
| multiple statements | one projection per SQLProjection |
125+
| `__CF_` anywhere in input | reserved prefix |
126+
127+
## API surface
128+
129+
`packages/sql-transform/sql_transform/_projection.py` (renamed from
130+
`_transform.py`):
131+
132+
```python
133+
class SQLProjection:
134+
def __init__(self, sql: str | Template) -> None: ...
135+
@classmethod
136+
def from_file(cls, path: str) -> SQLProjection: ...
137+
def fit(self, table: pa.Table, /, this_model: type[BaseModel] | None = None) -> SQLProjection: ...
138+
@property
139+
def serving_sql(self) -> str: ... # rewritten projection; fitted only
140+
@property
141+
def params(self) -> dict[str, pa.Table]: ... # {"__CF_PARAMS_0__": …}; fitted only
142+
# still raising NotImplementedError — later slices:
143+
def infer(self, row): ...
144+
def infer_batch(self, rows): ...
145+
@property
146+
def backend(self) -> str: ...
147+
@property
148+
def boundary(self) -> str: ...
149+
```
150+
151+
Internally, `marginalize(sql) -> Marginalized(serving_sql, params_specs)` is a
152+
pure function (parse + rewrite + plan, no execution); `fit` = marginalize +
153+
materialize. `this_model` is accepted for signature stability but unused this
154+
slice (the training table brings its own schema).
155+
156+
## The bulletproof gate
157+
158+
DuckDB-vs-DuckDB differential — no inference code anywhere:
159+
160+
```python
161+
original = duck(sql, __THIS__=train)
162+
rewritten = duck(m.serving_sql, __THIS__=train, **fitted_params)
163+
assert original.equals(rewritten) # bit-exact
164+
```
165+
166+
Cases must include: NULL partition keys, NULLs in aggregate inputs, single-row
167+
groups, one column used under two key sets, the same aggregate twice (dedupe),
168+
`OVER ()` alone and mixed with keyed windows, every allowlisted aggregate, and
169+
unicode/quoted identifiers. Plus:
170+
171+
- **Refusal tests:** every row of the refusal table asserts its named error.
172+
- **AST pins:** executed `json_serialize_sql` examples for every node shape the
173+
walker relies on, so a DuckDB bump that moves the format fails loudly.
174+
- Column-order and dtype equality in the differential, not just values.
175+
176+
## Out of scope (named next loops)
177+
178+
Static tables in the input SQL → computed partition keys → `FILTER`/`DISTINCT`
179+
→ inference wiring through Confit (`IS NOT DISTINCT FROM` join support needed
180+
there) → the DRAFT-20 leakage question. Loop-based, like Confit's corpus:
181+
each loop widens the accepted surface and shrinks the refusal table.

0 commit comments

Comments
 (0)