diff --git "a/backlog/tasks/task-41 - Specializer-M-ir-imperative-IR-\342\200\224-grammar-types-verifier-text-format.md" "b/backlog/tasks/task-41 - Specializer-M-ir-imperative-IR-\342\200\224-grammar-types-verifier-text-format.md" index 4f6e840..66a27a6 100644 --- "a/backlog/tasks/task-41 - Specializer-M-ir-imperative-IR-\342\200\224-grammar-types-verifier-text-format.md" +++ "b/backlog/tasks/task-41 - Specializer-M-ir-imperative-IR-\342\200\224-grammar-types-verifier-text-format.md" @@ -1,9 +1,10 @@ --- id: TASK-41 title: 'Specializer M-ir: imperative IR — grammar, types, verifier, text format' -status: To Do +status: In Progress assignee: [] created_date: '2026-07-25 02:31' +updated_date: '2026-07-25 13:46' labels: [] milestone: m-7 dependencies: [] @@ -21,8 +22,24 @@ Define the specializer's imperative IR per §6 of docs/superpowers/specs/2026-07 ## Acceptance Criteria -- [ ] #1 Verifier rejects: non-SSA defs, type mismatches, any arithmetic on a T? value not routed through the null-lane ops, unresolvable StaticRef ids, allocating constructs -- [ ] #2 Text format round-trips: parse(print(ir)) == ir on every test program, including a property/fuzz round-trip test -- [ ] #3 Hand-written IR programs covering every instruction exist as test fixtures -- [ ] #4 mise gate-specializer green +- [x] #1 Verifier rejects: non-SSA defs, type mismatches, any arithmetic on a T? value not routed through the null-lane ops, unresolvable StaticRef ids, allocating constructs +- [x] #2 Text format round-trips: parse(print(ir)) == ir on every test program, including a property/fuzz round-trip test +- [x] #3 Hand-written IR programs covering every instruction exist as test fixtures +- [x] #4 mise gate-specializer green + +## Implementation Plan + + +1. Freeze IR design decisions (recorded in design doc §6 update): implicit row cursors (no idx params), strict block-param SSA (cross-block value uses forbidden — no dominance analysis needed), acyclic CFG for v0, terminators emit/skip/trap/jump/brif, store-completeness dataflow at emit. +2. Implement src/specializer/ir/: core types + instructions (mod.rs), verifier (verify.rs), printer (print.rs), parser (parse.rs), shared fixtures (fixtures.rs). +3. Tests: per-instruction fixture programs (parse+verify+round-trip), negative verifier tests per rule, negative parser tests, deterministic seeded fuzz round-trip (hand-rolled xorshift generator, no new deps). +4. Adversarial workflow: fan-out attackers on verifier soundness + round-trip edge cases (float/string literals), plus design-conformance and simplification reviewers; fix confirmed findings. +5. Gate green; stacked PR onto claude/duckdb-native-interpreter-36d016. + + +## Implementation Notes + + +Implemented in src/specializer/ir/ (mod, verify, print, parse, fixtures, gen, tests). Key decisions vs the original sketch (design doc §6 updated): implicit row cursor, emit/skip/trap terminators carrying the store contract, strict block-param SSA (no dominance analysis), acyclic v0 CFG, presentation-only names (canonical %vN/bN). Adversarial pass (6 agents) found and I fixed: recursive-DFS process abort on deep CFGs (now iterative, 50k blocks OK), empty map static signatures and non-ident fn names verifying but printing unparseably, non-canonical NaN payloads breaking bitwise round-trip equality (NaN-class equality now), unreachable-island starvation masking join store errors (topo indegrees over reachable sources only), doc/grammar drift, dead uses() code. 45 cargo tests; every verifier rule and parser guard has a rejecting test; 300-seed fuzz round-trip. + diff --git a/docs/superpowers/specs/2026-07-25-sql-specializer-design.md b/docs/superpowers/specs/2026-07-25-sql-specializer-design.md index 4630f41..4e639af 100644 --- a/docs/superpowers/specs/2026-07-25-sql-specializer-design.md +++ b/docs/superpowers/specs/2026-07-25-sql-specializer-design.md @@ -166,40 +166,67 @@ enum StaticStruct { } ``` -## 6. Imperative IR sketch +## 6. Imperative IR (as implemented — M-ir, `src/specializer/ir/`) SSA, typed, no allocation vocabulary, explicit null lane. Text format is the -diagnostic surface and must round-trip (`parse(print(ir)) == ir`). +diagnostic surface and round-trips: `parse(print(p)) == p`. The module docs of +`src/specializer/ir/mod.rs` are the normative spec; highlights: ``` -;; SELECT age / s.mean_age AS z FROM __THIS__ t LEFT JOIN stats s ON t.seg = s.seg -;; after BTA: stats collapsed into staticref @0 (perfect hash: seg -> mean_age) +static @0: map(str) -> (f64) fn run(in: batch{age: i64?, seg: str}, out: batch{z: f64?}) { -entry(row: idx): - %age.f, %age.v = load.opt in.age, row ; (i1 flag, i64 payload) - %seg = load in.seg, row ; NOT NULL lane: no flag - %hit.f, %m = probe.opt @0, %seg ; miss -> flag=0 (LEFT JOIN) - %num = cast f64, %age.v - %q = fdiv %num, %m - %z.f = and %age.f, %hit.f ; null iff either input null - store.opt out.z, row, %z.f, %q - next row +entry: + %age_f, %age_v = load.opt in.age # (i1 flag, i64 payload); row cursor implicit + %seg = load in.seg # NOT NULL lane: no flag + %hit, %mean = probe @0, %seg # miss -> hit=false (LEFT JOIN) + %num = itof %age_v + %q = fdiv %num, %mean + %zf = and %age_f, %hit # null iff either input null + store.opt out.z, %zf, %q + emit } ``` -Verifier rules (initial set): - -1. SSA: single def, defs dominate uses. -2. Type check every op; `load.opt`/`probe.opt`/`store.opt` are the only ops that - touch flags; a `T?` value cannot flow into an arithmetic op — the verifier - rejects it (this is the "3VL mistakes are statically impossible" property). -3. `StaticRef` ids must resolve against the plan's static-structure table, with - matching key/value types. -4. No op allocates; `scratch` is reachable only from varlen `store` ops. -5. `out` column set and row count semantics must match the declared plan shape - (`|out| = |in|` for pure projection; filter introduces the one allowed - divergence and must declare it). +Decisions made at implementation (deviations from the original sketch, all in +the direction of a smaller, more verifiable core): + +- **Implicit row cursor** — no `idx` values, no `row` operand; nothing in v0 + needs random row access, and it removes a whole type from the verifier. +- **Terminators carry the row protocol**: `emit` (row complete; every out + column stored exactly once on the path), `skip` (filter drop; path must + store nothing), `trap "msg"` (runtime error — CAST failures, div guards), + plus `jump`/`brif`. `|out| == |in|` iff `skip` is unreachable — statically + known, per the "filter must declare divergence" requirement. +- **Strict block-param SSA**: values cross blocks only as branch args to + block params, so the verifier needs no dominance analysis. CFG acyclic in + v0 (lift when a lowering pattern needs loops, e.g. QuickScorer). +- **Nullability is structural, not checked**: SSA values are always bare + scalars; only `load.opt`/`store.opt`/`probe`/`sload.opt` touch flags. A + `T?` cannot reach arithmetic because the type system cannot express it. +- Names in text are presentation-only; printing canonicalizes to `%vN`/`bN`. + NaN payloads canonicalize to one `nan` token (literal equality is bitwise, + so `-0.0` and NaN round-trip). + +Verifier rules (each has a rejecting test in `ir/tests.rs`): structure (entry +has no params, unique column names, no branch to entry, identifier function +name, non-empty map signatures — a verified program must print to parseable +text), SSA (single def, same-block visibility), per-op operand types incl. +mandatory/forbidden `.opt` pairing against column/static nullability, static +resolution + kind/arity/key types, CFG reachability + acyclicity + branch-arg +typing (iterative DFS — deep legal CFGs must not overflow the native stack), +and the store-completeness dataflow (never twice on any path, exactly-once +per column at `emit`, zero at `skip`, agreement at joins, computed over the +reachable subgraph so an unreachable island can't mask a join's errors). + +An adversarial pass (4 attack lenses + design-conformance + simplification +reviews, 2026-07-25) ran against the initial implementation; every confirmed +finding is pinned by a regression test in `ir/tests.rs`. + +Deferred to M-interp, pinned there against the DuckDB oracle: `ftoi.round` +tie behavior and `fcmp` NaN ordering. `idiv`/`irem` trap on zero/overflow; +SQL-level NULL-on-zero (or error) semantics are a lowering decision built +from `brif` + `trap`. ## 7. Backends diff --git a/src/lib.rs b/src/lib.rs index 3aac431..e7fab14 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,6 +17,7 @@ mod duckdb; mod error; mod lookup; mod schema; +pub mod specializer; mod types; mod value; diff --git a/src/specializer/ir/fixtures.rs b/src/specializer/ir/fixtures.rs new file mode 100644 index 0000000..69ad1dc --- /dev/null +++ b/src/specializer/ir/fixtures.rs @@ -0,0 +1,147 @@ +//! Hand-written IR programs, one habitat per instruction. Written in the +//! human-friendly form (named values, named labels) that the parser accepts +//! and the printer canonicalizes — the round-trip tests exercise exactly that +//! path. M-interp reuses these as its first executable programs. + +/// The design-doc example: nullable arithmetic against a probed static. +/// Covers: load, load.opt, probe, itof, fdiv, and, store.opt, emit. +pub const PROJECTION: &str = r#" +static @0: map(str) -> (f64) + +fn run(in: batch{age: i64?, seg: str}, out: batch{z: f64?}) { +entry: + %age_f, %age_v = load.opt in.age + %seg = load in.seg + %hit, %mean = probe @0, %seg + %num = itof %age_v + %q = fdiv %num, %mean + %zf = and %age_f, %hit + store.opt out.z, %zf, %q + emit +} +"#; + +/// A WHERE clause: the one construct allowed to change |out|. +/// Covers: const.f64, fcmp, brif (no args), skip, store. +pub const FILTER: &str = r#" +fn keep_positive(in: batch{score: f64}, out: batch{score: f64}) { +entry: + %s = load in.score + %zero = const.f64 0.0 + %pos = fcmp.gt %s, %zero + brif %pos, keep, drop +keep: + %s2 = load in.score + store out.score, %s2 + emit +drop: + skip +} +"#; + +/// A CASE diamond joining through a block param, plus the i1 algebra. +/// Covers: const.i64, const.i1, const.str, icmp, jump with args, block +/// params, xor, not, select, store of i1. +pub const CASE_DIAMOND: &str = r#" +fn bucket(in: batch{age: i64}, out: batch{label: str, flag: i1}) { +entry: + %age = load in.age + %lim = const.i64 30 + %old = icmp.ge %age, %lim + brif %old, old_b, young_b +old_b: + %a = const.str "old" + jump join(%a) +young_b: + %b = const.str "young" + jump join(%b) +join(%label: str): + store out.label, %label + %t = const.i1 true + %f = const.i1 false + %x = xor %t, %f + %n = not %x + %pick = select %n, %t, %f + store out.flag, %pick + emit +} +"#; + +/// The cast family and scalar statics; a failed parse traps (SQL CAST +/// semantics — TRY_CAST lowers to a select instead). +/// Covers: stoi.opt, sload, sload.opt, ftoi.round, ftoi.trunc, isub, ftos, +/// sconcat, scmp, trap. +pub const CASTS: &str = r#" +static @0: scalar +static @1: scalar + +fn casts(in: batch{s: str}, out: batch{n: i64, msg: str}) { +entry: + %raw = load in.s + %ok, %parsed = stoi.opt %raw + brif %ok, good, bad +good: + %base = sload @0 + %r1 = ftoi.round %base + %t1 = ftoi.trunc %base + %d = isub %r1, %t1 + %pf, %pv = sload.opt @1 + %n2 = select %pf, %pv, %d + store out.n, %n2 + %ftxt = ftos %base + %sep = const.str ":" + %msg1 = sconcat %ftxt, %sep + %same = scmp.eq %msg1, %sep + %sel = select %same, %msg1, %sep + store out.msg, %sel + emit +bad: + trap "cast to i64 failed" +} +"#; + +/// Everything else: the full integer/float binary set, a compound-key probe +/// with multiple value columns, and the remaining casts. +/// Covers: iadd, imul, idiv, irem, fadd, fsub, fmul, or, icmp.ne, fcmp.lt, +/// itos, stof.opt, multi-key/multi-value probe, quoted column names. +pub const KITCHEN: &str = r#" +static @0: map(i64, str) -> (f64, i64) + +fn kitchen(in: batch{a: i64, b: i64?, k: str, x: f64}, out: batch{"r?": f64?, s: str}) { +entry: + %a = load in.a + %bf, %bv = load.opt in.b + %sum = iadd %a, %bv + %prod = imul %sum, %a + %quot = idiv %prod, %a + %rem = irem %quot, %a + %k = load in.k + %h, %vf, %vi = probe @0, %rem, %k + %x = load in.x + %fa = fadd %x, %vf + %fs = fsub %fa, %vf + %fm = fmul %fs, %x + %lt = fcmp.lt %fm, %x + %ne = icmp.ne %vi, %a + %both = or %lt, %ne + %rf = and %both, %bf + %rv = select %lt, %fm, %x + store.opt out."r?", %rf, %rv + %istr = itos %vi + %ff, %fv = stof.opt %istr + %num2 = select %ff, %fv, %fm + %ns = ftos %num2 + store out.s, %ns + emit +} +"#; + +pub fn all() -> Vec<(&'static str, &'static str)> { + vec![ + ("projection", PROJECTION), + ("filter", FILTER), + ("case_diamond", CASE_DIAMOND), + ("casts", CASTS), + ("kitchen", KITCHEN), + ] +} diff --git a/src/specializer/ir/gen.rs b/src/specializer/ir/gen.rs new file mode 100644 index 0000000..8f409f1 --- /dev/null +++ b/src/specializer/ir/gen.rs @@ -0,0 +1,430 @@ +//! Deterministic random-program generator for round-trip and (later) +//! differential fuzzing. Every generated program is verifier-valid by +//! construction and built with dense definition-ordered value ids, so +//! `parse(print(p)) == p` must hold exactly — any divergence is a bug in the +//! printer, the parser, or this generator, and all three are worth knowing. +//! +//! Hand-rolled xorshift instead of a proptest dependency: round-trip failures +//! shrink trivially (the seed pins the program), and the generator doubles as +//! the input source for M-interp's interpreter-vs-codegen fuzzing. + +use super::{ + BinOp, Block, BlockId, Builder, Col, ColTy, CmpPred, Inst, Lit, Program, RoundMode, StaticTy, + Term, Ty, Value, +}; + +pub struct Rng(u64); + +impl Rng { + pub fn new(seed: u64) -> Rng { + // xorshift64* must not be seeded with 0. + Rng(seed.wrapping_mul(2685821657736338717).max(1)) + } + + pub fn next(&mut self) -> u64 { + let mut x = self.0; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.0 = x; + x.wrapping_mul(2685821657736338717) + } + + pub fn below(&mut self, n: u64) -> u64 { + self.next() % n + } + + pub fn chance(&mut self, percent: u64) -> bool { + self.below(100) < percent + } +} + +const TYS: [Ty; 4] = [Ty::I1, Ty::I64, Ty::F64, Ty::Str]; + +/// Column-name pool: mixes plain identifiers with names that force the +/// quoted form (SQL-derived output names contain arbitrary characters). +const NASTY_NAMES: [&str; 4] = ["COALESCE(a, b)", "weird name", "a\"quote", "tab\there"]; + +fn rand_ty(rng: &mut Rng) -> Ty { + TYS[rng.below(4) as usize] +} + +fn rand_col_ty(rng: &mut Rng) -> ColTy { + ColTy { + ty: rand_ty(rng), + nullable: rng.chance(40), + } +} + +fn rand_lit(rng: &mut Rng, ty: Ty) -> Lit { + match ty { + Ty::I1 => Lit::I1(rng.chance(50)), + Ty::I64 => Lit::I64(match rng.below(6) { + 0 => 0, + 1 => -1, + 2 => i64::MAX, + 3 => i64::MIN, + _ => rng.next() as i64 % 1_000_000, + }), + Ty::F64 => Lit::F64(match rng.below(9) { + 0 => 0.0, + 1 => -0.0, + 2 => f64::NAN, + 3 => f64::INFINITY, + 4 => f64::NEG_INFINITY, + 5 => 1e300, + 6 => 1e-5, + 7 => 0.1, + _ => (rng.next() as i64 % 1000) as f64 / 8.0, + }), + Ty::Str => Lit::Str( + match rng.below(6) { + 0 => "", + 1 => "plain", + 2 => "quote\"backslash\\", + 3 => "line\nbreak\ttab", + 4 => "unicode é ✓", + _ => "x", + } + .to_string(), + ), + } +} + +/// Per-block generation state: the values in scope, by type. +struct Scope { + avail: Vec<(Value, Ty)>, +} + +impl Scope { + fn new() -> Scope { + Scope { avail: Vec::new() } + } + + fn add(&mut self, v: Value, ty: Ty) { + self.avail.push((v, ty)); + } + + fn pick(&self, rng: &mut Rng, ty: Ty) -> Option { + let of_ty: Vec = self + .avail + .iter() + .filter(|(_, t)| *t == ty) + .map(|(v, _)| *v) + .collect(); + if of_ty.is_empty() { + None + } else { + Some(of_ty[rng.below(of_ty.len() as u64) as usize]) + } + } +} + +/// Get a value of `ty`, minting a const when none is in scope (or sometimes +/// just because — consts are where the literal edge cases enter). +fn ensure(rng: &mut Rng, b: &mut Builder, scope: &mut Scope, insts: &mut Vec, ty: Ty) -> Value { + if rng.chance(70) { + if let Some(v) = scope.pick(rng, ty) { + return v; + } + } + let dst = b.fresh(); + insts.push(Inst::Const { dst, lit: rand_lit(rng, ty) }); + scope.add(dst, ty); + dst +} + +/// Loads for every input column plus reads of every static — the block's +/// raw material. +fn load_all( + rng: &mut Rng, + b: &mut Builder, + scope: &mut Scope, + insts: &mut Vec, + p_in: &[Col], + statics: &[StaticTy], +) { + for (ci, c) in p_in.iter().enumerate() { + if c.ty.nullable { + let flag = b.fresh(); + let dst = b.fresh(); + insts.push(Inst::LoadOpt { flag, dst, col: ci as u32 }); + scope.add(flag, Ty::I1); + scope.add(dst, c.ty.ty); + } else { + let dst = b.fresh(); + insts.push(Inst::Load { dst, col: ci as u32 }); + scope.add(dst, c.ty.ty); + } + } + for (si, st) in statics.iter().enumerate() { + match st { + StaticTy::Scalar(ct) if ct.nullable => { + let flag = b.fresh(); + let dst = b.fresh(); + insts.push(Inst::SloadOpt { static_id: si as u32, flag, dst }); + scope.add(flag, Ty::I1); + scope.add(dst, ct.ty); + } + StaticTy::Scalar(ct) => { + let dst = b.fresh(); + insts.push(Inst::Sload { static_id: si as u32, dst }); + scope.add(dst, ct.ty); + } + StaticTy::Map { keys, values } => { + let key_vals: Vec = keys + .iter() + .map(|kt| ensure(rng, b, scope, insts, *kt)) + .collect(); + let hit = b.fresh(); + scope.add(hit, Ty::I1); + let mut dsts = Vec::with_capacity(values.len()); + for vt in values { + let d = b.fresh(); + scope.add(d, *vt); + dsts.push(d); + } + insts.push(Inst::Probe { static_id: si as u32, hit, dsts, keys: key_vals }); + } + } + } +} + +/// A few random compute instructions over whatever is in scope. +fn compute(rng: &mut Rng, b: &mut Builder, scope: &mut Scope, insts: &mut Vec) { + let n = rng.below(7); + for _ in 0..n { + match rng.below(8) { + 0 => { + let ops = [ + BinOp::Iadd, + BinOp::Isub, + BinOp::Imul, + BinOp::Fadd, + BinOp::Fsub, + BinOp::Fmul, + BinOp::Fdiv, + BinOp::And, + BinOp::Or, + BinOp::Xor, + ]; + // idiv/irem excluded: they trap on zero, and generated + // programs must stay executable for M-interp fuzzing. + let op = ops[rng.below(ops.len() as u64) as usize]; + let (ot, rt) = op.sig(); + let a = ensure(rng, b, scope, insts, ot); + let rhs = ensure(rng, b, scope, insts, ot); + let dst = b.fresh(); + insts.push(Inst::Bin { op, dst, a, b: rhs }); + scope.add(dst, rt); + } + 1 => { + let ty = [Ty::I64, Ty::F64, Ty::Str][rng.below(3) as usize]; + let preds = [ + CmpPred::Eq, + CmpPred::Ne, + CmpPred::Lt, + CmpPred::Le, + CmpPred::Gt, + CmpPred::Ge, + ]; + let pred = preds[rng.below(6) as usize]; + let a = ensure(rng, b, scope, insts, ty); + let rhs = ensure(rng, b, scope, insts, ty); + let dst = b.fresh(); + insts.push(Inst::Cmp { pred, ty, dst, a, b: rhs }); + scope.add(dst, Ty::I1); + } + 2 => { + let a = ensure(rng, b, scope, insts, Ty::I1); + let dst = b.fresh(); + insts.push(Inst::Not { dst, a }); + scope.add(dst, Ty::I1); + } + 3 => { + let ty = rand_ty(rng); + let cond = ensure(rng, b, scope, insts, Ty::I1); + let a = ensure(rng, b, scope, insts, ty); + let rhs = ensure(rng, b, scope, insts, ty); + let dst = b.fresh(); + insts.push(Inst::Select { dst, cond, a, b: rhs }); + scope.add(dst, ty); + } + 4 => { + let a = ensure(rng, b, scope, insts, Ty::I64); + let dst = b.fresh(); + insts.push(Inst::Itof { dst, a }); + scope.add(dst, Ty::F64); + } + 5 => { + let mode = if rng.chance(50) { RoundMode::Trunc } else { RoundMode::Round }; + let a = ensure(rng, b, scope, insts, Ty::F64); + let dst = b.fresh(); + insts.push(Inst::Ftoi { mode, dst, a }); + scope.add(dst, Ty::I64); + } + 6 => { + let a = ensure(rng, b, scope, insts, Ty::Str); + let rhs = ensure(rng, b, scope, insts, Ty::Str); + let dst = b.fresh(); + insts.push(Inst::Sconcat { dst, a, b: rhs }); + scope.add(dst, Ty::Str); + } + _ => { + let (from, mk): (Ty, fn(Value, Value) -> Inst) = if rng.chance(50) { + (Ty::I64, |dst, a| Inst::Itos { dst, a }) + } else { + (Ty::F64, |dst, a| Inst::Ftos { dst, a }) + }; + let a = ensure(rng, b, scope, insts, from); + let dst = b.fresh(); + insts.push(mk(dst, a)); + scope.add(dst, Ty::Str); + } + } + } +} + +/// One store per out column, then the given terminator's stores contract. +fn stores( + rng: &mut Rng, + b: &mut Builder, + scope: &mut Scope, + insts: &mut Vec, + out_cols: &[Col], +) { + for (ci, c) in out_cols.iter().enumerate() { + let val = ensure(rng, b, scope, insts, c.ty.ty); + if c.ty.nullable { + let flag = ensure(rng, b, scope, insts, Ty::I1); + insts.push(Inst::StoreOpt { col: ci as u32, flag, val }); + } else { + insts.push(Inst::Store { col: ci as u32, val }); + } + } +} + +pub fn gen_program(seed: u64) -> Program { + let mut rng = Rng::new(seed); + let mut b = Builder::new(); + + let statics: Vec = (0..rng.below(3)) + .map(|_| { + if rng.chance(50) { + StaticTy::Scalar(rand_col_ty(&mut rng)) + } else { + StaticTy::Map { + keys: (0..1 + rng.below(2)).map(|_| rand_ty(&mut rng)).collect(), + values: (0..1 + rng.below(2)).map(|_| rand_ty(&mut rng)).collect(), + } + } + }) + .collect(); + + let name_of = |i: usize, prefix: &str, rng: &mut Rng| -> String { + if rng.chance(20) { + format!("{} {}", NASTY_NAMES[rng.below(4) as usize], i) + } else { + format!("{prefix}{i}") + } + }; + let in_cols: Vec = (0..1 + rng.below(3) as usize) + .map(|i| Col { name: name_of(i, "c", &mut rng), ty: rand_col_ty(&mut rng) }) + .collect(); + let out_cols: Vec = (0..1 + rng.below(2) as usize) + .map(|i| Col { name: name_of(i, "o", &mut rng), ty: rand_col_ty(&mut rng) }) + .collect(); + + let shape = rng.below(3); + let mut blocks = Vec::new(); + match shape { + // Straight line: load, compute, store, emit. + 0 => { + let mut scope = Scope::new(); + let mut insts = Vec::new(); + load_all(&mut rng, &mut b, &mut scope, &mut insts, &in_cols, &statics); + compute(&mut rng, &mut b, &mut scope, &mut insts); + stores(&mut rng, &mut b, &mut scope, &mut insts, &out_cols); + blocks.push(Block { params: vec![], insts, term: Term::Emit }); + } + // Filter: entry branches to a storing block or a skip/trap block. + 1 => { + let mut scope = Scope::new(); + let mut insts = Vec::new(); + load_all(&mut rng, &mut b, &mut scope, &mut insts, &in_cols, &statics); + compute(&mut rng, &mut b, &mut scope, &mut insts); + let cond = ensure(&mut rng, &mut b, &mut scope, &mut insts, Ty::I1); + blocks.push(Block { + params: vec![], + insts, + term: Term::Brif { + cond, + then_to: BlockId(1), + then_args: vec![], + else_to: BlockId(2), + else_args: vec![], + }, + }); + let mut keep_scope = Scope::new(); + let mut keep_insts = Vec::new(); + load_all(&mut rng, &mut b, &mut keep_scope, &mut keep_insts, &in_cols, &statics); + stores(&mut rng, &mut b, &mut keep_scope, &mut keep_insts, &out_cols); + blocks.push(Block { params: vec![], insts: keep_insts, term: Term::Emit }); + let drop_term = if rng.chance(80) { + Term::Skip + } else { + Term::Trap { msg: "generated trap".to_string() } + }; + blocks.push(Block { params: vec![], insts: vec![], term: drop_term }); + } + // Diamond: both arms feed a join param that lands in a store. + _ => { + let join_ty = rand_ty(&mut rng); + let mut scope = Scope::new(); + let mut insts = Vec::new(); + load_all(&mut rng, &mut b, &mut scope, &mut insts, &in_cols, &statics); + let cond = ensure(&mut rng, &mut b, &mut scope, &mut insts, Ty::I1); + blocks.push(Block { + params: vec![], + insts, + term: Term::Brif { + cond, + then_to: BlockId(1), + then_args: vec![], + else_to: BlockId(2), + else_args: vec![], + }, + }); + for _ in 0..2 { + let mut arm_scope = Scope::new(); + let mut arm_insts = Vec::new(); + let v = ensure(&mut rng, &mut b, &mut arm_scope, &mut arm_insts, join_ty); + blocks.push(Block { + params: vec![], + insts: arm_insts, + term: Term::Jump { to: BlockId(3), args: vec![v] }, + }); + } + let param = b.fresh(); + let mut join_scope = Scope::new(); + join_scope.add(param, join_ty); + let mut join_insts = Vec::new(); + load_all(&mut rng, &mut b, &mut join_scope, &mut join_insts, &in_cols, &statics); + compute(&mut rng, &mut b, &mut join_scope, &mut join_insts); + stores(&mut rng, &mut b, &mut join_scope, &mut join_insts, &out_cols); + blocks.push(Block { + params: vec![(param, join_ty)], + insts: join_insts, + term: Term::Emit, + }); + } + } + + Program { + statics, + name: "fuzzed".to_string(), + in_cols, + out_cols, + blocks, + } +} diff --git a/src/specializer/ir/mod.rs b/src/specializer/ir/mod.rs new file mode 100644 index 0000000..2dd8dd4 --- /dev/null +++ b/src/specializer/ir/mod.rs @@ -0,0 +1,521 @@ +//! The specializer's imperative IR: SSA over typed scalars, explicit null +//! lane, no allocation vocabulary. This module is the spec; `verify`, `print` +//! and `parse` implement its three mandatory properties (airtight boundary, +//! canonical text, round-trip). +//! +//! # Execution model +//! +//! A [`Program`] describes one specialized function `run(in, out, scratch)` +//! executed once **per input row** (the produce/consume row loop is implicit — +//! there is no row-index value in the IR). The function reads the current +//! input row via `load`/`load.opt`, computes, writes the current output row +//! via `store`/`store.opt`, and finishes with a terminator: +//! +//! * `emit` — the output row is complete (every out column stored exactly +//! once on this path); advance both cursors. +//! * `skip` — no output row for this input row (filters); nothing may have +//! been stored on this path. +//! * `trap "msg"` — abort the whole call with a runtime error (CAST failures, +//! division guards — whatever the lowered dialect defines as an error). +//! +//! No path may store the same column twice, whatever its terminator — a +//! double store is always a lowering bug, so the verifier rejects it even on +//! paths that end in `trap`. +//! +//! `|out| == |in|` therefore holds exactly when `skip` is unreachable, which +//! is statically known — the design doc's "filter is the one allowed +//! divergence and must declare it". +//! +//! # The null lane +//! +//! SSA values are always bare scalars — there is no nullable SSA type, so a +//! nullable value cannot reach an arithmetic instruction *by construction* +//! (the type system has no way to express it; this is how three-valued-logic +//! bugs are made unrepresentable rather than merely checked). Nullability +//! exists only at the edges: a nullable column or static is accessed through +//! the `.opt` instruction forms, which split it into an `i1` validity flag +//! plus a payload of the bare type; NULL logic is then ordinary `i1` algebra +//! (`and`, `or`, `select`), and `store.opt` reassembles flag + payload into a +//! nullable output column. On a false flag the payload is the type's default +//! value (0 / 0.0 / "" / false) — defined, deterministic, never poison. +//! +//! # SSA discipline +//! +//! Strict block-param form: a value may be used only in the block that +//! defines it (after its definition) or received as a block parameter. +//! Cross-block direct uses are illegal — everything flowing between blocks +//! rides on branch arguments. This removes the need for dominance analysis in +//! the verifier; the CFG must also be acyclic in v0 (no operator we lower +//! needs a loop yet; lift when one does, e.g. QuickScorer). +//! +//! # Statics +//! +//! Prepare-time structures are referenced through opaque `@N` handles typed +//! by the program header — `scalar`/`scalar` read with +//! `sload`/`sload.opt`, and `map(K..) -> (V..)` probed with `probe` (returns +//! a hit flag plus one value per declared column; a LEFT-join miss is just +//! `hit = false`). How a map is materialized (perfect hash, dense array, +//! inline chain) is a backend decision invisible here. Nullable static map +//! columns are flattened to an `i1` column + payload column at prepare time, +//! so map key/value types are bare [`Ty`]. +//! +//! # Allocation +//! +//! No instruction allocates on a heap. The only instructions that produce new +//! varlen data — `itos`, `ftos`, `sconcat` — write into the caller's bump +//! arena (`scratch` in the ABI), reset per call. Everything else moves +//! scalars between registers. +//! +//! # Text format +//! +//! Canonical, round-trippable; `parse(print(p)) == p` for every program whose +//! value ids are dense and in definition order (the parser and [`gen`] both +//! produce that form, so the property is closed under round-trip). A program +//! with sparse or out-of-order ids still verifies and still prints; the +//! round-trip then performs a bijective renumbering into canonical form, so +//! structural equality is only meaningful between canonical programs. Names +//! in the text are presentation only — they are not stored in the IR; +//! printing uses canonical `%vN` / `bN` names. +//! +//! ```text +//! program := static* func +//! comment := "#" ... end-of-line // allowed anywhere whitespace is +//! static := "static" "@" INT ":" static_ty +//! static_ty := "scalar" "<" col_ty ">" +//! | "map" "(" ty ("," ty)* ")" "->" "(" ty ("," ty)* ")" +//! func := "fn" IDENT "(" "in" ":" batch "," "out" ":" batch ")" "{" block+ "}" +//! batch := "batch" "{" [col ("," col)*] "}" +//! col := (IDENT | STRING) ":" col_ty // STRING for non-ident names +//! col_ty := ty ["?"] +//! ty := "i1" | "i64" | "f64" | "str" +//! block := IDENT ["(" VALUE ":" ty ("," VALUE ":" ty)* ")"] ":" inst* term +//! inst := [VALUE ("," VALUE)* "="] OPCODE operands +//! // only store/store.opt omit the dests; everything else +//! // defines at least one value +//! term := "jump" target +//! | "brif" VALUE "," target "," target +//! | "emit" | "skip" | "trap" STRING +//! target := IDENT ["(" VALUE ("," VALUE)* ")"] +//! VALUE := "%" NAME // NAME: any run of [A-Za-z0-9_]; +//! // the printer only emits %vN +//! ``` +//! +//! Instruction surface (`%d` result, `%f` an `i1` flag result): +//! +//! ```text +//! %d = const.i1 true|false %d = const.i64 INT +//! %d = const.f64 FLOAT %d = const.str STRING +//! %d = iadd|isub|imul|idiv|irem %a, %b // i64; idiv/irem trap on 0 or overflow +//! %d = fadd|fsub|fmul|fdiv %a, %b // f64, IEEE (inf/nan flow, no trap) +//! %d = and|or|xor %a, %b // i1 +//! %d = not %a // i1 +//! %d = icmp.P|fcmp.P|scmp.P %a, %b // P in eq ne lt le gt ge; -> i1 +//! %d = select %c, %a, %b // %c: i1; %a, %b same type +//! %d = itof %a // i64 -> f64 +//! %d = ftoi.trunc|ftoi.round %a // f64 -> i64; traps out of range +//! %d = itos %a | ftos %a // -> str (arena) +//! %f, %d = stoi.opt %a | stof.opt %a // parse; %f=false on failure +//! %d = sconcat %a, %b // str (arena) +//! %d = load in.COL // COL not nullable +//! %f, %d = load.opt in.COL // COL nullable +//! store out.COL, %v // COL not nullable +//! store.opt out.COL, %f, %v // COL nullable +//! %hit, %v1, .. = probe @N, %k1, .. // map static +//! %d = sload @N // scalar static +//! %f, %d = sload.opt @N // scalar static +//! ``` +//! +//! Numeric-semantics pins deferred to M-interp (settled against the DuckDB +//! oracle there): `ftoi.round` tie behavior, `fcmp` NaN ordering. The IR +//! names the operations; the interpreter pins their edge cases with tests. + +pub mod fixtures; +pub mod gen; +pub mod parse; +pub mod print; +pub mod verify; + +#[cfg(test)] +mod tests; + +/// Bare scalar type of an SSA value. There is deliberately no nullable +/// variant — see the module docs. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] +pub enum Ty { + I1, + I64, + F64, + Str, +} + +impl Ty { + pub fn name(self) -> &'static str { + match self { + Ty::I1 => "i1", + Ty::I64 => "i64", + Ty::F64 => "f64", + Ty::Str => "str", + } + } +} + +/// Column type: a bare type plus nullability. Only batch columns and scalar +/// statics carry nullability; SSA values never do. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct ColTy { + pub ty: Ty, + pub nullable: bool, +} + +/// A named batch column. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Col { + pub name: String, + pub ty: ColTy, +} + +/// Type of a prepare-time static structure, addressed as `@N`. +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum StaticTy { + Scalar(ColTy), + Map { keys: Vec, values: Vec }, +} + +/// SSA value id. Presentation names are not stored; the printer derives +/// `%vN` from the id. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)] +pub struct Value(pub u32); + +/// Block id = index into `Program::blocks`. Block 0 is the entry. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] +pub struct BlockId(pub u32); + +/// Literal for `const.*`. `F64` equality is bitwise — so `-0.0 != 0.0` +/// survives a round-trip — EXCEPT that all NaNs compare equal: the text form +/// canonicalizes every NaN payload to the one `nan` token, and equality must +/// match what the text format can distinguish or `parse(print(p)) == p` +/// would fail for non-canonical payloads (found by adversarial fuzzing). +#[derive(Clone, Debug)] +pub enum Lit { + I1(bool), + I64(i64), + F64(f64), + Str(String), +} + +impl PartialEq for Lit { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Lit::I1(a), Lit::I1(b)) => a == b, + (Lit::I64(a), Lit::I64(b)) => a == b, + (Lit::F64(a), Lit::F64(b)) => { + a.to_bits() == b.to_bits() || (a.is_nan() && b.is_nan()) + } + (Lit::Str(a), Lit::Str(b)) => a == b, + _ => false, + } + } +} +impl Eq for Lit {} + +impl Lit { + pub fn ty(&self) -> Ty { + match self { + Lit::I1(_) => Ty::I1, + Lit::I64(_) => Ty::I64, + Lit::F64(_) => Ty::F64, + Lit::Str(_) => Ty::Str, + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum BinOp { + Iadd, + Isub, + Imul, + Idiv, + Irem, + Fadd, + Fsub, + Fmul, + Fdiv, + And, + Or, + Xor, +} + +impl BinOp { + /// (operand type, result type). Uniform for all v0 binary ops. + pub fn sig(self) -> (Ty, Ty) { + match self { + BinOp::Iadd | BinOp::Isub | BinOp::Imul | BinOp::Idiv | BinOp::Irem => { + (Ty::I64, Ty::I64) + } + BinOp::Fadd | BinOp::Fsub | BinOp::Fmul | BinOp::Fdiv => (Ty::F64, Ty::F64), + BinOp::And | BinOp::Or | BinOp::Xor => (Ty::I1, Ty::I1), + } + } + + pub fn name(self) -> &'static str { + match self { + BinOp::Iadd => "iadd", + BinOp::Isub => "isub", + BinOp::Imul => "imul", + BinOp::Idiv => "idiv", + BinOp::Irem => "irem", + BinOp::Fadd => "fadd", + BinOp::Fsub => "fsub", + BinOp::Fmul => "fmul", + BinOp::Fdiv => "fdiv", + BinOp::And => "and", + BinOp::Or => "or", + BinOp::Xor => "xor", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum CmpPred { + Eq, + Ne, + Lt, + Le, + Gt, + Ge, +} + +impl CmpPred { + pub fn name(self) -> &'static str { + match self { + CmpPred::Eq => "eq", + CmpPred::Ne => "ne", + CmpPred::Lt => "lt", + CmpPred::Le => "le", + CmpPred::Gt => "gt", + CmpPred::Ge => "ge", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum RoundMode { + Trunc, + Round, +} + +#[derive(Clone, PartialEq, Debug)] +pub enum Inst { + Const { + dst: Value, + lit: Lit, + }, + Bin { + op: BinOp, + dst: Value, + a: Value, + b: Value, + }, + /// `icmp.P` / `fcmp.P` / `scmp.P` — `ty` is the operand type + /// (I64/F64/Str), result is I1. + Cmp { + pred: CmpPred, + ty: Ty, + dst: Value, + a: Value, + b: Value, + }, + Not { + dst: Value, + a: Value, + }, + /// Branchless pick; `a`/`b` any one type, `cond` I1. + Select { + dst: Value, + cond: Value, + a: Value, + b: Value, + }, + Itof { + dst: Value, + a: Value, + }, + Ftoi { + mode: RoundMode, + dst: Value, + a: Value, + }, + Itos { + dst: Value, + a: Value, + }, + Ftos { + dst: Value, + a: Value, + }, + StoiOpt { + flag: Value, + dst: Value, + a: Value, + }, + StofOpt { + flag: Value, + dst: Value, + a: Value, + }, + Sconcat { + dst: Value, + a: Value, + b: Value, + }, + /// Read a NOT NULL input column at the current row. + Load { + dst: Value, + col: u32, + }, + /// Read a nullable input column: validity flag + payload. + LoadOpt { + flag: Value, + dst: Value, + col: u32, + }, + /// Write a NOT NULL output column at the current row. + Store { + col: u32, + val: Value, + }, + /// Write a nullable output column: flag=false stores NULL. + StoreOpt { + col: u32, + flag: Value, + val: Value, + }, + /// Probe a map static: hit flag + one value per declared value column. + Probe { + static_id: u32, + hit: Value, + dsts: Vec, + keys: Vec, + }, + /// Read a `scalar` static. + Sload { + static_id: u32, + dst: Value, + }, + /// Read a `scalar` static: validity flag + payload. + SloadOpt { + static_id: u32, + flag: Value, + dst: Value, + }, +} + +#[derive(Clone, PartialEq, Debug)] +pub enum Term { + Jump { + to: BlockId, + args: Vec, + }, + Brif { + cond: Value, + then_to: BlockId, + then_args: Vec, + else_to: BlockId, + else_args: Vec, + }, + /// Output row complete; advance both cursors. + Emit, + /// Drop this input row; nothing may have been stored on this path. + Skip, + /// Abort the whole call with a runtime error. + Trap { + msg: String, + }, +} + +#[derive(Clone, PartialEq, Debug)] +pub struct Block { + pub params: Vec<(Value, Ty)>, + pub insts: Vec, + pub term: Term, +} + +#[derive(Clone, PartialEq, Debug)] +pub struct Program { + pub statics: Vec, + pub name: String, + pub in_cols: Vec, + pub out_cols: Vec, + pub blocks: Vec, +} + +impl Inst { + /// Values this instruction defines, in definition order. + pub fn dsts(&self) -> Vec { + match self { + Inst::Const { dst, .. } + | Inst::Bin { dst, .. } + | Inst::Cmp { dst, .. } + | Inst::Not { dst, .. } + | Inst::Select { dst, .. } + | Inst::Itof { dst, .. } + | Inst::Ftoi { dst, .. } + | Inst::Itos { dst, .. } + | Inst::Ftos { dst, .. } + | Inst::Sconcat { dst, .. } + | Inst::Load { dst, .. } + | Inst::Sload { dst, .. } => vec![*dst], + Inst::StoiOpt { flag, dst, .. } + | Inst::StofOpt { flag, dst, .. } + | Inst::LoadOpt { flag, dst, .. } + | Inst::SloadOpt { flag, dst, .. } => vec![*flag, *dst], + Inst::Probe { hit, dsts, .. } => { + let mut all = vec![*hit]; + all.extend(dsts.iter().copied()); + all + } + Inst::Store { .. } | Inst::StoreOpt { .. } => vec![], + } + } + +} + +impl Term { + /// Successor blocks with their branch arguments. + pub fn successors(&self) -> Vec<(BlockId, &[Value])> { + match self { + Term::Jump { to, args } => vec![(*to, args.as_slice())], + Term::Brif { + then_to, + then_args, + else_to, + else_args, + .. + } => vec![ + (*then_to, then_args.as_slice()), + (*else_to, else_args.as_slice()), + ], + Term::Emit | Term::Skip | Term::Trap { .. } => vec![], + } + } +} + +/// Builds programs with dense, definition-ordered value ids — the canonical +/// form for which `parse(print(p)) == p` holds exactly. Lowering (M-lower) +/// and the fuzz generator both construct through this. +pub struct Builder { + next: u32, +} + +impl Builder { + #[allow(clippy::new_without_default)] + pub fn new() -> Builder { + Builder { next: 0 } + } + + pub fn fresh(&mut self) -> Value { + let v = Value(self.next); + self.next += 1; + v + } +} diff --git a/src/specializer/ir/parse.rs b/src/specializer/ir/parse.rs new file mode 100644 index 0000000..607b3ce --- /dev/null +++ b/src/specializer/ir/parse.rs @@ -0,0 +1,1046 @@ +//! Parser for the IR text format — the inverse of [`print`]. Hand-rolled +//! lexer + recursive descent; no dependencies. The parser owns *syntax* only: +//! it interns value names to dense, definition-ordered ids (the canonical +//! form) and rejects malformed text, redefinition of a value name, use of an +//! unknown value/label/column, and unknown opcodes. Everything semantic +//! (types, SSA scoping, CFG shape, store completeness) is the verifier's job. +//! +//! [`print`]: super::print + +use std::collections::HashMap; + +use super::{ + Block, BlockId, BinOp, Col, ColTy, CmpPred, Inst, Lit, Program, RoundMode, StaticTy, Term, + Ty, Value, +}; + +#[derive(Debug)] +pub struct ParseError { + pub line: u32, + pub msg: String, +} + +impl std::fmt::Display for ParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "line {}: {}", self.line, self.msg) + } +} + +pub fn parse(text: &str) -> Result { + let tokens = lex(text)?; + Parser { + toks: tokens, + pos: 0, + values: HashMap::new(), + next_value: 0, + } + .program() +} + +// ---------------------------------------------------------------- lexer -- + +#[derive(Clone, PartialEq, Debug)] +enum Tok { + Ident(String), + Val(String), // %name + Num(String), // integer or float text, optional leading '-' + Str(String), // unescaped content + At, // @ + Comma, + Colon, + Dot, + Eq, + Question, + Arrow, // -> + LParen, + RParen, + LBrace, + RBrace, + Lt, + Gt, + Eof, +} + +impl Tok { + fn show(&self) -> String { + match self { + Tok::Ident(s) => format!("'{s}'"), + Tok::Val(s) => format!("'%{s}'"), + Tok::Num(s) => format!("number '{s}'"), + Tok::Str(_) => "string literal".to_string(), + Tok::At => "'@'".to_string(), + Tok::Comma => "','".to_string(), + Tok::Colon => "':'".to_string(), + Tok::Dot => "'.'".to_string(), + Tok::Eq => "'='".to_string(), + Tok::Question => "'?'".to_string(), + Tok::Arrow => "'->'".to_string(), + Tok::LParen => "'('".to_string(), + Tok::RParen => "')'".to_string(), + Tok::LBrace => "'{'".to_string(), + Tok::RBrace => "'}'".to_string(), + Tok::Lt => "'<'".to_string(), + Tok::Gt => "'>'".to_string(), + Tok::Eof => "end of input".to_string(), + } + } +} + +fn lex(text: &str) -> Result, ParseError> { + let mut out = Vec::new(); + let mut chars = text.chars().peekable(); + let mut line: u32 = 1; + while let Some(&c) = chars.peek() { + match c { + '\n' => { + line += 1; + chars.next(); + } + c if c.is_whitespace() => { + chars.next(); + } + '#' => { + // comment to end of line + for c in chars.by_ref() { + if c == '\n' { + line += 1; + break; + } + } + } + '@' => { + chars.next(); + out.push((Tok::At, line)); + } + ',' => { + chars.next(); + out.push((Tok::Comma, line)); + } + ':' => { + chars.next(); + out.push((Tok::Colon, line)); + } + '.' => { + chars.next(); + out.push((Tok::Dot, line)); + } + '=' => { + chars.next(); + out.push((Tok::Eq, line)); + } + '?' => { + chars.next(); + out.push((Tok::Question, line)); + } + '(' => { + chars.next(); + out.push((Tok::LParen, line)); + } + ')' => { + chars.next(); + out.push((Tok::RParen, line)); + } + '{' => { + chars.next(); + out.push((Tok::LBrace, line)); + } + '}' => { + chars.next(); + out.push((Tok::RBrace, line)); + } + '<' => { + chars.next(); + out.push((Tok::Lt, line)); + } + '>' => { + chars.next(); + out.push((Tok::Gt, line)); + } + '%' => { + chars.next(); + let name = take_ident(&mut chars); + if name.is_empty() { + return Err(ParseError { + line, + msg: "expected a value name after '%'".to_string(), + }); + } + out.push((Tok::Val(name), line)); + } + '"' => { + chars.next(); + let mut s = String::new(); + loop { + match chars.next() { + None => { + return Err(ParseError { + line, + msg: "unterminated string literal".to_string(), + }) + } + Some('"') => break, + Some('\\') => match chars.next() { + Some('"') => s.push('"'), + Some('\\') => s.push('\\'), + Some('n') => s.push('\n'), + Some('r') => s.push('\r'), + Some('t') => s.push('\t'), + Some('u') => { + if chars.next() != Some('{') { + return Err(ParseError { + line, + msg: "expected '{' after \\u".to_string(), + }); + } + let mut hex = String::new(); + loop { + match chars.next() { + Some('}') => break, + Some(h) if h.is_ascii_hexdigit() => hex.push(h), + _ => { + return Err(ParseError { + line, + msg: "bad \\u{...} escape".to_string(), + }) + } + } + } + let cp = u32::from_str_radix(&hex, 16).ok(); + match cp.and_then(char::from_u32) { + Some(c) => s.push(c), + None => { + return Err(ParseError { + line, + msg: format!("bad codepoint in \\u{{{hex}}}"), + }) + } + } + } + other => { + return Err(ParseError { + line, + msg: format!("unknown escape: \\{}", show_esc(other)), + }) + } + }, + Some('\n') => { + return Err(ParseError { + line, + msg: "newline in string literal (use \\n)".to_string(), + }) + } + Some(c) => s.push(c), + } + } + out.push((Tok::Str(s), line)); + } + '-' => { + chars.next(); + match chars.peek() { + Some('>') => { + chars.next(); + out.push((Tok::Arrow, line)); + } + Some(c) if c.is_ascii_digit() => { + let num = take_number(&mut chars); + out.push((Tok::Num(format!("-{num}")), line)); + } + Some('i') => { + let word = take_ident(&mut chars); + if word == "inf" { + out.push((Tok::Num("-inf".to_string()), line)); + } else { + return Err(ParseError { + line, + msg: format!("unexpected '-{word}'"), + }); + } + } + _ => { + return Err(ParseError { + line, + msg: "unexpected '-'".to_string(), + }) + } + } + } + c if c.is_ascii_digit() => { + let num = take_number(&mut chars); + out.push((Tok::Num(num), line)); + } + c if c.is_ascii_alphabetic() || c == '_' => { + let word = take_ident(&mut chars); + out.push((Tok::Ident(word), line)); + } + other => { + return Err(ParseError { + line, + msg: format!("unexpected character '{other}'"), + }) + } + } + } + out.push((Tok::Eof, line)); + Ok(out) +} + +fn show_esc(c: Option) -> String { + match c { + Some(c) => c.to_string(), + None => "".to_string(), + } +} + +fn take_ident(chars: &mut std::iter::Peekable>) -> String { + let mut s = String::new(); + while let Some(&c) = chars.peek() { + if c.is_ascii_alphanumeric() || c == '_' { + s.push(c); + chars.next(); + } else { + break; + } + } + s +} + +/// Number text: digits, optional fraction, optional exponent. The '.' is +/// consumed only when followed by a digit, so `1.` never eats a field access +/// dot (which cannot follow a number anyway in this grammar). +fn take_number(chars: &mut std::iter::Peekable>) -> String { + let mut s = String::new(); + while let Some(&c) = chars.peek() { + if c.is_ascii_digit() { + s.push(c); + chars.next(); + } else { + break; + } + } + if let Some('.') = chars.peek() { + let mut ahead = chars.clone(); + ahead.next(); + if matches!(ahead.peek(), Some(d) if d.is_ascii_digit()) { + s.push('.'); + chars.next(); + while let Some(&c) = chars.peek() { + if c.is_ascii_digit() { + s.push(c); + chars.next(); + } else { + break; + } + } + } + } + if matches!(chars.peek(), Some('e') | Some('E')) { + let mut ahead = chars.clone(); + ahead.next(); + let sign = matches!(ahead.peek(), Some('+') | Some('-')); + if sign { + ahead.next(); + } + if matches!(ahead.peek(), Some(d) if d.is_ascii_digit()) { + s.push(chars.next().unwrap()); + if sign { + s.push(chars.next().unwrap()); + } + while let Some(&c) = chars.peek() { + if c.is_ascii_digit() { + s.push(c); + chars.next(); + } else { + break; + } + } + } + } + s +} + +// --------------------------------------------------------------- parser -- + +struct Parser { + toks: Vec<(Tok, u32)>, + pos: usize, + values: HashMap, + next_value: u32, +} + +/// A block body before label resolution: terminator targets are still names. +struct RawBlock { + label: String, + params: Vec<(Value, Ty)>, + insts: Vec, + term: RawTerm, + line: u32, +} + +enum RawTerm { + Jump(String, Vec), + Brif(Value, (String, Vec), (String, Vec)), + Emit, + Skip, + Trap(String), +} + +impl Parser { + fn peek(&self) -> &Tok { + &self.toks[self.pos].0 + } + + fn line(&self) -> u32 { + self.toks[self.pos].1 + } + + fn bump(&mut self) -> Tok { + let t = self.toks[self.pos].0.clone(); + if self.pos + 1 < self.toks.len() { + self.pos += 1; + } + t + } + + fn err(&self, msg: impl Into) -> ParseError { + ParseError { + line: self.line(), + msg: msg.into(), + } + } + + fn expect(&mut self, tok: Tok) -> Result<(), ParseError> { + if *self.peek() == tok { + self.bump(); + Ok(()) + } else { + Err(self.err(format!("expected {}, found {}", tok.show(), self.peek().show()))) + } + } + + fn ident(&mut self, what: &str) -> Result { + match self.bump() { + Tok::Ident(s) => Ok(s), + other => Err(self.err(format!("expected {what}, found {}", other.show()))), + } + } + + /// Keyword = identifier with a required spelling. + fn keyword(&mut self, kw: &str) -> Result<(), ParseError> { + match self.peek() { + Tok::Ident(s) if s == kw => { + self.bump(); + Ok(()) + } + other => Err(self.err(format!("expected '{kw}', found {}", other.show()))), + } + } + + fn define_value(&mut self, name: String) -> Result { + if self.values.contains_key(&name) { + return Err(self.err(format!("value '%{name}' defined twice"))); + } + let v = Value(self.next_value); + self.next_value += 1; + self.values.insert(name, v); + Ok(v) + } + + fn use_value(&mut self) -> Result { + match self.bump() { + Tok::Val(name) => self + .values + .get(&name) + .copied() + .ok_or_else(|| self.err(format!("use of undefined value '%{name}'"))), + other => Err(self.err(format!("expected a %value, found {}", other.show()))), + } + } + + fn program(mut self) -> Result { + let mut statics = Vec::new(); + while matches!(self.peek(), Tok::Ident(s) if s == "static") { + self.bump(); + self.expect(Tok::At)?; + let idx = self.int_literal("static index")?; + if idx != statics.len() as i64 { + return Err(self.err(format!( + "static ids must be dense and in order: expected @{}, found @{idx}", + statics.len() + ))); + } + self.expect(Tok::Colon)?; + statics.push(self.static_ty()?); + } + + self.keyword("fn")?; + let name = self.ident("function name")?; + self.expect(Tok::LParen)?; + self.keyword("in")?; + self.expect(Tok::Colon)?; + let in_cols = self.batch()?; + self.expect(Tok::Comma)?; + self.keyword("out")?; + self.expect(Tok::Colon)?; + let out_cols = self.batch()?; + self.expect(Tok::RParen)?; + self.expect(Tok::LBrace)?; + + let mut raw_blocks: Vec = Vec::new(); + while *self.peek() != Tok::RBrace { + let block = self.block(&statics, &in_cols, &out_cols)?; + raw_blocks.push(block); + } + self.expect(Tok::RBrace)?; + if *self.peek() != Tok::Eof { + return Err(self.err(format!("trailing input: {}", self.peek().show()))); + } + if raw_blocks.is_empty() { + return Err(self.err("a function needs at least one block")); + } + + // Resolve labels. + let mut label_ids: HashMap = HashMap::new(); + for (i, rb) in raw_blocks.iter().enumerate() { + if label_ids.insert(rb.label.clone(), BlockId(i as u32)).is_some() { + return Err(ParseError { + line: rb.line, + msg: format!("block label '{}' defined twice", rb.label), + }); + } + } + let resolve = |label: &str, line: u32| -> Result { + label_ids.get(label).copied().ok_or(ParseError { + line, + msg: format!("jump to unknown block '{label}'"), + }) + }; + let mut blocks = Vec::with_capacity(raw_blocks.len()); + for rb in raw_blocks { + let term = match rb.term { + RawTerm::Jump(label, args) => Term::Jump { + to: resolve(&label, rb.line)?, + args, + }, + RawTerm::Brif(cond, (tl, ta), (el, ea)) => Term::Brif { + cond, + then_to: resolve(&tl, rb.line)?, + then_args: ta, + else_to: resolve(&el, rb.line)?, + else_args: ea, + }, + RawTerm::Emit => Term::Emit, + RawTerm::Skip => Term::Skip, + RawTerm::Trap(msg) => Term::Trap { msg }, + }; + blocks.push(Block { + params: rb.params, + insts: rb.insts, + term, + }); + } + + Ok(Program { + statics, + name, + in_cols, + out_cols, + blocks, + }) + } + + fn int_literal(&mut self, what: &str) -> Result { + match self.bump() { + Tok::Num(s) => s + .parse::() + .map_err(|_| self.err(format!("bad {what}: '{s}'"))), + other => Err(self.err(format!("expected {what}, found {}", other.show()))), + } + } + + fn static_ty(&mut self) -> Result { + let kind = self.ident("'scalar' or 'map'")?; + match kind.as_str() { + "scalar" => { + self.expect(Tok::Lt)?; + let ct = self.col_ty()?; + self.expect(Tok::Gt)?; + Ok(StaticTy::Scalar(ct)) + } + "map" => { + self.expect(Tok::LParen)?; + let keys = self.ty_list()?; + self.expect(Tok::RParen)?; + self.expect(Tok::Arrow)?; + self.expect(Tok::LParen)?; + let values = self.ty_list()?; + self.expect(Tok::RParen)?; + Ok(StaticTy::Map { keys, values }) + } + other => Err(self.err(format!("expected 'scalar' or 'map', found '{other}'"))), + } + } + + fn ty_list(&mut self) -> Result, ParseError> { + let mut list = vec![self.ty()?]; + while *self.peek() == Tok::Comma { + self.bump(); + list.push(self.ty()?); + } + Ok(list) + } + + fn ty(&mut self) -> Result { + let name = self.ident("a type")?; + match name.as_str() { + "i1" => Ok(Ty::I1), + "i64" => Ok(Ty::I64), + "f64" => Ok(Ty::F64), + "str" => Ok(Ty::Str), + other => Err(self.err(format!("unknown type '{other}'"))), + } + } + + fn col_ty(&mut self) -> Result { + let ty = self.ty()?; + let nullable = if *self.peek() == Tok::Question { + self.bump(); + true + } else { + false + }; + Ok(ColTy { ty, nullable }) + } + + fn batch(&mut self) -> Result, ParseError> { + self.keyword("batch")?; + self.expect(Tok::LBrace)?; + let mut cols = Vec::new(); + if *self.peek() != Tok::RBrace { + loop { + let name = match self.bump() { + Tok::Ident(s) => s, + Tok::Str(s) => s, + other => { + return Err( + self.err(format!("expected a column name, found {}", other.show())) + ) + } + }; + if cols.iter().any(|c: &Col| c.name == name) { + return Err(self.err(format!("column '{name}' declared twice"))); + } + self.expect(Tok::Colon)?; + let ty = self.col_ty()?; + cols.push(Col { name, ty }); + if *self.peek() == Tok::Comma { + self.bump(); + } else { + break; + } + } + } + self.expect(Tok::RBrace)?; + Ok(cols) + } + + fn block( + &mut self, + statics: &[StaticTy], + in_cols: &[Col], + out_cols: &[Col], + ) -> Result { + let line = self.line(); + let label = self.ident("a block label")?; + let mut params = Vec::new(); + if *self.peek() == Tok::LParen { + self.bump(); + loop { + let name = match self.bump() { + Tok::Val(n) => n, + other => { + return Err( + self.err(format!("expected a %param, found {}", other.show())) + ) + } + }; + self.expect(Tok::Colon)?; + let ty = self.ty()?; + let v = self.define_value(name)?; + params.push((v, ty)); + if *self.peek() == Tok::Comma { + self.bump(); + } else { + break; + } + } + self.expect(Tok::RParen)?; + } + self.expect(Tok::Colon)?; + + let mut insts = Vec::new(); + let term = loop { + if let Some(term) = self.try_terminator()? { + break term; + } + insts.push(self.inst(statics, in_cols, out_cols)?); + }; + Ok(RawBlock { + label, + params, + insts, + term, + line, + }) + } + + fn try_terminator(&mut self) -> Result, ParseError> { + let kw = match self.peek() { + Tok::Ident(s) => s.clone(), + _ => return Ok(None), + }; + match kw.as_str() { + "emit" => { + self.bump(); + Ok(Some(RawTerm::Emit)) + } + "skip" => { + self.bump(); + Ok(Some(RawTerm::Skip)) + } + "trap" => { + self.bump(); + match self.bump() { + Tok::Str(msg) => Ok(Some(RawTerm::Trap(msg))), + other => Err(self.err(format!( + "expected a string message after 'trap', found {}", + other.show() + ))), + } + } + "jump" => { + self.bump(); + let (label, args) = self.target()?; + Ok(Some(RawTerm::Jump(label, args))) + } + "brif" => { + self.bump(); + let cond = self.use_value()?; + self.expect(Tok::Comma)?; + let then_t = self.target()?; + self.expect(Tok::Comma)?; + let else_t = self.target()?; + Ok(Some(RawTerm::Brif(cond, then_t, else_t))) + } + _ => Ok(None), + } + } + + fn target(&mut self) -> Result<(String, Vec), ParseError> { + let label = self.ident("a block label")?; + let mut args = Vec::new(); + if *self.peek() == Tok::LParen { + self.bump(); + loop { + args.push(self.use_value()?); + if *self.peek() == Tok::Comma { + self.bump(); + } else { + break; + } + } + self.expect(Tok::RParen)?; + } + Ok((label, args)) + } + + /// One instruction line: `dsts = opcode operands`. + fn inst( + &mut self, + statics: &[StaticTy], + in_cols: &[Col], + out_cols: &[Col], + ) -> Result { + // store/store.opt have no dsts and start with an Ident. + if matches!(self.peek(), Tok::Ident(s) if s == "store") { + self.bump(); + let opt = self.dot_suffix()?; + let col = self.col_ref("out", out_cols)?; + self.expect(Tok::Comma)?; + return match opt.as_deref() { + None => { + let val = self.use_value()?; + Ok(Inst::Store { col, val }) + } + Some("opt") => { + let flag = self.use_value()?; + self.expect(Tok::Comma)?; + let val = self.use_value()?; + Ok(Inst::StoreOpt { col, flag, val }) + } + Some(other) => Err(self.err(format!("unknown opcode 'store.{other}'"))), + }; + } + + // Everything else: one or more dsts, '=', opcode. + let mut dst_names = Vec::new(); + loop { + match self.bump() { + Tok::Val(n) => dst_names.push(n), + other => { + return Err(self.err(format!( + "expected an instruction or terminator, found {}", + other.show() + ))) + } + } + if *self.peek() == Tok::Comma { + self.bump(); + } else { + break; + } + } + self.expect(Tok::Eq)?; + let head = self.ident("an opcode")?; + let suffix = self.dot_suffix()?; + let opcode = match &suffix { + Some(sfx) => format!("{head}.{sfx}"), + None => head.clone(), + }; + + let want_dsts = |n: usize, this: &Parser| -> Result<(), ParseError> { + if dst_names.len() != n { + Err(this.err(format!( + "'{opcode}' defines {n} value(s), found {}", + dst_names.len() + ))) + } else { + Ok(()) + } + }; + + // Helper closures cannot borrow self mutably twice; do defs inline. + macro_rules! def { + ($i:expr) => { + self.define_value(dst_names[$i].clone())? + }; + } + + let inst = match opcode.as_str() { + "const.i1" | "const.i64" | "const.f64" | "const.str" => { + want_dsts(1, self)?; + let lit = match opcode.as_str() { + "const.i1" => match self.ident("'true' or 'false'")?.as_str() { + "true" => Lit::I1(true), + "false" => Lit::I1(false), + other => { + return Err( + self.err(format!("expected 'true' or 'false', found '{other}'")) + ) + } + }, + "const.i64" => Lit::I64(self.int_literal("integer literal")?), + "const.f64" => Lit::F64(self.f64_literal()?), + _ => match self.bump() { + Tok::Str(s) => Lit::Str(s), + other => { + return Err( + self.err(format!("expected a string, found {}", other.show())) + ) + } + }, + }; + Inst::Const { dst: def!(0), lit } + } + "iadd" | "isub" | "imul" | "idiv" | "irem" | "fadd" | "fsub" | "fmul" | "fdiv" + | "and" | "or" | "xor" => { + want_dsts(1, self)?; + let op = match opcode.as_str() { + "iadd" => BinOp::Iadd, + "isub" => BinOp::Isub, + "imul" => BinOp::Imul, + "idiv" => BinOp::Idiv, + "irem" => BinOp::Irem, + "fadd" => BinOp::Fadd, + "fsub" => BinOp::Fsub, + "fmul" => BinOp::Fmul, + "fdiv" => BinOp::Fdiv, + "and" => BinOp::And, + "or" => BinOp::Or, + _ => BinOp::Xor, + }; + let a = self.use_value()?; + self.expect(Tok::Comma)?; + let b = self.use_value()?; + Inst::Bin { op, dst: def!(0), a, b } + } + _ if head == "icmp" || head == "fcmp" || head == "scmp" => { + want_dsts(1, self)?; + let ty = match head.as_str() { + "icmp" => Ty::I64, + "fcmp" => Ty::F64, + _ => Ty::Str, + }; + let pred = match suffix.as_deref() { + Some("eq") => CmpPred::Eq, + Some("ne") => CmpPred::Ne, + Some("lt") => CmpPred::Lt, + Some("le") => CmpPred::Le, + Some("gt") => CmpPred::Gt, + Some("ge") => CmpPred::Ge, + _ => return Err(self.err(format!("unknown opcode '{opcode}'"))), + }; + let a = self.use_value()?; + self.expect(Tok::Comma)?; + let b = self.use_value()?; + Inst::Cmp { pred, ty, dst: def!(0), a, b } + } + "not" => { + want_dsts(1, self)?; + let a = self.use_value()?; + Inst::Not { dst: def!(0), a } + } + "select" => { + want_dsts(1, self)?; + let cond = self.use_value()?; + self.expect(Tok::Comma)?; + let a = self.use_value()?; + self.expect(Tok::Comma)?; + let b = self.use_value()?; + Inst::Select { dst: def!(0), cond, a, b } + } + "itof" => { + want_dsts(1, self)?; + let a = self.use_value()?; + Inst::Itof { dst: def!(0), a } + } + "ftoi.trunc" | "ftoi.round" => { + want_dsts(1, self)?; + let mode = if opcode.ends_with("trunc") { + RoundMode::Trunc + } else { + RoundMode::Round + }; + let a = self.use_value()?; + Inst::Ftoi { mode, dst: def!(0), a } + } + "itos" => { + want_dsts(1, self)?; + let a = self.use_value()?; + Inst::Itos { dst: def!(0), a } + } + "ftos" => { + want_dsts(1, self)?; + let a = self.use_value()?; + Inst::Ftos { dst: def!(0), a } + } + "stoi.opt" | "stof.opt" => { + want_dsts(2, self)?; + let a = self.use_value()?; + let flag = def!(0); + let dst = def!(1); + if opcode.starts_with("stoi") { + Inst::StoiOpt { flag, dst, a } + } else { + Inst::StofOpt { flag, dst, a } + } + } + "sconcat" => { + want_dsts(1, self)?; + let a = self.use_value()?; + self.expect(Tok::Comma)?; + let b = self.use_value()?; + Inst::Sconcat { dst: def!(0), a, b } + } + "load" => { + want_dsts(1, self)?; + let col = self.col_ref("in", in_cols)?; + Inst::Load { dst: def!(0), col } + } + "load.opt" => { + want_dsts(2, self)?; + let col = self.col_ref("in", in_cols)?; + Inst::LoadOpt { flag: def!(0), dst: def!(1), col } + } + "probe" => { + if dst_names.is_empty() { + return Err(self.err("'probe' defines at least a hit flag")); + } + let static_id = self.static_ref(statics)?; + let mut keys = Vec::new(); + while *self.peek() == Tok::Comma { + self.bump(); + keys.push(self.use_value()?); + } + let hit = def!(0); + let mut dsts = Vec::with_capacity(dst_names.len() - 1); + for i in 1..dst_names.len() { + dsts.push(def!(i)); + } + Inst::Probe { static_id, hit, dsts, keys } + } + "sload" => { + want_dsts(1, self)?; + let static_id = self.static_ref(statics)?; + Inst::Sload { static_id, dst: def!(0) } + } + "sload.opt" => { + want_dsts(2, self)?; + let static_id = self.static_ref(statics)?; + Inst::SloadOpt { static_id, flag: def!(0), dst: def!(1) } + } + other => return Err(self.err(format!("unknown opcode '{other}'"))), + }; + Ok(inst) + } + + fn dot_suffix(&mut self) -> Result, ParseError> { + if *self.peek() == Tok::Dot { + self.bump(); + Ok(Some(self.ident("an opcode suffix")?)) + } else { + Ok(None) + } + } + + /// `in.NAME` / `out.NAME` (NAME bare or quoted) -> column index. + fn col_ref(&mut self, side: &str, cols: &[Col]) -> Result { + self.keyword(side)?; + self.expect(Tok::Dot)?; + let name = match self.bump() { + Tok::Ident(s) => s, + Tok::Str(s) => s, + other => { + return Err(self.err(format!("expected a column name, found {}", other.show()))) + } + }; + cols.iter() + .position(|c| c.name == name) + .map(|i| i as u32) + .ok_or_else(|| self.err(format!("unknown column '{side}.{name}'"))) + } + + fn static_ref(&mut self, statics: &[StaticTy]) -> Result { + self.expect(Tok::At)?; + let idx = self.int_literal("static index")?; + if idx < 0 || idx as usize >= statics.len() { + return Err(self.err(format!("unknown static '@{idx}'"))); + } + Ok(idx as u32) + } + + fn f64_literal(&mut self) -> Result { + match self.bump() { + Tok::Num(s) => { + if s == "-inf" { + Ok(f64::NEG_INFINITY) + } else { + s.parse::() + .map_err(|_| self.err(format!("bad float literal '{s}'"))) + } + } + Tok::Ident(s) if s == "inf" => Ok(f64::INFINITY), + Tok::Ident(s) if s == "nan" => Ok(f64::NAN), + other => Err(self.err(format!("expected a float literal, found {}", other.show()))), + } + } +} diff --git a/src/specializer/ir/print.rs b/src/specializer/ir/print.rs new file mode 100644 index 0000000..9026858 --- /dev/null +++ b/src/specializer/ir/print.rs @@ -0,0 +1,282 @@ +//! Canonical text form of a [`Program`]. The inverse of [`parse`]; every +//! choice here (names, spacing, literal forms) is pinned by the round-trip +//! property, so change both sides together or not at all. +//! +//! [`parse`]: super::parse + +use std::fmt::Write; + +use super::{Block, ColTy, Inst, Lit, Program, StaticTy, Term, Ty, Value}; + +pub fn print(p: &Program) -> String { + let mut s = String::new(); + for (i, st) in p.statics.iter().enumerate() { + let _ = write!(s, "static @{i}: "); + match st { + StaticTy::Scalar(ct) => { + let _ = writeln!(s, "scalar<{}>", col_ty(*ct)); + } + StaticTy::Map { keys, values } => { + let _ = writeln!(s, "map({}) -> ({})", tys(keys), tys(values)); + } + } + } + if !p.statics.is_empty() { + s.push('\n'); + } + let _ = writeln!( + s, + "fn {}(in: {}, out: {}) {{", + p.name, + batch(&p.in_cols), + batch(&p.out_cols) + ); + for (bi, b) in p.blocks.iter().enumerate() { + let _ = write!(s, "b{bi}"); + if !b.params.is_empty() { + let params: Vec = b + .params + .iter() + .map(|(v, t)| format!("{}: {}", val(*v), t.name())) + .collect(); + let _ = write!(s, "({})", params.join(", ")); + } + s.push_str(":\n"); + for inst in &b.insts { + s.push_str(" "); + print_inst(&mut s, p, inst); + s.push('\n'); + } + s.push_str(" "); + print_term(&mut s, b); + s.push('\n'); + } + s.push_str("}\n"); + s +} + +fn print_inst(s: &mut String, p: &Program, inst: &Inst) { + let dsts = inst.dsts(); + if !dsts.is_empty() { + let names: Vec = dsts.iter().map(|v| val(*v)).collect(); + let _ = write!(s, "{} = ", names.join(", ")); + } + match inst { + Inst::Const { lit, .. } => match lit { + Lit::I1(b) => { + let _ = write!(s, "const.i1 {b}"); + } + Lit::I64(i) => { + let _ = write!(s, "const.i64 {i}"); + } + Lit::F64(f) => { + let _ = write!(s, "const.f64 {}", f64_text(*f)); + } + Lit::Str(t) => { + let _ = write!(s, "const.str {}", quote(t)); + } + }, + Inst::Bin { op, a, b, .. } => { + let _ = write!(s, "{} {}, {}", op.name(), val(*a), val(*b)); + } + Inst::Cmp { pred, ty, a, b, .. } => { + let prefix = match ty { + Ty::I64 => "icmp", + Ty::F64 => "fcmp", + Ty::Str => "scmp", + // Unreachable in verified programs; printed anyway so a bad + // program still prints for diagnostics. + Ty::I1 => "icmp", + }; + let _ = write!(s, "{prefix}.{} {}, {}", pred.name(), val(*a), val(*b)); + } + Inst::Not { a, .. } => { + let _ = write!(s, "not {}", val(*a)); + } + Inst::Select { cond, a, b, .. } => { + let _ = write!(s, "select {}, {}, {}", val(*cond), val(*a), val(*b)); + } + Inst::Itof { a, .. } => { + let _ = write!(s, "itof {}", val(*a)); + } + Inst::Ftoi { mode, a, .. } => { + let m = match mode { + super::RoundMode::Trunc => "trunc", + super::RoundMode::Round => "round", + }; + let _ = write!(s, "ftoi.{m} {}", val(*a)); + } + Inst::Itos { a, .. } => { + let _ = write!(s, "itos {}", val(*a)); + } + Inst::Ftos { a, .. } => { + let _ = write!(s, "ftos {}", val(*a)); + } + Inst::StoiOpt { a, .. } => { + let _ = write!(s, "stoi.opt {}", val(*a)); + } + Inst::StofOpt { a, .. } => { + let _ = write!(s, "stof.opt {}", val(*a)); + } + Inst::Sconcat { a, b, .. } => { + let _ = write!(s, "sconcat {}, {}", val(*a), val(*b)); + } + Inst::Load { col, .. } => { + let _ = write!(s, "load in.{}", col_name(p, false, *col)); + } + Inst::LoadOpt { col, .. } => { + let _ = write!(s, "load.opt in.{}", col_name(p, false, *col)); + } + Inst::Store { col, val: v } => { + let _ = write!(s, "store out.{}, {}", col_name(p, true, *col), val(*v)); + } + Inst::StoreOpt { col, flag, val: v } => { + let _ = write!( + s, + "store.opt out.{}, {}, {}", + col_name(p, true, *col), + val(*flag), + val(*v) + ); + } + Inst::Probe { + static_id, keys, .. + } => { + let ks: Vec = keys.iter().map(|k| val(*k)).collect(); + let _ = write!(s, "probe @{static_id}, {}", ks.join(", ")); + } + Inst::Sload { static_id, .. } => { + let _ = write!(s, "sload @{static_id}"); + } + Inst::SloadOpt { static_id, .. } => { + let _ = write!(s, "sload.opt @{static_id}"); + } + } +} + +fn print_term(s: &mut String, b: &Block) { + match &b.term { + Term::Jump { to, args } => { + let _ = write!(s, "jump {}", target(to.0, args)); + } + Term::Brif { + cond, + then_to, + then_args, + else_to, + else_args, + } => { + let _ = write!( + s, + "brif {}, {}, {}", + val(*cond), + target(then_to.0, then_args), + target(else_to.0, else_args) + ); + } + Term::Emit => s.push_str("emit"), + Term::Skip => s.push_str("skip"), + Term::Trap { msg } => { + let _ = write!(s, "trap {}", quote(msg)); + } + } +} + +fn target(block: u32, args: &[Value]) -> String { + if args.is_empty() { + format!("b{block}") + } else { + let a: Vec = args.iter().map(|v| val(*v)).collect(); + format!("b{block}({})", a.join(", ")) + } +} + +fn val(v: Value) -> String { + format!("%v{}", v.0) +} + +fn tys(list: &[Ty]) -> String { + list.iter() + .map(|t| t.name()) + .collect::>() + .join(", ") +} + +fn col_ty(ct: ColTy) -> String { + if ct.nullable { + format!("{}?", ct.ty.name()) + } else { + ct.ty.name().to_string() + } +} + +fn batch(cols: &[super::Col]) -> String { + let inner: Vec = cols + .iter() + .map(|c| format!("{}: {}", ident_or_quoted(&c.name), col_ty(c.ty))) + .collect(); + format!("batch{{{}}}", inner.join(", ")) +} + +fn col_name(p: &Program, out: bool, idx: u32) -> String { + let cols = if out { &p.out_cols } else { &p.in_cols }; + match cols.get(idx as usize) { + Some(c) => ident_or_quoted(&c.name), + // Out-of-range in an unverified program; keep printing diagnosable. + None => format!("\"\""), + } +} + +/// Column names print bare when they are identifiers, quoted otherwise +/// (SQL-derived output names like `COALESCE(a, b)` survive). Function names +/// are NOT routed through this: the verifier requires them to be +/// identifiers, so the printer writes them raw. +fn ident_or_quoted(name: &str) -> String { + if is_ident(name) { + name.to_string() + } else { + quote(name) + } +} + +pub(super) fn is_ident(name: &str) -> bool { + let mut chars = name.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' => {} + _ => return false, + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// `f64` text form: `{:?}` for finite values (guaranteed shortest +/// round-trip), dedicated tokens for the specials. NaN payloads collapse to +/// the single canonical `nan`. +pub(super) fn f64_text(f: f64) -> String { + if f.is_nan() { + "nan".to_string() + } else if f == f64::INFINITY { + "inf".to_string() + } else if f == f64::NEG_INFINITY { + "-inf".to_string() + } else { + format!("{f:?}") + } +} + +pub(super) fn quote(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{{{:x}}}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} diff --git a/src/specializer/ir/tests.rs b/src/specializer/ir/tests.rs new file mode 100644 index 0000000..42ba1dd --- /dev/null +++ b/src/specializer/ir/tests.rs @@ -0,0 +1,749 @@ +//! IR boundary tests: fixtures round-trip and verify; every verifier rule +//! has a program that trips it; every parser guard has a text that trips it; +//! seeded fuzz pins `parse(print(p)) == p` across the generator's whole +//! surface. + +use super::{fixtures, gen, parse::parse, print::print, verify::verify, Program}; + +fn parsed(text: &str) -> Program { + match parse(text) { + Ok(p) => p, + Err(e) => panic!("parse failed: {e}\n---\n{text}"), + } +} + +fn verified(text: &str) -> Program { + let p = parsed(text); + if let Err(errs) = verify(&p) { + let msgs: Vec = errs.iter().map(|e| e.to_string()).collect(); + panic!("verify failed: {}\n---\n{text}", msgs.join("; ")); + } + p +} + +// ------------------------------------------------------------- fixtures -- + +#[test] +fn fixtures_verify_and_round_trip() { + for (name, text) in fixtures::all() { + let p = verified(text); + let printed = print(&p); + let p2 = parsed(&printed); + assert_eq!(p2, p, "round-trip changed '{name}':\n{printed}"); + assert_eq!(print(&p2), printed, "printing is not a fixpoint for '{name}'"); + verify(&p2).unwrap_or_else(|_| panic!("canonical form of '{name}' fails verify")); + } +} + +/// Every opcode and terminator must appear in at least one fixture — this is +/// the acceptance criterion "hand-written programs covering every +/// instruction", kept honest mechanically. +#[test] +fn fixtures_cover_every_opcode() { + let all: String = fixtures::all().iter().map(|(_, t)| *t).collect(); + let opcodes = [ + "const.i1", "const.i64", "const.f64", "const.str", "iadd", "isub", "imul", "idiv", + "irem", "fadd", "fsub", "fmul", "fdiv", "and", "or", "xor", "not", "icmp.", "fcmp.", + "scmp.", "select", "itof", "ftoi.trunc", "ftoi.round", "itos", "ftos", "stoi.opt", + "stof.opt", "sconcat", "load in.", "load.opt in.", "store out.", "store.opt out.", + "probe @", "sload @", "sload.opt @", "jump", "brif", "emit", "skip", "trap", + ]; + let missing: Vec<&str> = opcodes.iter().filter(|op| !all.contains(**op)).copied().collect(); + assert!(missing.is_empty(), "no fixture covers: {missing:?}"); +} + +// ------------------------------------------------------ verifier rejects -- + +/// Parse must succeed and verify must fail with a message containing `needle`. +fn assert_verify_rejects(text: &str, needle: &str) { + let p = parsed(text); + match verify(&p) { + Ok(()) => panic!("verifier accepted a bad program (wanted '{needle}'):\n{text}"), + Err(errs) => { + let all: Vec = errs.iter().map(|e| e.to_string()).collect(); + assert!( + all.iter().any(|m| m.contains(needle)), + "expected an error containing '{needle}', got: {all:?}\n---\n{text}" + ); + } + } +} + +#[test] +fn rejects_cross_block_use() { + // %x is defined in entry and used in b1 without riding a branch arg. + assert_verify_rejects( + r#"fn f(in: batch{a: i64}, out: batch{o: i64}) { +entry: + %x = load in.a + %t = const.i1 true + brif %t, one, two +one: + store out.o, %x + emit +two: + skip +}"#, + "cross blocks only as branch args", + ); +} + +#[test] +fn rejects_use_before_def() { + // Text form: the parser's name table already refuses a forward %use... + assert_parse_rejects( + r#"fn f(in: batch{a: i64}, out: batch{o: i64}) { +entry: + %y = iadd %x, %x + %x = load in.a + store out.o, %y + emit +}"#, + "undefined value '%x'", + ); + // ...and the verifier independently rejects the same shape when the + // program is constructed through the API (the path lowering will use). + use super::{Block, Col, ColTy, Inst, Program, Term, Ty, Value, BinOp}; + let p = Program { + statics: vec![], + name: "f".into(), + in_cols: vec![Col { name: "a".into(), ty: ColTy { ty: Ty::I64, nullable: false } }], + out_cols: vec![Col { name: "o".into(), ty: ColTy { ty: Ty::I64, nullable: false } }], + blocks: vec![Block { + params: vec![], + insts: vec![ + Inst::Bin { op: BinOp::Iadd, dst: Value(0), a: Value(1), b: Value(1) }, + Inst::Load { dst: Value(1), col: 0 }, + Inst::Store { col: 0, val: Value(0) }, + ], + term: Term::Emit, + }], + }; + let errs = verify(&p).expect_err("use-before-def must not verify"); + assert!( + errs.iter().any(|e| e.to_string().contains("used before any definition")), + "wrong errors: {:?}", + errs.iter().map(|e| e.to_string()).collect::>() + ); +} + +#[test] +fn rejects_arith_type_mismatch() { + assert_verify_rejects( + r#"fn f(in: batch{a: f64}, out: batch{o: f64}) { +entry: + %x = load in.a + %y = iadd %x, %x + %z = itof %y + store out.o, %z + emit +}"#, + "must be i64, got f64", + ); +} + +#[test] +fn rejects_skipping_the_null_lane() { + // A nullable column read with the non-opt load: the arithmetic below it + // would silently operate on a maybe-NULL — exactly the 3VL bug class the + // IR is designed to make unrepresentable. + assert_verify_rejects( + r#"fn f(in: batch{a: i64?}, out: batch{o: i64}) { +entry: + %x = load in.a + store out.o, %x + emit +}"#, + "is nullable: use load.opt", + ); +} + +#[test] +fn rejects_inventing_a_null_lane() { + assert_verify_rejects( + r#"fn f(in: batch{a: i64}, out: batch{o: i64}) { +entry: + %f, %x = load.opt in.a + store out.o, %x + emit +}"#, + "is not nullable: use load", + ); +} + +#[test] +fn rejects_store_without_flag_on_nullable_out() { + assert_verify_rejects( + r#"fn f(in: batch{a: i64}, out: batch{o: i64?}) { +entry: + %x = load in.a + store out.o, %x + emit +}"#, + "is nullable: use store.opt", + ); +} + +#[test] +fn rejects_probe_on_scalar_static() { + assert_verify_rejects( + r#"static @0: scalar +fn f(in: batch{k: str}, out: batch{o: f64}) { +entry: + %k = load in.k + %h, %v = probe @0, %k + store out.o, %v + emit +}"#, + "is a scalar: use sload", + ); +} + +#[test] +fn rejects_probe_key_arity_and_type() { + assert_verify_rejects( + r#"static @0: map(i64, str) -> (f64) +fn f(in: batch{k: str}, out: batch{o: f64}) { +entry: + %k = load in.k + %h, %v = probe @0, %k + store out.o, %v + emit +}"#, + "has 2 key(s), probe passes 1", + ); + assert_verify_rejects( + r#"static @0: map(i64) -> (f64) +fn f(in: batch{k: str}, out: batch{o: f64}) { +entry: + %k = load in.k + %h, %v = probe @0, %k + store out.o, %v + emit +}"#, + "probe key %v0 must be i64, got str", + ); +} + +#[test] +fn rejects_probe_value_arity() { + assert_verify_rejects( + r#"static @0: map(str) -> (f64, i64) +fn f(in: batch{k: str}, out: batch{o: f64}) { +entry: + %k = load in.k + %h, %v = probe @0, %k + store out.o, %v + emit +}"#, + "has 2 value column(s), probe defines 1", + ); +} + +#[test] +fn rejects_missing_store_at_emit() { + assert_verify_rejects( + r#"fn f(in: batch{a: i64}, out: batch{o: i64, p: i64}) { +entry: + %x = load in.a + store out.o, %x + emit +}"#, + "emit without storing out.p", + ); +} + +#[test] +fn rejects_double_store() { + assert_verify_rejects( + r#"fn f(in: batch{a: i64}, out: batch{o: i64}) { +entry: + %x = load in.a + store out.o, %x + store out.o, %x + emit +}"#, + "stored more than once", + ); +} + +#[test] +fn rejects_store_before_skip() { + assert_verify_rejects( + r#"fn f(in: batch{a: i64}, out: batch{o: i64}) { +entry: + %x = load in.a + store out.o, %x + skip +}"#, + "skip after storing", + ); +} + +#[test] +fn rejects_join_with_disagreeing_stores() { + // One arm stores out.o before the join, the other doesn't. + assert_verify_rejects( + r#"fn f(in: batch{a: i64}, out: batch{o: i64}) { +entry: + %t = const.i1 true + brif %t, one, two +one: + %x = const.i64 1 + store out.o, %x + jump join +two: + jump join +join: + emit +}"#, + "disagree on which out columns are stored", + ); +} + +#[test] +fn rejects_cycle() { + assert_verify_rejects( + r#"fn f(in: batch{a: i64}, out: batch{o: i64}) { +entry: + %t = const.i1 true + brif %t, one, two +one: + jump two +two: + jump one +}"#, + "control-flow cycle", + ); +} + +#[test] +fn rejects_unreachable_block() { + assert_verify_rejects( + r#"fn f(in: batch{a: i64}, out: batch{o: i64}) { +entry: + %x = load in.a + store out.o, %x + emit +island: + skip +}"#, + "unreachable block", + ); +} + +#[test] +fn rejects_branch_arg_mismatch() { + assert_verify_rejects( + r#"fn f(in: batch{a: i64}, out: batch{o: i64}) { +entry: + %x = load in.a + jump join(%x) +join(%p: f64): + %t = ftoi.trunc %p + store out.o, %t + emit +}"#, + "branch arg %v0 must be f64, got i64", + ); +} + +#[test] +fn rejects_select_arm_mismatch() { + assert_verify_rejects( + r#"fn f(in: batch{a: i64, b: f64}, out: batch{o: i64}) { +entry: + %x = load in.a + %y = load in.b + %t = const.i1 true + %s = select %t, %x, %y + store out.o, %s + emit +}"#, + "select arms differ", + ); +} + +// -------------------------------------------------------- parser rejects -- + +fn assert_parse_rejects(text: &str, needle: &str) { + match parse(text) { + Ok(_) => panic!("parser accepted bad text (wanted '{needle}'):\n{text}"), + Err(e) => assert!( + e.to_string().contains(needle), + "expected a parse error containing '{needle}', got '{e}'" + ), + } +} + +#[test] +fn parser_rejects_unknown_opcode() { + assert_parse_rejects( + "fn f(in: batch{a: i64}, out: batch{o: i64}) {\nentry:\n %x = alloc 8\n emit\n}", + "unknown opcode 'alloc'", + ); +} + +#[test] +fn parser_rejects_value_redefinition() { + assert_parse_rejects( + "fn f(in: batch{a: i64}, out: batch{o: i64}) {\nentry:\n %x = load in.a\n %x = load in.a\n emit\n}", + "defined twice", + ); +} + +#[test] +fn parser_rejects_unknown_value() { + assert_parse_rejects( + "fn f(in: batch{a: i64}, out: batch{o: i64}) {\nentry:\n store out.o, %ghost\n emit\n}", + "undefined value '%ghost'", + ); +} + +#[test] +fn parser_rejects_unknown_column() { + assert_parse_rejects( + "fn f(in: batch{a: i64}, out: batch{o: i64}) {\nentry:\n %x = load in.b\n emit\n}", + "unknown column 'in.b'", + ); +} + +#[test] +fn parser_rejects_unknown_label() { + assert_parse_rejects( + "fn f(in: batch{a: i64}, out: batch{o: i64}) {\nentry:\n jump nowhere\n}", + "unknown block 'nowhere'", + ); +} + +#[test] +fn parser_rejects_unknown_static() { + assert_parse_rejects( + "fn f(in: batch{a: i64}, out: batch{o: i64}) {\nentry:\n %x = sload @0\n emit\n}", + "unknown static '@0'", + ); +} + +#[test] +fn parser_rejects_sparse_static_ids() { + assert_parse_rejects( + "static @1: scalar\nfn f(in: batch{a: i64}, out: batch{o: i64}) {\nentry:\n emit\n}", + "dense and in order", + ); +} + +#[test] +fn parser_rejects_bad_escape_and_unterminated_string() { + assert_parse_rejects( + "fn f(in: batch{a: i64}, out: batch{o: str}) {\nentry:\n %x = const.str \"\\q\"\n emit\n}", + "unknown escape", + ); + assert_parse_rejects("fn f(in: batch{a: i64}, out: batch{o: str}) { \"", "unterminated"); +} + +#[test] +fn parser_rejects_trailing_garbage() { + assert_parse_rejects( + "fn f(in: batch{a: i64}, out: batch{}) {\nentry:\n emit\n}\nextra", + "trailing input", + ); +} + +#[test] +fn parser_rejects_wrong_dst_count() { + assert_parse_rejects( + "fn f(in: batch{a: i64?}, out: batch{o: i64}) {\nentry:\n %x = load.opt in.a\n emit\n}", + "'load.opt' defines 2 value(s), found 1", + ); +} + +// ----------------------------------------- adversarial-pass regressions -- +// Each of these pins a fix for a confirmed finding from the 2026-07-25 +// adversarial workflow (4 attack lenses + 2 reviews). + +/// Build the smallest valid API program shell around custom parts. +fn api_program( + statics: Vec, + name: &str, + blocks: Vec, +) -> Program { + use super::{Col, ColTy, Ty}; + Program { + statics, + name: name.into(), + in_cols: vec![], + out_cols: vec![Col { name: "o".into(), ty: ColTy { ty: Ty::I64, nullable: false } }], + blocks, + } +} + +fn store_emit_block() -> super::Block { + use super::{Block, Inst, Lit, Term, Value}; + Block { + params: vec![], + insts: vec![ + Inst::Const { dst: Value(0), lit: Lit::I64(1) }, + Inst::Store { col: 0, val: Value(0) }, + ], + term: Term::Emit, + } +} + +/// Deep-but-legal CFGs must verify, not abort the process — the reachability +/// DFS was recursive and stack-overflowed at ~8k blocks. +#[test] +fn deep_cfg_verifies_without_crashing() { + use super::{Block, BlockId, Term}; + let n: u32 = 50_000; + let mut blocks: Vec = (0..n - 1) + .map(|i| Block { + params: vec![], + insts: vec![], + term: Term::Jump { to: BlockId(i + 1), args: vec![] }, + }) + .collect(); + blocks.push(store_emit_block()); + let p = api_program(vec![], "deep", blocks); + verify(&p).expect("a deep linear CFG is legal"); +} + +/// `map() -> (..)` / `map(..) -> ()` cannot be expressed by the grammar, so +/// a verified program containing one could not be reloaded. +#[test] +fn rejects_empty_map_static_signatures() { + use super::{StaticTy, Ty}; + for st in [ + StaticTy::Map { keys: vec![], values: vec![Ty::I64] }, + StaticTy::Map { keys: vec![Ty::I64], values: vec![] }, + ] { + let p = api_program(vec![st], "f", vec![store_emit_block()]); + let errs = verify(&p).expect_err("empty map signature must not verify"); + assert!( + errs.iter().any(|e| e.to_string().contains("at least one key and one value")), + "wrong errors: {:?}", + errs.iter().map(|e| e.to_string()).collect::>() + ); + } +} + +/// A non-identifier (or empty) function name prints as unparseable text. +#[test] +fn rejects_non_identifier_function_name() { + for bad in ["weird name", "", "1fn", "a-b"] { + let p = api_program(vec![], bad, vec![store_emit_block()]); + let errs = verify(&p).expect_err("non-identifier fn name must not verify"); + assert!( + errs.iter().any(|e| e.to_string().contains("must be an identifier")), + "'{bad}': wrong errors: {:?}", + errs.iter().map(|e| e.to_string()).collect::>() + ); + } +} + +/// Every NaN payload canonicalizes to the one `nan` token; equality must +/// treat NaNs as one class or non-canonical payloads break the round-trip. +#[test] +fn non_canonical_nan_payload_round_trips() { + use super::{Block, Inst, Lit, Term, Value, Col, ColTy, Ty}; + let neg_quiet_nan = f64::from_bits(0xFFF8_0000_0000_0000); + let p = Program { + statics: vec![], + name: "f".into(), + in_cols: vec![], + out_cols: vec![Col { name: "o".into(), ty: ColTy { ty: Ty::F64, nullable: false } }], + blocks: vec![Block { + params: vec![], + insts: vec![ + Inst::Const { dst: Value(0), lit: Lit::F64(neg_quiet_nan) }, + Inst::Store { col: 0, val: Value(0) }, + ], + term: Term::Emit, + }], + }; + verify(&p).expect("NaN const is legal"); + assert_eq!(parsed(&print(&p)), p, "non-canonical NaN payload broke the round-trip"); +} + +/// Double stores are a lowering bug on ANY path, including trap-terminated +/// ones (deliberate: stricter than "only emit/skip paths"). +#[test] +fn rejects_double_store_on_trap_path() { + assert_verify_rejects( + r#"fn f(in: batch{a: i64}, out: batch{o: i64}) { +entry: + %x = load in.a + store out.o, %x + store out.o, %x + trap "boom" +}"#, + "stored more than once", + ); +} + +/// An unreachable island with an edge into a reachable join must not starve +/// the join's store dataflow — both the island error AND the join's store +/// error must surface in one verify pass. +#[test] +fn unreachable_island_does_not_mask_store_errors() { + let p = parsed( + r#"fn f(in: batch{a: i64}, out: batch{o: i64}) { +entry: + jump join +u1: + jump u2 +u2: + %c = const.i1 true + brif %c, u1, join +join: + emit +}"#, + ); + let errs = verify(&p).expect_err("island + missing store must not verify"); + let all: Vec = errs.iter().map(|e| e.to_string()).collect(); + assert!(all.iter().any(|m| m.contains("unreachable block")), "missing island error: {all:?}"); + assert!( + all.iter().any(|m| m.contains("emit without storing out.o")), + "store error masked by the island: {all:?}" + ); +} + +// Rule-coverage tests the design-conformance review found missing. + +#[test] +fn rejects_entry_with_params() { + assert_verify_rejects( + r#"fn f(in: batch{a: i64}, out: batch{o: i64}) { +entry(%p: i64): + store out.o, %p + emit +}"#, + "entry block cannot have params", + ); +} + +#[test] +fn rejects_duplicate_columns() { + use super::{Col, ColTy, Ty}; + let mut p = api_program(vec![], "f", vec![store_emit_block()]); + p.out_cols = vec![ + Col { name: "o".into(), ty: ColTy { ty: Ty::I64, nullable: false } }, + Col { name: "o".into(), ty: ColTy { ty: Ty::I64, nullable: false } }, + ]; + // Second column now unstored; the duplicate-name error is what matters. + let errs = verify(&p).expect_err("duplicate columns must not verify"); + assert!( + errs.iter().any(|e| e.to_string().contains("duplicate out column 'o'")), + "wrong errors: {:?}", + errs.iter().map(|e| e.to_string()).collect::>() + ); +} + +#[test] +fn rejects_branch_to_entry() { + assert_verify_rejects( + r#"fn f(in: batch{a: i64}, out: batch{o: i64}) { +entry: + jump entry +}"#, + "branch to entry block", + ); +} + +#[test] +fn rejects_sload_on_map_static() { + assert_verify_rejects( + r#"static @0: map(str) -> (i64) +fn f(in: batch{a: i64}, out: batch{o: i64}) { +entry: + %v = sload @0 + store out.o, %v + emit +}"#, + "is a map: use probe", + ); +} + +#[test] +fn rejects_wrong_opt_pairing_on_scalar_statics() { + assert_verify_rejects( + r#"static @0: scalar +fn f(in: batch{a: i64}, out: batch{o: i64}) { +entry: + %v = sload @0 + store out.o, %v + emit +}"#, + "is nullable: use sload.opt", + ); + assert_verify_rejects( + r#"static @0: scalar +fn f(in: batch{a: i64}, out: batch{o: i64}) { +entry: + %f, %v = sload.opt @0 + store out.o, %v + emit +}"#, + "is not nullable: use sload", + ); +} + +#[test] +fn rejects_store_opt_on_non_nullable_out() { + assert_verify_rejects( + r#"fn f(in: batch{a: i64}, out: batch{o: i64}) { +entry: + %x = load in.a + %t = const.i1 true + store.opt out.o, %t, %x + emit +}"#, + "is not nullable: use store", + ); +} + +// ------------------------------------------------------------- literals -- + +#[test] +fn literal_edge_cases_round_trip() { + let text = r#"fn lits(in: batch{a: i64}, out: batch{o: str}) { +entry: + %nan = const.f64 nan + %pinf = const.f64 inf + %ninf = const.f64 -inf + %nzero = const.f64 -0.0 + %tiny = const.f64 1e-5 + %big = const.f64 1e300 + %min = const.i64 -9223372036854775808 + %max = const.i64 9223372036854775807 + %esc = const.str "q\"b\\n\nl\tt\u{7}" + store out.o, %esc + emit +}"#; + let p = verified(text); + let printed = print(&p); + let p2 = parsed(&printed); + assert_eq!(p2, p, "literal round-trip changed the program:\n{printed}"); + // -0.0 must survive as -0.0 (bitwise equality, not IEEE ==). + let has_neg_zero = printed.contains("-0.0"); + assert!(has_neg_zero, "printer lost the sign of -0.0:\n{printed}"); +} + +// ----------------------------------------------------------------- fuzz -- + +#[test] +fn fuzz_round_trip() { + for seed in 0..300u64 { + let p = gen::gen_program(seed); + if let Err(errs) = verify(&p) { + let msgs: Vec = errs.iter().map(|e| e.to_string()).collect(); + panic!("generator produced an invalid program (seed {seed}): {msgs:?}\n{}", print(&p)); + } + let text = print(&p); + let p2 = match parse(&text) { + Ok(p2) => p2, + Err(e) => panic!("canonical text failed to parse (seed {seed}): {e}\n{text}"), + }; + assert_eq!(p2, p, "round-trip changed the program (seed {seed}):\n{text}"); + assert_eq!(print(&p2), text, "printing is not a fixpoint (seed {seed})"); + } +} diff --git a/src/specializer/ir/verify.rs b/src/specializer/ir/verify.rs new file mode 100644 index 0000000..968dbed --- /dev/null +++ b/src/specializer/ir/verify.rs @@ -0,0 +1,605 @@ +//! The verifier — the airtight boundary. Everything upstream (lowering) and +//! downstream (backends) is allowed to assume a verified program; nothing may +//! execute or compile an unverified one. +//! +//! Rules enforced (numbers referenced from tests): +//! 1. Structure: at least one block; entry (b0) has no params and is never a +//! branch target; batch column names unique per side; the function name +//! is an identifier and map statics have >= 1 key and >= 1 value column +//! (a verified program must print to parseable canonical text). +//! 2. SSA: every value defined exactly once function-wide; every use sees a +//! definition earlier in the same block or a param of the same block +//! (strict block-param form — cross-block uses are illegal, which is what +//! lets the verifier skip dominance analysis entirely). +//! 3. Types: every operand matches its instruction's signature; `.opt` forms +//! are mandatory for nullable columns/statics and illegal on non-nullable +//! ones (the null lane can be neither skipped nor invented). +//! 4. Statics: every `@N` resolves; probe/sload match the static's kind, +//! arity, and types. +//! 5. CFG: all blocks reachable from entry; no cycles (v0); branch args +//! match target params in count and type. +//! 6. Stores: no path stores a column twice, whatever its terminator +//! (including `trap` — a double store is always a lowering bug); paths +//! to `emit` store every column exactly once; paths to `skip` store +//! nothing; store states must agree at joins. + +use std::collections::HashMap; + +use super::{Block, Col, Inst, Program, StaticTy, Term, Ty, Value}; + +#[derive(Debug, PartialEq, Eq)] +pub struct VerifyError { + /// Block index, when the error is inside a block. + pub block: Option, + /// Instruction index within the block; `None` for param/terminator errors. + pub inst: Option, + pub msg: String, +} + +impl std::fmt::Display for VerifyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match (self.block, self.inst) { + (Some(b), Some(i)) => write!(f, "b{b}[{i}]: {}", self.msg), + (Some(b), None) => write!(f, "b{b}: {}", self.msg), + _ => write!(f, "{}", self.msg), + } + } +} + +pub fn verify(p: &Program) -> Result<(), Vec> { + let mut errs = Vec::new(); + + check_structure(p, &mut errs); + // Function-wide definition table: value -> (type, defining block), + // discovered at definition sites. Collected fully before use-checking so + // an early error doesn't cascade into spurious "undefined value" noise. + let mut def_types: HashMap = HashMap::new(); + collect_defs(p, &mut def_types, &mut errs); + for (bi, b) in p.blocks.iter().enumerate() { + check_block(p, bi, b, &def_types, &mut errs); + } + check_cfg_and_stores(p, &mut errs); + + if errs.is_empty() { + Ok(()) + } else { + Err(errs) + } +} + +fn err(errs: &mut Vec, block: Option, inst: Option, msg: String) { + errs.push(VerifyError { block, inst, msg }); +} + +fn check_structure(p: &Program, errs: &mut Vec) { + // A verified program must print to parseable canonical text, so text-only + // constraints (identifier function name, non-empty map signatures — the + // grammar cannot express `map() -> ()`) are verifier rules too. + if !super::print::is_ident(&p.name) { + err( + errs, + None, + None, + format!("function name '{}' must be an identifier", p.name), + ); + } + for (i, st) in p.statics.iter().enumerate() { + if let StaticTy::Map { keys, values } = st { + if keys.is_empty() || values.is_empty() { + err( + errs, + None, + None, + format!("@{i}: map statics need at least one key and one value column"), + ); + } + } + } + if p.blocks.is_empty() { + err(errs, None, None, "function has no blocks".to_string()); + return; + } + if !p.blocks[0].params.is_empty() { + err(errs, Some(0), None, "entry block cannot have params".to_string()); + } + for (side, cols) in [("in", &p.in_cols), ("out", &p.out_cols)] { + let mut seen: HashMap<&str, ()> = HashMap::new(); + for c in cols.iter() { + if seen.insert(c.name.as_str(), ()).is_some() { + err(errs, None, None, format!("duplicate {side} column '{}'", c.name)); + } + } + } +} + +/// First pass: register every definition (params + inst dsts) with its type, +/// flagging double definitions. A definition's type is derivable from the +/// instruction plus the column/static tables alone — except `select`, whose +/// result type equals its operands'; it is registered per its `a` operand +/// during the per-block pass (see `check_block`), and as a placeholder here +/// only to occupy the SSA slot. +fn collect_defs( + p: &Program, + def_types: &mut HashMap, + errs: &mut Vec, +) { + for (bi, b) in p.blocks.iter().enumerate() { + for (v, ty) in &b.params { + if def_types.insert(v.0, (*ty, bi)).is_some() { + err(errs, Some(bi), None, format!("%v{} defined more than once", v.0)); + } + } + for (ii, inst) in b.insts.iter().enumerate() { + for (dst, ty) in dst_types(p, inst) { + if def_types.insert(dst.0, (ty, bi)).is_some() { + err(errs, Some(bi), Some(ii), format!("%v{} defined more than once", dst.0)); + } + } + } + } +} + +/// Result types of an instruction's definitions, best-effort when the +/// program is malformed (bad indices fall back so verification continues and +/// the real error is reported at the site check). +fn dst_types(p: &Program, inst: &Inst) -> Vec<(Value, Ty)> { + let in_col = |c: u32| p.in_cols.get(c as usize).map(|c| c.ty.ty); + let scalar_ty = |id: u32| match p.statics.get(id as usize) { + Some(StaticTy::Scalar(ct)) => ct.ty, + _ => Ty::I1, + }; + match inst { + Inst::Const { dst, lit } => vec![(*dst, lit.ty())], + Inst::Bin { op, dst, .. } => vec![(*dst, op.sig().1)], + Inst::Cmp { dst, .. } | Inst::Not { dst, .. } => vec![(*dst, Ty::I1)], + // Placeholder; corrected per-block (see collect_defs docs). + Inst::Select { dst, .. } => vec![(*dst, Ty::I1)], + Inst::Itof { dst, .. } => vec![(*dst, Ty::F64)], + Inst::Ftoi { dst, .. } => vec![(*dst, Ty::I64)], + Inst::Itos { dst, .. } | Inst::Ftos { dst, .. } | Inst::Sconcat { dst, .. } => { + vec![(*dst, Ty::Str)] + } + Inst::StoiOpt { flag, dst, .. } => vec![(*flag, Ty::I1), (*dst, Ty::I64)], + Inst::StofOpt { flag, dst, .. } => vec![(*flag, Ty::I1), (*dst, Ty::F64)], + Inst::Load { dst, col } => vec![(*dst, in_col(*col).unwrap_or(Ty::I1))], + Inst::LoadOpt { flag, dst, col } => { + vec![(*flag, Ty::I1), (*dst, in_col(*col).unwrap_or(Ty::I1))] + } + Inst::Store { .. } | Inst::StoreOpt { .. } => vec![], + Inst::Probe { static_id, hit, dsts, .. } => { + let mut v = vec![(*hit, Ty::I1)]; + if let Some(StaticTy::Map { values, .. }) = p.statics.get(*static_id as usize) { + for (d, ty) in dsts.iter().zip(values.iter()) { + v.push((*d, *ty)); + } + } + // Dsts beyond the declared value columns keep no type; the site + // check reports the arity mismatch. + v + } + Inst::Sload { static_id, dst } => vec![(*dst, scalar_ty(*static_id))], + Inst::SloadOpt { static_id, flag, dst } => { + vec![(*flag, Ty::I1), (*dst, scalar_ty(*static_id))] + } + } +} + +/// Look up a use in the block's scope, reporting scope violations with a +/// message that distinguishes "defined later in this block" (use before def) +/// from "defined in another block" (illegal crossing) from "never defined". +fn scope_ty( + in_scope: &HashMap, + def_types: &HashMap, + v: Value, + what: &str, + bi: usize, + ii: Option, + errs: &mut Vec, +) -> Option { + match in_scope.get(&v.0) { + Some(ty) => Some(*ty), + None => { + let msg = match def_types.get(&v.0) { + Some((_, def_bi)) if *def_bi != bi => format!( + "{what} %v{} is not visible here: values cross blocks only as branch \ + args to block params", + v.0 + ), + _ => format!("{what} %v{} is used before any definition", v.0), + }; + err(errs, Some(bi), ii, msg); + None + } + } +} + +#[allow(clippy::too_many_arguments)] +fn want( + in_scope: &HashMap, + def_types: &HashMap, + v: Value, + ty: Ty, + what: &str, + bi: usize, + ii: Option, + errs: &mut Vec, +) { + if let Some(actual) = scope_ty(in_scope, def_types, v, what, bi, ii, errs) { + if actual != ty { + err( + errs, + Some(bi), + ii, + format!("{what} %v{} must be {}, got {}", v.0, ty.name(), actual.name()), + ); + } + } +} + +/// Second pass, per block: scoping (uses see only same-block earlier defs or +/// own params) and per-instruction operand typing. +fn check_block( + p: &Program, + bi: usize, + b: &Block, + def_types: &HashMap, + errs: &mut Vec, +) { + // Values visible at the current point of this block. + let mut in_scope: HashMap = HashMap::new(); + for (v, ty) in &b.params { + in_scope.insert(v.0, *ty); + } + + for (ii, inst) in b.insts.iter().enumerate() { + let i = Some(ii); + match inst { + Inst::Const { .. } => {} + Inst::Bin { op, a, b: rhs, .. } => { + let (operand, _) = op.sig(); + want(&in_scope, def_types, *a, operand, "operand", bi, i, errs); + want(&in_scope, def_types, *rhs, operand, "operand", bi, i, errs); + } + Inst::Cmp { ty, a, b: rhs, .. } => { + if *ty == Ty::I1 { + err(errs, Some(bi), i, "cmp on i1 is not defined; use xor/not".into()); + } + want(&in_scope, def_types, *a, *ty, "operand", bi, i, errs); + want(&in_scope, def_types, *rhs, *ty, "operand", bi, i, errs); + } + Inst::Not { a, .. } => { + want(&in_scope, def_types, *a, Ty::I1, "operand", bi, i, errs) + } + Inst::Select { dst, cond, a, b: rhs } => { + want(&in_scope, def_types, *cond, Ty::I1, "condition", bi, i, errs); + let ta = scope_ty(&in_scope, def_types, *a, "operand", bi, i, errs); + let tb = scope_ty(&in_scope, def_types, *rhs, "operand", bi, i, errs); + if let (Some(ta), Some(tb)) = (ta, tb) { + if ta != tb { + err( + errs, + Some(bi), + i, + format!("select arms differ: {} vs {}", ta.name(), tb.name()), + ); + } + } + // Correct the placeholder from collect_defs with the real type. + if let Some(ta) = ta { + in_scope.insert(dst.0, ta); + } + } + Inst::Itof { a, .. } => { + want(&in_scope, def_types, *a, Ty::I64, "operand", bi, i, errs) + } + Inst::Ftoi { a, .. } => { + want(&in_scope, def_types, *a, Ty::F64, "operand", bi, i, errs) + } + Inst::Itos { a, .. } => { + want(&in_scope, def_types, *a, Ty::I64, "operand", bi, i, errs) + } + Inst::Ftos { a, .. } => { + want(&in_scope, def_types, *a, Ty::F64, "operand", bi, i, errs) + } + Inst::StoiOpt { a, .. } | Inst::StofOpt { a, .. } => { + want(&in_scope, def_types, *a, Ty::Str, "operand", bi, i, errs) + } + Inst::Sconcat { a, b: rhs, .. } => { + want(&in_scope, def_types, *a, Ty::Str, "operand", bi, i, errs); + want(&in_scope, def_types, *rhs, Ty::Str, "operand", bi, i, errs); + } + Inst::Load { col, .. } => match p.in_cols.get(*col as usize) { + None => err(errs, Some(bi), i, format!("unknown in column {col}")), + Some(Col { ty, name }) if ty.nullable => { + err(errs, Some(bi), i, format!("in.{name} is nullable: use load.opt")) + } + Some(_) => {} + }, + Inst::LoadOpt { col, .. } => match p.in_cols.get(*col as usize) { + None => err(errs, Some(bi), i, format!("unknown in column {col}")), + Some(Col { ty, name }) if !ty.nullable => { + err(errs, Some(bi), i, format!("in.{name} is not nullable: use load")) + } + Some(_) => {} + }, + Inst::Store { col, val } => match p.out_cols.get(*col as usize) { + None => err(errs, Some(bi), i, format!("unknown out column {col}")), + Some(c) if c.ty.nullable => { + err(errs, Some(bi), i, format!("out.{} is nullable: use store.opt", c.name)) + } + Some(c) => { + want(&in_scope, def_types, *val, c.ty.ty, "stored value", bi, i, errs) + } + }, + Inst::StoreOpt { col, flag, val } => match p.out_cols.get(*col as usize) { + None => err(errs, Some(bi), i, format!("unknown out column {col}")), + Some(c) if !c.ty.nullable => { + err(errs, Some(bi), i, format!("out.{} is not nullable: use store", c.name)) + } + Some(c) => { + want(&in_scope, def_types, *flag, Ty::I1, "validity flag", bi, i, errs); + want(&in_scope, def_types, *val, c.ty.ty, "stored value", bi, i, errs); + } + }, + Inst::Probe { static_id, dsts, keys, .. } => { + match p.statics.get(*static_id as usize) { + None => err(errs, Some(bi), i, format!("unknown static @{static_id}")), + Some(StaticTy::Scalar(_)) => { + err(errs, Some(bi), i, format!("@{static_id} is a scalar: use sload")) + } + Some(StaticTy::Map { keys: kts, values: vts }) => { + if keys.len() != kts.len() { + err( + errs, + Some(bi), + i, + format!( + "@{static_id} has {} key(s), probe passes {}", + kts.len(), + keys.len() + ), + ); + } else { + for (k, kt) in keys.iter().zip(kts.iter()) { + want(&in_scope, def_types, *k, *kt, "probe key", bi, i, errs); + } + } + if dsts.len() != vts.len() { + err( + errs, + Some(bi), + i, + format!( + "@{static_id} has {} value column(s), probe defines {}", + vts.len(), + dsts.len() + ), + ); + } + } + } + } + Inst::Sload { static_id, .. } => match p.statics.get(*static_id as usize) { + None => err(errs, Some(bi), i, format!("unknown static @{static_id}")), + Some(StaticTy::Map { .. }) => { + err(errs, Some(bi), i, format!("@{static_id} is a map: use probe")) + } + Some(StaticTy::Scalar(ct)) if ct.nullable => { + err(errs, Some(bi), i, format!("@{static_id} is nullable: use sload.opt")) + } + Some(_) => {} + }, + Inst::SloadOpt { static_id, .. } => match p.statics.get(*static_id as usize) { + None => err(errs, Some(bi), i, format!("unknown static @{static_id}")), + Some(StaticTy::Map { .. }) => { + err(errs, Some(bi), i, format!("@{static_id} is a map: use probe")) + } + Some(StaticTy::Scalar(ct)) if !ct.nullable => { + err(errs, Some(bi), i, format!("@{static_id} is not nullable: use sload")) + } + Some(_) => {} + }, + } + + // Definitions become visible AFTER the instruction (no self-use). + for d in inst.dsts() { + let ty = def_types.get(&d.0).map(|(t, _)| *t).unwrap_or(Ty::I1); + // Select already inserted its corrected type above; keep it. + in_scope.entry(d.0).or_insert(ty); + } + } + + // Terminator: cond and branch args are uses in this block's final scope. + if let Term::Brif { cond, .. } = &b.term { + want(&in_scope, def_types, *cond, Ty::I1, "branch condition", bi, None, errs); + } + for (target, args) in b.term.successors() { + match p.blocks.get(target.0 as usize) { + None => err(errs, Some(bi), None, format!("branch to unknown block b{}", target.0)), + Some(tb) => { + if args.len() != tb.params.len() { + err( + errs, + Some(bi), + None, + format!( + "b{} expects {} arg(s), got {}", + target.0, + tb.params.len(), + args.len() + ), + ); + } else { + for (arg, (_, pty)) in args.iter().zip(tb.params.iter()) { + want(&in_scope, def_types, *arg, *pty, "branch arg", bi, None, errs); + } + } + if target.0 == 0 { + err(errs, Some(bi), None, "branch to entry block".to_string()); + } + } + } + } +} + +/// CFG shape (reachability, acyclicity) and the store-completeness dataflow. +fn check_cfg_and_stores(p: &Program, errs: &mut Vec) { + let n = p.blocks.len(); + if n == 0 { + return; + } + + // Iterative DFS for reachability + cycle detection (0 white, 1 gray, + // 2 black). Explicit stack, NOT recursion: a deep-but-legal CFG (large + // CASE/decision-tree lowerings) must not abort the process — recursion + // stack-overflowed at ~8k blocks under adversarial fuzzing. + let mut color = vec![0u8; n]; + let mut cyclic = false; + let mut stack: Vec<(usize, usize)> = vec![(0, 0)]; // (block, next successor index) + color[0] = 1; + while let Some(frame) = stack.last_mut() { + let (b, si) = *frame; + let succs = p.blocks[b].term.successors(); + if si < succs.len() { + frame.1 += 1; + let s = succs[si].0 .0 as usize; + if s >= n { + continue; // reported by check_block + } + match color[s] { + 0 => { + color[s] = 1; + stack.push((s, 0)); + } + 1 => cyclic = true, + _ => {} + } + } else { + color[b] = 2; + stack.pop(); + } + } + if cyclic { + err(errs, None, None, "control-flow cycle (v0 CFGs must be acyclic)".to_string()); + return; // the store dataflow below needs a DAG + } + let reachable: Vec = color.iter().map(|c| *c != 0).collect(); + for (bi, r) in reachable.iter().enumerate() { + if !r { + err(errs, Some(bi), None, "unreachable block".to_string()); + } + } + + // Store dataflow over the DAG in topological order. State: per out + // column, how many times it has been stored on every path reaching this + // point; all paths into a join must agree. + let ncols = p.out_cols.len(); + let mut entry_state: Vec>> = vec![None; n]; + entry_state[0] = Some(vec![0; ncols]); + + for bi in topo_order(p, n, &reachable) { + let Some(state) = entry_state[bi].clone() else { + continue; // unreachable; already reported + }; + let mut state_out = state; + for (ii, inst) in p.blocks[bi].insts.iter().enumerate() { + let col = match inst { + Inst::Store { col, .. } | Inst::StoreOpt { col, .. } => *col as usize, + _ => continue, + }; + if col < ncols { + state_out[col] = state_out[col].saturating_add(1); + if state_out[col] > 1 { + err( + errs, + Some(bi), + Some(ii), + format!("out.{} stored more than once on this path", p.out_cols[col].name), + ); + } + } + } + match &p.blocks[bi].term { + Term::Emit => { + for (ci, count) in state_out.iter().enumerate() { + if *count == 0 { + err( + errs, + Some(bi), + None, + format!("emit without storing out.{}", p.out_cols[ci].name), + ); + } + } + } + Term::Skip => { + if state_out.iter().any(|c| *c > 0) { + err( + errs, + Some(bi), + None, + "skip after storing (a skipped row must store nothing)".to_string(), + ); + } + } + Term::Trap { .. } => {} + _ => { + for (succ, _) in p.blocks[bi].term.successors() { + let s = succ.0 as usize; + if s >= n { + continue; + } + match &entry_state[s] { + None => entry_state[s] = Some(state_out.clone()), + Some(existing) if *existing != state_out => { + err( + errs, + Some(s), + None, + "paths joining here disagree on which out columns are stored" + .to_string(), + ); + } + Some(_) => {} + } + } + } + } + } +} + +/// Topological order of the reachable, acyclicity-checked subgraph via +/// Kahn's algorithm. Indegrees count only edges whose SOURCE is reachable: +/// an edge out of an unreachable island (which may itself be cyclic and +/// never drain) must not starve a reachable join of its dataflow visit — +/// that would mask the join's store errors behind the island's +/// unreachable-block errors and make them reappear one fix later. +fn topo_order(p: &Program, n: usize, reachable: &[bool]) -> Vec { + let mut indegree = vec![0usize; n]; + for (bi, b) in p.blocks.iter().enumerate() { + if !reachable[bi] { + continue; + } + for (succ, _) in b.term.successors() { + let s = succ.0 as usize; + if s < n { + indegree[s] += 1; + } + } + } + let mut stack: Vec = (0..n).filter(|&i| reachable[i] && indegree[i] == 0).collect(); + let mut order = Vec::with_capacity(n); + while let Some(b) = stack.pop() { + order.push(b); + for (succ, _) in p.blocks[b].term.successors() { + let s = succ.0 as usize; + if s < n { + indegree[s] -= 1; + if indegree[s] == 0 { + stack.push(s); + } + } + } + } + order +} diff --git a/src/specializer/mod.rs b/src/specializer/mod.rs new file mode 100644 index 0000000..c4c38c9 --- /dev/null +++ b/src/specializer/mod.rs @@ -0,0 +1,11 @@ +//! The SQL specializer: a partial evaluator that turns (fixed SQL + static +//! tables) into a specialized native function `f : Rows -> Rows`, prepared +//! once and invoked millions of times with a small dynamic input relation. +//! +//! Design: docs/superpowers/specs/2026-07-25-sql-specializer-design.md. +//! Build order (backlog milestone m-7): the imperative IR below (M-ir), then +//! the closure-compiled interpreter oracle (M-interp), the frontend + BTA + +//! lowering (M-lower), the Cranelift backend (M-cranelift), and the generated +//! Python-boundary marshaller (M-boundary). + +pub mod ir;