Skip to content

Commit 89acbd0

Browse files
ahrzbclaude
andcommitted
feat(specializer): standing differential regexp fuzzer + 12 new reject classes from its first deep run (TASK-54)
Grammar-biased duckdb-vs-engine fuzzer in the normal gate (~4s, fixed seed, env-overridable). First deep run: 122 divergences in 3k cases; after the retrans.rs fixes (reject list + POSIX class tracker + rewrite MaxSubmatch pre-scan), zero divergences over 40k cases / 8 seeds. Pins: pins-waveB/fuzzer-task54.json + spec addendum. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1c5c894 commit 89acbd0

5 files changed

Lines changed: 789 additions & 21 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
---
2+
id: TASK-54
3+
title: >-
4+
Specializer standing differential regexp fuzzer — grammar-biased duckdb vs
5+
engine parity
6+
status: Done
7+
assignee: []
8+
created_date: '2026-07-27 12:00'
9+
updated_date: '2026-07-27 14:30'
10+
labels: []
11+
milestone: m-7
12+
dependencies:
13+
- TASK-53
14+
priority: medium
15+
type: chore
16+
ordinal: 48000
17+
---
18+
19+
## Description
20+
21+
<!-- SECTION:DESCRIPTION:BEGIN -->
22+
Wave B (TASK-53) shipped the regexp family on a bind-time RE2→rust-regex
23+
translation with a measured reject list (pins:
24+
docs/superpowers/specs/2026-07-27-waveB-regexp-pins.md). The one-time
25+
differential battery had 98 entries; the residual risk is untested constructs
26+
slipping through translate_pattern's pass-through path and serving wrong
27+
answers.
28+
29+
Build a STANDING differential fuzzer: a pytest (bounded to ~2-5s, in the
30+
normal gate) that generates random patterns from a grammar biased toward the
31+
divergence-prone axes — Perl classes in/out of char classes, inline flags,
32+
alternation, bounded repetition incl. the 1000 cap edge, escapes
33+
(octal/hex/\Q\E/\u), Unicode properties, char-class edge shapes (POSIX
34+
elements, rust set-notation `&&`/`--`/nested `[..]`), options strings,
35+
replacement templates — and runs each against BOTH duckdb
36+
(regexp_matches/extract/replace) and the engine (DuckDBInferFn on a tiny
37+
table).
38+
39+
Per-case contract: identical rows, OR the engine rejected at build time
40+
(conservative), OR both engines errored. duck-errors-while-engine-serves is
41+
always a failure. Deterministic fixed seed, env-overridable
42+
(REGEXP_FUZZ_SEED / REGEXP_FUZZ_N) for exploratory deep runs; failure
43+
messages carry seed + case index + SQL for direct reproduction.
44+
45+
Repo process: any divergence found → the construct goes on the retrans.rs
46+
reject list + a pin note in the wave-B spec addendum (pins discipline,
47+
decision: never a wrong answer).
48+
<!-- SECTION:DESCRIPTION:END -->
49+
50+
## Acceptance Criteria
51+
<!-- AC:BEGIN -->
52+
- [x] #1 Fuzzer test committed (tests/test_duckdb_regexp_fuzz.py): deterministic default seed, REGEXP_FUZZ_SEED/REGEXP_FUZZ_N overrides, bounded to ~2-5s in the normal gate
53+
- [x] #2 Generator covers the divergence-prone axes: Perl classes in/out of classes, inline flags, alternation, bounded repetition (1000 cap edge), escapes, Unicode properties, char-class edge shapes, options strings, replacement templates
54+
- [x] #3 Contract asserted per case: identical multiset of rows, or engine build-time rejection, or both error; duck-ok-engine-wrong and duck-err-engine-serves both fail with a reproducible message
55+
- [x] #4 Exploratory deep run (≥20k cases across seeds) executed before landing; every divergence found lands on the retrans.rs reject list with a pin note in the spec addendum
56+
- [x] #5 Gate green (cargo + pytest), clippy clean if Rust touched
57+
<!-- AC:END -->
58+
59+
## Final Summary
60+
61+
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
62+
Standing fuzzer landed (tests/test_duckdb_regexp_fuzz.py, N=250 default ~4s
63+
in the normal gate, seed fixed at 20260727, REGEXP_FUZZ_SEED/REGEXP_FUZZ_N
64+
overrides, failure messages carry seed+case+SQL). First deep run found 12
65+
divergence classes the 98-entry battery missed — including three silent
66+
wrong-answer shapes (class set-notation `--`/`&&`/`~~`, spaced bounds
67+
`{1, 3}`, nested-class `[`) and one DuckDB self-inconsistency (anchor-only
68+
patterns: row path literal-optimizes `$\z` to string equality while the
69+
constant fold matches). All fixed in retrans.rs (reject list + POSIX tracker
70+
fix + rewrite MaxSubmatch pre-scan) and pinned:
71+
docs/superpowers/specs/pins-waveB/fuzzer-task54.json + spec addendum.
72+
Re-swept to ZERO divergences over 40k cases / 8 seeds. Gate: 155 Rust + 802
73+
py green, clippy clean on retrans.rs.
74+
<!-- SECTION:FINAL_SUMMARY:END -->

