diff --git "a/backlog/tasks/task-55 - Specializer-small-tails-sweep-\342\200\224-NULL-value-statics-schema-qualified-relations.md" "b/backlog/tasks/task-55 - Specializer-small-tails-sweep-\342\200\224-NULL-value-statics-schema-qualified-relations.md" new file mode 100644 index 0000000..7a5e3bd --- /dev/null +++ "b/backlog/tasks/task-55 - Specializer-small-tails-sweep-\342\200\224-NULL-value-statics-schema-qualified-relations.md" @@ -0,0 +1,43 @@ +--- +id: TASK-55 +title: >- + Specializer small-tails sweep — NULL-value statics + schema-qualified + relations +status: Done +assignee: [] +created_date: '2026-07-27 22:10' +updated_date: '2026-07-27 22:23' +labels: [] +milestone: m-7 +dependencies: + - TASK-53 +priority: medium +type: feature +ordinal: 49000 +--- + +## Description + + +Post-wave-B census tails that are conservative rejects rather than semantic gaps: + +1. NULL values in static join tables (9 cases): the original static-map design rejected any NULL in a value column. Real fitted-encoding tables have NULLs. Design: split each NULLABLE value column into (validity i1, payload) pairs at materialization — zero IR changes; the frontend's static_lane combines validity into the null lane (AND with the LEFT-miss flag). NULL KEYS keep the existing drop-the-row rule (never equi-match). + +2. Schema-qualified relations (5 cases): SELECT test.tbl.col FROM test.tbl / FROM s1.t1, s2.t1. The engine is schema-less; accept a single qualifier when the table part matches the registered bare name (driving, joined, and comma-joined statics — registered names may themselves be qualified), and bind 3-part column refs (schema.table.col). This AMENDS the wave-5 main.-only pin: DuckDB's schema-existence errors are unknowable to a schema-less registry; document as a contract choice in known-limitations.md + twin test updates in the same commit. + +Deferred with named rejects: COLUMNS(* REPLACE ...) and fn(COLUMNS(*)) expression forms, try_trim_null (a corpus-local macro), UBIGINT key payloads. + + +## Acceptance Criteria + +- [x] #1 Static tables with NULL VALUE columns serve: NULL flows through join lanes (INNER and LEFT, incl. residuals); NULL keys keep dropping; both backends +- [x] #2 Schema-qualified driving/joined/comma-joined relations + 3-part column refs bind by suffix match; known-limitations.md §5 + twin suite updated in the same commit +- [x] #3 Corpus replay: zero FAILs, match count reported (expect ~+12 to ~517) +- [x] #4 Gate green both backends, clippy clean + + +## Final Summary + + +Small-tails sweep shipped: corpus 505 -> 511 of 678, zero FAILs. (1) NULL values in static join tables serve — declared-nullable value columns flatten to (validity i1, payload) pairs in the map layout with zero IR changes; the probe's typed miss-defaults make validity=false free on LEFT misses, and StaticCol ANDs validity into the null lane on both backends (INNER/LEFT/residual all oracle-checked); NULL keys keep the drop rule; the materializer maps NULL -> (false, typed default) with the non-nullable guard kept as a safety net. (2) Schema qualifiers are registry-noise: s1.t1 resolves by table-part suffix match for driving/joined/comma-joined relations, 3-part schema.table.col refs bind; amends the wave-5 main.-only rule, documented as a §5 contract choice (DuckDB's schema-existence errors are unknowable to a schema-less registry); ambiguity still errors. known-limitations.md + twin suite updated in the same commit (NULL-value reject row flipped to served). 3 of the 9 NULL-static corpus cases uncovered second blockers (dup keys / self-joins — stage-B constituency grows again). Gates: 155 Rust + 599 py green, interp identical minus backend-identity guards, clippy clean on touched files. PR #42 (stacked on #41). + diff --git a/docs/known-limitations.md b/docs/known-limitations.md index bc778ba..5ea8079 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -30,7 +30,7 @@ rejected, permanently by design: |---|---|---| | 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. | | Replacement strings / regex options / extract group indexes must be constants | `non-constant regexp_replace replacement` etc. | Same. | -| 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. | +| 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. | | 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. | | 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. | @@ -83,6 +83,7 @@ wrong answer or require semantics we can't reproduce exactly: | `^` 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()`. | | prefix `~`, `#`, `NOT GLOB` | Same class: precedence/parse divergences that would silently mis-associate. `xor()` covers bit-xor; `NOT (x GLOB p)` works. | | Regex reject list: `\B`, `\Q…\E`, `(?…)`, 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. | +| Fuzzer-found regex rejects (TASK-54): `\1`–`\9` backrefs outside classes, the full stacked-quantifier grammar (`{2}*`, `?*`, `a???` — one lazy `?` is the only legal follower), nested repetition products > 1000, whitespace inside `{m, n}` bounds, class set-op lookalikes (`--`/`&&`/`~~`), non-POSIX `[` inside classes, Perl-class range endpoints (`[a-\d]`), capturing `(x){0}`, anchor-only multi-anchor patterns (DuckDB is SELF-inconsistent on these — its row path disagrees with its own constant fold) | The standing differential fuzzer (`tests/test_duckdb_regexp_fuzz.py`, in the normal gate) found these 12 classes in its first 3k-case deep run — each one a silent-wrong-answer risk in rust-regex — then re-swept to ZERO divergences over 40k cases across 8 seeds. Pins: `pins-waveB/fuzzer-task54.json`. | | `SIMILAR TO ... ESCAPE` | Not implemented in DuckDB itself. | | `* 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. | | `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. | @@ -115,6 +116,12 @@ These are served, but with a consciously chosen surface — know them: principle; the engine is NUL-transparent (the ASCII-kernel behavior). - **`%`-by-zero NaN bit pattern is platform-libm** — pinned as engine==oracle bit agreement per platform, not a constant. +- **Schema qualifiers are registry-noise** (TASK-55): the engine's table + registry is schema-less, so `s1.t1` (and 3-part `s1.t1.col` refs) + resolve when the table part matches a registered bare name. DuckDB's + schema-existence errors (`schema "x" does not exist`) are not + reproduced — a schema-less registry cannot know which schemas would + exist. Ambiguous matches still error. ## 6. How to read a rejection @@ -128,3 +135,18 @@ whose message starts with a classification: If a message you hit isn't in this document or the tests, that's a bug in our bookkeeping — file it. + +## 7. How this document stays honest + +Three mechanisms, all in the normal test gate: + +1. **The corpus replay** (678 statements mined from DuckDB's test suite): + every statement must match bit-for-bit, reject cleanly, or be a named + divergence — a wrong answer anywhere fails the gate. +2. **The executable twin** (`tests/test_known_limitations.py`): every + limitation in this document is asserted; lifting one breaks a test. +3. **The standing differential fuzzer** (`tests/test_duckdb_regexp_fuzz.py`): + randomized DuckDB-vs-engine sweeps of the regex surface on every run + (seed/size overridable for deep runs) — new divergences fail with the + reproducing seed and SQL, and their fix lands as a reject-list entry + plus a row in this document. diff --git a/src/duckdb/mod.rs b/src/duckdb/mod.rs index f7e2b06..34e305a 100644 --- a/src/duckdb/mod.rs +++ b/src/duckdb/mod.rs @@ -98,27 +98,53 @@ fn materialize_map( }); } let mut vals = Vec::with_capacity(val_tys.len()); - for (name, ty) in spec.val_cols.iter().zip(val_tys) { + let mut vt = val_tys.iter(); + for (name, &nullable) in spec.val_cols.iter().zip(&spec.val_nullable) { let v = get(name)?; - if v.is_none() { - return Err(build_err(format!( - "static table '{}' has a NULL in value column '{name}' — joins to NULL \ - values are not supported", - spec.table - ))); - } - vals.push(match ty { - Ty::I1 => ScalarVal::I1(v.extract()?), - Ty::I64 => ScalarVal::I64(v.extract().map_err(|_| { - build_err(format!( - "unsupported: static table '{}' value column '{name}' value \ - outside BIGINT range (UBIGINT/HUGEINT payloads)", + let convert = |v: &pyo3::Bound<'_, PyAny>, ty: Ty| -> PyResult { + Ok(match ty { + Ty::I1 => ScalarVal::I1(v.extract()?), + Ty::I64 => ScalarVal::I64(v.extract().map_err(|_| { + build_err(format!( + "unsupported: static table '{}' value column '{name}' value \ + outside BIGINT range (UBIGINT/HUGEINT payloads)", + spec.table + )) + })?), + Ty::F64 => ScalarVal::F64(v.extract()?), + Ty::Str => ScalarVal::Str(v.extract()?), + }) + }; + if nullable { + // (validity, payload) pair per the flattened map layout + // (TASK-55): NULL -> (false, typed default). + let _validity_ty = vt.next(); + let ty = *vt.next().expect("payload type follows validity"); + if v.is_none() { + vals.push(ScalarVal::I1(false)); + vals.push(match ty { + Ty::I1 => ScalarVal::I1(false), + Ty::I64 => ScalarVal::I64(0), + Ty::F64 => ScalarVal::F64(0.0), + Ty::Str => ScalarVal::Str(String::new()), + }); + } else { + vals.push(ScalarVal::I1(true)); + vals.push(convert(&v, ty)?); + } + } else { + let ty = *vt.next().expect("one type per non-nullable column"); + if v.is_none() { + // Declared non-nullable yet NULL in the data — the + // original guard stays as a safety net. + return Err(build_err(format!( + "static table '{}' has a NULL in value column '{name}' — declared \ + non-nullable", spec.table - )) - })?), - Ty::F64 => ScalarVal::F64(v.extract()?), - Ty::Str => ScalarVal::Str(v.extract()?), - }); + ))); + } + vals.push(convert(&v, ty)?); + } } entries.push((keys, vals)); } diff --git a/src/specializer/frontend.rs b/src/specializer/frontend.rs index 428f066..8d600e2 100644 --- a/src/specializer/frontend.rs +++ b/src/specializer/frontend.rs @@ -249,14 +249,15 @@ fn bind_from<'a>( let dyn_name = match &table.relation { TableFactor::Table { name, alias, .. } => { let n = name.to_string(); - // `main.tbl` resolves to bare `tbl` (DuckDB's default schema); - // every OTHER qualifier fails in DuckDB itself with a - // schema-does-not-exist Catalog Error, so it stays unsupported - // here — never a silent bare-name fallback (wave-5 pins). - let bare = n - .strip_prefix("main.") - .or_else(|| n.strip_prefix("MAIN.")) - .unwrap_or(&n); + // The engine's registry is SCHEMA-LESS: a single schema + // qualifier is accepted when the table part matches the + // registered bare name (TASK-55, amends the wave-5 main.-only + // rule — DuckDB's schema-existence errors are unknowable to a + // schema-less registry; documented in known-limitations.md §5). + let bare = match n.rsplit_once('.') { + Some((_, t)) => t, + None => &n, + }; if !bare.eq_ignore_ascii_case(this_name) { return Err(unsup(format!( "table '{n}' as the driving relation (must be the dynamic table '{this_name}')" @@ -568,9 +569,15 @@ fn bind_from<'a>( } fn resolve_static(statics: &[StaticTable], raw_name: &str) -> Result { + // Schema-less registry (TASK-55): an exact registered-name match wins; + // otherwise a single-qualifier SQL name (`s1.t1`) matches a registered + // bare `t1`. Ambiguity stays an error. + let bare = raw_name.rsplit_once('.').map(|(_, t)| t); let mut table_idx = None; for (i, st) in statics.iter().enumerate() { - if st.name.eq_ignore_ascii_case(raw_name) { + let hit = st.name.eq_ignore_ascii_case(raw_name) + || bare.is_some_and(|b| st.name.eq_ignore_ascii_case(b)); + if hit { if table_idx.is_some() { return Err(PrepareError::Bind(format!( "ambiguous static table '{raw_name}'" @@ -1502,6 +1509,9 @@ impl Binder<'_> { SqlExpr::Identifier(ident) => self.column(&ident.value), SqlExpr::CompoundIdentifier(parts) => match parts.as_slice() { [table, col] => self.qualified(&table.value, &col.value), + // `schema.table.col` — the schema part is registry-noise + // (TASK-55; structs would be a 4th meaning, not modeled). + [_, table, col] => self.qualified(&table.value, &col.value), _ => Err(unsup("nested field access")), }, SqlExpr::Nested(inner) => self.expr(inner), @@ -2153,7 +2163,10 @@ impl Binder<'_> { col: pos as u32, }, ty: col.ty.ty, - nullable: sj.kind == JoinKind::Left, + // NULL-able on a LEFT miss OR when the static column itself is + // declared nullable (TASK-55: NULL values ride as validity+ + // payload pairs through the probe). + nullable: sj.kind == JoinKind::Left || col.ty.nullable, } } diff --git a/src/specializer/lower.rs b/src/specializer/lower.rs index 8ab5814..8c6e206 100644 --- a/src/specializer/lower.rs +++ b/src/specializer/lower.rs @@ -136,10 +136,19 @@ pub fn lower( .iter() .map(|spec| StaticTy::Map { keys: spec.keys.iter().map(|k| k.ty).collect(), + // A NULLABLE value column flattens to (validity i1, payload) — + // TASK-55; the probe dst layout mirrors this (val_slots). values: spec .val_cols .iter() - .map(|&c| catalog[spec.table].cols[c as usize].ty.ty) + .flat_map(|&c| { + let ct = catalog[spec.table].cols[c as usize].ty; + if ct.nullable { + vec![Ty::I1, ct.ty] + } else { + vec![ct.ty] + } + }) .collect(), }) .collect(); @@ -411,15 +420,22 @@ impl<'a> FB<'a> { } SKind::StaticCol { join, col } => { let (valid_hit, dsts) = self.emit_probe(*join, live)?; - let flag = match self.joins[*join as usize].kind { + let slots = self.val_slots(*join); + let (validity, payload) = slots[*col as usize]; + let hit_flag = match self.joins[*join as usize].kind { // INNER: a miss already skipped the row before any // expression could look — the lane is provably valid. JoinKind::Inner => None, JoinKind::Left => Some(valid_hit), }; + // A nullable static column carries its own validity dst + // (TASK-55); on a LEFT miss the probe defaults it to false, + // so the AND is correct without extra guards. + let vflag = validity.map(|vi| dsts[vi]); + let flag = self.combine_flags(hit_flag, vflag); Ok(Lane { flag, - val: dsts[*col as usize], + val: dsts[payload], }) } SKind::Lit(lit) => Ok(Lane { @@ -959,6 +975,42 @@ impl<'a> FB<'a> { /// flag (map hit AND every nullable key's validity: a NULL key never /// matches, and a garbage payload under a false flag must not spuriously /// hit) plus the value-column registers. + /// Flattened probe-dst layout for join `j`: per value column either + /// `(None, payload_idx)` or `(Some(validity_idx), payload_idx)` — + /// nullable static value columns ride as validity+payload pairs + /// (TASK-55), mirroring the StaticTy::Map flattening. + fn val_slots(&self, j: u32) -> Vec<(Option, usize)> { + let spec = &self.joins[j as usize]; + let mut out = Vec::with_capacity(spec.val_cols.len()); + let mut i = 0usize; + for &c in &spec.val_cols { + if self.catalog[spec.table].cols[c as usize].ty.nullable { + out.push((Some(i), i + 1)); + i += 2; + } else { + out.push((None, i)); + i += 1; + } + } + out + } + + /// Flattened probe-dst TYPES for join `j` (same order as val_slots). + fn val_flat_tys(&self, j: u32) -> Vec { + let spec = &self.joins[j as usize]; + spec.val_cols + .iter() + .flat_map(|&c| { + let ct = self.catalog[spec.table].cols[c as usize].ty; + if ct.nullable { + vec![Ty::I1, ct.ty] + } else { + vec![ct.ty] + } + }) + .collect() + } + fn emit_probe(&mut self, j: u32, live: &mut Live) -> Result<(Value, Vec), PrepareError> { if let Some((valid_hit, dsts)) = self.blocks[self.cur].probes.get(&j) { return Ok((*valid_hit, dsts.clone())); @@ -979,9 +1031,9 @@ impl<'a> FB<'a> { } live.truncate(live.len() - nkeys); - let spec = &self.joins[j as usize]; + let flat_len = self.val_flat_tys(j).len(); let hit = self.fresh(); - let mut dsts: Vec = spec.val_cols.iter().map(|_| self.b.fresh()).collect(); + let mut dsts: Vec = (0..flat_len).map(|_| self.b.fresh()).collect(); self.inst(Inst::Probe { static_id: j, hit, @@ -1008,12 +1060,7 @@ impl<'a> FB<'a> { // The strict block-param SSA means the probe's value lanes // must ride the params (and, during residual emission, the // live stack — nested CASE machinery rebinds them). - let spec = &self.joins[j as usize]; - let dst_tys: Vec = spec - .val_cols - .iter() - .map(|&c| self.catalog[spec.table].cols[c as usize].ty.ty) - .collect(); + let dst_tys: Vec = self.val_flat_tys(j); let live_width = Self::live_types(live).len(); let mut join_tys = Self::live_types(live); join_tys.extend(dst_tys.iter().copied()); diff --git a/src/specializer/mod.rs b/src/specializer/mod.rs index 78fb16b..a79c2cc 100644 --- a/src/specializer/mod.rs +++ b/src/specializer/mod.rs @@ -31,6 +31,10 @@ pub struct StaticSpec { pub table: String, pub key_cols: Vec, pub val_cols: Vec, + /// Per val_col: a declared-nullable column's map value is a + /// (validity i1, payload) PAIR in the flattened StaticTy::Map values + /// (TASK-55 — NULL join values flow through as NULL, not errors). + pub val_nullable: Vec, } /// The output of stage 1: a verified program plus, per map static, the @@ -78,6 +82,11 @@ pub fn prepare( .iter() .map(|&c| t.cols[c as usize].name.clone()) .collect(), + val_nullable: j + .val_cols + .iter() + .map(|&c| t.cols[c as usize].ty.nullable) + .collect(), } }) .collect(); diff --git a/src/specializer/tests.rs b/src/specializer/tests.rs index 3a55e90..b2282a2 100644 --- a/src/specializer/tests.rs +++ b/src/specializer/tests.rs @@ -1504,10 +1504,13 @@ fn binder_tail_null_ops_natural_join_main_qualifier() { ) .unwrap(); assert_eq!(got, rows(&[&["NULL", "NULL", "NULL"]])); - // main.tbl resolves to the bare dynamic table; other schemas reject. + // Schema qualifiers are registry-noise (TASK-55): any single qualifier + // resolves when the table part matches; 3-part column refs bind too. let p = prep("SELECT a FROM main.__THIS__", &schema).unwrap(); assert_eq!(p.out_cols[0].name, "a"); - match prep("SELECT a FROM test.__THIS__", &schema) { + let p = prep("SELECT test.__THIS__.a FROM test.__THIS__", &schema).unwrap(); + assert_eq!(p.out_cols[0].name, "a"); + match prep("SELECT a FROM test.other", &schema) { Err(PrepareError::Unsupported(m)) => assert!(m.contains("driving relation"), "{m}"), other => panic!("wrong outcome: {:?}", other.err()), } diff --git a/tests/test_duckdb_interpreter.py b/tests/test_duckdb_interpreter.py index c3281c9..8ea358d 100644 --- a/tests/test_duckdb_interpreter.py +++ b/tests/test_duckdb_interpreter.py @@ -145,14 +145,16 @@ def test_duplicate_build_keys_error(): ) -def test_null_in_value_column_errors(): +def test_null_in_value_column_serves(): + # TASK-55: NULL VALUES flow through joins as NULL (validity+payload + # pairs in the map); only NULL KEYS keep the drop rule. holed = static({"id": "int", "v": "int?"}, [{"id": 1, "v": None}]) - with pytest.raises(ValueError, match="NULL in value column"): - DuckDBInferFn( - "SELECT v FROM __THIS__ JOIN dim ON k = dim.id", - row_tables={"__THIS__": _row_model({"k": "int"})}, - static_tables={"dim": holed}, - ) + duck_check( + "SELECT v FROM __THIS__ JOIN dim ON k = dim.id", + {"k": "int"}, + [{"k": 1}, {"k": 2}], + {"dim": holed}, + ) def test_null_key_build_rows_are_dropped(): diff --git a/tests/test_duckdb_wave5_structural.py b/tests/test_duckdb_wave5_structural.py index b1f2be4..7fbb977 100644 --- a/tests/test_duckdb_wave5_structural.py +++ b/tests/test_duckdb_wave5_structural.py @@ -23,6 +23,47 @@ ] +def test_null_value_statics_vs_oracle(): + # TASK-55: NULL values in static tables flow through joins as NULL + # (INNER and LEFT, incl. under residual predicates); NULL keys drop. + dim = static( + {"id": "int", "v": "int?", "w": "str?"}, + [ + {"id": 3, "v": None, "w": "x"}, + {"id": 7, "v": 70, "w": None}, + ], + ) + duck_check( + "SELECT a, v, w FROM __THIS__ JOIN dim ON a = dim.id", + T, + T_ROWS, + {"dim": dim}, + ) + duck_check( + "SELECT a, v, w, v + 1 AS v1 FROM __THIS__ LEFT JOIN dim ON a = dim.id", + T, + T_ROWS, + {"dim": dim}, + ) + duck_check( + "SELECT a, v FROM __THIS__ LEFT JOIN dim ON a = dim.id AND a > 2", + T, + T_ROWS, + {"dim": dim}, + ) + duck_check( + "SELECT * FROM __THIS__ LEFT JOIN dim ON a = dim.id", + T, + T_ROWS, + {"dim": dim}, + ) + + +def test_schema_qualified_relations_vs_oracle(): + # TASK-55: schema qualifiers are registry-noise; suffix match binds. + duck_check("SELECT main.__THIS__.a FROM main.__THIS__", T, T_ROWS) + + def test_binder_tail_vs_oracle(): # NULL NULL typing, lateral aliases (real column wins), main. # qualifier, t AS u(x,y), NATURAL JOIN, mixed-literal IN/BETWEEN. diff --git a/tests/test_known_limitations.py b/tests/test_known_limitations.py index 4d1e823..7e652ce 100644 --- a/tests/test_known_limitations.py +++ b/tests/test_known_limitations.py @@ -47,10 +47,13 @@ def test_static_tables_are_frozen_unique_key_maps(): "duplicate map key", {"d": dup}, ) + # NULL VALUES serve since TASK-55 (they ride as validity+payload pairs); + # only NULL keys keep the drop rule (a NULL never equi-matches). withnull = static({"id": "int", "v": "int?"}, [{"id": 1, "v": None}]) - rejects( + duck_check( "SELECT v FROM __THIS__ JOIN d ON a = d.id", - "NULL in value column", + {"a": "int", "s": "str?"}, + [{"a": 1, "s": None}], {"d": withnull}, ) @@ -133,6 +136,17 @@ def test_ubigint_static_payloads_reject(): "duplicate regex capture group", ), ("SELECT regexp_matches(s, 'a{1001}') FROM __THIS__", "repetition bound"), + # TASK-54 fuzzer-found classes (pins-waveB/fuzzer-task54.json): + # each was a measured silent-wrong-answer risk in rust-regex. + ("SELECT regexp_matches(s, '(a)x\\1') FROM __THIS__", "backref"), + ("SELECT regexp_matches(s, 'a?*') FROM __THIS__", "quantifi"), + ("SELECT regexp_matches(s, 'a{2}*') FROM __THIS__", "quantifi"), + ("SELECT regexp_matches(s, '(a{100}){20}') FROM __THIS__", "repetition"), + ("SELECT regexp_matches(s, 'a{1, 3}') FROM __THIS__", "unsupported|parse"), + ("SELECT regexp_matches(s, '[a--b]') FROM __THIS__", "unsupported"), + ("SELECT regexp_matches(s, '[a-\\d]') FROM __THIS__", "Perl class endpoint"), + ("SELECT regexp_matches(s, '(x){0}') FROM __THIS__", "unsupported"), + ("SELECT regexp_matches(s, '^$') FROM __THIS__", "anchor-only"), # Not implemented in DuckDB itself. ("SELECT s SIMILAR TO 'a' ESCAPE 'x' FROM __THIS__", "escape"), # Exec-time conversion order (an empty input succeeds in DuckDB).