Skip to content

Commit 3172a45

Browse files
ahrzbclaude
andcommitted
feat(specializer): stage-B contract surface — oracle tests, limitations doc/twin, corpus 546 (TASK-59 stage B-5)
- tests/test_duckdb_stageb_many.py: sorted-multiset oracle tests (dup-key fan-out, residual/WHERE combos, cross + inequality + constant ON) plus the engine's OWN order contract pinned as a test. - known-limitations §1/§2: dup-key/cross/inequality joins now documented as serving under the shape='many' opt-in (multiset parity, engine- defined order); self-join row marked in-progress; corpus count 546. - Twin + shape tests flipped: dup keys serve under many, reject under the defaults; fn.shape == 'many'. Full gate: cargo 167, pytest 616 + 13 xfail, corpus 546/0 FAIL. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d8a2194 commit 3172a45

4 files changed

Lines changed: 112 additions & 11 deletions

File tree

docs/known-limitations.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ forces this document to change with it.
99
**The contract.** For any SQL you hand it, the engine does exactly one of:
1010

1111
1. **Serve it bit-for-bit identical to DuckDB** (verified continuously
12-
against DuckDB's own test corpus: 505 of 678 statements as of wave B), or
12+
against DuckDB's own test corpus: 546 of 678 statements as of stage B), or
1313
2. **Refuse loudly at BUILD time**`DuckDBInferFn(...)` raises a
1414
`ValueError` naming the construct. Nothing is ever silently wrong or
1515
silently dropped at inference time.
@@ -30,8 +30,8 @@ rejected, permanently by design:
3030
|---|---|---|
3131
| Regex patterns must be constants (`regexp_matches(s, pattern_col)` rejects) | `unsupported: non-constant regex pattern (compiled at prepare in v0)` | Regexes compile at prepare; DuckDB compiles per row. Per-row compilation is the opposite of specialization. |
3232
| Replacement strings / regex options / extract group indexes must be constants | `non-constant regexp_replace replacement` etc. | Same. |
33-
| Static (join) tables must be provided at build time, with unique keys | `duplicate map key` | Joins are frozen hash maps baked into the function. Duplicate keys mean 1:N join multiplicity — a designed extension (stage B, TASK-50 notes) that is deliberately not built yet. NULL *values* serve since TASK-55 (they flow through as NULL); NULL *keys* drop the row, matching equi-join semantics. |
34-
| The dynamic table cannot be joined to itself | `joining the dynamic table to itself` | The batch is the probe side; using it as a build side too needs stage-B machinery. |
33+
| Static (join) tables must be provided at build time; under the DEFAULT shapes their keys must be unique | `duplicate map key` | Joins are frozen hash maps baked into the function. Duplicate keys mean 1:N join multiplicity, which SERVES under the opt-in `shape='many'` (TASK-59) — along with cross joins, inequality `ON` predicates, and constant `ON` clauses — with multiset parity vs DuckDB (its join output order is a measured hash-join accident; the engine emits probe order outer, build insertion order inner). Under `filter`/`map` the 1:1 contract is unchanged. NULL *values* serve since TASK-55; NULL *keys* never match. |
34+
| The dynamic table cannot be joined to itself | `joining the dynamic table to itself` | The batch is the probe side; using it as a build side too is the one stage-B piece still in progress (it will also require `shape='many'`). |
3535
| Exactly one row table drives the query | `the specializer takes exactly one row table`, `must be the dynamic table` | The serving contract is rows-in → rows-out for one entity stream. |
3636

3737
## 2. Out of scope for row-serving (by decision, not difficulty)
@@ -42,10 +42,11 @@ build time. `"filter"` (the default) is the engine's native 0..1;
4242
`"map"` statically PROVES exactly-one (`out[i] ↔ in[i]`, the strict
4343
serving guarantee) by rejecting anything that can drop a row — a WHERE
4444
clause, an INNER join (key misses drop), a static-tables-only constant
45-
query; `"many"` (0..N) is reserved for join multiplicity (stage B) and
46-
will be the only shape under which duplicate-key joins, cross/inequality
47-
joins, and self-joins ever build — multiplicity can never sneak into a
48-
serving path by default.
45+
query; `"many"` (0..N) is the multiplicity opt-in (stage B): duplicate-key
46+
joins, cross joins, and inequality/constant `ON` joins build ONLY under
47+
it (one join per query for now, named restriction) — multiplicity can
48+
never sneak into a serving path by default. Self-joins are still
49+
rejected under every shape (in progress).
4950

5051
The engine serves **row-at-a-time feature transforms**. Whole-relation
5152
constructs are out of scope because their output shape is not

tests/test_duckdb_stageb_many.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Stage-B multiplicity vs the duckdb oracle (TASK-59), multiset parity.
2+
3+
Pins: docs/superpowers/specs/2026-07-28-stageB-multiplicity-pins.md —
4+
DuckDB's join output ORDER is a hash-join accident, so comparison is
5+
SORTED; the engine's own order (probe outer, insertion inner) is a
6+
documented contract of its own.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import duckdb
12+
import pyarrow as pa
13+
from pydantic import create_model
14+
15+
from sql_transform._interpreter import DuckDBInferFn
16+
17+
T = create_model("T", pid=(int | None, None))
18+
ROWS = [{"pid": 1}, {"pid": 2}, {"pid": 3}, {"pid": None}]
19+
DIM = pa.table({"id": [1, 2, 1, 2, 1], "v": ["a", "b", "c", "d", "e"]})
20+
21+
22+
def _many_check(sql: str):
23+
"""Engine (shape='many') vs DuckDB, sorted-row multiset."""
24+
fn = DuckDBInferFn(
25+
sql.replace("__THIS__", "__THIS__"),
26+
row_tables={"__THIS__": T},
27+
static_tables={"d": DIM},
28+
output="dict",
29+
shape="many",
30+
)
31+
got = [tuple(r.values()) for r in fn.infer({"__THIS__": [T(**r) for r in ROWS]})]
32+
33+
con = duckdb.connect()
34+
con.execute("CREATE TABLE __THIS__ (pid BIGINT)")
35+
for r in ROWS:
36+
con.execute("INSERT INTO __THIS__ VALUES (?)", [r["pid"]])
37+
con.register("__arrow_d", DIM)
38+
con.execute('CREATE TABLE d AS SELECT * FROM "__arrow_d"')
39+
want = con.execute(sql).fetchall()
40+
key = lambda t: tuple((x is None, x) for x in t) # noqa: E731
41+
assert sorted(got, key=key) == sorted(want, key=key), f"{sql}\n{got}\n{want}"
42+
43+
44+
def test_dup_key_fanout_vs_oracle():
45+
_many_check("SELECT pid, v FROM __THIS__ JOIN d ON pid = d.id")
46+
_many_check("SELECT pid, v FROM __THIS__ LEFT JOIN d ON pid = d.id")
47+
_many_check("SELECT pid, v FROM __THIS__ LEFT JOIN d ON pid = d.id AND d.v > 'b'")
48+
_many_check("SELECT pid, v FROM __THIS__ LEFT JOIN d ON pid = d.id WHERE v IS NULL")
49+
_many_check("SELECT upper(v) AS u FROM __THIS__ JOIN d ON pid = d.id WHERE pid > 1")
50+
51+
52+
def test_cross_and_inequality_vs_oracle():
53+
_many_check("SELECT pid, id, v FROM __THIS__, d")
54+
_many_check("SELECT pid, id FROM __THIS__ JOIN d ON pid > d.id")
55+
_many_check("SELECT pid, id FROM __THIS__ LEFT JOIN d ON pid > d.id")
56+
_many_check("SELECT pid, id FROM __THIS__ LEFT JOIN d ON NULL = 2")
57+
_many_check("SELECT pid, id FROM __THIS__, d WHERE pid >= id AND v <> 'c'")
58+
59+
60+
def test_engine_order_contract():
61+
# The engine's OWN documented deterministic order: probe rows in input
62+
# order, matches contiguous in build INSERTION order, null-extension
63+
# in place.
64+
fn = DuckDBInferFn(
65+
"SELECT pid, v FROM __THIS__ LEFT JOIN d ON pid = d.id",
66+
row_tables={"__THIS__": T},
67+
static_tables={"d": DIM},
68+
output="dict",
69+
shape="many",
70+
)
71+
got = [tuple(r.values()) for r in fn.infer({"__THIS__": [T(**r) for r in ROWS]})]
72+
assert got == [
73+
(1, "a"),
74+
(1, "c"),
75+
(1, "e"),
76+
(2, "b"),
77+
(2, "d"),
78+
(3, None),
79+
(None, None),
80+
]

tests/test_known_limitations.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,22 @@ def test_non_constant_regex_pattern_rejects():
4141

4242
def test_static_tables_are_frozen_unique_key_maps():
4343
dup = static({"id": "int", "v": "int"}, [{"id": 1, "v": 1}, {"id": 1, "v": 2}])
44-
# Duplicate keys = 1:N multiplicity — designed (stage B), not built.
44+
# Duplicate keys = 1:N multiplicity: rejected under the DEFAULT shapes,
45+
# served under the opt-in shape='many' (stage B, TASK-59).
4546
rejects(
4647
"SELECT v FROM __THIS__ JOIN d ON a = d.id",
4748
"duplicate map key",
4849
{"d": dup},
4950
)
51+
fn = DuckDBInferFn(
52+
"SELECT v FROM __THIS__ JOIN d ON a = d.id",
53+
row_tables={"__THIS__": T},
54+
static_tables={"d": dup},
55+
output="dict",
56+
shape="many",
57+
)
58+
got = sorted(r["v"] for r in fn.infer({"__THIS__": [T(a=1)]}))
59+
assert got == [1, 2]
5060
# NULL VALUES serve since TASK-55 (they ride as validity+payload pairs);
5161
# only NULL keys keep the drop rule (a NULL never equi-matches).
5262
withnull = static({"id": "int", "v": "int?"}, [{"id": 1, "v": None}])

tests/test_shape_contract.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,18 @@ def test_filter_default_unchanged():
6565
assert [r["a"] for r in got] == [2]
6666

6767

68-
def test_many_is_reserved_and_bad_values_are_named():
69-
with pytest.raises(ValueError, match="stage B"):
70-
build("SELECT a FROM __THIS__", shape="many")
68+
def test_many_enables_multiplicity_and_bad_values_are_named():
69+
# 'many' is the stage-B opt-in: dup-key joins build ONLY under it.
70+
dup = pa.table({"id": [1, 1], "v": [10, 11]})
71+
with pytest.raises(ValueError, match="duplicate map key"):
72+
build("SELECT a, v FROM __THIS__ JOIN d ON a = d.id", statics={"d": dup})
73+
fn = build(
74+
"SELECT a, v FROM __THIS__ JOIN d ON a = d.id",
75+
shape="many",
76+
statics={"d": dup},
77+
)
78+
assert fn.shape == "many"
79+
got = fn.infer({"__THIS__": [T(a=1), T(a=2)]})
80+
assert sorted(r["v"] for r in got) == [10, 11]
7181
with pytest.raises(ValueError, match="must be 'map', 'filter', or 'many'"):
7282
build("SELECT a FROM __THIS__", shape="projection")

0 commit comments

Comments
 (0)