Skip to content

Commit 1c5c894

Browse files
ahrzbclaude
authored andcommitted
docs(specializer): known-limitations reference + executable twin suite
docs/known-limitations.md is the user-facing contract: every deliberate limitation grouped by kind (the specialization bargain, row-serving scope, type-system boundaries, measured descopes, contract choices), each with its justification and the exact build-time message. tests/ test_known_limitations.py asserts all of them — lifting a limitation breaks a test and forces the doc to change in the same commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent cfa735c commit 1c5c894

2 files changed

Lines changed: 324 additions & 0 deletions

File tree

docs/known-limitations.md

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# Known limitations — deliberate, named, and loud
2+
3+
This is the user-facing contract of the SQL specializer (`DuckDBInferFn`):
4+
what it refuses to serve, **why**, and what you see when you hit a limit.
5+
Its executable twin is [`tests/test_known_limitations.py`](../tests/test_known_limitations.py)
6+
every limitation below is asserted there, so lifting one breaks a test and
7+
forces this document to change with it.
8+
9+
**The contract.** For any SQL you hand it, the engine does exactly one of:
10+
11+
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
13+
2. **Refuse loudly at BUILD time**`DuckDBInferFn(...)` raises a
14+
`ValueError` naming the construct. Nothing is ever silently wrong or
15+
silently dropped at inference time.
16+
17+
There is no third mode. Every limitation here is a *measured decision*
18+
recorded in a pins spec (`docs/superpowers/specs/`), not an accident.
19+
20+
---
21+
22+
## 1. The specialization bargain (inherent to the engine model)
23+
24+
The specializer's speed comes from doing ALL general work at build time:
25+
parse once, bind once, compile once, freeze the static tables into the
26+
code. Anything that would require re-doing general work per row is
27+
rejected, permanently by design:
28+
29+
| Limitation | You'll see | Why |
30+
|---|---|---|
31+
| 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. |
32+
| 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 and no NULL values | `duplicate map key`, `has a NULL in value column` | 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. |
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. |
35+
| 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. |
36+
37+
## 2. Out of scope for row-serving (by decision, not difficulty)
38+
39+
The engine serves **row-at-a-time feature transforms**. Whole-relation
40+
constructs are out of scope because their output shape is not
41+
one-row-in/one-row-out:
42+
43+
- Aggregation: `GROUP BY`, `HAVING`, `sum`/`count`/`avg`/... →
44+
`aggregate function ... (no aggregation in v0)`
45+
- `ORDER BY`, `LIMIT`/`OFFSET`, `DISTINCT` — row-independent transforms
46+
don't reorder or deduplicate.
47+
- CTEs (`WITH`), `UNION`/`INTERSECT`/`EXCEPT`, subqueries, multiple
48+
statements.
49+
- Table functions in FROM (`range(...)`) — there is no base table.
50+
- `rowid` pseudo-column — rows have no stable identity in a stream.
51+
- `FULL OUTER JOIN` — emits rows that no input row produced.
52+
53+
## 3. Type-system boundaries
54+
55+
The engine computes in exactly four types: `i64`, `f64`, UTF-8 string,
56+
bool. Measured consequences:
57+
58+
- **f32 base tables reject** (`engine is f64-only`).
59+
- **Static-table key/value columns must fit BIGINT**`UBIGINT`/`HUGEINT`
60+
payloads outside i64 reject with a named message.
61+
- **Lists and structs reject** (`row column 'x' has a non-scalar type`) —
62+
this is the designated next capability wave ("wave C"), and it also gates
63+
`regexp_extract_all` / `regexp_split_to_array` / the STRUCT form of
64+
`regexp_extract` (`list-valued — non-scalar in v0`).
65+
- **DECIMAL literals are f64** — a documented divergence: DuckDB types
66+
`1.5` as `DECIMAL(2,1)` and does decimal arithmetic; we map to f64.
67+
Values agree on every corpus case; exact-decimal accumulation semantics
68+
are not reproduced.
69+
- Narrow integer widths don't exist: bitwise ops compute in i64, which
70+
matches DuckDB whenever either operand is BIGINT (always true for
71+
row-model ints). Explicit narrow CASTs are rejected rather than
72+
risking DuckDB's narrow-width overflow behavior.
73+
74+
## 4. Semantics descoped after measurement
75+
76+
Each of these was measured against DuckDB 1.5.5 first (pins in
77+
`docs/superpowers/specs/`), and rejected because serving it would risk a
78+
wrong answer or require semantics we can't reproduce exactly:
79+
80+
| Construct | Why it's descoped |
81+
|---|---|
82+
| `reverse()` | DuckDB reverses UAX-29 grapheme clusters (incl. regional-indicator pairing), not codepoints. A codepoint reverse would be silently wrong on emoji/accents. |
83+
| `^` operator | It IS pow in DuckDB, but sqlparser's precedence differs from DuckDB's (`2*x^y` would parse as `(2*x)^y`). Mapping it computes the wrong tree silently. Use `pow()`. |
84+
| prefix `~`, `#`, `NOT GLOB` | Same class: precedence/parse divergences that would silently mis-associate. `xor()` covers bit-xor; `NOT (x GLOB p)` works. |
85+
| Regex reject list: `\B`, `\Q…\E`, `(?<name>…)`, duplicate group names, bounds > 1000, stacked quantifiers (`a*+`), `\u` escapes, negated Perl classes inside `[...]` | The RE2↔rust-regex differential battery (98 entries) proved these are the constructs where the engines disagree or DuckDB itself is broken (`\B` crashes DuckDB at runtime on non-ASCII). Everything else is byte-identical. |
86+
| `SIMILAR TO ... ESCAPE` | Not implemented in DuckDB itself. |
87+
| `* EXCLUDE (t.key)` on a USING join | DuckDB UNMERGES the coalesced column (it reappears at the right table's position) — measured, not modeled. Unqualified EXCLUDE works. |
88+
| `BETWEEN`/`IN` mixing non-numeric string literals with numbers | DuckDB converts at EXECUTION time (an empty input succeeds!); a bind-time conversion was measured to be over-eager. Numeric literals convert fine. |
89+
| `COLUMNS(...)` inside expressions, lambda/list forms | Only bare `COLUMNS('re')` / `COLUMNS(*)` as select items are served. |
90+
| Bare `NULL` with no typing context, `CASE` where every branch is NULL, `COALESCE`/`NULLIF` of only NULLs | DuckDB's SQLNULL type has no i64/f64/str/bool home. `NULL <op> NULL` IS served with the measured result types. |
91+
92+
## 5. Deliberate contract choices (behavior differs from raw DuckDB surface)
93+
94+
These are served, but with a consciously chosen surface — know them:
95+
96+
- **Duplicate output column names are renamed**, using DuckDB's own
97+
boundary-rename algorithm (`id, id, id_1``id, id_1, id_1_1`;
98+
case-insensitive collision check). Raw DuckDB keeps duplicates at the
99+
top level, but a pydantic model or dict cannot — and this rename is
100+
bit-identical to what DuckDB itself does at every subquery/CTE/CTAS
101+
boundary and in `.df()`. Verified against `.df()` in tests.
102+
- **`NULL || NULL` types as VARCHAR** in the output model (value is NULL
103+
either way; DuckDB's SQLNULL materializes as INTEGER).
104+
- **Error TEXTS are approximate where noted.** Runtime traps
105+
(overflow, shifts, substring range) reproduce DuckDB's message bodies
106+
verbatim; some bind-time rejections (star-filter zero-match, regex
107+
compile errors) use our own wording with the same error class. The
108+
corpus only ever compares successful results, so texts never affect
109+
parity.
110+
- **Two known oracle divergences** (excluded from the corpus by name in
111+
`tests/test_corpus_replay.py::_KNOWN_DIVERGENT_SOURCES`): DuckDB
112+
behaviors that depend on column STATISTICS (e.g. ILIKE's NUL handling
113+
selects a different kernel depending on *sibling rows*). A row-at-a-time
114+
engine cannot reproduce statistics-dependent semantics even in
115+
principle; the engine is NUL-transparent (the ASCII-kernel behavior).
116+
- **`%`-by-zero NaN bit pattern is platform-libm** — pinned as
117+
engine==oracle bit agreement per platform, not a constant.
118+
119+
## 6. How to read a rejection
120+
121+
Every rejection is a `ValueError` at `DuckDBInferFn(...)` construction
122+
whose message starts with a classification:
123+
124+
- `unsupported: ...` — real SQL, deliberately not served (this document).
125+
- `parse error: ...` — the dialect surface ends here.
126+
- `bind error: ...` — the query is wrong against YOUR schema (typo,
127+
type mismatch), not a limitation.
128+
129+
If a message you hit isn't in this document or the tests, that's a bug in
130+
our bookkeeping — file it.

tests/test_known_limitations.py

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"""Executable twin of docs/known-limitations.md.
2+
3+
Every DELIBERATE limitation is asserted here: the SQL that hits it and the
4+
named build-time rejection (or, for contract choices, the chosen behavior).
5+
If an engine change lifts one of these, the test fails — update the doc in
6+
the same commit. Section numbers mirror the document.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import pytest
12+
from pydantic import create_model
13+
from test_duckdb_interpreter import duck_check, static
14+
15+
from sql_transform._interpreter import DuckDBInferFn
16+
17+
T = create_model("T", a=(int, ...), s=(str | None, None))
18+
19+
20+
def build(sql, statics=None):
21+
return DuckDBInferFn(sql, row_tables={"__THIS__": T}, static_tables=statics or {})
22+
23+
24+
def rejects(sql, needle, statics=None):
25+
with pytest.raises(ValueError, match=needle):
26+
build(sql, statics)
27+
28+
29+
# ---- 1. The specialization bargain ----------------------------------------
30+
31+
32+
def test_non_constant_regex_pattern_rejects():
33+
# Regexes compile ONCE at prepare; per-row compilation is the opposite
34+
# of specialization.
35+
rejects("SELECT regexp_matches(s, s) FROM __THIS__", "non-constant regex")
36+
rejects(
37+
"SELECT regexp_replace(s, 'a', s) FROM __THIS__",
38+
"non-constant regexp_replace replacement",
39+
)
40+
41+
42+
def test_static_tables_are_frozen_unique_key_maps():
43+
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.
45+
rejects(
46+
"SELECT v FROM __THIS__ JOIN d ON a = d.id",
47+
"duplicate map key",
48+
{"d": dup},
49+
)
50+
withnull = static({"id": "int", "v": "int?"}, [{"id": 1, "v": None}])
51+
rejects(
52+
"SELECT v FROM __THIS__ JOIN d ON a = d.id",
53+
"NULL in value column",
54+
{"d": withnull},
55+
)
56+
57+
58+
def test_dynamic_self_join_rejects():
59+
rejects(
60+
"SELECT t2.a FROM __THIS__ JOIN __THIS__ t2 ON __THIS__.a = t2.a",
61+
"dynamic table",
62+
)
63+
64+
65+
# ---- 2. Out of scope for row-serving --------------------------------------
66+
67+
68+
@pytest.mark.parametrize(
69+
("sql", "needle"),
70+
[
71+
("SELECT sum(a) FROM __THIS__", "no aggregation"),
72+
("SELECT a FROM __THIS__ GROUP BY a", "aggregation"),
73+
("SELECT a FROM __THIS__ ORDER BY a", "ORDER BY"),
74+
("SELECT a FROM __THIS__ LIMIT 5", "LIMIT"),
75+
("SELECT DISTINCT a FROM __THIS__", "DISTINCT"),
76+
("WITH c AS (SELECT 1) SELECT a FROM __THIS__", "common table"),
77+
("SELECT a FROM __THIS__ UNION SELECT a FROM __THIS__", "UNION"),
78+
("SELECT rowid FROM __THIS__", "rowid"),
79+
(
80+
"SELECT a FROM __THIS__ FULL OUTER JOIN x ON a = x.a",
81+
"join type",
82+
),
83+
],
84+
)
85+
def test_whole_relation_constructs_reject(sql, needle):
86+
rejects(sql, needle)
87+
88+
89+
# ---- 3. Type-system boundaries ---------------------------------------------
90+
91+
92+
def test_non_scalar_row_columns_reject():
93+
L = create_model("L", xs=(list[int], ...))
94+
with pytest.raises(ValueError, match="non-scalar"):
95+
DuckDBInferFn(
96+
"SELECT xs FROM __THIS__", row_tables={"__THIS__": L}, static_tables={}
97+
)
98+
99+
100+
def test_list_valued_regexp_forms_reject():
101+
# Gated on list types (wave C), not on regex semantics.
102+
rejects("SELECT regexp_extract_all(s, 'a') FROM __THIS__", "list-valued")
103+
rejects("SELECT regexp_split_to_array(s, 'a') FROM __THIS__", "regexp_split")
104+
105+
106+
def test_ubigint_static_payloads_reject():
107+
import pyarrow as pa
108+
109+
big = pa.table({"id": pa.array([2**64 - 1], pa.uint64()), "v": [1]})
110+
rejects(
111+
"SELECT v FROM __THIS__ JOIN d ON a = d.id",
112+
"outside BIGINT range",
113+
{"d": big},
114+
)
115+
116+
117+
# ---- 4. Semantics descoped after measurement -------------------------------
118+
119+
120+
@pytest.mark.parametrize(
121+
("sql", "needle"),
122+
[
123+
# Grapheme-cluster semantics (UAX-29) not modeled.
124+
("SELECT reverse(s) FROM __THIS__", "grapheme"),
125+
# ^ IS pow, but sqlparser's precedence would compute the wrong tree.
126+
("SELECT a ^ 2 FROM __THIS__", "precedence"),
127+
# Regex reject list: measured RE2 <-> rust-regex divergences.
128+
("SELECT regexp_matches(s, 'a\\B') FROM __THIS__", "B in a regex"),
129+
("SELECT regexp_matches(s, '\\Qab\\E') FROM __THIS__", "literal quoting"),
130+
("SELECT regexp_matches(s, 'a*+') FROM __THIS__", "stacked"),
131+
(
132+
"SELECT regexp_matches(s, '(?P<n>a)(?P<n>b)') FROM __THIS__",
133+
"duplicate regex capture group",
134+
),
135+
("SELECT regexp_matches(s, 'a{1001}') FROM __THIS__", "repetition bound"),
136+
# Not implemented in DuckDB itself.
137+
("SELECT s SIMILAR TO 'a' ESCAPE 'x' FROM __THIS__", "escape"),
138+
# Exec-time conversion order (an empty input succeeds in DuckDB).
139+
("SELECT a IN ('abc') FROM __THIS__", "non-numeric"),
140+
# Only bare COLUMNS as a select item is served.
141+
("SELECT COLUMNS('a') + 1 FROM __THIS__", "COLUMNS"),
142+
# SQLNULL has no home type.
143+
("SELECT NULL FROM __THIS__", "NULL literal"),
144+
],
145+
)
146+
def test_measured_descopes_reject(sql, needle):
147+
rejects(sql, needle)
148+
149+
150+
def test_using_unmerge_exclude_rejects():
151+
d = static({"a": "int", "v": "int"}, [{"a": 1, "v": 10}])
152+
# DuckDB UNMERGES the coalesced column here — measured, not modeled.
153+
rejects(
154+
"SELECT * EXCLUDE (d.a) FROM __THIS__ JOIN d USING (a)",
155+
"USING-merged",
156+
{"d": d},
157+
)
158+
159+
160+
# ---- 5. Deliberate contract choices ----------------------------------------
161+
162+
163+
def test_duplicate_names_use_duckdbs_boundary_rename():
164+
# Raw DuckDB keeps top-level duplicates; a typed model cannot. We apply
165+
# DuckDB's OWN subquery/CTAS/.df() rename — not an invention.
166+
fn = DuckDBInferFn(
167+
"SELECT a, a AS a, a AS a_1 FROM __THIS__",
168+
row_tables={"__THIS__": T},
169+
static_tables={},
170+
output="dict",
171+
)
172+
got = fn.infer({"__THIS__": [T(a=7)]})
173+
assert list(got[0].keys()) == ["a", "a_1", "a_1_1"]
174+
175+
176+
def test_null_op_null_serves_with_measured_types():
177+
# The limitation is ONLY the context-free bare NULL; typed NULL ops
178+
# serve with the pinned result types.
179+
duck_check(
180+
"SELECT NULL + NULL AS s, NULL / NULL AS d FROM __THIS__",
181+
{"a": "int", "s": "str?"},
182+
[{"a": 1, "s": None}],
183+
)
184+
185+
186+
def test_rejections_are_build_time_and_named():
187+
# The load-bearing property: limits surface at CONSTRUCTION, before any
188+
# row is ever inferred — never silently at inference time.
189+
with pytest.raises(ValueError, match="unsupported"):
190+
DuckDBInferFn(
191+
"SELECT sum(a) FROM __THIS__",
192+
row_tables={"__THIS__": T},
193+
static_tables={},
194+
)

0 commit comments

Comments
 (0)