Skip to content

Commit c7b0479

Browse files
ahrzbclaude
andcommitted
fix(specializer): harden the IR boundary against adversarial findings — TASK-41
Six agents (4 attack lenses + 2 reviews) probed the initial IR; every confirmed finding is fixed and pinned by a regression test: - reachability DFS made iterative: a ~8k-block legal CFG stack-overflowed the process; 50k blocks now verify (deep_cfg_verifies_without_crashing) - verified programs must print to parseable text: reject empty map static signatures (grammar can't express `map() -> ()`) and non-identifier function names - Lit::F64 equality treats all NaNs as one class, matching the text form's canonical `nan` token — non-canonical payloads round-trip again - store dataflow runs over the reachable subgraph: an unreachable cyclic island no longer starves a reachable join and masks its store errors - docs aligned: double stores rejected on ALL paths incl. trap (deliberate), grammar EBNF covers store/comments/lenient %names, canonical-form note - dead Inst::uses/Term::uses deleted; 7 missing rule-coverage tests added (entry params, duplicate cols, branch-to-entry, sload-on-map, scalar-static .opt pairing both ways, store.opt on non-nullable) cargo 45 passed; gate green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 8b50641 commit c7b0479

5 files changed

Lines changed: 354 additions & 76 deletions

File tree

docs/superpowers/specs/2026-07-25-sql-specializer-design.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -209,12 +209,19 @@ the direction of a smaller, more verifiable core):
209209
so `-0.0` and NaN round-trip).
210210

211211
Verifier rules (each has a rejecting test in `ir/tests.rs`): structure (entry
212-
has no params, unique column names, no branch to entry), SSA (single def,
213-
same-block visibility), per-op operand types incl. mandatory/forbidden `.opt`
214-
pairing against column/static nullability, static resolution + kind/arity/key
215-
types, CFG reachability + acyclicity + branch-arg typing, and the
216-
store-completeness dataflow (exactly-once per column at `emit`, zero at
217-
`skip`, agreement at joins).
212+
has no params, unique column names, no branch to entry, identifier function
213+
name, non-empty map signatures — a verified program must print to parseable
214+
text), SSA (single def, same-block visibility), per-op operand types incl.
215+
mandatory/forbidden `.opt` pairing against column/static nullability, static
216+
resolution + kind/arity/key types, CFG reachability + acyclicity + branch-arg
217+
typing (iterative DFS — deep legal CFGs must not overflow the native stack),
218+
and the store-completeness dataflow (never twice on any path, exactly-once
219+
per column at `emit`, zero at `skip`, agreement at joins, computed over the
220+
reachable subgraph so an unreachable island can't mask a join's errors).
221+
222+
An adversarial pass (4 attack lenses + design-conformance + simplification
223+
reviews, 2026-07-25) ran against the initial implementation; every confirmed
224+
finding is pinned by a regression test in `ir/tests.rs`.
218225

219226
Deferred to M-interp, pinned there against the DuckDB oracle: `ftoi.round`
220227
tie behavior and `fcmp` NaN ordering. `idiv`/`irem` trap on zero/overflow;

src/specializer/ir/mod.rs

Lines changed: 24 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
//! * `trap "msg"` — abort the whole call with a runtime error (CAST failures,
1919
//! division guards — whatever the lowered dialect defines as an error).
2020
//!
21+
//! No path may store the same column twice, whatever its terminator — a
22+
//! double store is always a lowering bug, so the verifier rejects it even on
23+
//! paths that end in `trap`.
24+
//!
2125
//! `|out| == |in|` therefore holds exactly when `skip` is unreachable, which
2226
//! is statically known — the design doc's "filter is the one allowed
2327
//! divergence and must declare it".
@@ -66,12 +70,16 @@
6670
//!
6771
//! Canonical, round-trippable; `parse(print(p)) == p` for every program whose
6872
//! value ids are dense and in definition order (the parser and [`gen`] both
69-
//! produce that form, so the property is closed under round-trip). Names in
70-
//! the text are presentation only — they are not stored in the IR; printing
71-
//! uses canonical `%vN` / `bN` names.
73+
//! produce that form, so the property is closed under round-trip). A program
74+
//! with sparse or out-of-order ids still verifies and still prints; the
75+
//! round-trip then performs a bijective renumbering into canonical form, so
76+
//! structural equality is only meaningful between canonical programs. Names
77+
//! in the text are presentation only — they are not stored in the IR;
78+
//! printing uses canonical `%vN` / `bN` names.
7279
//!
7380
//! ```text
7481
//! program := static* func
82+
//! comment := "#" ... end-of-line // allowed anywhere whitespace is
7583
//! static := "static" "@" INT ":" static_ty
7684
//! static_ty := "scalar" "<" col_ty ">"
7785
//! | "map" "(" ty ("," ty)* ")" "->" "(" ty ("," ty)* ")"
@@ -81,12 +89,15 @@
8189
//! col_ty := ty ["?"]
8290
//! ty := "i1" | "i64" | "f64" | "str"
8391
//! block := IDENT ["(" VALUE ":" ty ("," VALUE ":" ty)* ")"] ":" inst* term
84-
//! inst := VALUE ("," VALUE)* "=" OPCODE operands
92+
//! inst := [VALUE ("," VALUE)* "="] OPCODE operands
93+
//! // only store/store.opt omit the dests; everything else
94+
//! // defines at least one value
8595
//! term := "jump" target
8696
//! | "brif" VALUE "," target "," target
8797
//! | "emit" | "skip" | "trap" STRING
8898
//! target := IDENT ["(" VALUE ("," VALUE)* ")"]
89-
//! VALUE := "%" IDENT
99+
//! VALUE := "%" NAME // NAME: any run of [A-Za-z0-9_];
100+
//! // the printer only emits %vN
90101
//! ```
91102
//!
92103
//! Instruction surface (`%d` result, `%f` an `i1` flag result):
@@ -179,9 +190,11 @@ pub struct Value(pub u32);
179190
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
180191
pub struct BlockId(pub u32);
181192

