Skip to content

Commit 68a1eac

Browse files
ahrzbclaude
authored andcommitted
feat(specializer): LIKE / NOT LIKE / ILIKE with full ESCAPE semantics (TASK-48)
Byte-based matcher with codepoint _, leftmost-first two-pointer backtracking: identical booleans AND identical error rows to DuckDB — including the data-dependent dangling-escape (plain false when the string is exhausted, per-row SyntaxException only when the matcher examines it with bytes remaining) — at O(n*m) where DuckDB's own column-pattern path is naive-recursive (measured 23s on one pathological row). ESCAPE: any single byte, doubled = literal, the escape char never matches itself unescaped, '' = none, NULL = NULL, multi-byte operand traps with DuckDB's message. ILIKE folds both sides with the measured simple casemap (== DuckDB lower(), exhaustive sweep). The corpus replay caught a genuine oracle divergence: ILIKE on NUL-containing rows is COLUMN-STATISTICS-dependent in DuckDB (sibling rows change the result) — irreproducible row-locally, now the repo's first documented divergence with the measurement attached. SIMILAR TO rejects by name (binds to regexp_full_match in DuckDB). Corpus: 240 -> 265 of 678, zero FAILs; gate green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b2bc6b0 commit 68a1eac

16 files changed

Lines changed: 1099 additions & 6 deletions

backlog/tasks/task-48 - Specializer-SQL-support-LIKE-alias-comma-join-wave-2.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,21 @@ The dual-axis rung plus two structural cheap wins, ~120 corpus first-blockers in
2424

2525
## Acceptance Criteria
2626
<!-- AC:BEGIN -->
27-
- [ ] #1 LIKE semantics pinned by measurement (duck_check tests recorded before implementation: wildcards, ESCAPE, unicode, NULL, degenerate patterns) and both backends agree via a shared matcher (differential extended)
28-
- [ ] #2 Dynamic-table alias binds (qualified refs via alias, original name behavior measured and mirrored)
27+
- [x] #1 LIKE semantics pinned by measurement (duck_check tests recorded before implementation: wildcards, ESCAPE, unicode, NULL, degenerate patterns) and both backends agree via a shared matcher (differential extended)
28+
- [x] #2 Dynamic-table alias binds (qualified refs via alias, original name behavior measured and mirrored)
2929
- [x] #3 RE-SCOPED by census (recorded below): the corpus comma-join cases are SELF-joins of the dynamic table (FROM integers i1, integers i2 ...), not dynamic-x-static shapes — a rewrite would flip ~0 cases. Comma-join stays cleanly unsupported; dynamic self-join is a distinct future feature (needs row-table-as-static materialization).
30-
- [ ] #4 Corpus replay: wave-2 first-blocker cases flip to match or a named deeper blocker; zero FAILs; tally recorded here
31-
- [ ] #5 mise gate-specializer green
30+
- [x] #4 Corpus replay: wave-2 first-blocker cases flip to match or a named deeper blocker; zero FAILs; tally recorded here
31+
- [x] #5 mise gate-specializer green
3232
<!-- AC:END -->
3333

3434
## Implementation Notes
3535

