Skip to content

Commit 9c5efae

Browse files
ahrzbclaude
andcommitted
feat: schema-aware resolution, plus DRAFT-21 (step semantics, parked)
SQLProjection(sql, this_model=Row) declares the __THIS__ schema (pydantic field names, definition order; the model is authoritative — fit canonicalizes the table to it). One mechanism carries the whole loop: the base substitution environment becomes explicit, and everything falls out — unknown columns refuse at construction with bind-style messages; every star expands to named items so * EXCLUDE/REPLACE/RENAME compose at any level; COLUMNS('regex') expands with the pattern matched by DuckDB's own regex engine, never Python's re; lateral aliases resolve by DuckDB's column-wins rule (still refused inside window arguments, whose fit-side text runs against the source relation); struct access composes through plain projected columns. this_model moves from fit to the constructor (v0 break); schema-free construction keeps every loop-1..3 behavior. Schema mode always canonicalizes (no identity fast-path): expansion is the feature, and the serving text stays in vanilla form. DRAFT-21 parks the agreed direction for order-keyed windows off the training support: step-function semantics via AS-OF joins (cume_dist becomes the empirical CDF against training), sequenced after this loop. Gate: 1500-case fuzz bit-exact with half of all cases running schema mode; corpus gains 5 schema families; root pytest 494+1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent e05de15 commit 9c5efae

8 files changed

