|
| 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