docs/superpowers/specs/2026-07-27-waveB-regexp-pins.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,44 @@ EXCLUDE-then-filter only. COLUMNS('re') in scope: SELECT-list expansion
113113
(incl. inside an expression, zipped per-column); COLUMNS in WHERE
114114
(AND-conjunction) and multi-set expressions stay unsupported.
115115

116+
## Standing fuzzer addendum (TASK-54, pins-waveB/fuzzer-task54.json)
117+
118+
The one-time 98-entry battery left the pass-through path unguarded; the
119+
standing differential fuzzer (tests/test_duckdb_regexp_fuzz.py, in the normal
120+
gate) found 12 further divergence classes in its first deep run — each now on
121+
the reject list or fixed, re-swept to ZERO divergences over 40k cases / 8
122+
seeds. Headlines (witnesses + measured outputs in the JSON):
123+
124+
- `\1`-`\9` outside a class: RE2 backref-reject vs rust octal-mode serve —
125+
the pinned `octal(true)` cuts both ways. Reject outside; in-class stays
126+
(octal in both).
127+
- Stacked quantifiers are a GRAMMAR, not a pair list: one lazy `?` is the
128+
only legal follower; `{2}*`, `?*`, `*{2}`, `a???` all serve wrongly in
129+
rust. Rejected via a quantifier-state machine.
130+
- RE2 caps NESTED counted-repetition bound products at 1000
131+
("invalid repetition size"); reject past the product, unbounded inner
132+
counts as over-cap.
133+
- `{1, 3}` with whitespace: literal to RE2, a repetition to rust — silent
134+
wrong answers. Reject.
135+
- Character classes are the minefield: `--`/`&&`/`~~` (rust set operations),
136+
non-POSIX `[` (rust nested classes), Perl-class range endpoints, and
137+
ranges starting at the class-leading `]` all reject; POSIX `[:...:]`
138+
elements now consume atomically (the tracker used to desync and
139+
mis-rewrite `[[:alpha:]\d]`).
140+
- Anchor-only patterns (2+ text anchors, only flag/`(?:)` noise): DuckDB is
141+
SELF-inconsistent — row path literal-optimizes `'$\z'` to string equality
142+
(FALSE for 'hello') while its constant fold says TRUE. Unservable; reject.
143+
- `(x){0}`: rust erases the capture group, shifting the group model the
144+
rewrite quirks key off. Reject capturing-{0}.
145+
- Rewrite templates: RE2's MaxSubmatch pre-check outranks the bad-escape
146+
consume quirk even when the bad escape comes FIRST — translate_rewrite now
147+
pre-scans the whole template.
148+
149+
Fuzzer contract per case: identical rows, or engine build-time reject, or
150+
both error; duck-errors-engine-serves and mismatches fail with seed + case
151+
index + SQL in the message. `REGEXP_FUZZ_SEED` / `REGEXP_FUZZ_N` override the
152+
fixed defaults for deep runs.
153+
116154
## Implementation stages (each lands with tests + corpus replay green)
117155