Lines changed: 546 additions & 45 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
---
2+
id: DRAFT-21
3+
title: Step semantics for order-keyed windows off the training support
4+
status: Draft
5+
type: spike
6+
created: 2026-07-29
7+
---
8+
9+
## Where this came from
10+
11+
Design dialogue with AmirHossein, 2026-07-29, right after loop 3
12+
(projection chains + the fit plan) merged. The question: **what should
13+
`rank()` — and every order-keyed window — mean when the serving row's value
14+
was never in the training set?** Parked with a converged direction so the
15+
conclusions survive; sequenced after the schema-aware-resolution loop.
16+
17+
## The problem
18+
19+
Order-keyed windows (`rank`, `dense_rank`, `percent_rank`, `cume_dist`,
20+
running aggregates under RANGE/GROUPS frames, `last_value`/`nth_value`)
21+
marginalize with the order values in the join key set. Serving joins by
22+
exact `IS NOT DISTINCT FROM` match — so an unseen order value (almost
23+
*every* value, when the key is continuous like `salary`) misses the LEFT
24+
JOIN and yields NULL. Truthful, but it makes the whole order-keyed family
25+
nearly useless on real serving traffic.
26+
27+
## The converged answer: the step-function reading
28+
29+
Every order-discriminated window we admit is, per partition, a **step
30+
function of the order value** — that is exactly *why* it was marginalizable.
31+
The params table is that function sampled at the training points. The fitted
32+
transform should mean: **evaluate the training-set step function at the
33+
serving row's coordinate** (row excluded).
34+
35+
```
36+
rank() OVER (PARTITION BY dep ORDER BY salary)
37+
-- fitted step for dep='sales': 4800 → 1 (two peers), 5000 → 3
38+
-- serving salary=4750 (unseen):
39+
-- exact-join semantics (today): NULL
40+
-- step semantics: 1 + #{training rows < 4750} = 1
41+
```
42+
43+
Why this is the right default, not an accommodation:
44+
45+
1. **It extends, never contradicts.** At observed coordinates the step
46+
function equals the exact join, so the training-set round-trip invariant
47+
is untouched. Exact-join is the degenerate restriction of step semantics.
48+
2. **Partition keys stay exact.** Categorical keys have no "between";
49+
NULL for an unseen group remains the honest answer. Step semantics is for
50+
*order* keys only — partition keys are categorical, order keys ordinal.
51+
3. **`cume_dist() OVER (ORDER BY x)` becomes the empirical CDF against
52+
training** — sklearn's QuantileTransformer, falling out of one SQL
53+
function. Strong evidence the step reading is the ML-correct semantics.
54+
4. **Still oracle-testable.** Off-support behavior has a definitional form
55+
DuckDB can execute per probe (`SELECT 1 + count(*) FROM train WHERE
56+
dep = ? AND salary < ?`), so the gate extends rather than weakens.
57+
58+
## Mechanics (sketch)
59+
60+
AS-OF join instead of equality on the order-key part of the join predicate.
61+
DuckDB has `ASOF JOIN` natively for the fit-side gate; on the Confit side it
62+
is a binary search over a sorted params column — fits the static-lookup
63+
architecture. Per family:
64+
65+
- running `sum`/`count`/`min`/`max` (RANGE ≤ frames): as-of pick of the
66+
cumulative value at the greatest observed `o' ≤ v`.
67+
- `rank`: `1 + cum_count(o < v)` — one extra cumulative-count column in the
68+
params table; exact hits read the stored rank.
69+
- `percent_rank`/`cume_dist`: derived from rank/cum_count and `n`.
70+
71+
## Pins needed before believing anything
72+
73+
- Tie boundaries: `<` vs `` per function, and DuckDB ASOF's strictness.
74+
- NULL order values at serving: NULL is its own peer class — stays an exact
75+
match, never an as-of probe.
76+
- `last_value` off-support is genuinely debatable: "the last peer of a value
77+
nobody has" is arguably the serving row itself — a different kind of
78+
answer than a frozen param. Decide per-function; refusing step semantics
79+
for `last_value`/`nth_value` and keeping them exact-only is a fine v0.
80+
- Bounded RANGE frames (`RANGE BETWEEN 2 PRECEDING AND CURRENT ROW`): the
81+
step function has 2·n breakpoints, not n — the as-of table must sample
82+
frame *boundaries*, not just observed points. May stay exact-only at
83+
first.
84+
85+
## Decision state
86+
87+
Direction agreed ("ah that's nice I like it"): adopt step semantics as THE
88+
meaning of order-keyed windows, in its own loop with its own pins and an
89+
off-support gate, sequenced after schema-aware resolution. Until that loop,
90+
NULL-off-support is the documented behavior.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Schema-aware resolution
2+
3+
**Date:** 2026-07-29
4+
**Status:** loop 4 of SQLProjection marginalization; extends loops 1–3.
5+
6+
## What changes
7+
8+
`marginalize(sql, columns=None)` optionally takes the `__THIS__` column
9+
names; `SQLProjection(sql, this_model=SomeModel)` passes its pydantic field
10+
names (in definition order — the model is the declared, authoritative
11+
schema). `fit(table)` then canonicalizes with `table.select(columns)`: model
12+
order wins, extra table columns drop, missing ones fail with a named error.
13+
`this_model` moves from `fit` to the constructor (v0 break) — errors surface
14+
at construction and the plan is final before any data exists.
15+
16+
Schema-free mode is unchanged: every loop-1–3 behavior and refusal stands.
17+
18+
## The mechanism: the base environment becomes explicit
19+
20+
Schema knowledge is one change, not many: the base level's substitution
21+
environment stops being a star-fallback and becomes an explicit ordered list
22+
of `(name, __cf_t.name)` entries — base columns are just substitution
23+
expressions like any projected column. Everything else falls out:
24+
25+
- **Unknown columns refuse at construction** with a bind-style message.
26+
- **Struct access composes through plain columns**: a substitution target
27+
that is itself a pure column reference extends (`s.f``__cf_t.s.f`) —
28+
including through a CTE that projected the column; only access through a
29+
*computed* expression stays refused.
30+
- **Every star expands to named items**, so `* EXCLUDE/REPLACE/RENAME`
31+
compose at any level (previously final-over-base only), and star-through-
32+
CTE needs no special cases.
33+
- **`COLUMNS('regex')` expands** against the known names — the pattern is
34+
matched *by DuckDB itself* (`regexp_matches` at marginalize time), never by
35+
Python's `re`, so the dialect of the regex is the oracle's. Lambda and
36+
EXCLUDE forms of COLUMNS stay refused by name. Select-item position only.
37+
- **Lateral aliases resolve exactly by DuckDB's rule**: a source column wins
38+
over an earlier item's alias; otherwise the alias's rewritten expression is
39+
inlined; otherwise unknown-column. The schema-free ambiguity refusal
40+
remains only in schema-free mode. Inside window arguments/keys/filters a
41+
lateral alias stays refused (the window's fit-side text runs against the
42+
source relation, which has no such column) — a named error with the same
43+
disambiguation hint.
44+
45+
## Gate
46+
47+
The round-trip invariant is unchanged and the fuzz gains a schema mode: half
48+
of all generated cases construct with a `this_model` built from the table, so
49+
every resolution path runs explicit. Corpus: new curated families (COLUMNS,
50+
star modifiers through CTEs, resolved lateral aliases, struct-through-CTE)
51+
move the pinned scoreboard up.
52+
53+
## Out of scope
54+
55+
Step semantics for off-support order keys (DRAFT-21, next); t-strings
56+
(deprioritized); joins/static tables; `COLUMNS` inside expressions.