182-
/// Literal for `const.*`. `F64` equality is bitwise so that programs
183-
/// containing NaN still satisfy `parse(print(p)) == p` (the text form
184-
/// canonicalizes NaN payloads to the one `nan` token).
193+
/// Literal for `const.*`. `F64` equality is bitwise — so `-0.0 != 0.0`
194+
/// survives a round-trip — EXCEPT that all NaNs compare equal: the text form
195+
/// canonicalizes every NaN payload to the one `nan` token, and equality must
196+
/// match what the text format can distinguish or `parse(print(p)) == p`
197+
/// would fail for non-canonical payloads (found by adversarial fuzzing).
185198
#[derive(Clone, Debug)]
186199
pub enum Lit {
187200
I1(bool),
@@ -195,7 +208,9 @@ impl PartialEq for Lit {
195208
match (self, other) {
196209
(Lit::I1(a), Lit::I1(b)) => a == b,
197210
(Lit::I64(a), Lit::I64(b)) => a == b,
198-
(Lit::F64(a), Lit::F64(b)) => a.to_bits() == b.to_bits(),
211+
(Lit::F64(a), Lit::F64(b)) => {
212+
a.to_bits() == b.to_bits() || (a.is_nan() && b.is_nan())
213+
}
199214
(Lit::Str(a), Lit::Str(b)) => a == b,
200215
_ => false,
201216
}
@@ -463,30 +478,6 @@ impl Inst {
463478
}
464479
}
465480

466-
/// Values this instruction uses.
467-
pub fn uses(&self) -> Vec<Value> {
468-
match self {
469-
Inst::Const { .. }
470-
| Inst::Load { .. }
471-
| Inst::LoadOpt { .. }
472-
| Inst::Sload { .. }
473-
| Inst::SloadOpt { .. } => vec![],
474-
Inst::Not { a, .. }
475-
| Inst::Itof { a, .. }
476-
| Inst::Ftoi { a, .. }
477-
| Inst::Itos { a, .. }
478-
| Inst::Ftos { a, .. }
479-
| Inst::StoiOpt { a, .. }
480-
| Inst::StofOpt { a, .. } => vec![*a],
481-
Inst::Bin { a, b, .. } | Inst::Cmp { a, b, .. } | Inst::Sconcat { a, b, .. } => {
482-
vec![*a, *b]
483-
}
484-
Inst::Select { cond, a, b, .. } => vec![*cond, *a, *b],
485-
Inst::Store { val, .. } => vec![*val],
486-
Inst::StoreOpt { flag, val, .. } => vec![*flag, *val],
487-
Inst::Probe { keys, .. } => keys.clone(),
488-
}
489-
}
490481
}
491482

492483
impl Term {
@@ -507,17 +498,6 @@ impl Term {
507498
Term::Emit | Term::Skip | Term::Trap { .. } => vec![],
508499
}
509500
}
510-
511-
pub fn uses(&self) -> Vec<Value> {
512-
let mut out = Vec::new();
513-
if let Term::Brif { cond, .. } = self {
514-
out.push(*cond);
515-
}
516-
for (_, args) in self.successors() {
517-
out.extend_from_slice(args);
518-
}
519-
out
520-
}
521501
}
522502

523503
/// Builds programs with dense, definition-ordered value ids — the canonical

src/specializer/ir/print.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,8 +227,10 @@ fn col_name(p: &Program, out: bool, idx: u32) -> String {
227227
}
228228
}
229229

230-
/// Column and function names print bare when they are identifiers, quoted
231-
/// otherwise (SQL-derived output names like `COALESCE(a, b)` survive).
230+
/// Column names print bare when they are identifiers, quoted otherwise
231+
/// (SQL-derived output names like `COALESCE(a, b)` survive). Function names
232+
/// are NOT routed through this: the verifier requires them to be
233+
/// identifiers, so the printer writes them raw.
232234
fn ident_or_quoted(name: &str) -> String {
233235
if is_ident(name) {
234236
name.to_string()

0 commit comments

Comments
 (0)