118156
1. Regex infrastructure: `regex = "1"` dependency; translation module
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
{
2+
"meta": {
3+
"task": "TASK-54 standing differential regexp fuzzer",
4+
"measured": "2026-07-27, DuckDB 1.5.5 vs the engine (rust regex 1.13 behind retrans.rs), tests/test_duckdb_regexp_fuzz.py grammar",
5+
"method": "random grammar-biased patterns run through regexp_matches/full_match/extract/replace on both engines over the divergence-prone subject rows; every class below reproduced from a concrete generated case, then re-swept to zero divergences (40k cases, 8 seeds) after the fix",
6+
"contract": "identical rows OR engine build-time reject OR both error; duck-errors-engine-serves and value mismatches are failures"
7+
},
8+
"findings": [
9+
{
10+
"construct": "\\1-\\9 outside a character class",
11+
"witness": "regexp_full_match(s, '\\1((?m)\\W\u06639|...)a?')",
12+
"duckdb": "Invalid Input Error: invalid escape sequence: \\1 (RE2 has no backrefs)",
13+
"rust_regex": "octal(true) — pinned for in-class octal parity — reads \\1 as an octal escape and SERVES",
14+
"decision": "reject at bind ('backreference-style \\N escape'); in-class \\1 stays served (octal in both)"
15+
},
16+
{
17+
"construct": "stacked quantifiers, full grammar (not just */+ pairs)",
18+
"witness": "'z{3,}+' 'a.{2}*' 'h??*' 'X?*' 'a{1}+' '(?:..)*{2}' '\\pL{0,}+'",
19+
"duckdb": "Invalid Input Error: bad repetition operator (one lazy '?' is the only legal follower of a quantifier)",
20+
"rust_regex": "silently reinterprets and serves",
21+
"decision": "reject: after a quantifier and optional single lazy '?', any further * + ? or strict {bounds} is an error"
22+
},
23+
{
24+
"construct": "nested counted repetition, bound product over 1000",
25+
"witness": "regexp_matches(s, '(?:^(c{3,}\\dz0){1,999}\\p{L}+?\\D)|..')",
26+
"duckdb": "Invalid Input Error: invalid repetition size: {1,999}",
27+
"rust_regex": "no product cap at these sizes — serves",
28+
"decision": "reject when nested {m,n} bound products exceed 1000; unbounded inner {n,} counts as over-cap (conservative: '(a{2,}){3}' rejects)"
29+
},
30+
{
31+
"construct": "whitespace inside repetition bounds",
32+
"witness": "regexp_replace(s, '.*a{1, 3}\\D*', '') on 'abc123def'",
33+
"duckdb": "'{1, 3}' is LITERAL (no match, input unchanged)",
34+
"rust_regex": "parses it as the 1..3 repetition and replaces ('123def') — silent wrong answer",
35+
"decision": "reject '{...}' bodies that are strict bounds after whitespace stripping but contain whitespace"
36+
},
37+
{
38+
"construct": "'--' / '&&' / '~~' inside a character class",
39+
"witness": "regexp_replace(s, '[a-fz\\x41][--\u0660-\u0669]{0,}', '\\\\') ; regexp_matches(s, '[c\\s&&][^\u0660-\u0669~~\\052]')",
40+
"duckdb": "literals / ranges ('[--\u0660]' = range '-'..'\u0660'; backwards ones error 'invalid character class range: h--')",
41+
"rust_regex": "class set operations (difference/intersection/symmetric difference) — silent wrong answers measured both directions",
42+
"decision": "reject any doubled '-' '&' '~' inside a class"
43+
},
44+
{
45+
"construct": "non-POSIX '[' inside a character class",
46+
"witness": "regexp_replace(s, '..|[]a-f[b]]\\S\\A{0,}', '$1')",
47+
"duckdb": "'[' is a literal class member",
48+
"rust_regex": "opens a NESTED class — silent wrong answers",
49+
"decision": "reject '[' inside a class unless it starts a '[:name:]' POSIX element"
50+
},
51+
{
52+
"construct": "POSIX element desyncing the class tracker",
53+
"witness": "translate_pattern('[[:alpha:]\\d]') pre-fix emitted '[[:alpha:](?-u:\\d)]'",
54+
"duckdb": "'[[:alpha:]\\d]' = alpha \u222a digits",
55+
"rust_regex": "same semantics once the element is consumed atomically",
56+
"decision": "FIX (not reject): consume '[:...:]' atomically; '[[:alpha:]\\d]' now translates to '[[:alpha:]0-9]'"
57+
},
58+
{
59+
"construct": "Perl class as a range endpoint inside a class",
60+
"witness": "regexp_extract(s, '..[--\\s[:^digit:]]..', 3, 'i') ; shapes '[a-\\d]' '[\\d-a]' '[\\x08-\\s]'",
61+
"duckdb": "Invalid Input Error: invalid escape sequence / invalid character class range",
62+
"rust_regex": "the in-class expansion can COMPILE (e.g. '[\\x08-\\t\\n..]') and serve",
63+
"decision": "reject a Perl class escape adjacent to an unescaped range '-' on either side; trailing literal '-' ('[\\d-]') stays fine"
64+
},
65+
{
66+
"construct": "range starting at the class-leading literal ']'",
67+
"witness": "regexp_full_match(s, '[]-Z]\\w^h{999}') ; '[^]-0-7]'",
68+
"duckdb": "']-Z' parses as a RANGE (backwards ones error: 'invalid character class range: ]-Z')",
69+
"rust_regex": "treats the '-' as literal and serves; forward ranges ('[]-a]') would silently disagree on membership",
70+
"decision": "reject '-' directly after the class-leading ']' unless immediately before ']' ('[]-]' stays fine)"
71+
},
72+
{
73+
"construct": "anchor-only pattern with 2+ text anchors",
74+
"witness": "regexp_matches(s, '$\\z(?i)') over __THIS__ vs as a constant",
75+
"duckdb": "SELF-INCONSISTENT (measured): row path FALSE for 'hello world' / TRUE only for '' (literal-comparison optimization); constant fold TRUE everywhere. 'd$\\z', '($\\z)', 'x|$\\z', '^()^' are all rescued and consistent",
76+
"rust_regex": "matches the empty string at the anchors (agrees with duckdb's constant fold only)",
77+
"decision": "reject patterns consisting only of ^ $ \\A \\z, \\b, flag groups, and empty '(?:)' when they contain 2+ text anchors — unservable either way"
78+
},
79+
{
80+
"construct": "capture group under an exactly-zero repetition",
81+
"witness": "regexp_replace(s, '\\P{L}(\u00e9{1,999}\\x41+|\u212a*9?\u0663){0}', '$1-\\1')",
82+
"duckdb": "keeps the group in the count: \\1 in range, non-participating -> '' ; replaces (' ' -> '$1-')",
83+
"rust_regex": "erases the group from the compiled regex — the rewrite's MaxSubmatch pre-check sees 0 groups and no-ops; later group numbers would shift too",
84+
"decision": "reject a capturing group followed by {0}/{0,0}; '(?:a){0}' and 'a{0}' stay fine"
85+
},
86+
{
87+
"construct": "rewrite template: out-of-range backref AFTER a bad escape",
88+
"witness": "regexp_replace(s, '..|.', '\\x\\x\\2', 'ccg') with 0 groups",
89+
"duckdb": "input UNCHANGED — RE2's MaxSubmatch pre-check scans the whole template first, out-of-range wins over the global bad-escape consume quirk",
90+
"rust_regex": "n/a (bind-time template translation ordered the checks left-to-right)",
91+
"decision": "FIX: translate_rewrite pre-scans the whole template for out-of-range \\N before the bad-escape scan"
92+
},
93+
{
94+
"construct": "\\C (any byte)",
95+
"witness": "regexp_replace over 'h\u00e9llo \u0663\u0664' produced invalid UTF-8 (pyarrow decode failure on the ORACLE side)",
96+
"duckdb": "serves raw bytes that can split multibyte chars",
97+
"rust_regex": "does not support \\C — engine build-rejects via the compile error",
98+
"decision": "no change (already an identical-reject); fuzzer harness treats undecodable duckdb output as a duckdb error"
99+
}
100+
]
101+
}

0 commit comments

Comments
 (0)