packages/sql-transform/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ named windows — plus scalar and `EXISTS` subqueries over `__THIS__`, which
4646
are provably uncorrelated (there is no syntax to reach the outer row) and run
4747
verbatim at fit time.
4848

49+
Declaring a schema unlocks more: `SQLProjection(sql, this_model=Row)` (the
50+
pydantic model is the authoritative `__THIS__` schema) makes unknown columns
51+
refuse at construction, expands `COLUMNS('regex')` (matched by DuckDB's own
52+
regex engine) and `* EXCLUDE/REPLACE/RENAME` at any level, and resolves
53+
lateral aliases by DuckDB's column-wins rule. Schema-free construction keeps
54+
working, with the ambiguous cases refused.
55+
4956
The rule deciding it all: a window is marginalizable iff its value is a
5057
function of row-visible values. What depends on **physical row position**
5158
`row_number`, `ntile`, `lag`/`lead`, bounded ROWS frames, `EXCLUDE` — is

packages/sql-transform/sql_transform/_corpus_test.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,17 @@
171171
]
172172

173173

174+
# --- curated, schema-aware (loop 4): replayed with a declared this_model -----
175+
176+
CURATED_SCHEMA = [
177+
"SELECT COLUMNS('.*name') FROM __THIS__",
178+
"SELECT * EXCLUDE (enroll_date) REPLACE (salary + 1 AS salary) FROM __THIS__",
179+
"SELECT * RENAME (depname AS dep) FROM __THIS__",
180+
"SELECT salary + 1 AS s2, s2 * 2 AS s4 FROM __THIS__",
181+
"WITH a AS (SELECT * EXCLUDE (empno) FROM __THIS__) SELECT salary - avg(salary) OVER (PARTITION BY depname) AS d FROM a",
182+
]
183+
184+
174185
def _outcome(sql: str) -> tuple[str, str]:
175186
try:
176187
marginalize(sql)
@@ -208,9 +219,21 @@ def test_curated_refuses(sql):
208219
assert kind == "refused", f"{kind}: {detail}"
209220

210221

222+
@pytest.mark.parametrize("sql", CURATED_SCHEMA, ids=lambda s: s[:56])
223+
def test_curated_schema_marginalizes(sql):
224+
from sql_transform._projection_test import gate
225+
226+
try:
227+
marginalize(sql, list(EMPSALARY.column_names))
228+
except MarginalizeError as e:
229+
raise AssertionError(f"refused: {e}") from e
230+
gate(sql, EMPSALARY, schema=True)
231+
232+
211233
def test_progression_totals():
212234
"""The metric, in one place. Edit these pins when a loop widens support."""
213235
assert len(MINED) == 22
214236
assert MINED_SCOREBOARD["marginalized"] + MINED_SCOREBOARD["refused"] == 22
215237
assert len(CURATED_MARGINALIZED) == 39
216238
assert len(CURATED_REFUSED) == 17
239+
assert len(CURATED_SCHEMA) == 5

0 commit comments

Comments
 (0)