3636
<!-- SECTION:NOTES:BEGIN -->
37-
Census before code (2026-07-26): the 30 alias-first-blocker cases are mostly alias + a DEEPER blocker (self-joins, USING, rowid, column-renaming aliases); the ~30 comma-join cases are nearly all SELF cross-products of the dynamic table — the planned equi-WHERE rewrite would flip approximately zero, so it was dropped from scope honestly rather than shipped as dead code. Alias landed: the alias REPLACES the original name (measured: qualified refs through the original are DuckDB binder errors) — implemented by making the alias the binder's this_name, which yields exactly that scoping for qualified refs, star expansion, and error messages; AS/bare forms identical; column-renaming alias t(a,b) rejects by name. LIKE pins fleet running; matcher lands next.
37+
Census before code (2026-07-26): the 30 alias-first-blocker cases are mostly alias + a DEEPER blocker (self-joins, USING, rowid, column-renaming aliases); the ~30 comma-join cases are nearly all SELF cross-products of the dynamic table — the planned equi-WHERE rewrite would flip approximately zero, so it was dropped from scope honestly rather than shipped as dead code. Alias landed: the alias REPLACES the original name (measured: qualified refs through the original are DuckDB binder errors) — implemented by making the alias the binder's this_name, which yields exactly that scoping for qualified refs, star expansion, and error messages; AS/bare forms identical; column-renaming alias t(a,b) rejects by name. LIKE landed: byte-based O(n*m) two-pointer matcher with codepoint _, leftmost-first backtracking reproducing the DATA-DEPENDENT dangling-escape error rows, full ESCAPE semantics (any single byte, doubled = literal, never self-matching, '' = none, NULL = NULL), NOT LIKE, and ILIKE via double simple-casemap fold (exhaustively verified vs DuckDB lower()). The corpus caught a live divergence during replay: DuckDB's ILIKE result for NUL-containing rows depends on SIBLING rows (stats-selected kernels; the generic one NUL-truncates) — irreproducible row-locally, recorded as the repo's first documented oracle divergence (_KNOWN_DIVERGENT_SOURCES) with the measurement in the pins. SIMILAR TO rejects by name (DuckDB binds it to regexp_full_match). CORPUS: 240 -> 265 match of 678, zero FAILs, gate green.
3838
<!-- SECTION:NOTES:END -->
39+
40+
## Final Summary
41+
42+
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
43+
Wave 2 complete: LIKE/NOT LIKE/ILIKE with full ESCAPE semantics on both backends (pins-first; the matcher reproduces even DuckDB's data-dependent error rows while avoiding its measured O(n^k) blowup), dynamic-table alias (alias replaces the original name), comma-join honestly descoped by census (corpus cases are self-joins). Corpus 240 -> 265/678, zero FAILs; first documented oracle divergence recorded with proof of row-local irreproducibility (stats-dependent ILIKE kernels).
44+
<!-- SECTION:FINAL_SUMMARY:END -->

docs/superpowers/specs/2026-07-26-wave1-builtin-pins.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,24 @@ differs.
112112
DOUBLE column path, as with substr).
113113
- Repo doc drift found during verification: interp.rs header still says
114114
fcmp is IEEE-with-NaN-false; it is duck_fcmp. Fix in this wave.
115+
116+
## Wave-2 addendum: LIKE (pins-wave1/pins_like.json)
117+
118+
Byte-based matcher, codepoint `_`, no normalization; backslash is a LITERAL
119+
(no implicit escape — unlike Postgres); ESCAPE takes any single BYTE (a
120+
2-byte codepoint errors), doubled = literal, the escape char never matches
121+
itself unescaped, ESCAPE '' = none, ESCAPE NULL = NULL. The dangling-escape
122+
error is DATA-DEPENDENT per row (string exhausted -> plain false; bytes
123+
remaining when the matcher examines it -> SyntaxException) — reproduced by
124+
leftmost-first backtracking. ILIKE = fold both sides with the measured
125+
simple casemap then LIKE (exhaustive all-codepoint sweep, zero exceptions);
126+
DuckDB's oracle-side caveats: (1) a stats-dependent ASCII kernel whose only
127+
divergence surface is U+212A/U+0130 in patterns, and (2) the generic
128+
kernel's fold NUL-truncates inconsistently, making a NUL-containing row's
129+
result depend on SIBLING rows — statistics-dependent semantics are
130+
irreproducible row-locally, so the engine is NUL-transparent and the one
131+
corpus case is a documented divergence (test_corpus_replay
132+
_KNOWN_DIVERGENT_SOURCES). SIMILAR TO rejects by name: DuckDB binds it to
133+
regexp_full_match (RE2), not SQL wildcards. DuckDB's own column-pattern
134+
LIKE is naive-recursive (measured 23s on one pathological row); ours is the
135+
O(n*m) two-pointer restart with identical booleans and error rows.

0 commit comments

Comments
 (0)