From 8e4b829389fed9a62beb419955c8d0187f0f975b Mon Sep 17 00:00:00 2001 From: Nicole Date: Fri, 2 Oct 2026 16:35:09 -0300 Subject: [PATCH 01/29] =?UTF-8?q?Bound=20MUL/DVRM=20multiplicities=20and?= =?UTF-8?q?=20bit-constrain=20SHIFT=20zbs=20and=20=CE=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- executor/programs/asm/poc_divu_commit.s | 22 + executor/programs/asm/poc_mul_commit.s | 22 + executor/programs/asm/poc_sll_commit.s | 22 + prover/src/tables/dvrm.rs | 76 ++- prover/src/tables/mul.rs | 77 ++- prover/src/tables/shift.rs | 21 +- prover/src/tables/trace_builder.rs | 158 +++--- prover/src/tests/dvrm_tests.rs | 128 ++++- prover/src/tests/mod.rs | 2 + prover/src/tests/mul_tests.rs | 143 +++++- prover/src/tests/multiplicity_forgery_poc.rs | 485 +++++++++++++++++++ prover/src/tests/shift_tests.rs | 173 ++++++- 12 files changed, 1227 insertions(+), 102 deletions(-) create mode 100644 executor/programs/asm/poc_divu_commit.s create mode 100644 executor/programs/asm/poc_mul_commit.s create mode 100644 executor/programs/asm/poc_sll_commit.s create mode 100644 prover/src/tests/multiplicity_forgery_poc.rs diff --git a/executor/programs/asm/poc_divu_commit.s b/executor/programs/asm/poc_divu_commit.s new file mode 100644 index 000000000..22cab3c7e --- /dev/null +++ b/executor/programs/asm/poc_divu_commit.s @@ -0,0 +1,22 @@ + .text + .attribute 5, "rv64i2p1_m2p0" + .globl main +main: + # Compute 7 / 2 with a real DIVU, spill the quotient to the stack and + # commit it. The committed public output is the DVRM chip's quotient, + # so a proof reporting anything but 3 certifies a false division. + addi a3, zero, 7 + addi a4, zero, 2 + divu t1, a3, a4 # t1 = 7 / 2 + addi sp, sp, -16 + sd t1, 0(sp) # spill to stack + li a0, 1 # fd = 1 + mv a1, sp # buf = sp + li a2, 8 # count = 8 + li a7, 64 # syscall = Commit + ecall + + addi sp, sp, 16 + li a0, 0 + li a7, 93 # syscall = Halt + ecall diff --git a/executor/programs/asm/poc_mul_commit.s b/executor/programs/asm/poc_mul_commit.s new file mode 100644 index 000000000..6bc4000ef --- /dev/null +++ b/executor/programs/asm/poc_mul_commit.s @@ -0,0 +1,22 @@ + .text + .attribute 5, "rv64i2p1_m2p0_zmmul1p0" + .globl main +main: + # Compute 3 * 5 with a real MUL, spill the product to the stack and + # commit it. The committed public output is the MUL chip's lo result, + # so a proof reporting anything but 15 certifies a false product. + addi a3, zero, 3 + addi a4, zero, 5 + mul t1, a3, a4 # t1 = 3 * 5 + addi sp, sp, -16 + sd t1, 0(sp) # spill to stack + li a0, 1 # fd = 1 + mv a1, sp # buf = sp + li a2, 8 # count = 8 + li a7, 64 # syscall = Commit + ecall + + addi sp, sp, 16 + li a0, 0 + li a7, 93 # syscall = Halt + ecall diff --git a/executor/programs/asm/poc_sll_commit.s b/executor/programs/asm/poc_sll_commit.s new file mode 100644 index 000000000..9647f48ea --- /dev/null +++ b/executor/programs/asm/poc_sll_commit.s @@ -0,0 +1,22 @@ + .text + .attribute 5, "rv64i2p1" + .globl main +main: + # Compute 1 << 1 with a real SLL, spill the result to the stack and + # commit it. The committed public output is the SHIFT chip's result, + # so a proof reporting anything but 2 certifies a false shift. + addi a3, zero, 1 + addi a4, zero, 1 + sll t1, a3, a4 # t1 = 1 << 1 + addi sp, sp, -16 + sd t1, 0(sp) # spill to stack + li a0, 1 # fd = 1 + mv a1, sp # buf = sp + li a2, 8 # count = 8 + li a7, 64 # syscall = Commit + ecall + + addi sp, sp, 16 + li a0, 0 + li a7, 93 # syscall = Halt + ecall diff --git a/prover/src/tables/dvrm.rs b/prover/src/tables/dvrm.rs index c499a72bf..b388e78cd 100644 --- a/prover/src/tables/dvrm.rs +++ b/prover/src/tables/dvrm.rs @@ -22,7 +22,7 @@ //! - `sign_n`, `sign_d`, `sign_q`, `sign_r`: Bit - sign bits //! //! ## Bus Interactions -//! - Sender: IS_HALF (×20: n, d, r, n_sub_r, q) +//! - Sender: IS_HALF (×20: n, d, r, n_sub_r, q; ×2: μ_q, μ_r bounds) //! - Sender: MSB16 (×3 for sign extraction: n, d, r) //! - Sender: ALU (×3, on the unified bus: ×1 LT-flavored for `|r| < |d|`, //! ×2 MUL-flavored for `n - r = d * q` lo/hi) @@ -273,19 +273,21 @@ impl DvrmOperation { // Trace generation // ========================================================================= -/// Generates the DVRM trace table from a list of operations. -/// -/// Operations are deduplicated by (n, d, signed). -/// Each unique operation tracks separate multiplicities for quotient and remainder lookups. -/// -/// # Arguments -/// * `operations` - List of (DvrmOperation, wants_remainder) pairs -pub fn generate_dvrm_trace( +/// Largest per-row multiplicity: `μ_q` and `μ_r` are each range-checked to a +/// halfword (`IS_HALF[μ]`, weighted by μ), so a row holds at most this many +/// lookups of each kind. The bound is what makes `μ_q + μ_r = 0` imply +/// `μ_q = μ_r = 0`: without it a `μ_q = 1, μ_r = −1` row receives a quotient +/// lookup while sending none of its checks (forged `n = q·d + r`). +pub const MU_MAX: u64 = (1 << 16) - 1; + +/// Deduplicates DVRM operations into trace rows: `(n, d, signed) -> (μ_q, μ_r)`, +/// splitting an op over several rows when a count exceeds [`MU_MAX`]. Shared by +/// trace generation and the BITWISE collector so the per-row lookups they count +/// cannot drift apart. +pub fn dedup_dvrm_rows( operations: &[(DvrmOperation, bool)], -) -> TraceTable { - // Deduplicate: (n, d, signed) -> (mu_q, mu_r) +) -> Vec<(DvrmOperation, DvrmMultiplicities)> { let mut op_map: HashMap = HashMap::new(); - for (op, wants_remainder) in operations { let entry = op_map.entry(op.clone()).or_default(); if *wants_remainder { @@ -295,7 +297,32 @@ pub fn generate_dvrm_trace( } } - let unique_ops: Vec<_> = op_map.into_iter().collect(); + let mut rows = Vec::with_capacity(op_map.len()); + for (op, mut left) in op_map { + while left.mu_q > 0 || left.mu_r > 0 { + let row = DvrmMultiplicities { + mu_q: left.mu_q.min(MU_MAX), + mu_r: left.mu_r.min(MU_MAX), + }; + left.mu_q -= row.mu_q; + left.mu_r -= row.mu_r; + rows.push((op.clone(), row)); + } + } + rows +} + +/// Generates the DVRM trace table from a list of operations. +/// +/// Operations are deduplicated by (n, d, signed) (see [`dedup_dvrm_rows`]). +/// Each unique operation tracks separate multiplicities for quotient and remainder lookups. +/// +/// # Arguments +/// * `operations` - List of (DvrmOperation, wants_remainder) pairs +pub fn generate_dvrm_trace( + operations: &[(DvrmOperation, bool)], +) -> TraceTable { + let unique_ops = dedup_dvrm_rows(operations); let num_rows = unique_ops.len().next_power_of_two().max(4); let mut trace = TraceTable::new_main( crate::tables::types::zeroed_fe_vec(num_rows * cols::NUM_COLUMNS), @@ -357,6 +384,7 @@ pub fn generate_dvrm_trace( /// /// The DVRM table: /// - **Sends** IS_HALF lookups for n, d, q, r, n_sub_r range checks (×20) +/// and for the μ_q/μ_r multiplicity bounds (×2) /// - **Sends** MSB16 lookups for sign extraction (×3: n, d, r) /// - **Sends** LT lookup for |r| < |d| (×1) /// - **Sends** MUL lookups for n_sub_r = d * q verification (×2: lo and hi) @@ -365,6 +393,28 @@ pub fn generate_dvrm_trace( pub fn bus_interactions() -> Vec { let mut interactions = Vec::new(); + // ------------------------------------------------------------------------- + // IS_HALF[μ_q] | μ_q, IS_HALF[μ_r] | μ_r. + // Every check below fires with μ_q + μ_r, so the multiplicities themselves + // must be bounded and non-negative. Otherwise μ_q = 1, μ_r = −1 receives a + // quotient lookup with all checks off (forged quotient), + // and an honest μ_r = 1 copy of the row cancels the stray −1. Each μ is sent + // with itself as multiplicity: `k` rows holding an out-of-range value `v` + // put weight `k·v ≠ 0` on a tuple IS_HALF has no row for, while padding + // (μ = 0) contributes nothing, so an empty instance still matches an absent + // one. See `MU_MAX`. + // ------------------------------------------------------------------------- + for col in [cols::MU_Q, cols::MU_R] { + interactions.push(BusInteraction::sender( + BusId::IsHalfword, + Multiplicity::Column(col), + vec![BusValue::Packed { + start_column: col, + packing: Packing::Direct, + }], + )); + } + // ------------------------------------------------------------------------- // DVRM-A1.i: IS_HALF[n[i]] (×4) and DVRM-A2.i: IS_HALF[d[i]] (×4), // multiplicity: μ_q + μ_r. diff --git a/prover/src/tables/mul.rs b/prover/src/tables/mul.rs index a615f74df..b9b2576f5 100644 --- a/prover/src/tables/mul.rs +++ b/prover/src/tables/mul.rs @@ -25,7 +25,8 @@ //! //! ## Bus Interactions //! - Sender: MSB16 (×2 for sign extraction) -//! - Sender: IS_HALF (×16 for lhs/rhs input and lo/hi output range checks) +//! - Sender: IS_HALF (×16 for lhs/rhs input and lo/hi output range checks, +//! ×2 for the μ_lo/μ_hi multiplicity bounds) //! - Sender: IS_B20 (×4 for carry range checks) //! - Receiver: ALU (×2 for lo and hi results — every MUL lookup, CPU //! MUL/MULH dispatch and dvrm's internal `d*q` consistency) @@ -281,19 +282,21 @@ impl MulOperation { // Trace generation // ========================================================================= -/// Generates the MUL trace table from a list of operations. -/// -/// Operations are deduplicated by (lhs, lhs_signed, rhs, rhs_signed). -/// Each unique operation tracks separate multiplicities for lo and hi lookups. -/// -/// # Arguments -/// * `operations` - List of (MulOperation, wants_hi) pairs -pub fn generate_mul_trace( +/// Largest per-row multiplicity: `μ_lo` and `μ_hi` are each range-checked to a +/// halfword (`IS_HALF[μ]`, weighted by μ), so a row holds at most this many +/// lookups of each kind. The bound is what makes `μ_lo + μ_hi = 0` imply +/// `μ_lo = μ_hi = 0`: without it a `μ_lo = 1, μ_hi = −1` row receives a lookup +/// while sending none of its range checks. +pub const MU_MAX: u64 = (1 << 16) - 1; + +/// Deduplicates MUL operations into trace rows: `(lhs, lhs_signed, rhs, +/// rhs_signed) -> (μ_lo, μ_hi)`, splitting an op over several rows when a count +/// exceeds [`MU_MAX`]. Shared by trace generation and the BITWISE collector so +/// the per-row lookups they count cannot drift apart. +pub fn dedup_mul_rows( operations: &[(MulOperation, bool)], -) -> TraceTable { - // Deduplicate: (lhs, lhs_signed, rhs, rhs_signed) -> (mu_lo, mu_hi) +) -> Vec<(MulOperation, MulMultiplicities)> { let mut op_map: HashMap = HashMap::new(); - for (op, wants_hi) in operations { let entry = op_map.entry(op.clone()).or_default(); if *wants_hi { @@ -303,7 +306,32 @@ pub fn generate_mul_trace( } } - let unique_ops: Vec<_> = op_map.into_iter().collect(); + let mut rows = Vec::with_capacity(op_map.len()); + for (op, mut left) in op_map { + while left.mu_lo > 0 || left.mu_hi > 0 { + let row = MulMultiplicities { + mu_lo: left.mu_lo.min(MU_MAX), + mu_hi: left.mu_hi.min(MU_MAX), + }; + left.mu_lo -= row.mu_lo; + left.mu_hi -= row.mu_hi; + rows.push((op.clone(), row)); + } + } + rows +} + +/// Generates the MUL trace table from a list of operations. +/// +/// Operations are deduplicated by (lhs, lhs_signed, rhs, rhs_signed). +/// Each unique operation tracks separate multiplicities for lo and hi lookups. +/// +/// # Arguments +/// * `operations` - List of (MulOperation, wants_hi) pairs +pub fn generate_mul_trace( + operations: &[(MulOperation, bool)], +) -> TraceTable { + let unique_ops = dedup_mul_rows(operations); let num_rows = unique_ops.len().next_power_of_two().max(4); let mut trace = TraceTable::new_main( crate::tables::types::zeroed_fe_vec(num_rows * cols::NUM_COLUMNS), @@ -358,6 +386,7 @@ pub fn generate_mul_trace( /// The MUL table: /// - **Sends** MSB16 lookups for sign bit extraction (×2) /// - **Sends** IS_HALF lookups for lhs/rhs input and lo/hi output range checks (×16) +/// and for the μ_lo/μ_hi multiplicity bounds (×2) /// - **Sends** IS_B20 lookups for carry range checks (×4) /// - **Receives** MUL lookups from CPU table (×2: lo and hi) pub fn bus_interactions() -> Vec { @@ -398,6 +427,28 @@ pub fn bus_interactions() -> Vec { ], )); + // ------------------------------------------------------------------------- + // IS_HALF[μ_lo] | μ_lo, IS_HALF[μ_hi] | μ_hi. + // Every check below fires with μ_lo + μ_hi, so the multiplicities themselves + // must be bounded and non-negative. Otherwise μ_lo = 1, μ_hi = −1 receives a + // `lo` lookup with all range checks off (forged product), + // and an honest μ_hi = 1 copy of the row cancels the stray −1. Each μ is sent + // with itself as multiplicity: `k` rows holding an out-of-range value `v` + // put weight `k·v ≠ 0` on a tuple IS_HALF has no row for, while padding + // (μ = 0) contributes nothing, so an empty instance still matches an absent + // one. See `MU_MAX`. + // ------------------------------------------------------------------------- + for col in [cols::MU_LO, cols::MU_HI] { + interactions.push(BusInteraction::sender( + BusId::IsHalfword, + Multiplicity::Column(col), + vec![BusValue::Packed { + start_column: col, + packing: Packing::Direct, + }], + )); + } + // ------------------------------------------------------------------------- // IS_HALF lookups for lhs/rhs INPUT range checks (multiplicity: mu_lo + mu_hi). // The bus binds only the packed 32-bit words, so without these the input diff --git a/prover/src/tables/shift.rs b/prover/src/tables/shift.rs index 75d7605db..99cee2f8b 100644 --- a/prover/src/tables/shift.rs +++ b/prover/src/tables/shift.rs @@ -726,14 +726,14 @@ pub fn bus_interactions() -> Vec { } /// Total number of SHIFT transition constraints. -pub const NUM_SHIFT_CONSTRAINTS: usize = 19; +pub const NUM_SHIFT_CONSTRAINTS: usize = 21; // ========================================================================= // Single-body constraint set (ConstraintSet front-end) // ========================================================================= // // One body against the generic `ConstraintBuilder` serves the compiled prover -// folder, the verifier folder and IR capture. Constraint indices 0..19. +// folder, the verifier folder and IR capture. Constraint indices 0..21. use stark::constraints::builder::{ConstraintBuilder, ConstraintSet}; @@ -893,6 +893,23 @@ impl ConstraintSet for ShiftConstraints { let one = b.one(); b.emit_base(16 + off, flag.clone() * (one - flag)); } + + // idx 19: ZbsIsBit — zbs * (1 - zbs). The only other anchor on zbs is + // `ZERO[bit_shift] → zbs | μ`, which vanishes on a μ = 0 padding row; there + // a free zbs = 2 turns the five HWSL senders' `1 - zbs` into −1, so the row + // *provides* arbitrary HWSL tuples and cancels a real row's forged ones + // (`SLL 1, 1 = 0` verified). + let zbs = b.main(0, cols::ZBS); + let one = b.one(); + b.emit_base(19, zbs.clone() * (one - zbs)); + + // idx 20: MuIsBit — μ * (1 - μ). SHIFT never deduplicates (one row per op), + // and idx 0 only pins μ = 1 when direction = 1. For a left shift a free μ + // scales the output (`left = μ - direction`), and μ = −1 turns every + // μ-gated lookup into a provider. + let mu = b.main(0, cols::MU); + let one = b.one(); + b.emit_base(20, mu.clone() * (one - mu)); } } diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index c3b695a80..e72ce502e 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -1734,9 +1734,11 @@ fn collect_bitwise_from_lt(lt_ops: &[LtOperation]) -> Vec { /// IS_HALF lookups for lhs/rhs input and lo/hi output range checks, /// and IS_B20 lookups for carry range checks. /// -/// IS_HALF and IS_B20 are emitted once per raw op. MSB16 is deduplicated -/// per `max_rows_mul` chunk, mirroring `chunk_and_generate_optional` — a unique -/// signed op that spans two instances is sent twice and must be tallied twice. +/// IS_HALF and IS_B20 are emitted once per raw op. The per-row lookups (MSB16 +/// and the IS_HALF[μ_lo]/IS_HALF[μ_hi] multiplicity bounds) are counted over the +/// rows of each `max_rows_mul` chunk, mirroring `chunk_and_generate_optional` — +/// a unique signed op that spans two instances is sent twice and must be tallied +/// twice. /// /// Returns: Vec of bitwise lookups pub(crate) fn collect_bitwise_from_mul( @@ -1793,28 +1795,38 @@ pub(crate) fn collect_bitwise_from_mul( } } - // MSB16: dedup per chunk — the MUL AIR sends Msb16 once per unique signed row - // per instance, so the collector must mirror the same chunk boundary. + // Per-row lookups, over exactly the rows `generate_mul_trace` builds for each + // `max_rows_mul` chunk (`chunk_and_generate_optional`): IS_HALF[μ_lo] and + // IS_HALF[μ_hi] are sent μ_lo / μ_hi times (so padding rows send nothing), and + // MSB16 fires once per row whose sign flag is set — a unique signed op spanning two + // instances, or split over two rows by `mul::MU_MAX`, is sent once per row. + let halfword = |v: u64| { + BitwiseOperation::halfword( + BitwiseOperationType::IsHalf, + (v & 0xFF) as u8, + ((v >> 8) & 0xFF) as u8, + ) + }; for chunk in mul_ops.chunks(max_rows_mul) { - let mut msb16_seen = std::collections::HashSet::new(); - for (op, _wants_hi) in chunk { - if msb16_seen.insert((op.lhs, op.lhs_signed, op.rhs, op.rhs_signed)) { - if op.lhs_signed { - let lhs_3 = ((op.lhs >> 48) & 0xFFFF) as u16; - bitwise_ops.push(BitwiseOperation::halfword( - BitwiseOperationType::Msb16, - (lhs_3 & 0xFF) as u8, - (lhs_3 >> 8) as u8, - )); - } - if op.rhs_signed { - let rhs_3 = ((op.rhs >> 48) & 0xFFFF) as u16; - bitwise_ops.push(BitwiseOperation::halfword( - BitwiseOperationType::Msb16, - (rhs_3 & 0xFF) as u8, - (rhs_3 >> 8) as u8, - )); - } + for (op, mu) in &mul::dedup_mul_rows(chunk) { + for m in [mu.mu_lo, mu.mu_hi] { + bitwise_ops.extend(std::iter::repeat_n(halfword(m), m as usize)); + } + if op.lhs_signed { + let lhs_3 = ((op.lhs >> 48) & 0xFFFF) as u16; + bitwise_ops.push(BitwiseOperation::halfword( + BitwiseOperationType::Msb16, + (lhs_3 & 0xFF) as u8, + (lhs_3 >> 8) as u8, + )); + } + if op.rhs_signed { + let rhs_3 = ((op.rhs >> 48) & 0xFFFF) as u16; + bitwise_ops.push(BitwiseOperation::halfword( + BitwiseOperationType::Msb16, + (rhs_3 & 0xFF) as u8, + (rhs_3 >> 8) as u8, + )); } } } @@ -1831,9 +1843,10 @@ pub(crate) fn collect_bitwise_from_mul( /// table itself (n/d IS_HALF senders in dvrm::bus_interactions), so their lookups /// are collected here alongside the constraint-level ones. /// -/// IS_HALF and ZERO (C8/C20) are emitted once per raw op. MSB16 and the -/// NEG-template ZERO lookups (C3/C5) are deduplicated per `max_rows_dvrm` -/// chunk, mirroring `chunk_and_generate_optional`. +/// IS_HALF and ZERO (C8/C20) are emitted once per raw op. MSB16, the +/// NEG-template ZERO lookups (C3/C5) and the IS_HALF[μ_q]/IS_HALF[μ_r] +/// multiplicity bounds are counted over the rows of each `max_rows_dvrm` chunk, +/// mirroring `chunk_and_generate_optional`. /// /// Returns: Vec of bitwise lookups pub(crate) fn collect_bitwise_from_dvrm( @@ -1920,11 +1933,26 @@ pub(crate) fn collect_bitwise_from_dvrm( bitwise_ops.push(BitwiseOperation::zero(d_sum)); } - // MSB16: same per-chunk dedup as MUL (Column(SIGNED) is a bit, not a count). + // Per-row lookups, over exactly the rows `generate_dvrm_trace` builds for each + // `max_rows_dvrm` chunk (`chunk_and_generate_optional`): IS_HALF[μ_q] and + // IS_HALF[μ_r] sent μ_q / μ_r times (padding rows send nothing); MSB16 (Column(SIGNED)) + // and the NEG-template ZERO lookups (Column(SIGN_R)/Column(SIGN_D)) once per + // row whose bit is set. An op split over two rows by `dvrm::MU_MAX` sends + // them once per row. + let halfword = |v: u64| { + BitwiseOperation::halfword( + BitwiseOperationType::IsHalf, + (v & 0xFF) as u8, + ((v >> 8) & 0xFF) as u8, + ) + }; for chunk in dvrm_ops.chunks(max_rows_dvrm) { - let mut msb16_seen = std::collections::HashSet::new(); - for (op, _wants_remainder) in chunk { - if op.signed && msb16_seen.insert(op.clone()) { + for (op, mu) in &dvrm::dedup_dvrm_rows(chunk) { + for m in [mu.mu_q, mu.mu_r] { + bitwise_ops.extend(std::iter::repeat_n(halfword(m), m as usize)); + } + + if op.signed { let r = op.compute_remainder(); // MSB16[n[3]] @@ -1951,46 +1979,38 @@ pub(crate) fn collect_bitwise_from_dvrm( (d_3 >> 8) as u8, )); } - } - } - // ZERO (NEG template): same — SIGN_R/SIGN_D are bits, dedup per chunk. - for chunk in dvrm_ops.chunks(max_rows_dvrm) { - let mut zero_seen = std::collections::HashSet::new(); - for (op, _wants_remainder) in chunk { - if zero_seen.insert(op.clone()) { - // C3: NEG for r (when sign_r = 1) - if op.sign_r() { - let r = op.compute_remainder(); - let r_halves: [u32; 4] = [ - (r & 0xFFFF) as u32, - ((r >> 16) & 0xFFFF) as u32, - ((r >> 32) & 0xFFFF) as u32, - ((r >> 48) & 0xFFFF) as u32, - ]; - // C3a: ZERO[1-carry_r[0]; r[0]+r[1]] - bitwise_ops.push(BitwiseOperation::zero(r_halves[0] + r_halves[1])); - // C3b: ZERO[1-carry_r[1]; r[0]+r[1]+r[2]+r[3]] - bitwise_ops.push(BitwiseOperation::zero( - r_halves[0] + r_halves[1] + r_halves[2] + r_halves[3], - )); - } + // C3: NEG for r (when sign_r = 1) + if op.sign_r() { + let r = op.compute_remainder(); + let r_halves: [u32; 4] = [ + (r & 0xFFFF) as u32, + ((r >> 16) & 0xFFFF) as u32, + ((r >> 32) & 0xFFFF) as u32, + ((r >> 48) & 0xFFFF) as u32, + ]; + // C3a: ZERO[1-carry_r[0]; r[0]+r[1]] + bitwise_ops.push(BitwiseOperation::zero(r_halves[0] + r_halves[1])); + // C3b: ZERO[1-carry_r[1]; r[0]+r[1]+r[2]+r[3]] + bitwise_ops.push(BitwiseOperation::zero( + r_halves[0] + r_halves[1] + r_halves[2] + r_halves[3], + )); + } - // C5: NEG for d (when sign_d = 1) - if op.sign_d() { - let d_halves: [u32; 4] = [ - (op.d & 0xFFFF) as u32, - ((op.d >> 16) & 0xFFFF) as u32, - ((op.d >> 32) & 0xFFFF) as u32, - ((op.d >> 48) & 0xFFFF) as u32, - ]; - // C5a: ZERO[1-carry_d[0]; d[0]+d[1]] - bitwise_ops.push(BitwiseOperation::zero(d_halves[0] + d_halves[1])); - // C5b: ZERO[1-carry_d[1]; d[0]+d[1]+d[2]+d[3]] - bitwise_ops.push(BitwiseOperation::zero( - d_halves[0] + d_halves[1] + d_halves[2] + d_halves[3], - )); - } + // C5: NEG for d (when sign_d = 1) + if op.sign_d() { + let d_halves: [u32; 4] = [ + (op.d & 0xFFFF) as u32, + ((op.d >> 16) & 0xFFFF) as u32, + ((op.d >> 32) & 0xFFFF) as u32, + ((op.d >> 48) & 0xFFFF) as u32, + ]; + // C5a: ZERO[1-carry_d[0]; d[0]+d[1]] + bitwise_ops.push(BitwiseOperation::zero(d_halves[0] + d_halves[1])); + // C5b: ZERO[1-carry_d[1]; d[0]+d[1]+d[2]+d[3]] + bitwise_ops.push(BitwiseOperation::zero( + d_halves[0] + d_halves[1] + d_halves[2] + d_halves[3], + )); } } } diff --git a/prover/src/tests/dvrm_tests.rs b/prover/src/tests/dvrm_tests.rs index 8179a98ec..c129f45b6 100644 --- a/prover/src/tests/dvrm_tests.rs +++ b/prover/src/tests/dvrm_tests.rs @@ -311,8 +311,9 @@ fn test_bus_interactions_count() { // - 2x MUL senders (n_sub_r = d*q lo + hi) // - 6x ZERO senders (C3×2 NEG r, C5×2 NEG d, C8 overflow, C17 div_by_zero) // - 2x DVRM receivers (quotient, remainder) - // Total: 8 + 12 + 3 + 1 + 2 + 6 + 2 = 34 - assert_eq!(interactions.len(), 34, "Expected 34 bus interactions"); + // - 2x IS_HALF senders bounding the multiplicities (μ_q, μ_r) + // Total: 8 + 12 + 3 + 1 + 2 + 6 + 2 + 2 = 36 + assert_eq!(interactions.len(), 36, "Expected 36 bus interactions"); } #[test] @@ -555,3 +556,126 @@ fn neg_template_zero_lookups_dedup_per_chip_instance() { {two_instances} (2 instances) must exceed {one_instance} (1 instance)" ); } + +// Soundness regression: μ_q/μ_r are bounded, non-negative multiplicities. +// Every DVRM check fires with μ_q + μ_r, so a free μ_r = −1 next to μ_q = 1 +// switched them all off (`multiplicity_forgery_poc`). Each μ is now IS_HALF-checked +// weighted by itself, and trace generation splits a row whose count would +// exceed `MU_MAX`. + +/// Presence: each multiplicity is IS_HALF-checked weighted by itself, so an +/// out-of-range value `v` always lands on the bus with weight `v ≠ 0`. +#[test] +fn test_dvrm_bounds_its_multiplicities() { + use crate::tables::types::BusId; + use stark::lookup::{BusValue, Multiplicity, Packing}; + for col in [cols::MU_Q, cols::MU_R] { + assert!( + bus_interactions().iter().any(|i| i.is_sender + && i.bus_id == BusId::IsHalfword as u64 + && matches!(i.multiplicity, Multiplicity::Column(m) if m == col) + && matches!(i.values.as_slice(), + [BusValue::Packed { start_column, packing: Packing::Direct }] if *start_column == col)), + "DVRM must IS_HALF-check multiplicity column {col} weighted by itself" + ); + } +} + +/// Splitting: a count above `MU_MAX` spreads over rows within the bound that +/// preserve both totals; exactly `MU_MAX` still fits one row. +#[test] +fn test_dedup_dvrm_rows_splits_counts_above_mu_max() { + use crate::tables::dvrm::{MU_MAX, dedup_dvrm_rows}; + let op = DvrmOperation::new(7, 2, UNSIGNED); + let ops: Vec<_> = std::iter::repeat_n((op.clone(), true), MU_MAX as usize + 5) + .chain(std::iter::repeat_n((op.clone(), false), 3)) + .collect(); + let mut rows: Vec<(u64, u64)> = dedup_dvrm_rows(&ops) + .iter() + .map(|(_, m)| (m.mu_q, m.mu_r)) + .collect(); + rows.sort(); + assert_eq!(rows, vec![(0, 5), (3, MU_MAX)]); + + let ops: Vec<_> = std::iter::repeat_n((op, false), MU_MAX as usize).collect(); + assert_eq!(dedup_dvrm_rows(&ops).len(), 1); + assert!(dedup_dvrm_rows(&[]).is_empty()); +} + +/// Consistency: the BITWISE collector tallies exactly the per-row lookups the +/// generated DVRM instances send — IS_HALF[μ_q], IS_HALF[μ_r] weighted by μ +/// (padding sends nothing), MSB16 ×3 per SIGNED row and the NEG-template ZERO ×2 per +/// SIGN_R / SIGN_D row — across small instances and for a row split by `MU_MAX`. +#[test] +fn test_dvrm_per_row_lookups_match_collector() { + use crate::tables::bitwise::BitwiseOperationType; + use crate::tables::dvrm::MU_MAX; + use crate::tables::trace_builder::collect_bitwise_from_dvrm; + use std::collections::HashMap; + + let half = |h: u64| ((h & 0xFF) as u8, ((h >> 8) & 0xFF) as u8); + let distinct = [ + DvrmOperation::new(7, 2, UNSIGNED), + DvrmOperation::new((-7i64) as u64, 2, SIGNED), + DvrmOperation::new(100, (-3i64) as u64, SIGNED), + ]; + let small: Vec<_> = distinct + .iter() + .cycle() + .take(11) + .enumerate() + .map(|(i, op)| (op.clone(), i % 2 == 0)) + .collect(); + let split: Vec<_> = std::iter::repeat_n((distinct[1].clone(), false), MU_MAX as usize + 2) + .chain(distinct.iter().map(|op| (op.clone(), true))) + .collect(); + + for (ops, chunk) in [(small, 4usize), (split, 1 << 19)] { + let collected = collect_bitwise_from_dvrm(&ops, chunk); + let count = |ty| collected.iter().filter(|b| b.lookup_type == ty).count(); + + let mut is_half: HashMap<(u8, u8), i64> = HashMap::new(); + for b in collected + .iter() + .filter(|b| b.lookup_type == BitwiseOperationType::IsHalf) + { + *is_half.entry((b.x, b.y)).or_default() += 1; + } + for (op, _) in &ops { + let r = op.compute_remainder(); + for word in [op.n, op.d, r, op.n.wrapping_sub(r), op.compute_quotient()] { + for shift in [0, 16, 32, 48] { + *is_half.entry(half(word >> shift & 0xFFFF)).or_default() -= 1; + } + } + } + + let mut expected: HashMap<(u8, u8), i64> = HashMap::new(); + let (mut msb16, mut neg_zero) = (0usize, 0usize); + for c in ops.chunks(chunk) { + let t = generate_dvrm_trace(c); + for r in 0..t.num_rows() { + for col in [cols::MU_Q, cols::MU_R] { + let mu = t.get_main(r, col).to_raw(); + *expected.entry(half(mu)).or_default() += mu as i64; + } + msb16 += 3 * t.get_main(r, cols::SIGNED).to_raw() as usize; + neg_zero += 2 * t.get_main(r, cols::SIGN_R).to_raw() as usize; + neg_zero += 2 * t.get_main(r, cols::SIGN_D).to_raw() as usize; + } + } + is_half.retain(|_, n| *n != 0); + expected.retain(|_, n| *n != 0); + assert_eq!( + is_half, expected, + "IS_HALF[μ] tally must match the trace rows" + ); + assert_eq!(count(BitwiseOperationType::Msb16), msb16, "MSB16 tally"); + // ZERO = C8 + C20 per raw op, plus the per-row NEG-template sends. + assert_eq!( + count(BitwiseOperationType::Zero), + 2 * ops.len() + neg_zero, + "ZERO tally" + ); + } +} diff --git a/prover/src/tests/mod.rs b/prover/src/tests/mod.rs index 73ff6ee45..4d8d08e23 100644 --- a/prover/src/tests/mod.rs +++ b/prover/src/tests/mod.rs @@ -69,6 +69,8 @@ pub mod memw_tests; #[cfg(test)] pub mod mul_tests; #[cfg(test)] +pub mod multiplicity_forgery_poc; +#[cfg(test)] pub mod ood_window_ir_tests; #[cfg(test)] pub mod page_layout_tests; diff --git a/prover/src/tests/mul_tests.rs b/prover/src/tests/mul_tests.rs index 0d2e22243..cce10d890 100644 --- a/prover/src/tests/mul_tests.rs +++ b/prover/src/tests/mul_tests.rs @@ -270,8 +270,9 @@ fn test_bus_interactions_count() { // - 8x IS_HALF senders for outputs (lo[0..4], hi[0..4]) // - 4x IS_B20 senders (carry[0..4] virtual range checks) // - 2x ALU receivers (lo, hi) - // Total: 2 + 8 + 8 + 4 + 2 = 24 - assert_eq!(interactions.len(), 24, "Expected 24 bus interactions"); + // - 2x IS_HALF senders bounding the multiplicities (μ_lo, μ_hi) + // Total: 2 + 8 + 8 + 4 + 2 + 2 = 26 + assert_eq!(interactions.len(), 26, "Expected 26 bus interactions"); } #[test] @@ -416,3 +417,141 @@ fn msb16_bitwise_multiplicity_matches_per_instance_sends() { sends ({sends}); a mismatch leaves the Msb16 bus unbalanced" ); } + +// Soundness regression: μ_lo/μ_hi are bounded, non-negative multiplicities. +// Every MUL range check fires with μ_lo + μ_hi, so a free μ_hi = −1 next to +// μ_lo = 1 switched them all off (`multiplicity_forgery_poc`). Each μ is now +// IS_HALF-range-checked weighted by itself, and trace generation splits a +// row whose count would exceed `MU_MAX`. + +/// Presence: each multiplicity is IS_HALF-checked weighted by itself, so an +/// out-of-range value `v` always lands on the bus with weight `v ≠ 0`. +#[test] +fn test_mul_bounds_its_multiplicities() { + use crate::tables::types::BusId; + use stark::lookup::{BusValue, Multiplicity, Packing}; + for col in [cols::MU_LO, cols::MU_HI] { + assert!( + bus_interactions().iter().any(|i| i.is_sender + && i.bus_id == BusId::IsHalfword as u64 + && matches!(i.multiplicity, Multiplicity::Column(m) if m == col) + && matches!(i.values.as_slice(), + [BusValue::Packed { start_column, packing: Packing::Direct }] if *start_column == col)), + "MUL must IS_HALF-check multiplicity column {col} weighted by itself" + ); + } +} + +/// Splitting: a count above `MU_MAX` spreads over rows that each stay within the +/// bound and together preserve both totals; exactly `MU_MAX` still fits one row. +#[test] +fn test_dedup_mul_rows_splits_counts_above_mu_max() { + use crate::tables::mul::{MU_MAX, dedup_mul_rows}; + let op = MulOperation::new(3, false, 5, false); + let ops: Vec<_> = std::iter::repeat_n((op.clone(), false), MU_MAX as usize + 5) + .chain(std::iter::repeat_n((op.clone(), true), 3)) + .collect(); + let mut rows: Vec<(u64, u64)> = dedup_mul_rows(&ops) + .iter() + .map(|(_, m)| (m.mu_lo, m.mu_hi)) + .collect(); + rows.sort(); + assert_eq!(rows, vec![(5, 0), (MU_MAX, 3)]); + + let ops: Vec<_> = std::iter::repeat_n((op, true), MU_MAX as usize).collect(); + assert_eq!(dedup_mul_rows(&ops).len(), 1); + assert!(dedup_mul_rows(&[]).is_empty()); + + // The generated trace never holds a μ above the bound. + let ops: Vec<_> = std::iter::repeat_n( + (MulOperation::new(7, true, 9, true), false), + 2 * MU_MAX as usize + 1, + ) + .collect(); + let trace = generate_mul_trace(&ops); + let mut total = 0u64; + for r in 0..trace.num_rows() { + let mu = trace.get_main(r, cols::MU_LO).to_raw(); + assert!(mu <= MU_MAX); + total += mu; + } + assert_eq!(total, ops.len() as u64); +} + +/// Consistency: the BITWISE collector tallies exactly the per-row lookups the +/// generated MUL instances send — IS_HALF[μ_lo], IS_HALF[μ_hi] weighted by μ +/// (padding sends nothing) and MSB16 per row whose sign flag is set — both across +/// several small instances and for a row split by `MU_MAX`. +#[test] +fn test_mul_per_row_lookups_match_collector() { + use crate::tables::bitwise::BitwiseOperationType; + use crate::tables::mul::MU_MAX; + use crate::tables::trace_builder::collect_bitwise_from_mul; + use std::collections::HashMap; + + let half = |h: u64| ((h & 0xFF) as u8, ((h >> 8) & 0xFF) as u8); + let distinct = [ + MulOperation::new(3, false, 5, false), + MulOperation::new(0x1234_5678_9abc_def0, true, 0x8000_0000_0000_0001, true), + MulOperation::new(u64::MAX, true, 2, false), + ]; + let small: Vec<_> = distinct + .iter() + .cycle() + .take(11) + .enumerate() + .map(|(i, op)| (op.clone(), i % 2 == 0)) + .collect(); + let split: Vec<_> = std::iter::repeat_n((distinct[1].clone(), false), MU_MAX as usize + 2) + .chain(distinct.iter().map(|op| (op.clone(), true))) + .collect(); + + for (ops, chunk) in [(small, 4usize), (split, 1 << 20)] { + let collected = collect_bitwise_from_mul(&ops, chunk); + + // IS_HALF minus the per-raw-op range checks leaves the μ bounds. + let mut is_half: HashMap<(u8, u8), i64> = HashMap::new(); + for b in collected + .iter() + .filter(|b| b.lookup_type == BitwiseOperationType::IsHalf) + { + *is_half.entry((b.x, b.y)).or_default() += 1; + } + for (op, _) in &ops { + let (lo, hi) = op.compute_product(); + for word in [op.lhs, op.rhs, lo, hi] { + for shift in [0, 16, 32, 48] { + *is_half.entry(half(word >> shift & 0xFFFF)).or_default() -= 1; + } + } + } + + let mut expected: HashMap<(u8, u8), i64> = HashMap::new(); + let mut msb16 = 0usize; + for c in ops.chunks(chunk) { + let t = generate_mul_trace(c); + for r in 0..t.num_rows() { + for col in [cols::MU_LO, cols::MU_HI] { + let mu = t.get_main(r, col).to_raw(); + *expected.entry(half(mu)).or_default() += mu as i64; + } + msb16 += t.get_main(r, cols::LHS_SIGNED).to_raw() as usize; + msb16 += t.get_main(r, cols::RHS_SIGNED).to_raw() as usize; + } + } + is_half.retain(|_, n| *n != 0); + expected.retain(|_, n| *n != 0); + assert_eq!( + is_half, expected, + "IS_HALF[μ] tally must match the trace rows" + ); + assert_eq!( + collected + .iter() + .filter(|b| b.lookup_type == BitwiseOperationType::Msb16) + .count(), + msb16, + "MSB16 tally must match the trace rows" + ); + } +} diff --git a/prover/src/tests/multiplicity_forgery_poc.rs b/prover/src/tests/multiplicity_forgery_poc.rs new file mode 100644 index 000000000..19eac4739 --- /dev/null +++ b/prover/src/tests/multiplicity_forgery_poc.rs @@ -0,0 +1,485 @@ +//! End-to-end regression tests for unconstrained multiplicities: MUL's +//! `μ_lo`/`μ_hi` and DVRM's `μ_q`/`μ_r` must be bounded and non-negative, and +//! SHIFT's `zbs` must be a bit. +//! +//! **MUL / DVRM.** Both chips send every range check (and DVRM its MUL/LT/ZERO checks) with +//! multiplicity `μ_a + μ_b`, the row's total lookup count. Before the fix neither +//! column was constrained, so a row with `μ_a = +1, μ_b = −1` sent no checks at +//! all while still receiving one `a` lookup: its result was free. The stray `−1` +//! on the `b` tuple was cancelled by an honest copy of the row with +//! `μ_a = 0, μ_b = +1`, whose checks fire once and pass. Both forgeries below +//! verified against `main` (ffc4ac19). The fix range-checks each μ to a halfword +//! (`IS_HALF[μ]`, weighted by μ), so `μ_b = −1` has no receiver. +//! +//! **SHIFT.** The five HWSL senders fire with `1 − zbs`, and on a μ = 0 padding +//! row nothing pinned `zbs`: `zbs = 2` made that −1, so the padding row provided +//! exactly the HWSL tuples a forged real row needed (`1 << 1 = 0` verified +//! against `main`). The fix bit-constrains `zbs` (and μ). +//! +//! Each guest computes a value with one real instruction and commits it; the +//! malicious prover claims a different value and reshapes the chip's rows. It +//! also rebalances every BITWISE receiver multiplicity it can (those are free +//! main-trace columns, as in `page_offset_forgery_poc`), so the only thing left +//! to reject the proof is the new constraint itself. +//! +//! Run under production proof options. + +use crypto::fiat_shamir::default_transcript::DefaultTranscript; +use stark::lookup::{BusInteraction, BusValue, Multiplicity, Packing}; +use stark::proof::options::ProofOptions; +use stark::prover::{IsStarkProver, Prover}; +use stark::trace::TraceTable; + +use crate::statement::{StatementKind, absorb_statement}; +use crate::tables::bitwise::{cols as bw_cols, row_index as bw_row_index}; +use crate::tables::trace_builder::Traces; +use crate::tables::types::{BusId, FE, GoldilocksExtension as Ext, GoldilocksField as Base}; +use crate::tables::{dvrm, mul}; +use crate::test_utils::{E, asm_elf_bytes}; +use crate::{MaxRowsConfig, VmAirs, VmProof}; + +use executor::elf::Elf; +use executor::vm::execution::Executor; + +fn opts() -> ProofOptions { + crate::GoldilocksCubicProofOptions::with_blowup(2).expect("blowup=2 is valid") +} + +/// One forgery scenario: a guest whose single `lhs ∘ rhs` instruction yields +/// `honest`, and the chip rewrite that makes it yield `forged` instead. +struct Scenario { + guest: &'static str, + lhs: u64, + rhs: u64, + honest: u64, + forged: u64, + forge_rows: fn(&mut Traces, &Scenario), +} + +const MUL: Scenario = Scenario { + guest: "poc_mul_commit", + lhs: 3, + rhs: 5, + honest: 15, + forged: 99, + forge_rows: forge_mul_rows, +}; + +const DIVU: Scenario = Scenario { + guest: "poc_divu_commit", + lhs: 7, + rhs: 2, + honest: 3, + forged: 100, + forge_rows: forge_dvrm_rows, +}; + +const SLL: Scenario = Scenario { + guest: "poc_sll_commit", + lhs: 1, + rhs: 1, + honest: 2, + forged: 0, + forge_rows: forge_shift_rows, +}; + +/// Which deviations the malicious prover applies. +#[derive(Clone, Copy)] +struct Forge { + /// Rewrite the execution so the CPU claims `lhs ∘ rhs = forged`. + claim: bool, + /// Rewrite the chip's rows so they answer the forged claim. + rows: bool, +} + +fn craft_proof(s: &Scenario, forge: Forge) -> Result { + let elf_bytes = asm_elf_bytes(s.guest); + let options = opts(); + let program = Elf::load(&elf_bytes).expect("ELF load"); + let mut logs = Executor::new(&program, vec![]) + .expect("executor construction") + .run() + .expect("run") + .logs; + + if forge.claim { + let idx = logs + .iter() + .position(|l| l.src1_val == s.lhs && l.src2_val == s.rhs && l.dst_val == s.honest) + .expect("arithmetic log not found"); + logs[idx].dst_val = s.forged; + // The following `sd t1, 0(sp)` stores the result: keep it consistent. + logs[idx + 1..] + .iter_mut() + .find(|l| l.src2_val == s.honest) + .expect("SD of the result not found") + .src2_val = s.forged; + } + + let mut traces = Traces::from_elf_and_logs( + &program, + &logs, + &MaxRowsConfig::default(), + &[], + #[cfg(feature = "disk-spill")] + stark::storage_mode::StorageMode::Ram, + ) + .expect("trace build"); + + if forge.rows { + (s.forge_rows)(&mut traces, s); + } + + let table_counts = traces.table_counts(); + let airs = VmAirs::new( + &program, + &options, + false, + &traces.page_configs, + &table_counts, + None, + true, + None, + None, + None, + ); + let runtime_page_ranges = traces.runtime_page_ranges(); + let num_private_input_pages = traces + .page_configs + .iter() + .filter(|c| c.is_private_input) + .count(); + + let mut transcript = DefaultTranscript::::new(&[]); + absorb_statement( + &mut transcript, + StatementKind::Monolithic, + &elf_bytes, + &traces.public_output_bytes, + &table_counts, + num_private_input_pages, + &runtime_page_ranges, + options.fri_final_poly_log_degree, + ); + + let proof = Prover::multi_prove( + airs.air_trace_pairs(&mut traces), + &mut transcript, + #[cfg(feature = "disk-spill")] + stark::storage_mode::StorageMode::Ram, + )?; + + Ok(VmProof { + proof, + runtime_page_ranges, + table_counts, + public_output: traces.public_output_bytes.clone(), + num_private_input_pages, + }) +} + +/// Whether `interactions` range-check column `col`, weighted by itself. +fn bounds_column(interactions: &[BusInteraction], col: usize) -> bool { + interactions.iter().any(|i| { + i.is_sender + && i.bus_id == BusId::IsHalfword as u64 + && matches!(i.multiplicity, Multiplicity::Column(m) if m == col) + && matches!( + i.values.as_slice(), + [BusValue::Packed { start_column, packing: Packing::Direct }] if *start_column == col + ) + }) +} + +/// Add `delta` to the BITWISE receiver multiplicity `mu_col` at halfword `half`. +fn bump_bitwise(traces: &mut Traces, mu_col: usize, half: u64, delta: FE) { + assert!(half < 1 << 16, "only halfwords have a BITWISE row"); + let bw = &mut traces.bitwise.main_table; + let row = bw_row_index((half & 0xFF) as u8, (half >> 8) as u8, 0); + let old = *bw.get(row, mu_col); + bw.set(row, mu_col, old + delta); +} + +/// The two-row rewrite shared by both chips, on one chip instance `t`: +/// +/// * row A (the honest row answering the claim, `μ_a = 1, μ_b = 0`) gets the +/// forged result written by `write_forged` and `μ_b = −1`; +/// * row B (an all-zero padding row) becomes an honest copy with +/// `μ_a = 0, μ_b = 1`. +/// +/// Returns the row-B copy's index so the caller can rebalance flag-gated sends. +/// If the chip range-checks its μ columns, also rebalances those IS_HALF sends +/// wherever a BITWISE row exists (`−1` has none — that is the fix). +fn forge_two_rows( + traces: &mut Traces, + table: fn(&mut Traces) -> &mut TraceTable, + find_a: impl Fn(&TraceTable, usize) -> bool, + (mu_a, mu_b, num_cols): (usize, usize, usize), + bounded: bool, + write_forged: impl Fn(&mut TraceTable, usize), +) -> usize { + let t = table(traces); + let rows = t.num_rows(); + let a = (0..rows) + .find(|&r| find_a(t, r)) + .expect("honest row not found"); + assert_eq!(*t.get_main(a, mu_a), FE::one()); + assert_eq!(*t.get_main(a, mu_b), FE::zero()); + let b = (0..rows) + .find(|&r| (0..num_cols).all(|c| *t.get_main(r, c) == FE::zero())) + .expect("an all-zero padding row is needed for the honest copy"); + + for c in 0..num_cols { + let v = *t.get_main(a, c); + t.set_main(b, c, v); + } + t.set_main(b, mu_a, FE::zero()); + t.set_main(b, mu_b, FE::one()); + write_forged(t, a); + t.set_main(a, mu_b, FE::zero() - FE::one()); + + if bounded { + // IS_HALF[μ] | μ sends: B's μ_b 0 -> 1 adds IS_HALF[1] once. A's μ_b + // 0 -> −1 adds IS_HALF[p−1] with weight −1, which has no BITWISE row. + bump_bitwise(traces, bw_cols::MU_IS_HALF, 1, FE::one()); + } + b +} + +/// MUL: `lo(3 * 5)` forged to 99 (`μ_lo = 1, μ_hi = −1`). +fn forge_mul_rows(traces: &mut Traces, s: &Scenario) { + use mul::cols; + let bounded = bounds_column(&mul::bus_interactions(), cols::MU_HI); + let forged = s.forged; + let b = forge_two_rows( + traces, + |t| &mut t.muls[0], + |t, r| { + *t.get_main(r, cols::LHS_0) == FE::from(s.lhs) + && *t.get_main(r, cols::RHS_0) == FE::from(s.rhs) + && *t.get_main(r, cols::MU_LO) == FE::one() + }, + (cols::MU_LO, cols::MU_HI, cols::NUM_COLUMNS), + bounded, + |t, a| t.set_main(a, cols::LO_0, FE::from(forged)), + ); + + // Row B's two MSB16 sign lookups are gated by the sign flags, not by μ, so + // the copy duplicates them. Rebalance the BITWISE MSB16 receiver. + for (half, flag) in [ + (cols::LHS_3, cols::LHS_SIGNED), + (cols::RHS_3, cols::RHS_SIGNED), + ] { + let t = &traces.muls[0]; + let (half, mult) = (t.get_main(b, half).to_raw(), *t.get_main(b, flag)); + bump_bitwise(traces, bw_cols::MU_MSB16, half, mult); + } +} + +/// DVRM: `7 / 2` (unsigned) forged to 100 (`μ_q = 1, μ_r = −1`). Unsigned, so +/// the copy duplicates no SIGNED/SIGN_R/SIGN_D-gated sends. +fn forge_dvrm_rows(traces: &mut Traces, s: &Scenario) { + use dvrm::cols; + let bounded = bounds_column(&dvrm::bus_interactions(), cols::MU_R); + let forged = s.forged; + let b = forge_two_rows( + traces, + |t| &mut t.dvrms[0], + |t, r| { + *t.get_main(r, cols::N_0) == FE::from(s.lhs) + && *t.get_main(r, cols::D_0) == FE::from(s.rhs) + && *t.get_main(r, cols::MU_Q) == FE::one() + }, + (cols::MU_Q, cols::MU_R, cols::NUM_COLUMNS), + bounded, + |t, a| t.set_main(a, cols::Q_0, FE::from(forged)), + ); + let t = &traces.dvrms[0]; + for flag in [cols::SIGNED, cols::SIGN_R, cols::SIGN_D] { + assert_eq!( + *t.get_main(b, flag), + FE::zero(), + "scenario must stay unsigned" + ); + } +} + +/// SHIFT: `1 << 1` forged to 0. Row A (the real op) gets `X[0] = 0`, so +/// `out = 0` and its five HWSL tuples are no longer honest; padding row B gets +/// `zbs = 2, in[0] = 1, bit_shift = 1`, so its five HWSL senders fire with +/// `1 − zbs = −1` on exactly A's tuples. With μ = 0 every other B lookup +/// vanishes. The HWSL bus then needs no BITWISE entry for A at all, so the +/// prover drops A's honest HWSL demand from the BITWISE multiplicities. +fn forge_shift_rows(traces: &mut Traces, s: &Scenario) { + use crate::tables::shift::cols; + let t = &mut traces.shifts[0]; + let rows = t.num_rows(); + let a = (0..rows) + .find(|&r| { + *t.get_main(r, cols::IN_0) == FE::from(s.lhs) + && *t.get_main(r, cols::SHIFT_AMOUNT) == FE::from(s.rhs) + && *t.get_main(r, cols::MU) == FE::one() + }) + .expect("honest SLL row not found"); + assert_eq!(*t.get_main(a, cols::X_0), FE::from(s.honest)); + let b = (0..rows) + .find(|&r| *t.get_main(r, cols::MU) == FE::zero() && r != a) + .expect("a padding row is needed"); + + // A's honest HWSL demand: [in[i], bit_shift] for i in 0..4 and the extension + // half `65535·is_negative`, each read at BITWISE row (half, bit_shift). + let bit_shift = t.get_main(a, cols::BIT_SHIFT).to_raw(); + let ext = 65535 * t.get_main(a, cols::IS_NEGATIVE).to_raw(); + let halves: Vec = cols::IN + .iter() + .map(|&c| t.get_main(a, c).to_raw()) + .chain([ext]) + .collect(); + + t.set_main(a, cols::X_0, FE::zero()); + t.set_main(a, cols::OUT_0, FE::from(s.forged)); + t.set_main(b, cols::ZBS, FE::from(2u64)); + t.set_main(b, cols::IN_0, FE::from(s.lhs)); + t.set_main(b, cols::BIT_SHIFT, FE::from(bit_shift)); + + let bw = &mut traces.bitwise.main_table; + for half in halves { + let row = bw_row_index((half & 0xFF) as u8, (half >> 8) as u8, bit_shift as u8); + let old = *bw.get(row, bw_cols::MU_HWSL); + bw.set(row, bw_cols::MU_HWSL, old - FE::one()); + } +} + +fn verifier_accepts(s: &Scenario, proof: &VmProof) -> bool { + crate::verify_with_options(proof, &asm_elf_bytes(s.guest), &opts(), None, None) + .expect("verify must not error") +} + +fn u64_output(proof: &VmProof) -> u64 { + u64::from_le_bytes(proof.public_output[..8].try_into().expect("8-byte output")) +} + +/// Honest proving of the scenario verifies and commits the honest result. +fn assert_honest_verifies(s: &Scenario) { + let proof = craft_proof( + s, + Forge { + claim: false, + rows: false, + }, + ) + .expect("honest proving"); + assert_eq!(u64_output(&proof), s.honest); + assert!(verifier_accepts(s, &proof), "honest proof must verify"); +} + +/// Did a crafted proof get accepted? A prover refusal also counts as rejected. +fn crafted_accepted(s: &Scenario, forge: Forge) -> bool { + match craft_proof(s, forge) { + Ok(proof) => { + assert_eq!( + u64_output(&proof), + s.forged, + "public output must be the forged value" + ); + verifier_accepts(s, &proof) + } + Err(_) => false, + } +} + +#[test] +fn mul_honest_control_verifies() { + assert_honest_verifies(&MUL); +} + +/// Negative control: the CPU claims 99 but the MUL rows are left honest. +#[test] +fn mul_unanswered_forged_claim_is_rejected() { + assert!(!crafted_accepted( + &MUL, + Forge { + claim: true, + rows: false + } + )); +} + +/// Regression: `3 * 5 = 99` via `μ_lo = 1, μ_hi = −1` (verified on ffc4ac19). +#[test] +fn mul_negative_mu_hi_forgery_is_rejected() { + assert!( + !crafted_accepted( + &MUL, + Forge { + claim: true, + rows: true + } + ), + "FORGERY: 3 * 5 = 99 was accepted — μ_lo/μ_hi are not bounded" + ); +} + +#[test] +fn dvrm_honest_control_verifies() { + assert_honest_verifies(&DIVU); +} + +/// Negative control: the CPU claims 7 / 2 = 100 but the DVRM rows are left honest. +#[test] +fn dvrm_unanswered_forged_claim_is_rejected() { + assert!(!crafted_accepted( + &DIVU, + Forge { + claim: true, + rows: false + } + )); +} + +/// Regression: `7 / 2 = 100` via `μ_q = 1, μ_r = −1` (verified on ffc4ac19). +#[test] +fn dvrm_negative_mu_r_forgery_is_rejected() { + assert!( + !crafted_accepted( + &DIVU, + Forge { + claim: true, + rows: true + } + ), + "FORGERY: 7 / 2 = 100 was accepted — μ_q/μ_r are not bounded" + ); +} + +#[test] +fn shift_honest_control_verifies() { + assert_honest_verifies(&SLL); +} + +/// Negative control: the CPU claims 1 << 1 = 0 but the SHIFT rows are left honest. +#[test] +fn shift_unanswered_forged_claim_is_rejected() { + assert!(!crafted_accepted( + &SLL, + Forge { + claim: true, + rows: false + } + )); +} + +/// Regression: `1 << 1 = 0` via a `zbs = 2` padding row (verified on ffc4ac19). +#[test] +fn shift_zbs_two_padding_row_forgery_is_rejected() { + assert!( + !crafted_accepted( + &SLL, + Forge { + claim: true, + rows: true + } + ), + "FORGERY: 1 << 1 = 0 was accepted — zbs is not bit-constrained" + ); +} diff --git a/prover/src/tests/shift_tests.rs b/prover/src/tests/shift_tests.rs index 33e3e4c65..1563c4030 100644 --- a/prover/src/tests/shift_tests.rs +++ b/prover/src/tests/shift_tests.rs @@ -37,5 +37,176 @@ fn test_shift_air_wires_in_chip_constraints() { bus_interactions(), ); assert_eq!(in_chip, NUM_SHIFT_CONSTRAINTS); - assert_eq!(NUM_SHIFT_CONSTRAINTS, 19); + // 19 + ZbsIsBit + MuIsBit. + assert_eq!(NUM_SHIFT_CONSTRAINTS, 21); +} + +// ========================================================================= +// Soundness regression: `zbs` and `μ` are bit-constrained. +// +// Before ZbsIsBit (idx 19) nothing pinned `zbs` on a μ = 0 padding row, so +// `zbs = 2` made the `1 - zbs` HWSL multiplicity −1 and the row provided the +// HWSL tuples a forged real row needed (`SLL 1, 1 = 0`, verified on ffc4ac19). +// Before MuIsBit (idx 20) a left shift's output scaled with a free μ. +// ========================================================================= + +mod zbs_mu_bit_regression { + use std::collections::HashMap; + + use math::field::element::FieldElement; + use stark::lookup::{LinearTerm, Multiplicity}; + use stark::trace::TraceTable; + + use crate::tables::shift::{ + ShiftConstraints, ShiftOperation, bus_interactions, cols, generate_shift_trace, + }; + use crate::tables::types::{BusId, GoldilocksExtension, GoldilocksField}; + use crate::test_utils::{busless_air, validate_busless}; + + type FE = FieldElement; + type Trace = TraceTable; + + fn mult(m: &Multiplicity, g: &dyn Fn(usize) -> FE) -> FE { + match m { + Multiplicity::One => FE::one(), + Multiplicity::Column(c) => g(*c), + Multiplicity::Sum(a, b) => g(*a) + g(*b), + Multiplicity::Negated(c) => FE::one() - g(*c), + Multiplicity::Diff(a, b) => g(*a) - g(*b), + Multiplicity::Sum3(a, b, c) => g(*a) + g(*b) + g(*c), + Multiplicity::Linear(ts) => ts.iter().fold(FE::zero(), |acc, t| match *t { + LinearTerm::Column { + coefficient, + column, + } => acc + g(column) * FE::from(coefficient), + LinearTerm::ColumnUnsigned { + coefficient, + column, + } => acc + g(column) * FE::from(coefficient), + LinearTerm::Constant(v) => acc + FE::from(v), + }), + } + } + + /// Net multiset contribution of the SHIFT table per (bus, tuple): sender +m, + /// receiver −m, zero entries dropped. This is exactly what LogUp checks. + fn bus_net(trace: &Trace) -> HashMap<(u64, Vec), FE> { + let t = &trace.main_table; + let mut net: HashMap<(u64, Vec), FE> = HashMap::new(); + for bi in bus_interactions() { + for r in 0..trace.num_rows() { + let g = |c: usize| *t.get(r, c); + let m = mult(&bi.multiplicity, &g); + if m == FE::zero() { + continue; + } + let tuple: Vec = bi + .values + .iter() + .flat_map(|v| v.combine_from(g)) + .map(|x: FE| x.canonical_u64()) + .collect(); + let e = net.entry((bi.bus_id, tuple)).or_insert(FE::zero()); + *e = if bi.is_sender { *e + m } else { *e - m }; + } + } + net.retain(|_, v| *v != FE::zero()); + net + } + + fn only(net: &HashMap<(u64, Vec), FE>, bus: BusId) -> HashMap, FE> { + let id: u64 = bus.into(); + net.iter() + .filter(|((b, _), _)| *b == id) + .map(|((_, t), m)| (t.clone(), *m)) + .collect() + } + + /// Forges `SLL 1, 1 = 0` (truth: 2) on the SHIFT chip. + /// + /// Row 0 (μ=1, real): honest except `X[0] = 0` → `out = 0`; its HWSL tuple + /// `[1, 1, 0, 0]` is not in the HWSL table. + /// Row 1 (padding, μ=0): `zbs = 2`, `in[0] = 1`, `bit_shift = 1`. With μ=0 + /// every other lookup and `left/right` vanish, so the zbs-override constraints + /// only force `X = Y = 0`; the five HWSL senders then fire with multiplicity + /// `1 - zbs = -1` and exactly cancel row 0's five HWSL tuples. + fn forged_sll() -> (ShiftOperation, Trace, Trace) { + let op = ShiftOperation::new(1, 1, false, false, false); + let honest = generate_shift_trace(std::slice::from_ref(&op)); + let mut forged = honest.clone(); + { + let t = &mut forged.main_table; + assert_eq!(*t.get(0, cols::X_0), FE::from(2u64)); + t.set(0, cols::X_0, FE::zero()); + t.set(0, cols::OUT_0, FE::zero()); + + t.set(1, cols::ZBS, FE::from(2u64)); + t.set(1, cols::IN_0, FE::one()); + t.set(1, cols::BIT_SHIFT, FE::one()); + } + (op, honest, forged) + } + + /// The forgery is complete on every bus: if the trace passed the in-chip + /// constraints, `1 << 1 = 0` would be accepted. This is what makes + /// `zbs_two_padding_row_is_rejected` load-bearing. + #[test] + fn forged_sll_would_balance_every_bus() { + let (op, honest, forged) = forged_sll(); + let (h, f) = (bus_net(&honest), bus_net(&forged)); + + // HWSL: the forged trace needs *no* HWSL table entries at all. + assert!(!only(&h, BusId::Hwsl).is_empty()); + assert!(only(&f, BusId::Hwsl).is_empty(), "HWSL fully self-cancels"); + + // Every other lookup bus is identical to the honest run (all real entries). + for bus in [ + BusId::ByteAlu, + BusId::Zero, + BusId::AreBytes, + BusId::IsHalfword, + BusId::Msb16, + ] { + assert_eq!(only(&h, bus), only(&f, bus), "{bus:?} demand unchanged"); + } + + // ALU: the chip would accept the CPU's claim `1 << 1 = 0`. + let alu = only(&f, BusId::Alu); + assert_eq!(alu.len(), 1); + let (tuple, m) = alu.iter().next().unwrap(); + assert_eq!(*m, -FE::one(), "received once"); + let out = tuple[tuple.len() - 2] | (tuple[tuple.len() - 1] << 32); + assert_eq!(op.compute_out(), 2); + assert_eq!(out, 0, "forged result"); + } + + #[test] + fn zbs_two_padding_row_is_rejected() { + let (_, honest, forged) = forged_sll(); + let air = busless_air(cols::NUM_COLUMNS, ShiftConstraints); + assert!(validate_busless(&air, &honest), "honest trace must pass"); + assert!( + !validate_busless(&air, &forged), + "zbs = 2 must be rejected by IS_BIT[zbs]" + ); + } + + /// A left shift with μ = 2 claims twice the result (`left = μ - direction` + /// scales `out`); it would answer two identical CPU lookups with `2·(x << s)`. + #[test] + fn mu_two_left_shift_row_is_rejected() { + let op = ShiftOperation::new(1, 1, false, false, false); + let mut trace = generate_shift_trace(std::slice::from_ref(&op)); + { + let t = &mut trace.main_table; + assert_eq!(*t.get(0, cols::OUT_0), FE::from(2u64)); + t.set(0, cols::MU, FE::from(2u64)); + t.set(0, cols::OUT_0, FE::from(4u64)); + } + let air = busless_air(cols::NUM_COLUMNS, ShiftConstraints); + assert!( + !validate_busless(&air, &trace), + "μ = 2 must be rejected by IS_BIT[μ]" + ); + } } From 7c7e62a43fd532239b7cd383f05d5aeb96f224b4 Mon Sep 17 00:00:00 2001 From: Nicole Date: Fri, 2 Oct 2026 17:05:17 -0300 Subject: [PATCH 02/29] =?UTF-8?q?Bit-constrain=20LOAD=20=CE=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- executor/programs/asm/poc_lb_commit.s | 23 +++ prover/src/tables/load.rs | 13 +- prover/src/tests/load_tests.rs | 73 +++++++++ prover/src/tests/multiplicity_forgery_poc.rs | 154 ++++++++++++++++++- 4 files changed, 256 insertions(+), 7 deletions(-) create mode 100644 executor/programs/asm/poc_lb_commit.s diff --git a/executor/programs/asm/poc_lb_commit.s b/executor/programs/asm/poc_lb_commit.s new file mode 100644 index 000000000..c657f4517 --- /dev/null +++ b/executor/programs/asm/poc_lb_commit.s @@ -0,0 +1,23 @@ + .text + .attribute 5, "rv64i2p1_m2p0_zmmul1p0" + .globl main +main: + # Store the byte 0x05 (sign bit clear), read it back with a real + # sign-extending LB, spill the result and commit it. The committed public + # output is the LOAD chip's res, so a proof reporting anything but 5 + # certifies a false sign extension. + addi sp, sp, -16 + addi t0, zero, 5 + sb t0, 8(sp) + lb t1, 8(sp) # t1 = sext(0x05) = 5 + sd t1, 0(sp) # spill to stack + li a0, 1 # fd = 1 + mv a1, sp # buf = sp + li a2, 8 # count = 8 + li a7, 64 # syscall = Commit + ecall + + addi sp, sp, 16 + li a0, 0 + li a7, 93 # syscall = Halt + ecall diff --git a/prover/src/tables/load.rs b/prover/src/tables/load.rs index a18ebddd9..d914fd394 100644 --- a/prover/src/tables/load.rs +++ b/prover/src/tables/load.rs @@ -476,10 +476,11 @@ pub fn bus_interactions() -> Vec { // ========================================================================= // // One body against the generic `ConstraintBuilder` serves the compiled prover -// folder, the verifier folder and IR capture. Constraint indices 0..13: +// folder, the verifier folder and IR capture. Constraint indices 0..14: // 0..4: FlagIsBit(SIGNED, READ2, READ4, READ8) 4: WidthSumIsBit // 5: ReadImpliesMu 6..10: ExtensionHigh(4..8) // 10..12: ExtensionMid(2..4) 12: ExtensionLow +// 13: MuIsBit use stark::constraints::builder::{ConstraintBuilder, ConstraintSet}; @@ -575,5 +576,15 @@ impl ConstraintSet for LoadConstraints { let expected = Self::extended(b); let one = b.one(); b.emit_base(12, (one - read2 - read4 - read8) * (res_1 - expected)); + + // idx 13: MuIsBit — μ * (1 - μ). LOAD never deduplicates (one row per + // op), and idx 5 only pins μ = 1 when a read2/4/8 flag is set. On a byte + // load a free μ = −1 turns every lookup into a provider: a −1/+1 pair of + // LBU rows with the same `res[0]` cancels on MEMORY/MEMW (neither carries + // `sign_bit`) but nets `MSB8[res[0]] → ¬sign_bit` minus `→ sign_bit`, + // which cancels a real LB row's forged sign bit (`lb 0x05 = 0xFF..05` + // verified). + let root = Self::flag_is_bit(b, cols::MU); + b.emit_base(13, root); } } diff --git a/prover/src/tests/load_tests.rs b/prover/src/tests/load_tests.rs index a85a7a920..c15a5710f 100644 --- a/prover/src/tests/load_tests.rs +++ b/prover/src/tests/load_tests.rs @@ -61,3 +61,76 @@ fn test_sign_bit_extraction() { let op4 = LoadOperation::new(0, 0, 4, true, [0, 0, 0, 0x80, 0, 0, 0, 0]); assert!(op4.compute_sign_bit()); } + +// ========================================================================= +// Soundness regression: μ is bit-constrained (MuIsBit, idx 13). +// +// Before MuIsBit, idx 5 pinned μ = 1 only when a read2/4/8 flag was set, so a +// byte-load row's μ was free. A μ = −1 / μ = 1 pair of LBU rows with the same +// `res[0]` cancels on MEMORY/MEMW but nets `MSB8[res[0]] → 0` minus `→ 1`, +// which cancels a real LB row's forged `sign_bit = 1`: `lb 0x05` answered +// `0xFFFF_FFFF_FFFF_FF05` and verified end to end +// (`multiplicity_forgery_poc::load_negative_mu_sign_extension_forgery_is_rejected`). +// ========================================================================= + +mod mu_bit_regression { + use crate::tables::load::{LoadConstraints, LoadOperation, cols, generate_load_trace}; + use crate::tables::types::{FE, GoldilocksExtension, GoldilocksField}; + use crate::test_utils::{busless_air, validate_busless}; + + type Trace = stark::trace::TraceTable; + + /// Row 0: the real `lb` of 0x05 with the forged sign extension. Rows 1/2: + /// the cancelling `μ = −1 / μ = 1` byte-load pair. + fn forged_lb() -> (Trace, Trace) { + let op = LoadOperation::new(0x1000, 100, 1, true, [0x05, 0, 0, 0, 0, 0, 0, 0]); + let honest = generate_load_trace(std::slice::from_ref(&op)); + let mut forged = honest.clone(); + let t = &mut forged.main_table; + assert_eq!(*t.get(0, cols::SIGN_BIT), FE::zero()); + for &c in &cols::RES[1..] { + t.set(0, c, FE::from(0xFFu64)); + } + t.set(0, cols::SIGN_BIT, FE::one()); + for (row, mu, sign_bit) in [(1, -FE::one(), FE::one()), (2, FE::one(), FE::zero())] { + t.set(row, cols::RES[0], FE::from(0x05u64)); + t.set(row, cols::MU, mu); + t.set(row, cols::SIGN_BIT, sign_bit); + } + (honest, forged) + } + + #[test] + fn negative_mu_byte_load_pair_is_rejected() { + let (honest, forged) = forged_lb(); + let air = busless_air(cols::NUM_COLUMNS, LoadConstraints); + assert!(validate_busless(&air, &honest), "honest trace must pass"); + assert!( + !validate_busless(&air, &forged), + "μ = −1 must be rejected by IS_BIT[μ]" + ); + } + + /// Every other in-chip constraint holds on the forged rows: only MuIsBit + /// stands between the trace and a forged sign extension. + #[test] + fn forged_rows_differ_from_valid_only_in_mu() { + let (_, mut forged) = forged_lb(); + forged.main_table.set(1, cols::MU, FE::zero()); + forged.main_table.set(2, cols::MU, FE::zero()); + let air = busless_air(cols::NUM_COLUMNS, LoadConstraints); + assert!(validate_busless(&air, &forged)); + } + + #[test] + fn mu_two_byte_load_row_is_rejected() { + let op = LoadOperation::new(0x1000, 100, 1, false, [0x42, 0, 0, 0, 0, 0, 0, 0]); + let mut trace = generate_load_trace(std::slice::from_ref(&op)); + trace.main_table.set(0, cols::MU, FE::from(2u64)); + let air = busless_air(cols::NUM_COLUMNS, LoadConstraints); + assert!( + !validate_busless(&air, &trace), + "μ = 2 must be rejected by IS_BIT[μ]" + ); + } +} diff --git a/prover/src/tests/multiplicity_forgery_poc.rs b/prover/src/tests/multiplicity_forgery_poc.rs index 19eac4739..11d858c73 100644 --- a/prover/src/tests/multiplicity_forgery_poc.rs +++ b/prover/src/tests/multiplicity_forgery_poc.rs @@ -1,6 +1,6 @@ //! End-to-end regression tests for unconstrained multiplicities: MUL's //! `μ_lo`/`μ_hi` and DVRM's `μ_q`/`μ_r` must be bounded and non-negative, and -//! SHIFT's `zbs` must be a bit. +//! SHIFT's `zbs` and LOAD's `μ` must be bits. //! //! **MUL / DVRM.** Both chips send every range check (and DVRM its MUL/LT/ZERO checks) with //! multiplicity `μ_a + μ_b`, the row's total lookup count. Before the fix neither @@ -16,6 +16,13 @@ //! exactly the HWSL tuples a forged real row needed (`1 << 1 = 0` verified //! against `main`). The fix bit-constrains `zbs` (and μ). //! +//! **LOAD.** LOAD never deduplicates, but μ was pinned to 1 only when a +//! read2/4/8 flag was set, so a byte-load row's μ was free. A `μ = −1 / μ = 1` +//! pair of LBU rows with the same `res[0]` cancels on MEMORY/MEMW (neither +//! carries `sign_bit`) while netting `MSB8[res[0]] → 0` minus `→ 1`, which +//! cancels a real LB row's forged sign bit (`lb 0x05 = 0xFF..05` verified +//! against `main`). The fix bit-constrains μ. +//! //! Each guest computes a value with one real instruction and commits it; the //! malicious prover claims a different value and reshapes the chip's rows. It //! also rebalances every BITWISE receiver multiplicity it can (those are free @@ -40,28 +47,43 @@ use crate::{MaxRowsConfig, VmAirs, VmProof}; use executor::elf::Elf; use executor::vm::execution::Executor; +use executor::vm::logs::Log; fn opts() -> ProofOptions { crate::GoldilocksCubicProofOptions::with_blowup(2).expect("blowup=2 is valid") } -/// One forgery scenario: a guest whose single `lhs ∘ rhs` instruction yields -/// `honest`, and the chip rewrite that makes it yield `forged` instead. +/// One forgery scenario: a guest whose target instruction (picked out of the +/// logs by `is_target`) yields `honest`, and the chip rewrite that makes it +/// yield `forged` instead. struct Scenario { guest: &'static str, lhs: u64, rhs: u64, honest: u64, forged: u64, + is_target: fn(&Log, &Scenario) -> bool, forge_rows: fn(&mut Traces, &Scenario), } +/// An `lhs ∘ rhs = honest` arithmetic instruction. +fn arith_log(l: &Log, s: &Scenario) -> bool { + l.src1_val == s.lhs && l.src2_val == s.rhs && l.dst_val == s.honest +} + +/// The `lb t1, 8(sp)` log: the only instruction with a non-zero base register +/// that writes the honest byte (`li t0, 5` reads `zero`, `sb`/`sd` write none). +fn lb_log(l: &Log, s: &Scenario) -> bool { + l.src1_val != 0 && l.dst_val == s.honest +} + const MUL: Scenario = Scenario { guest: "poc_mul_commit", lhs: 3, rhs: 5, honest: 15, forged: 99, + is_target: arith_log, forge_rows: forge_mul_rows, }; @@ -71,6 +93,7 @@ const DIVU: Scenario = Scenario { rhs: 2, honest: 3, forged: 100, + is_target: arith_log, forge_rows: forge_dvrm_rows, }; @@ -80,13 +103,24 @@ const SLL: Scenario = Scenario { rhs: 1, honest: 2, forged: 0, + is_target: arith_log, forge_rows: forge_shift_rows, }; +const LB: Scenario = Scenario { + guest: "poc_lb_commit", + lhs: 0, + rhs: 0, + honest: 5, + forged: 0xFFFF_FFFF_FFFF_FF05, + is_target: lb_log, + forge_rows: forge_load_rows, +}; + /// Which deviations the malicious prover applies. #[derive(Clone, Copy)] struct Forge { - /// Rewrite the execution so the CPU claims `lhs ∘ rhs = forged`. + /// Rewrite the execution so the CPU claims the target yields `forged`. claim: bool, /// Rewrite the chip's rows so they answer the forged claim. rows: bool, @@ -105,8 +139,8 @@ fn craft_proof(s: &Scenario, forge: Forge) -> Result = (0..rows) + .filter(|&r| (0..cols::NUM_COLUMNS).all(|c| *t.get_main(r, c) == FE::zero())) + .take(2) + .collect(); + let [b, c] = padding[..] else { + panic!("two all-zero padding rows are needed for the cancelling pair"); + }; + + for &col in &cols::RES[1..] { + t.set_main(a, col, FE::from(FF)); + } + t.set_main(a, cols::SIGN_BIT, FE::one()); + for (row, mu, sign_bit) in [ + (b, FE::zero() - FE::one(), FE::one()), + (c, FE::one(), FE::zero()), + ] { + t.set_main(row, cols::RES[0], FE::from(byte)); + t.set_main(row, cols::MU, mu); + t.set_main(row, cols::SIGN_BIT, sign_bit); + } + + let m = traces.memw_aligneds.iter_mut().find_map(|m| { + (0..m.num_rows()) + .find(|&r| { + *m.get_main(r, ma::IS_REGISTER) == FE::zero() + && *m.get_main(r, ma::MU_READ) == FE::one() + && *m.get_main(r, ma::TIMESTAMP_0) == ts[0] + && *m.get_main(r, ma::TIMESTAMP_1) == ts[1] + }) + .map(|r| (m, r)) + }); + let (m, r) = m.expect("the MEMW_A read answering the LB not found"); + assert_eq!(*m.get_main(r, ma::VALUE[0]), FE::from(byte)); + for i in 1..8 { + m.set_main(r, ma::OLD[i], FE::from(FF)); + m.set_main(r, ma::VALUE[i], FE::from(FF)); + } +} + fn verifier_accepts(s: &Scenario, proof: &VmProof) -> bool { crate::verify_with_options(proof, &asm_elf_bytes(s.guest), &opts(), None, None) .expect("verify must not error") @@ -483,3 +591,37 @@ fn shift_zbs_two_padding_row_forgery_is_rejected() { "FORGERY: 1 << 1 = 0 was accepted — zbs is not bit-constrained" ); } + +#[test] +fn load_honest_control_verifies() { + assert_honest_verifies(&LB); +} + +/// Negative control: the CPU claims `lb 0x05 = 0xFF..05` but the LOAD rows are +/// left honest. +#[test] +fn load_unanswered_forged_claim_is_rejected() { + assert!(!crafted_accepted( + &LB, + Forge { + claim: true, + rows: false + } + )); +} + +/// Regression: `lb` of 0x05 sign-extended to `0xFFFF_FFFF_FFFF_FF05` via a +/// `μ = −1 / μ = 1` byte-load pair that swaps A's MSB8 tuple. +#[test] +fn load_negative_mu_sign_extension_forgery_is_rejected() { + assert!( + !crafted_accepted( + &LB, + Forge { + claim: true, + rows: true + } + ), + "FORGERY: lb 0x05 = 0xFFFFFFFFFFFFFF05 was accepted — LOAD's μ is not bit-constrained" + ); +} From 8580f42555de9c4a6add5b957f58ce9aeae03691 Mon Sep 17 00:00:00 2001 From: Nicole Date: Mon, 5 Oct 2026 10:53:34 -0300 Subject: [PATCH 03/29] Bound LT multiplicity --- executor/programs/asm/poc_slt_commit.s | 23 ++ prover/src/tables/lt.rs | 60 +++- prover/src/tables/trace_builder.rs | 25 +- prover/src/tests/lt_multiplicity_poc.rs | 306 +++++++++++++++++++ prover/src/tests/lt_tests.rs | 113 ++++++- prover/src/tests/mod.rs | 2 + prover/src/tests/multiplicity_forgery_poc.rs | 134 +++++++- 7 files changed, 648 insertions(+), 15 deletions(-) create mode 100644 executor/programs/asm/poc_slt_commit.s create mode 100644 prover/src/tests/lt_multiplicity_poc.rs diff --git a/executor/programs/asm/poc_slt_commit.s b/executor/programs/asm/poc_slt_commit.s new file mode 100644 index 000000000..1c8ee32b9 --- /dev/null +++ b/executor/programs/asm/poc_slt_commit.s @@ -0,0 +1,23 @@ + .text + .attribute 5, "rv64i2p1" + .globl main +main: + # Compare 5 < 3 with a real SLT, spill the result to the stack and commit + # it. The committed public output is the LT chip's result, so a proof + # reporting anything but 0 certifies a false comparison. The spill comes + # right after the SLT so it is the first instruction to read the result. + addi sp, sp, -16 + addi a3, zero, 5 + addi a4, zero, 3 + slt t1, a3, a4 # t1 = (5 < 3) = 0 + sd t1, 0(sp) # spill to stack + li a0, 1 # fd = 1 + mv a1, sp # buf = sp + li a2, 8 # count = 8 + li a7, 64 # syscall = Commit + ecall + + addi sp, sp, 16 + li a0, 0 + li a7, 93 # syscall = Halt + ecall diff --git a/prover/src/tables/lt.rs b/prover/src/tables/lt.rs index fb7d34267..0ea208ec2 100644 --- a/prover/src/tables/lt.rs +++ b/prover/src/tables/lt.rs @@ -22,7 +22,8 @@ //! //! ## Bus Interactions //! - Sender: MSB16 (×2 for lhs_msb, rhs_msb) -//! - Sender: IS_HALFWORD (×6: ×4 for lhs_sub_rhs, ×1 for lhs[1], ×1 for rhs[1]) +//! - Sender: IS_HALFWORD (×6: ×4 for lhs_sub_rhs, ×1 for lhs[1], ×1 for rhs[1]; +//! ×1 for the μ multiplicity bound) //! - Receiver: ALU (all less-than lookups — CPU SLT/BLT/BGE dispatch and the //! internal `memw`/`memw_aligned`/`dvrm` timestamp / |r|<|d| checks) @@ -152,20 +153,43 @@ impl LtOperation { } } -/// Generates the LT trace table from a list of operations. -/// -/// Duplicate operations (same lhs, rhs, signed) are merged into a single row -/// with their multiplicities summed. The table is then padded to the next power of 2. -pub fn generate_lt_trace( - operations: &[LtOperation], -) -> TraceTable { - // Deduplicate operations: (lhs, rhs, signed) -> multiplicity +/// Largest per-row multiplicity: `μ` is range-checked to a halfword +/// (`IS_HALF[μ]`, weighted by μ), so a row holds at most this many lookups. The +/// bound is what keeps μ non-negative: without it a `μ = −1` twin of an honest +/// row cancels it on the ALU bus while *receiving* its range lookups, which +/// absorbs another row's out-of-range limb (a forged `5 < 3 = 1` verified). +pub const MU_MAX: u64 = (1 << 16) - 1; + +/// Deduplicates LT operations into trace rows: `(lhs, rhs, signed, invert) -> μ`, +/// splitting an op over several rows when its count exceeds [`MU_MAX`]. Shared +/// by trace generation and the BITWISE collector so the per-row lookups they +/// count cannot drift apart. +pub fn dedup_lt_rows(operations: &[LtOperation]) -> Vec<(LtOperation, u64)> { let mut op_map: HashMap = HashMap::new(); for op in operations { *op_map.entry(op.clone()).or_insert(0) += 1; } - let unique_ops: Vec<_> = op_map.into_iter().collect(); + let mut rows = Vec::with_capacity(op_map.len()); + for (op, mut left) in op_map { + while left > 0 { + let mu = left.min(MU_MAX); + left -= mu; + rows.push((op.clone(), mu)); + } + } + rows +} + +/// Generates the LT trace table from a list of operations. +/// +/// Duplicate operations (same lhs, rhs, signed, invert) are merged into a single +/// row with their multiplicities summed (see [`dedup_lt_rows`]). The table is +/// then padded to the next power of 2. +pub fn generate_lt_trace( + operations: &[LtOperation], +) -> TraceTable { + let unique_ops = dedup_lt_rows(operations); let num_rows = unique_ops.len().next_power_of_two().max(4); let mut trace = TraceTable::new_main( crate::tables::types::zeroed_fe_vec(num_rows * cols::NUM_COLUMNS), @@ -306,6 +330,22 @@ pub fn bus_interactions() -> Vec { packing: Packing::Direct, }], ), + // IS_HALF[μ] | μ. Every lookup here fires with μ, so μ itself must be + // bounded and non-negative. Otherwise a μ = −1 twin of an honest row + // cancels it on the ALU bus while *receiving* its range lookups, with + // `sub_0` free up to 2^32: it absorbs an out-of-range IS_HALFWORD sent + // by any row, another LT row included (forged `5 < 3 = 1`). Sent with + // itself as multiplicity: `k` rows holding an out-of-range `v` put + // weight `k·v ≠ 0` on a tuple IS_HALF has no row for, while padding + // (μ = 0) contributes nothing. See `MU_MAX`. + BusInteraction::sender( + BusId::IsHalfword, + Multiplicity::Column(cols::MU), + vec![BusValue::Packed { + start_column: cols::MU, + packing: Packing::Direct, + }], + ), // ALU[lhs, rhs, opsel(LT) + 32*signed + 64*invert] -> out (receiver). // Every LT lookup arrives here: the CPU dispatches SLT/BLT/BGE on the // unified ALU bus, and the internal memw/memw_aligned/dvrm comparisons diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index e72ce502e..fbccd74c3 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -1679,8 +1679,15 @@ fn reg_ts_delta_in_range(timestamp: u64, old_ts: u64) -> bool { /// Collects bitwise lookups from LT operations (MSB16 and IS_HALFWORD). /// +/// MSB16 and the operand/difference IS_HALFWORD lookups are emitted once per raw +/// op. The IS_HALF[μ] multiplicity bound is counted over the rows of each +/// `max_rows_lt` chunk, mirroring `chunk_and_generate_optional`. +/// /// Returns: Vec of bitwise lookups -fn collect_bitwise_from_lt(lt_ops: &[LtOperation]) -> Vec { +pub(crate) fn collect_bitwise_from_lt( + lt_ops: &[LtOperation], + max_rows_lt: usize, +) -> Vec { let mut bitwise_ops = Vec::with_capacity(lt_ops.len() * 8); for op in lt_ops { @@ -1725,6 +1732,19 @@ fn collect_bitwise_from_lt(lt_ops: &[LtOperation]) -> Vec { )); } + // IS_HALF[μ] | μ: each row sends its own μ, μ times (padding sends nothing), + // over exactly the rows `generate_lt_trace` builds for each chunk. + for chunk in lt_ops.chunks(max_rows_lt) { + for (_, mu) in lt::dedup_lt_rows(chunk) { + let op = BitwiseOperation::halfword( + BitwiseOperationType::IsHalf, + (mu & 0xFF) as u8, + ((mu >> 8) & 0xFF) as u8, + ); + bitwise_ops.extend(std::iter::repeat_n(op, mu as usize)); + } + } + bitwise_ops } @@ -3315,12 +3335,13 @@ fn build_traces( // commutative monoid, so per-worker histograms tree-reduce to multiplicities that are // independent of accumulation order. type Collector<'a> = Box; + let lt_chunk = max_rows.lt; let mul_chunk = max_rows.mul; let dvrm_chunk = max_rows.dvrm; // Every source except the two dominant ones (the in-walk lookups and MEMW_R, which are // split into row-ranges in the parallel path below) stays a single whole-source collector. let mut collectors: Vec = vec![ - Box::new(|h| h.add_ops(&collect_bitwise_from_lt(<_ops))), + Box::new(|h| h.add_ops(&collect_bitwise_from_lt(<_ops, lt_chunk))), Box::new(|h| h.add_ops(&collect_bitwise_from_mul(&mul_ops, mul_chunk))), Box::new(|h| h.add_ops(&collect_bitwise_from_dvrm(&dvrm_ops, dvrm_chunk))), Box::new(|h| h.add_ops(&collect_bitwise_from_branch(&branch_ops))), diff --git a/prover/src/tests/lt_multiplicity_poc.rs b/prover/src/tests/lt_multiplicity_poc.rs new file mode 100644 index 000000000..deecb0f4a --- /dev/null +++ b/prover/src/tests/lt_multiplicity_poc.rs @@ -0,0 +1,306 @@ +//! LT's `μ` must be a bounded, non-negative multiplicity (chip-level +//! regression; the end-to-end one is `multiplicity_forgery_poc::lt_*`). +//! +//! LT weights its ALU receive and all of its range lookups (2× MSB16, +//! 6× IS_HALFWORD) by the same `μ`. A row with `μ = −1` therefore acts as an ALU +//! *sender* and a range-check *receiver*. Paired with an honest `μ = +1` copy +//! carrying the same ALU tuple, the two cancel on the ALU bus, but their range +//! lookups do not have to match: the `−1` twin can hold `sub_0 = X` (any +//! `X < 2^32`) with `sub_1 = 0` and still satisfy the carry constraints, so one +//! IS_HALFWORD receive of X appears from nowhere. That absorbs an out-of-range +//! limb of *any* sender, another LT row included — which forges the LT result. +//! The fix range-checks μ (`IS_HALF[μ]`, weighted by μ), so a twin's `μ = −1` +//! itself has no receiver. +//! +//! Harness, with the real LT AIR (`lt::bus_interactions()` + `LtConstraints`): +//! - CPU: a mock ALU sender for the LT lookups the program "made". +//! - VICTIM: an optional mock IS_HALFWORD sender. +//! - RANGE / MSB16: stand-ins for the preprocessed BITWISE receivers, built +//! honestly (exactly the halfwords 0..2^16). Their multiplicities are the net +//! demand of every other table, *evaluated from the real interactions* — so a +//! lookup added by a fix is tallied automatically, and a value outside the +//! table can only balance if the other tables cancel it themselves. + +use std::collections::HashMap; + +use crypto::fiat_shamir::default_transcript::DefaultTranscript; +use math::field::element::FieldElement; +use stark::constraints::builder::EmptyConstraints; +use stark::lookup::{ + AirWithBuses, AuxiliaryTraceBuildData, BusInteraction, BusValue, LinearTerm, Multiplicity, + NullBoundaryConstraintBuilder, Packing, +}; +use stark::proof::options::ProofOptions; +use stark::trace::TraceTable; +use stark::traits::AIR; +use stark::verifier::{IsStarkVerifier, Verifier}; + +use crate::tables::lt::{LtOperation, bus_interactions, cols, generate_lt_trace}; +use crate::tables::types::{BusId, FE, GoldilocksExtension, GoldilocksField, alu_op}; +use crate::test_utils::{create_lt_air, multi_prove_ram}; + +type F = GoldilocksField; +type E = GoldilocksExtension; +type MockAir = AirWithBuses; +pub(crate) type Row = Vec; + +const HALF: u64 = 1 << 16; + +/// A mock table with one interaction: columns `0..n` are the tuple, column `n` +/// the multiplicity. +fn lookup_air(opts: &ProofOptions, bus: BusId, n: usize, sender: bool) -> MockAir { + let values = (0..n) + .map(|c| BusValue::Packed { + start_column: c, + packing: Packing::Direct, + }) + .collect(); + let i = if sender { + BusInteraction::sender(bus, Multiplicity::Column(n), values) + } else { + BusInteraction::receiver(bus, Multiplicity::Column(n), values) + }; + AirWithBuses::new( + n + 1, + AuxiliaryTraceBuildData { + interactions: vec![i], + }, + opts, + 1, + EmptyConstraints, + ) +} + +/// Trace for `lookup_air`: one row per `(tuple, multiplicity)`, zero-padded. +fn lookup_trace(rows: &[(Vec, FE)], n: usize) -> TraceTable { + let len = rows.len().next_power_of_two().max(4); + let mut data = vec![FE::zero(); len * (n + 1)]; + for (r, (tuple, m)) in rows.iter().enumerate() { + for (c, v) in tuple.iter().enumerate() { + data[r * (n + 1) + c] = FE::from(*v); + } + data[r * (n + 1) + n] = *m; + } + TraceTable::new_main(data, n + 1, 1) +} + +fn eval_mult(m: &Multiplicity, g: &dyn Fn(usize) -> FE) -> FE { + match m { + Multiplicity::One => FE::one(), + Multiplicity::Column(c) => g(*c), + Multiplicity::Sum(a, b) => g(*a) + g(*b), + Multiplicity::Negated(c) => FE::one() - g(*c), + Multiplicity::Diff(a, b) => g(*a) - g(*b), + Multiplicity::Sum3(a, b, c) => g(*a) + g(*b) + g(*c), + Multiplicity::Linear(ts) => ts.iter().fold(FE::zero(), |acc, t| match *t { + LinearTerm::Column { + coefficient, + column, + } => acc + g(column) * FE::from(coefficient), + LinearTerm::ColumnUnsigned { + coefficient, + column, + } => acc + g(column) * FE::from(coefficient), + LinearTerm::Constant(v) => acc + FE::from(v), + }), + } +} + +/// Net sends per `(bus, tuple)` of LT's real interactions over `rows`. +pub(crate) fn lt_net(rows: &[Row]) -> HashMap<(u64, Vec), FE> { + let mut net: HashMap<(u64, Vec), FE> = HashMap::new(); + for bi in bus_interactions() { + for row in rows { + let g = |c: usize| row[c]; + let m = eval_mult(&bi.multiplicity, &g); + if m == FE::zero() { + continue; + } + let tuple = bi + .values + .iter() + .flat_map(|v| v.combine_from(g)) + .map(|x: FE| x.canonical_u64()) + .collect(); + let e = net.entry((bi.bus_id, tuple)).or_insert(FE::zero()); + *e = if bi.is_sender { *e + m } else { *e - m }; + } + } + net +} + +/// The ALU tuple an LT lookup carries: `[lhs_lo, lhs_hi, rhs_lo, rhs_hi, flags, out, 0]`. +fn alu_tuple(op: &LtOperation, out: u64) -> Vec { + let flags = alu_op::LT as u64 + 32 * op.signed as u64 + 64 * op.invert as u64; + vec![ + op.lhs & 0xFFFF_FFFF, + op.lhs >> 32, + op.rhs & 0xFFFF_FFFF, + op.rhs >> 32, + flags, + out, + 0, + ] +} + +/// The honest LT row for `op`. +pub(crate) fn honest_row(op: &LtOperation) -> Row { + let t = generate_lt_trace(std::slice::from_ref(op)); + let row = t.main_table.get_row(0).to_vec(); + assert_eq!(row[cols::MU], FE::one()); + row +} + +/// A `μ = −1` copy of `op`'s honest row whose `sub_0` is `x` (with `sub_1 = 0`, +/// so `sub_0 + 2^16·sub_1` and every carry are unchanged). Needs `lhs − rhs = x` +/// on the low word, `x < 2^32`. +pub(crate) fn twin_row(op: &LtOperation, x: u64) -> Row { + let mut row = honest_row(op); + assert_eq!(op.lhs.wrapping_sub(op.rhs) & 0xFFFF_FFFF, x); + row[cols::MU] = -FE::one(); + row[cols::LHS_SUB_RHS_0] = FE::from(x); + row[cols::LHS_SUB_RHS_1] = FE::zero(); + row +} + +/// Proves CPU + LT + VICTIM + RANGE + MSB16 and returns whether it verifies. +/// +/// `cpu`: the ALU lookups the CPU sends (once each). `victim`: IS_HALFWORD +/// values sent by another table. RANGE/MSB16 absorb the net demand for every +/// value they have a row for; anything else is left for the bus to reject. +fn prove_and_verify(lt_rows: &[Row], cpu: &[Vec], victim: &[u64]) -> bool { + let opts = ProofOptions::default_test_options(); + let half_id: u64 = BusId::IsHalfword.into(); + let msb_id: u64 = BusId::Msb16.into(); + + let mut net = lt_net(lt_rows); + for &v in victim { + *net.entry((half_id, vec![v])).or_insert(FE::zero()) += FE::one(); + } + let range: Vec<(Vec, FE)> = (0..HALF) + .map(|v| { + let m = net.get(&(half_id, vec![v])).copied().unwrap_or(FE::zero()); + (vec![v], m) + }) + .collect(); + let msb16: Vec<(Vec, FE)> = (0..HALF) + .map(|v| { + let t = vec![v, v >> 15]; + let m = net.get(&(msb_id, t.clone())).copied().unwrap_or(FE::zero()); + (t, m) + }) + .collect(); + let cpu_rows: Vec<(Vec, FE)> = cpu.iter().map(|t| (t.clone(), FE::one())).collect(); + let victim_rows: Vec<(Vec, FE)> = victim.iter().map(|&v| (vec![v], FE::one())).collect(); + + let lt_len = lt_rows.len().next_power_of_two().max(4); + let mut lt_data = vec![FE::zero(); lt_len * cols::NUM_COLUMNS]; + for (r, row) in lt_rows.iter().enumerate() { + lt_data[r * cols::NUM_COLUMNS..(r + 1) * cols::NUM_COLUMNS].copy_from_slice(row); + } + let mut lt_trace = TraceTable::new_main(lt_data, cols::NUM_COLUMNS, 1); + let mut cpu_trace = lookup_trace(&cpu_rows, 7); + let mut victim_trace = lookup_trace(&victim_rows, 1); + let mut range_trace = lookup_trace(&range, 1); + let mut msb16_trace = lookup_trace(&msb16, 2); + + let lt_air = create_lt_air(&opts); + let cpu_air = lookup_air(&opts, BusId::Alu, 7, true); + let victim_air = lookup_air(&opts, BusId::IsHalfword, 1, true); + let range_air = lookup_air(&opts, BusId::IsHalfword, 1, false); + let msb16_air = lookup_air(&opts, BusId::Msb16, 2, false); + + let pairs: Vec<( + &dyn AIR, + _, + _, + )> = vec![ + (&cpu_air, &mut cpu_trace, &()), + (&victim_air, &mut victim_trace, &()), + (<_air, &mut lt_trace, &()), + (&range_air, &mut range_trace, &()), + (&msb16_air, &mut msb16_trace, &()), + ]; + let proof = multi_prove_ram(pairs, &mut DefaultTranscript::::new(&[])).unwrap(); + let airs: Vec<&dyn AIR> = + vec![&cpu_air, &victim_air, <_air, &range_air, &msb16_air]; + Verifier::multi_verify( + &airs, + &proof, + &mut DefaultTranscript::::new(&[]), + &FieldElement::zero(), + ) +} + +/// The honest LT row's out, as the CPU would receive it. +fn honest_out(op: &LtOperation) -> u64 { + honest_row(op)[cols::OUT].canonical_u64() +} + +/// Positive control: honest LT rows answering the CPU verify. Without this, a +/// "rejected" below would say nothing. +#[test] +fn control_honest_rows_verify() { + let ops = [ + LtOperation::new(5, 3, false), + LtOperation::new(HALF, 0, false), + ]; + let rows: Vec = ops.iter().map(honest_row).collect(); + let cpu: Vec<_> = ops.iter().map(|op| alu_tuple(op, honest_out(op))).collect(); + assert!(prove_and_verify(&rows, &cpu, &[])); +} + +/// Control: an out-of-range IS_HALFWORD send with no twin has no receiver. +#[test] +fn control_out_of_range_victim_is_rejected() { + let op = LtOperation::new(HALF, 0, false); + assert!(!prove_and_verify( + &[honest_row(&op)], + &[alu_tuple(&op, honest_out(&op))], + &[HALF] + )); +} + +/// Control: a twin whose carry no longer holds is rejected by `LtConstraints`. +#[test] +fn control_twin_with_broken_carry_is_rejected() { + let op = LtOperation::new(HALF, 0, false); + let mut twin = twin_row(&op, HALF); + twin[cols::LHS_SUB_RHS_1] = FE::one(); + assert!(!prove_and_verify(&[honest_row(&op), twin], &[], &[HALF])); +} + +/// Regression: a `μ = −1` twin absorbing another table's out-of-range +/// IS_HALFWORD[2^16] (accepted before LT's μ was bounded). +#[test] +fn regression_negative_mu_twin_cannot_absorb_victim() { + let op = LtOperation::new(HALF, 0, false); + assert!(!prove_and_verify( + &[honest_row(&op), twin_row(&op, HALF)], + &[], + &[HALF] + )); +} + +/// Regression: the victim can be LT itself — `5 < 3` claimed true (accepted +/// before LT's μ was bounded). +/// +/// Row A (μ = 1) answers the CPU's `LT(5, 3) = 1` with `carry_1 = 1`: its high +/// `lhs − rhs` word becomes `2^32`, split as `sub_2 = 0, sub_3 = 2^16` — every +/// LT constraint holds, but `sub_3` is not a halfword. Twin C (`μ = −1`, op +/// `2^16 − 0`) absorbs IS_HALFWORD[2^16]; D, the honest `2^16 − 0` row, cancels +/// C on the ALU bus. +#[test] +fn regression_negative_mu_twin_cannot_forge_5_lt_3() { + let op = LtOperation::new(5, 3, false); + assert_eq!(honest_out(&op), 0); + let mut a = honest_row(&op); + a[cols::LT] = FE::one(); + a[cols::OUT] = FE::one(); + a[cols::LHS_SUB_RHS_2] = FE::zero(); + a[cols::LHS_SUB_RHS_3] = FE::from(HALF); + + let carrier = LtOperation::new(HALF, 0, false); + let rows = [a, twin_row(&carrier, HALF), honest_row(&carrier)]; + assert!(!prove_and_verify(&rows, &[alu_tuple(&op, 1)], &[])); +} diff --git a/prover/src/tests/lt_tests.rs b/prover/src/tests/lt_tests.rs index d7f707a13..ea4f92e38 100644 --- a/prover/src/tests/lt_tests.rs +++ b/prover/src/tests/lt_tests.rs @@ -169,8 +169,8 @@ fn test_bus_interactions_count() { // MSB16 x2 + IS_HALFWORD x6 (lhs_sub_rhs x4 + lhs[1] + rhs[1]) // + ALU receiver x1 (every LT lookup goes through the unified ALU bus // — CPU SLT/BLT/BGE dispatch and the internal memw/dvrm - // timestamp / |r|<|d| checks) = 9. - assert_eq!(interactions.len(), 9); + // timestamp / |r|<|d| checks) + IS_HALFWORD x1 bounding μ = 10. + assert_eq!(interactions.len(), 10); } // Soundness regression: `lt` must equal `(lhs < rhs)`. The in-chip constraints were @@ -232,3 +232,112 @@ fn test_lt_rejects_forged_out() { "forged out=1 (lt=invert=0) must be rejected by OutXorInvert" ); } + +// Soundness regression: μ is a bounded, non-negative multiplicity. Every LT +// lookup fires with μ, so a free μ = −1 twin of an honest row received range +// lookups instead of sending them and absorbed another row's out-of-range limb +// (`lt_multiplicity_poc`, `multiplicity_forgery_poc`). μ is now IS_HALF-checked +// weighted by itself, and trace generation splits a row whose count would +// exceed `MU_MAX`. + +/// Presence: μ is IS_HALF-checked weighted by itself, so an out-of-range value +/// `v` always lands on the bus with weight `v ≠ 0`. +#[test] +fn test_lt_bounds_its_multiplicity() { + use crate::tables::types::BusId; + use stark::lookup::{BusValue, Multiplicity, Packing}; + assert!( + bus_interactions().iter().any(|i| i.is_sender + && i.bus_id == BusId::IsHalfword as u64 + && matches!(i.multiplicity, Multiplicity::Column(m) if m == cols::MU) + && matches!(i.values.as_slice(), + [BusValue::Packed { start_column, packing: Packing::Direct }] if *start_column == cols::MU)), + "LT must IS_HALF-check μ weighted by itself" + ); +} + +/// Splitting: a count above `MU_MAX` spreads over rows within the bound that +/// preserve the total; exactly `MU_MAX` still fits one row. +#[test] +fn test_dedup_lt_rows_splits_counts_above_mu_max() { + use crate::tables::lt::{MU_MAX, dedup_lt_rows}; + let op = LtOperation::new(5, 3, SIGNED); + let ops: Vec<_> = std::iter::repeat_n(op.clone(), 2 * MU_MAX as usize + 7).collect(); + let mut rows: Vec = dedup_lt_rows(&ops).iter().map(|(_, mu)| *mu).collect(); + rows.sort(); + assert_eq!(rows, vec![7, MU_MAX, MU_MAX]); + + let ops: Vec<_> = std::iter::repeat_n(op, MU_MAX as usize).collect(); + assert_eq!(dedup_lt_rows(&ops).len(), 1); + assert!(dedup_lt_rows(&[]).is_empty()); + + // The generated trace never holds a μ above the bound. + let ops: Vec<_> = + std::iter::repeat_n(LtOperation::new(1, 2, UNSIGNED), MU_MAX as usize + 1).collect(); + let trace = generate_lt_trace(&ops); + let mut total = 0u64; + for r in 0..trace.num_rows() { + let mu = trace.get_main(r, cols::MU).to_raw(); + assert!(mu <= MU_MAX); + total += mu; + } + assert_eq!(total, ops.len() as u64); +} + +/// Consistency: the BITWISE collector tallies exactly the IS_HALF[μ] lookups the +/// generated LT instances send (each row's μ, weighted by μ; padding nothing), +/// across several small instances and for a row split by `MU_MAX`. +#[test] +fn test_lt_mu_bound_lookups_match_collector() { + use crate::tables::bitwise::BitwiseOperationType; + use crate::tables::lt::MU_MAX; + use crate::tables::trace_builder::collect_bitwise_from_lt; + use std::collections::HashMap; + + let half = |h: u64| ((h & 0xFF) as u8, ((h >> 8) & 0xFF) as u8); + let distinct = [ + LtOperation::new(5, 3, SIGNED), + LtOperation::new(u64::MAX, 1, UNSIGNED), + LtOperation::new(0x1234_5678_9abc_def0, 0x1234_5678_0000_0000, SIGNED), + ]; + let small: Vec<_> = distinct.iter().cycle().take(11).cloned().collect(); + let split: Vec<_> = std::iter::repeat_n(distinct[0].clone(), MU_MAX as usize + 2) + .chain(distinct.iter().cloned()) + .collect(); + + for (ops, chunk) in [(small, 4usize), (split, 1 << 20)] { + let collected = collect_bitwise_from_lt(&ops, chunk); + + // IS_HALF minus the per-raw-op range checks leaves the μ bound. + let mut is_half: HashMap<(u8, u8), i64> = HashMap::new(); + for b in collected + .iter() + .filter(|b| b.lookup_type == BitwiseOperationType::IsHalf) + { + *is_half.entry((b.x, b.y)).or_default() += 1; + } + for op in &ops { + let sub = op.lhs.wrapping_sub(op.rhs); + for shift in [0, 16, 32, 48] { + *is_half.entry(half(sub >> shift & 0xFFFF)).or_default() -= 1; + } + *is_half.entry(half(op.lhs >> 32 & 0xFFFF)).or_default() -= 1; + *is_half.entry(half(op.rhs >> 32 & 0xFFFF)).or_default() -= 1; + } + + let mut expected: HashMap<(u8, u8), i64> = HashMap::new(); + for c in ops.chunks(chunk) { + let t = generate_lt_trace(c); + for r in 0..t.num_rows() { + let mu = t.get_main(r, cols::MU).to_raw(); + *expected.entry(half(mu)).or_default() += mu as i64; + } + } + is_half.retain(|_, n| *n != 0); + expected.retain(|_, n| *n != 0); + assert_eq!( + is_half, expected, + "IS_HALF[μ] tally must match the trace rows" + ); + } +} diff --git a/prover/src/tests/mod.rs b/prover/src/tests/mod.rs index 4d8d08e23..11051e08e 100644 --- a/prover/src/tests/mod.rs +++ b/prover/src/tests/mod.rs @@ -59,6 +59,8 @@ pub mod local_to_global_bus_tests; #[cfg(test)] pub mod lt_bus_tests; #[cfg(test)] +pub mod lt_multiplicity_poc; +#[cfg(test)] pub mod lt_tests; #[cfg(test)] pub mod memw_aligned_tests; diff --git a/prover/src/tests/multiplicity_forgery_poc.rs b/prover/src/tests/multiplicity_forgery_poc.rs index 11d858c73..afb8677f9 100644 --- a/prover/src/tests/multiplicity_forgery_poc.rs +++ b/prover/src/tests/multiplicity_forgery_poc.rs @@ -1,6 +1,7 @@ //! End-to-end regression tests for unconstrained multiplicities: MUL's //! `μ_lo`/`μ_hi` and DVRM's `μ_q`/`μ_r` must be bounded and non-negative, and -//! SHIFT's `zbs` and LOAD's `μ` must be bits. +//! SHIFT's `zbs` and LOAD's `μ` must be bits, and LT's `μ` must be bounded and +//! non-negative. //! //! **MUL / DVRM.** Both chips send every range check (and DVRM its MUL/LT/ZERO checks) with //! multiplicity `μ_a + μ_b`, the row's total lookup count. Before the fix neither @@ -23,6 +24,13 @@ //! cancels a real LB row's forged sign bit (`lb 0x05 = 0xFF..05` verified //! against `main`). The fix bit-constrains μ. //! +//! **LT.** LT weights its ALU receive and all its range lookups by one free μ. A +//! `μ = −1` twin of an honest row cancels it on the ALU bus while *receiving* +//! its range lookups, with `sub_0` set to any `X < 2^32` — so it absorbs an +//! out-of-range IS_HALFWORD send. The victim can be another LT row: `5 < 3` +//! answered 1 with a `sub_3 = 2^16` limb verified against the branch before the +//! LT fix. See also `lt_multiplicity_poc` (chip level). +//! //! Each guest computes a value with one real instruction and commits it; the //! malicious prover claims a different value and reshapes the chip's rows. It //! also rebalances every BITWISE receiver multiplicity it can (those are free @@ -107,6 +115,16 @@ const SLL: Scenario = Scenario { forge_rows: forge_shift_rows, }; +const SLT: Scenario = Scenario { + guest: "poc_slt_commit", + lhs: 5, + rhs: 3, + honest: 0, + forged: 1, + is_target: arith_log, + forge_rows: forge_lt_rows, +}; + const LB: Scenario = Scenario { guest: "poc_lb_commit", lhs: 0, @@ -458,6 +476,87 @@ fn forge_load_rows(traces: &mut Traces, s: &Scenario) { } } +/// LT: `5 < 3` forged to 1. +/// +/// * Row A (the real SLT, `μ = 1`) gets `lt = out = 1` and `carry_1 = 1`: its +/// high `lhs − rhs` word becomes `2^32`, split as `sub_2 = 0, sub_3 = 2^16`. +/// Every LT constraint holds; only `IS_HALFWORD[2^16]` is out of range. +/// * Padding row C becomes a `μ = −1` twin of the op `2^16 − 0` with +/// `sub_0 = 2^16, sub_1 = 0`: it *receives* `IS_HALFWORD[2^16]`. +/// * Padding row D is the honest `2^16 − 0` row, cancelling C on the ALU bus. +/// +/// The BITWISE IS_HALF/MSB16 multiplicities are moved by the net change in LT's +/// own sends, evaluated from the real interactions (so a lookup added by the +/// fix is accounted for); a value with no BITWISE row is left for the bus. +fn forge_lt_rows(traces: &mut Traces, s: &Scenario) { + use super::lt_multiplicity_poc::{Row, honest_row, lt_net, twin_row}; + use crate::tables::lt::{LtOperation, cols}; + const HALF: u64 = 1 << 16; + + let t = &mut traces.lts[0]; + let rows = |t: &TraceTable| -> Vec { + (0..t.num_rows()) + .map(|r| (0..cols::NUM_COLUMNS).map(|c| *t.get_main(r, c)).collect()) + .collect() + }; + let before = rows(t); + + let a = (0..t.num_rows()) + .find(|&r| { + *t.get_main(r, cols::LHS_0) == FE::from(s.lhs) + && *t.get_main(r, cols::RHS_0) == FE::from(s.rhs) + && *t.get_main(r, cols::MU) == FE::one() + }) + .expect("honest SLT row not found"); + assert_eq!(*t.get_main(a, cols::OUT), FE::from(s.honest)); + // Equal high words and no low borrow: the honest high diff word is 0. + assert_eq!(*t.get_main(a, cols::LHS_SUB_RHS_2), FE::zero()); + assert_eq!(*t.get_main(a, cols::LHS_SUB_RHS_3), FE::zero()); + let padding: Vec = (0..t.num_rows()) + .filter(|&r| (0..cols::NUM_COLUMNS).all(|c| *t.get_main(r, c) == FE::zero())) + .take(2) + .collect(); + let [c, d] = padding[..] else { + panic!("two all-zero padding rows are needed for the twin and its honest copy"); + }; + + t.set_main(a, cols::LT, FE::one()); + t.set_main(a, cols::OUT, FE::from(s.forged)); + t.set_main(a, cols::LHS_SUB_RHS_3, FE::from(HALF)); + let carrier = LtOperation::new(HALF, 0, false); + for (r, row) in [(c, twin_row(&carrier, HALF)), (d, honest_row(&carrier))] { + for (col, v) in row.into_iter().enumerate() { + t.set_main(r, col, v); + } + } + let after = rows(t); + + let (old, new) = (lt_net(&before), lt_net(&after)); + let half_id: u64 = BusId::IsHalfword.into(); + let msb_id: u64 = BusId::Msb16.into(); + let keys: std::collections::HashSet<_> = old.keys().chain(new.keys()).cloned().collect(); + for (bus, tuple) in keys { + let delta = new + .get(&(bus, tuple.clone())) + .copied() + .unwrap_or(FE::zero()) + - old + .get(&(bus, tuple.clone())) + .copied() + .unwrap_or(FE::zero()); + let v = tuple[0]; + if delta == FE::zero() || v >= HALF { + continue; + } + if bus == half_id { + bump_bitwise(traces, bw_cols::MU_IS_HALF, v, delta); + } else if bus == msb_id { + assert_eq!(tuple[1], v >> 15, "MSB16 sends stay honest"); + bump_bitwise(traces, bw_cols::MU_MSB16, v, delta); + } + } +} + fn verifier_accepts(s: &Scenario, proof: &VmProof) -> bool { crate::verify_with_options(proof, &asm_elf_bytes(s.guest), &opts(), None, None) .expect("verify must not error") @@ -625,3 +724,36 @@ fn load_negative_mu_sign_extension_forgery_is_rejected() { "FORGERY: lb 0x05 = 0xFFFFFFFFFFFFFF05 was accepted — LOAD's μ is not bit-constrained" ); } + +#[test] +fn lt_honest_control_verifies() { + assert_honest_verifies(&SLT); +} + +/// Negative control: the CPU claims `5 < 3` but the LT rows are left honest. +#[test] +fn lt_unanswered_forged_claim_is_rejected() { + assert!(!crafted_accepted( + &SLT, + Forge { + claim: true, + rows: false + } + )); +} + +/// Regression: `5 < 3` answered 1 via a `μ = −1` twin absorbing the forged row's +/// out-of-range `sub_3 = 2^16` limb. +#[test] +fn lt_negative_mu_twin_forgery_is_rejected() { + assert!( + !crafted_accepted( + &SLT, + Forge { + claim: true, + rows: true + } + ), + "FORGERY: 5 < 3 = 1 was accepted — LT's μ is not bounded" + ); +} From 5fec4bf72a04def3cfcc686a1f808e7f35bead12 Mon Sep 17 00:00:00 2001 From: Nicole Date: Mon, 5 Oct 2026 12:46:55 -0300 Subject: [PATCH 04/29] Bound BRANCH multiplicity --- prover/src/tables/branch.rs | 59 ++++- prover/src/tables/trace_builder.rs | 21 +- prover/src/tests/branch_bus_tests.rs | 112 ++++++++ prover/src/tests/branch_multiplicity_poc.rs | 246 ++++++++++++++++++ prover/src/tests/mod.rs | 4 + prover/src/tests/mul_mu_carrier_poc.rs | 270 ++++++++++++++++++++ 6 files changed, 700 insertions(+), 12 deletions(-) create mode 100644 prover/src/tests/branch_multiplicity_poc.rs create mode 100644 prover/src/tests/mul_mu_carrier_poc.rs diff --git a/prover/src/tables/branch.rs b/prover/src/tables/branch.rs index 0d3c2e206..0847d6b8f 100644 --- a/prover/src/tables/branch.rs +++ b/prover/src/tables/branch.rs @@ -150,20 +150,43 @@ impl BranchOperation { } } -/// Generates the BRANCH trace table from a list of operations. -/// -/// Duplicate operations (same pc, offset, register, jalr) are merged into a single row -/// with their multiplicities summed. The table is then padded to the next power of 2. -pub fn generate_branch_trace( - operations: &[BranchOperation], -) -> TraceTable { - // Deduplicate operations: (pc, offset, register, jalr) -> multiplicity +/// Largest per-row multiplicity: `μ` is range-checked to a halfword +/// (`IS_HALF[μ]`, weighted by μ), so a row holds at most this many lookups. The +/// bound keeps μ non-negative: without it a `μ = −1` twin of an honest row +/// cancels it on the BRANCH bus while *receiving* its range lookups, absorbing +/// another row's out-of-range limb (same bug as LT). +pub const MU_MAX: u64 = (1 << 16) - 1; + +/// Deduplicates BRANCH operations into trace rows: `(pc, offset, register, +/// jalr) -> μ`, splitting an op over several rows when its count exceeds +/// [`MU_MAX`]. Shared by trace generation and the BITWISE collector so the +/// per-row lookups they count cannot drift apart. +pub fn dedup_branch_rows(operations: &[BranchOperation]) -> Vec<(BranchOperation, u64)> { let mut op_map: HashMap = HashMap::new(); for op in operations { *op_map.entry(op.clone()).or_insert(0) += 1; } - let unique_ops: Vec<_> = op_map.into_iter().collect(); + let mut rows = Vec::with_capacity(op_map.len()); + for (op, mut left) in op_map { + while left > 0 { + let mu = left.min(MU_MAX); + left -= mu; + rows.push((op.clone(), mu)); + } + } + rows +} + +/// Generates the BRANCH trace table from a list of operations. +/// +/// Duplicate operations (same pc, offset, register, jalr) are merged into a +/// single row with their multiplicities summed (see [`dedup_branch_rows`]). The +/// table is then padded to the next power of 2. +pub fn generate_branch_trace( + operations: &[BranchOperation], +) -> TraceTable { + let unique_ops = dedup_branch_rows(operations); let num_rows = unique_ops.len().next_power_of_two().max(4); let mut trace = TraceTable::new_main( crate::tables::types::zeroed_fe_vec(num_rows * cols::NUM_COLUMNS), @@ -222,7 +245,8 @@ pub fn generate_branch_trace( /// - **Sends** ARE_BYTES lookup for next_pc_low[1] range check (Y=0) /// - **Sends** BYTE_ALU[AND] lookup for LSB masking /// (next_pc_low[0] = unmasked_low_byte & 254) -/// - **Sends** IS_HALFWORD lookups for next_pc_high[0..3] range checks +/// - **Sends** IS_HALFWORD lookups for next_pc_high[0..3] range checks and for +/// the μ multiplicity bound /// - **Receives** BRANCH lookups from CPU table pub fn bus_interactions() -> Vec { vec![ @@ -283,6 +307,21 @@ pub fn bus_interactions() -> Vec { packing: Packing::Direct, }], ), + // IS_HALF[μ] | μ. Every lookup here fires with μ, so μ itself must be + // bounded and non-negative. Otherwise a μ = −1 twin of an honest row + // cancels it on the BRANCH bus while *receiving* its range lookups and + // absorbs an out-of-range IS_HALFWORD sent by any row (same as LT). Sent + // with itself as multiplicity: `k` rows holding an out-of-range `v` put + // weight `k·v ≠ 0` on a tuple IS_HALF has no row for, while padding + // (μ = 0) contributes nothing. See `MU_MAX`. + BusInteraction::sender( + BusId::IsHalfword, + Multiplicity::Column(cols::MU), + vec![BusValue::Packed { + start_column: cols::MU, + packing: Packing::Direct, + }], + ), // BRANCH[next_pc; pc, offset, register, JALR] (receiver) // Signature: [next_pc (DWordWL), pc (DWordWL), offset (DWordWL), register (DWordWL), JALR (Bit)] BusInteraction::receiver( diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index fbccd74c3..e56e68a0a 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -2046,7 +2046,10 @@ pub(crate) fn collect_bitwise_from_dvrm( /// - IS_HALFWORD[next_pc_high[0..3]] - range checks for bits 16-63 /// /// Returns: Vec of bitwise lookups -fn collect_bitwise_from_branch(branch_ops: &[BranchOperation]) -> Vec { +pub(crate) fn collect_bitwise_from_branch( + branch_ops: &[BranchOperation], + max_rows_branch: usize, +) -> Vec { let mut bitwise_ops = Vec::with_capacity(branch_ops.len() * 5); for op in branch_ops { @@ -2097,6 +2100,19 @@ fn collect_bitwise_from_branch(branch_ops: &[BranchOperation]) -> Vec> 8) & 0xFF) as u8, + ); + bitwise_ops.extend(std::iter::repeat_n(op, mu as usize)); + } + } + bitwise_ops } @@ -3336,6 +3352,7 @@ fn build_traces( // independent of accumulation order. type Collector<'a> = Box; let lt_chunk = max_rows.lt; + let branch_chunk = max_rows.branch; let mul_chunk = max_rows.mul; let dvrm_chunk = max_rows.dvrm; // Every source except the two dominant ones (the in-walk lookups and MEMW_R, which are @@ -3344,7 +3361,7 @@ fn build_traces( Box::new(|h| h.add_ops(&collect_bitwise_from_lt(<_ops, lt_chunk))), Box::new(|h| h.add_ops(&collect_bitwise_from_mul(&mul_ops, mul_chunk))), Box::new(|h| h.add_ops(&collect_bitwise_from_dvrm(&dvrm_ops, dvrm_chunk))), - Box::new(|h| h.add_ops(&collect_bitwise_from_branch(&branch_ops))), + Box::new(|h| h.add_ops(&collect_bitwise_from_branch(&branch_ops, branch_chunk))), Box::new(|h| h.add_ops(&shift::collect_bitwise_from_shift(&shift_ops))), Box::new(|h| { for op in &bytewise_ops { diff --git a/prover/src/tests/branch_bus_tests.rs b/prover/src/tests/branch_bus_tests.rs index ee81ebb5a..710554155 100644 --- a/prover/src/tests/branch_bus_tests.rs +++ b/prover/src/tests/branch_bus_tests.rs @@ -690,3 +690,115 @@ fn test_soundness_wrong_offset() { assert!(!prove_and_verify_custom(&ops, &receiver_rows)); } + +// Soundness regression: μ is a bounded, non-negative multiplicity. Every BRANCH +// lookup fires with μ, so a free μ = −1 twin of an honest row received range +// lookups instead of sending them and absorbed another row's out-of-range limb +// (`branch_multiplicity_poc`). μ is now IS_HALF-checked weighted by itself, and +// trace generation splits a row whose count would exceed `MU_MAX`. +mod mu_bound { + use crate::tables::branch::BranchOperation; + use crate::tables::branch::{ + MU_MAX, bus_interactions, cols, dedup_branch_rows, generate_branch_trace, + }; + use crate::tables::types::{BusId, FE}; + + /// Presence: μ is IS_HALF-checked weighted by itself. + #[test] + fn branch_bounds_its_multiplicity() { + use stark::lookup::{BusValue, Multiplicity, Packing}; + assert!( + bus_interactions().iter().any(|i| i.is_sender + && i.bus_id == BusId::IsHalfword as u64 + && matches!(i.multiplicity, Multiplicity::Column(m) if m == cols::MU) + && matches!(i.values.as_slice(), + [BusValue::Packed { start_column, packing: Packing::Direct }] if *start_column == cols::MU)), + "BRANCH must IS_HALF-check μ weighted by itself" + ); + } + + /// Splitting: a count above `MU_MAX` spreads over rows within the bound that + /// preserve the total; exactly `MU_MAX` still fits one row. + #[test] + fn dedup_branch_rows_splits_counts_above_mu_max() { + let op = BranchOperation::new(1 << 48, 0, 0, false); + let ops: Vec<_> = std::iter::repeat_n(op.clone(), 2 * MU_MAX as usize + 7).collect(); + let mut rows: Vec = dedup_branch_rows(&ops).iter().map(|(_, mu)| *mu).collect(); + rows.sort(); + assert_eq!(rows, vec![7, MU_MAX, MU_MAX]); + + let ops: Vec<_> = std::iter::repeat_n(op, MU_MAX as usize).collect(); + assert_eq!(dedup_branch_rows(&ops).len(), 1); + assert!(dedup_branch_rows(&[]).is_empty()); + + // The generated trace never holds a μ above the bound. + let ops: Vec<_> = std::iter::repeat_n( + BranchOperation::new(1 << 48, 0, 0, false), + MU_MAX as usize + 1, + ) + .collect(); + let trace = generate_branch_trace(&ops); + let mut total = 0u64; + for r in 0..trace.num_rows() { + let mu = trace.get_main(r, cols::MU).to_raw(); + assert!(mu <= MU_MAX); + total += mu; + } + assert_eq!(total, ops.len() as u64); + } + + /// Consistency: the BITWISE collector tallies exactly the IS_HALF[μ] lookups + /// the generated BRANCH instances send (each row's μ, weighted by μ; padding + /// nothing), across small instances and for a row split by `MU_MAX`. + #[test] + fn branch_mu_bound_lookups_match_collector() { + use crate::tables::bitwise::BitwiseOperationType; + use crate::tables::trace_builder::collect_bitwise_from_branch; + use std::collections::HashMap; + + let half = |h: u64| ((h & 0xFF) as u8, ((h >> 8) & 0xFF) as u8); + let distinct = [ + BranchOperation::new(1 << 48, 0, 0, false), + BranchOperation::new(0x1234_5678, 0x100, 0, false), + BranchOperation::new(0, 0, 0x20, true), + ]; + let small: Vec<_> = distinct.iter().cycle().take(11).cloned().collect(); + let split: Vec<_> = std::iter::repeat_n(distinct[0].clone(), MU_MAX as usize + 2) + .chain(distinct.iter().cloned()) + .collect(); + + for (ops, chunk) in [(small, 4usize), (split, 1 << 20)] { + let collected = collect_bitwise_from_branch(&ops, chunk); + + // IS_HALF minus the per-raw-op next_pc_high range checks leaves the μ bound. + let mut is_half: HashMap<(u8, u8), i64> = HashMap::new(); + for b in collected + .iter() + .filter(|b| b.lookup_type == BitwiseOperationType::IsHalf) + { + *is_half.entry((b.x, b.y)).or_default() += 1; + } + for op in &ops { + let next_pc = op.compute_next_pc(); + for shift in [16, 32, 48] { + *is_half.entry(half(next_pc >> shift & 0xFFFF)).or_default() -= 1; + } + } + + let mut expected: HashMap<(u8, u8), i64> = HashMap::new(); + for c in ops.chunks(chunk) { + let t = generate_branch_trace(c); + for r in 0..t.num_rows() { + let mu = t.get_main(r, cols::MU).to_raw(); + *expected.entry(half(mu)).or_default() += mu as i64; + } + } + is_half.retain(|_, n| *n != 0); + expected.retain(|_, n| *n != 0); + assert_eq!( + is_half, expected, + "IS_HALF[μ] tally must match the trace rows" + ); + } + } +} diff --git a/prover/src/tests/branch_multiplicity_poc.rs b/prover/src/tests/branch_multiplicity_poc.rs new file mode 100644 index 000000000..8357c80af --- /dev/null +++ b/prover/src/tests/branch_multiplicity_poc.rs @@ -0,0 +1,246 @@ +//! BRANCH's `μ` must be a bounded, non-negative multiplicity — same bug and fix +//! as LT (`lt_multiplicity_poc`). +//! +//! BRANCH weights its BRANCH receive and all range lookups (ARE_BYTES, +//! BYTE_ALU[AND], IS_HALFWORD ×3) by one free `μ`. A `μ = −1` twin of an honest +//! row cancels it on the BRANCH bus while *receiving* its range lookups; split +//! the high word non-canonically (`high_1 = 2^16, high_2 -= 1`, same packed +//! `next_pc`) and the twin absorbs an out-of-range `IS_HALFWORD[2^16]` sent by +//! another table. The fix range-checks `μ` (`IS_HALF[μ]`, weighted by μ), so the +//! twin's `μ = −1` itself lands at `p−1` with no receiver. +//! +//! Real BRANCH AIR + real bus balance; the surrounding tables are faithful mocks +//! built from BRANCH's own interactions (CPU sends what BRANCH receives; the +//! range buses receive what BRANCH sends, in-range only). A VICTIM mock sends an +//! out-of-range `IS_HALFWORD` value. + +use std::collections::HashMap; + +use crypto::fiat_shamir::default_transcript::DefaultTranscript; +use math::field::element::FieldElement; +use stark::constraints::builder::EmptyConstraints; +use stark::lookup::{ + AirWithBuses, AuxiliaryTraceBuildData, BusInteraction, BusValue, LinearTerm, Multiplicity, + NullBoundaryConstraintBuilder, Packing, +}; +use stark::proof::options::ProofOptions; +use stark::trace::TraceTable; +use stark::traits::AIR; +use stark::verifier::{IsStarkVerifier, Verifier}; + +use crate::tables::branch::{BranchOperation, bus_interactions, cols, generate_branch_trace}; +use crate::tables::types::{BusId, FE, GoldilocksExtension, GoldilocksField}; +use crate::test_utils::{create_branch_air, multi_prove_ram}; + +type F = GoldilocksField; +type E = GoldilocksExtension; +type MockAir = AirWithBuses; +type Row = Vec; + +const HALF: u64 = 1 << 16; + +fn eval_mult(m: &Multiplicity, g: &dyn Fn(usize) -> FE) -> FE { + match m { + Multiplicity::One => FE::one(), + Multiplicity::Column(c) => g(*c), + Multiplicity::Sum(a, b) => g(*a) + g(*b), + Multiplicity::Negated(c) => FE::one() - g(*c), + Multiplicity::Diff(a, b) => g(*a) - g(*b), + Multiplicity::Sum3(a, b, c) => g(*a) + g(*b) + g(*c), + Multiplicity::Linear(ts) => ts.iter().fold(FE::zero(), |acc, t| match *t { + LinearTerm::Column { + coefficient, + column, + } => acc + g(column) * FE::from(coefficient), + LinearTerm::ColumnUnsigned { + coefficient, + column, + } => acc + g(column) * FE::from(coefficient), + LinearTerm::Constant(v) => acc + FE::from(v), + }), + } +} + +/// Net sends per `(bus, tuple)` of BRANCH's real interactions over `rows`. +fn branch_net(rows: &[Row]) -> HashMap<(u64, Vec), FE> { + let mut net: HashMap<(u64, Vec), FE> = HashMap::new(); + for bi in bus_interactions() { + for row in rows { + let g = |c: usize| row[c]; + let m = eval_mult(&bi.multiplicity, &g); + if m == FE::zero() { + continue; + } + let tuple = bi + .values + .iter() + .flat_map(|v| v.combine_from(g)) + .map(|x: FE| x.canonical_u64()) + .collect(); + let e = net.entry((bi.bus_id, tuple)).or_insert(FE::zero()); + *e = if bi.is_sender { *e + m } else { *e - m }; + } + } + net +} + +fn mock(opts: &ProofOptions, bus: u64, n: usize, sender: bool) -> MockAir { + let values = (0..n) + .map(|c| BusValue::Packed { + start_column: c, + packing: Packing::Direct, + }) + .collect(); + let i = if sender { + BusInteraction::sender(bus, Multiplicity::Column(n), values) + } else { + BusInteraction::receiver(bus, Multiplicity::Column(n), values) + }; + AirWithBuses::new( + n + 1, + AuxiliaryTraceBuildData { + interactions: vec![i], + }, + opts, + 1, + EmptyConstraints, + ) +} + +fn mock_trace(rows: &[(Vec, FE)], n: usize) -> TraceTable { + let len = rows.len().next_power_of_two().max(4); + let mut data = vec![FE::zero(); len * (n + 1)]; + for (r, (tuple, m)) in rows.iter().enumerate() { + for (c, v) in tuple.iter().enumerate() { + data[r * (n + 1) + c] = FE::from(*v); + } + data[r * (n + 1) + n] = *m; + } + TraceTable::new_main(data, n + 1, 1) +} + +/// An honest BRANCH row whose `next_pc = 2^48` (so `high_1 = 0, high_2 = 1`). +fn honest_row() -> Row { + let t = generate_branch_trace(&[BranchOperation::new(1 << 48, 0, 0, false)]); + let row = t.main_table.get_row(0).to_vec(); + assert_eq!(row[cols::MU], FE::one()); + assert_eq!(row[cols::NEXT_PC_HIGH_1], FE::zero()); + assert_eq!(row[cols::NEXT_PC_HIGH_2], FE::one()); + row +} + +/// A `μ = −1` copy of the honest row with the high word split non-canonically +/// (`high_1 = 2^16, high_2 = 0`): same packed `next_pc`, so BRANCH and its +/// constraints agree, but `high_1` is out of range. +fn twin_row() -> Row { + let mut row = honest_row(); + row[cols::MU] = -FE::one(); + row[cols::NEXT_PC_HIGH_1] = FE::from(HALF); + row[cols::NEXT_PC_HIGH_2] = FE::zero(); + row +} + +/// Proves CPU + BRANCH + range-bus mocks (+ VICTIM) and returns whether it +/// verifies. `victim` sends one out-of-range `IS_HALFWORD` value. +fn prove_and_verify(rows: &[Row], victim: Option) -> bool { + let opts = ProofOptions::default_test_options(); + let branch_id: u64 = BusId::Branch.into(); + let half_id: u64 = BusId::IsHalfword.into(); + let widths: HashMap = [ + (branch_id, 9usize), + (BusId::AreBytes.into(), 2), + (BusId::ByteAlu.into(), 4), + (half_id, 1), + ] + .into_iter() + .collect(); + + let mut net = branch_net(rows); + if let Some(v) = victim { + *net.entry((half_id, vec![v])).or_insert(FE::zero()) += FE::one(); + } + + // One mock per bus. BRANCH is received by the chip, so the CPU mock sends it; + // the range buses are sent by the chip, so their mocks receive. Out-of-range + // IS_HALFWORD values get no receiver row (only the victim/μ bound reach them). + let mut per_bus: HashMap, FE)>> = HashMap::new(); + for ((bus, tuple), m) in &net { + if *m == FE::zero() { + continue; + } + if *bus == half_id && tuple[0] >= HALF { + continue; + } + per_bus.entry(*bus).or_default().push((tuple.clone(), *m)); + } + + let mut airs: Vec = Vec::new(); + let mut traces: Vec> = Vec::new(); + let mut is_sender: Vec = Vec::new(); + for (&bus, rows) in &per_bus { + let n = widths[&bus]; + let sender = bus == branch_id; // CPU sends BRANCH lookups; others receive. + // BRANCH net is negative (chip receives); the sender supplies −net. + let rows: Vec<(Vec, FE)> = rows + .iter() + .map(|(t, m)| (t.clone(), if sender { -*m } else { *m })) + .collect(); + airs.push(mock(&opts, bus, n, sender)); + traces.push(mock_trace(&rows, n)); + is_sender.push(sender); + } + let _ = is_sender; + if let Some(v) = victim { + airs.push(mock(&opts, half_id, 1, true)); + traces.push(mock_trace(&[(vec![v], FE::one())], 1)); + } + + let branch_len = rows.len().next_power_of_two().max(4); + let mut branch_data = vec![FE::zero(); branch_len * cols::NUM_COLUMNS]; + for (r, row) in rows.iter().enumerate() { + branch_data[r * cols::NUM_COLUMNS..(r + 1) * cols::NUM_COLUMNS].copy_from_slice(row); + } + let mut branch_trace = TraceTable::new_main(branch_data, cols::NUM_COLUMNS, 1); + let branch_air = create_branch_air(&opts); + + let mut pairs: Vec<( + &dyn AIR, + _, + _, + )> = vec![(&branch_air, &mut branch_trace, &())]; + for (a, t) in airs.iter().zip(traces.iter_mut()) { + pairs.push((a, t, &())); + } + let proof = multi_prove_ram(pairs, &mut DefaultTranscript::::new(&[])).unwrap(); + + let mut air_refs: Vec<&dyn AIR> = + vec![&branch_air]; + for a in &airs { + air_refs.push(a); + } + Verifier::multi_verify( + &air_refs, + &proof, + &mut DefaultTranscript::::new(&[]), + &FieldElement::zero(), + ) +} + +/// Positive control: an honest BRANCH row answering the CPU verifies. +#[test] +fn control_honest_row_verifies() { + assert!(prove_and_verify(&[honest_row()], None)); +} + +/// Control: an out-of-range IS_HALFWORD send with no twin has no receiver. +#[test] +fn control_out_of_range_victim_is_rejected() { + assert!(!prove_and_verify(&[honest_row()], Some(HALF))); +} + +/// Regression: the `μ = −1` twin can no longer absorb the victim's +/// IS_HALFWORD[2^16] (accepted before BRANCH's μ was bounded). +#[test] +fn regression_negative_mu_twin_cannot_absorb_victim() { + assert!(!prove_and_verify(&[honest_row(), twin_row()], Some(HALF))); +} diff --git a/prover/src/tests/mod.rs b/prover/src/tests/mod.rs index 11051e08e..9df45c683 100644 --- a/prover/src/tests/mod.rs +++ b/prover/src/tests/mod.rs @@ -9,6 +9,8 @@ pub mod branch_bus_tests; #[cfg(test)] pub mod branch_constraints_tests; #[cfg(test)] +pub mod branch_multiplicity_poc; +#[cfg(test)] pub mod bytewise_tests; #[cfg(test)] pub mod commit_tests; @@ -69,6 +71,8 @@ pub mod memw_register_tests; #[cfg(test)] pub mod memw_tests; #[cfg(test)] +pub mod mul_mu_carrier_poc; +#[cfg(test)] pub mod mul_tests; #[cfg(test)] pub mod multiplicity_forgery_poc; diff --git a/prover/src/tests/mul_mu_carrier_poc.rs b/prover/src/tests/mul_mu_carrier_poc.rs new file mode 100644 index 000000000..e3e9ae42f --- /dev/null +++ b/prover/src/tests/mul_mu_carrier_poc.rs @@ -0,0 +1,270 @@ +//! Adversarial check of the MUL `μ` bound (`fix/multiplicity-soundness`). +//! +//! The fix bounds `μ_lo`/`μ_hi` with `IS_HALF[μ]` weighted *by μ itself* +//! (`Multiplicity::Column`), because the skip-empty-tables invariant (#977) +//! forbids a constant `Multiplicity::One` on an optional table. The open +//! question (raised reviewing BRANCH): does weighting by μ leave a hole that +//! `One` would not? A malicious `μ_hi = −1` row's bound send lands at value +//! `p−1` with weight `p−1 = −1`; the proof then balances iff *something else* +//! supplies `+1` at `p−1` on the IS_HALFWORD bus. +//! +//! This test pins down exactly that, with the real fixed MUL AIR and real bus +//! balance. Every surrounding table is a faithful mock built by evaluating +//! MUL's own interactions: the CPU sends what MUL receives on ALU; RANGE / +//! RANGE20 receive what MUL sends to IS_HALFWORD / IS_B20, but *only for values +//! a real range table has a row for* (`< 2^16` / `< 2^20`). A `CARRIER` mock +//! optionally sends `IS_HALFWORD[p−1]`, standing in for "some other table emits +//! an out-of-range halfword with +1". +//! +//! Result characterises the fix: +//! - no carrier → the `μ_hi = −1` forgery is REJECTED (the known attack is +//! blocked; same as `multiplicity_forgery_poc`); +//! - with carrier → whether it verifies says whether the bound is unconditional +//! or only holds while no table leaks an out-of-range halfword. + +use std::collections::HashMap; + +use crypto::fiat_shamir::default_transcript::DefaultTranscript; +use math::field::element::FieldElement; +use stark::constraints::builder::EmptyConstraints; +use stark::lookup::{ + AirWithBuses, AuxiliaryTraceBuildData, BusInteraction, BusValue, Multiplicity, + NullBoundaryConstraintBuilder, Packing, +}; +use stark::proof::options::ProofOptions; +use stark::trace::TraceTable; +use stark::traits::AIR; +use stark::verifier::{IsStarkVerifier, Verifier}; + +use crate::tables::mul::{MulOperation, bus_interactions, cols, generate_mul_trace}; +use crate::tables::types::{BusId, FE, GoldilocksExtension, GoldilocksField}; +use crate::test_utils::{create_mul_air, multi_prove_ram}; + +type F = GoldilocksField; +type E = GoldilocksExtension; +type MockAir = AirWithBuses; + +const HALF: u64 = 1 << 16; +const B20: u64 = 1 << 20; +/// `p − 1`, i.e. the field value of `−1` — where a `μ = −1` bound send lands. +fn minus_one() -> u64 { + (-FE::one()).canonical_u64() +} + +fn eval_mult(m: &Multiplicity, g: &dyn Fn(usize) -> FE) -> FE { + use stark::lookup::LinearTerm; + match m { + Multiplicity::One => FE::one(), + Multiplicity::Column(c) => g(*c), + Multiplicity::Sum(a, b) => g(*a) + g(*b), + Multiplicity::Negated(c) => FE::one() - g(*c), + Multiplicity::Diff(a, b) => g(*a) - g(*b), + Multiplicity::Sum3(a, b, c) => g(*a) + g(*b) + g(*c), + Multiplicity::Linear(ts) => ts.iter().fold(FE::zero(), |acc, t| match *t { + LinearTerm::Column { + coefficient, + column, + } => acc + g(column) * FE::from(coefficient), + LinearTerm::ColumnUnsigned { + coefficient, + column, + } => acc + g(column) * FE::from(coefficient), + LinearTerm::Constant(v) => acc + FE::from(v), + }), + } +} + +/// Net sends per `(bus, tuple)` of MUL's real interactions over `rows`. +fn mul_net(rows: &[Vec]) -> HashMap<(u64, Vec), FE> { + let mut net: HashMap<(u64, Vec), FE> = HashMap::new(); + for bi in bus_interactions() { + for row in rows { + let g = |c: usize| row[c]; + let m = eval_mult(&bi.multiplicity, &g); + if m == FE::zero() { + continue; + } + let tuple = bi + .values + .iter() + .flat_map(|v| v.combine_from(g)) + .map(|x: FE| x.canonical_u64()) + .collect(); + let e = net.entry((bi.bus_id, tuple)).or_insert(FE::zero()); + *e = if bi.is_sender { *e + m } else { *e - m }; + } + } + net +} + +fn mock(opts: &ProofOptions, bus: BusId, n: usize, sender: bool) -> MockAir { + let values = (0..n) + .map(|c| BusValue::Packed { + start_column: c, + packing: Packing::Direct, + }) + .collect(); + let i = if sender { + BusInteraction::sender(bus, Multiplicity::Column(n), values) + } else { + BusInteraction::receiver(bus, Multiplicity::Column(n), values) + }; + AirWithBuses::new( + n + 1, + AuxiliaryTraceBuildData { + interactions: vec![i], + }, + opts, + 1, + EmptyConstraints, + ) +} + +fn mock_trace(rows: &[(Vec, FE)], n: usize) -> TraceTable { + let len = rows.len().next_power_of_two().max(4); + let mut data = vec![FE::zero(); len * (n + 1)]; + for (r, (tuple, m)) in rows.iter().enumerate() { + for (c, v) in tuple.iter().enumerate() { + data[r * (n + 1) + c] = FE::from(*v); + } + data[r * (n + 1) + n] = *m; + } + TraceTable::new_main(data, n + 1, 1) +} + +/// Forged MUL rows for `3 * 5 = 99`: row A (`μ_lo = 1, μ_hi = −1`, `lo` forged) +/// plus its honest `μ_lo = 0, μ_hi = 1` copy cancelling A on ALU-hi. +fn forged_mul_rows() -> Vec> { + let honest = generate_mul_trace(&[(MulOperation::new(3, false, 5, false), false)]); + let a = honest.main_table.get_row(0).to_vec(); + assert_eq!(a[cols::MU_LO], FE::one()); + assert_eq!(a[cols::MU_HI], FE::zero()); + assert_eq!(a[cols::LO_0], FE::from(15u64)); + + let mut forged = a.clone(); + forged[cols::LO_0] = FE::from(99u64); + forged[cols::MU_HI] = -FE::one(); + + let mut copy = a.clone(); + copy[cols::MU_LO] = FE::zero(); + copy[cols::MU_HI] = FE::one(); + vec![forged, copy] +} + +/// Proves CPU + MUL + RANGE(+RANGE20)(+CARRIER) and returns whether it verifies. +/// +/// RANGE/RANGE20 receive MUL's IS_HALFWORD/IS_B20 demand only for values a real +/// table has a row for; `extra_half` lets a carrier send one out-of-range value. +fn prove_and_verify(rows: &[Vec], extra_half: Option) -> bool { + let opts = ProofOptions::default_test_options(); + let alu: u64 = BusId::Alu.into(); + let half: u64 = BusId::IsHalfword.into(); + let b20: u64 = BusId::IsB20.into(); + + let net = mul_net(rows); + let mut cpu = Vec::new(); + let mut range = Vec::new(); + let mut range20 = Vec::new(); + for ((bus, tuple), m) in &net { + if *m == FE::zero() { + continue; + } + if *bus == alu { + // MUL receives on ALU (net < 0); the CPU sends −net. + cpu.push((tuple.clone(), -*m)); + } else if *bus == half { + // MUL sends to IS_HALFWORD; RANGE receives it, if it is a halfword. + if tuple[0] < HALF { + range.push((tuple.clone(), *m)); + } + } else if *bus == b20 { + if tuple[0] < B20 { + range20.push((tuple.clone(), *m)); + } + } + } + let mut carrier = Vec::new(); + if let Some(v) = extra_half { + carrier.push((vec![v], FE::one())); + } + + let mul_len = rows.len().next_power_of_two().max(4); + let mut mul_data = vec![FE::zero(); mul_len * cols::NUM_COLUMNS]; + for (r, row) in rows.iter().enumerate() { + mul_data[r * cols::NUM_COLUMNS..(r + 1) * cols::NUM_COLUMNS].copy_from_slice(row); + } + let mut mul_trace = TraceTable::new_main(mul_data, cols::NUM_COLUMNS, 1); + let mut cpu_trace = mock_trace(&cpu, 7); + let mut range_trace = mock_trace(&range, 1); + let mut range20_trace = mock_trace(&range20, 1); + let mut carrier_trace = mock_trace(&carrier, 1); + + let mul_air = create_mul_air(&opts); + let cpu_air = mock(&opts, BusId::Alu, 7, true); + let range_air = mock(&opts, BusId::IsHalfword, 1, false); + let range20_air = mock(&opts, BusId::IsB20, 1, false); + let carrier_air = mock(&opts, BusId::IsHalfword, 1, true); + + let pairs: Vec<( + &dyn AIR, + _, + _, + )> = vec![ + (&cpu_air, &mut cpu_trace, &()), + (&mul_air, &mut mul_trace, &()), + (&range_air, &mut range_trace, &()), + (&range20_air, &mut range20_trace, &()), + (&carrier_air, &mut carrier_trace, &()), + ]; + let proof = multi_prove_ram(pairs, &mut DefaultTranscript::::new(&[])).unwrap(); + let airs: Vec<&dyn AIR> = + vec![&cpu_air, &mul_air, &range_air, &range20_air, &carrier_air]; + Verifier::multi_verify( + &airs, + &proof, + &mut DefaultTranscript::::new(&[]), + &FieldElement::zero(), + ) +} + +/// Positive control: honest `3 * 5 = 15` answering the CPU verifies. +#[test] +fn control_honest_mul_verifies() { + let honest = generate_mul_trace(&[(MulOperation::new(3, false, 5, false), false)]); + let row = honest.main_table.get_row(0).to_vec(); + assert!(prove_and_verify(&[row], None)); +} + +/// The forgery leaves exactly a `−1` at value `p−1` on IS_HALFWORD, and nowhere +/// else out of range — so a single carrier `+1` at `p−1` is all it would need. +#[test] +fn forgery_leaves_only_minus_one_at_p_minus_1() { + let half: u64 = BusId::IsHalfword.into(); + let net = mul_net(&forged_mul_rows()); + for ((bus, tuple), m) in &net { + if *bus == half && tuple[0] >= HALF && *m != FE::zero() { + assert_eq!(tuple[0], minus_one()); + assert_eq!(*m, -FE::one()); + } + } +} + +/// Known attack, no carrier: rejected (matches `multiplicity_forgery_poc`). +#[test] +fn forgery_without_carrier_is_rejected() { + assert!(!prove_and_verify(&forged_mul_rows(), None)); +} + +/// The decisive case: does a carrier that supplies `+1` at `p−1` let the +/// `μ_hi = −1` product forgery through the weighted-by-μ bound? +#[test] +fn forgery_with_carrier_at_p_minus_1() { + let verified = prove_and_verify(&forged_mul_rows(), Some(minus_one())); + // Asserting the observed behaviour so the harness is not vacuous; the point + // of the test is the printed verdict, interpreted in the module docs. + println!("MUL forgery with p-1 carrier verified = {verified}"); + assert!( + verified, + "if this fails the weighted-by-μ bound resisted the carrier" + ); +} From 4dd1ee5ebd6a665602774b26bb39c141b579ab8b Mon Sep 17 00:00:00 2001 From: Nicole Date: Mon, 5 Oct 2026 15:29:10 -0300 Subject: [PATCH 05/29] Fix clippy lints in tests --- prover/src/tests/branch_bus_tests.rs | 2 +- prover/src/tests/mul_mu_carrier_poc.rs | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/prover/src/tests/branch_bus_tests.rs b/prover/src/tests/branch_bus_tests.rs index 710554155..2195009e0 100644 --- a/prover/src/tests/branch_bus_tests.rs +++ b/prover/src/tests/branch_bus_tests.rs @@ -701,7 +701,7 @@ mod mu_bound { use crate::tables::branch::{ MU_MAX, bus_interactions, cols, dedup_branch_rows, generate_branch_trace, }; - use crate::tables::types::{BusId, FE}; + use crate::tables::types::BusId; /// Presence: μ is IS_HALF-checked weighted by itself. #[test] diff --git a/prover/src/tests/mul_mu_carrier_poc.rs b/prover/src/tests/mul_mu_carrier_poc.rs index e3e9ae42f..409bb9f8f 100644 --- a/prover/src/tests/mul_mu_carrier_poc.rs +++ b/prover/src/tests/mul_mu_carrier_poc.rs @@ -177,10 +177,8 @@ fn prove_and_verify(rows: &[Vec], extra_half: Option) -> bool { if tuple[0] < HALF { range.push((tuple.clone(), *m)); } - } else if *bus == b20 { - if tuple[0] < B20 { - range20.push((tuple.clone(), *m)); - } + } else if *bus == b20 && tuple[0] < B20 { + range20.push((tuple.clone(), *m)); } } let mut carrier = Vec::new(); From 6eea7c25b365e6ef83e852eaffd0ffe3386afde7 Mon Sep 17 00:00:00 2001 From: Nicole Date: Tue, 6 Oct 2026 13:33:27 -0300 Subject: [PATCH 06/29] Bound table multiplicities with IS_BYTE instead of IS_HALFWORD --- prover/src/tables/branch.rs | 25 +++---- prover/src/tables/dvrm.rs | 26 ++++---- prover/src/tables/lt.rs | 26 ++++---- prover/src/tables/mul.rs | 27 ++++---- prover/src/tables/trace_builder.rs | 48 ++++---------- prover/src/tests/branch_bus_tests.rs | 40 ++++++++--- prover/src/tests/branch_multiplicity_poc.rs | 5 ++ prover/src/tests/dvrm_tests.rs | 34 +++++++--- prover/src/tests/lt_multiplicity_poc.rs | 73 ++++++++++++++++++++- prover/src/tests/lt_tests.rs | 34 +++++++--- prover/src/tests/mul_mu_carrier_poc.rs | 56 +++++++++++----- prover/src/tests/mul_tests.rs | 34 +++++++--- 12 files changed, 294 insertions(+), 134 deletions(-) diff --git a/prover/src/tables/branch.rs b/prover/src/tables/branch.rs index 0847d6b8f..243f84925 100644 --- a/prover/src/tables/branch.rs +++ b/prover/src/tables/branch.rs @@ -150,12 +150,12 @@ impl BranchOperation { } } -/// Largest per-row multiplicity: `μ` is range-checked to a halfword -/// (`IS_HALF[μ]`, weighted by μ), so a row holds at most this many lookups. The +/// Largest per-row multiplicity: `μ` is range-checked to a byte +/// (`IS_BYTE[μ]`, weighted by μ), so a row holds at most this many lookups. The /// bound keeps μ non-negative: without it a `μ = −1` twin of an honest row /// cancels it on the BRANCH bus while *receiving* its range lookups, absorbing /// another row's out-of-range limb (same bug as LT). -pub const MU_MAX: u64 = (1 << 16) - 1; +pub const MU_MAX: u64 = (1 << 8) - 1; /// Deduplicates BRANCH operations into trace rows: `(pc, offset, register, /// jalr) -> μ`, splitting an op over several rows when its count exceeds @@ -246,7 +246,7 @@ pub fn generate_branch_trace( /// - **Sends** BYTE_ALU[AND] lookup for LSB masking /// (next_pc_low[0] = unmasked_low_byte & 254) /// - **Sends** IS_HALFWORD lookups for next_pc_high[0..3] range checks and for -/// the μ multiplicity bound +/// (next_pc_high limbs); ARE_BYTES for the μ multiplicity bound /// - **Receives** BRANCH lookups from CPU table pub fn bus_interactions() -> Vec { vec![ @@ -307,20 +307,23 @@ pub fn bus_interactions() -> Vec { packing: Packing::Direct, }], ), - // IS_HALF[μ] | μ. Every lookup here fires with μ, so μ itself must be + // ARE_BYTES[μ, 0] | μ. Every lookup here fires with μ, so μ itself must be // bounded and non-negative. Otherwise a μ = −1 twin of an honest row // cancels it on the BRANCH bus while *receiving* its range lookups and // absorbs an out-of-range IS_HALFWORD sent by any row (same as LT). Sent // with itself as multiplicity: `k` rows holding an out-of-range `v` put - // weight `k·v ≠ 0` on a tuple IS_HALF has no row for, while padding + // weight `k·v ≠ 0` on a byte tuple ARE_BYTES has no row for, while padding // (μ = 0) contributes nothing. See `MU_MAX`. BusInteraction::sender( - BusId::IsHalfword, + BusId::AreBytes, Multiplicity::Column(cols::MU), - vec![BusValue::Packed { - start_column: cols::MU, - packing: Packing::Direct, - }], + vec![ + BusValue::Packed { + start_column: cols::MU, + packing: Packing::Direct, + }, + BusValue::constant(0), + ], ), // BRANCH[next_pc; pc, offset, register, JALR] (receiver) // Signature: [next_pc (DWordWL), pc (DWordWL), offset (DWordWL), register (DWordWL), JALR (Bit)] diff --git a/prover/src/tables/dvrm.rs b/prover/src/tables/dvrm.rs index b388e78cd..ee9c75451 100644 --- a/prover/src/tables/dvrm.rs +++ b/prover/src/tables/dvrm.rs @@ -22,7 +22,8 @@ //! - `sign_n`, `sign_d`, `sign_q`, `sign_r`: Bit - sign bits //! //! ## Bus Interactions -//! - Sender: IS_HALF (×20: n, d, r, n_sub_r, q; ×2: μ_q, μ_r bounds) +//! - Sender: IS_HALF (×20: n, d, r, n_sub_r, q) +//! - Sender: ARE_BYTES (×2: μ_q, μ_r bounds) //! - Sender: MSB16 (×3 for sign extraction: n, d, r) //! - Sender: ALU (×3, on the unified bus: ×1 LT-flavored for `|r| < |d|`, //! ×2 MUL-flavored for `n - r = d * q` lo/hi) @@ -274,11 +275,11 @@ impl DvrmOperation { // ========================================================================= /// Largest per-row multiplicity: `μ_q` and `μ_r` are each range-checked to a -/// halfword (`IS_HALF[μ]`, weighted by μ), so a row holds at most this many +/// byte (`IS_BYTE[μ]`, weighted by μ), so a row holds at most this many /// lookups of each kind. The bound is what makes `μ_q + μ_r = 0` imply /// `μ_q = μ_r = 0`: without it a `μ_q = 1, μ_r = −1` row receives a quotient /// lookup while sending none of its checks (forged `n = q·d + r`). -pub const MU_MAX: u64 = (1 << 16) - 1; +pub const MU_MAX: u64 = (1 << 8) - 1; /// Deduplicates DVRM operations into trace rows: `(n, d, signed) -> (μ_q, μ_r)`, /// splitting an op over several rows when a count exceeds [`MU_MAX`]. Shared by @@ -384,7 +385,7 @@ pub fn generate_dvrm_trace( /// /// The DVRM table: /// - **Sends** IS_HALF lookups for n, d, q, r, n_sub_r range checks (×20) -/// and for the μ_q/μ_r multiplicity bounds (×2) +/// - **Sends** ARE_BYTES[μ_q, 0] and ARE_BYTES[μ_r, 0] to bound the multiplicities /// - **Sends** MSB16 lookups for sign extraction (×3: n, d, r) /// - **Sends** LT lookup for |r| < |d| (×1) /// - **Sends** MUL lookups for n_sub_r = d * q verification (×2: lo and hi) @@ -394,24 +395,27 @@ pub fn bus_interactions() -> Vec { let mut interactions = Vec::new(); // ------------------------------------------------------------------------- - // IS_HALF[μ_q] | μ_q, IS_HALF[μ_r] | μ_r. + // ARE_BYTES[μ_q, 0] | μ_q, ARE_BYTES[μ_r, 0] | μ_r. // Every check below fires with μ_q + μ_r, so the multiplicities themselves // must be bounded and non-negative. Otherwise μ_q = 1, μ_r = −1 receives a // quotient lookup with all checks off (forged quotient), // and an honest μ_r = 1 copy of the row cancels the stray −1. Each μ is sent // with itself as multiplicity: `k` rows holding an out-of-range value `v` - // put weight `k·v ≠ 0` on a tuple IS_HALF has no row for, while padding + // put weight `k·v ≠ 0` on a byte tuple ARE_BYTES has no row for, while padding // (μ = 0) contributes nothing, so an empty instance still matches an absent // one. See `MU_MAX`. // ------------------------------------------------------------------------- for col in [cols::MU_Q, cols::MU_R] { interactions.push(BusInteraction::sender( - BusId::IsHalfword, + BusId::AreBytes, Multiplicity::Column(col), - vec![BusValue::Packed { - start_column: col, - packing: Packing::Direct, - }], + vec![ + BusValue::Packed { + start_column: col, + packing: Packing::Direct, + }, + BusValue::constant(0), + ], )); } diff --git a/prover/src/tables/lt.rs b/prover/src/tables/lt.rs index 0ea208ec2..b7f3d2710 100644 --- a/prover/src/tables/lt.rs +++ b/prover/src/tables/lt.rs @@ -23,7 +23,8 @@ //! ## Bus Interactions //! - Sender: MSB16 (×2 for lhs_msb, rhs_msb) //! - Sender: IS_HALFWORD (×6: ×4 for lhs_sub_rhs, ×1 for lhs[1], ×1 for rhs[1]; -//! ×1 for the μ multiplicity bound) +//! ) +//! - Sender: ARE_BYTES (×1 for the μ multiplicity bound) //! - Receiver: ALU (all less-than lookups — CPU SLT/BLT/BGE dispatch and the //! internal `memw`/`memw_aligned`/`dvrm` timestamp / |r|<|d| checks) @@ -153,12 +154,12 @@ impl LtOperation { } } -/// Largest per-row multiplicity: `μ` is range-checked to a halfword -/// (`IS_HALF[μ]`, weighted by μ), so a row holds at most this many lookups. The +/// Largest per-row multiplicity: `μ` is range-checked to a byte +/// (`IS_BYTE[μ]`, weighted by μ), so a row holds at most this many lookups. The /// bound is what keeps μ non-negative: without it a `μ = −1` twin of an honest /// row cancels it on the ALU bus while *receiving* its range lookups, which /// absorbs another row's out-of-range limb (a forged `5 < 3 = 1` verified). -pub const MU_MAX: u64 = (1 << 16) - 1; +pub const MU_MAX: u64 = (1 << 8) - 1; /// Deduplicates LT operations into trace rows: `(lhs, rhs, signed, invert) -> μ`, /// splitting an op over several rows when its count exceeds [`MU_MAX`]. Shared @@ -330,21 +331,24 @@ pub fn bus_interactions() -> Vec { packing: Packing::Direct, }], ), - // IS_HALF[μ] | μ. Every lookup here fires with μ, so μ itself must be + // ARE_BYTES[μ, 0] | μ. Every lookup here fires with μ, so μ itself must be // bounded and non-negative. Otherwise a μ = −1 twin of an honest row // cancels it on the ALU bus while *receiving* its range lookups, with // `sub_0` free up to 2^32: it absorbs an out-of-range IS_HALFWORD sent // by any row, another LT row included (forged `5 < 3 = 1`). Sent with // itself as multiplicity: `k` rows holding an out-of-range `v` put - // weight `k·v ≠ 0` on a tuple IS_HALF has no row for, while padding + // weight `k·v ≠ 0` on a byte tuple ARE_BYTES has no row for, while padding // (μ = 0) contributes nothing. See `MU_MAX`. BusInteraction::sender( - BusId::IsHalfword, + BusId::AreBytes, Multiplicity::Column(cols::MU), - vec![BusValue::Packed { - start_column: cols::MU, - packing: Packing::Direct, - }], + vec![ + BusValue::Packed { + start_column: cols::MU, + packing: Packing::Direct, + }, + BusValue::constant(0), + ], ), // ALU[lhs, rhs, opsel(LT) + 32*signed + 64*invert] -> out (receiver). // Every LT lookup arrives here: the CPU dispatches SLT/BLT/BGE on the diff --git a/prover/src/tables/mul.rs b/prover/src/tables/mul.rs index b9b2576f5..08863abb5 100644 --- a/prover/src/tables/mul.rs +++ b/prover/src/tables/mul.rs @@ -25,8 +25,8 @@ //! //! ## Bus Interactions //! - Sender: MSB16 (×2 for sign extraction) -//! - Sender: IS_HALF (×16 for lhs/rhs input and lo/hi output range checks, -//! ×2 for the μ_lo/μ_hi multiplicity bounds) +//! - Sender: IS_HALF (×16 for lhs/rhs input and lo/hi output range checks) +//! - Sender: ARE_BYTES (×2 for the μ_lo/μ_hi multiplicity bounds) //! - Sender: IS_B20 (×4 for carry range checks) //! - Receiver: ALU (×2 for lo and hi results — every MUL lookup, CPU //! MUL/MULH dispatch and dvrm's internal `d*q` consistency) @@ -283,11 +283,11 @@ impl MulOperation { // ========================================================================= /// Largest per-row multiplicity: `μ_lo` and `μ_hi` are each range-checked to a -/// halfword (`IS_HALF[μ]`, weighted by μ), so a row holds at most this many +/// byte (`IS_BYTE[μ]`, weighted by μ), so a row holds at most this many /// lookups of each kind. The bound is what makes `μ_lo + μ_hi = 0` imply /// `μ_lo = μ_hi = 0`: without it a `μ_lo = 1, μ_hi = −1` row receives a lookup /// while sending none of its range checks. -pub const MU_MAX: u64 = (1 << 16) - 1; +pub const MU_MAX: u64 = (1 << 8) - 1; /// Deduplicates MUL operations into trace rows: `(lhs, lhs_signed, rhs, /// rhs_signed) -> (μ_lo, μ_hi)`, splitting an op over several rows when a count @@ -386,7 +386,7 @@ pub fn generate_mul_trace( /// The MUL table: /// - **Sends** MSB16 lookups for sign bit extraction (×2) /// - **Sends** IS_HALF lookups for lhs/rhs input and lo/hi output range checks (×16) -/// and for the μ_lo/μ_hi multiplicity bounds (×2) +/// - **Sends** ARE_BYTES[μ_lo, 0] and ARE_BYTES[μ_hi, 0] to bound the multiplicities /// - **Sends** IS_B20 lookups for carry range checks (×4) /// - **Receives** MUL lookups from CPU table (×2: lo and hi) pub fn bus_interactions() -> Vec { @@ -428,24 +428,27 @@ pub fn bus_interactions() -> Vec { )); // ------------------------------------------------------------------------- - // IS_HALF[μ_lo] | μ_lo, IS_HALF[μ_hi] | μ_hi. + // ARE_BYTES[μ_lo, 0] | μ_lo, ARE_BYTES[μ_hi, 0] | μ_hi. // Every check below fires with μ_lo + μ_hi, so the multiplicities themselves // must be bounded and non-negative. Otherwise μ_lo = 1, μ_hi = −1 receives a // `lo` lookup with all range checks off (forged product), // and an honest μ_hi = 1 copy of the row cancels the stray −1. Each μ is sent // with itself as multiplicity: `k` rows holding an out-of-range value `v` - // put weight `k·v ≠ 0` on a tuple IS_HALF has no row for, while padding + // put weight `k·v ≠ 0` on a byte tuple ARE_BYTES has no row for, while padding // (μ = 0) contributes nothing, so an empty instance still matches an absent // one. See `MU_MAX`. // ------------------------------------------------------------------------- for col in [cols::MU_LO, cols::MU_HI] { interactions.push(BusInteraction::sender( - BusId::IsHalfword, + BusId::AreBytes, Multiplicity::Column(col), - vec![BusValue::Packed { - start_column: col, - packing: Packing::Direct, - }], + vec![ + BusValue::Packed { + start_column: col, + packing: Packing::Direct, + }, + BusValue::constant(0), + ], )); } diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index e56e68a0a..5ecbf57ef 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -1732,15 +1732,11 @@ pub(crate) fn collect_bitwise_from_lt( )); } - // IS_HALF[μ] | μ: each row sends its own μ, μ times (padding sends nothing), + // IS_BYTE[μ] | μ: each row sends its own μ, μ times (padding sends nothing), // over exactly the rows `generate_lt_trace` builds for each chunk. for chunk in lt_ops.chunks(max_rows_lt) { for (_, mu) in lt::dedup_lt_rows(chunk) { - let op = BitwiseOperation::halfword( - BitwiseOperationType::IsHalf, - (mu & 0xFF) as u8, - ((mu >> 8) & 0xFF) as u8, - ); + let op = BitwiseOperation::single_byte(BitwiseOperationType::AreBytes, mu as u8); bitwise_ops.extend(std::iter::repeat_n(op, mu as usize)); } } @@ -1755,7 +1751,7 @@ pub(crate) fn collect_bitwise_from_lt( /// and IS_B20 lookups for carry range checks. /// /// IS_HALF and IS_B20 are emitted once per raw op. The per-row lookups (MSB16 -/// and the IS_HALF[μ_lo]/IS_HALF[μ_hi] multiplicity bounds) are counted over the +/// and the IS_BYTE[μ_lo]/IS_BYTE[μ_hi] multiplicity bounds) are counted over the /// rows of each `max_rows_mul` chunk, mirroring `chunk_and_generate_optional` — /// a unique signed op that spans two instances is sent twice and must be tallied /// twice. @@ -1816,21 +1812,15 @@ pub(crate) fn collect_bitwise_from_mul( } // Per-row lookups, over exactly the rows `generate_mul_trace` builds for each - // `max_rows_mul` chunk (`chunk_and_generate_optional`): IS_HALF[μ_lo] and - // IS_HALF[μ_hi] are sent μ_lo / μ_hi times (so padding rows send nothing), and + // `max_rows_mul` chunk (`chunk_and_generate_optional`): IS_BYTE[μ_lo] and + // IS_BYTE[μ_hi] are sent μ_lo / μ_hi times (so padding rows send nothing), and // MSB16 fires once per row whose sign flag is set — a unique signed op spanning two // instances, or split over two rows by `mul::MU_MAX`, is sent once per row. - let halfword = |v: u64| { - BitwiseOperation::halfword( - BitwiseOperationType::IsHalf, - (v & 0xFF) as u8, - ((v >> 8) & 0xFF) as u8, - ) - }; + let mu_byte = |v: u64| BitwiseOperation::single_byte(BitwiseOperationType::AreBytes, v as u8); for chunk in mul_ops.chunks(max_rows_mul) { for (op, mu) in &mul::dedup_mul_rows(chunk) { for m in [mu.mu_lo, mu.mu_hi] { - bitwise_ops.extend(std::iter::repeat_n(halfword(m), m as usize)); + bitwise_ops.extend(std::iter::repeat_n(mu_byte(m), m as usize)); } if op.lhs_signed { let lhs_3 = ((op.lhs >> 48) & 0xFFFF) as u16; @@ -1864,7 +1854,7 @@ pub(crate) fn collect_bitwise_from_mul( /// are collected here alongside the constraint-level ones. /// /// IS_HALF and ZERO (C8/C20) are emitted once per raw op. MSB16, the -/// NEG-template ZERO lookups (C3/C5) and the IS_HALF[μ_q]/IS_HALF[μ_r] +/// NEG-template ZERO lookups (C3/C5) and the IS_BYTE[μ_q]/IS_BYTE[μ_r] /// multiplicity bounds are counted over the rows of each `max_rows_dvrm` chunk, /// mirroring `chunk_and_generate_optional`. /// @@ -1954,22 +1944,16 @@ pub(crate) fn collect_bitwise_from_dvrm( } // Per-row lookups, over exactly the rows `generate_dvrm_trace` builds for each - // `max_rows_dvrm` chunk (`chunk_and_generate_optional`): IS_HALF[μ_q] and - // IS_HALF[μ_r] sent μ_q / μ_r times (padding rows send nothing); MSB16 (Column(SIGNED)) + // `max_rows_dvrm` chunk (`chunk_and_generate_optional`): IS_BYTE[μ_q] and + // IS_BYTE[μ_r] sent μ_q / μ_r times (padding rows send nothing); MSB16 (Column(SIGNED)) // and the NEG-template ZERO lookups (Column(SIGN_R)/Column(SIGN_D)) once per // row whose bit is set. An op split over two rows by `dvrm::MU_MAX` sends // them once per row. - let halfword = |v: u64| { - BitwiseOperation::halfword( - BitwiseOperationType::IsHalf, - (v & 0xFF) as u8, - ((v >> 8) & 0xFF) as u8, - ) - }; + let mu_byte = |v: u64| BitwiseOperation::single_byte(BitwiseOperationType::AreBytes, v as u8); for chunk in dvrm_ops.chunks(max_rows_dvrm) { for (op, mu) in &dvrm::dedup_dvrm_rows(chunk) { for m in [mu.mu_q, mu.mu_r] { - bitwise_ops.extend(std::iter::repeat_n(halfword(m), m as usize)); + bitwise_ops.extend(std::iter::repeat_n(mu_byte(m), m as usize)); } if op.signed { @@ -2100,15 +2084,11 @@ pub(crate) fn collect_bitwise_from_branch( )); } - // IS_HALF[μ] | μ: each row sends its own μ, μ times (padding sends nothing), + // IS_BYTE[μ] | μ: each row sends its own μ, μ times (padding sends nothing), // over exactly the rows `generate_branch_trace` builds for each chunk. for chunk in branch_ops.chunks(max_rows_branch) { for (_, mu) in branch::dedup_branch_rows(chunk) { - let op = BitwiseOperation::halfword( - BitwiseOperationType::IsHalf, - (mu & 0xFF) as u8, - ((mu >> 8) & 0xFF) as u8, - ); + let op = BitwiseOperation::single_byte(BitwiseOperationType::AreBytes, mu as u8); bitwise_ops.extend(std::iter::repeat_n(op, mu as usize)); } } diff --git a/prover/src/tests/branch_bus_tests.rs b/prover/src/tests/branch_bus_tests.rs index 2195009e0..e9d83741d 100644 --- a/prover/src/tests/branch_bus_tests.rs +++ b/prover/src/tests/branch_bus_tests.rs @@ -709,11 +709,11 @@ mod mu_bound { use stark::lookup::{BusValue, Multiplicity, Packing}; assert!( bus_interactions().iter().any(|i| i.is_sender - && i.bus_id == BusId::IsHalfword as u64 + && i.bus_id == BusId::AreBytes as u64 && matches!(i.multiplicity, Multiplicity::Column(m) if m == cols::MU) - && matches!(i.values.as_slice(), - [BusValue::Packed { start_column, packing: Packing::Direct }] if *start_column == cols::MU)), - "BRANCH must IS_HALF-check μ weighted by itself" + && matches!(i.values.first(), + Some(BusValue::Packed { start_column, packing: Packing::Direct }) if *start_column == cols::MU)), + "BRANCH must IS_BYTE-check μ weighted by itself" ); } @@ -785,19 +785,41 @@ mod mu_bound { } } - let mut expected: HashMap<(u8, u8), i64> = HashMap::new(); + // The μ bound moved to ARE_BYTES, so IS_HALF must now be exactly the + // next_pc_high limbs. + is_half.retain(|_, n| *n != 0); + assert!( + is_half.is_empty(), + "IS_HALF must match exactly the next_pc_high range checks" + ); + + // ARE_BYTES carries both the real next_pc_low[1] byte check and the + // μ bound; subtract the former to isolate the latter. + let mut arebytes: HashMap = HashMap::new(); + for b in collected + .iter() + .filter(|b| b.lookup_type == BitwiseOperationType::AreBytes) + { + assert_eq!(b.y, 0, "BRANCH ARE_BYTES sends are single bytes"); + *arebytes.entry(b.x).or_default() += 1; + } + for op in &ops { + let next_pc = op.compute_next_pc(); + *arebytes.entry(((next_pc >> 8) & 0xFF) as u8).or_default() -= 1; + } + let mut expected: HashMap = HashMap::new(); for c in ops.chunks(chunk) { let t = generate_branch_trace(c); for r in 0..t.num_rows() { let mu = t.get_main(r, cols::MU).to_raw(); - *expected.entry(half(mu)).or_default() += mu as i64; + *expected.entry(mu as u8).or_default() += mu as i64; } } - is_half.retain(|_, n| *n != 0); + arebytes.retain(|_, n| *n != 0); expected.retain(|_, n| *n != 0); assert_eq!( - is_half, expected, - "IS_HALF[μ] tally must match the trace rows" + arebytes, expected, + "ARE_BYTES[μ] tally must match the trace rows" ); } } diff --git a/prover/src/tests/branch_multiplicity_poc.rs b/prover/src/tests/branch_multiplicity_poc.rs index 8357c80af..ab486219f 100644 --- a/prover/src/tests/branch_multiplicity_poc.rs +++ b/prover/src/tests/branch_multiplicity_poc.rs @@ -168,6 +168,11 @@ fn prove_and_verify(rows: &[Row], victim: Option) -> bool { if *m == FE::zero() { continue; } + // A real byte table has rows only for [0, 256); the μ bound sends + // ARE_BYTES[μ, 0], so an out-of-range μ (p−1) has no receiver. + if *bus == BusId::AreBytes as u64 && tuple[0] >= 256 { + continue; + } if *bus == half_id && tuple[0] >= HALF { continue; } diff --git a/prover/src/tests/dvrm_tests.rs b/prover/src/tests/dvrm_tests.rs index c129f45b6..588b77c55 100644 --- a/prover/src/tests/dvrm_tests.rs +++ b/prover/src/tests/dvrm_tests.rs @@ -572,11 +572,11 @@ fn test_dvrm_bounds_its_multiplicities() { for col in [cols::MU_Q, cols::MU_R] { assert!( bus_interactions().iter().any(|i| i.is_sender - && i.bus_id == BusId::IsHalfword as u64 + && i.bus_id == BusId::AreBytes as u64 && matches!(i.multiplicity, Multiplicity::Column(m) if m == col) - && matches!(i.values.as_slice(), - [BusValue::Packed { start_column, packing: Packing::Direct }] if *start_column == col)), - "DVRM must IS_HALF-check multiplicity column {col} weighted by itself" + && matches!(i.values.first(), + Some(BusValue::Packed { start_column, packing: Packing::Direct }) if *start_column == col)), + "DVRM must IS_BYTE-check multiplicity column {col} weighted by itself" ); } } @@ -650,25 +650,41 @@ fn test_dvrm_per_row_lookups_match_collector() { } } - let mut expected: HashMap<(u8, u8), i64> = HashMap::new(); + // The μ bounds moved to ARE_BYTES, so IS_HALF must now be exactly the limbs. + is_half.retain(|_, n| *n != 0); + assert!( + is_half.is_empty(), + "IS_HALF must match exactly the limb range checks" + ); + + // μ bounds: ARE_BYTES[μ_q, 0] and ARE_BYTES[μ_r, 0], weighted by μ. + let mut arebytes: HashMap = HashMap::new(); + for b in collected + .iter() + .filter(|b| b.lookup_type == BitwiseOperationType::AreBytes) + { + assert_eq!(b.y, 0, "μ bounds send ARE_BYTES[μ, 0]"); + *arebytes.entry(b.x).or_default() += 1; + } + let mut expected: HashMap = HashMap::new(); let (mut msb16, mut neg_zero) = (0usize, 0usize); for c in ops.chunks(chunk) { let t = generate_dvrm_trace(c); for r in 0..t.num_rows() { for col in [cols::MU_Q, cols::MU_R] { let mu = t.get_main(r, col).to_raw(); - *expected.entry(half(mu)).or_default() += mu as i64; + *expected.entry(mu as u8).or_default() += mu as i64; } msb16 += 3 * t.get_main(r, cols::SIGNED).to_raw() as usize; neg_zero += 2 * t.get_main(r, cols::SIGN_R).to_raw() as usize; neg_zero += 2 * t.get_main(r, cols::SIGN_D).to_raw() as usize; } } - is_half.retain(|_, n| *n != 0); + arebytes.retain(|_, n| *n != 0); expected.retain(|_, n| *n != 0); assert_eq!( - is_half, expected, - "IS_HALF[μ] tally must match the trace rows" + arebytes, expected, + "ARE_BYTES[μ] tally must match the trace rows" ); assert_eq!(count(BitwiseOperationType::Msb16), msb16, "MSB16 tally"); // ZERO = C8 + C20 per raw op, plus the per-row NEG-template sends. diff --git a/prover/src/tests/lt_multiplicity_poc.rs b/prover/src/tests/lt_multiplicity_poc.rs index deecb0f4a..af53429bf 100644 --- a/prover/src/tests/lt_multiplicity_poc.rs +++ b/prover/src/tests/lt_multiplicity_poc.rs @@ -172,6 +172,7 @@ fn prove_and_verify(lt_rows: &[Row], cpu: &[Vec], victim: &[u64]) -> bool { let opts = ProofOptions::default_test_options(); let half_id: u64 = BusId::IsHalfword.into(); let msb_id: u64 = BusId::Msb16.into(); + let byte_id: u64 = BusId::AreBytes.into(); let mut net = lt_net(lt_rows); for &v in victim { @@ -190,6 +191,13 @@ fn prove_and_verify(lt_rows: &[Row], cpu: &[Vec], victim: &[u64]) -> bool { (t, m) }) .collect(); + // ARE_BYTES receiver (the μ bound sends ARE_BYTES[μ, 0]); a real byte table + // has rows only for [0, 256), so an out-of-range value has no receiver. + let arebytes: Vec<(Vec, FE)> = net + .iter() + .filter(|((bus, t), m)| *bus == byte_id && t[0] < 256 && **m != FE::zero()) + .map(|((_, t), m)| (t.clone(), *m)) + .collect(); let cpu_rows: Vec<(Vec, FE)> = cpu.iter().map(|t| (t.clone(), FE::one())).collect(); let victim_rows: Vec<(Vec, FE)> = victim.iter().map(|&v| (vec![v], FE::one())).collect(); @@ -203,12 +211,14 @@ fn prove_and_verify(lt_rows: &[Row], cpu: &[Vec], victim: &[u64]) -> bool { let mut victim_trace = lookup_trace(&victim_rows, 1); let mut range_trace = lookup_trace(&range, 1); let mut msb16_trace = lookup_trace(&msb16, 2); + let mut arebytes_trace = lookup_trace(&arebytes, 2); let lt_air = create_lt_air(&opts); let cpu_air = lookup_air(&opts, BusId::Alu, 7, true); let victim_air = lookup_air(&opts, BusId::IsHalfword, 1, true); let range_air = lookup_air(&opts, BusId::IsHalfword, 1, false); let msb16_air = lookup_air(&opts, BusId::Msb16, 2, false); + let arebytes_air = lookup_air(&opts, BusId::AreBytes, 2, false); let pairs: Vec<( &dyn AIR, @@ -220,10 +230,17 @@ fn prove_and_verify(lt_rows: &[Row], cpu: &[Vec], victim: &[u64]) -> bool { (<_air, &mut lt_trace, &()), (&range_air, &mut range_trace, &()), (&msb16_air, &mut msb16_trace, &()), + (&arebytes_air, &mut arebytes_trace, &()), ]; let proof = multi_prove_ram(pairs, &mut DefaultTranscript::::new(&[])).unwrap(); - let airs: Vec<&dyn AIR> = - vec![&cpu_air, &victim_air, <_air, &range_air, &msb16_air]; + let airs: Vec<&dyn AIR> = vec![ + &cpu_air, + &victim_air, + <_air, + &range_air, + &msb16_air, + &arebytes_air, + ]; Verifier::multi_verify( &airs, &proof, @@ -237,6 +254,21 @@ fn honest_out(op: &LtOperation) -> u64 { honest_row(op)[cols::OUT].canonical_u64() } +/// A *real, constraint-satisfying* LT row whose `sub_0 = p−1` — the non-canonical +/// split `(p−1, 1)` of `sub_lo = 0xFFFF`. `carry_0` reads only `sub_0 + 2^16·sub_1` +/// (lt.rs:410), so every LtConstraint holds, yet its IS_HALFWORD send carries the +/// out-of-range value `p−1` with multiplicity +1. This is the carrier that cancels +/// a twin's `IS_HALF[μ=p−1]` bound send — no mock, no other table needed. +fn p_minus_1_carrier() -> (LtOperation, Row) { + let op = LtOperation::new(0xFFFF, 0, false); + let mut row = honest_row(&op); + assert_eq!(row[cols::LHS_SUB_RHS_0], FE::from(0xFFFFu64)); + assert_eq!(row[cols::LHS_SUB_RHS_1], FE::zero()); + row[cols::LHS_SUB_RHS_0] = -FE::one(); // p−1 + row[cols::LHS_SUB_RHS_1] = FE::one(); // (p−1) + 2^16 = 0xFFFF, carry unchanged + (op, row) +} + /// Positive control: honest LT rows answering the CPU verify. Without this, a /// "rejected" below would say nothing. #[test] @@ -304,3 +336,40 @@ fn regression_negative_mu_twin_cannot_forge_5_lt_3() { let rows = [a, twin_row(&carrier, HALF), honest_row(&carrier)]; assert!(!prove_and_verify(&rows, &[alu_tuple(&op, 1)], &[])); } + +/// Regression: the in-table carrier that defeated the OLD `IS_HALF[μ]` bound no +/// longer works now that the μ bound is on ARE_BYTES. +/// +/// The four-row forgery of `5 < 3 = 1`: +/// - A: the attacked `5<3` row forced to `lt=1` (high diff word 2^32, `sub_3=2^16`). +/// - T: a `μ=−1` twin of `2^16 < 0` with `sub_0=2^16`, absorbing A's `IS_HALF[2^16]`. +/// - D: the honest `2^16 < 0` row, cancelling T on the ALU bus. +/// - C: a carrier (`sub_0=p−1`) that used to cancel T's bound send on IS_HALF. +/// +/// With the bound on ARE_BYTES, T's `μ=p−1` send lands on `ARE_BYTES[p−1]` (out +/// of byte range, no receiver) and C's `IS_HALF[p−1]` is now itself uncancelled: +/// both buses reject. A limb (IS_HALF) carrier can no longer reach the bound. +#[test] +fn are_bytes_bound_resists_in_table_carrier() { + let op_a = LtOperation::new(5, 3, false); + let mut a = honest_row(&op_a); + a[cols::LT] = FE::one(); + a[cols::OUT] = FE::one(); + a[cols::LHS_SUB_RHS_2] = FE::zero(); + a[cols::LHS_SUB_RHS_3] = FE::from(1u64 << 16); + + let carrier = LtOperation::new(1 << 16, 0, false); + let t = twin_row(&carrier, 1 << 16); + let d = honest_row(&carrier); + + let (op_c, c) = p_minus_1_carrier(); + + let rows = [a, t, d, c]; + let cpu = [alu_tuple(&op_a, 1), alu_tuple(&op_c, honest_out(&op_c))]; + let verified = prove_and_verify(&rows, &cpu, &[]); + println!("LT 5<3=1 with in-table p-1 carrier verified = {verified}"); + assert!( + !verified, + "carrier no longer defeats the ARE_BYTES-bus bound" + ); +} diff --git a/prover/src/tests/lt_tests.rs b/prover/src/tests/lt_tests.rs index ea4f92e38..d11493544 100644 --- a/prover/src/tests/lt_tests.rs +++ b/prover/src/tests/lt_tests.rs @@ -248,11 +248,11 @@ fn test_lt_bounds_its_multiplicity() { use stark::lookup::{BusValue, Multiplicity, Packing}; assert!( bus_interactions().iter().any(|i| i.is_sender - && i.bus_id == BusId::IsHalfword as u64 + && i.bus_id == BusId::AreBytes as u64 && matches!(i.multiplicity, Multiplicity::Column(m) if m == cols::MU) - && matches!(i.values.as_slice(), - [BusValue::Packed { start_column, packing: Packing::Direct }] if *start_column == cols::MU)), - "LT must IS_HALF-check μ weighted by itself" + && matches!(i.values.first(), + Some(BusValue::Packed { start_column, packing: Packing::Direct }) if *start_column == cols::MU)), + "LT must IS_BYTE-check μ weighted by itself" ); } @@ -325,19 +325,35 @@ fn test_lt_mu_bound_lookups_match_collector() { *is_half.entry(half(op.rhs >> 32 & 0xFFFF)).or_default() -= 1; } - let mut expected: HashMap<(u8, u8), i64> = HashMap::new(); + // The μ bound moved to ARE_BYTES, so IS_HALF must now be exactly the limbs. + is_half.retain(|_, n| *n != 0); + assert!( + is_half.is_empty(), + "IS_HALF must match exactly the limb range checks" + ); + + // μ bound: ARE_BYTES[μ, 0] weighted by μ. + let mut arebytes: HashMap = HashMap::new(); + for b in collected + .iter() + .filter(|b| b.lookup_type == BitwiseOperationType::AreBytes) + { + assert_eq!(b.y, 0, "μ bound sends ARE_BYTES[μ, 0]"); + *arebytes.entry(b.x).or_default() += 1; + } + let mut expected: HashMap = HashMap::new(); for c in ops.chunks(chunk) { let t = generate_lt_trace(c); for r in 0..t.num_rows() { let mu = t.get_main(r, cols::MU).to_raw(); - *expected.entry(half(mu)).or_default() += mu as i64; + *expected.entry(mu as u8).or_default() += mu as i64; } } - is_half.retain(|_, n| *n != 0); + arebytes.retain(|_, n| *n != 0); expected.retain(|_, n| *n != 0); assert_eq!( - is_half, expected, - "IS_HALF[μ] tally must match the trace rows" + arebytes, expected, + "ARE_BYTES[μ] tally must match the trace rows" ); } } diff --git a/prover/src/tests/mul_mu_carrier_poc.rs b/prover/src/tests/mul_mu_carrier_poc.rs index 409bb9f8f..813639b9e 100644 --- a/prover/src/tests/mul_mu_carrier_poc.rs +++ b/prover/src/tests/mul_mu_carrier_poc.rs @@ -160,11 +160,13 @@ fn prove_and_verify(rows: &[Vec], extra_half: Option) -> bool { let alu: u64 = BusId::Alu.into(); let half: u64 = BusId::IsHalfword.into(); let b20: u64 = BusId::IsB20.into(); + let byte: u64 = BusId::AreBytes.into(); let net = mul_net(rows); let mut cpu = Vec::new(); let mut range = Vec::new(); let mut range20 = Vec::new(); + let mut arebytes = Vec::new(); for ((bus, tuple), m) in &net { if *m == FE::zero() { continue; @@ -179,6 +181,9 @@ fn prove_and_verify(rows: &[Vec], extra_half: Option) -> bool { } } else if *bus == b20 && tuple[0] < B20 { range20.push((tuple.clone(), *m)); + } else if *bus == byte && tuple[0] < 256 { + // μ bound: ARE_BYTES[μ, 0]; a real byte table has rows only for [0,256). + arebytes.push((tuple.clone(), *m)); } } let mut carrier = Vec::new(); @@ -195,12 +200,14 @@ fn prove_and_verify(rows: &[Vec], extra_half: Option) -> bool { let mut cpu_trace = mock_trace(&cpu, 7); let mut range_trace = mock_trace(&range, 1); let mut range20_trace = mock_trace(&range20, 1); + let mut arebytes_trace = mock_trace(&arebytes, 2); let mut carrier_trace = mock_trace(&carrier, 1); let mul_air = create_mul_air(&opts); let cpu_air = mock(&opts, BusId::Alu, 7, true); let range_air = mock(&opts, BusId::IsHalfword, 1, false); let range20_air = mock(&opts, BusId::IsB20, 1, false); + let arebytes_air = mock(&opts, BusId::AreBytes, 2, false); let carrier_air = mock(&opts, BusId::IsHalfword, 1, true); let pairs: Vec<( @@ -212,11 +219,18 @@ fn prove_and_verify(rows: &[Vec], extra_half: Option) -> bool { (&mul_air, &mut mul_trace, &()), (&range_air, &mut range_trace, &()), (&range20_air, &mut range20_trace, &()), + (&arebytes_air, &mut arebytes_trace, &()), (&carrier_air, &mut carrier_trace, &()), ]; let proof = multi_prove_ram(pairs, &mut DefaultTranscript::::new(&[])).unwrap(); - let airs: Vec<&dyn AIR> = - vec![&cpu_air, &mul_air, &range_air, &range20_air, &carrier_air]; + let airs: Vec<&dyn AIR> = vec![ + &cpu_air, + &mul_air, + &range_air, + &range20_air, + &arebytes_air, + &carrier_air, + ]; Verifier::multi_verify( &airs, &proof, @@ -233,16 +247,25 @@ fn control_honest_mul_verifies() { assert!(prove_and_verify(&[row], None)); } -/// The forgery leaves exactly a `−1` at value `p−1` on IS_HALFWORD, and nowhere -/// else out of range — so a single carrier `+1` at `p−1` is all it would need. +/// The twin's `μ_hi = −1` bound now lands on ARE_BYTES, not IS_HALFWORD: it +/// leaves a `−1` at `ARE_BYTES[p−1]` (out of byte range, no receiver), which is +/// why an IS_HALFWORD carrier cannot cancel it. The IS_HALFWORD bus carries no +/// out-of-range residue from the bound. #[test] -fn forgery_leaves_only_minus_one_at_p_minus_1() { +fn forgery_bound_residue_is_on_are_bytes() { let half: u64 = BusId::IsHalfword.into(); + let byte: u64 = BusId::AreBytes.into(); let net = mul_net(&forged_mul_rows()); + + // The μ bound's out-of-range residue is on ARE_BYTES[p−1] with weight −1. + assert_eq!( + net.get(&(byte, vec![minus_one(), 0])).copied(), + Some(-FE::one()) + ); + // No out-of-range IS_HALFWORD send comes from the μ bound any more. for ((bus, tuple), m) in &net { - if *bus == half && tuple[0] >= HALF && *m != FE::zero() { - assert_eq!(tuple[0], minus_one()); - assert_eq!(*m, -FE::one()); + if *bus == half && tuple[0] == minus_one() { + assert_eq!(*m, FE::zero(), "no μ-bound residue on IS_HALFWORD"); } } } @@ -253,16 +276,15 @@ fn forgery_without_carrier_is_rejected() { assert!(!prove_and_verify(&forged_mul_rows(), None)); } -/// The decisive case: does a carrier that supplies `+1` at `p−1` let the -/// `μ_hi = −1` product forgery through the weighted-by-μ bound? +/// Regression: the μ bound now lives on ARE_BYTES, a bus the `lo`/`hi` limb +/// range checks (IS_HALFWORD) never touch. So an IS_HALFWORD carrier at `p−1` +/// can no longer cancel the twin's `μ_hi = −1` bound send (it lands on +/// ARE_BYTES[p−1], out of byte range, with no receiver). The forgery is +/// rejected even WITH the carrier. #[test] -fn forgery_with_carrier_at_p_minus_1() { - let verified = prove_and_verify(&forged_mul_rows(), Some(minus_one())); - // Asserting the observed behaviour so the harness is not vacuous; the point - // of the test is the printed verdict, interpreted in the module docs. - println!("MUL forgery with p-1 carrier verified = {verified}"); +fn is_halfword_carrier_no_longer_defeats_byte_bound() { assert!( - verified, - "if this fails the weighted-by-μ bound resisted the carrier" + !prove_and_verify(&forged_mul_rows(), Some(minus_one())), + "ARE_BYTES bound must resist an IS_HALFWORD carrier" ); } diff --git a/prover/src/tests/mul_tests.rs b/prover/src/tests/mul_tests.rs index cce10d890..1c72dfab8 100644 --- a/prover/src/tests/mul_tests.rs +++ b/prover/src/tests/mul_tests.rs @@ -433,11 +433,11 @@ fn test_mul_bounds_its_multiplicities() { for col in [cols::MU_LO, cols::MU_HI] { assert!( bus_interactions().iter().any(|i| i.is_sender - && i.bus_id == BusId::IsHalfword as u64 + && i.bus_id == BusId::AreBytes as u64 && matches!(i.multiplicity, Multiplicity::Column(m) if m == col) - && matches!(i.values.as_slice(), - [BusValue::Packed { start_column, packing: Packing::Direct }] if *start_column == col)), - "MUL must IS_HALF-check multiplicity column {col} weighted by itself" + && matches!(i.values.first(), + Some(BusValue::Packed { start_column, packing: Packing::Direct }) if *start_column == col)), + "MUL must IS_BYTE-check multiplicity column {col} weighted by itself" ); } } @@ -526,24 +526,40 @@ fn test_mul_per_row_lookups_match_collector() { } } - let mut expected: HashMap<(u8, u8), i64> = HashMap::new(); + // The μ bound moved to ARE_BYTES, so IS_HALF must now be exactly the limbs. + is_half.retain(|_, n| *n != 0); + assert!( + is_half.is_empty(), + "IS_HALF must match exactly the limb range checks" + ); + + // μ bounds: ARE_BYTES[μ_lo, 0] and ARE_BYTES[μ_hi, 0], weighted by μ. + let mut arebytes: HashMap = HashMap::new(); + for b in collected + .iter() + .filter(|b| b.lookup_type == BitwiseOperationType::AreBytes) + { + assert_eq!(b.y, 0, "μ bounds send ARE_BYTES[μ, 0]"); + *arebytes.entry(b.x).or_default() += 1; + } + let mut expected: HashMap = HashMap::new(); let mut msb16 = 0usize; for c in ops.chunks(chunk) { let t = generate_mul_trace(c); for r in 0..t.num_rows() { for col in [cols::MU_LO, cols::MU_HI] { let mu = t.get_main(r, col).to_raw(); - *expected.entry(half(mu)).or_default() += mu as i64; + *expected.entry(mu as u8).or_default() += mu as i64; } msb16 += t.get_main(r, cols::LHS_SIGNED).to_raw() as usize; msb16 += t.get_main(r, cols::RHS_SIGNED).to_raw() as usize; } } - is_half.retain(|_, n| *n != 0); + arebytes.retain(|_, n| *n != 0); expected.retain(|_, n| *n != 0); assert_eq!( - is_half, expected, - "IS_HALF[μ] tally must match the trace rows" + arebytes, expected, + "ARE_BYTES[μ] tally must match the trace rows" ); assert_eq!( collected From 3a5a3f46cc15825444b606a2b686c306026c9308 Mon Sep 17 00:00:00 2001 From: Nicole Date: Wed, 7 Oct 2026 09:23:02 -0300 Subject: [PATCH 07/29] Force is_negative to 0 on unsigned shift rows --- prover/src/tables/shift.rs | 16 ++++++++-- prover/src/tests/shift_tests.rs | 56 +++++++++++++++++++++++++++++++-- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/prover/src/tables/shift.rs b/prover/src/tables/shift.rs index 99cee2f8b..ea0fce6e8 100644 --- a/prover/src/tables/shift.rs +++ b/prover/src/tables/shift.rs @@ -726,14 +726,14 @@ pub fn bus_interactions() -> Vec { } /// Total number of SHIFT transition constraints. -pub const NUM_SHIFT_CONSTRAINTS: usize = 21; +pub const NUM_SHIFT_CONSTRAINTS: usize = 22; // ========================================================================= // Single-body constraint set (ConstraintSet front-end) // ========================================================================= // // One body against the generic `ConstraintBuilder` serves the compiled prover -// folder, the verifier folder and IR capture. Constraint indices 0..21. +// folder, the verifier folder and IR capture. Constraint indices 0..22. use stark::constraints::builder::{ConstraintBuilder, ConstraintSet}; @@ -910,6 +910,18 @@ impl ConstraintSet for ShiftConstraints { let mu = b.main(0, cols::MU); let one = b.one(); b.emit_base(20, mu.clone() * (one - mu)); + + // idx 21: UnsignedIsNotNegative — (1 - signed) * is_negative. On a signed + // row `is_negative` is pinned to the real MSB via the MSB16 bus (multiplicity + // `signed`); on an unsigned row nothing constrains it, yet `shifted_half` + // fills with `extension = 65535 * is_negative` without gating on `signed`. + // So an unsigned right shift (SRL) could set `is_negative = 1` and forge a + // sign-filled result (e.g. `srl 0, 16 = 0xFFFF_0000_0000_0000`). This pins + // it to 0 when `signed = 0`. + let signed = b.main(0, cols::SIGNED); + let is_negative = b.main(0, cols::IS_NEGATIVE); + let one = b.one(); + b.emit_base(21, (one - signed) * is_negative); } } diff --git a/prover/src/tests/shift_tests.rs b/prover/src/tests/shift_tests.rs index 1563c4030..88b5bc465 100644 --- a/prover/src/tests/shift_tests.rs +++ b/prover/src/tests/shift_tests.rs @@ -37,8 +37,8 @@ fn test_shift_air_wires_in_chip_constraints() { bus_interactions(), ); assert_eq!(in_chip, NUM_SHIFT_CONSTRAINTS); - // 19 + ZbsIsBit + MuIsBit. - assert_eq!(NUM_SHIFT_CONSTRAINTS, 21); + // 19 + ZbsIsBit + MuIsBit + UnsignedIsNotNegative. + assert_eq!(NUM_SHIFT_CONSTRAINTS, 22); } // ========================================================================= @@ -210,3 +210,55 @@ mod zbs_mu_bit_regression { ); } } + +// Separate preexisting bug (NOT a multiplicity bug, not fixed by this PR): +// Soundness regression: `is_negative` must be 0 on unsigned rows. It is pinned +// to the real MSB via the MSB16 bus only when `signed = 1` (multiplicity +// `signed`), but `shifted_half` fills with `extension = 65535·is_negative` +// without gating on `signed`, so an unsigned right shift (SRL) could set +// `is_negative = 1` and sign-fill the result. Closed by idx 21 +// `(1 − signed)·is_negative = 0`. +mod is_negative_unsigned_poc { + use crate::tables::shift::{ShiftConstraints, ShiftOperation, cols, generate_shift_trace}; + use crate::tables::types::{FE, GoldilocksExtension, GoldilocksField}; + use crate::test_utils::{busless_air, validate_busless}; + + type Trace = stark::trace::TraceTable; + + /// `srl 0, 16` (unsigned right shift, direction = right, signed = false). + /// Honest result is 0; forged `is_negative = 1` sign-fills the top limb. + fn srl_0_by_16() -> Trace { + // new(value, shift_amount, direction=right, signed, word_instr) + let op = ShiftOperation::new(0, 16, true, false, false); + generate_shift_trace(std::slice::from_ref(&op)) + } + + #[test] + fn honest_srl_is_zero_and_valid() { + let t = srl_0_by_16(); + let air = busless_air(cols::NUM_COLUMNS, ShiftConstraints); + assert!(validate_busless(&air, &t), "honest SRL must validate"); + assert_eq!(*t.main_table.get(0, cols::IS_NEGATIVE), FE::zero()); + assert_eq!(*t.main_table.get(0, cols::OUT_0), FE::zero()); + assert_eq!(*t.main_table.get(0, cols::OUT_1), FE::zero()); + } + + /// Regression: the same unsigned row with `is_negative = 1` and the + /// sign-filled output (`srl 0, 16 = 0xFFFF_0000_0000_0000`) is now rejected + /// by `(1 − signed)·is_negative = 0`. + #[test] + fn unsigned_srl_forged_sign_extension_is_rejected() { + let mut t = srl_0_by_16(); + assert_eq!(*t.main_table.get(0, cols::SIGNED), FE::zero()); + // Flip the aux bit and set the sign-filled output it implies + // (top 16-bit limb = extension = 0xFFFF): out = 0xFFFF_0000_0000_0000. + t.main_table.set(0, cols::IS_NEGATIVE, FE::one()); + t.main_table.set(0, cols::OUT_1, FE::from(0xFFFF_0000u64)); + + let air = busless_air(cols::NUM_COLUMNS, ShiftConstraints); + assert!( + !validate_busless(&air, &t), + "unsigned SRL with is_negative=1 must be rejected by UnsignedIsNotNegative" + ); + } +} From de098d6692307c485435b30e9e67eae2a178d351 Mon Sep 17 00:00:00 2001 From: Nicole Date: Wed, 7 Oct 2026 09:32:31 -0300 Subject: [PATCH 08/29] Document why the multiplicity bound rides the ARE_BYTES bus --- prover/src/tables/branch.rs | 7 +++++++ prover/src/tables/dvrm.rs | 7 +++++++ prover/src/tables/lt.rs | 7 +++++++ prover/src/tables/mul.rs | 7 +++++++ 4 files changed, 28 insertions(+) diff --git a/prover/src/tables/branch.rs b/prover/src/tables/branch.rs index 243f84925..2dccf8e4a 100644 --- a/prover/src/tables/branch.rs +++ b/prover/src/tables/branch.rs @@ -155,6 +155,13 @@ impl BranchOperation { /// bound keeps μ non-negative: without it a `μ = −1` twin of an honest row /// cancels it on the BRANCH bus while *receiving* its range lookups, absorbing /// another row's out-of-range limb (same bug as LT). +/// +/// The bound rides the ARE_BYTES bus, which the limb range checks +/// (IS_HALFWORD) never use. That is what closes it: an IS_HALFWORD-bus +/// bound could be cancelled by a non-canonical limb `(p−1, b+1)` (same +/// packed value, passes every constraint) sending `IS_HALFWORD[p−1]`; +/// on ARE_BYTES no limb can reach the `p−1` tuple, so a `μ = −1` row's +/// bound send has no receiver and the whole proof is rejected. pub const MU_MAX: u64 = (1 << 8) - 1; /// Deduplicates BRANCH operations into trace rows: `(pc, offset, register, diff --git a/prover/src/tables/dvrm.rs b/prover/src/tables/dvrm.rs index ee9c75451..21ab9da06 100644 --- a/prover/src/tables/dvrm.rs +++ b/prover/src/tables/dvrm.rs @@ -279,6 +279,13 @@ impl DvrmOperation { /// lookups of each kind. The bound is what makes `μ_q + μ_r = 0` imply /// `μ_q = μ_r = 0`: without it a `μ_q = 1, μ_r = −1` row receives a quotient /// lookup while sending none of its checks (forged `n = q·d + r`). +/// +/// The bound rides the ARE_BYTES bus, which the limb range checks +/// (IS_HALFWORD) never use. That is what closes it: an IS_HALFWORD-bus +/// bound could be cancelled by a non-canonical limb `(p−1, b+1)` (same +/// packed value, passes every constraint) sending `IS_HALFWORD[p−1]`; +/// on ARE_BYTES no limb can reach the `p−1` tuple, so a `μ = −1` row's +/// bound send has no receiver and the whole proof is rejected. pub const MU_MAX: u64 = (1 << 8) - 1; /// Deduplicates DVRM operations into trace rows: `(n, d, signed) -> (μ_q, μ_r)`, diff --git a/prover/src/tables/lt.rs b/prover/src/tables/lt.rs index b7f3d2710..fa5e0ac52 100644 --- a/prover/src/tables/lt.rs +++ b/prover/src/tables/lt.rs @@ -159,6 +159,13 @@ impl LtOperation { /// bound is what keeps μ non-negative: without it a `μ = −1` twin of an honest /// row cancels it on the ALU bus while *receiving* its range lookups, which /// absorbs another row's out-of-range limb (a forged `5 < 3 = 1` verified). +/// +/// The bound rides the ARE_BYTES bus, which the limb range checks +/// (IS_HALFWORD) never use. That is what closes it: an IS_HALFWORD-bus +/// bound could be cancelled by a non-canonical limb `(p−1, b+1)` (same +/// packed value, passes every constraint) sending `IS_HALFWORD[p−1]`; +/// on ARE_BYTES no limb can reach the `p−1` tuple, so a `μ = −1` row's +/// bound send has no receiver and the whole proof is rejected. pub const MU_MAX: u64 = (1 << 8) - 1; /// Deduplicates LT operations into trace rows: `(lhs, rhs, signed, invert) -> μ`, diff --git a/prover/src/tables/mul.rs b/prover/src/tables/mul.rs index 08863abb5..2869c9160 100644 --- a/prover/src/tables/mul.rs +++ b/prover/src/tables/mul.rs @@ -287,6 +287,13 @@ impl MulOperation { /// lookups of each kind. The bound is what makes `μ_lo + μ_hi = 0` imply /// `μ_lo = μ_hi = 0`: without it a `μ_lo = 1, μ_hi = −1` row receives a lookup /// while sending none of its range checks. +/// +/// The bound rides the ARE_BYTES bus, which the limb range checks +/// (IS_HALFWORD) never use. That is what closes it: an IS_HALFWORD-bus +/// bound could be cancelled by a non-canonical limb `(p−1, b+1)` (same +/// packed value, passes every constraint) sending `IS_HALFWORD[p−1]`; +/// on ARE_BYTES no limb can reach the `p−1` tuple, so a `μ = −1` row's +/// bound send has no receiver and the whole proof is rejected. pub const MU_MAX: u64 = (1 << 8) - 1; /// Deduplicates MUL operations into trace rows: `(lhs, lhs_signed, rhs, From af6f14de8f4474db673af355f6b19a297e2d65cd Mon Sep 17 00:00:00 2001 From: Nicole Date: Wed, 7 Oct 2026 10:00:05 -0300 Subject: [PATCH 09/29] Bit-constrain KECCAK and KECCAK_RND multiplicities --- prover/src/tables/keccak.rs | 9 ++ prover/src/tables/keccak_rnd.rs | 10 +++ prover/src/tests/keccak_rnd_tests.rs | 110 ++++++++++++++++++++++++ prover/src/tests/keccak_tests.rs | 38 ++++++++ prover/src/tests/mod.rs | 2 + prover/src/tests/trace_builder_tests.rs | 8 +- 6 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 prover/src/tests/keccak_tests.rs diff --git a/prover/src/tables/keccak.rs b/prover/src/tables/keccak.rs index 7b84cbd48..8c4c879b1 100644 --- a/prover/src/tables/keccak.rs +++ b/prover/src/tables/keccak.rs @@ -507,5 +507,14 @@ impl ConstraintSet for KeccakConstraints { let carry_1 = (addr_hi + carry_0 - ptr_hi) * inv_2_32; let mu = b.main(0, cols::MU); b.emit_base(50, mu * carry_1); + + // idx 51: MuIsBit — μ·(1−μ). Every other constraint and bus send is + // weighted by μ, so μ ≠ 0 is indistinguishable from μ = 1 at the + // constraint level, while on the bus a μ = −1 row inverts a range check + // (carrier). KECCAK does not deduplicate (one row per hash, μ ∈ {0,1}), + // so the bit constraint is honest and forbids μ = −1. + let mu = b.main(0, cols::MU); + let one = b.one(); + b.emit_base(51, mu.clone() * (one - mu)); } } diff --git a/prover/src/tables/keccak_rnd.rs b/prover/src/tables/keccak_rnd.rs index 51b7759f3..f6ebd7031 100644 --- a/prover/src/tables/keccak_rnd.rs +++ b/prover/src/tables/keccak_rnd.rs @@ -918,5 +918,15 @@ impl ConstraintSet for KeccakRndConstraint } } } + + // MuIsBit — μ·(1−μ). Every other constraint and bus send is weighted by + // μ, so μ = 2, 7 or −1 is indistinguishable from μ = 1 at the constraint + // level, while on the bus a μ = −1 row inverts a range check and can hold + // a non-canonical (out-of-range) shift byte — the carrier mechanism. + // KECCAK_RND does not deduplicate (one row per round, μ ∈ {0,1}), so the + // bit constraint is honest and forbids μ = −1, closing the carrier. + let mu = b.main(0, cols::MU); + let one = b.one(); + b.emit_base(idx, mu.clone() * (one - mu)); } } diff --git a/prover/src/tests/keccak_rnd_tests.rs b/prover/src/tests/keccak_rnd_tests.rs index 230ef6065..1b643d4e5 100644 --- a/prover/src/tests/keccak_rnd_tests.rs +++ b/prover/src/tests/keccak_rnd_tests.rs @@ -55,3 +55,113 @@ fn test_pi_virtual_matches_rotate() { } } } + +// Soundness gap (unfixed): KECCAK_RND's `μ` is not bit-constrained. Every +// Soundness regression: μ must be a bit. Every other constraint is `μ · identity` +// (or `μ · x·(1−x)`), so μ = 2, 7 or −1 used to be indistinguishable from μ = 1 at +// the constraint level, while on the bus μ weights (and with −1 inverts) every +// send — so a μ = −1 row could invert a range check and act as a carrier (the +// bytes are not pinned, see `are_bytes_carrier_poc`). The chip does not +// deduplicate, so `MuIsBit` (μ·(1−μ)=0) is the right fix (like SHIFT/LOAD): it +// forbids μ = −1 and closes the carrier. +mod mu_unconstrained_poc { + use crate::tables::keccak_rnd::{ + KeccakRndConstraints, KeccakRoundOperation, cols, generate_keccak_rnd_trace, + }; + use crate::tables::types::FE; + use crate::test_utils::{busless_air, validate_busless}; + use executor::vm::instruction::execution::keccak_f1600; + + #[test] + fn mu_is_bit_constrained() { + let input = [0x0102030405060708u64; 25]; + let mut output = input; + keccak_f1600(&mut output); + let honest = generate_keccak_rnd_trace(&[KeccakRoundOperation { + timestamp: 42, + input, + output, + }]); + let air = busless_air(cols::NUM_COLUMNS, KeccakRndConstraints); + + assert!( + validate_busless(&air, &honest), + "honest trace must validate" + ); + assert_eq!(*honest.main_table.get(0, cols::MU), FE::one()); + + // μ = 2, 7, −1 are now all rejected by MuIsBit. + for bad in [FE::from(2u64), FE::from(7u64), -FE::one()] { + let mut t = honest.clone(); + t.main_table.set(0, cols::MU, bad); + assert!( + !validate_busless(&air, &t), + "μ ∉ {{0,1}} must be rejected by MuIsBit" + ); + } + } +} + +// Why MuIsBit is necessary: the θ shift identity reads `cxz_left` as a HALFWORD +// (`byte_lo + 256·byte_hi`), while ARE_BYTES range-checks the two bytes. So the +// bytes are NOT pinned by the constraints — a non-canonical split keeps the +// halfword (and the identity) unchanged while sending an out-of-range byte to +// ARE_BYTES. A μ = −1 row would invert that send (carrier). With `MuIsBit` +// forbidding μ = −1 the carrier is closed, but this test shows the underlying +// gap — the constraints still do not range-constrain the bytes; a μ=−1 row could +// hold an out-of-range byte and absorb another table's out-of-range ARE_BYTES. +mod are_bytes_carrier_poc { + use crate::tables::keccak_rnd::{ + KeccakRndConstraints, KeccakRoundOperation, cols, generate_keccak_rnd_trace, + }; + use crate::tables::types::FE; + use crate::test_utils::{busless_air, validate_busless}; + use executor::vm::instruction::execution::keccak_f1600; + + #[test] + fn non_canonical_cxz_byte_passes_constraints() { + let input = [0x0102030405060708u64; 25]; + let mut output = input; + keccak_f1600(&mut output); + let honest = generate_keccak_rnd_trace(&[KeccakRoundOperation { + timestamp: 42, + input, + output, + }]); + let air = busless_air(cols::NUM_COLUMNS, KeccakRndConstraints); + assert!( + validate_busless(&air, &honest), + "honest trace must validate" + ); + + // Row 0 is a real round row (μ = 1). Take the first θ halfword of x = 0. + let lo_col = cols::cxz_left(0, 0); + let hi_col = cols::cxz_left(0, 1); + let lo = honest.main_table.get(0, lo_col).to_raw(); + let hi = honest.main_table.get(0, hi_col).to_raw(); + let value = lo + 256 * hi; // the halfword the identity reads + + // Non-canonical split: byte_lo = p−1 (= −1), byte_hi = (value + 1)/256. + // Then byte_lo + 256·byte_hi = value (mod p), so the identity is unchanged, + // but byte_lo is out of range for ARE_BYTES. + let inv_256 = FE::from(256u64).inv().expect("256 invertible"); + let new_hi = (FE::from(value) + FE::one()) * inv_256; + let mut forged = honest.clone(); + forged.main_table.set(0, lo_col, -FE::one()); + forged.main_table.set(0, hi_col, new_hi); + + // Sanity: the halfword is preserved. + let lo2 = *forged.main_table.get(0, lo_col); + let hi2 = *forged.main_table.get(0, hi_col); + assert_eq!(lo2 + FE::from(256u64) * hi2, FE::from(value)); + // And the forged low byte is out of the byte range [0, 256). + assert!((-FE::one()).to_raw() >= 256); + + // The AIR accepts the non-canonical bytes: the constraints read only the + // halfword, so they never range-constrain the bytes. + assert!( + validate_busless(&air, &forged), + "out-of-range cxz_left byte must still satisfy KECCAK_RND's constraints" + ); + } +} diff --git a/prover/src/tests/keccak_tests.rs b/prover/src/tests/keccak_tests.rs new file mode 100644 index 000000000..98e71fe70 --- /dev/null +++ b/prover/src/tests/keccak_tests.rs @@ -0,0 +1,38 @@ +//! Tests for the KECCAK (core) table. + +// Soundness regression: μ must be a bit. Every KECCAK constraint and bus send is +// weighted by μ, so μ = 2 or −1 was indistinguishable from μ = 1 at the +// constraint level while on the bus a μ = −1 row inverts a range check (carrier). +// KECCAK does not deduplicate (one row per hash, μ ∈ {0,1}), so `MuIsBit` +// (μ·(1−μ)=0) is the right fix and forbids μ = −1. + +use crate::tables::keccak::{KeccakConstraints, KeccakOperation, cols, generate_keccak_trace}; +use crate::tables::types::FE; +use crate::test_utils::{busless_air, validate_busless}; + +#[test] +fn mu_is_bit_constrained() { + let op = KeccakOperation { + timestamp: 7, + state_addr: 0x1000, + input: [0x0102030405060708u64; 25], + output: [0u64; 25], + }; + let honest = generate_keccak_trace(std::slice::from_ref(&op)); + let air = busless_air(cols::NUM_COLUMNS, KeccakConstraints); + + assert!( + validate_busless(&air, &honest), + "honest trace must validate" + ); + assert_eq!(*honest.main_table.get(0, cols::MU), FE::one()); + + for bad in [FE::from(2u64), -FE::one()] { + let mut t = honest.clone(); + t.main_table.set(0, cols::MU, bad); + assert!( + !validate_busless(&air, &t), + "μ ∉ {{0,1}} must be rejected by MuIsBit" + ); + } +} diff --git a/prover/src/tests/mod.rs b/prover/src/tests/mod.rs index 9df45c683..b3147f0e4 100644 --- a/prover/src/tests/mod.rs +++ b/prover/src/tests/mod.rs @@ -55,6 +55,8 @@ pub mod ir_stats_dump; #[cfg(test)] pub mod keccak_rnd_tests; #[cfg(test)] +pub mod keccak_tests; +#[cfg(test)] pub mod load_tests; #[cfg(test)] pub mod local_to_global_bus_tests; diff --git a/prover/src/tests/trace_builder_tests.rs b/prover/src/tests/trace_builder_tests.rs index 428fd4700..ca5426dce 100644 --- a/prover/src/tests/trace_builder_tests.rs +++ b/prover/src/tests/trace_builder_tests.rs @@ -763,13 +763,13 @@ mod keccak_tests { use stark::constraints::builder::ConstraintSet; assert_eq!( keccak::KeccakConstraints.meta().len(), - 51, - "KECCAK core: 25 ADD pairs + no-overflow" + 52, + "KECCAK core: 25 ADD pairs + no-overflow + MuIsBit" ); assert_eq!( keccak_rnd::KeccakRndConstraints.meta().len(), - 140, - "KECCAK_RND: 20 IS_BIT(μ; Cxz_right_bit) + 20 θ + 100 ρ inline shift identities" + 141, + "KECCAK_RND: 20 IS_BIT(μ; Cxz_right_bit) + 20 θ + 100 ρ inline shift identities + MuIsBit" ); } } From 01f8f5e37dfd69fc3a36b92a46c5149a77c3d275 Mon Sep 17 00:00:00 2001 From: Nicole Date: Wed, 7 Oct 2026 13:29:49 -0300 Subject: [PATCH 10/29] Fix stale test docs and rebalance forgery residue on ARE_BYTES --- prover/src/tables/lt.rs | 3 +-- prover/src/test_utils.rs | 8 +++++- prover/src/tests/dvrm_tests.rs | 6 ++--- prover/src/tests/keccak_rnd_tests.rs | 7 +++-- prover/src/tests/keccak_tests.rs | 3 +++ prover/src/tests/lt_tests.rs | 10 +++---- prover/src/tests/mul_tests.rs | 4 +-- prover/src/tests/multiplicity_forgery_poc.rs | 28 +++++++++++++------- 8 files changed, 44 insertions(+), 25 deletions(-) diff --git a/prover/src/tables/lt.rs b/prover/src/tables/lt.rs index fa5e0ac52..2c618fa9d 100644 --- a/prover/src/tables/lt.rs +++ b/prover/src/tables/lt.rs @@ -22,8 +22,7 @@ //! //! ## Bus Interactions //! - Sender: MSB16 (×2 for lhs_msb, rhs_msb) -//! - Sender: IS_HALFWORD (×6: ×4 for lhs_sub_rhs, ×1 for lhs[1], ×1 for rhs[1]; -//! ) +//! - Sender: IS_HALFWORD (×6: ×4 for lhs_sub_rhs, ×1 for lhs[1], ×1 for rhs[1]) //! - Sender: ARE_BYTES (×1 for the μ multiplicity bound) //! - Receiver: ALU (all less-than lookups — CPU SLT/BLT/BGE dispatch and the //! internal `memw`/`memw_aligned`/`dvrm` timestamp / |r|<|d| checks) diff --git a/prover/src/test_utils.rs b/prover/src/test_utils.rs index 5b4206356..b0d1d3977 100644 --- a/prover/src/test_utils.rs +++ b/prover/src/test_utils.rs @@ -237,7 +237,13 @@ pub fn asm_elf_bytes(name: &str) -> Vec { .join("asm") .join(format!("{}.elf", name)); - std::fs::read(&path).unwrap_or_else(|_| panic!("Failed to read ELF: {}", path.display())) + std::fs::read(&path).unwrap_or_else(|_| { + panic!( + "Failed to read ELF: {} — the .elf artifacts are gitignored; run \ + `make compile-programs-asm` first", + path.display() + ) + }) } /// Helper to run an ELF from the program_artifacts directory. diff --git a/prover/src/tests/dvrm_tests.rs b/prover/src/tests/dvrm_tests.rs index 588b77c55..ba4f95296 100644 --- a/prover/src/tests/dvrm_tests.rs +++ b/prover/src/tests/dvrm_tests.rs @@ -311,7 +311,7 @@ fn test_bus_interactions_count() { // - 2x MUL senders (n_sub_r = d*q lo + hi) // - 6x ZERO senders (C3×2 NEG r, C5×2 NEG d, C8 overflow, C17 div_by_zero) // - 2x DVRM receivers (quotient, remainder) - // - 2x IS_HALF senders bounding the multiplicities (μ_q, μ_r) + // - 2x ARE_BYTES senders bounding the multiplicities (μ_q, μ_r) // Total: 8 + 12 + 3 + 1 + 2 + 6 + 2 + 2 = 36 assert_eq!(interactions.len(), 36, "Expected 36 bus interactions"); } @@ -559,11 +559,11 @@ fn neg_template_zero_lookups_dedup_per_chip_instance() { // Soundness regression: μ_q/μ_r are bounded, non-negative multiplicities. // Every DVRM check fires with μ_q + μ_r, so a free μ_r = −1 next to μ_q = 1 -// switched them all off (`multiplicity_forgery_poc`). Each μ is now IS_HALF-checked +// switched them all off (`multiplicity_forgery_poc`). Each μ is now ARE_BYTES-checked // weighted by itself, and trace generation splits a row whose count would // exceed `MU_MAX`. -/// Presence: each multiplicity is IS_HALF-checked weighted by itself, so an +/// Presence: each multiplicity is ARE_BYTES-checked weighted by itself, so an /// out-of-range value `v` always lands on the bus with weight `v ≠ 0`. #[test] fn test_dvrm_bounds_its_multiplicities() { diff --git a/prover/src/tests/keccak_rnd_tests.rs b/prover/src/tests/keccak_rnd_tests.rs index 1b643d4e5..8b42f10eb 100644 --- a/prover/src/tests/keccak_rnd_tests.rs +++ b/prover/src/tests/keccak_rnd_tests.rs @@ -56,7 +56,6 @@ fn test_pi_virtual_matches_rotate() { } } -// Soundness gap (unfixed): KECCAK_RND's `μ` is not bit-constrained. Every // Soundness regression: μ must be a bit. Every other constraint is `μ · identity` // (or `μ · x·(1−x)`), so μ = 2, 7 or −1 used to be indistinguishable from μ = 1 at // the constraint level, while on the bus μ weights (and with −1 inverts) every @@ -64,7 +63,11 @@ fn test_pi_virtual_matches_rotate() { // bytes are not pinned, see `are_bytes_carrier_poc`). The chip does not // deduplicate, so `MuIsBit` (μ·(1−μ)=0) is the right fix (like SHIFT/LOAD): it // forbids μ = −1 and closes the carrier. -mod mu_unconstrained_poc { +// +// DEFENSIVE: this is a constraint-presence regression (μ ∉ {0,1} is rejected), not +// an end-to-end forgery; the carrier was confirmed structurally (bytes not pinned, +// see `are_bytes_carrier_poc`), not demonstrated. +mod mu_bit_regression { use crate::tables::keccak_rnd::{ KeccakRndConstraints, KeccakRoundOperation, cols, generate_keccak_rnd_trace, }; diff --git a/prover/src/tests/keccak_tests.rs b/prover/src/tests/keccak_tests.rs index 98e71fe70..59eccdfbd 100644 --- a/prover/src/tests/keccak_tests.rs +++ b/prover/src/tests/keccak_tests.rs @@ -5,6 +5,9 @@ // constraint level while on the bus a μ = −1 row inverts a range check (carrier). // KECCAK does not deduplicate (one row per hash, μ ∈ {0,1}), so `MuIsBit` // (μ·(1−μ)=0) is the right fix and forbids μ = −1. +// +// DEFENSIVE: this is a constraint-presence regression (μ ∉ {0,1} is rejected), not +// an end-to-end forgery. The carrier was confirmed structurally, not demonstrated. use crate::tables::keccak::{KeccakConstraints, KeccakOperation, cols, generate_keccak_trace}; use crate::tables::types::FE; diff --git a/prover/src/tests/lt_tests.rs b/prover/src/tests/lt_tests.rs index d11493544..b32faf6a5 100644 --- a/prover/src/tests/lt_tests.rs +++ b/prover/src/tests/lt_tests.rs @@ -169,7 +169,7 @@ fn test_bus_interactions_count() { // MSB16 x2 + IS_HALFWORD x6 (lhs_sub_rhs x4 + lhs[1] + rhs[1]) // + ALU receiver x1 (every LT lookup goes through the unified ALU bus // — CPU SLT/BLT/BGE dispatch and the internal memw/dvrm - // timestamp / |r|<|d| checks) + IS_HALFWORD x1 bounding μ = 10. + // timestamp / |r|<|d| checks) + ARE_BYTES x1 bounding μ = 10. assert_eq!(interactions.len(), 10); } @@ -236,11 +236,11 @@ fn test_lt_rejects_forged_out() { // Soundness regression: μ is a bounded, non-negative multiplicity. Every LT // lookup fires with μ, so a free μ = −1 twin of an honest row received range // lookups instead of sending them and absorbed another row's out-of-range limb -// (`lt_multiplicity_poc`, `multiplicity_forgery_poc`). μ is now IS_HALF-checked -// weighted by itself, and trace generation splits a row whose count would -// exceed `MU_MAX`. +// (`lt_multiplicity_poc`, `multiplicity_forgery_poc`). μ is now range-checked on +// ARE_BYTES (`ARE_BYTES[μ, 0]`, weighted by μ), and trace generation splits a row +// whose count would exceed `MU_MAX`. -/// Presence: μ is IS_HALF-checked weighted by itself, so an out-of-range value +/// Presence: μ is ARE_BYTES-checked weighted by itself, so an out-of-range value /// `v` always lands on the bus with weight `v ≠ 0`. #[test] fn test_lt_bounds_its_multiplicity() { diff --git a/prover/src/tests/mul_tests.rs b/prover/src/tests/mul_tests.rs index 1c72dfab8..1429ba424 100644 --- a/prover/src/tests/mul_tests.rs +++ b/prover/src/tests/mul_tests.rs @@ -270,7 +270,7 @@ fn test_bus_interactions_count() { // - 8x IS_HALF senders for outputs (lo[0..4], hi[0..4]) // - 4x IS_B20 senders (carry[0..4] virtual range checks) // - 2x ALU receivers (lo, hi) - // - 2x IS_HALF senders bounding the multiplicities (μ_lo, μ_hi) + // - 2x ARE_BYTES senders bounding the multiplicities (μ_lo, μ_hi) // Total: 2 + 8 + 8 + 4 + 2 + 2 = 26 assert_eq!(interactions.len(), 26, "Expected 26 bus interactions"); } @@ -424,7 +424,7 @@ fn msb16_bitwise_multiplicity_matches_per_instance_sends() { // IS_HALF-range-checked weighted by itself, and trace generation splits a // row whose count would exceed `MU_MAX`. -/// Presence: each multiplicity is IS_HALF-checked weighted by itself, so an +/// Presence: each multiplicity is ARE_BYTES-checked weighted by itself, so an /// out-of-range value `v` always lands on the bus with weight `v ≠ 0`. #[test] fn test_mul_bounds_its_multiplicities() { diff --git a/prover/src/tests/multiplicity_forgery_poc.rs b/prover/src/tests/multiplicity_forgery_poc.rs index afb8677f9..cc0b86f97 100644 --- a/prover/src/tests/multiplicity_forgery_poc.rs +++ b/prover/src/tests/multiplicity_forgery_poc.rs @@ -9,8 +9,10 @@ //! all while still receiving one `a` lookup: its result was free. The stray `−1` //! on the `b` tuple was cancelled by an honest copy of the row with //! `μ_a = 0, μ_b = +1`, whose checks fire once and pass. Both forgeries below -//! verified against `main` (ffc4ac19). The fix range-checks each μ to a halfword -//! (`IS_HALF[μ]`, weighted by μ), so `μ_b = −1` has no receiver. +//! verified against `main` (ffc4ac19). The fix range-checks each μ to a byte on +//! the ARE_BYTES bus (`ARE_BYTES[μ, 0]`, weighted by μ) — a bus the limb checks +//! (IS_HALFWORD) never use — so `μ_b = −1` lands at `ARE_BYTES[p−1, 0]` with no +//! receiver. //! //! **SHIFT.** The five HWSL senders fire with `1 − zbs`, and on a μ = 0 padding //! row nothing pinned `zbs`: `zbs = 2` made that −1, so the padding row provided @@ -234,11 +236,11 @@ fn craft_proof(s: &Scenario, forge: Forge) -> Result bool { interactions.iter().any(|i| { i.is_sender - && i.bus_id == BusId::IsHalfword as u64 + && i.bus_id == BusId::AreBytes as u64 && matches!(i.multiplicity, Multiplicity::Column(m) if m == col) && matches!( - i.values.as_slice(), - [BusValue::Packed { start_column, packing: Packing::Direct }] if *start_column == col + i.values.first(), + Some(BusValue::Packed { start_column, packing: Packing::Direct }) if *start_column == col ) }) } @@ -260,8 +262,8 @@ fn bump_bitwise(traces: &mut Traces, mu_col: usize, half: u64, delta: FE) { /// `μ_a = 0, μ_b = 1`. /// /// Returns the row-B copy's index so the caller can rebalance flag-gated sends. -/// If the chip range-checks its μ columns, also rebalances those IS_HALF sends -/// wherever a BITWISE row exists (`−1` has none — that is the fix). +/// If the chip range-checks its μ columns (ARE_BYTES[μ, 0]), also rebalances +/// those sends wherever a BITWISE row exists (`p−1` has none — that is the fix). fn forge_two_rows( traces: &mut Traces, table: fn(&mut Traces) -> &mut TraceTable, @@ -291,9 +293,10 @@ fn forge_two_rows( t.set_main(a, mu_b, FE::zero() - FE::one()); if bounded { - // IS_HALF[μ] | μ sends: B's μ_b 0 -> 1 adds IS_HALF[1] once. A's μ_b - // 0 -> −1 adds IS_HALF[p−1] with weight −1, which has no BITWISE row. - bump_bitwise(traces, bw_cols::MU_IS_HALF, 1, FE::one()); + // ARE_BYTES[μ, 0] | μ sends: B's μ_b 0 -> 1 adds ARE_BYTES[1, 0] once; A's + // μ_b 0 -> −1 adds ARE_BYTES[p−1, 0] with weight −1, which has no BITWISE + // row (that out-of-range send is what rejects the forgery). + bump_bitwise(traces, bw_cols::MU_ARE_BYTES, 1, FE::one()); } b } @@ -534,6 +537,7 @@ fn forge_lt_rows(traces: &mut Traces, s: &Scenario) { let (old, new) = (lt_net(&before), lt_net(&after)); let half_id: u64 = BusId::IsHalfword.into(); let msb_id: u64 = BusId::Msb16.into(); + let byte_id: u64 = BusId::AreBytes.into(); let keys: std::collections::HashSet<_> = old.keys().chain(new.keys()).cloned().collect(); for (bus, tuple) in keys { let delta = new @@ -553,6 +557,10 @@ fn forge_lt_rows(traces: &mut Traces, s: &Scenario) { } else if bus == msb_id { assert_eq!(tuple[1], v >> 15, "MSB16 sends stay honest"); bump_bitwise(traces, bw_cols::MU_MSB16, v, delta); + } else if bus == byte_id { + // LT's only ARE_BYTES send is the μ bound (`ARE_BYTES[μ, 0]`). + assert_eq!(tuple[1], 0, "the μ bound sends ARE_BYTES[μ, 0]"); + bump_bitwise(traces, bw_cols::MU_ARE_BYTES, v, delta); } } } From 2c1335db5293fca5f727d58a69a4b3835b04c591 Mon Sep 17 00:00:00 2001 From: Nicole Date: Wed, 7 Oct 2026 14:25:36 -0300 Subject: [PATCH 11/29] Bound BYTEWISE multiplicity with IS_BYTE and keep EQ multiplicity a bit --- prover/src/tables/bytewise.rs | 50 ++++++++++++++++++++++++++---- prover/src/tables/eq.rs | 27 ++++++++-------- prover/src/tables/trace_builder.rs | 11 +++++++ prover/src/tests/bytewise_tests.rs | 4 +-- prover/src/tests/eq_tests.rs | 13 ++++---- 5 files changed, 77 insertions(+), 28 deletions(-) diff --git a/prover/src/tables/bytewise.rs b/prover/src/tables/bytewise.rs index 2808365c6..f05886c2c 100644 --- a/prover/src/tables/bytewise.rs +++ b/prover/src/tables/bytewise.rs @@ -92,11 +92,16 @@ impl BytewiseOperation { /// Generates the BYTEWISE trace from a list of operations. /// -/// Duplicate operations are merged with summed multiplicities, then padded to -/// the next power of two (minimum 4). -pub fn generate_bytewise_trace( - operations: &[BytewiseOperation], -) -> TraceTable { +/// Largest per-row multiplicity: `μ` is range-checked to a byte on the ARE_BYTES +/// bus (`IS_BYTE[μ]`, weighted by μ), so a row holds at most this many lookups. +/// The bound keeps μ non-negative: without it a `μ = −1` row inverts its byte +/// range checks and can hold an out-of-range byte (carrier). See [`dedup_bytewise_rows`]. +pub const MU_MAX: u64 = (1 << 8) - 1; + +/// Deduplicates BYTEWISE operations into trace rows: `op -> μ`, splitting an op +/// over several rows when its count exceeds [`MU_MAX`]. Shared by trace +/// generation and the BITWISE collector so the per-row lookups cannot drift. +pub fn dedup_bytewise_rows(operations: &[BytewiseOperation]) -> Vec<(BytewiseOperation, u64)> { use std::collections::HashMap; let mut op_map: HashMap = HashMap::new(); @@ -104,7 +109,23 @@ pub fn generate_bytewise_trace( *op_map.entry(op.clone()).or_insert(0) += 1; } - let unique_ops: Vec<_> = op_map.into_iter().collect(); + let mut rows = Vec::with_capacity(op_map.len()); + for (op, mut left) in op_map { + while left > 0 { + let mu = left.min(MU_MAX); + left -= mu; + rows.push((op.clone(), mu)); + } + } + rows +} + +/// Duplicate operations are merged with summed multiplicities (split at +/// [`MU_MAX`]), then padded to the next power of two (minimum 4). +pub fn generate_bytewise_trace( + operations: &[BytewiseOperation], +) -> TraceTable { + let unique_ops = dedup_bytewise_rows(operations); let num_rows = unique_ops.len().next_power_of_two().max(4); let mut trace = TraceTable::new_main( crate::tables::types::zeroed_fe_vec(num_rows * cols::NUM_COLUMNS), @@ -132,6 +153,7 @@ pub fn generate_bytewise_trace( /// All bus interactions for the BYTEWISE table: /// - **Sends** `BYTE_ALU[op, a[i], b[i]] -> res[i]` for each of the 8 bytes. +/// - **Sends** `ARE_BYTES[μ, 0]` to bound the multiplicity. /// - **Receives** `ALU[a, b, op] -> res` (operands packed DWordBL -> 2 words). pub fn bus_interactions() -> Vec { let mut interactions = Vec::with_capacity(9); @@ -161,6 +183,22 @@ pub fn bus_interactions() -> Vec { )); } + // ARE_BYTES[μ, 0] | μ. Bounds μ to a byte and keeps it non-negative: the eight + // BYTE_ALU sends above are weighted by μ, so a free μ = −1 would invert them + // and let the row hold an out-of-range byte. The bound rides ARE_BYTES with + // value = weight = μ, so a μ = −1 row lands at `(p−1, 0)` with no receiver. + interactions.push(BusInteraction::sender( + BusId::AreBytes, + Multiplicity::Column(cols::MU), + vec![ + BusValue::Packed { + start_column: cols::MU, + packing: Packing::Direct, + }, + BusValue::constant(0), + ], + )); + // ALU[a, b, op] -> res (receiver). a/b/res are DWordBL (8 bytes) packed // into 2 words each, matching the CPU's DWordWL operands. interactions.push(BusInteraction::receiver( diff --git a/prover/src/tables/eq.rs b/prover/src/tables/eq.rs index f967becf4..b3287f6d8 100644 --- a/prover/src/tables/eq.rs +++ b/prover/src/tables/eq.rs @@ -113,20 +113,14 @@ impl EqOperation { /// Generates the EQ trace from a list of operations. /// -/// Duplicate operations are merged into a single row with summed multiplicities, -/// then padded to the next power of two (minimum 4). +/// One row per operation (no deduplication), each with `μ = 1`, then padded to +/// the next power of two (minimum 4). Keeping `μ` a bit lets `MuIsBit` +/// (`μ·(1−μ) = 0`) pin it: a free `μ = −1` would invert every bus send (a +/// carrier), and a `μ = 2` would over-answer the ALU lookup. pub fn generate_eq_trace( operations: &[EqOperation], ) -> TraceTable { - use std::collections::HashMap; - - let mut op_map: HashMap = HashMap::new(); - for op in operations { - *op_map.entry(op.clone()).or_insert(0) += 1; - } - - let unique_ops: Vec<_> = op_map.into_iter().collect(); - let num_rows = unique_ops.len().next_power_of_two().max(4); + let num_rows = operations.len().next_power_of_two().max(4); let mut trace = TraceTable::new_main( crate::tables::types::zeroed_fe_vec(num_rows * cols::NUM_COLUMNS), cols::NUM_COLUMNS, @@ -134,7 +128,7 @@ pub fn generate_eq_trace( ); let table = &mut trace.main_table; - for (row_idx, (op, multiplicity)) in unique_ops.iter().enumerate() { + for (row_idx, op) in operations.iter().enumerate() { // a, b as DWordWL (2 words each) table.set_dword_wl(row_idx, cols::A_0, op.a); table.set_dword_wl(row_idx, cols::B_0, op.b); @@ -147,7 +141,7 @@ pub fn generate_eq_trace( table.set_dword_hl(row_idx, cols::DIFF_0, diff); table.set_bool(row_idx, cols::EQ, op.compute_eq()); - table.set_u64(row_idx, cols::MU, *multiplicity); + table.set_u64(row_idx, cols::MU, 1); } trace @@ -249,7 +243,8 @@ pub fn bus_interactions() -> Vec { /// The EQ table's transition constraints as a single [`ConstraintSet`]: /// - idx 0,1: `ADD` pair `b + diff = a` (unconditional); /// - idx 2: `IS_BIT(invert)` (unconditional); -/// - idx 3: `res = eq XOR invert`. +/// - idx 3: `res = eq XOR invert`; +/// - idx 4: `IS_BIT(μ)` — μ is a bit (no dedup), so it cannot invert the bus. #[derive(Clone, Copy)] pub struct EqConstraints; @@ -272,5 +267,9 @@ impl ConstraintSet for EqConstraints { let invert = b.main(0, cols::INVERT); let two = b.const_base(2); b.emit_base(3, res - (eq.clone() + invert.clone() - two * eq * invert)); + // IS_BIT(μ): the table does not deduplicate, so μ ∈ {0, 1}. Without this a + // μ = −1 row would invert every bus send (carrier) and μ = 2 would + // over-answer the ALU lookup. + emit_is_bit(b, 4, cols::MU, None); } } diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 5ecbf57ef..2ce3891eb 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -3335,6 +3335,7 @@ fn build_traces( let branch_chunk = max_rows.branch; let mul_chunk = max_rows.mul; let dvrm_chunk = max_rows.dvrm; + let bytewise_chunk = max_rows.bytewise; // Every source except the two dominant ones (the in-walk lookups and MEMW_R, which are // split into row-ranges in the parallel path below) stays a single whole-source collector. let mut collectors: Vec = vec![ @@ -3347,6 +3348,16 @@ fn build_traces( for op in &bytewise_ops { h.add_ops(&op.collect_bitwise_ops()); } + // IS_BYTE[μ] | μ: each dedup row sends its own μ, μ times (padding + // sends nothing), over exactly the rows `generate_bytewise_trace` + // builds for each chunk. Mirrors the LT/MUL/DVRM/BRANCH bound. + for chunk in bytewise_ops.chunks(bytewise_chunk) { + for (_, mu) in bytewise::dedup_bytewise_rows(chunk) { + let op = + BitwiseOperation::single_byte(BitwiseOperationType::AreBytes, mu as u8); + h.add_ops(&std::iter::repeat_n(op, mu as usize).collect::>()); + } + } }), Box::new(|h| { for op in &eq_ops { diff --git a/prover/src/tests/bytewise_tests.rs b/prover/src/tests/bytewise_tests.rs index ac534cdc2..619679ba2 100644 --- a/prover/src/tests/bytewise_tests.rs +++ b/prover/src/tests/bytewise_tests.rs @@ -70,8 +70,8 @@ fn test_multiplicity_aggregation() { #[test] fn test_bus_interactions_shape() { let interactions = bus_interactions(); - // 8 BYTE_ALU senders + 1 ALU receiver. - assert_eq!(interactions.len(), 9); + // 8 BYTE_ALU senders + 1 ARE_BYTES[μ] sender + 1 ALU receiver. + assert_eq!(interactions.len(), 10); let byte_alu_senders = interactions .iter() diff --git a/prover/src/tests/eq_tests.rs b/prover/src/tests/eq_tests.rs index df0c76fdb..d1495dfb7 100644 --- a/prover/src/tests/eq_tests.rs +++ b/prover/src/tests/eq_tests.rs @@ -73,8 +73,9 @@ fn test_trace_dword_split() { } #[test] -fn test_multiplicity_aggregation() { - // Same op three times + one distinct → 2 unique rows, padded to 4. +fn test_no_dedup_one_row_per_op() { + // No deduplication: each op gets its own row with μ = 1 (μ is kept a bit so + // MuIsBit can pin it). Three identical ops + one distinct → 4 rows. let ops = vec![ EqOperation::new(5, 5, false), EqOperation::new(9, 8, false), @@ -84,15 +85,15 @@ fn test_multiplicity_aggregation() { let trace = generate_eq_trace(&ops); assert_eq!(trace.main_table.height, 4); - let mut found = false; + let mut fives = 0; for row_idx in 0..4 { let row = trace.main_table.get_row(row_idx); + assert_eq!(row[cols::MU], FE::from(1u64), "every real row has μ = 1"); if row[cols::A_0] == FE::from(5u64) && row[cols::B_0] == FE::from(5u64) { - assert_eq!(row[cols::MU], FE::from(3u64)); - found = true; + fives += 1; } } - assert!(found, "expected the (5,5) row with multiplicity 3"); + assert_eq!(fives, 3, "the three (5,5) ops each get their own row"); } #[test] From a4b80837cd05a78e215de5b34ba87752ee9518aa Mon Sep 17 00:00:00 2001 From: Nicole Date: Thu, 8 Oct 2026 15:30:44 -0300 Subject: [PATCH 12/29] Guard the BYTEWISE multiplicity collector against a zero row cap --- prover/src/tables/trace_builder.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 2ce3891eb..cfe055ef2 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -3351,7 +3351,9 @@ fn build_traces( // IS_BYTE[μ] | μ: each dedup row sends its own μ, μ times (padding // sends nothing), over exactly the rows `generate_bytewise_trace` // builds for each chunk. Mirrors the LT/MUL/DVRM/BRANCH bound. - for chunk in bytewise_ops.chunks(bytewise_chunk) { + // `.max(1)`: `chunks(0)` panics, and `bytewise_chunk` is an + // externally supplied row cap that could be 0. + for chunk in bytewise_ops.chunks(bytewise_chunk.max(1)) { for (_, mu) in bytewise::dedup_bytewise_rows(chunk) { let op = BitwiseOperation::single_byte(BitwiseOperationType::AreBytes, mu as u8); From cb19d79fa53d2be6493ddd43848385d03275a194 Mon Sep 17 00:00:00 2001 From: Nicole Date: Thu, 8 Oct 2026 15:31:36 -0300 Subject: [PATCH 13/29] Treat a prover refusal as inconclusive instead of a rejection in the forgery harness --- prover/src/tests/multiplicity_forgery_poc.rs | 25 ++++++++++---------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/prover/src/tests/multiplicity_forgery_poc.rs b/prover/src/tests/multiplicity_forgery_poc.rs index cc0b86f97..b8bb71689 100644 --- a/prover/src/tests/multiplicity_forgery_poc.rs +++ b/prover/src/tests/multiplicity_forgery_poc.rs @@ -588,19 +588,20 @@ fn assert_honest_verifies(s: &Scenario) { assert!(verifier_accepts(s, &proof), "honest proof must verify"); } -/// Did a crafted proof get accepted? A prover refusal also counts as rejected. +/// Did a crafted proof get accepted by the verifier? The prover must succeed: a +/// prover refusal is not a verifier rejection (a malicious prover skips the +/// honest prover's checks), so an `Err` here means the test cannot conclude the +/// forgery is blocked and must fail loudly rather than pass silently. fn crafted_accepted(s: &Scenario, forge: Forge) -> bool { - match craft_proof(s, forge) { - Ok(proof) => { - assert_eq!( - u64_output(&proof), - s.forged, - "public output must be the forged value" - ); - verifier_accepts(s, &proof) - } - Err(_) => false, - } + let proof = craft_proof(s, forge).expect( + "prover must build the crafted proof; a prover refusal is not a verifier rejection", + ); + assert_eq!( + u64_output(&proof), + s.forged, + "public output must be the forged value" + ); + verifier_accepts(s, &proof) } #[test] From 8e62f38a5ce66d71c1651fa842ad2d01ea6219aa Mon Sep 17 00:00:00 2001 From: Nicole Date: Tue, 13 Oct 2026 15:06:02 -0300 Subject: [PATCH 14/29] Range check multiplicities with ARE_BYTES sends as in spec --- prover/src/tables/branch.rs | 60 ++- prover/src/tables/bytewise.rs | 25 +- prover/src/tables/dvrm.rs | 54 ++- prover/src/tables/keccak_rnd.rs | 22 +- prover/src/tables/lt.rs | 40 +- prover/src/tables/mul.rs | 54 ++- prover/src/tables/trace_builder.rs | 129 ++++-- prover/src/tests/branch_bus_tests.rs | 51 ++- prover/src/tests/branch_carrier_poc.rs | 435 +++++++++++++++++++ prover/src/tests/branch_multiplicity_poc.rs | 20 +- prover/src/tests/dvrm_tests.rs | 62 +-- prover/src/tests/lt_multiplicity_poc.rs | 17 +- prover/src/tests/lt_tests.rs | 23 +- prover/src/tests/mod.rs | 2 + prover/src/tests/mul_mu_carrier_poc.rs | 40 +- prover/src/tests/mul_tests.rs | 62 +-- prover/src/tests/multiplicity_forgery_poc.rs | 89 ++-- prover/src/tests/skip_empty_tables_tests.rs | 14 +- 18 files changed, 880 insertions(+), 319 deletions(-) create mode 100644 prover/src/tests/branch_carrier_poc.rs diff --git a/prover/src/tables/branch.rs b/prover/src/tables/branch.rs index 2dccf8e4a..0913ef9f4 100644 --- a/prover/src/tables/branch.rs +++ b/prover/src/tables/branch.rs @@ -150,18 +150,18 @@ impl BranchOperation { } } -/// Largest per-row multiplicity: `μ` is range-checked to a byte -/// (`IS_BYTE[μ]`, weighted by μ), so a row holds at most this many lookups. The -/// bound keeps μ non-negative: without it a `μ = −1` twin of an honest row -/// cancels it on the BRANCH bus while *receiving* its range lookups, absorbing -/// another row's out-of-range limb (same bug as LT). +/// Largest per-row multiplicity: `μ` is range-checked to a byte by `IS_BYTE[μ]`, +/// batched with `IS_BYTE[next_pc_low_1]` into one `ARE_BYTES[μ, next_pc_low_1]` +/// with multiplicity 1 (the spec's default `cond`), so a row holds at most this +/// many lookups. The bound keeps μ non-negative: without it a `μ = −1` twin of +/// an honest row cancels it on the BRANCH bus while *receiving* its range +/// lookups, absorbing another row's out-of-range limb (same bug as LT). /// -/// The bound rides the ARE_BYTES bus, which the limb range checks -/// (IS_HALFWORD) never use. That is what closes it: an IS_HALFWORD-bus -/// bound could be cancelled by a non-canonical limb `(p−1, b+1)` (same -/// packed value, passes every constraint) sending `IS_HALFWORD[p−1]`; -/// on ARE_BYTES no limb can reach the `p−1` tuple, so a `μ = −1` row's -/// bound send has no receiver and the whole proof is rejected. +/// `next_pc_low_1` is only constrained packed (`low0 + 2^8·low1 + 2^16·high0`), +/// so its byte check must not be weighted by μ either: with weight μ a row with +/// μ < 0 and `low1 = p−1` cancels its own (or another table's) bound on +/// `(p−1, 0)` (`branch_carrier_poc::lt_forgery_with_branch_cycle_is_rejected`). +/// See `lt::MU_MAX`. pub const MU_MAX: u64 = (1 << 8) - 1; /// Deduplicates BRANCH operations into trace rows: `(pc, offset, register, @@ -249,24 +249,34 @@ pub fn generate_branch_trace( /// Creates all bus interactions for the BRANCH table. /// /// The BRANCH table: -/// - **Sends** ARE_BYTES lookup for next_pc_low[1] range check (Y=0) +/// - **Sends** ARE_BYTES[μ, next_pc_low[1]]: the μ bound and the next_pc_low[1] +/// range check, batched (both have multiplicity 1) /// - **Sends** BYTE_ALU[AND] lookup for LSB masking /// (next_pc_low[0] = unmasked_low_byte & 254) /// - **Sends** IS_HALFWORD lookups for next_pc_high[0..3] range checks and for -/// (next_pc_high limbs); ARE_BYTES for the μ multiplicity bound +/// (next_pc_high limbs) /// - **Receives** BRANCH lookups from CPU table pub fn bus_interactions() -> Vec { vec![ - // ARE_BYTES[next_pc_low[1], 0] - range check bits 8-15 + // IS_BYTE[μ], IS_BYTE[next_pc_low[1]] = ARE_BYTES[μ, next_pc_low[1]] | 1. + // Every lookup here fires with μ, so μ itself must be bounded and + // non-negative. Otherwise a μ = −1 twin of an honest row cancels it on + // the BRANCH bus while *receiving* its range lookups and absorbs an + // out-of-range IS_HALFWORD sent by any row (same as LT). Both checks + // have the spec's default `cond` (1), so they batch into one + // interaction; padding rows send ARE_BYTES[0, 0]. See `MU_MAX`. BusInteraction::sender( BusId::AreBytes, - Multiplicity::Column(cols::MU), + Multiplicity::One, vec![ + BusValue::Packed { + start_column: cols::MU, + packing: Packing::Direct, + }, BusValue::Packed { start_column: cols::NEXT_PC_LOW_1, packing: Packing::Direct, }, - BusValue::constant(0), ], ), // BYTE_ALU[next_pc_low[0]; AND, unmasked_low_byte, 254] @@ -314,24 +324,6 @@ pub fn bus_interactions() -> Vec { packing: Packing::Direct, }], ), - // ARE_BYTES[μ, 0] | μ. Every lookup here fires with μ, so μ itself must be - // bounded and non-negative. Otherwise a μ = −1 twin of an honest row - // cancels it on the BRANCH bus while *receiving* its range lookups and - // absorbs an out-of-range IS_HALFWORD sent by any row (same as LT). Sent - // with itself as multiplicity: `k` rows holding an out-of-range `v` put - // weight `k·v ≠ 0` on a byte tuple ARE_BYTES has no row for, while padding - // (μ = 0) contributes nothing. See `MU_MAX`. - BusInteraction::sender( - BusId::AreBytes, - Multiplicity::Column(cols::MU), - vec![ - BusValue::Packed { - start_column: cols::MU, - packing: Packing::Direct, - }, - BusValue::constant(0), - ], - ), // BRANCH[next_pc; pc, offset, register, JALR] (receiver) // Signature: [next_pc (DWordWL), pc (DWordWL), offset (DWordWL), register (DWordWL), JALR (Bit)] BusInteraction::receiver( diff --git a/prover/src/tables/bytewise.rs b/prover/src/tables/bytewise.rs index f05886c2c..8d86e489d 100644 --- a/prover/src/tables/bytewise.rs +++ b/prover/src/tables/bytewise.rs @@ -90,12 +90,12 @@ impl BytewiseOperation { } } -/// Generates the BYTEWISE trace from a list of operations. -/// -/// Largest per-row multiplicity: `μ` is range-checked to a byte on the ARE_BYTES -/// bus (`IS_BYTE[μ]`, weighted by μ), so a row holds at most this many lookups. -/// The bound keeps μ non-negative: without it a `μ = −1` row inverts its byte -/// range checks and can hold an out-of-range byte (carrier). See [`dedup_bytewise_rows`]. +/// Largest per-row multiplicity: `μ` is range-checked to a byte by `IS_BYTE[μ]` +/// (`ARE_BYTES[μ, 0]` with multiplicity 1, the spec's default `cond`), so a row +/// holds at most this many lookups. The bound keeps μ non-negative: without it a +/// `μ = −1` row inverts its byte range checks and can hold an out-of-range byte +/// (carrier). See [`dedup_bytewise_rows`] and `lt::MU_MAX` for why the bound is +/// sent with multiplicity 1 rather than μ. pub const MU_MAX: u64 = (1 << 8) - 1; /// Deduplicates BYTEWISE operations into trace rows: `op -> μ`, splitting an op @@ -120,6 +120,8 @@ pub fn dedup_bytewise_rows(operations: &[BytewiseOperation]) -> Vec<(BytewiseOpe rows } +/// Generates the BYTEWISE trace from a list of operations. +/// /// Duplicate operations are merged with summed multiplicities (split at /// [`MU_MAX`]), then padded to the next power of two (minimum 4). pub fn generate_bytewise_trace( @@ -183,13 +185,14 @@ pub fn bus_interactions() -> Vec { )); } - // ARE_BYTES[μ, 0] | μ. Bounds μ to a byte and keeps it non-negative: the eight - // BYTE_ALU sends above are weighted by μ, so a free μ = −1 would invert them - // and let the row hold an out-of-range byte. The bound rides ARE_BYTES with - // value = weight = μ, so a μ = −1 row lands at `(p−1, 0)` with no receiver. + // IS_BYTE[μ] = ARE_BYTES[μ, 0] | 1. Bounds μ to a byte and keeps it + // non-negative: the eight BYTE_ALU sends above are weighted by μ, so a free + // μ = −1 would invert them and let the row hold an out-of-range byte. + // Multiplicity 1 (the spec's default `cond`), so padding rows send + // ARE_BYTES[0, 0] too. See `MU_MAX`. interactions.push(BusInteraction::sender( BusId::AreBytes, - Multiplicity::Column(cols::MU), + Multiplicity::One, vec![ BusValue::Packed { start_column: cols::MU, diff --git a/prover/src/tables/dvrm.rs b/prover/src/tables/dvrm.rs index 21ab9da06..b99ef461a 100644 --- a/prover/src/tables/dvrm.rs +++ b/prover/src/tables/dvrm.rs @@ -275,17 +275,14 @@ impl DvrmOperation { // ========================================================================= /// Largest per-row multiplicity: `μ_q` and `μ_r` are each range-checked to a -/// byte (`IS_BYTE[μ]`, weighted by μ), so a row holds at most this many -/// lookups of each kind. The bound is what makes `μ_q + μ_r = 0` imply -/// `μ_q = μ_r = 0`: without it a `μ_q = 1, μ_r = −1` row receives a quotient +/// byte by `IS_BYTE[μ_q]` and `IS_BYTE[μ_r]`, batched into one +/// `ARE_BYTES[μ_q, μ_r]` with multiplicity 1 (the spec's default `cond`), so a +/// row holds at most this many lookups of each kind. The bound is what makes +/// `μ_q + μ_r = 0` imply `μ_q = μ_r = 0`: without it a `μ_q = 1, μ_r = −1` +/// row receives a quotient /// lookup while sending none of its checks (forged `n = q·d + r`). /// -/// The bound rides the ARE_BYTES bus, which the limb range checks -/// (IS_HALFWORD) never use. That is what closes it: an IS_HALFWORD-bus -/// bound could be cancelled by a non-canonical limb `(p−1, b+1)` (same -/// packed value, passes every constraint) sending `IS_HALFWORD[p−1]`; -/// on ARE_BYTES no limb can reach the `p−1` tuple, so a `μ = −1` row's -/// bound send has no receiver and the whole proof is rejected. +/// See `lt::MU_MAX` for why the bound is sent with multiplicity 1 rather than μ. pub const MU_MAX: u64 = (1 << 8) - 1; /// Deduplicates DVRM operations into trace rows: `(n, d, signed) -> (μ_q, μ_r)`, @@ -392,7 +389,7 @@ pub fn generate_dvrm_trace( /// /// The DVRM table: /// - **Sends** IS_HALF lookups for n, d, q, r, n_sub_r range checks (×20) -/// - **Sends** ARE_BYTES[μ_q, 0] and ARE_BYTES[μ_r, 0] to bound the multiplicities +/// - **Sends** ARE_BYTES[μ_q, μ_r] to bound the multiplicities /// - **Sends** MSB16 lookups for sign extraction (×3: n, d, r) /// - **Sends** LT lookup for |r| < |d| (×1) /// - **Sends** MUL lookups for n_sub_r = d * q verification (×2: lo and hi) @@ -402,29 +399,28 @@ pub fn bus_interactions() -> Vec { let mut interactions = Vec::new(); // ------------------------------------------------------------------------- - // ARE_BYTES[μ_q, 0] | μ_q, ARE_BYTES[μ_r, 0] | μ_r. + // IS_BYTE[μ_q], IS_BYTE[μ_r] = ARE_BYTES[μ_q, μ_r] | 1. // Every check below fires with μ_q + μ_r, so the multiplicities themselves // must be bounded and non-negative. Otherwise μ_q = 1, μ_r = −1 receives a // quotient lookup with all checks off (forged quotient), - // and an honest μ_r = 1 copy of the row cancels the stray −1. Each μ is sent - // with itself as multiplicity: `k` rows holding an out-of-range value `v` - // put weight `k·v ≠ 0` on a byte tuple ARE_BYTES has no row for, while padding - // (μ = 0) contributes nothing, so an empty instance still matches an absent - // one. See `MU_MAX`. + // and an honest μ_r = 1 copy of the row cancels the stray −1. + // Both checks share the spec's default `cond` (1), so they batch into one + // interaction; padding rows send ARE_BYTES[0, 0]. See `MU_MAX`. // ------------------------------------------------------------------------- - for col in [cols::MU_Q, cols::MU_R] { - interactions.push(BusInteraction::sender( - BusId::AreBytes, - Multiplicity::Column(col), - vec![ - BusValue::Packed { - start_column: col, - packing: Packing::Direct, - }, - BusValue::constant(0), - ], - )); - } + interactions.push(BusInteraction::sender( + BusId::AreBytes, + Multiplicity::One, + vec![ + BusValue::Packed { + start_column: cols::MU_Q, + packing: Packing::Direct, + }, + BusValue::Packed { + start_column: cols::MU_R, + packing: Packing::Direct, + }, + ], + )); // ------------------------------------------------------------------------- // DVRM-A1.i: IS_HALF[n[i]] (×4) and DVRM-A2.i: IS_HALF[d[i]] (×4), diff --git a/prover/src/tables/keccak_rnd.rs b/prover/src/tables/keccak_rnd.rs index f6ebd7031..7e7b8b243 100644 --- a/prover/src/tables/keccak_rnd.rs +++ b/prover/src/tables/keccak_rnd.rs @@ -238,6 +238,22 @@ fn hwsl(halfword: u16, shift: u8) -> (u16, u16) { } } +/// `ARE_BYTES` sends per KECCAK_RND row: 20 `Cxz_left` pairs and 200 +/// `(rot_left, rot_right)` pairs. The spec gives these byte checks no `cond` +/// (default 1, spec #1017 256e3970b), so they fire on padding rows too, where +/// every cell is 0: each padding row sends `ARE_BYTES[0, 0]` this many times. +pub const ARE_BYTES_PER_ROW: usize = 20 + 200; + +/// `ARE_BYTES[0, 0]` lookups sent by the padding rows of the KECCAK_RND table +/// built for `num_ops` keccak calls (none when the table is absent). +pub fn padding_are_bytes(num_ops: usize) -> u64 { + if num_ops == 0 { + return 0; + } + let rows = num_ops * 24; + ((rows.next_power_of_two().max(4) - rows) * ARE_BYTES_PER_ROW) as u64 +} + #[allow(clippy::needless_range_loop)] /// Generate the KECCAK_RND trace table. /// @@ -604,11 +620,12 @@ pub fn bus_interactions() -> Vec { // byte pairs (z=2i, z=2i+1) into ARE_BYTES interactions per the // implementation guidance in spec/is_byte.typ. // Cxz_right uses IS_BIT polynomial constraints (see `KeccakRndConstraints`). + // Multiplicity 1 (the spec's default `cond`, see `ARE_BYTES_PER_ROW`). for x in 0..5 { for i in 0..4 { interactions.push(BusInteraction::sender( BusId::AreBytes, - Multiplicity::Column(cols::MU), + Multiplicity::One, vec![ BusValue::Packed { start_column: cols::cxz_left(x, 2 * i), @@ -692,12 +709,13 @@ pub fn bus_interactions() -> Vec { // --- Rho: ARE_BYTES range checks on rot_left + rot_right (200 pairs) --- // Spec emits 400 IS_BYTE templates (200 per side); we merge each // (rot_left[x][y][b], rot_right[x][y][b]) into one ARE_BYTES interaction. + // Multiplicity 1 (the spec's default `cond`, see `ARE_BYTES_PER_ROW`). for x in 0..5 { for y in 0..5 { for b in 0..8 { interactions.push(BusInteraction::sender( BusId::AreBytes, - Multiplicity::Column(cols::MU), + Multiplicity::One, vec![ BusValue::Packed { start_column: cols::rot_left(x, y, b), diff --git a/prover/src/tables/lt.rs b/prover/src/tables/lt.rs index 2c618fa9d..a31c5ca59 100644 --- a/prover/src/tables/lt.rs +++ b/prover/src/tables/lt.rs @@ -153,18 +153,19 @@ impl LtOperation { } } -/// Largest per-row multiplicity: `μ` is range-checked to a byte -/// (`IS_BYTE[μ]`, weighted by μ), so a row holds at most this many lookups. The -/// bound is what keeps μ non-negative: without it a `μ = −1` twin of an honest -/// row cancels it on the ALU bus while *receiving* its range lookups, which -/// absorbs another row's out-of-range limb (a forged `5 < 3 = 1` verified). +/// Largest per-row multiplicity: `μ` is range-checked to a byte by `IS_BYTE[μ]` +/// (`ARE_BYTES[μ, 0]` with multiplicity 1, the spec's default `cond`), so a row +/// holds at most this many lookups. The bound is what keeps μ non-negative: +/// without it a `μ = −1` twin of an honest row cancels it on the ALU bus while +/// *receiving* its range lookups, which absorbs another row's out-of-range limb +/// (a forged `5 < 3 = 1` verified). /// -/// The bound rides the ARE_BYTES bus, which the limb range checks -/// (IS_HALFWORD) never use. That is what closes it: an IS_HALFWORD-bus -/// bound could be cancelled by a non-canonical limb `(p−1, b+1)` (same -/// packed value, passes every constraint) sending `IS_HALFWORD[p−1]`; -/// on ARE_BYTES no limb can reach the `p−1` tuple, so a `μ = −1` row's -/// bound send has no receiver and the whole proof is rejected. +/// The bound is sent with multiplicity 1, not μ. A `μ < 0` row then leaves `+1` +/// on an out-of-range tuple, which nothing can cancel as long as no ARE_BYTES +/// sender carries a negative weight (each one is `1` or a bit-constrained μ). +/// Weighted by μ, the bound was itself that negative weight, and a real BRANCH +/// row with a non-canonical `next_pc_low_1 = p−1` cancelled it +/// (`branch_carrier_poc`). pub const MU_MAX: u64 = (1 << 8) - 1; /// Deduplicates LT operations into trace rows: `(lhs, rhs, signed, invert) -> μ`, @@ -337,17 +338,16 @@ pub fn bus_interactions() -> Vec { packing: Packing::Direct, }], ), - // ARE_BYTES[μ, 0] | μ. Every lookup here fires with μ, so μ itself must be - // bounded and non-negative. Otherwise a μ = −1 twin of an honest row - // cancels it on the ALU bus while *receiving* its range lookups, with - // `sub_0` free up to 2^32: it absorbs an out-of-range IS_HALFWORD sent - // by any row, another LT row included (forged `5 < 3 = 1`). Sent with - // itself as multiplicity: `k` rows holding an out-of-range `v` put - // weight `k·v ≠ 0` on a byte tuple ARE_BYTES has no row for, while padding - // (μ = 0) contributes nothing. See `MU_MAX`. + // IS_BYTE[μ] = ARE_BYTES[μ, 0] | 1. Every lookup here fires with μ, so μ + // itself must be bounded and non-negative. Otherwise a μ = −1 twin of an + // honest row cancels it on the ALU bus while *receiving* its range + // lookups, with `sub_0` free up to 2^32: it absorbs an out-of-range + // IS_HALFWORD sent by any row, another LT row included (forged + // `5 < 3 = 1`). Multiplicity 1 (the spec's default `cond`), so padding + // rows send ARE_BYTES[0, 0] too. See `MU_MAX`. BusInteraction::sender( BusId::AreBytes, - Multiplicity::Column(cols::MU), + Multiplicity::One, vec![ BusValue::Packed { start_column: cols::MU, diff --git a/prover/src/tables/mul.rs b/prover/src/tables/mul.rs index 2869c9160..295b3327d 100644 --- a/prover/src/tables/mul.rs +++ b/prover/src/tables/mul.rs @@ -283,17 +283,14 @@ impl MulOperation { // ========================================================================= /// Largest per-row multiplicity: `μ_lo` and `μ_hi` are each range-checked to a -/// byte (`IS_BYTE[μ]`, weighted by μ), so a row holds at most this many -/// lookups of each kind. The bound is what makes `μ_lo + μ_hi = 0` imply -/// `μ_lo = μ_hi = 0`: without it a `μ_lo = 1, μ_hi = −1` row receives a lookup +/// byte by `IS_BYTE[μ_lo]` and `IS_BYTE[μ_hi]`, batched into one +/// `ARE_BYTES[μ_lo, μ_hi]` with multiplicity 1 (the spec's default `cond`), so a +/// row holds at most this many lookups of each kind. The bound is what makes +/// `μ_lo + μ_hi = 0` imply `μ_lo = μ_hi = 0`: without it a `μ_lo = 1, μ_hi = −1` +/// row receives a lookup /// while sending none of its range checks. /// -/// The bound rides the ARE_BYTES bus, which the limb range checks -/// (IS_HALFWORD) never use. That is what closes it: an IS_HALFWORD-bus -/// bound could be cancelled by a non-canonical limb `(p−1, b+1)` (same -/// packed value, passes every constraint) sending `IS_HALFWORD[p−1]`; -/// on ARE_BYTES no limb can reach the `p−1` tuple, so a `μ = −1` row's -/// bound send has no receiver and the whole proof is rejected. +/// See `lt::MU_MAX` for why the bound is sent with multiplicity 1 rather than μ. pub const MU_MAX: u64 = (1 << 8) - 1; /// Deduplicates MUL operations into trace rows: `(lhs, lhs_signed, rhs, @@ -393,7 +390,7 @@ pub fn generate_mul_trace( /// The MUL table: /// - **Sends** MSB16 lookups for sign bit extraction (×2) /// - **Sends** IS_HALF lookups for lhs/rhs input and lo/hi output range checks (×16) -/// - **Sends** ARE_BYTES[μ_lo, 0] and ARE_BYTES[μ_hi, 0] to bound the multiplicities +/// - **Sends** ARE_BYTES[μ_lo, μ_hi] to bound the multiplicities /// - **Sends** IS_B20 lookups for carry range checks (×4) /// - **Receives** MUL lookups from CPU table (×2: lo and hi) pub fn bus_interactions() -> Vec { @@ -435,29 +432,28 @@ pub fn bus_interactions() -> Vec { )); // ------------------------------------------------------------------------- - // ARE_BYTES[μ_lo, 0] | μ_lo, ARE_BYTES[μ_hi, 0] | μ_hi. + // IS_BYTE[μ_lo], IS_BYTE[μ_hi] = ARE_BYTES[μ_lo, μ_hi] | 1. // Every check below fires with μ_lo + μ_hi, so the multiplicities themselves // must be bounded and non-negative. Otherwise μ_lo = 1, μ_hi = −1 receives a // `lo` lookup with all range checks off (forged product), - // and an honest μ_hi = 1 copy of the row cancels the stray −1. Each μ is sent - // with itself as multiplicity: `k` rows holding an out-of-range value `v` - // put weight `k·v ≠ 0` on a byte tuple ARE_BYTES has no row for, while padding - // (μ = 0) contributes nothing, so an empty instance still matches an absent - // one. See `MU_MAX`. + // and an honest μ_hi = 1 copy of the row cancels the stray −1. + // Both checks share the spec's default `cond` (1), so they batch into one + // interaction; padding rows send ARE_BYTES[0, 0]. See `MU_MAX`. // ------------------------------------------------------------------------- - for col in [cols::MU_LO, cols::MU_HI] { - interactions.push(BusInteraction::sender( - BusId::AreBytes, - Multiplicity::Column(col), - vec![ - BusValue::Packed { - start_column: col, - packing: Packing::Direct, - }, - BusValue::constant(0), - ], - )); - } + interactions.push(BusInteraction::sender( + BusId::AreBytes, + Multiplicity::One, + vec![ + BusValue::Packed { + start_column: cols::MU_LO, + packing: Packing::Direct, + }, + BusValue::Packed { + start_column: cols::MU_HI, + packing: Packing::Direct, + }, + ], + )); // ------------------------------------------------------------------------- // IS_HALF lookups for lhs/rhs INPUT range checks (multiplicity: mu_lo + mu_hi). diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index cfe055ef2..7d57f08b4 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -1680,8 +1680,9 @@ fn reg_ts_delta_in_range(timestamp: u64, old_ts: u64) -> bool { /// Collects bitwise lookups from LT operations (MSB16 and IS_HALFWORD). /// /// MSB16 and the operand/difference IS_HALFWORD lookups are emitted once per raw -/// op. The IS_HALF[μ] multiplicity bound is counted over the rows of each -/// `max_rows_lt` chunk, mirroring `chunk_and_generate_optional`. +/// op. The IS_BYTE[μ] multiplicity bound (`ARE_BYTES[μ, 0] | 1`) is counted once +/// per row of each `max_rows_lt` chunk, padding rows included, mirroring +/// `chunk_and_generate_optional`. /// /// Returns: Vec of bitwise lookups pub(crate) fn collect_bitwise_from_lt( @@ -1732,13 +1733,16 @@ pub(crate) fn collect_bitwise_from_lt( )); } - // IS_BYTE[μ] | μ: each row sends its own μ, μ times (padding sends nothing), - // over exactly the rows `generate_lt_trace` builds for each chunk. + // IS_BYTE[μ] | 1: every row sends its own μ once, padding rows (μ = 0) + // included, over exactly the rows `generate_lt_trace` builds for each chunk. + let mu_byte = |v: u64| BitwiseOperation::single_byte(BitwiseOperationType::AreBytes, v as u8); for chunk in lt_ops.chunks(max_rows_lt) { - for (_, mu) in lt::dedup_lt_rows(chunk) { - let op = BitwiseOperation::single_byte(BitwiseOperationType::AreBytes, mu as u8); - bitwise_ops.extend(std::iter::repeat_n(op, mu as usize)); - } + let rows = lt::dedup_lt_rows(chunk); + bitwise_ops.extend(rows.iter().map(|(_, mu)| mu_byte(*mu))); + bitwise_ops.extend(std::iter::repeat_n( + mu_byte(0), + dedup_padding_rows(rows.len()), + )); } bitwise_ops @@ -1812,16 +1816,22 @@ pub(crate) fn collect_bitwise_from_mul( } // Per-row lookups, over exactly the rows `generate_mul_trace` builds for each - // `max_rows_mul` chunk (`chunk_and_generate_optional`): IS_BYTE[μ_lo] and - // IS_BYTE[μ_hi] are sent μ_lo / μ_hi times (so padding rows send nothing), and - // MSB16 fires once per row whose sign flag is set — a unique signed op spanning two - // instances, or split over two rows by `mul::MU_MAX`, is sent once per row. - let mu_byte = |v: u64| BitwiseOperation::single_byte(BitwiseOperationType::AreBytes, v as u8); + // `max_rows_mul` chunk (`chunk_and_generate_optional`): ARE_BYTES[μ_lo, μ_hi] + // (the batched IS_BYTE bounds) once per row, padding rows included as + // ARE_BYTES[0, 0], and MSB16 once per row whose sign flag is set — a unique + // signed op spanning two instances, or split over two rows by `mul::MU_MAX`, is + // sent once per row. + let mu_bytes = |a: u64, b: u64| { + BitwiseOperation::byte_op(BitwiseOperationType::AreBytes, a as u8, b as u8) + }; for chunk in mul_ops.chunks(max_rows_mul) { - for (op, mu) in &mul::dedup_mul_rows(chunk) { - for m in [mu.mu_lo, mu.mu_hi] { - bitwise_ops.extend(std::iter::repeat_n(mu_byte(m), m as usize)); - } + let rows = mul::dedup_mul_rows(chunk); + bitwise_ops.extend(std::iter::repeat_n( + mu_bytes(0, 0), + dedup_padding_rows(rows.len()), + )); + for (op, mu) in &rows { + bitwise_ops.push(mu_bytes(mu.mu_lo, mu.mu_hi)); if op.lhs_signed { let lhs_3 = ((op.lhs >> 48) & 0xFFFF) as u16; bitwise_ops.push(BitwiseOperation::halfword( @@ -1944,17 +1954,23 @@ pub(crate) fn collect_bitwise_from_dvrm( } // Per-row lookups, over exactly the rows `generate_dvrm_trace` builds for each - // `max_rows_dvrm` chunk (`chunk_and_generate_optional`): IS_BYTE[μ_q] and - // IS_BYTE[μ_r] sent μ_q / μ_r times (padding rows send nothing); MSB16 (Column(SIGNED)) + // `max_rows_dvrm` chunk (`chunk_and_generate_optional`): ARE_BYTES[μ_q, μ_r] (the + // batched IS_BYTE bounds) once per row, padding rows included as + // ARE_BYTES[0, 0]; MSB16 (Column(SIGNED)) // and the NEG-template ZERO lookups (Column(SIGN_R)/Column(SIGN_D)) once per // row whose bit is set. An op split over two rows by `dvrm::MU_MAX` sends // them once per row. - let mu_byte = |v: u64| BitwiseOperation::single_byte(BitwiseOperationType::AreBytes, v as u8); + let mu_bytes = |a: u64, b: u64| { + BitwiseOperation::byte_op(BitwiseOperationType::AreBytes, a as u8, b as u8) + }; for chunk in dvrm_ops.chunks(max_rows_dvrm) { - for (op, mu) in &dvrm::dedup_dvrm_rows(chunk) { - for m in [mu.mu_q, mu.mu_r] { - bitwise_ops.extend(std::iter::repeat_n(mu_byte(m), m as usize)); - } + let rows = dvrm::dedup_dvrm_rows(chunk); + bitwise_ops.extend(std::iter::repeat_n( + mu_bytes(0, 0), + dedup_padding_rows(rows.len()), + )); + for (op, mu) in &rows { + bitwise_ops.push(mu_bytes(mu.mu_q, mu.mu_r)); if op.signed { let r = op.compute_remainder(); @@ -2041,19 +2057,13 @@ pub(crate) fn collect_bitwise_from_branch( let next_pc_unmasked = op.compute_next_pc_unmasked(); // Extract next_pc components - let _next_pc_low_0 = (next_pc & 0xFF) as u8; // Used by constraint, not lookup - let next_pc_low_1 = ((next_pc >> 8) & 0xFF) as u8; + // next_pc_low[0] is fixed by the BYTE_ALU AND below; next_pc_low[1] is + // range-checked per row with the μ bound (ARE_BYTES[μ, next_pc_low[1]]). let next_pc_high_0 = ((next_pc >> 16) & 0xFFFF) as u16; let next_pc_high_1 = ((next_pc >> 32) & 0xFFFF) as u16; let next_pc_high_2 = ((next_pc >> 48) & 0xFFFF) as u16; let unmasked_low_byte = (next_pc_unmasked & 0xFF) as u8; - // ARE_BYTES[next_pc_low[1], 0] - range check for byte value - bitwise_ops.push(BitwiseOperation::single_byte( - BitwiseOperationType::AreBytes, - next_pc_low_1, - )); - // BYTE_ALU[AND, unmasked_low_byte, 254] → next_pc_low[0] // Verifies: next_pc_low[0] = unmasked_low_byte & 0xFE bitwise_ops.push(BitwiseOperation::byte_op( @@ -2084,18 +2094,34 @@ pub(crate) fn collect_bitwise_from_branch( )); } - // IS_BYTE[μ] | μ: each row sends its own μ, μ times (padding sends nothing), - // over exactly the rows `generate_branch_trace` builds for each chunk. + // ARE_BYTES[μ, next_pc_low[1]] | 1 (the batched IS_BYTE bounds): every row + // sends it once, padding rows included as ARE_BYTES[0, 0], over exactly the + // rows `generate_branch_trace` builds for each chunk. + let row_bytes = |mu: u64, low_1: u8| { + BitwiseOperation::byte_op(BitwiseOperationType::AreBytes, mu as u8, low_1) + }; for chunk in branch_ops.chunks(max_rows_branch) { - for (_, mu) in branch::dedup_branch_rows(chunk) { - let op = BitwiseOperation::single_byte(BitwiseOperationType::AreBytes, mu as u8); - bitwise_ops.extend(std::iter::repeat_n(op, mu as usize)); - } + let rows = branch::dedup_branch_rows(chunk); + bitwise_ops.extend( + rows.iter() + .map(|(op, mu)| row_bytes(*mu, ((op.compute_next_pc() >> 8) & 0xFF) as u8)), + ); + bitwise_ops.extend(std::iter::repeat_n( + row_bytes(0, 0), + dedup_padding_rows(rows.len()), + )); } bitwise_ops } +/// Padding rows of a deduplicated chip instance (LT, MUL, DVRM, BRANCH, BYTEWISE) +/// with `rows` real rows: their `generate_*_trace` pads to `next_power_of_two().max(4)`. +/// Their μ bound is sent with multiplicity 1, so each padding row sends it too. +fn dedup_padding_rows(rows: usize) -> usize { + rows.next_power_of_two().max(4) - rows +} + /// Generates ARE_BYTES ops for CPU padding rows. /// /// CPU padding rows have all byte columns = 0 (RS1=0, RS2=0, RD=0, etc.). @@ -3348,17 +3374,18 @@ fn build_traces( for op in &bytewise_ops { h.add_ops(&op.collect_bitwise_ops()); } - // IS_BYTE[μ] | μ: each dedup row sends its own μ, μ times (padding - // sends nothing), over exactly the rows `generate_bytewise_trace` - // builds for each chunk. Mirrors the LT/MUL/DVRM/BRANCH bound. + // IS_BYTE[μ] | 1: every row sends its own μ once, padding rows (μ = 0) + // included, over exactly the rows `generate_bytewise_trace` builds for + // each chunk. Mirrors the LT/MUL/DVRM/BRANCH bound. // `.max(1)`: `chunks(0)` panics, and `bytewise_chunk` is an // externally supplied row cap that could be 0. + let mu_byte = + |v: u64| BitwiseOperation::single_byte(BitwiseOperationType::AreBytes, v as u8); for chunk in bytewise_ops.chunks(bytewise_chunk.max(1)) { - for (_, mu) in bytewise::dedup_bytewise_rows(chunk) { - let op = - BitwiseOperation::single_byte(BitwiseOperationType::AreBytes, mu as u8); - h.add_ops(&std::iter::repeat_n(op, mu as usize).collect::>()); - } + let rows = bytewise::dedup_bytewise_rows(chunk); + let ops: Vec<_> = rows.iter().map(|(_, mu)| mu_byte(*mu)).collect(); + h.add_ops(&ops); + h.bump_n(mu_byte(0), dedup_padding_rows(rows.len()) as u64); } }), Box::new(|h| { @@ -3373,7 +3400,15 @@ fn build_traces( }), Box::new(|h| h.add_ops(&collect_bitwise_from_memw_aligned(&memw_aligned_ops))), Box::new(|h| h.add_ops(&collect_bitwise_from_commit(&commit_ops))), - Box::new(|h| h.add_ops(&collect_bitwise_from_keccak(&keccak_ops))), + Box::new(|h| { + h.add_ops(&collect_bitwise_from_keccak(&keccak_ops)); + // KECCAK_RND's byte checks have multiplicity 1, so its padding rows + // (all cells 0) send ARE_BYTES[0, 0] too. + h.bump_n( + BitwiseOperation::byte_op(BitwiseOperationType::AreBytes, 0, 0), + keccak_rnd::padding_are_bytes(keccak_ops.len()), + ); + }), Box::new(|h| h.add_ops(&collect_bitwise_from_ecsm(&ecsm_ops))), Box::new(|h| h.add_ops(&collect_bitwise_from_ecdas(&ecdas_ops))), Box::new(|h| h.add_ops(&collect_bitwise_from_hint(&hint_ops))), diff --git a/prover/src/tests/branch_bus_tests.rs b/prover/src/tests/branch_bus_tests.rs index e9d83741d..5129d9ffd 100644 --- a/prover/src/tests/branch_bus_tests.rs +++ b/prover/src/tests/branch_bus_tests.rs @@ -694,8 +694,9 @@ fn test_soundness_wrong_offset() { // Soundness regression: μ is a bounded, non-negative multiplicity. Every BRANCH // lookup fires with μ, so a free μ = −1 twin of an honest row received range // lookups instead of sending them and absorbed another row's out-of-range limb -// (`branch_multiplicity_poc`). μ is now IS_HALF-checked weighted by itself, and -// trace generation splits a row whose count would exceed `MU_MAX`. +// (`branch_multiplicity_poc`). μ is now byte-checked together with +// next_pc_low[1] by one ARE_BYTES[μ, next_pc_low[1]] with multiplicity 1 (spec +// #1017), and trace generation splits a row whose count would exceed `MU_MAX`. mod mu_bound { use crate::tables::branch::BranchOperation; use crate::tables::branch::{ @@ -703,17 +704,26 @@ mod mu_bound { }; use crate::tables::types::BusId; - /// Presence: μ is IS_HALF-checked weighted by itself. + /// Presence: μ and next_pc_low[1] are byte-checked by one ARE_BYTES with + /// multiplicity 1. Weighted by μ, a μ < 0 row's next_pc_low[1] check could + /// cancel a μ bound (`branch_carrier_poc::lt_forgery_with_branch_cycle_is_rejected`). #[test] fn branch_bounds_its_multiplicity() { use stark::lookup::{BusValue, Multiplicity, Packing}; + let packed = |v: &BusValue| match v { + BusValue::Packed { + start_column, + packing: Packing::Direct, + } => Some(*start_column), + _ => None, + }; assert!( bus_interactions().iter().any(|i| i.is_sender && i.bus_id == BusId::AreBytes as u64 - && matches!(i.multiplicity, Multiplicity::Column(m) if m == cols::MU) - && matches!(i.values.first(), - Some(BusValue::Packed { start_column, packing: Packing::Direct }) if *start_column == cols::MU)), - "BRANCH must IS_BYTE-check μ weighted by itself" + && matches!(i.multiplicity, Multiplicity::One) + && i.values.iter().map(packed).collect::>() + == [Some(cols::MU), Some(cols::NEXT_PC_LOW_1)]), + "BRANCH must IS_BYTE-check μ and next_pc_low[1] in one ARE_BYTES with multiplicity 1" ); } @@ -747,9 +757,10 @@ mod mu_bound { assert_eq!(total, ops.len() as u64); } - /// Consistency: the BITWISE collector tallies exactly the IS_HALF[μ] lookups - /// the generated BRANCH instances send (each row's μ, weighted by μ; padding - /// nothing), across small instances and for a row split by `MU_MAX`. + /// Consistency: the BITWISE collector tallies exactly the ARE_BYTES[μ, + /// next_pc_low[1]] lookups the generated BRANCH instances send (once per row, + /// padding rows included), across small instances and for a row split by + /// `MU_MAX`. #[test] fn branch_mu_bound_lookups_match_collector() { use crate::tables::bitwise::BitwiseOperationType; @@ -793,26 +804,22 @@ mod mu_bound { "IS_HALF must match exactly the next_pc_high range checks" ); - // ARE_BYTES carries both the real next_pc_low[1] byte check and the - // μ bound; subtract the former to isolate the latter. - let mut arebytes: HashMap = HashMap::new(); + // ARE_BYTES[μ, next_pc_low[1]] once per trace row, padding included. + let mut arebytes: HashMap<(u8, u8), i64> = HashMap::new(); for b in collected .iter() .filter(|b| b.lookup_type == BitwiseOperationType::AreBytes) { - assert_eq!(b.y, 0, "BRANCH ARE_BYTES sends are single bytes"); - *arebytes.entry(b.x).or_default() += 1; + *arebytes.entry((b.x, b.y)).or_default() += 1; } - for op in &ops { - let next_pc = op.compute_next_pc(); - *arebytes.entry(((next_pc >> 8) & 0xFF) as u8).or_default() -= 1; - } - let mut expected: HashMap = HashMap::new(); + let mut expected: HashMap<(u8, u8), i64> = HashMap::new(); for c in ops.chunks(chunk) { let t = generate_branch_trace(c); for r in 0..t.num_rows() { - let mu = t.get_main(r, cols::MU).to_raw(); - *expected.entry(mu as u8).or_default() += mu as i64; + let byte = |col| t.get_main(r, col).to_raw() as u8; + *expected + .entry((byte(cols::MU), byte(cols::NEXT_PC_LOW_1))) + .or_default() += 1; } } arebytes.retain(|_, n| *n != 0); diff --git a/prover/src/tests/branch_carrier_poc.rs b/prover/src/tests/branch_carrier_poc.rs new file mode 100644 index 000000000..dea6240c8 --- /dev/null +++ b/prover/src/tests/branch_carrier_poc.rs @@ -0,0 +1,435 @@ +//! The ARE_BYTES[μ, 0] bound is cancellable by a real BRANCH row. +//! +//! BRANCH sends `ARE_BYTES[next_pc_low[1], 0]`, but `next_pc_low[1]` only enters +//! its constraints and the BRANCH bus packed as `low0 + 2^8·low1 + 2^16·high0`. +//! An honest `(low1, high0) = (255, h)` can be stored as `(p−1, h+1)`: same packed +//! next_pc (−256 + 65536 = 255·256), every BranchConstraint holds, `IS_HALF[h+1]` +//! is in range, and the row sends `ARE_BYTES[p−1, 0]` with +1. That cancels the +//! μ bound of any `μ = −1` twin, which brings back the `5 < 3 = 1` forgery. +//! +//! Harness: real LT and BRANCH AIRs; mock CPU senders (ALU, BRANCH); mock +//! BITWISE receivers that only hold in-domain tuples (IS_HALFWORD < 2^16, MSB16, +//! ARE_BYTES bytes, BYTE_ALU AND), with multiplicities set to the net demand. + +use std::collections::HashMap; + +use crypto::fiat_shamir::default_transcript::DefaultTranscript; +use math::field::element::FieldElement; +use stark::constraints::builder::EmptyConstraints; +use stark::lookup::{ + AirWithBuses, AuxiliaryTraceBuildData, BusInteraction, BusValue, LinearTerm, Multiplicity, + NullBoundaryConstraintBuilder, Packing, +}; +use stark::proof::options::ProofOptions; +use stark::trace::TraceTable; +use stark::traits::AIR; +use stark::verifier::{IsStarkVerifier, Verifier}; + +use crate::tables::branch::{self, BranchOperation, generate_branch_trace}; +use crate::tables::lt::{self, LtOperation}; +use crate::tables::types::{BusId, FE, GoldilocksExtension, GoldilocksField, alu_op}; +use crate::test_utils::{create_branch_air, create_lt_air, multi_prove_ram}; +use crate::tests::lt_multiplicity_poc::{Row, honest_row, twin_row}; + +type F = GoldilocksField; +type E = GoldilocksExtension; +type MockAir = AirWithBuses; +type Net = HashMap<(u64, Vec), FE>; + +const HALF: u64 = 1 << 16; + +fn lookup_air(opts: &ProofOptions, bus: BusId, n: usize, sender: bool) -> MockAir { + let values = (0..n) + .map(|c| BusValue::Packed { + start_column: c, + packing: Packing::Direct, + }) + .collect(); + let i = if sender { + BusInteraction::sender(bus, Multiplicity::Column(n), values) + } else { + BusInteraction::receiver(bus, Multiplicity::Column(n), values) + }; + AirWithBuses::new( + n + 1, + AuxiliaryTraceBuildData { + interactions: vec![i], + }, + opts, + 1, + EmptyConstraints, + ) +} + +fn lookup_trace(rows: &[(Vec, FE)], n: usize) -> TraceTable { + let len = rows.len().next_power_of_two().max(4); + let mut data = vec![FE::zero(); len * (n + 1)]; + for (r, (tuple, m)) in rows.iter().enumerate() { + for (c, v) in tuple.iter().enumerate() { + data[r * (n + 1) + c] = FE::from(*v); + } + data[r * (n + 1) + n] = *m; + } + TraceTable::new_main(data, n + 1, 1) +} + +fn eval_mult(m: &Multiplicity, g: &dyn Fn(usize) -> FE) -> FE { + match m { + Multiplicity::One => FE::one(), + Multiplicity::Column(c) => g(*c), + Multiplicity::Sum(a, b) => g(*a) + g(*b), + Multiplicity::Negated(c) => FE::one() - g(*c), + Multiplicity::Diff(a, b) => g(*a) - g(*b), + Multiplicity::Sum3(a, b, c) => g(*a) + g(*b) + g(*c), + Multiplicity::Linear(ts) => ts.iter().fold(FE::zero(), |acc, t| match *t { + LinearTerm::Column { + coefficient, + column, + } => acc + g(column) * FE::from(coefficient), + LinearTerm::ColumnUnsigned { + coefficient, + column, + } => acc + g(column) * FE::from(coefficient), + LinearTerm::Constant(v) => acc + FE::from(v), + }), + } +} + +/// Adds the net sends per `(bus, tuple)` of `interactions` over `rows` to `net`. +fn add_net(net: &mut Net, interactions: &[BusInteraction], rows: &[Row]) { + for bi in interactions { + for row in rows { + let g = |c: usize| row[c]; + let m = eval_mult(&bi.multiplicity, &g); + if m == FE::zero() { + continue; + } + let tuple = bi + .values + .iter() + .flat_map(|v| v.combine_from(g)) + .map(|x: FE| x.canonical_u64()) + .collect(); + let e = net.entry((bi.bus_id, tuple)).or_insert(FE::zero()); + *e = if bi.is_sender { *e + m } else { *e - m }; + } + } +} + +/// The single tuple `rows` put on `bus` (asserted unique). +fn tuple_on(interactions: &[BusInteraction], row: &Row, bus: BusId) -> Vec { + let mut net = Net::new(); + add_net(&mut net, interactions, std::slice::from_ref(row)); + let id: u64 = bus.into(); + let hits: Vec<_> = net.keys().filter(|(b, _)| *b == id).collect(); + assert_eq!(hits.len(), 1); + hits[0].1.clone() +} + +fn alu_tuple(op: &LtOperation, out: u64) -> Vec { + let flags = alu_op::LT as u64 + 32 * op.signed as u64 + 64 * op.invert as u64; + vec![ + op.lhs & 0xFFFF_FFFF, + op.lhs >> 32, + op.rhs & 0xFFFF_FFFF, + op.rhs >> 32, + flags, + out, + 0, + ] +} + +fn honest_branch_row(op: &BranchOperation) -> Row { + let t = generate_branch_trace(std::slice::from_ref(op)); + let row = t.main_table.get_row(0).to_vec(); + assert_eq!(row[branch::cols::MU], FE::one()); + row +} + +/// `pc + 0xFF00` with `next_pc_low[1] = 255` re-encoded as `(p−1, high0 + 1)`. +fn branch_carrier() -> (Row, Row) { + let op = BranchOperation::new(0, 0xFF00, 0, false); + let honest = honest_branch_row(&op); + let mut c = honest.clone(); + assert_eq!(c[branch::cols::NEXT_PC_LOW_1], FE::from(255u64)); + assert_eq!(c[branch::cols::NEXT_PC_HIGH_0], FE::zero()); + c[branch::cols::NEXT_PC_LOW_1] = -FE::one(); + c[branch::cols::NEXT_PC_HIGH_0] = FE::one(); + (honest, c) +} + +fn in_domain(bus: u64, t: &[u64]) -> bool { + let is = |b: BusId| bus == u64::from(b); + if is(BusId::IsHalfword) { + t.len() == 1 && t[0] < HALF + } else if is(BusId::Msb16) { + t.len() == 2 && t[0] < HALF && t[1] == t[0] >> 15 + } else if is(BusId::AreBytes) { + t.len() == 2 && t[0] < 256 && t[1] < 256 + } else if is(BusId::ByteAlu) { + t.len() == 4 + && t[0] == alu_op::AND as u64 + && t[1] < 256 + && t[2] < 256 + && t[3] == t[1] & t[2] + } else { + false + } +} + +/// `rows` padded with all-zero rows to the instance height, as the trace is. +/// Constant-multiplicity sends (the μ bounds) fire on padding rows too. +fn padded(rows: &[Row], ncols: usize) -> Vec { + let mut v = rows.to_vec(); + v.resize( + rows.len().next_power_of_two().max(4), + vec![FE::zero(); ncols], + ); + v +} + +/// Proves CPU + LT + BRANCH + BITWISE mocks; returns whether it verifies. +fn prove_and_verify( + lt_rows: &[Row], + branch_rows: &[Row], + cpu_alu: &[Vec], + cpu_branch: &[Vec], +) -> bool { + let opts = ProofOptions::default_test_options(); + let lt_rows = &padded(lt_rows, lt::cols::NUM_COLUMNS); + let branch_rows = &padded(branch_rows, branch::cols::NUM_COLUMNS); + let mut net = Net::new(); + add_net(&mut net, <::bus_interactions(), lt_rows); + add_net(&mut net, &branch::bus_interactions(), branch_rows); + + let provider = |bus: BusId| -> Vec<(Vec, FE)> { + let id: u64 = bus.into(); + net.iter() + .filter(|((b, t), m)| *b == id && **m != FE::zero() && in_domain(id, t)) + .map(|((_, t), m)| (t.clone(), *m)) + .collect() + }; + + let table = |rows: &[Row], ncols: usize| { + let len = rows.len().next_power_of_two().max(4); + let mut data = vec![FE::zero(); len * ncols]; + for (r, row) in rows.iter().enumerate() { + data[r * ncols..(r + 1) * ncols].copy_from_slice(row); + } + TraceTable::::new_main(data, ncols, 1) + }; + let sends = |ts: &[Vec]| -> Vec<(Vec, FE)> { + ts.iter().map(|t| (t.clone(), FE::one())).collect() + }; + let branch_width = cpu_branch.first().map_or(1, |t| t.len()); + + let mut lt_trace = table(lt_rows, lt::cols::NUM_COLUMNS); + let mut branch_trace = table(branch_rows, branch::cols::NUM_COLUMNS); + let mut cpu_alu_trace = lookup_trace(&sends(cpu_alu), 7); + let mut cpu_branch_trace = lookup_trace(&sends(cpu_branch), branch_width); + let mut half_trace = lookup_trace(&provider(BusId::IsHalfword), 1); + let mut msb_trace = lookup_trace(&provider(BusId::Msb16), 2); + let mut bytes_trace = lookup_trace(&provider(BusId::AreBytes), 2); + let mut balu_trace = lookup_trace(&provider(BusId::ByteAlu), 4); + + let lt_air = create_lt_air(&opts); + let branch_air = create_branch_air(&opts); + let cpu_alu_air = lookup_air(&opts, BusId::Alu, 7, true); + let cpu_branch_air = lookup_air(&opts, BusId::Branch, branch_width, true); + let half_air = lookup_air(&opts, BusId::IsHalfword, 1, false); + let msb_air = lookup_air(&opts, BusId::Msb16, 2, false); + let bytes_air = lookup_air(&opts, BusId::AreBytes, 2, false); + let balu_air = lookup_air(&opts, BusId::ByteAlu, 4, false); + + let pairs: Vec<( + &dyn AIR, + _, + _, + )> = vec![ + (&cpu_alu_air, &mut cpu_alu_trace, &()), + (&cpu_branch_air, &mut cpu_branch_trace, &()), + (<_air, &mut lt_trace, &()), + (&branch_air, &mut branch_trace, &()), + (&half_air, &mut half_trace, &()), + (&msb_air, &mut msb_trace, &()), + (&bytes_air, &mut bytes_trace, &()), + (&balu_air, &mut balu_trace, &()), + ]; + let Ok(proof) = multi_prove_ram(pairs, &mut DefaultTranscript::::new(&[])) else { + panic!("prover failed: the harness must produce a proof for the verifier to judge"); + }; + let airs: Vec<&dyn AIR> = vec![ + &cpu_alu_air, + &cpu_branch_air, + <_air, + &branch_air, + &half_air, + &msb_air, + &bytes_air, + &balu_air, + ]; + Verifier::multi_verify( + &airs, + &proof, + &mut DefaultTranscript::::new(&[]), + &FieldElement::zero(), + ) +} + +/// LT rows A (forged `5 < 3 = 1`), T (`μ = −1` twin of `2^16 < 0`), D (honest copy of T). +fn forged_lt_rows() -> (LtOperation, Vec) { + let op_a = LtOperation::new(5, 3, false); + let mut a = honest_row(&op_a); + a[lt::cols::LT] = FE::one(); + a[lt::cols::OUT] = FE::one(); + a[lt::cols::LHS_SUB_RHS_2] = FE::zero(); + a[lt::cols::LHS_SUB_RHS_3] = FE::from(HALF); + let base = LtOperation::new(HALF, 0, false); + (op_a, vec![a, twin_row(&base, HALF), honest_row(&base)]) +} + +/// Control: honest LT + honest BRANCH verify, so a rejection below means something. +#[test] +fn control_honest_lt_and_branch_verify() { + let op = LtOperation::new(5, 3, false); + let (b, _) = branch_carrier(); + let cpu_branch = tuple_on(&branch::bus_interactions(), &b, BusId::Branch); + assert!(prove_and_verify( + &[honest_row(&op)], + &[b], + &[alu_tuple(&op, 0)], + &[cpu_branch] + )); +} + +/// Control: the carrier row alone is rejected (its ARE_BYTES[p−1, 0] has no receiver). +#[test] +fn control_branch_carrier_alone_is_rejected() { + let op = LtOperation::new(5, 3, false); + let (honest, c) = branch_carrier(); + let cpu_branch = tuple_on(&branch::bus_interactions(), &honest, BusId::Branch); + assert_eq!( + cpu_branch, + tuple_on(&branch::bus_interactions(), &c, BusId::Branch) + ); + assert!(!prove_and_verify( + &[honest_row(&op)], + &[c], + &[alu_tuple(&op, 0)], + &[cpu_branch] + )); +} + +/// `5 < 3 = 1` with a real BRANCH carrier must be rejected. FAILS on 2c1335db5: +/// the carrier's ARE_BYTES[p−1, 0] (+1) cancels T's bound send (−1). +#[test] +fn lt_forgery_with_branch_carrier_is_rejected() { + let (op_a, lt_rows) = forged_lt_rows(); + let (honest, c) = branch_carrier(); + let cpu_branch = tuple_on(&branch::bus_interactions(), &honest, BusId::Branch); + let verified = prove_and_verify(<_rows, &[c], &[alu_tuple(&op_a, 1)], &[cpu_branch]); + assert!( + !verified, + "verifier accepted 5 < 3 = 1 via a BRANCH carrier on ARE_BYTES" + ); +} + +/// BRANCH row for `pc = 0, offset`, re-encoded with `next_pc_low_1 = honest − 256` +/// (i.e. `p − (256 − honest)`) and `next_pc_high_0 + 1`, at multiplicity `mu`. +/// The packed next_pc is unchanged, so every BranchConstraint still holds. +fn branch_reencoded(offset: u64, honest_low1: u64, mu: FE) -> (Row, Row) { + let op = BranchOperation::new(0, offset, 0, false); + let honest = honest_branch_row(&op); + assert_eq!(honest[branch::cols::NEXT_PC_LOW_1], FE::from(honest_low1)); + assert_eq!(honest[branch::cols::NEXT_PC_HIGH_0], FE::zero()); + let mut r = honest.clone(); + r[branch::cols::NEXT_PC_LOW_1] = FE::from(honest_low1) - FE::from(256u64); + r[branch::cols::NEXT_PC_HIGH_0] = FE::one(); + r[branch::cols::MU] = mu; + (honest, r) +} + +/// Weight 1 on the μ bound alone is not enough while BRANCH also sends +/// `next_pc_low_1` with weight μ (spec #1017 before 256e3970b). The LT twin's +/// bound leaves `+1` on `(p−1, 0)`, and two BRANCH rows with μ < 0 cancel it: +/// - B: μ = −2, `low1 = p−1`: bound `(p−2, 0)` +1, limb `(p−1, 0)` −2; +/// - C: μ = −1, `low1 = p−2`: bound `(p−1, 0)` +1, limb `(p−2, 0)` −1. +/// +/// Both tuples net to zero. Honest copies of B and C at μ = 2 and μ = 1 cancel +/// them on the BRANCH bus, and everything else lands in range. Batching the two +/// byte checks into one `ARE_BYTES[μ, next_pc_low_1]` with multiplicity 1 +/// leaves B and C each with an uncancellable `+1` on an out-of-range tuple. +#[test] +fn lt_forgery_with_branch_cycle_is_rejected() { + let (op_a, lt_rows) = forged_lt_rows(); + let (honest_b, b) = branch_reencoded(0xFF00, 255, -FE::from(2u64)); + let (honest_c, c) = branch_reencoded(0xFE00, 254, -FE::one()); + let mut b_copy = honest_b.clone(); + b_copy[branch::cols::MU] = FE::from(2u64); + // One honest BRANCH lookup the CPU actually makes, so the BRANCH bus is live. + let cpu_branch = tuple_on(&branch::bus_interactions(), &honest_b, BusId::Branch); + let branch_rows = [honest_b, b, b_copy, c, honest_c]; + let verified = prove_and_verify( + <_rows, + &branch_rows, + &[alu_tuple(&op_a, 1)], + &[cpu_branch], + ); + assert!( + !verified, + "verifier accepted 5 < 3 = 1 via a two-row BRANCH cycle on ARE_BYTES" + ); +} + +/// Guard for the invariant both forgeries above exploit. The μ bounds hold only +/// while no ARE_BYTES sender carries a negative weight, and in a dedup table μ is +/// a count a cheating prover may set to any field element. So every ARE_BYTES send +/// of a dedup table must have the constant multiplicity 1, and together they must +/// cover each of its μ columns. +#[test] +fn dedup_tables_bound_every_mu_with_constant_are_bytes_sends() { + use crate::tables::{bytewise, dvrm, mul}; + let tables: [(&str, Vec, Vec); 5] = [ + ("LT", lt::bus_interactions(), vec![lt::cols::MU]), + ("BRANCH", branch::bus_interactions(), vec![branch::cols::MU]), + ( + "BYTEWISE", + bytewise::bus_interactions(), + vec![bytewise::cols::MU], + ), + ( + "MUL", + mul::bus_interactions(), + vec![mul::cols::MU_LO, mul::cols::MU_HI], + ), + ( + "DVRM", + dvrm::bus_interactions(), + vec![dvrm::cols::MU_Q, dvrm::cols::MU_R], + ), + ]; + for (name, interactions, mus) in tables { + let mut checked = Vec::new(); + for i in interactions + .iter() + .filter(|i| i.bus_id == u64::from(BusId::AreBytes)) + { + assert!( + i.is_sender && matches!(i.multiplicity, Multiplicity::One), + "{name} sends ARE_BYTES with a non-constant multiplicity: a μ < 0 row \ + could cancel a μ bound" + ); + for v in &i.values { + if let BusValue::Packed { start_column, .. } = v { + checked.push(*start_column); + } + } + } + for mu in mus { + assert!( + checked.contains(&mu), + "{name} does not range-check μ column {mu} on ARE_BYTES" + ); + } + } +} diff --git a/prover/src/tests/branch_multiplicity_poc.rs b/prover/src/tests/branch_multiplicity_poc.rs index ab486219f..989af3d40 100644 --- a/prover/src/tests/branch_multiplicity_poc.rs +++ b/prover/src/tests/branch_multiplicity_poc.rs @@ -6,8 +6,9 @@ //! row cancels it on the BRANCH bus while *receiving* its range lookups; split //! the high word non-canonically (`high_1 = 2^16, high_2 -= 1`, same packed //! `next_pc`) and the twin absorbs an out-of-range `IS_HALFWORD[2^16]` sent by -//! another table. The fix range-checks `μ` (`IS_HALF[μ]`, weighted by μ), so the -//! twin's `μ = −1` itself lands at `p−1` with no receiver. +//! another table. The fix range-checks `μ` together with `next_pc_low_1` +//! (`ARE_BYTES[μ, next_pc_low_1]` with multiplicity 1, spec #1017), so the twin's +//! `μ = −1` itself lands at `(p−1, ·)` with no receiver. //! //! Real BRANCH AIR + real bus balance; the surrounding tables are faithful mocks //! built from BRANCH's own interactions (CPU sends what BRANCH receives; the @@ -155,7 +156,14 @@ fn prove_and_verify(rows: &[Row], victim: Option) -> bool { .into_iter() .collect(); - let mut net = branch_net(rows); + // The μ bound has multiplicity 1, so BRANCH's all-zero padding rows send + // ARE_BYTES[0, 0] too: tally the padded instance, not just `rows`. + let mut padded = rows.to_vec(); + padded.resize( + rows.len().next_power_of_two().max(4), + vec![FE::zero(); cols::NUM_COLUMNS], + ); + let mut net = branch_net(&padded); if let Some(v) = victim { *net.entry((half_id, vec![v])).or_insert(FE::zero()) += FE::one(); } @@ -168,9 +176,9 @@ fn prove_and_verify(rows: &[Row], victim: Option) -> bool { if *m == FE::zero() { continue; } - // A real byte table has rows only for [0, 256); the μ bound sends - // ARE_BYTES[μ, 0], so an out-of-range μ (p−1) has no receiver. - if *bus == BusId::AreBytes as u64 && tuple[0] >= 256 { + // A real byte table has rows only for [0, 256)²; the μ bound sends + // ARE_BYTES[μ, next_pc_low_1], so an out-of-range μ (p−1) has no receiver. + if *bus == BusId::AreBytes as u64 && (tuple[0] >= 256 || tuple[1] >= 256) { continue; } if *bus == half_id && tuple[0] >= HALF { diff --git a/prover/src/tests/dvrm_tests.rs b/prover/src/tests/dvrm_tests.rs index ba4f95296..3e76317dd 100644 --- a/prover/src/tests/dvrm_tests.rs +++ b/prover/src/tests/dvrm_tests.rs @@ -311,9 +311,9 @@ fn test_bus_interactions_count() { // - 2x MUL senders (n_sub_r = d*q lo + hi) // - 6x ZERO senders (C3×2 NEG r, C5×2 NEG d, C8 overflow, C17 div_by_zero) // - 2x DVRM receivers (quotient, remainder) - // - 2x ARE_BYTES senders bounding the multiplicities (μ_q, μ_r) - // Total: 8 + 12 + 3 + 1 + 2 + 6 + 2 + 2 = 36 - assert_eq!(interactions.len(), 36, "Expected 36 bus interactions"); + // - 1x ARE_BYTES[μ_q, μ_r] sender bounding both multiplicities (batched) + // Total: 8 + 12 + 3 + 1 + 2 + 6 + 2 + 1 = 35 + assert_eq!(interactions.len(), 35, "Expected 35 bus interactions"); } #[test] @@ -559,26 +559,31 @@ fn neg_template_zero_lookups_dedup_per_chip_instance() { // Soundness regression: μ_q/μ_r are bounded, non-negative multiplicities. // Every DVRM check fires with μ_q + μ_r, so a free μ_r = −1 next to μ_q = 1 -// switched them all off (`multiplicity_forgery_poc`). Each μ is now ARE_BYTES-checked -// weighted by itself, and trace generation splits a row whose count would -// exceed `MU_MAX`. +// switched them all off (`multiplicity_forgery_poc`). Both μ are now byte-checked +// by one ARE_BYTES[μ_q, μ_r] with multiplicity 1 (spec #1017), and trace +// generation splits a row whose count would exceed `MU_MAX`. -/// Presence: each multiplicity is ARE_BYTES-checked weighted by itself, so an -/// out-of-range value `v` always lands on the bus with weight `v ≠ 0`. +/// Presence: both multiplicities are ARE_BYTES-checked with multiplicity 1, so an +/// out-of-range value always leaves `+1` on a tuple ARE_BYTES has no row for. #[test] fn test_dvrm_bounds_its_multiplicities() { use crate::tables::types::BusId; use stark::lookup::{BusValue, Multiplicity, Packing}; - for col in [cols::MU_Q, cols::MU_R] { - assert!( - bus_interactions().iter().any(|i| i.is_sender - && i.bus_id == BusId::AreBytes as u64 - && matches!(i.multiplicity, Multiplicity::Column(m) if m == col) - && matches!(i.values.first(), - Some(BusValue::Packed { start_column, packing: Packing::Direct }) if *start_column == col)), - "DVRM must IS_BYTE-check multiplicity column {col} weighted by itself" - ); - } + let packed = |v: &BusValue| match v { + BusValue::Packed { + start_column, + packing: Packing::Direct, + } => Some(*start_column), + _ => None, + }; + assert!( + bus_interactions().iter().any(|i| i.is_sender + && i.bus_id == BusId::AreBytes as u64 + && matches!(i.multiplicity, Multiplicity::One) + && i.values.iter().map(packed).collect::>() + == [Some(cols::MU_Q), Some(cols::MU_R)]), + "DVRM must IS_BYTE-check μ_q and μ_r in one ARE_BYTES with multiplicity 1" + ); } /// Splitting: a count above `MU_MAX` spreads over rows within the bound that @@ -603,8 +608,8 @@ fn test_dedup_dvrm_rows_splits_counts_above_mu_max() { } /// Consistency: the BITWISE collector tallies exactly the per-row lookups the -/// generated DVRM instances send — IS_HALF[μ_q], IS_HALF[μ_r] weighted by μ -/// (padding sends nothing), MSB16 ×3 per SIGNED row and the NEG-template ZERO ×2 per +/// generated DVRM instances send — ARE_BYTES[μ_q, μ_r] once per row (padding rows +/// included), MSB16 ×3 per SIGNED row and the NEG-template ZERO ×2 per /// SIGN_R / SIGN_D row — across small instances and for a row split by `MU_MAX`. #[test] fn test_dvrm_per_row_lookups_match_collector() { @@ -657,24 +662,23 @@ fn test_dvrm_per_row_lookups_match_collector() { "IS_HALF must match exactly the limb range checks" ); - // μ bounds: ARE_BYTES[μ_q, 0] and ARE_BYTES[μ_r, 0], weighted by μ. - let mut arebytes: HashMap = HashMap::new(); + // μ bounds: ARE_BYTES[μ_q, μ_r] once per trace row, padding included. + let mut arebytes: HashMap<(u8, u8), i64> = HashMap::new(); for b in collected .iter() .filter(|b| b.lookup_type == BitwiseOperationType::AreBytes) { - assert_eq!(b.y, 0, "μ bounds send ARE_BYTES[μ, 0]"); - *arebytes.entry(b.x).or_default() += 1; + *arebytes.entry((b.x, b.y)).or_default() += 1; } - let mut expected: HashMap = HashMap::new(); + let mut expected: HashMap<(u8, u8), i64> = HashMap::new(); let (mut msb16, mut neg_zero) = (0usize, 0usize); for c in ops.chunks(chunk) { let t = generate_dvrm_trace(c); for r in 0..t.num_rows() { - for col in [cols::MU_Q, cols::MU_R] { - let mu = t.get_main(r, col).to_raw(); - *expected.entry(mu as u8).or_default() += mu as i64; - } + let mu = |col| t.get_main(r, col).to_raw() as u8; + *expected + .entry((mu(cols::MU_Q), mu(cols::MU_R))) + .or_default() += 1; msb16 += 3 * t.get_main(r, cols::SIGNED).to_raw() as usize; neg_zero += 2 * t.get_main(r, cols::SIGN_R).to_raw() as usize; neg_zero += 2 * t.get_main(r, cols::SIGN_D).to_raw() as usize; diff --git a/prover/src/tests/lt_multiplicity_poc.rs b/prover/src/tests/lt_multiplicity_poc.rs index af53429bf..52794d4da 100644 --- a/prover/src/tests/lt_multiplicity_poc.rs +++ b/prover/src/tests/lt_multiplicity_poc.rs @@ -9,8 +9,10 @@ //! `X < 2^32`) with `sub_1 = 0` and still satisfy the carry constraints, so one //! IS_HALFWORD receive of X appears from nowhere. That absorbs an out-of-range //! limb of *any* sender, another LT row included — which forges the LT result. -//! The fix range-checks μ (`IS_HALF[μ]`, weighted by μ), so a twin's `μ = −1` -//! itself has no receiver. +//! The fix range-checks μ (`ARE_BYTES[μ, 0]` with multiplicity 1, spec #1017), +//! so a twin's `μ = −1` itself has no receiver. (Its first version, `IS_HALF[μ]` +//! weighted by μ, was cancellable: see `are_bytes_bound_resists_in_table_carrier` +//! and `branch_carrier_poc`.) //! //! Harness, with the real LT AIR (`lt::bus_interactions()` + `LtConstraints`): //! - CPU: a mock ALU sender for the LT lookups the program "made". @@ -84,7 +86,7 @@ fn lookup_trace(rows: &[(Vec, FE)], n: usize) -> TraceTable { TraceTable::new_main(data, n + 1, 1) } -fn eval_mult(m: &Multiplicity, g: &dyn Fn(usize) -> FE) -> FE { +pub(crate) fn eval_mult(m: &Multiplicity, g: &dyn Fn(usize) -> FE) -> FE { match m { Multiplicity::One => FE::one(), Multiplicity::Column(c) => g(*c), @@ -174,7 +176,14 @@ fn prove_and_verify(lt_rows: &[Row], cpu: &[Vec], victim: &[u64]) -> bool { let msb_id: u64 = BusId::Msb16.into(); let byte_id: u64 = BusId::AreBytes.into(); - let mut net = lt_net(lt_rows); + // The μ bound has multiplicity 1, so LT's all-zero padding rows send + // ARE_BYTES[0, 0] too: tally the padded instance, not just `lt_rows`. + let mut padded = lt_rows.to_vec(); + padded.resize( + lt_rows.len().next_power_of_two().max(4), + vec![FE::zero(); cols::NUM_COLUMNS], + ); + let mut net = lt_net(&padded); for &v in victim { *net.entry((half_id, vec![v])).or_insert(FE::zero()) += FE::one(); } diff --git a/prover/src/tests/lt_tests.rs b/prover/src/tests/lt_tests.rs index b32faf6a5..747c28766 100644 --- a/prover/src/tests/lt_tests.rs +++ b/prover/src/tests/lt_tests.rs @@ -237,11 +237,12 @@ fn test_lt_rejects_forged_out() { // lookup fires with μ, so a free μ = −1 twin of an honest row received range // lookups instead of sending them and absorbed another row's out-of-range limb // (`lt_multiplicity_poc`, `multiplicity_forgery_poc`). μ is now range-checked on -// ARE_BYTES (`ARE_BYTES[μ, 0]`, weighted by μ), and trace generation splits a row -// whose count would exceed `MU_MAX`. +// ARE_BYTES (`ARE_BYTES[μ, 0]` with multiplicity 1, spec #1017), and trace +// generation splits a row whose count would exceed `MU_MAX`. -/// Presence: μ is ARE_BYTES-checked weighted by itself, so an out-of-range value -/// `v` always lands on the bus with weight `v ≠ 0`. +/// Presence: μ is ARE_BYTES-checked with multiplicity 1, so an out-of-range μ +/// always leaves `+1` on a tuple ARE_BYTES has no row for (a weight-μ bound was +/// itself cancellable, `branch_carrier_poc`). #[test] fn test_lt_bounds_its_multiplicity() { use crate::tables::types::BusId; @@ -249,10 +250,10 @@ fn test_lt_bounds_its_multiplicity() { assert!( bus_interactions().iter().any(|i| i.is_sender && i.bus_id == BusId::AreBytes as u64 - && matches!(i.multiplicity, Multiplicity::Column(m) if m == cols::MU) + && matches!(i.multiplicity, Multiplicity::One) && matches!(i.values.first(), Some(BusValue::Packed { start_column, packing: Packing::Direct }) if *start_column == cols::MU)), - "LT must IS_BYTE-check μ weighted by itself" + "LT must IS_BYTE-check μ with multiplicity 1" ); } @@ -284,9 +285,9 @@ fn test_dedup_lt_rows_splits_counts_above_mu_max() { assert_eq!(total, ops.len() as u64); } -/// Consistency: the BITWISE collector tallies exactly the IS_HALF[μ] lookups the -/// generated LT instances send (each row's μ, weighted by μ; padding nothing), -/// across several small instances and for a row split by `MU_MAX`. +/// Consistency: the BITWISE collector tallies exactly the IS_BYTE[μ] lookups the +/// generated LT instances send (each row's μ once, padding rows as μ = 0), across +/// several small instances and for a row split by `MU_MAX`. #[test] fn test_lt_mu_bound_lookups_match_collector() { use crate::tables::bitwise::BitwiseOperationType; @@ -332,7 +333,7 @@ fn test_lt_mu_bound_lookups_match_collector() { "IS_HALF must match exactly the limb range checks" ); - // μ bound: ARE_BYTES[μ, 0] weighted by μ. + // μ bound: ARE_BYTES[μ, 0] once per trace row, padding (μ = 0) included. let mut arebytes: HashMap = HashMap::new(); for b in collected .iter() @@ -346,7 +347,7 @@ fn test_lt_mu_bound_lookups_match_collector() { let t = generate_lt_trace(c); for r in 0..t.num_rows() { let mu = t.get_main(r, cols::MU).to_raw(); - *expected.entry(mu as u8).or_default() += mu as i64; + *expected.entry(mu as u8).or_default() += 1; } } arebytes.retain(|_, n| *n != 0); diff --git a/prover/src/tests/mod.rs b/prover/src/tests/mod.rs index b3147f0e4..18e2fec22 100644 --- a/prover/src/tests/mod.rs +++ b/prover/src/tests/mod.rs @@ -7,6 +7,8 @@ pub mod bitwise_tests; #[cfg(test)] pub mod branch_bus_tests; #[cfg(test)] +pub mod branch_carrier_poc; +#[cfg(test)] pub mod branch_constraints_tests; #[cfg(test)] pub mod branch_multiplicity_poc; diff --git a/prover/src/tests/mul_mu_carrier_poc.rs b/prover/src/tests/mul_mu_carrier_poc.rs index 813639b9e..4f5cb7b9c 100644 --- a/prover/src/tests/mul_mu_carrier_poc.rs +++ b/prover/src/tests/mul_mu_carrier_poc.rs @@ -1,12 +1,11 @@ //! Adversarial check of the MUL `μ` bound (`fix/multiplicity-soundness`). //! -//! The fix bounds `μ_lo`/`μ_hi` with `IS_HALF[μ]` weighted *by μ itself* -//! (`Multiplicity::Column`), because the skip-empty-tables invariant (#977) -//! forbids a constant `Multiplicity::One` on an optional table. The open -//! question (raised reviewing BRANCH): does weighting by μ leave a hole that -//! `One` would not? A malicious `μ_hi = −1` row's bound send lands at value -//! `p−1` with weight `p−1 = −1`; the proof then balances iff *something else* -//! supplies `+1` at `p−1` on the IS_HALFWORD bus. +//! The fix bounds `μ_lo`/`μ_hi` with one `ARE_BYTES[μ_lo, μ_hi]` with +//! multiplicity 1 (spec #1017). Its first version was `IS_HALF[μ]` weighted *by +//! μ itself*: a malicious `μ_hi = −1` row's bound send then landed at value +//! `p−1` with weight `p−1 = −1`, and the proof balanced iff *something else* +//! supplied `+1` at `p−1` on the IS_HALFWORD bus. Now the bound sends `+1` at +//! `(1, p−1)`, which only a negative-weight ARE_BYTES sender could cancel. //! //! This test pins down exactly that, with the real fixed MUL AIR and real bus //! balance. Every surrounding table is a faithful mock built by evaluating @@ -162,7 +161,14 @@ fn prove_and_verify(rows: &[Vec], extra_half: Option) -> bool { let b20: u64 = BusId::IsB20.into(); let byte: u64 = BusId::AreBytes.into(); - let net = mul_net(rows); + // The μ bound has multiplicity 1, so MUL's all-zero padding rows send + // ARE_BYTES[0, 0] too: tally the padded instance, not just `rows`. + let mut padded = rows.to_vec(); + padded.resize( + rows.len().next_power_of_two().max(4), + vec![FE::zero(); cols::NUM_COLUMNS], + ); + let net = mul_net(&padded); let mut cpu = Vec::new(); let mut range = Vec::new(); let mut range20 = Vec::new(); @@ -181,8 +187,8 @@ fn prove_and_verify(rows: &[Vec], extra_half: Option) -> bool { } } else if *bus == b20 && tuple[0] < B20 { range20.push((tuple.clone(), *m)); - } else if *bus == byte && tuple[0] < 256 { - // μ bound: ARE_BYTES[μ, 0]; a real byte table has rows only for [0,256). + } else if *bus == byte && tuple[0] < 256 && tuple[1] < 256 { + // μ bound: ARE_BYTES[μ_lo, μ_hi]; a real byte table has rows only for [0,256)². arebytes.push((tuple.clone(), *m)); } } @@ -247,9 +253,9 @@ fn control_honest_mul_verifies() { assert!(prove_and_verify(&[row], None)); } -/// The twin's `μ_hi = −1` bound now lands on ARE_BYTES, not IS_HALFWORD: it -/// leaves a `−1` at `ARE_BYTES[p−1]` (out of byte range, no receiver), which is -/// why an IS_HALFWORD carrier cannot cancel it. The IS_HALFWORD bus carries no +/// The twin's `μ_hi = −1` bound lands on ARE_BYTES, not IS_HALFWORD: it leaves a +/// `+1` at `ARE_BYTES[1, p−1]` (out of byte range, no receiver), which is why an +/// IS_HALFWORD carrier cannot cancel it. The IS_HALFWORD bus carries no /// out-of-range residue from the bound. #[test] fn forgery_bound_residue_is_on_are_bytes() { @@ -257,10 +263,10 @@ fn forgery_bound_residue_is_on_are_bytes() { let byte: u64 = BusId::AreBytes.into(); let net = mul_net(&forged_mul_rows()); - // The μ bound's out-of-range residue is on ARE_BYTES[p−1] with weight −1. + // The μ bound's out-of-range residue is on ARE_BYTES[1, p−1] with weight +1. assert_eq!( - net.get(&(byte, vec![minus_one(), 0])).copied(), - Some(-FE::one()) + net.get(&(byte, vec![1, minus_one()])).copied(), + Some(FE::one()) ); // No out-of-range IS_HALFWORD send comes from the μ bound any more. for ((bus, tuple), m) in &net { @@ -279,7 +285,7 @@ fn forgery_without_carrier_is_rejected() { /// Regression: the μ bound now lives on ARE_BYTES, a bus the `lo`/`hi` limb /// range checks (IS_HALFWORD) never touch. So an IS_HALFWORD carrier at `p−1` /// can no longer cancel the twin's `μ_hi = −1` bound send (it lands on -/// ARE_BYTES[p−1], out of byte range, with no receiver). The forgery is +/// ARE_BYTES[1, p−1], out of byte range, with no receiver). The forgery is /// rejected even WITH the carrier. #[test] fn is_halfword_carrier_no_longer_defeats_byte_bound() { diff --git a/prover/src/tests/mul_tests.rs b/prover/src/tests/mul_tests.rs index 1429ba424..b63903221 100644 --- a/prover/src/tests/mul_tests.rs +++ b/prover/src/tests/mul_tests.rs @@ -270,9 +270,9 @@ fn test_bus_interactions_count() { // - 8x IS_HALF senders for outputs (lo[0..4], hi[0..4]) // - 4x IS_B20 senders (carry[0..4] virtual range checks) // - 2x ALU receivers (lo, hi) - // - 2x ARE_BYTES senders bounding the multiplicities (μ_lo, μ_hi) - // Total: 2 + 8 + 8 + 4 + 2 + 2 = 26 - assert_eq!(interactions.len(), 26, "Expected 26 bus interactions"); + // - 1x ARE_BYTES[μ_lo, μ_hi] sender bounding both multiplicities (batched) + // Total: 2 + 8 + 8 + 4 + 2 + 1 = 25 + assert_eq!(interactions.len(), 25, "Expected 25 bus interactions"); } #[test] @@ -420,26 +420,31 @@ fn msb16_bitwise_multiplicity_matches_per_instance_sends() { // Soundness regression: μ_lo/μ_hi are bounded, non-negative multiplicities. // Every MUL range check fires with μ_lo + μ_hi, so a free μ_hi = −1 next to -// μ_lo = 1 switched them all off (`multiplicity_forgery_poc`). Each μ is now -// IS_HALF-range-checked weighted by itself, and trace generation splits a -// row whose count would exceed `MU_MAX`. +// μ_lo = 1 switched them all off (`multiplicity_forgery_poc`). Both μ are now +// byte-checked by one ARE_BYTES[μ_lo, μ_hi] with multiplicity 1 (spec #1017), +// and trace generation splits a row whose count would exceed `MU_MAX`. -/// Presence: each multiplicity is ARE_BYTES-checked weighted by itself, so an -/// out-of-range value `v` always lands on the bus with weight `v ≠ 0`. +/// Presence: both multiplicities are ARE_BYTES-checked with multiplicity 1, so an +/// out-of-range value always leaves `+1` on a tuple ARE_BYTES has no row for. #[test] fn test_mul_bounds_its_multiplicities() { use crate::tables::types::BusId; use stark::lookup::{BusValue, Multiplicity, Packing}; - for col in [cols::MU_LO, cols::MU_HI] { - assert!( - bus_interactions().iter().any(|i| i.is_sender - && i.bus_id == BusId::AreBytes as u64 - && matches!(i.multiplicity, Multiplicity::Column(m) if m == col) - && matches!(i.values.first(), - Some(BusValue::Packed { start_column, packing: Packing::Direct }) if *start_column == col)), - "MUL must IS_BYTE-check multiplicity column {col} weighted by itself" - ); - } + let packed = |v: &BusValue| match v { + BusValue::Packed { + start_column, + packing: Packing::Direct, + } => Some(*start_column), + _ => None, + }; + assert!( + bus_interactions().iter().any(|i| i.is_sender + && i.bus_id == BusId::AreBytes as u64 + && matches!(i.multiplicity, Multiplicity::One) + && i.values.iter().map(packed).collect::>() + == [Some(cols::MU_LO), Some(cols::MU_HI)]), + "MUL must IS_BYTE-check μ_lo and μ_hi in one ARE_BYTES with multiplicity 1" + ); } /// Splitting: a count above `MU_MAX` spreads over rows that each stay within the @@ -479,8 +484,8 @@ fn test_dedup_mul_rows_splits_counts_above_mu_max() { } /// Consistency: the BITWISE collector tallies exactly the per-row lookups the -/// generated MUL instances send — IS_HALF[μ_lo], IS_HALF[μ_hi] weighted by μ -/// (padding sends nothing) and MSB16 per row whose sign flag is set — both across +/// generated MUL instances send — ARE_BYTES[μ_lo, μ_hi] once per row (padding +/// rows included) and MSB16 per row whose sign flag is set — both across /// several small instances and for a row split by `MU_MAX`. #[test] fn test_mul_per_row_lookups_match_collector() { @@ -533,24 +538,23 @@ fn test_mul_per_row_lookups_match_collector() { "IS_HALF must match exactly the limb range checks" ); - // μ bounds: ARE_BYTES[μ_lo, 0] and ARE_BYTES[μ_hi, 0], weighted by μ. - let mut arebytes: HashMap = HashMap::new(); + // μ bounds: ARE_BYTES[μ_lo, μ_hi] once per trace row, padding included. + let mut arebytes: HashMap<(u8, u8), i64> = HashMap::new(); for b in collected .iter() .filter(|b| b.lookup_type == BitwiseOperationType::AreBytes) { - assert_eq!(b.y, 0, "μ bounds send ARE_BYTES[μ, 0]"); - *arebytes.entry(b.x).or_default() += 1; + *arebytes.entry((b.x, b.y)).or_default() += 1; } - let mut expected: HashMap = HashMap::new(); + let mut expected: HashMap<(u8, u8), i64> = HashMap::new(); let mut msb16 = 0usize; for c in ops.chunks(chunk) { let t = generate_mul_trace(c); for r in 0..t.num_rows() { - for col in [cols::MU_LO, cols::MU_HI] { - let mu = t.get_main(r, col).to_raw(); - *expected.entry(mu as u8).or_default() += mu as i64; - } + let mu = |col| t.get_main(r, col).to_raw() as u8; + *expected + .entry((mu(cols::MU_LO), mu(cols::MU_HI))) + .or_default() += 1; msb16 += t.get_main(r, cols::LHS_SIGNED).to_raw() as usize; msb16 += t.get_main(r, cols::RHS_SIGNED).to_raw() as usize; } diff --git a/prover/src/tests/multiplicity_forgery_poc.rs b/prover/src/tests/multiplicity_forgery_poc.rs index b8bb71689..74a68fb91 100644 --- a/prover/src/tests/multiplicity_forgery_poc.rs +++ b/prover/src/tests/multiplicity_forgery_poc.rs @@ -9,10 +9,9 @@ //! all while still receiving one `a` lookup: its result was free. The stray `−1` //! on the `b` tuple was cancelled by an honest copy of the row with //! `μ_a = 0, μ_b = +1`, whose checks fire once and pass. Both forgeries below -//! verified against `main` (ffc4ac19). The fix range-checks each μ to a byte on -//! the ARE_BYTES bus (`ARE_BYTES[μ, 0]`, weighted by μ) — a bus the limb checks -//! (IS_HALFWORD) never use — so `μ_b = −1` lands at `ARE_BYTES[p−1, 0]` with no -//! receiver. +//! verified against `main` (ffc4ac19). The fix range-checks both μ to bytes with +//! one `ARE_BYTES[μ_a, μ_b]` of multiplicity 1 (spec #1017), so `μ_b = −1` leaves +//! `+1` at `ARE_BYTES[1, p−1]` with no receiver. //! //! **SHIFT.** The five HWSL senders fire with `1 − zbs`, and on a μ = 0 padding //! row nothing pinned `zbs`: `zbs = 2` made that −1, so the padding row provided @@ -42,7 +41,7 @@ //! Run under production proof options. use crypto::fiat_shamir::default_transcript::DefaultTranscript; -use stark::lookup::{BusInteraction, BusValue, Multiplicity, Packing}; +use stark::lookup::BusInteraction; use stark::proof::options::ProofOptions; use stark::prover::{IsStarkProver, Prover}; use stark::trace::TraceTable; @@ -232,17 +231,29 @@ fn craft_proof(s: &Scenario, forge: Forge) -> Result bool { - interactions.iter().any(|i| { - i.is_sender - && i.bus_id == BusId::AreBytes as u64 - && matches!(i.multiplicity, Multiplicity::Column(m) if m == col) - && matches!( - i.values.first(), - Some(BusValue::Packed { start_column, packing: Packing::Direct }) if *start_column == col - ) - }) +/// Net ARE_BYTES sends of `interactions` over every row of `t`, padding included. +fn are_bytes_net( + interactions: &[BusInteraction], + t: &TraceTable, +) -> std::collections::HashMap, FE> { + use super::lt_multiplicity_poc::eval_mult; + let id: u64 = BusId::AreBytes.into(); + let mut net = std::collections::HashMap::new(); + for bi in interactions.iter().filter(|i| i.bus_id == id) { + for r in 0..t.num_rows() { + let g = |c: usize| *t.get_main(r, c); + let m = eval_mult(&bi.multiplicity, &g); + let tuple: Vec = bi + .values + .iter() + .flat_map(|v| v.combine_from(g)) + .map(|x: FE| x.canonical_u64()) + .collect(); + let e = net.entry(tuple).or_insert(FE::zero()); + *e = if bi.is_sender { *e + m } else { *e - m }; + } + } + net } /// Add `delta` to the BITWISE receiver multiplicity `mu_col` at halfword `half`. @@ -262,17 +273,20 @@ fn bump_bitwise(traces: &mut Traces, mu_col: usize, half: u64, delta: FE) { /// `μ_a = 0, μ_b = 1`. /// /// Returns the row-B copy's index so the caller can rebalance flag-gated sends. -/// If the chip range-checks its μ columns (ARE_BYTES[μ, 0]), also rebalances -/// those sends wherever a BITWISE row exists (`p−1` has none — that is the fix). +/// Also rebalances the chip's ARE_BYTES sends (the batched μ bound +/// `ARE_BYTES[μ_a, μ_b] | 1`) wherever a BITWISE row exists, and asserts the +/// only send left without one is A's bound at `(1, p−1)` with `+1` — the +/// out-of-range lookup that is what rejects the forgery. fn forge_two_rows( traces: &mut Traces, table: fn(&mut Traces) -> &mut TraceTable, find_a: impl Fn(&TraceTable, usize) -> bool, (mu_a, mu_b, num_cols): (usize, usize, usize), - bounded: bool, + interactions: &[BusInteraction], write_forged: impl Fn(&mut TraceTable, usize), ) -> usize { let t = table(traces); + let before = are_bytes_net(interactions, t); let rows = t.num_rows(); let a = (0..rows) .find(|&r| find_a(t, r)) @@ -292,19 +306,39 @@ fn forge_two_rows( write_forged(t, a); t.set_main(a, mu_b, FE::zero() - FE::one()); - if bounded { - // ARE_BYTES[μ, 0] | μ sends: B's μ_b 0 -> 1 adds ARE_BYTES[1, 0] once; A's - // μ_b 0 -> −1 adds ARE_BYTES[p−1, 0] with weight −1, which has no BITWISE - // row (that out-of-range send is what rejects the forgery). - bump_bitwise(traces, bw_cols::MU_ARE_BYTES, 1, FE::one()); + let after = are_bytes_net(interactions, t); + let mut out_of_range = Vec::new(); + let keys: std::collections::HashSet<_> = before.keys().chain(after.keys()).cloned().collect(); + for tuple in keys { + let get = |n: &std::collections::HashMap, FE>| { + n.get(&tuple).copied().unwrap_or(FE::zero()) + }; + let delta = get(&after) - get(&before); + if delta == FE::zero() { + continue; + } + if tuple.iter().all(|&v| v < 256) { + bump_bitwise( + traces, + bw_cols::MU_ARE_BYTES, + tuple[0] + 256 * tuple[1], + delta, + ); + } else { + out_of_range.push((tuple, delta)); + } } + assert_eq!( + out_of_range, + vec![(vec![1, (-FE::one()).canonical_u64()], FE::one())], + "only A's μ bound may be left without a BITWISE row" + ); b } /// MUL: `lo(3 * 5)` forged to 99 (`μ_lo = 1, μ_hi = −1`). fn forge_mul_rows(traces: &mut Traces, s: &Scenario) { use mul::cols; - let bounded = bounds_column(&mul::bus_interactions(), cols::MU_HI); let forged = s.forged; let b = forge_two_rows( traces, @@ -315,7 +349,7 @@ fn forge_mul_rows(traces: &mut Traces, s: &Scenario) { && *t.get_main(r, cols::MU_LO) == FE::one() }, (cols::MU_LO, cols::MU_HI, cols::NUM_COLUMNS), - bounded, + &mul::bus_interactions(), |t, a| t.set_main(a, cols::LO_0, FE::from(forged)), ); @@ -335,7 +369,6 @@ fn forge_mul_rows(traces: &mut Traces, s: &Scenario) { /// the copy duplicates no SIGNED/SIGN_R/SIGN_D-gated sends. fn forge_dvrm_rows(traces: &mut Traces, s: &Scenario) { use dvrm::cols; - let bounded = bounds_column(&dvrm::bus_interactions(), cols::MU_R); let forged = s.forged; let b = forge_two_rows( traces, @@ -346,7 +379,7 @@ fn forge_dvrm_rows(traces: &mut Traces, s: &Scenario) { && *t.get_main(r, cols::MU_Q) == FE::one() }, (cols::MU_Q, cols::MU_R, cols::NUM_COLUMNS), - bounded, + &dvrm::bus_interactions(), |t, a| t.set_main(a, cols::Q_0, FE::from(forged)), ); let t = &traces.dvrms[0]; diff --git a/prover/src/tests/skip_empty_tables_tests.rs b/prover/src/tests/skip_empty_tables_tests.rs index 8fef3d190..848ad029e 100644 --- a/prover/src/tests/skip_empty_tables_tests.rs +++ b/prover/src/tests/skip_empty_tables_tests.rs @@ -373,6 +373,16 @@ fn dropping_a_used_table_through_the_real_verifier_is_rejected() { /// on whether an unused chip shipped. The tables that do use one — CPU, HALT, /// PAGE, REGISTER, GLOBAL_MEMORY — are exactly the mandatory ones, and nothing /// enforced that this stays true. +/// +/// One exception, by bus: `ARE_BYTES` byte checks the spec gives no `cond` +/// (default 1) — the μ bounds of the dedup tables (spec #1017) and KECCAK_RND's +/// rotation bytes. A padding row sends them as `ARE_BYTES[0, 0]`, which is in +/// range, and their only counterparty is BITWISE, whose multiplicity columns are +/// free witnesses, so the contribution is absorbed whichever way an unused chip +/// went; and an optional table with no ops is never shipped +/// (`chunk_and_generate_optional`/`generate_optional` return no table), so the +/// collector only counts padding for instances that exist. On every other bus a +/// constant multiplicity on a padding row would be a real receive or send. #[test] fn no_optional_table_uses_a_constant_multiplicity() { use crate::tables::{ @@ -405,8 +415,10 @@ fn no_optional_table_uses_a_constant_multiplicity() { "{name} declares no bus interactions" ); for (i, interaction) in interactions.iter().enumerate() { + let byte_check = interaction.bus_id == u64::from(crate::tables::types::BusId::AreBytes) + && interaction.is_sender; assert!( - !matches!(interaction.multiplicity, stark::lookup::Multiplicity::One), + byte_check || !matches!(interaction.multiplicity, stark::lookup::Multiplicity::One), "{name} interaction {i} uses a constant multiplicity, so its padding rows \ contribute and dropping the table stops being equivalent to shipping it empty" ); From cc8a181b8e981b7e9b82ed283a0696f15732a940 Mon Sep 17 00:00:00 2001 From: Nicole Date: Wed, 14 Oct 2026 11:13:02 -0300 Subject: [PATCH 15/29] Align SHIFT and KECCAK_RND byte and bit checks with the spec --- prover/src/tables/keccak_rnd.rs | 15 +++++----- prover/src/tables/shift.rs | 44 ++++++++++++------------------ prover/src/tables/trace_builder.rs | 32 +++++++++++++++------- 3 files changed, 47 insertions(+), 44 deletions(-) diff --git a/prover/src/tables/keccak_rnd.rs b/prover/src/tables/keccak_rnd.rs index 7e7b8b243..ce20810b3 100644 --- a/prover/src/tables/keccak_rnd.rs +++ b/prover/src/tables/keccak_rnd.rs @@ -855,9 +855,10 @@ fn halfword>( /// The KECCAK round table's 140 transition constraints as a single /// [`ConstraintSet`]: /// -/// * **20 IS_BIT** on the θ carry bits: for `x ∈ 0..5`, `hw ∈ 0..4`, the μ-gated -/// `μ · Cxz_right·(1 − Cxz_right)` (degree 3). Load-bearing: it pins the θ -/// carry to a single bit so the θ shift identity below is unique. +/// * **20 IS_BIT** on the θ carry bits: for `x ∈ 0..5`, `hw ∈ 0..4`, +/// `Cxz_right·(1 − Cxz_right)` (degree 2), unconditional as in the spec. +/// Load-bearing: it pins the θ carry to a single bit so the θ shift identity +/// below is unique. /// * **20 θ shift identities** (rnc = 1): for `x ∈ 0..5`, `hw ∈ 0..4`, /// `μ · (in·2 − right·2¹⁶ − left)` where `in` is the `Cxz[x][3]` halfword, /// `left` the `Cxz_left` byte pair and `right` the single `Cxz_right` carry @@ -876,10 +877,10 @@ fn halfword>( pub struct KeccakRndConstraints; impl ConstraintSet for KeccakRndConstraints { - // The IS_BIT constraints are gated by μ (cond·x·(1−x)), so degree 3; the - // shift identities are μ × linear, degree 2. + // The IS_BIT constraints are x·(1−x) and the shift identities μ × linear, + // all degree 2. fn max_degree(&self) -> usize { - 3 + 2 } #[allow(clippy::needless_range_loop)] @@ -892,7 +893,7 @@ impl ConstraintSet for KeccakRndConstraint // (1) IS_BIT on the θ carry bits (Cxz_right). for x in 0..5 { for hw in 0..4 { - emit_is_bit(b, idx, cols::cxz_right_bit(x, hw), Some(cols::MU)); + emit_is_bit(b, idx, cols::cxz_right_bit(x, hw), None); idx += 1; } } diff --git a/prover/src/tables/shift.rs b/prover/src/tables/shift.rs index ea0fce6e8..7c37989c5 100644 --- a/prover/src/tables/shift.rs +++ b/prover/src/tables/shift.rs @@ -669,32 +669,25 @@ pub fn bus_interactions() -> Vec { )); // Range checks for the low-word high bits (so the in2 low-word decomposition - // is unique → SHIFT_AMOUNT is forced to `arg2 & 0xFF`). SHIFT_AMOUNT is also - // byte-checked implicitly via the BYTE_ALU[AND, shift, mask] lookups; we still emit - // the explicit ARE_BYTES[shift[0]] below to match the spec's `IS_BYTE[shift[0]]` - // (defense-in-depth, redundant with BYTE_ALU[AND]). SHIFT_HIGH (the high word) needs - // no check: IS_WORD is assumed (it equals the CPU's well-formed arg2 high word - // on the bus), matching the spec's `shift[3]`. + // is unique → SHIFT_AMOUNT is forced to `arg2 & 0xFF`). The spec's + // `IS_BYTE[shift[0]]` and `IS_BYTE[shift[1]]` (cond 1) are batched into one + // ARE_BYTES[shift[0], shift[1]] | 1, so padding rows send ARE_BYTES[0, 0] too. + // SHIFT_AMOUNT is also byte-checked implicitly via the BYTE_ALU[AND, shift, mask] + // lookups (defense-in-depth). SHIFT_HIGH (the high word) needs no check: IS_WORD + // is assumed (it equals the CPU's well-formed arg2 high word on the bus), + // matching the spec's `shift[3]`. interactions.push(BusInteraction::sender( BusId::AreBytes, - Multiplicity::Column(cols::MU), + Multiplicity::One, vec![ BusValue::Packed { - start_column: cols::SHIFT_B1, + start_column: cols::SHIFT_AMOUNT, packing: Packing::Direct, }, - BusValue::constant(0), - ], - )); - interactions.push(BusInteraction::sender( - BusId::AreBytes, - Multiplicity::Column(cols::MU), - vec![ BusValue::Packed { - start_column: cols::SHIFT_AMOUNT, + start_column: cols::SHIFT_B1, packing: Packing::Direct, }, - BusValue::constant(0), ], )); interactions.push(BusInteraction::sender( @@ -1010,18 +1003,15 @@ pub fn collect_bitwise_from_shift(operations: &[ShiftOperation]) -> Vec> 8) & 0xFF) as u8, - )); - // ARE_BYTES[shift[0]] — spec IS_BYTE[shift[0]] (defense-in-depth, - // redundant with the BYTE_ALU[AND, shift, mask] lookups above). - bitwise_ops.push(BitwiseOperation::single_byte( + // 0-7, bits 8-15] + IS_HALF[bits 16-31]. The high word (bits 32-63, + // SHIFT_HIGH) is the spec's `shift[3]` Word; IS_WORD is assumed via its bus + // equality with the CPU's well-formed arg2 high word, so it needs no check. + // Padding rows' ARE_BYTES[0, 0] are counted by the BITWISE collector in + // `trace_builder`, which knows the chunking. + bitwise_ops.push(BitwiseOperation::byte_op( BitwiseOperationType::AreBytes, op.shift, + ((op.shift_amount >> 8) & 0xFF) as u8, )); let half = ((op.shift_amount >> 16) & 0xFFFF) as u16; bitwise_ops.push(BitwiseOperation::halfword( diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 7d57f08b4..cae2d9624 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -1741,7 +1741,7 @@ pub(crate) fn collect_bitwise_from_lt( bitwise_ops.extend(rows.iter().map(|(_, mu)| mu_byte(*mu))); bitwise_ops.extend(std::iter::repeat_n( mu_byte(0), - dedup_padding_rows(rows.len()), + chunk_padding_rows(rows.len()), )); } @@ -1828,7 +1828,7 @@ pub(crate) fn collect_bitwise_from_mul( let rows = mul::dedup_mul_rows(chunk); bitwise_ops.extend(std::iter::repeat_n( mu_bytes(0, 0), - dedup_padding_rows(rows.len()), + chunk_padding_rows(rows.len()), )); for (op, mu) in &rows { bitwise_ops.push(mu_bytes(mu.mu_lo, mu.mu_hi)); @@ -1967,7 +1967,7 @@ pub(crate) fn collect_bitwise_from_dvrm( let rows = dvrm::dedup_dvrm_rows(chunk); bitwise_ops.extend(std::iter::repeat_n( mu_bytes(0, 0), - dedup_padding_rows(rows.len()), + chunk_padding_rows(rows.len()), )); for (op, mu) in &rows { bitwise_ops.push(mu_bytes(mu.mu_q, mu.mu_r)); @@ -2108,17 +2108,18 @@ pub(crate) fn collect_bitwise_from_branch( ); bitwise_ops.extend(std::iter::repeat_n( row_bytes(0, 0), - dedup_padding_rows(rows.len()), + chunk_padding_rows(rows.len()), )); } bitwise_ops } -/// Padding rows of a deduplicated chip instance (LT, MUL, DVRM, BRANCH, BYTEWISE) -/// with `rows` real rows: their `generate_*_trace` pads to `next_power_of_two().max(4)`. -/// Their μ bound is sent with multiplicity 1, so each padding row sends it too. -fn dedup_padding_rows(rows: usize) -> usize { +/// Padding rows of a chip instance with `rows` real rows: `generate_*_trace` pads to +/// `next_power_of_two().max(4)`. The byte checks sent with multiplicity 1 (the μ bound of +/// LT, MUL, DVRM, BRANCH, BYTEWISE and SHIFT's `ARE_BYTES[shift[0], shift[1]]`) fire on +/// each padding row too, as `ARE_BYTES[0, 0]`. +fn chunk_padding_rows(rows: usize) -> usize { rows.next_power_of_two().max(4) - rows } @@ -3362,6 +3363,7 @@ fn build_traces( let mul_chunk = max_rows.mul; let dvrm_chunk = max_rows.dvrm; let bytewise_chunk = max_rows.bytewise; + let shift_chunk = max_rows.shift; // Every source except the two dominant ones (the in-walk lookups and MEMW_R, which are // split into row-ranges in the parallel path below) stays a single whole-source collector. let mut collectors: Vec = vec![ @@ -3369,7 +3371,17 @@ fn build_traces( Box::new(|h| h.add_ops(&collect_bitwise_from_mul(&mul_ops, mul_chunk))), Box::new(|h| h.add_ops(&collect_bitwise_from_dvrm(&dvrm_ops, dvrm_chunk))), Box::new(|h| h.add_ops(&collect_bitwise_from_branch(&branch_ops, branch_chunk))), - Box::new(|h| h.add_ops(&shift::collect_bitwise_from_shift(&shift_ops))), + Box::new(|h| { + h.add_ops(&shift::collect_bitwise_from_shift(&shift_ops)); + // ARE_BYTES[shift[0], shift[1]] | 1 also fires on each chunk's padding rows. + // `.max(1)`: `chunks(0)` panics on an externally supplied zero row cap. + for chunk in shift_ops.chunks(shift_chunk.max(1)) { + h.bump_n( + BitwiseOperation::byte_op(BitwiseOperationType::AreBytes, 0, 0), + chunk_padding_rows(chunk.len()) as u64, + ); + } + }), Box::new(|h| { for op in &bytewise_ops { h.add_ops(&op.collect_bitwise_ops()); @@ -3385,7 +3397,7 @@ fn build_traces( let rows = bytewise::dedup_bytewise_rows(chunk); let ops: Vec<_> = rows.iter().map(|(_, mu)| mu_byte(*mu)).collect(); h.add_ops(&ops); - h.bump_n(mu_byte(0), dedup_padding_rows(rows.len()) as u64); + h.bump_n(mu_byte(0), chunk_padding_rows(rows.len()) as u64); } }), Box::new(|h| { From a85946e6a63bde1393962abf0ff86cb6a44dcace Mon Sep 17 00:00:00 2001 From: Nicole Date: Wed, 14 Oct 2026 13:00:36 -0300 Subject: [PATCH 16/29] Add missing multiplicity tests, guard zero row caps and fix stale docs --- prover/src/tables/branch.rs | 5 +- prover/src/tables/dvrm.rs | 2 +- prover/src/tables/keccak.rs | 5 +- prover/src/tables/keccak_rnd.rs | 14 ++-- prover/src/tables/local_to_global.rs | 5 +- prover/src/tables/mul.rs | 2 +- prover/src/tables/shift.rs | 8 +- prover/src/tables/trace_builder.rs | 84 +++++++++++++------- prover/src/tests/branch_multiplicity_poc.rs | 4 +- prover/src/tests/bytewise_tests.rs | 72 +++++++++++++++++ prover/src/tests/eq_tests.rs | 25 ++++++ prover/src/tests/keccak_rnd_tests.rs | 74 +++++++++++++---- prover/src/tests/mul_mu_carrier_poc.rs | 8 +- prover/src/tests/multiplicity_forgery_poc.rs | 7 +- prover/src/tests/shift_tests.rs | 68 +++++++++++++++- prover/src/tests/skip_empty_tables_tests.rs | 4 +- prover/src/tests/trace_builder_tests.rs | 2 +- 17 files changed, 308 insertions(+), 81 deletions(-) diff --git a/prover/src/tables/branch.rs b/prover/src/tables/branch.rs index 0913ef9f4..1d5c5569c 100644 --- a/prover/src/tables/branch.rs +++ b/prover/src/tables/branch.rs @@ -21,7 +21,7 @@ //! - `carry[0]`, `carry[1]`: Carries from 64-bit addition //! //! ## Bus Interactions -//! - Sender: ARE_BYTES (×1 for `[next_pc_low[1], 0]`, spec template `IS_BYTE`) +//! - Sender: ARE_BYTES (×1 for `[μ, next_pc_low[1]]`, spec `IS_BYTE<μ>` and `IS_BYTE` batched) //! - Sender: BYTE_ALU[AND] (×1 for masking LSB) //! - Sender: IS_HALFWORD (×3 for next_pc_high[0..3]) //! - Receiver: BRANCH (provides branch targets to CPU) @@ -253,8 +253,7 @@ pub fn generate_branch_trace( /// range check, batched (both have multiplicity 1) /// - **Sends** BYTE_ALU[AND] lookup for LSB masking /// (next_pc_low[0] = unmasked_low_byte & 254) -/// - **Sends** IS_HALFWORD lookups for next_pc_high[0..3] range checks and for -/// (next_pc_high limbs) +/// - **Sends** IS_HALFWORD lookups for the next_pc_high[0..2] range checks /// - **Receives** BRANCH lookups from CPU table pub fn bus_interactions() -> Vec { vec![ diff --git a/prover/src/tables/dvrm.rs b/prover/src/tables/dvrm.rs index b99ef461a..dff9b5e66 100644 --- a/prover/src/tables/dvrm.rs +++ b/prover/src/tables/dvrm.rs @@ -23,7 +23,7 @@ //! //! ## Bus Interactions //! - Sender: IS_HALF (×20: n, d, r, n_sub_r, q) -//! - Sender: ARE_BYTES (×2: μ_q, μ_r bounds) +//! - Sender: ARE_BYTES (×1: `[μ_q, μ_r]`, the batched multiplicity bounds) //! - Sender: MSB16 (×3 for sign extraction: n, d, r) //! - Sender: ALU (×3, on the unified bus: ×1 LT-flavored for `|r| < |d|`, //! ×2 MUL-flavored for `n - r = d * q` lo/hi) diff --git a/prover/src/tables/keccak.rs b/prover/src/tables/keccak.rs index 8c4c879b1..3fecd51a4 100644 --- a/prover/src/tables/keccak.rs +++ b/prover/src/tables/keccak.rs @@ -455,12 +455,13 @@ pub fn bus_interactions() -> Vec { // Single-source constraint set (ConstraintBuilder front-end) // ========================================================================= -/// The KECCAK core table's 51 transition constraints as a single [`ConstraintSet`]: +/// The KECCAK core table's 52 transition constraints as a single [`ConstraintSet`]: /// - idx 0-49: for `lane_idx ∈ 0..25`, the `ADD` carry pair (gated on `μ`) /// enforcing `state_ptr[lane] = addr + 8·lane_idx` (`addr` DWordBL, /// `state_ptr` DWordHL); /// - idx 50: `μ · carry_1 = 0` (top-lane no-overflow), where `carry_1` is the -/// high carry of `addr + 192 = state_ptr[24]`. +/// high carry of `addr + 192 = state_ptr[24]`; +/// - idx 51: `μ · (1 − μ) = 0` (MuIsBit). #[derive(Clone, Copy)] pub struct KeccakConstraints; diff --git a/prover/src/tables/keccak_rnd.rs b/prover/src/tables/keccak_rnd.rs index ce20810b3..1b4f0a318 100644 --- a/prover/src/tables/keccak_rnd.rs +++ b/prover/src/tables/keccak_rnd.rs @@ -852,7 +852,7 @@ fn halfword>( b.main(0, lo_col) + b.main(0, hi_col) * b.const_base(256) } -/// The KECCAK round table's 140 transition constraints as a single +/// The KECCAK round table's 141 transition constraints as a single /// [`ConstraintSet`]: /// /// * **20 IS_BIT** on the θ carry bits: for `x ∈ 0..5`, `hw ∈ 0..4`, @@ -868,6 +868,7 @@ fn halfword>( /// `in` is the `theta[x][y]` halfword, `left`/`right` the `rot_left`/ /// `rot_right` byte pairs (degree 2; the general form covers rnc = 0, which /// pins right = 0, left = in). +/// * **1 MuIsBit**: `μ · (1 − μ)` (degree 2). /// /// These identities replace the former θ/ρ HWSL bus lookups. Uniqueness of the /// (left, right) decomposition rests on the ARE_BYTES range checks bounding both @@ -938,12 +939,11 @@ impl ConstraintSet for KeccakRndConstraint } } - // MuIsBit — μ·(1−μ). Every other constraint and bus send is weighted by - // μ, so μ = 2, 7 or −1 is indistinguishable from μ = 1 at the constraint - // level, while on the bus a μ = −1 row inverts a range check and can hold - // a non-canonical (out-of-range) shift byte — the carrier mechanism. - // KECCAK_RND does not deduplicate (one row per round, μ ∈ {0,1}), so the - // bit constraint is honest and forbids μ = −1, closing the carrier. + // MuIsBit — μ·(1−μ). The shift identities and every bus send except the + // ARE_BYTES byte checks (multiplicity 1) are weighted by μ, so μ = 2, 7 + // or −1 would scale those sends, and μ = −1 would turn them into + // receives. KECCAK_RND does not deduplicate (one row per round, μ ∈ + // {0,1}), so the bit constraint is honest. let mu = b.main(0, cols::MU); let one = b.one(); b.emit_base(idx, mu.clone() * (one - mu)); diff --git a/prover/src/tables/local_to_global.rs b/prover/src/tables/local_to_global.rs index 3de72f62a..2b4d07f5f 100644 --- a/prover/src/tables/local_to_global.rs +++ b/prover/src/tables/local_to_global.rs @@ -51,8 +51,9 @@ //! Real rows carry `MU = 1`; the power-of-two padding rows carry `MU = 0`. Every //! interaction uses `Multiplicity::Column(MU)`, so padding rows fire nothing — //! we never rely on token self-cancellation (this is the standard pattern used -//! by every variable-length table). `MU` is self-enforced: dropping a real row -//! (`MU = 0`) breaks its telescoping link → bus imbalance. +//! by every variable-length table). `MU` is a bit (`MU·(1−MU) = 0` on the +//! epoch-local air), and dropping a real row (`MU = 0`) breaks its telescoping +//! link → bus imbalance. use std::collections::HashMap; diff --git a/prover/src/tables/mul.rs b/prover/src/tables/mul.rs index 295b3327d..a69ad244f 100644 --- a/prover/src/tables/mul.rs +++ b/prover/src/tables/mul.rs @@ -26,7 +26,7 @@ //! ## Bus Interactions //! - Sender: MSB16 (×2 for sign extraction) //! - Sender: IS_HALF (×16 for lhs/rhs input and lo/hi output range checks) -//! - Sender: ARE_BYTES (×2 for the μ_lo/μ_hi multiplicity bounds) +//! - Sender: ARE_BYTES (×1: `[μ_lo, μ_hi]`, the batched multiplicity bounds) //! - Sender: IS_B20 (×4 for carry range checks) //! - Receiver: ALU (×2 for lo and hi results — every MUL lookup, CPU //! MUL/MULH dispatch and dvrm's internal `d*q` consistency) diff --git a/prover/src/tables/shift.rs b/prover/src/tables/shift.rs index 7c37989c5..6885afaf9 100644 --- a/prover/src/tables/shift.rs +++ b/prover/src/tables/shift.rs @@ -14,8 +14,8 @@ //! - Shift decomposition (ALU-bus shift amount): `shift_b1` (idx 26, Byte = shift[1]), `shift_h1` (idx 27, Half = shift[2]), `shift_high` (idx 28, Word = shift[3]) //! - Multiplicity: `μ` //! -//! ## Bus Interactions (18 total) -//! - Senders: MSB16, BYTE_ALU[AND] (×3), ZERO, HWSL (×5), ARE_BYTES (×2), IS_HALFWORD (×5) +//! ## Bus Interactions (17 total) +//! - Senders: MSB16, BYTE_ALU[AND] (×3), ZERO, HWSL (×5), ARE_BYTES (×1: `[shift[0], shift[1]]`), IS_HALFWORD (×5) //! - Receiver: ALU (from CPU) use stark::lookup::{BusInteraction, BusValue, LinearTerm, Multiplicity, Packing}; @@ -1006,8 +1006,8 @@ pub fn collect_bitwise_from_shift(operations: &[ShiftOperation]) -> Vec bool { // Phase 4: All → Bitwise lookups // ============================================================================= -/// Collects bitwise lookups from LT operations (MSB16 and IS_HALFWORD). +/// Collects bitwise lookups from LT operations (MSB16, IS_HALFWORD and the +/// ARE_BYTES[μ, 0] multiplicity bound). /// /// MSB16 and the operand/difference IS_HALFWORD lookups are emitted once per raw /// op. The IS_BYTE[μ] multiplicity bound (`ARE_BYTES[μ, 0] | 1`) is counted once @@ -1736,7 +1737,7 @@ pub(crate) fn collect_bitwise_from_lt( // IS_BYTE[μ] | 1: every row sends its own μ once, padding rows (μ = 0) // included, over exactly the rows `generate_lt_trace` builds for each chunk. let mu_byte = |v: u64| BitwiseOperation::single_byte(BitwiseOperationType::AreBytes, v as u8); - for chunk in lt_ops.chunks(max_rows_lt) { + for chunk in op_chunks(lt_ops, max_rows_lt) { let rows = lt::dedup_lt_rows(chunk); bitwise_ops.extend(rows.iter().map(|(_, mu)| mu_byte(*mu))); bitwise_ops.extend(std::iter::repeat_n( @@ -1824,7 +1825,7 @@ pub(crate) fn collect_bitwise_from_mul( let mu_bytes = |a: u64, b: u64| { BitwiseOperation::byte_op(BitwiseOperationType::AreBytes, a as u8, b as u8) }; - for chunk in mul_ops.chunks(max_rows_mul) { + for chunk in op_chunks(mul_ops, max_rows_mul) { let rows = mul::dedup_mul_rows(chunk); bitwise_ops.extend(std::iter::repeat_n( mu_bytes(0, 0), @@ -1963,7 +1964,7 @@ pub(crate) fn collect_bitwise_from_dvrm( let mu_bytes = |a: u64, b: u64| { BitwiseOperation::byte_op(BitwiseOperationType::AreBytes, a as u8, b as u8) }; - for chunk in dvrm_ops.chunks(max_rows_dvrm) { + for chunk in op_chunks(dvrm_ops, max_rows_dvrm) { let rows = dvrm::dedup_dvrm_rows(chunk); bitwise_ops.extend(std::iter::repeat_n( mu_bytes(0, 0), @@ -2041,7 +2042,8 @@ pub(crate) fn collect_bitwise_from_dvrm( /// Collects bitwise lookups from BRANCH operations. /// /// BRANCH sends: -/// - ARE_BYTES[next_pc_low[1], 0] - range check bits 8-15 +/// - ARE_BYTES[μ, next_pc_low[1]] | 1 - the μ bound batched with the bits 8-15 +/// range check, once per row (padding rows included) /// - BYTE_ALU[AND, unmasked_low_byte, 254, next_pc_low[0]] - LSB masking /// - IS_HALFWORD[next_pc_high[0..3]] - range checks for bits 16-63 /// @@ -2100,7 +2102,7 @@ pub(crate) fn collect_bitwise_from_branch( let row_bytes = |mu: u64, low_1: u8| { BitwiseOperation::byte_op(BitwiseOperationType::AreBytes, mu as u8, low_1) }; - for chunk in branch_ops.chunks(max_rows_branch) { + for chunk in op_chunks(branch_ops, max_rows_branch) { let rows = branch::dedup_branch_rows(chunk); bitwise_ops.extend( rows.iter() @@ -2123,6 +2125,44 @@ fn chunk_padding_rows(rows: usize) -> usize { rows.next_power_of_two().max(4) - rows } +/// The per-instance op slices the BITWISE collectors walk, mirroring +/// `chunk_and_generate_optional`. `.max(1)`: `chunks(0)` panics even on an empty +/// slice, while the trace builder skips a table with no ops, so a zero row cap +/// must not panic here either. +fn op_chunks(ops: &[T], max_rows: usize) -> std::slice::Chunks<'_, T> { + ops.chunks(max_rows.max(1)) +} + +/// SHIFT's `ARE_BYTES[shift[0], shift[1]] | 1` on the padding rows of every +/// `max_rows` instance: each one sends `ARE_BYTES[0, 0]` (padding cells are 0). +/// The real rows are counted by `shift::collect_bitwise_from_shift`. +pub(crate) fn shift_padding_are_bytes(shift_ops: &[ShiftOperation], max_rows: usize) -> u64 { + op_chunks(shift_ops, max_rows) + .map(|chunk| chunk_padding_rows(chunk.len()) as u64) + .sum() +} + +/// BYTEWISE's IS_BYTE[μ] bound (`ARE_BYTES[μ, 0] | 1`): every row of each +/// `max_rows` instance sends its own μ once, padding rows (μ = 0) included, over +/// exactly the rows `generate_bytewise_trace` builds. The per-op BYTE_ALU lookups +/// are counted by `bytewise::BytewiseOperation::collect_bitwise_ops`. +pub(crate) fn collect_bytewise_mu_bounds( + bytewise_ops: &[bytewise::BytewiseOperation], + max_rows: usize, +) -> Vec { + let mu_byte = |v: u64| BitwiseOperation::single_byte(BitwiseOperationType::AreBytes, v as u8); + let mut bounds = Vec::new(); + for chunk in op_chunks(bytewise_ops, max_rows) { + let rows = bytewise::dedup_bytewise_rows(chunk); + bounds.extend(rows.iter().map(|(_, mu)| mu_byte(*mu))); + bounds.extend(std::iter::repeat_n( + mu_byte(0), + chunk_padding_rows(rows.len()), + )); + } + bounds +} + /// Generates ARE_BYTES ops for CPU padding rows. /// /// CPU padding rows have all byte columns = 0 (RS1=0, RS2=0, RD=0, etc.). @@ -3344,9 +3384,9 @@ fn build_traces( // so every source can be collected in parallel and the per-worker histograms summed in // any order. // - // MUL/DVRM dedup their per-unique bit-gated lookups PER CHIP INSTANCE, so pass the same - // chunk size used to split them into instances so multiplicities match the per-instance - // sends. MEMW_R sends IS_HALFWORD[timestamp_0 - old_timestamp_lo - 1]. PAGE does a + // LT/MUL/DVRM/BRANCH/BYTEWISE count their per-row lookups (μ bound, bit-gated + // MSB16/ZERO) and SHIFT its padding rows PER CHIP INSTANCE, so pass the same chunk size + // used to split them into instances so multiplicities match the per-instance sends. MEMW_R sends IS_HALFWORD[timestamp_0 - old_timestamp_lo - 1]. PAGE does a // batched ARE_BYTES[init, fini] per row (skipped in continuation epochs, which the L2G // table owns). COMMIT sends AreBytes+IsHalfword; KECCAK_RND sends XOR/AND/ARE_BYTES/HWSL; // HINT sends ARE_BYTES for its 32 output cells. @@ -3373,32 +3413,16 @@ fn build_traces( Box::new(|h| h.add_ops(&collect_bitwise_from_branch(&branch_ops, branch_chunk))), Box::new(|h| { h.add_ops(&shift::collect_bitwise_from_shift(&shift_ops)); - // ARE_BYTES[shift[0], shift[1]] | 1 also fires on each chunk's padding rows. - // `.max(1)`: `chunks(0)` panics on an externally supplied zero row cap. - for chunk in shift_ops.chunks(shift_chunk.max(1)) { - h.bump_n( - BitwiseOperation::byte_op(BitwiseOperationType::AreBytes, 0, 0), - chunk_padding_rows(chunk.len()) as u64, - ); - } + h.bump_n( + BitwiseOperation::byte_op(BitwiseOperationType::AreBytes, 0, 0), + shift_padding_are_bytes(&shift_ops, shift_chunk), + ); }), Box::new(|h| { for op in &bytewise_ops { h.add_ops(&op.collect_bitwise_ops()); } - // IS_BYTE[μ] | 1: every row sends its own μ once, padding rows (μ = 0) - // included, over exactly the rows `generate_bytewise_trace` builds for - // each chunk. Mirrors the LT/MUL/DVRM/BRANCH bound. - // `.max(1)`: `chunks(0)` panics, and `bytewise_chunk` is an - // externally supplied row cap that could be 0. - let mu_byte = - |v: u64| BitwiseOperation::single_byte(BitwiseOperationType::AreBytes, v as u8); - for chunk in bytewise_ops.chunks(bytewise_chunk.max(1)) { - let rows = bytewise::dedup_bytewise_rows(chunk); - let ops: Vec<_> = rows.iter().map(|(_, mu)| mu_byte(*mu)).collect(); - h.add_ops(&ops); - h.bump_n(mu_byte(0), chunk_padding_rows(rows.len()) as u64); - } + h.add_ops(&collect_bytewise_mu_bounds(&bytewise_ops, bytewise_chunk)); }), Box::new(|h| { for op in &eq_ops { diff --git a/prover/src/tests/branch_multiplicity_poc.rs b/prover/src/tests/branch_multiplicity_poc.rs index 989af3d40..39cfcd4a5 100644 --- a/prover/src/tests/branch_multiplicity_poc.rs +++ b/prover/src/tests/branch_multiplicity_poc.rs @@ -1,8 +1,8 @@ //! BRANCH's `μ` must be a bounded, non-negative multiplicity — same bug and fix //! as LT (`lt_multiplicity_poc`). //! -//! BRANCH weights its BRANCH receive and all range lookups (ARE_BYTES, -//! BYTE_ALU[AND], IS_HALFWORD ×3) by one free `μ`. A `μ = −1` twin of an honest +//! Before the fix, BRANCH weighted its BRANCH receive and all range lookups +//! (ARE_BYTES, BYTE_ALU[AND], IS_HALFWORD ×3) by one free `μ`. A `μ = −1` twin of an honest //! row cancels it on the BRANCH bus while *receiving* its range lookups; split //! the high word non-canonically (`high_1 = 2^16, high_2 -= 1`, same packed //! `next_pc`) and the twin absorbs an out-of-range `IS_HALFWORD[2^16]` sent by diff --git a/prover/src/tests/bytewise_tests.rs b/prover/src/tests/bytewise_tests.rs index 619679ba2..bb3db2b38 100644 --- a/prover/src/tests/bytewise_tests.rs +++ b/prover/src/tests/bytewise_tests.rs @@ -87,3 +87,75 @@ fn test_bus_interactions_shape() { assert!(!alu[0].is_sender, "ALU is a receiver for BYTEWISE"); assert_eq!(alu[0].values.len(), 4); // [a, b, op, res] } + +/// Splitting: a count above `MU_MAX` spreads over rows that each stay within the +/// bound and together preserve the total; exactly `MU_MAX` still fits one row. +#[test] +fn test_dedup_bytewise_rows_splits_counts_above_mu_max() { + use crate::tables::bytewise::{MU_MAX, dedup_bytewise_rows}; + let op = BytewiseOperation::new(0xF0, 0x3C, alu_op::AND); + let ops: Vec<_> = std::iter::repeat_n(op.clone(), 2 * MU_MAX as usize + 7).collect(); + let mut rows: Vec = dedup_bytewise_rows(&ops) + .iter() + .map(|(_, mu)| *mu) + .collect(); + rows.sort(); + assert_eq!(rows, vec![7, MU_MAX, MU_MAX]); + + let ops: Vec<_> = std::iter::repeat_n(op, MU_MAX as usize).collect(); + assert_eq!(dedup_bytewise_rows(&ops).len(), 1); + assert!(dedup_bytewise_rows(&[]).is_empty()); + + // The generated trace never holds a μ above the bound. + let ops: Vec<_> = std::iter::repeat_n( + BytewiseOperation::new(1, 2, alu_op::XOR), + MU_MAX as usize + 1, + ) + .collect(); + let trace = generate_bytewise_trace(&ops); + let mut total = 0u64; + for r in 0..trace.num_rows() { + let mu = trace.get_main(r, cols::MU).to_raw(); + assert!(mu <= MU_MAX); + total += mu; + } + assert_eq!(total, ops.len() as u64); +} + +/// Consistency: the BITWISE collector tallies exactly the IS_BYTE[μ] lookups the +/// generated BYTEWISE instances send (each row's μ once, padding rows as μ = 0), +/// across several small instances and for a row split by `MU_MAX`. +#[test] +fn test_bytewise_mu_bound_lookups_match_collector() { + use crate::tables::bitwise::BitwiseOperationType; + use crate::tables::bytewise::MU_MAX; + use crate::tables::trace_builder::collect_bytewise_mu_bounds; + use std::collections::HashMap; + + let distinct = [ + BytewiseOperation::new(1, 2, alu_op::AND), + BytewiseOperation::new(0xFF00, 0x0FF0, alu_op::OR), + BytewiseOperation::new(u64::MAX, 0x1234, alu_op::XOR), + ]; + let small: Vec<_> = distinct.iter().cycle().take(11).cloned().collect(); + let split: Vec<_> = std::iter::repeat_n(distinct[1].clone(), MU_MAX as usize + 2) + .chain(distinct.iter().cloned()) + .collect(); + + for (ops, chunk) in [(small, 4usize), (split, 1 << 20)] { + let mut collected: HashMap<(u8, u8), usize> = HashMap::new(); + for b in collect_bytewise_mu_bounds(&ops, chunk) { + assert!(b.lookup_type == BitwiseOperationType::AreBytes); + *collected.entry((b.x, b.y)).or_default() += 1; + } + let mut expected: HashMap<(u8, u8), usize> = HashMap::new(); + for c in ops.chunks(chunk) { + let t = generate_bytewise_trace(c); + for r in 0..t.num_rows() { + let mu = t.get_main(r, cols::MU).to_raw() as u8; + *expected.entry((mu, 0)).or_default() += 1; + } + } + assert_eq!(collected, expected, "instances of {chunk}"); + } +} diff --git a/prover/src/tests/eq_tests.rs b/prover/src/tests/eq_tests.rs index d1495dfb7..69863cffe 100644 --- a/prover/src/tests/eq_tests.rs +++ b/prover/src/tests/eq_tests.rs @@ -124,3 +124,28 @@ fn test_bus_interactions_shape() { // [a, b, flags, res, 0] — the ALU output is DWordWL ([res, 0]). assert_eq!(alu[0].values.len(), 5); } + +/// Soundness regression: μ must be a bit (spec `IS_BIT[μ]`). EQ does not +/// deduplicate, so real rows have μ = 1 and padding rows μ = 0; μ = 2 or −1 +/// must be rejected by MuIsBit (idx 4). +#[test] +fn test_mu_is_bit_constrained() { + use crate::tables::eq::EqConstraints; + use crate::test_utils::{busless_air, validate_busless}; + + let honest = generate_eq_trace(&[EqOperation::new(5, 3, false), EqOperation::new(7, 7, true)]); + let air = busless_air(cols::NUM_COLUMNS, EqConstraints); + assert!( + validate_busless(&air, &honest), + "honest trace must validate" + ); + + for bad in [FE::from(2u64), -FE::one()] { + let mut t = honest.clone(); + t.main_table.set(0, cols::MU, bad); + assert!( + !validate_busless(&air, &t), + "μ ∉ {{0,1}} must be rejected by MuIsBit" + ); + } +} diff --git a/prover/src/tests/keccak_rnd_tests.rs b/prover/src/tests/keccak_rnd_tests.rs index 8b42f10eb..22c0a0937 100644 --- a/prover/src/tests/keccak_rnd_tests.rs +++ b/prover/src/tests/keccak_rnd_tests.rs @@ -56,17 +56,15 @@ fn test_pi_virtual_matches_rotate() { } } -// Soundness regression: μ must be a bit. Every other constraint is `μ · identity` -// (or `μ · x·(1−x)`), so μ = 2, 7 or −1 used to be indistinguishable from μ = 1 at -// the constraint level, while on the bus μ weights (and with −1 inverts) every -// send — so a μ = −1 row could invert a range check and act as a carrier (the -// bytes are not pinned, see `are_bytes_carrier_poc`). The chip does not -// deduplicate, so `MuIsBit` (μ·(1−μ)=0) is the right fix (like SHIFT/LOAD): it -// forbids μ = −1 and closes the carrier. +// Soundness regression: μ must be a bit. The shift identities are `μ · identity`, +// and every bus send except the ARE_BYTES byte checks (multiplicity 1) is +// weighted by μ, so μ = 2, 7 or −1 used to be indistinguishable from μ = 1 at the +// constraint level while scaling those sends (and with −1 turning them into +// receives). The chip does not deduplicate, so `MuIsBit` (μ·(1−μ)=0) is the right +// fix (like SHIFT/LOAD). // // DEFENSIVE: this is a constraint-presence regression (μ ∉ {0,1} is rejected), not -// an end-to-end forgery; the carrier was confirmed structurally (bytes not pinned, -// see `are_bytes_carrier_poc`), not demonstrated. +// an end-to-end forgery. mod mu_bit_regression { use crate::tables::keccak_rnd::{ KeccakRndConstraints, KeccakRoundOperation, cols, generate_keccak_rnd_trace, @@ -105,14 +103,14 @@ mod mu_bit_regression { } } -// Why MuIsBit is necessary: the θ shift identity reads `cxz_left` as a HALFWORD -// (`byte_lo + 256·byte_hi`), while ARE_BYTES range-checks the two bytes. So the -// bytes are NOT pinned by the constraints — a non-canonical split keeps the -// halfword (and the identity) unchanged while sending an out-of-range byte to -// ARE_BYTES. A μ = −1 row would invert that send (carrier). With `MuIsBit` -// forbidding μ = −1 the carrier is closed, but this test shows the underlying -// gap — the constraints still do not range-constrain the bytes; a μ=−1 row could -// hold an out-of-range byte and absorb another table's out-of-range ARE_BYTES. +// Why the byte checks must not depend on μ: the θ shift identity reads +// `cxz_left` as a HALFWORD (`byte_lo + 256·byte_hi`), while ARE_BYTES +// range-checks the two bytes. So the bytes are NOT pinned by the constraints — a +// non-canonical split keeps the halfword (and the identity) unchanged while +// sending an out-of-range byte to ARE_BYTES. The bus catches it: the byte checks +// are sent with multiplicity 1 (spec #1017), so no μ can invert them and the +// out-of-range tuple stays unmatched. This test documents the constraint-level +// gap. mod are_bytes_carrier_poc { use crate::tables::keccak_rnd::{ KeccakRndConstraints, KeccakRoundOperation, cols, generate_keccak_rnd_trace, @@ -168,3 +166,45 @@ mod are_bytes_carrier_poc { ); } } + +// The θ carry bit `Cxz_right` is IS_BIT-checked on every row, not only when +// μ = 1 (spec #1017 dropped the `cond = μ`). A padding row (μ = 0) holding +// `Cxz_right = 2` must be rejected. +mod cxz_right_bit_regression { + use crate::tables::keccak_rnd::{ + KeccakRndConstraints, KeccakRoundOperation, cols, generate_keccak_rnd_trace, + }; + use crate::tables::types::FE; + use crate::test_utils::{busless_air, validate_busless}; + use executor::vm::instruction::execution::keccak_f1600; + + #[test] + fn cxz_right_is_bit_on_padding_rows() { + let input = [0x0102030405060708u64; 25]; + let mut output = input; + keccak_f1600(&mut output); + let honest = generate_keccak_rnd_trace(&[KeccakRoundOperation { + timestamp: 42, + input, + output, + }]); + let air = busless_air(cols::NUM_COLUMNS, KeccakRndConstraints); + assert!( + validate_busless(&air, &honest), + "honest trace must validate" + ); + + // 24 round rows padded to 32: the last row is padding (μ = 0). + let pad = honest.main_table.height - 1; + assert_eq!(*honest.main_table.get(pad, cols::MU), FE::zero()); + + for bad in [FE::from(2u64), -FE::one()] { + let mut t = honest.clone(); + t.main_table.set(pad, cols::cxz_right_bit(0, 0), bad); + assert!( + !validate_busless(&air, &t), + "Cxz_right ∉ {{0,1}} must be rejected even on a μ = 0 row" + ); + } + } +} diff --git a/prover/src/tests/mul_mu_carrier_poc.rs b/prover/src/tests/mul_mu_carrier_poc.rs index 4f5cb7b9c..2ea0468a3 100644 --- a/prover/src/tests/mul_mu_carrier_poc.rs +++ b/prover/src/tests/mul_mu_carrier_poc.rs @@ -1,4 +1,4 @@ -//! Adversarial check of the MUL `μ` bound (`fix/multiplicity-soundness`). +//! Adversarial check of the MUL `μ` bound. //! //! The fix bounds `μ_lo`/`μ_hi` with one `ARE_BYTES[μ_lo, μ_hi]` with //! multiplicity 1 (spec #1017). Its first version was `IS_HALF[μ]` weighted *by @@ -15,11 +15,11 @@ //! optionally sends `IS_HALFWORD[p−1]`, standing in for "some other table emits //! an out-of-range halfword with +1". //! -//! Result characterises the fix: +//! Result: //! - no carrier → the `μ_hi = −1` forgery is REJECTED (the known attack is //! blocked; same as `multiplicity_forgery_poc`); -//! - with carrier → whether it verifies says whether the bound is unconditional -//! or only holds while no table leaks an out-of-range halfword. +//! - with carrier → still REJECTED: the bound no longer rides IS_HALFWORD, so an +//! out-of-range halfword leaked by another table cannot cancel it. use std::collections::HashMap; diff --git a/prover/src/tests/multiplicity_forgery_poc.rs b/prover/src/tests/multiplicity_forgery_poc.rs index 74a68fb91..9d54d6a69 100644 --- a/prover/src/tests/multiplicity_forgery_poc.rs +++ b/prover/src/tests/multiplicity_forgery_poc.rs @@ -1,7 +1,6 @@ -//! End-to-end regression tests for unconstrained multiplicities: MUL's -//! `μ_lo`/`μ_hi` and DVRM's `μ_q`/`μ_r` must be bounded and non-negative, and -//! SHIFT's `zbs` and LOAD's `μ` must be bits, and LT's `μ` must be bounded and -//! non-negative. +//! End-to-end regression tests for unconstrained multiplicities: the dedup +//! multiplicities (MUL's `μ_lo`/`μ_hi`, DVRM's `μ_q`/`μ_r`, LT's `μ`) must be +//! bounded and non-negative, and SHIFT's `zbs` and LOAD's `μ` must be bits. //! //! **MUL / DVRM.** Both chips send every range check (and DVRM its MUL/LT/ZERO checks) with //! multiplicity `μ_a + μ_b`, the row's total lookup count. Before the fix neither diff --git a/prover/src/tests/shift_tests.rs b/prover/src/tests/shift_tests.rs index 88b5bc465..b32cf6d49 100644 --- a/prover/src/tests/shift_tests.rs +++ b/prover/src/tests/shift_tests.rs @@ -211,7 +211,7 @@ mod zbs_mu_bit_regression { } } -// Separate preexisting bug (NOT a multiplicity bug, not fixed by this PR): +// Separate preexisting bug (not a multiplicity bug): // Soundness regression: `is_negative` must be 0 on unsigned rows. It is pinned // to the real MSB via the MSB16 bus only when `signed = 1` (multiplicity // `signed`), but `shifted_half` fills with `extension = 65535·is_negative` @@ -262,3 +262,69 @@ mod is_negative_unsigned_poc { ); } } + +/// Consistency: the BITWISE collector tallies exactly the batched +/// `ARE_BYTES[shift[0], shift[1]] | 1` sends of the generated SHIFT instances — +/// each real row once and each padding row as `ARE_BYTES[0, 0]` — across single +/// and multiple instances, with and without padding. +#[test] +fn test_shift_byte_checks_match_collector() { + use std::collections::HashMap; + + use stark::lookup::{BusValue, Multiplicity, Packing}; + + use crate::tables::bitwise::BitwiseOperationType; + use crate::tables::shift::{ShiftOperation, collect_bitwise_from_shift, generate_shift_trace}; + use crate::tables::trace_builder::shift_padding_are_bytes; + use crate::tables::types::BusId; + + // The AIR sends one ARE_BYTES[shift[0], shift[1]] with multiplicity 1. + let packed = |v: &BusValue| match v { + BusValue::Packed { + start_column, + packing: Packing::Direct, + } => Some(*start_column), + _ => None, + }; + let are_bytes: Vec<_> = bus_interactions() + .into_iter() + .filter(|i| i.bus_id == BusId::AreBytes as u64) + .collect(); + assert_eq!(are_bytes.len(), 1); + assert!(are_bytes[0].is_sender && matches!(are_bytes[0].multiplicity, Multiplicity::One)); + assert_eq!( + are_bytes[0].values.iter().map(packed).collect::>(), + [Some(cols::SHIFT_AMOUNT), Some(cols::SHIFT_B1)] + ); + + let distinct = [ + ShiftOperation::new(0x1234, 3, false, false, false), + ShiftOperation::new(u64::MAX, 0x0305, true, true, false), + ShiftOperation::new(7, 0xAB_0110, true, false, true), + ]; + for (n, chunk) in [(11usize, 4usize), (5, 1 << 20), (8, 4), (1, 4)] { + let ops: Vec<_> = distinct.iter().cycle().take(n).cloned().collect(); + + let mut collected: HashMap<(u8, u8), u64> = HashMap::new(); + for b in collect_bitwise_from_shift(&ops) + .iter() + .filter(|b| b.lookup_type == BitwiseOperationType::AreBytes) + { + *collected.entry((b.x, b.y)).or_default() += 1; + } + *collected.entry((0, 0)).or_default() += shift_padding_are_bytes(&ops, chunk); + collected.retain(|_, c| *c != 0); + + let mut expected: HashMap<(u8, u8), u64> = HashMap::new(); + for c in ops.chunks(chunk) { + let t = generate_shift_trace(c); + for r in 0..t.num_rows() { + let byte = |col| t.get_main(r, col).to_raw() as u8; + *expected + .entry((byte(cols::SHIFT_AMOUNT), byte(cols::SHIFT_B1))) + .or_default() += 1; + } + } + assert_eq!(collected, expected, "{n} ops in instances of {chunk}"); + } +} diff --git a/prover/src/tests/skip_empty_tables_tests.rs b/prover/src/tests/skip_empty_tables_tests.rs index 848ad029e..428a1a738 100644 --- a/prover/src/tests/skip_empty_tables_tests.rs +++ b/prover/src/tests/skip_empty_tables_tests.rs @@ -375,8 +375,8 @@ fn dropping_a_used_table_through_the_real_verifier_is_rejected() { /// enforced that this stays true. /// /// One exception, by bus: `ARE_BYTES` byte checks the spec gives no `cond` -/// (default 1) — the μ bounds of the dedup tables (spec #1017) and KECCAK_RND's -/// rotation bytes. A padding row sends them as `ARE_BYTES[0, 0]`, which is in +/// (default 1) — the μ bounds of the dedup tables (spec #1017), SHIFT's +/// `shift[0..1]` bytes and KECCAK_RND's θ/ρ bytes. A padding row sends them as `ARE_BYTES[0, 0]`, which is in /// range, and their only counterparty is BITWISE, whose multiplicity columns are /// free witnesses, so the contribution is absorbed whichever way an unused chip /// went; and an optional table with no ops is never shipped diff --git a/prover/src/tests/trace_builder_tests.rs b/prover/src/tests/trace_builder_tests.rs index ca5426dce..7bc9026d4 100644 --- a/prover/src/tests/trace_builder_tests.rs +++ b/prover/src/tests/trace_builder_tests.rs @@ -769,7 +769,7 @@ mod keccak_tests { assert_eq!( keccak_rnd::KeccakRndConstraints.meta().len(), 141, - "KECCAK_RND: 20 IS_BIT(μ; Cxz_right_bit) + 20 θ + 100 ρ inline shift identities + MuIsBit" + "KECCAK_RND: 20 IS_BIT(Cxz_right_bit) + 20 θ + 100 ρ inline shift identities + MuIsBit" ); } } From 363b32c740332620148911cf65915138160a621c Mon Sep 17 00:00:00 2001 From: Nicole Date: Thu, 15 Oct 2026 10:59:03 -0300 Subject: [PATCH 17/29] Reference the SIGN template in SHIFT is_negative comments --- prover/src/tables/shift.rs | 30 +++++++++++++++--------------- prover/src/tests/shift_tests.rs | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/prover/src/tables/shift.rs b/prover/src/tables/shift.rs index 6885afaf9..69c8a685b 100644 --- a/prover/src/tables/shift.rs +++ b/prover/src/tables/shift.rs @@ -215,13 +215,12 @@ impl ShiftOperation { let left = !self.direction; let right = self.direction; - // is_negative is the MSB of in[3] BUT gated by `signed`. The SHIFT - // AIR constrains IS_NEGATIVE via the MSB16 bus (SHIFT-C14) only when - // `signed = 1` — for `signed = 0` IS_NEGATIVE is free, so we set it - // to zero. This makes `extension = 65535 * is_negative = 0` for SRL, - // so the extension contribution in `shifted_half` naturally - // vanishes (zero fill) — matching RISC-V SRL semantics regardless of - // the top-bit value of the input. + // is_negative is the MSB of in[3] BUT gated by `signed` (SIGN template): + // MSB16[in[3]] → is_negative when `signed = 1` (SHIFT-C14) and + // `(1 - signed) * is_negative = 0` (idx 21) otherwise. This makes + // `extension = 65535 * is_negative = 0` for SRL, so the extension + // contribution in `shifted_half` vanishes (zero fill) — matching RISC-V + // SRL semantics regardless of the top-bit value of the input. let is_negative = self.signed && (self.in_halves[3] >> 15) & 1 == 1; let extension: u16 = if is_negative { 0xFFFF } else { 0 }; @@ -422,7 +421,8 @@ pub fn generate_shift_trace( pub fn bus_interactions() -> Vec { let mut interactions = Vec::with_capacity(15); - // SHIFT-C14: MSB16[in[3]] → is_negative | signed + // SHIFT-C14: MSB16[in[3]] → is_negative | signed (SIGN template; its + // `(1 - signed) * is_negative = 0` half is constraint idx 21) interactions.push(BusInteraction::sender( BusId::Msb16, Multiplicity::Column(cols::SIGNED), @@ -904,13 +904,13 @@ impl ConstraintSet for ShiftConstraints { let one = b.one(); b.emit_base(20, mu.clone() * (one - mu)); - // idx 21: UnsignedIsNotNegative — (1 - signed) * is_negative. On a signed - // row `is_negative` is pinned to the real MSB via the MSB16 bus (multiplicity - // `signed`); on an unsigned row nothing constrains it, yet `shifted_half` - // fills with `extension = 65535 * is_negative` without gating on `signed`. - // So an unsigned right shift (SRL) could set `is_negative = 1` and forge a - // sign-filled result (e.g. `srl 0, 16 = 0xFFFF_0000_0000_0000`). This pins - // it to 0 when `signed = 0`. + // idx 21: UnsignedIsNotNegative — (1 - signed) * is_negative, the second + // half of the spec's SIGN template (the first is the MSB16 lookup, which + // pins `is_negative` to the real MSB only when `signed = 1`). Without it, + // `shifted_half` fills with `extension = 65535 * is_negative` without + // gating on `signed`, so an unsigned right shift (SRL) could set + // `is_negative = 1` and forge a sign-filled result (e.g. + // `srl 0, 16 = 0xFFFF_0000_0000_0000`). let signed = b.main(0, cols::SIGNED); let is_negative = b.main(0, cols::IS_NEGATIVE); let one = b.one(); diff --git a/prover/src/tests/shift_tests.rs b/prover/src/tests/shift_tests.rs index b32cf6d49..4b98bc69f 100644 --- a/prover/src/tests/shift_tests.rs +++ b/prover/src/tests/shift_tests.rs @@ -217,7 +217,7 @@ mod zbs_mu_bit_regression { // `signed`), but `shifted_half` fills with `extension = 65535·is_negative` // without gating on `signed`, so an unsigned right shift (SRL) could set // `is_negative = 1` and sign-fill the result. Closed by idx 21 -// `(1 − signed)·is_negative = 0`. +// `(1 − signed)·is_negative = 0`, the second half of the spec's SIGN template. mod is_negative_unsigned_poc { use crate::tables::shift::{ShiftConstraints, ShiftOperation, cols, generate_shift_trace}; use crate::tables::types::{FE, GoldilocksExtension, GoldilocksField}; From d329077daa6ff5579284b9cc4e21226afcbca01c Mon Sep 17 00:00:00 2001 From: Nicole Date: Thu, 15 Oct 2026 11:36:32 -0300 Subject: [PATCH 18/29] Require padding-safe values in the #977 byte check exemption --- prover/src/tests/skip_empty_tables_tests.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/prover/src/tests/skip_empty_tables_tests.rs b/prover/src/tests/skip_empty_tables_tests.rs index 428a1a738..0549562fd 100644 --- a/prover/src/tests/skip_empty_tables_tests.rs +++ b/prover/src/tests/skip_empty_tables_tests.rs @@ -376,8 +376,10 @@ fn dropping_a_used_table_through_the_real_verifier_is_rejected() { /// /// One exception, by bus: `ARE_BYTES` byte checks the spec gives no `cond` /// (default 1) — the μ bounds of the dedup tables (spec #1017), SHIFT's -/// `shift[0..1]` bytes and KECCAK_RND's θ/ρ bytes. A padding row sends them as `ARE_BYTES[0, 0]`, which is in -/// range, and their only counterparty is BITWISE, whose multiplicity columns are +/// `shift[0..1]` bytes and KECCAK_RND's θ/ρ bytes. A padding row sends them as +/// `ARE_BYTES[0, 0]`, which is in range — the test checks this by requiring each +/// value to vanish on an all-zero row (columns only, no nonzero constant term) — +/// and their only counterparty is BITWISE, whose multiplicity columns are /// free witnesses, so the contribution is absorbed whichever way an unused chip /// went; and an optional table with no ops is never shipped /// (`chunk_and_generate_optional`/`generate_optional` return no table), so the @@ -389,6 +391,7 @@ fn no_optional_table_uses_a_constant_multiplicity() { branch, bytewise, commit, cpu32, dvrm, ecdas, ecsm, eq, hint, keccak, keccak_rnd, load, lt, memw, memw_aligned, mul, shift, store, }; + use stark::lookup::{BusValue, LinearTerm}; let optional: [(&str, Vec); 18] = [ ("LT", lt::bus_interactions()), ("MEMW", memw::bus_interactions()), @@ -415,8 +418,15 @@ fn no_optional_table_uses_a_constant_multiplicity() { "{name} declares no bus interactions" ); for (i, interaction) in interactions.iter().enumerate() { + let zero_on_padding = interaction.values.iter().all(|value| match value { + BusValue::Packed { .. } => true, + BusValue::Linear(terms) => terms + .iter() + .all(|term| !matches!(term, LinearTerm::Constant(c) if *c != 0)), + }); let byte_check = interaction.bus_id == u64::from(crate::tables::types::BusId::AreBytes) - && interaction.is_sender; + && interaction.is_sender + && zero_on_padding; assert!( byte_check || !matches!(interaction.multiplicity, stark::lookup::Multiplicity::One), "{name} interaction {i} uses a constant multiplicity, so its padding rows \ From b8824bdcda665b94aead6d4c7b747f36ade7ff90 Mon Sep 17 00:00:00 2001 From: Nicole Date: Thu, 15 Oct 2026 11:36:52 -0300 Subject: [PATCH 19/29] Update the max rows width table and wrap a long SHIFT doc line --- prover/src/tables/mod.rs | 24 +++++++++++++----------- prover/src/tables/shift.rs | 3 ++- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/prover/src/tables/mod.rs b/prover/src/tables/mod.rs index f1a899f56..4f125a0a2 100644 --- a/prover/src/tables/mod.rs +++ b/prover/src/tables/mod.rs @@ -64,30 +64,32 @@ pub const STATIC_BLOWUP_FACTORS: &[u8] = &[2, 4, 8]; /// /// Effective width = main_cols + 3 × bus_interactions (extension field = 3× cost). /// MEMW (effective width 127) at 2^19 is the baseline; other tables are scaled -/// proportionally: max_rows = (127 × 2^19) / effective_width, rounded to 2^N. +/// proportionally: max_rows = (127 × 2^19) / effective_width, rounded to 2^N and +/// capped at 2^20. /// (* MEMW_A formula gives 2^20, but set to 2^19 to match MEMW chunk geometry; /// benchmarks show better parallel throughput with smaller chunks.) +/// († MUL formula gives 2^19; kept at 2^20, not re-tuned since its width grew.) /// /// | Table | Main | Bus | Eff.width | Max rows | /// |---------|------|-----|-----------|----------| /// | MEMW | 49 | 26 | 127 | 2^19 | /// | MEMW_A | 29 | 20 | 89 | 2^19 * | -/// | CPU | 74 | 40 | 194 | 2^19 | -/// | DVRM | 34 | 34 | 136 | 2^19 | -/// | MUL | 26 | 16 | 74 | 2^20 | -/// | LT | 15 | 9 | 42 | 2^20 | -/// | SHIFT | 27 | 15 | 72 | 2^20 | +/// | CPU | 38 | 20 | 98 | 2^19 | +/// | DVRM | 34 | 35 | 139 | 2^19 | +/// | MUL | 26 | 25 | 101 | 2^20 † | +/// | LT | 17 | 10 | 47 | 2^20 | +/// | SHIFT | 29 | 17 | 80 | 2^20 | /// | LOAD | 18 | 5 | 33 | 2^20 | /// | BRANCH | 14 | 6 | 32 | 2^20 | /// | MEMW_R | 10 | 7 | 31 | 2^20 | pub mod max_rows { - pub const CPU: usize = 1 << 19; // 524,288 — eff. width 194 + pub const CPU: usize = 1 << 19; // 524,288 — eff. width 98 pub const MEMW: usize = 1 << 19; // 524,288 — eff. width 127 (baseline) pub const MEMW_A: usize = 1 << 19; // 524,288 — eff. width 89 - pub const DVRM: usize = 1 << 19; // 524,288 — eff. width 136 - pub const MUL: usize = 1 << 20; // 1,048,576 — eff. width 74 - pub const LT: usize = 1 << 20; // 1,048,576 — eff. width 42 - pub const SHIFT: usize = 1 << 20; // 1,048,576 — eff. width 72 + pub const DVRM: usize = 1 << 19; // 524,288 — eff. width 139 + pub const MUL: usize = 1 << 20; // 1,048,576 — eff. width 101 + pub const LT: usize = 1 << 20; // 1,048,576 — eff. width 47 + pub const SHIFT: usize = 1 << 20; // 1,048,576 — eff. width 80 pub const LOAD: usize = 1 << 20; // 1,048,576 — eff. width 33 pub const BRANCH: usize = 1 << 20; // 1,048,576 — eff. width 32 pub const MEMW_R: usize = 1 << 20; // 1,048,576 — eff. width 31 diff --git a/prover/src/tables/shift.rs b/prover/src/tables/shift.rs index 69c8a685b..891396405 100644 --- a/prover/src/tables/shift.rs +++ b/prover/src/tables/shift.rs @@ -15,7 +15,8 @@ //! - Multiplicity: `μ` //! //! ## Bus Interactions (17 total) -//! - Senders: MSB16, BYTE_ALU[AND] (×3), ZERO, HWSL (×5), ARE_BYTES (×1: `[shift[0], shift[1]]`), IS_HALFWORD (×5) +//! - Senders: MSB16, BYTE_ALU[AND] (×3), ZERO, HWSL (×5), ARE_BYTES (×1: +//! `[shift[0], shift[1]]`), IS_HALFWORD (×5) //! - Receiver: ALU (from CPU) use stark::lookup::{BusInteraction, BusValue, LinearTerm, Multiplicity, Packing}; From 0ea687217c6d2f7a1ac3f07d8c7a59ab68b9c898 Mon Sep 17 00:00:00 2001 From: Nicole Date: Thu, 15 Oct 2026 11:37:35 -0300 Subject: [PATCH 20/29] Share MU_MAX and the row dedup helper across the dedup tables --- prover/src/tables/branch.rs | 61 ++++++++-------------------- prover/src/tables/bytewise.rs | 42 ++++--------------- prover/src/tables/dvrm.rs | 19 ++++----- prover/src/tables/lt.rs | 52 ++++-------------------- prover/src/tables/mul.rs | 19 ++++----- prover/src/tables/trace_builder.rs | 15 +++---- prover/src/tables/types.rs | 43 ++++++++++++++++++++ prover/src/tests/branch_bus_tests.rs | 14 +++---- prover/src/tests/bytewise_tests.rs | 15 +++---- prover/src/tests/dvrm_tests.rs | 5 ++- prover/src/tests/lt_tests.rs | 12 +++--- prover/src/tests/mul_tests.rs | 5 ++- 12 files changed, 122 insertions(+), 180 deletions(-) diff --git a/prover/src/tables/branch.rs b/prover/src/tables/branch.rs index 1d5c5569c..4c5dda690 100644 --- a/prover/src/tables/branch.rs +++ b/prover/src/tables/branch.rs @@ -21,7 +21,8 @@ //! - `carry[0]`, `carry[1]`: Carries from 64-bit addition //! //! ## Bus Interactions -//! - Sender: ARE_BYTES (×1 for `[μ, next_pc_low[1]]`, spec `IS_BYTE<μ>` and `IS_BYTE` batched) +//! - Sender: ARE_BYTES (×1 for `[μ, next_pc_low[1]]`, spec `IS_BYTE<μ>` and +//! `IS_BYTE` batched) //! - Sender: BYTE_ALU[AND] (×1 for masking LSB) //! - Sender: IS_HALFWORD (×3 for next_pc_high[0..3]) //! - Receiver: BRANCH (provides branch targets to CPU) @@ -30,9 +31,9 @@ use stark::constraints::builder::{ConstraintBuilder, ConstraintSet}; use stark::lookup::{BusInteraction, BusValue, LinearTerm, Multiplicity, Packing}; use stark::trace::TraceTable; -use std::collections::HashMap; - -use super::types::{BusId, GoldilocksExtension, GoldilocksField, SHIFT_16, VmTable, alu_op}; +use super::types::{ + BusId, GoldilocksExtension, GoldilocksField, SHIFT_16, VmTable, alu_op, dedup_rows, +}; // ========================================================================= // Column indices for BRANCH table @@ -150,50 +151,17 @@ impl BranchOperation { } } -/// Largest per-row multiplicity: `μ` is range-checked to a byte by `IS_BYTE[μ]`, -/// batched with `IS_BYTE[next_pc_low_1]` into one `ARE_BYTES[μ, next_pc_low_1]` -/// with multiplicity 1 (the spec's default `cond`), so a row holds at most this -/// many lookups. The bound keeps μ non-negative: without it a `μ = −1` twin of -/// an honest row cancels it on the BRANCH bus while *receiving* its range -/// lookups, absorbing another row's out-of-range limb (same bug as LT). -/// -/// `next_pc_low_1` is only constrained packed (`low0 + 2^8·low1 + 2^16·high0`), -/// so its byte check must not be weighted by μ either: with weight μ a row with -/// μ < 0 and `low1 = p−1` cancels its own (or another table's) bound on -/// `(p−1, 0)` (`branch_carrier_poc::lt_forgery_with_branch_cycle_is_rejected`). -/// See `lt::MU_MAX`. -pub const MU_MAX: u64 = (1 << 8) - 1; - -/// Deduplicates BRANCH operations into trace rows: `(pc, offset, register, -/// jalr) -> μ`, splitting an op over several rows when its count exceeds -/// [`MU_MAX`]. Shared by trace generation and the BITWISE collector so the -/// per-row lookups they count cannot drift apart. -pub fn dedup_branch_rows(operations: &[BranchOperation]) -> Vec<(BranchOperation, u64)> { - let mut op_map: HashMap = HashMap::new(); - for op in operations { - *op_map.entry(op.clone()).or_insert(0) += 1; - } - - let mut rows = Vec::with_capacity(op_map.len()); - for (op, mut left) in op_map { - while left > 0 { - let mu = left.min(MU_MAX); - left -= mu; - rows.push((op.clone(), mu)); - } - } - rows -} - /// Generates the BRANCH trace table from a list of operations. /// /// Duplicate operations (same pc, offset, register, jalr) are merged into a -/// single row with their multiplicities summed (see [`dedup_branch_rows`]). The -/// table is then padded to the next power of 2. +/// single row with their multiplicities summed, split at +/// [`MU_MAX`](crate::tables::types::MU_MAX) (see +/// [`dedup_rows`]). The table is then padded +/// to the next power of 2. pub fn generate_branch_trace( operations: &[BranchOperation], ) -> TraceTable { - let unique_ops = dedup_branch_rows(operations); + let unique_ops = dedup_rows(operations); let num_rows = unique_ops.len().next_power_of_two().max(4); let mut trace = TraceTable::new_main( crate::tables::types::zeroed_fe_vec(num_rows * cols::NUM_COLUMNS), @@ -253,7 +221,7 @@ pub fn generate_branch_trace( /// range check, batched (both have multiplicity 1) /// - **Sends** BYTE_ALU[AND] lookup for LSB masking /// (next_pc_low[0] = unmasked_low_byte & 254) -/// - **Sends** IS_HALFWORD lookups for the next_pc_high[0..2] range checks +/// - **Sends** IS_HALFWORD lookups for the next_pc_high[0..3] range checks /// - **Receives** BRANCH lookups from CPU table pub fn bus_interactions() -> Vec { vec![ @@ -263,7 +231,12 @@ pub fn bus_interactions() -> Vec { // the BRANCH bus while *receiving* its range lookups and absorbs an // out-of-range IS_HALFWORD sent by any row (same as LT). Both checks // have the spec's default `cond` (1), so they batch into one - // interaction; padding rows send ARE_BYTES[0, 0]. See `MU_MAX`. + // interaction; padding rows send ARE_BYTES[0, 0]. `next_pc_low_1` is + // only constrained packed (`low0 + 2^8·low1 + 2^16·high0`), so its byte + // check must not be weighted by μ either: with weight μ a row with μ < 0 + // and `low1 = p−1` cancels its own (or another table's) bound on + // `(p−1, 0)` (`branch_carrier_poc::lt_forgery_with_branch_cycle_is_rejected`). + // See `types::MU_MAX`. BusInteraction::sender( BusId::AreBytes, Multiplicity::One, diff --git a/prover/src/tables/bytewise.rs b/prover/src/tables/bytewise.rs index 8d86e489d..3ac757a37 100644 --- a/prover/src/tables/bytewise.rs +++ b/prover/src/tables/bytewise.rs @@ -19,7 +19,7 @@ use stark::lookup::{BusInteraction, BusValue, Multiplicity, Packing}; use stark::trace::TraceTable; -use super::types::{BusId, GoldilocksExtension, GoldilocksField, VmTable, alu_op}; +use super::types::{BusId, GoldilocksExtension, GoldilocksField, VmTable, alu_op, dedup_rows}; // ========================================================================= // Column indices for BYTEWISE table @@ -90,44 +90,16 @@ impl BytewiseOperation { } } -/// Largest per-row multiplicity: `μ` is range-checked to a byte by `IS_BYTE[μ]` -/// (`ARE_BYTES[μ, 0]` with multiplicity 1, the spec's default `cond`), so a row -/// holds at most this many lookups. The bound keeps μ non-negative: without it a -/// `μ = −1` row inverts its byte range checks and can hold an out-of-range byte -/// (carrier). See [`dedup_bytewise_rows`] and `lt::MU_MAX` for why the bound is -/// sent with multiplicity 1 rather than μ. -pub const MU_MAX: u64 = (1 << 8) - 1; - -/// Deduplicates BYTEWISE operations into trace rows: `op -> μ`, splitting an op -/// over several rows when its count exceeds [`MU_MAX`]. Shared by trace -/// generation and the BITWISE collector so the per-row lookups cannot drift. -pub fn dedup_bytewise_rows(operations: &[BytewiseOperation]) -> Vec<(BytewiseOperation, u64)> { - use std::collections::HashMap; - - let mut op_map: HashMap = HashMap::new(); - for op in operations { - *op_map.entry(op.clone()).or_insert(0) += 1; - } - - let mut rows = Vec::with_capacity(op_map.len()); - for (op, mut left) in op_map { - while left > 0 { - let mu = left.min(MU_MAX); - left -= mu; - rows.push((op.clone(), mu)); - } - } - rows -} - /// Generates the BYTEWISE trace from a list of operations. /// -/// Duplicate operations are merged with summed multiplicities (split at -/// [`MU_MAX`]), then padded to the next power of two (minimum 4). +/// Duplicate operations are merged with summed multiplicities, split at +/// [`MU_MAX`](crate::tables::types::MU_MAX) (see +/// [`dedup_rows`]), then padded to the next +/// power of two (minimum 4). pub fn generate_bytewise_trace( operations: &[BytewiseOperation], ) -> TraceTable { - let unique_ops = dedup_bytewise_rows(operations); + let unique_ops = dedup_rows(operations); let num_rows = unique_ops.len().next_power_of_two().max(4); let mut trace = TraceTable::new_main( crate::tables::types::zeroed_fe_vec(num_rows * cols::NUM_COLUMNS), @@ -189,7 +161,7 @@ pub fn bus_interactions() -> Vec { // non-negative: the eight BYTE_ALU sends above are weighted by μ, so a free // μ = −1 would invert them and let the row hold an out-of-range byte. // Multiplicity 1 (the spec's default `cond`), so padding rows send - // ARE_BYTES[0, 0] too. See `MU_MAX`. + // ARE_BYTES[0, 0] too. See `types::MU_MAX`. interactions.push(BusInteraction::sender( BusId::AreBytes, Multiplicity::One, diff --git a/prover/src/tables/dvrm.rs b/prover/src/tables/dvrm.rs index dff9b5e66..1ce2b8991 100644 --- a/prover/src/tables/dvrm.rs +++ b/prover/src/tables/dvrm.rs @@ -36,7 +36,7 @@ use stark::trace::TraceTable; use std::collections::HashMap; use super::types::{ - BusId, GoldilocksExtension, GoldilocksField, NEG_INV_2_16, NEG_INV_2_32, NEG_INV_2_48, + BusId, GoldilocksExtension, GoldilocksField, MU_MAX, NEG_INV_2_16, NEG_INV_2_32, NEG_INV_2_48, NEG_INV_2_64, SHIFT_16, VmTable, alu_op, }; @@ -274,21 +274,16 @@ impl DvrmOperation { // Trace generation // ========================================================================= -/// Largest per-row multiplicity: `μ_q` and `μ_r` are each range-checked to a -/// byte by `IS_BYTE[μ_q]` and `IS_BYTE[μ_r]`, batched into one -/// `ARE_BYTES[μ_q, μ_r]` with multiplicity 1 (the spec's default `cond`), so a -/// row holds at most this many lookups of each kind. The bound is what makes -/// `μ_q + μ_r = 0` imply `μ_q = μ_r = 0`: without it a `μ_q = 1, μ_r = −1` -/// row receives a quotient -/// lookup while sending none of its checks (forged `n = q·d + r`). -/// -/// See `lt::MU_MAX` for why the bound is sent with multiplicity 1 rather than μ. -pub const MU_MAX: u64 = (1 << 8) - 1; - /// Deduplicates DVRM operations into trace rows: `(n, d, signed) -> (μ_q, μ_r)`, /// splitting an op over several rows when a count exceeds [`MU_MAX`]. Shared by /// trace generation and the BITWISE collector so the per-row lookups they count /// cannot drift apart. +/// +/// `μ_q` and `μ_r` are each range-checked to a byte by `IS_BYTE[μ_q]` and +/// `IS_BYTE[μ_r]`, batched into one `ARE_BYTES[μ_q, μ_r]` with multiplicity 1. +/// The bound is what makes `μ_q + μ_r = 0` imply `μ_q = μ_r = 0`: without it a +/// `μ_q = 1, μ_r = −1` row receives a quotient lookup while sending none of its +/// checks (forged `n = q·d + r`). pub fn dedup_dvrm_rows( operations: &[(DvrmOperation, bool)], ) -> Vec<(DvrmOperation, DvrmMultiplicities)> { diff --git a/prover/src/tables/lt.rs b/prover/src/tables/lt.rs index a31c5ca59..e4cbf457c 100644 --- a/prover/src/tables/lt.rs +++ b/prover/src/tables/lt.rs @@ -30,9 +30,9 @@ use stark::lookup::{BusInteraction, BusValue, LinearTerm, Multiplicity, Packing}; use stark::trace::TraceTable; -use std::collections::HashMap; - -use super::types::{BusId, GoldilocksExtension, GoldilocksField, SHIFT_16, VmTable, alu_op}; +use super::types::{ + BusId, GoldilocksExtension, GoldilocksField, SHIFT_16, VmTable, alu_op, dedup_rows, +}; // ========================================================================= // Column indices for LT table @@ -153,51 +153,17 @@ impl LtOperation { } } -/// Largest per-row multiplicity: `μ` is range-checked to a byte by `IS_BYTE[μ]` -/// (`ARE_BYTES[μ, 0]` with multiplicity 1, the spec's default `cond`), so a row -/// holds at most this many lookups. The bound is what keeps μ non-negative: -/// without it a `μ = −1` twin of an honest row cancels it on the ALU bus while -/// *receiving* its range lookups, which absorbs another row's out-of-range limb -/// (a forged `5 < 3 = 1` verified). -/// -/// The bound is sent with multiplicity 1, not μ. A `μ < 0` row then leaves `+1` -/// on an out-of-range tuple, which nothing can cancel as long as no ARE_BYTES -/// sender carries a negative weight (each one is `1` or a bit-constrained μ). -/// Weighted by μ, the bound was itself that negative weight, and a real BRANCH -/// row with a non-canonical `next_pc_low_1 = p−1` cancelled it -/// (`branch_carrier_poc`). -pub const MU_MAX: u64 = (1 << 8) - 1; - -/// Deduplicates LT operations into trace rows: `(lhs, rhs, signed, invert) -> μ`, -/// splitting an op over several rows when its count exceeds [`MU_MAX`]. Shared -/// by trace generation and the BITWISE collector so the per-row lookups they -/// count cannot drift apart. -pub fn dedup_lt_rows(operations: &[LtOperation]) -> Vec<(LtOperation, u64)> { - let mut op_map: HashMap = HashMap::new(); - for op in operations { - *op_map.entry(op.clone()).or_insert(0) += 1; - } - - let mut rows = Vec::with_capacity(op_map.len()); - for (op, mut left) in op_map { - while left > 0 { - let mu = left.min(MU_MAX); - left -= mu; - rows.push((op.clone(), mu)); - } - } - rows -} - /// Generates the LT trace table from a list of operations. /// /// Duplicate operations (same lhs, rhs, signed, invert) are merged into a single -/// row with their multiplicities summed (see [`dedup_lt_rows`]). The table is -/// then padded to the next power of 2. +/// row with their multiplicities summed, split at +/// [`MU_MAX`](crate::tables::types::MU_MAX) (see +/// [`dedup_rows`]). The table is then padded +/// to the next power of 2. pub fn generate_lt_trace( operations: &[LtOperation], ) -> TraceTable { - let unique_ops = dedup_lt_rows(operations); + let unique_ops = dedup_rows(operations); let num_rows = unique_ops.len().next_power_of_two().max(4); let mut trace = TraceTable::new_main( crate::tables::types::zeroed_fe_vec(num_rows * cols::NUM_COLUMNS), @@ -344,7 +310,7 @@ pub fn bus_interactions() -> Vec { // lookups, with `sub_0` free up to 2^32: it absorbs an out-of-range // IS_HALFWORD sent by any row, another LT row included (forged // `5 < 3 = 1`). Multiplicity 1 (the spec's default `cond`), so padding - // rows send ARE_BYTES[0, 0] too. See `MU_MAX`. + // rows send ARE_BYTES[0, 0] too. See `types::MU_MAX`. BusInteraction::sender( BusId::AreBytes, Multiplicity::One, diff --git a/prover/src/tables/mul.rs b/prover/src/tables/mul.rs index a69ad244f..8b9ee5865 100644 --- a/prover/src/tables/mul.rs +++ b/prover/src/tables/mul.rs @@ -37,7 +37,7 @@ use stark::trace::TraceTable; use std::collections::HashMap; use super::types::{ - BusId, GoldilocksExtension, GoldilocksField, INV_2_32, INV_2_64, INV_2_96, INV_2_128, + BusId, GoldilocksExtension, GoldilocksField, INV_2_32, INV_2_64, INV_2_96, INV_2_128, MU_MAX, NEG_INV_2_16, NEG_INV_2_32, NEG_INV_2_48, NEG_INV_2_64, NEG_INV_2_80, NEG_INV_2_96, NEG_INV_2_112, NEG_INV_2_128, SHIFT_16, VmTable, alu_op, }; @@ -282,21 +282,16 @@ impl MulOperation { // Trace generation // ========================================================================= -/// Largest per-row multiplicity: `μ_lo` and `μ_hi` are each range-checked to a -/// byte by `IS_BYTE[μ_lo]` and `IS_BYTE[μ_hi]`, batched into one -/// `ARE_BYTES[μ_lo, μ_hi]` with multiplicity 1 (the spec's default `cond`), so a -/// row holds at most this many lookups of each kind. The bound is what makes -/// `μ_lo + μ_hi = 0` imply `μ_lo = μ_hi = 0`: without it a `μ_lo = 1, μ_hi = −1` -/// row receives a lookup -/// while sending none of its range checks. -/// -/// See `lt::MU_MAX` for why the bound is sent with multiplicity 1 rather than μ. -pub const MU_MAX: u64 = (1 << 8) - 1; - /// Deduplicates MUL operations into trace rows: `(lhs, lhs_signed, rhs, /// rhs_signed) -> (μ_lo, μ_hi)`, splitting an op over several rows when a count /// exceeds [`MU_MAX`]. Shared by trace generation and the BITWISE collector so /// the per-row lookups they count cannot drift apart. +/// +/// `μ_lo` and `μ_hi` are each range-checked to a byte by `IS_BYTE[μ_lo]` and +/// `IS_BYTE[μ_hi]`, batched into one `ARE_BYTES[μ_lo, μ_hi]` with multiplicity 1. +/// The bound is what makes `μ_lo + μ_hi = 0` imply `μ_lo = μ_hi = 0`: without it +/// a `μ_lo = 1, μ_hi = −1` row receives a lookup while sending none of its range +/// checks. pub fn dedup_mul_rows( operations: &[(MulOperation, bool)], ) -> Vec<(MulOperation, MulMultiplicities)> { diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 83d08883f..d10026f33 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -66,7 +66,7 @@ use super::page::{self, PageConfig}; use super::register::{self, FinalRegisterStateMap, FinalRegisterWordState}; use super::shift::{self, ShiftOperation}; use super::store; -use super::types::{GoldilocksExtension, GoldilocksField}; +use super::types::{GoldilocksExtension, GoldilocksField, dedup_rows}; use crate::Error; use crate::paged_mem::{ImageSource, PagedMem}; @@ -1738,7 +1738,7 @@ pub(crate) fn collect_bitwise_from_lt( // included, over exactly the rows `generate_lt_trace` builds for each chunk. let mu_byte = |v: u64| BitwiseOperation::single_byte(BitwiseOperationType::AreBytes, v as u8); for chunk in op_chunks(lt_ops, max_rows_lt) { - let rows = lt::dedup_lt_rows(chunk); + let rows = dedup_rows(chunk); bitwise_ops.extend(rows.iter().map(|(_, mu)| mu_byte(*mu))); bitwise_ops.extend(std::iter::repeat_n( mu_byte(0), @@ -1820,7 +1820,7 @@ pub(crate) fn collect_bitwise_from_mul( // `max_rows_mul` chunk (`chunk_and_generate_optional`): ARE_BYTES[μ_lo, μ_hi] // (the batched IS_BYTE bounds) once per row, padding rows included as // ARE_BYTES[0, 0], and MSB16 once per row whose sign flag is set — a unique - // signed op spanning two instances, or split over two rows by `mul::MU_MAX`, is + // signed op spanning two instances, or split over two rows by `types::MU_MAX`, is // sent once per row. let mu_bytes = |a: u64, b: u64| { BitwiseOperation::byte_op(BitwiseOperationType::AreBytes, a as u8, b as u8) @@ -1959,7 +1959,7 @@ pub(crate) fn collect_bitwise_from_dvrm( // batched IS_BYTE bounds) once per row, padding rows included as // ARE_BYTES[0, 0]; MSB16 (Column(SIGNED)) // and the NEG-template ZERO lookups (Column(SIGN_R)/Column(SIGN_D)) once per - // row whose bit is set. An op split over two rows by `dvrm::MU_MAX` sends + // row whose bit is set. An op split over two rows by `types::MU_MAX` sends // them once per row. let mu_bytes = |a: u64, b: u64| { BitwiseOperation::byte_op(BitwiseOperationType::AreBytes, a as u8, b as u8) @@ -2103,7 +2103,7 @@ pub(crate) fn collect_bitwise_from_branch( BitwiseOperation::byte_op(BitwiseOperationType::AreBytes, mu as u8, low_1) }; for chunk in op_chunks(branch_ops, max_rows_branch) { - let rows = branch::dedup_branch_rows(chunk); + let rows = dedup_rows(chunk); bitwise_ops.extend( rows.iter() .map(|(op, mu)| row_bytes(*mu, ((op.compute_next_pc() >> 8) & 0xFF) as u8)), @@ -2153,7 +2153,7 @@ pub(crate) fn collect_bytewise_mu_bounds( let mu_byte = |v: u64| BitwiseOperation::single_byte(BitwiseOperationType::AreBytes, v as u8); let mut bounds = Vec::new(); for chunk in op_chunks(bytewise_ops, max_rows) { - let rows = bytewise::dedup_bytewise_rows(chunk); + let rows = dedup_rows(chunk); bounds.extend(rows.iter().map(|(_, mu)| mu_byte(*mu))); bounds.extend(std::iter::repeat_n( mu_byte(0), @@ -3386,7 +3386,8 @@ fn build_traces( // // LT/MUL/DVRM/BRANCH/BYTEWISE count their per-row lookups (μ bound, bit-gated // MSB16/ZERO) and SHIFT its padding rows PER CHIP INSTANCE, so pass the same chunk size - // used to split them into instances so multiplicities match the per-instance sends. MEMW_R sends IS_HALFWORD[timestamp_0 - old_timestamp_lo - 1]. PAGE does a + // used to split them into instances so multiplicities match the per-instance sends. + // MEMW_R sends IS_HALFWORD[timestamp_0 - old_timestamp_lo - 1]. PAGE does a // batched ARE_BYTES[init, fini] per row (skipped in continuation epochs, which the L2G // table owns). COMMIT sends AreBytes+IsHalfword; KECCAK_RND sends XOR/AND/ARE_BYTES/HWSL; // HINT sends ARE_BYTES for its 32 output cells. diff --git a/prover/src/tables/types.rs b/prover/src/tables/types.rs index fab4aabff..da98dddc0 100644 --- a/prover/src/tables/types.rs +++ b/prover/src/tables/types.rs @@ -474,6 +474,49 @@ pub const NEG_INV_2_112: u64 = 18446462594437939201; /// -(2^-128) mod p = p - 2^(-128) pub const NEG_INV_2_128: u64 = 18446744065119617026; +// ========================================================================= +// Multiplicity bound of the deduplicated tables +// ========================================================================= + +/// Largest per-row multiplicity of a deduplicated table (LT, BYTEWISE, MUL, DVRM, +/// BRANCH): each `μ` is range-checked to a byte by `IS_BYTE[μ]` (an `ARE_BYTES` +/// send with multiplicity 1, the spec's default `cond`), so a row holds at most +/// this many lookups. The bound is what keeps μ non-negative: without it a +/// `μ = −1` twin of an honest row cancels it on the ALU bus while *receiving* +/// its range lookups, which absorbs another row's out-of-range limb (a forged +/// `5 < 3 = 1` verified). +/// +/// The bound is sent with multiplicity 1, not μ. A `μ < 0` row then leaves `+1` +/// on an out-of-range tuple, which nothing can cancel as long as no ARE_BYTES +/// sender carries a negative weight (each one is `1` or a bit-constrained μ). +/// Weighted by μ, the bound was itself that negative weight, and a real BRANCH +/// row with a non-canonical `next_pc_low_1 = p−1` cancelled it +/// (`branch_carrier_poc`). +/// +/// It is `u8::MAX` so the BITWISE collectors can send each μ as a byte. +pub const MU_MAX: u64 = u8::MAX as u64; + +/// Deduplicates operations into trace rows: `op -> μ`, splitting an op over +/// several rows when its count exceeds [`MU_MAX`]. Shared by trace generation +/// and the BITWISE collectors of LT, BYTEWISE and BRANCH so the per-row lookups +/// they count cannot drift apart. +pub fn dedup_rows(operations: &[T]) -> Vec<(T, u64)> { + let mut op_map: std::collections::HashMap = std::collections::HashMap::new(); + for op in operations { + *op_map.entry(op.clone()).or_insert(0) += 1; + } + + let mut rows = Vec::with_capacity(op_map.len()); + for (op, mut left) in op_map { + while left > 0 { + let mu = left.min(MU_MAX); + left -= mu; + rows.push((op.clone(), mu)); + } + } + rows +} + // ========================================================================= // ALU operation descriptors // ========================================================================= diff --git a/prover/src/tests/branch_bus_tests.rs b/prover/src/tests/branch_bus_tests.rs index 5129d9ffd..2d1520232 100644 --- a/prover/src/tests/branch_bus_tests.rs +++ b/prover/src/tests/branch_bus_tests.rs @@ -699,10 +699,8 @@ fn test_soundness_wrong_offset() { // #1017), and trace generation splits a row whose count would exceed `MU_MAX`. mod mu_bound { use crate::tables::branch::BranchOperation; - use crate::tables::branch::{ - MU_MAX, bus_interactions, cols, dedup_branch_rows, generate_branch_trace, - }; - use crate::tables::types::BusId; + use crate::tables::branch::{bus_interactions, cols, generate_branch_trace}; + use crate::tables::types::{BusId, MU_MAX, dedup_rows}; /// Presence: μ and next_pc_low[1] are byte-checked by one ARE_BYTES with /// multiplicity 1. Weighted by μ, a μ < 0 row's next_pc_low[1] check could @@ -730,16 +728,16 @@ mod mu_bound { /// Splitting: a count above `MU_MAX` spreads over rows within the bound that /// preserve the total; exactly `MU_MAX` still fits one row. #[test] - fn dedup_branch_rows_splits_counts_above_mu_max() { + fn branch_rows_split_counts_above_mu_max() { let op = BranchOperation::new(1 << 48, 0, 0, false); let ops: Vec<_> = std::iter::repeat_n(op.clone(), 2 * MU_MAX as usize + 7).collect(); - let mut rows: Vec = dedup_branch_rows(&ops).iter().map(|(_, mu)| *mu).collect(); + let mut rows: Vec = dedup_rows(&ops).iter().map(|(_, mu)| *mu).collect(); rows.sort(); assert_eq!(rows, vec![7, MU_MAX, MU_MAX]); let ops: Vec<_> = std::iter::repeat_n(op, MU_MAX as usize).collect(); - assert_eq!(dedup_branch_rows(&ops).len(), 1); - assert!(dedup_branch_rows(&[]).is_empty()); + assert_eq!(dedup_rows(&ops).len(), 1); + assert!(dedup_rows::(&[]).is_empty()); // The generated trace never holds a μ above the bound. let ops: Vec<_> = std::iter::repeat_n( diff --git a/prover/src/tests/bytewise_tests.rs b/prover/src/tests/bytewise_tests.rs index bb3db2b38..2ccb6b933 100644 --- a/prover/src/tests/bytewise_tests.rs +++ b/prover/src/tests/bytewise_tests.rs @@ -91,20 +91,17 @@ fn test_bus_interactions_shape() { /// Splitting: a count above `MU_MAX` spreads over rows that each stay within the /// bound and together preserve the total; exactly `MU_MAX` still fits one row. #[test] -fn test_dedup_bytewise_rows_splits_counts_above_mu_max() { - use crate::tables::bytewise::{MU_MAX, dedup_bytewise_rows}; +fn test_bytewise_rows_split_counts_above_mu_max() { + use crate::tables::types::{MU_MAX, dedup_rows}; let op = BytewiseOperation::new(0xF0, 0x3C, alu_op::AND); let ops: Vec<_> = std::iter::repeat_n(op.clone(), 2 * MU_MAX as usize + 7).collect(); - let mut rows: Vec = dedup_bytewise_rows(&ops) - .iter() - .map(|(_, mu)| *mu) - .collect(); + let mut rows: Vec = dedup_rows(&ops).iter().map(|(_, mu)| *mu).collect(); rows.sort(); assert_eq!(rows, vec![7, MU_MAX, MU_MAX]); let ops: Vec<_> = std::iter::repeat_n(op, MU_MAX as usize).collect(); - assert_eq!(dedup_bytewise_rows(&ops).len(), 1); - assert!(dedup_bytewise_rows(&[]).is_empty()); + assert_eq!(dedup_rows(&ops).len(), 1); + assert!(dedup_rows::(&[]).is_empty()); // The generated trace never holds a μ above the bound. let ops: Vec<_> = std::iter::repeat_n( @@ -128,8 +125,8 @@ fn test_dedup_bytewise_rows_splits_counts_above_mu_max() { #[test] fn test_bytewise_mu_bound_lookups_match_collector() { use crate::tables::bitwise::BitwiseOperationType; - use crate::tables::bytewise::MU_MAX; use crate::tables::trace_builder::collect_bytewise_mu_bounds; + use crate::tables::types::MU_MAX; use std::collections::HashMap; let distinct = [ diff --git a/prover/src/tests/dvrm_tests.rs b/prover/src/tests/dvrm_tests.rs index 3e76317dd..3d0269a06 100644 --- a/prover/src/tests/dvrm_tests.rs +++ b/prover/src/tests/dvrm_tests.rs @@ -590,7 +590,8 @@ fn test_dvrm_bounds_its_multiplicities() { /// preserve both totals; exactly `MU_MAX` still fits one row. #[test] fn test_dedup_dvrm_rows_splits_counts_above_mu_max() { - use crate::tables::dvrm::{MU_MAX, dedup_dvrm_rows}; + use crate::tables::dvrm::dedup_dvrm_rows; + use crate::tables::types::MU_MAX; let op = DvrmOperation::new(7, 2, UNSIGNED); let ops: Vec<_> = std::iter::repeat_n((op.clone(), true), MU_MAX as usize + 5) .chain(std::iter::repeat_n((op.clone(), false), 3)) @@ -614,8 +615,8 @@ fn test_dedup_dvrm_rows_splits_counts_above_mu_max() { #[test] fn test_dvrm_per_row_lookups_match_collector() { use crate::tables::bitwise::BitwiseOperationType; - use crate::tables::dvrm::MU_MAX; use crate::tables::trace_builder::collect_bitwise_from_dvrm; + use crate::tables::types::MU_MAX; use std::collections::HashMap; let half = |h: u64| ((h & 0xFF) as u8, ((h >> 8) & 0xFF) as u8); diff --git a/prover/src/tests/lt_tests.rs b/prover/src/tests/lt_tests.rs index 747c28766..cd1506fea 100644 --- a/prover/src/tests/lt_tests.rs +++ b/prover/src/tests/lt_tests.rs @@ -260,17 +260,17 @@ fn test_lt_bounds_its_multiplicity() { /// Splitting: a count above `MU_MAX` spreads over rows within the bound that /// preserve the total; exactly `MU_MAX` still fits one row. #[test] -fn test_dedup_lt_rows_splits_counts_above_mu_max() { - use crate::tables::lt::{MU_MAX, dedup_lt_rows}; +fn test_lt_rows_split_counts_above_mu_max() { + use crate::tables::types::{MU_MAX, dedup_rows}; let op = LtOperation::new(5, 3, SIGNED); let ops: Vec<_> = std::iter::repeat_n(op.clone(), 2 * MU_MAX as usize + 7).collect(); - let mut rows: Vec = dedup_lt_rows(&ops).iter().map(|(_, mu)| *mu).collect(); + let mut rows: Vec = dedup_rows(&ops).iter().map(|(_, mu)| *mu).collect(); rows.sort(); assert_eq!(rows, vec![7, MU_MAX, MU_MAX]); let ops: Vec<_> = std::iter::repeat_n(op, MU_MAX as usize).collect(); - assert_eq!(dedup_lt_rows(&ops).len(), 1); - assert!(dedup_lt_rows(&[]).is_empty()); + assert_eq!(dedup_rows(&ops).len(), 1); + assert!(dedup_rows::(&[]).is_empty()); // The generated trace never holds a μ above the bound. let ops: Vec<_> = @@ -291,8 +291,8 @@ fn test_dedup_lt_rows_splits_counts_above_mu_max() { #[test] fn test_lt_mu_bound_lookups_match_collector() { use crate::tables::bitwise::BitwiseOperationType; - use crate::tables::lt::MU_MAX; use crate::tables::trace_builder::collect_bitwise_from_lt; + use crate::tables::types::MU_MAX; use std::collections::HashMap; let half = |h: u64| ((h & 0xFF) as u8, ((h >> 8) & 0xFF) as u8); diff --git a/prover/src/tests/mul_tests.rs b/prover/src/tests/mul_tests.rs index b63903221..306c9bd67 100644 --- a/prover/src/tests/mul_tests.rs +++ b/prover/src/tests/mul_tests.rs @@ -451,7 +451,8 @@ fn test_mul_bounds_its_multiplicities() { /// bound and together preserve both totals; exactly `MU_MAX` still fits one row. #[test] fn test_dedup_mul_rows_splits_counts_above_mu_max() { - use crate::tables::mul::{MU_MAX, dedup_mul_rows}; + use crate::tables::mul::dedup_mul_rows; + use crate::tables::types::MU_MAX; let op = MulOperation::new(3, false, 5, false); let ops: Vec<_> = std::iter::repeat_n((op.clone(), false), MU_MAX as usize + 5) .chain(std::iter::repeat_n((op.clone(), true), 3)) @@ -490,8 +491,8 @@ fn test_dedup_mul_rows_splits_counts_above_mu_max() { #[test] fn test_mul_per_row_lookups_match_collector() { use crate::tables::bitwise::BitwiseOperationType; - use crate::tables::mul::MU_MAX; use crate::tables::trace_builder::collect_bitwise_from_mul; + use crate::tables::types::MU_MAX; use std::collections::HashMap; let half = |h: u64| ((h & 0xFF) as u8, ((h >> 8) & 0xFF) as u8); From 976c5e1e267fd227b30c17b6867bdbab05fa66b5 Mon Sep 17 00:00:00 2001 From: Nicole Date: Fri, 16 Oct 2026 10:41:17 -0300 Subject: [PATCH 21/29] Describe the pre-fix BRANCH send in past tense --- prover/src/tests/branch_carrier_poc.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/prover/src/tests/branch_carrier_poc.rs b/prover/src/tests/branch_carrier_poc.rs index dea6240c8..02d07ae9f 100644 --- a/prover/src/tests/branch_carrier_poc.rs +++ b/prover/src/tests/branch_carrier_poc.rs @@ -1,11 +1,14 @@ -//! The ARE_BYTES[μ, 0] bound is cancellable by a real BRANCH row. +//! The ARE_BYTES[μ, 0] bound was cancellable by a real BRANCH row. //! -//! BRANCH sends `ARE_BYTES[next_pc_low[1], 0]`, but `next_pc_low[1]` only enters -//! its constraints and the BRANCH bus packed as `low0 + 2^8·low1 + 2^16·high0`. -//! An honest `(low1, high0) = (255, h)` can be stored as `(p−1, h+1)`: same packed -//! next_pc (−256 + 65536 = 255·256), every BranchConstraint holds, `IS_HALF[h+1]` -//! is in range, and the row sends `ARE_BYTES[p−1, 0]` with +1. That cancels the -//! μ bound of any `μ = −1` twin, which brings back the `5 < 3 = 1` forgery. +//! Before the fix, BRANCH sent `ARE_BYTES[next_pc_low[1], 0]` weighted by μ, but +//! `next_pc_low[1]` only enters its constraints and the BRANCH bus packed as +//! `low0 + 2^8·low1 + 2^16·high0`. An honest `(low1, high0) = (255, h)` could be +//! stored as `(p−1, h+1)`: same packed next_pc (−256 + 65536 = 255·256), every +//! BranchConstraint holds, `IS_HALF[h+1]` is in range, and the row sent +//! `ARE_BYTES[p−1, 0]` with +1. That cancelled the μ bound of any `μ = −1` twin, +//! which brought back the `5 < 3 = 1` forgery. The fix sends +//! `ARE_BYTES[μ, next_pc_low[1]]` with multiplicity 1 (spec #1017), so no +//! ARE_BYTES sender carries a negative weight and the cycle is rejected. //! //! Harness: real LT and BRANCH AIRs; mock CPU senders (ALU, BRANCH); mock //! BITWISE receivers that only hold in-domain tuples (IS_HALFWORD < 2^16, MSB16, From f96f820dcaf0476373c60d0dd07eec176313605f Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 15 Oct 2026 09:39:39 -0300 Subject: [PATCH 22/29] Add AddOperand::from_dword_whh so ADD can take a DWordWHH operand (lo = two halves, hi = the word). Needed for the KECCAK state_ptr. --- prover/src/constraints/templates.rs | 24 ++++++++++++ prover/src/tests/constraint_emit_tests.rs | 16 ++++++++ prover/src/tests/constraints_tests.rs | 47 +++++++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/prover/src/constraints/templates.rs b/prover/src/constraints/templates.rs index 04932eab8..c5c5651df 100644 --- a/prover/src/constraints/templates.rs +++ b/prover/src/constraints/templates.rs @@ -109,6 +109,7 @@ impl core::ops::Deref for AddTerms { /// - Constants: `AddOperand::constant(42)` → lo=42, hi=0 /// - Word → DWordWL: `AddOperand::from_word(col)` → lo=col, hi=0 /// - DWordHL → DWordWL: `AddOperand::from_dword_hl(col)` → repack 4 halves +/// - DWordWHH → DWordWL: `AddOperand::from_dword_whh(col)` → repack 2 halves + word /// - DWordBL → DWordWL: `AddOperand::from_dword_bl(col)` → repack 8 bytes /// - Expressions: `AddOperand::linear(...)` → arbitrary linear combinations #[derive(Debug, Clone, Copy)] @@ -185,6 +186,29 @@ impl AddOperand { } } + /// DWordWHH → DWordWL: repack the two low halves into a word; the high + /// word is already a single column. + /// lo = h[0] + 2^16 * h[1] + /// hi = w[2] + pub fn from_dword_whh(start_column: usize) -> Self { + AddOperand::Linear { + lo: AddTerms::of(&[ + AddLinearTerm::Column { + coefficient: 1, + column: start_column, + }, + AddLinearTerm::Column { + coefficient: 1 << 16, + column: start_column + 1, + }, + ]), + hi: AddTerms::of(&[AddLinearTerm::Column { + coefficient: 1, + column: start_column + 2, + }]), + } + } + /// DWordBL → DWordWL: repack 8 bytes into 2 words. /// lo = b[0] + 2^8*b[1] + 2^16*b[2] + 2^24*b[3] /// hi = b[4] + 2^8*b[5] + 2^16*b[6] + 2^24*b[7] diff --git a/prover/src/tests/constraint_emit_tests.rs b/prover/src/tests/constraint_emit_tests.rs index 64f03da51..095ea2af7 100644 --- a/prover/src/tests/constraint_emit_tests.rs +++ b/prover/src/tests/constraint_emit_tests.rs @@ -231,6 +231,22 @@ fn emit_add_pair_linear_unconditional() { check_add_pair_case("add_pair_linear_unconditional", &Body, false); } +#[test] +fn emit_add_pair_conditional_whh_sum() { + // DWordBL lhs + constant, DWordWHH sum: the KECCAK state_ptr shape. + emit_body!(Body, 2, |b| { + emit_add_pair( + b, + 0, + &[0], + &AddOperand::from_dword_bl(1), + &AddOperand::constant(192), + &AddOperand::from_dword_whh(9), + ) + }); + check_add_pair_case("add_pair_conditional_whh_sum", &Body, true); +} + #[test] fn emit_add_pair_multi_cond_bytes() { // Multi-column condition (flag sum), Word + Constant operands, and a diff --git a/prover/src/tests/constraints_tests.rs b/prover/src/tests/constraints_tests.rs index 0caf71264..58af0b18a 100644 --- a/prover/src/tests/constraints_tests.rs +++ b/prover/src/tests/constraints_tests.rs @@ -259,6 +259,53 @@ fn test_add_operand_from_dword_hl() { } } +#[test] +fn test_add_operand_from_dword_whh() { + // Test DWordWHH → DWordWL (repack 2 halves → lo word, high word as is) + let op = AddOperand::from_dword_whh(4); + match op { + AddOperand::Linear { lo, hi } => { + assert_eq!(lo.len(), 2); + assert_eq!(hi.len(), 1); + + // lo = h[0] + 2^16 * h[1] + match &lo[0] { + AddLinearTerm::Column { + coefficient, + column, + } => { + assert_eq!(*coefficient, 1); + assert_eq!(*column, 4); + } + _ => panic!("Expected Column"), + } + match &lo[1] { + AddLinearTerm::Column { + coefficient, + column, + } => { + assert_eq!(*coefficient, 1 << 16); + assert_eq!(*column, 5); + } + _ => panic!("Expected Column"), + } + + // hi = w[2], unscaled: the word is the most significant limb + match &hi[0] { + AddLinearTerm::Column { + coefficient, + column, + } => { + assert_eq!(*coefficient, 1); + assert_eq!(*column, 6); + } + _ => panic!("Expected Column"), + } + } + _ => panic!("Expected Linear variant"), + } +} + #[test] fn test_add_operand_from_dword_bl() { // Test DWordBL → DWordWL (repack 8 bytes → 2 words) From f673ab9058292065a891e1365ee2b99f30ce60ea Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 15 Oct 2026 09:40:13 -0300 Subject: [PATCH 23/29] Add the IS_WH table and the IS_WHH template from the spec (#1003, #1015) and use them for the KECCAK state_ptr. IS_WH range checks the top 48 bits of an address, with one row per distinct (word, half) pair. Its multiplicity is bounded with IS_B20, as in down from 511 and 134. TableCounts gets an is_wh field, so RECURSION_INPUT_VERSION goes to 4 and the statement tags to V5. --- prover/src/auto_storage.rs | 4 +- prover/src/continuation.rs | 1 + prover/src/lib.rs | 38 +- prover/src/statement.rs | 6 +- prover/src/tables/bitwise.rs | 9 + prover/src/tables/is_wh.rs | 214 +++++++++++ prover/src/tables/keccak.rs | 80 ++-- prover/src/tables/mod.rs | 2 + prover/src/tables/trace_builder.rs | 52 ++- prover/src/tables/types.rs | 11 + prover/src/test_utils.rs | 13 + prover/src/tests/is_wh_tests.rs | 388 ++++++++++++++++++++ prover/src/tests/mod.rs | 2 + prover/src/tests/prove_elfs_tests.rs | 2 + prover/src/tests/skip_empty_tables_tests.rs | 54 ++- prover/src/tests/statement_tests.rs | 5 +- prover/src/tests/trace_builder_tests.rs | 16 +- 17 files changed, 823 insertions(+), 74 deletions(-) create mode 100644 prover/src/tables/is_wh.rs create mode 100644 prover/src/tests/is_wh_tests.rs diff --git a/prover/src/auto_storage.rs b/prover/src/auto_storage.rs index 8cc437edd..6ef27cd41 100644 --- a/prover/src/auto_storage.rs +++ b/prover/src/auto_storage.rs @@ -108,8 +108,8 @@ fn aux_cols(bus_count: usize) -> u64 { /// consumer sums or sorts — and it does not track `air_trace_pairs`. /// /// Note this list models only the tables `TableLengths` carries: there is no -/// entry for eq, bytewise, store, cpu32, keccak, keccak_rnd, ecsm, ecdas or -/// hint, so a run dominated by one of those is under-estimated. +/// entry for eq, bytewise, store, cpu32, keccak, keccak_rnd, ecsm, ecdas, hint +/// or is_wh, so a run dominated by one of those is under-estimated. fn table_specs(lengths: &TableLengths) -> Vec { let bitwise_rows = BITWISE_ROWS as u64; let register_rows = NUM_REGISTER_ADDRESSES.next_power_of_two() as u64; diff --git a/prover/src/continuation.rs b/prover/src/continuation.rs index 5b16fcd29..09becccf4 100644 --- a/prover/src/continuation.rs +++ b/prover/src/continuation.rs @@ -2058,6 +2058,7 @@ mod tests { ("ecdas", c.ecdas), ("hint", c.hint), ("commit", c.commit), + ("is_wh", c.is_wh), ] }) .collect(); diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 2a1772d0d..7c3edb543 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -53,10 +53,10 @@ use crate::tables::types::BusId; use crate::test_utils::{ E, F, VmAir, create_bitwise_air, create_branch_air, create_bytewise_air, create_commit_air, create_cpu_air, create_cpu32_air, create_decode_air, create_dvrm_air, create_ecdas_air, - create_ecsm_air, create_eq_air, create_halt_air, create_hint_air, create_keccak_air, - create_keccak_rc_air, create_keccak_rnd_air, create_load_air, create_lt_air, create_memw_air, - create_memw_aligned_air, create_memw_register_air, create_mul_air, create_page_air, - create_register_air, create_shift_air, create_store_air, + create_ecsm_air, create_eq_air, create_halt_air, create_hint_air, create_is_wh_air, + create_keccak_air, create_keccak_rc_air, create_keccak_rnd_air, create_load_air, create_lt_air, + create_memw_air, create_memw_aligned_air, create_memw_register_air, create_mul_air, + create_page_air, create_register_air, create_shift_air, create_store_air, }; // Re-exported for downstream hosts and verifier guests (e.g. the in-VM @@ -120,6 +120,9 @@ pub struct TableCounts { pub ecdas: usize, pub hint: usize, pub commit: usize, + // Range-check chip for the top 48 bits of `DWordWHH` addresses. One table + // when a chip sends `IS_WH` (today only KECCAK), none otherwise. + pub is_wh: usize, } impl TableCounts { @@ -152,6 +155,7 @@ impl TableCounts { self.ecdas, self.hint, self.commit, + self.is_wh, ] .into_iter() .try_fold(0usize, usize::checked_add) @@ -168,7 +172,7 @@ impl TableCounts { /// /// What keeps a zero count honest is the LogUp bus, not this check. A chip /// influences the run only through its bus interactions, and none of the - /// eighteen optional chips carries boundary constraints of its own, so one + /// nineteen optional chips carries boundary constraints of its own, so one /// with no rows contributes zero to the bus and removing it changes nothing. /// (The mandatory tables do constrain boundaries — that is part of why they /// are mandatory.) A prover that omits a table whose operations *did* @@ -212,6 +216,10 @@ impl TableCounts { ("ecdas", self.ecdas), ("hint", self.hint), ("commit", self.commit), + // Also what bounds the IS_WH multiplicity sum per proof: one table + // of at most 2^k rows, each μ < 2^20, stays far below p, so no + // stack of repeated rows reaches μ = −1 (spec verifier.typ TODO). + ("is_wh", self.is_wh), ]; for (name, count) in at_most_one { if count > 1 { @@ -276,12 +284,13 @@ pub struct GuestInput { /// 4-byte magic identifying a lambda-vm recursion input blob ("LVMR"). pub const RECURSION_INPUT_MAGIC: [u8; 4] = *b"LVMR"; -/// Wire-format version of the recursion input blob. v3: `TableCounts` gained the +/// Wire-format version of the recursion input blob. v4: `TableCounts` gained +/// `is_wh`. v3: `TableCounts` gained the /// six accelerator fields, which moves every field after it in the archive. v2: /// rkyv pointer_width_64 (64-bit rel-ptrs) — v1 archives use 32-bit offsets. /// Any change to a type reachable from `GuestInput` belongs here: the guest /// reads the archive in place, so a stale blob is misread rather than rejected. -pub const RECURSION_INPUT_VERSION: u32 = 3; +pub const RECURSION_INPUT_VERSION: u32 = 4; /// Required alignment (bytes) of the archive's first byte in guest memory. pub const RECURSION_INPUT_ALIGN: usize = 16; @@ -593,6 +602,7 @@ pub(crate) struct VmAirs { pub ecsms: Vec, pub ecdases: Vec, pub hints: Vec, + pub is_whs: Vec, pub register: VmAir, pub pages: Vec, pub memw_registers: Vec, @@ -630,6 +640,7 @@ impl VmAirs { self.ecsms.len(), self.ecdases.len(), self.hints.len(), + self.is_whs.len(), self.pages.len(), self.memw_registers.len(), self.eqs.len(), @@ -653,6 +664,7 @@ impl VmAirs { traces.ecsms.len(), traces.ecdases.len(), traces.hints.len(), + traces.is_whs.len(), traces.pages.len(), traces.memw_registers.len(), traces.eqs.len(), @@ -689,6 +701,9 @@ impl VmAirs { for (air, trace) in self.hints.iter().zip(traces.hints.iter_mut()) { pairs.push((air.as_ref(), trace, &())); } + for (air, trace) in self.is_whs.iter().zip(traces.is_whs.iter_mut()) { + pairs.push((air.as_ref(), trace, &())); + } for (air, trace) in self.cpus.iter().zip(traces.cpus.iter_mut()) { pairs.push((air.as_ref(), trace, &())); @@ -776,6 +791,9 @@ impl VmAirs { for air in &self.hints { refs.push(air.as_ref()); } + for air in &self.is_whs { + refs.push(air.as_ref()); + } for air in &self.cpus { refs.push(air.as_ref()); @@ -971,6 +989,11 @@ impl VmAirs { Box::new(create_hint_air(proof_options).with_name(&format!("HINT[{i}]"))) as VmAir }) .collect(); + let is_whs: Vec<_> = (0..table_counts.is_wh) + .map(|i| { + Box::new(create_is_wh_air(proof_options).with_name(&format!("IS_WH[{i}]"))) as VmAir + }) + .collect(); let register: VmAir = if let Some((commitment, num_preprocessed_cols)) = register_preprocessed { Box::new( @@ -1092,6 +1115,7 @@ impl VmAirs { ecsms, ecdases, hints, + is_whs, register, pages, memw_registers, diff --git a/prover/src/statement.rs b/prover/src/statement.rs index edac11b90..b5fdacfb1 100644 --- a/prover/src/statement.rs +++ b/prover/src/statement.rs @@ -17,7 +17,7 @@ use crate::test_utils::E; use crate::{RuntimePageRange, TableCounts}; /// Domain-separation tag. Bump the suffix (`_V2`, ...) on any encoding change. -const DOMAIN_TAG: &[u8] = b"LAMBDAVM_STARK_STATEMENT_V4"; +const DOMAIN_TAG: &[u8] = b"LAMBDAVM_STARK_STATEMENT_V5"; /// Canonical full-ELF identity digest — exactly what [`absorb_statement`] binds /// into the transcript. The recursion attestation folds the same digest into @@ -118,6 +118,7 @@ pub(crate) fn absorb_statement_with_digest( ecdas, hint, commit, + is_wh, } = table_counts; for count in [ cpu, @@ -140,6 +141,7 @@ pub(crate) fn absorb_statement_with_digest( ecdas, hint, commit, + is_wh, ] { t.append_bytes(&(count as u64).to_le_bytes()); } @@ -189,7 +191,7 @@ pub(crate) fn absorb_statement_with_digest( /// Continuation domain tags. Distinct from the monolithic `DOMAIN_TAG` so a /// monolithic proof and a continuation proof can never share a transcript prefix. -const CONTINUATION_EPOCH_TAG: &[u8] = b"LAMBDAVM_CONTINUATION_EPOCH_V4"; +const CONTINUATION_EPOCH_TAG: &[u8] = b"LAMBDAVM_CONTINUATION_EPOCH_V5"; const CONTINUATION_GLOBAL_TAG: &[u8] = b"LAMBDAVM_CONTINUATION_GLOBAL_V2"; /// Statement bound into the cross-epoch **global** proof's transcript before diff --git a/prover/src/tables/bitwise.rs b/prover/src/tables/bitwise.rs index c73e1e341..8970daeaa 100644 --- a/prover/src/tables/bitwise.rs +++ b/prover/src/tables/bitwise.rs @@ -527,6 +527,15 @@ pub(crate) struct BitwiseHistogram { counters: Box<[u64]>, } +/// Cell-by-cell equality, so a test can compare a collector against the +/// lookups it should have counted. +#[cfg(test)] +impl PartialEq for BitwiseHistogram { + fn eq(&self, other: &Self) -> bool { + self.counters == other.counters + } +} + impl BitwiseHistogram { /// Allocate a zeroed histogram (80 MiB). // No `Default` impl on purpose: `new()` allocates 80 MiB, so a stray diff --git a/prover/src/tables/is_wh.rs b/prover/src/tables/is_wh.rs new file mode 100644 index 000000000..15dc92927 --- /dev/null +++ b/prover/src/tables/is_wh.rs @@ -0,0 +1,214 @@ +//! IS_WH table — range check for the top 48 bits of a 64-bit value. +//! +//! Chips that read or write contiguous memory hold one address per access, and +//! those addresses usually differ only in their lowest limb. Instead of range +//! checking all 64 bits locally, a caller keeps the address as a `DWordWHH` +//! (`[half_lo, half_mid, word_hi]`), checks `half_lo` itself and sends the +//! `(word_hi, half_mid)` pair here on the `IsWh` bus (spec `is_wh.toml`). The +//! `IS_WHH` template (spec `is_whh.toml`) is that pair of sends, see [`is_whh`]. +//! +//! One row per distinct `(word, half)` pair, with `μ` counting its sends. +//! +//! ## Columns +//! - `value[0]`: Half — the `Half` of the pair (bits 16..32 of the address) +//! - `value[1]`, `value[2]`: Half — the `Word` of the pair as two halves +//! (bits 32..48 and 48..64) +//! - `μ`: B20 — number of sends of this pair +//! +//! ## Bus Interactions +//! - Sender: IS_B20[μ] (multiplicity 1, bounds μ) +//! - Sender: IS_HALFWORD (×3, one per `value` limb, multiplicity μ) +//! - Receiver: IS_WH[value[1] + 2^16·value[2], value[0]] (multiplicity μ) +//! +//! ## μ bound +//! `μ` lives in the field, so `μ = −1` would turn a row from receiver into +//! sender: it could cancel a forged `IS_WH` send while also receiving the row's +//! own `IS_HALFWORD` checks. The `IS_B20[μ]` send (multiplicity 1, so it is not +//! itself flipped by `μ`) pins `μ ∈ [0, 2^20)`. A pair sent more than [`MU_MAX`] +//! times is split over several rows (see [`dedup_is_wh_rows`]). Padding rows send +//! `IS_B20[0]`, which the BITWISE collector counts (see +//! [`collect_bitwise_from_is_wh`]). +//! +//! The table has no polynomial constraints. + +use std::collections::BTreeMap; + +use stark::lookup::{BusInteraction, BusValue, LinearTerm, Multiplicity, Packing}; +use stark::trace::TraceTable; + +use super::bitwise::{BitwiseHistogram, BitwiseOperation, BitwiseOperationType}; +use super::types::{BusId, GoldilocksExtension, GoldilocksField, VmTable}; + +/// Column definitions for the IS_WH table. +pub mod cols { + /// value[0]: Half — the `Half` of the `(Word, Half)` pair. + pub const VALUE_0: usize = 0; + /// value[1]: Half — low half of the `Word`. + pub const VALUE_1: usize = 1; + /// value[2]: Half — high half of the `Word`. + pub const VALUE_2: usize = 2; + /// μ: B20 — number of `IS_WH` sends of this pair. + pub const MU: usize = 3; + + pub const NUM_COLUMNS: usize = 4; +} + +/// Largest multiplicity a single row may carry: `μ` is a `B20`. +pub const MU_MAX: u64 = (1 << 20) - 1; + +/// A `(Word, Half)` pair as it travels on the `IsWh` bus. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct IsWhKey { + /// Bits 32..64 of the checked value. + pub word: u32, + /// Bits 16..32 of the checked value. + pub half: u16, +} + +impl IsWhKey { + /// The pair a `DWordWHH` holding `value` sends to `IS_WH`. + pub fn of_dword(value: u64) -> Self { + Self { + word: (value >> 32) as u32, + half: ((value >> 16) & 0xFFFF) as u16, + } + } +} + +/// Deduplicates `IS_WH` sends into trace rows: `(key) -> μ`, splitting a key +/// over several rows when its count exceeds [`MU_MAX`]. Rows come out sorted by +/// key so the trace is deterministic. Shared by trace generation and the +/// BITWISE collector so the per-row lookups they count cannot drift apart. +pub fn dedup_is_wh_rows(keys: &[IsWhKey]) -> Vec<(IsWhKey, u64)> { + let mut counts: BTreeMap = BTreeMap::new(); + for key in keys { + *counts.entry(*key).or_insert(0) += 1; + } + + let mut rows = Vec::with_capacity(counts.len()); + for (key, mut left) in counts { + while left > 0 { + let mu = left.min(MU_MAX); + left -= mu; + rows.push((key, mu)); + } + } + rows +} + +/// Number of trace rows (real + padding) for the given deduplicated row count. +fn padded_rows(real_rows: usize) -> usize { + real_rows.next_power_of_two().max(4) +} + +/// Generates the IS_WH trace from the `IS_WH` sends of every caller. +/// +/// Padding rows are all zero (`value = 0`, `μ = 0`, spec `pad = 0`). +pub fn generate_is_wh_trace(keys: &[IsWhKey]) -> TraceTable { + let rows = dedup_is_wh_rows(keys); + let num_rows = padded_rows(rows.len()); + let mut trace = TraceTable::new_main( + crate::tables::types::zeroed_fe_vec(num_rows * cols::NUM_COLUMNS), + cols::NUM_COLUMNS, + 1, + ); + let table = &mut trace.main_table; + + for (row_idx, (key, mu)) in rows.iter().enumerate() { + table.set_half(row_idx, cols::VALUE_0, key.half); + table.set_half(row_idx, cols::VALUE_1, (key.word & 0xFFFF) as u16); + table.set_half(row_idx, cols::VALUE_2, (key.word >> 16) as u16); + table.set_u64(row_idx, cols::MU, *mu); + } + + trace +} + +/// Counts the IS_WH table's BITWISE lookups: per row, `IS_HALFWORD` ×μ on each +/// `value` limb and `IS_B20[μ]` ×1 — padding rows included, which send +/// `IS_B20[0]`. +pub(crate) fn collect_bitwise_from_is_wh(keys: &[IsWhKey], h: &mut BitwiseHistogram) { + if keys.is_empty() { + // No sends, no table (`generate_optional`), so nothing to count. + return; + } + let rows = dedup_is_wh_rows(keys); + let padding = padded_rows(rows.len()) - rows.len(); + + let half = |v: u16| { + BitwiseOperation::halfword( + BitwiseOperationType::IsHalf, + (v & 0xFF) as u8, + (v >> 8) as u8, + ) + }; + let b20 = |v: u64| BitwiseOperation::b20(v as u8, (v >> 8) as u8, (v >> 16) as u8); + + for (key, mu) in &rows { + h.bump_n(half(key.half), *mu); + h.bump_n(half((key.word & 0xFFFF) as u16), *mu); + h.bump_n(half((key.word >> 16) as u16), *mu); + h.bump(b20(*mu)); + } + h.bump_n(b20(0), padding as u64); +} + +/// Bus interactions of the IS_WH table. +pub fn bus_interactions() -> Vec { + let mu = || Multiplicity::Column(cols::MU); + let column = |c: usize| BusValue::Packed { + start_column: c, + packing: Packing::Direct, + }; + + vec![ + // IS_B20[μ] ×1: bounds μ (spec is_wh.toml, #1017). + BusInteraction::sender(BusId::IsB20, Multiplicity::One, vec![column(cols::MU)]), + // IS_HALF[value[i]] ×μ, i = 0..2 (is_wh:c:range_value). + BusInteraction::sender(BusId::IsHalfword, mu(), vec![column(cols::VALUE_0)]), + BusInteraction::sender(BusId::IsHalfword, mu(), vec![column(cols::VALUE_1)]), + BusInteraction::sender(BusId::IsHalfword, mu(), vec![column(cols::VALUE_2)]), + // IS_WH[value[1] + 2^16·value[2], value[0]] ×(−μ) (is_wh:c:is_wh). + BusInteraction::receiver( + BusId::IsWh, + mu(), + vec![ + BusValue::linear(vec![ + LinearTerm::Column { + coefficient: 1, + column: cols::VALUE_1, + }, + LinearTerm::Column { + coefficient: 1 << 16, + column: cols::VALUE_2, + }, + ]), + column(cols::VALUE_0), + ], + ), + ] +} + +/// The `IS_WHH` template (spec `is_whh.toml`, #1015): range-checks the +/// `DWordWHH` `[half_lo, half_mid, word_hi]` at `start_column` by sending +/// `IS_WH[word_hi, half_mid]` and `IS_HALF[half_lo]`, both with multiplicity +/// `cond`. +/// +/// `cond` must be constrained to a bit by the caller (spec `cond: Bit`). These +/// are bus sends, not constraints, so the template cannot enforce it: with +/// `cond = −1` the row would *receive* both checks, cancelling forged sends +/// elsewhere. +pub fn is_whh(start_column: usize, cond: Multiplicity) -> [BusInteraction; 2] { + let column = |c: usize| BusValue::Packed { + start_column: c, + packing: Packing::Direct, + }; + [ + BusInteraction::sender( + BusId::IsWh, + cond.clone(), + vec![column(start_column + 2), column(start_column + 1)], + ), + BusInteraction::sender(BusId::IsHalfword, cond, vec![column(start_column)]), + ] +} diff --git a/prover/src/tables/keccak.rs b/prover/src/tables/keccak.rs index 3fecd51a4..d961455ce 100644 --- a/prover/src/tables/keccak.rs +++ b/prover/src/tables/keccak.rs @@ -4,7 +4,7 @@ //! sends input state to the round chip via the Keccak bus, and receives the output //! state after 24 rounds. //! -//! ## Column layout (~511 columns) +//! ## Column layout (486 columns) //! //! | Group | Size | Description | //! |----------------|------|------------------------------------------------| @@ -12,7 +12,7 @@ //! | addr | 8 | State address as DWordBL (8 bytes) | //! | input_state | 200 | Input state bytes [5][5][8] | //! | output_state | 200 | Output state bytes [5][5][8] | -//! | state_ptr | 100 | Per-lane DWordHL addresses [25][4] | +//! | state_ptr | 75 | Per-lane DWordWHH addresses [25][3] | //! | mu | 1 | Multiplicity flag | use executor::vm::instruction::execution::KECCAK_SYSCALL_NUMBER; @@ -21,6 +21,7 @@ use stark::trace::TraceTable; use stark::constraints::builder::{ConstraintBuilder, ConstraintSet}; +use super::is_wh::{IsWhKey, is_whh}; use super::types::{BusId, FE, GoldilocksExtension, GoldilocksField, VmTable, alu_op}; use crate::constraints::templates::{AddOperand, INV_SHIFT_32}; @@ -41,12 +42,12 @@ pub mod cols { // output_state[5][5][8] = 200 bytes pub const OUTPUT_STATE: usize = INPUT_STATE + 200; // 210 - // state_ptr[25][4] = 100 halfwords (DWordHL per lane) + // state_ptr[25][3] = 75 columns (DWordWHH per lane: half_lo, half_mid, word_hi) pub const STATE_PTR: usize = OUTPUT_STATE + 200; // 410 - pub const MU: usize = STATE_PTR + 100; // 510 + pub const MU: usize = STATE_PTR + 75; // 485 - pub const NUM_COLUMNS: usize = MU + 1; // 511 + pub const NUM_COLUMNS: usize = MU + 1; // 486 // ------------------------------------------------------------------------- // Index helpers @@ -69,10 +70,10 @@ pub mod cols { OUTPUT_STATE + (x + 5 * y) * 8 + byte } - /// Index into state_ptr[lane_idx][halfword] (DWordHL = 4 halfwords) + /// Index into state_ptr[lane_idx][limb] (DWordWHH = [half_lo, half_mid, word_hi]) #[inline] - pub const fn state_ptr(lane_idx: usize, hw: usize) -> usize { - STATE_PTR + lane_idx * 4 + hw + pub const fn state_ptr(lane_idx: usize, limb: usize) -> usize { + STATE_PTR + lane_idx * 3 + limb } } @@ -88,6 +89,22 @@ pub struct KeccakOperation { pub output: [u64; 25], } +/// The `IS_WH` sends of the KECCAK table: one per lane of every call, the top +/// 48 bits of `state_ptr[lane] = state_addr + 8·lane` (`keccak:c:range_state_ptr`). +pub fn is_wh_keys(ops: &[KeccakOperation]) -> Vec { + let mut keys = Vec::with_capacity(ops.len() * 25); + for op in ops { + for lane_idx in 0..25 { + let ptr = op + .state_addr + .checked_add(lane_idx as u64 * 8) + .expect("keccak state address range must be validated by the executor"); + keys.push(IsWhKey::of_dword(ptr)); + } + } + keys +} + // ========================================================================= // Trace generation // ========================================================================= @@ -133,7 +150,7 @@ pub fn generate_keccak_trace( .state_addr .checked_add(lane_idx as u64 * 8) .expect("keccak state address range must be validated by the executor"); - table.set_dword_hl(row_idx, cols::state_ptr(lane_idx, 0), ptr); + table.set_dword_whh(row_idx, cols::state_ptr(lane_idx, 0), ptr); } // mu = 1 (real row) @@ -141,7 +158,7 @@ pub fn generate_keccak_trace( } // Padding rows: state_ptr[lane][0] = 8 * lane_idx (per spec keccak.toml pad). - // Halfwords 1..3 stay zero since 8*24 = 192 fits in the low halfword. + // half_mid and word_hi stay zero since 8*24 = 192 fits in half_lo. // mu = 0 gates all bus interactions and the ADD constraint, so these values // only need to satisfy the pad requirement, not reconstruct a real address. for row_idx in n..num_rows { @@ -324,18 +341,14 @@ pub fn bus_interactions() -> Vec { )); } - // 4. IS_HALF range checks on state_ptr (100 interactions) + // 4. IS_WHH range checks on state_ptr (keccak:c:range_state_ptr): IS_WH on the + // top 48 bits and IS_HALF on half_lo, 50 interactions. μ is a bit (idx 51 below), + // as the template's `cond: Bit` requires. for lane_idx in 0..25 { - for hw in 0..4 { - interactions.push(BusInteraction::sender( - BusId::IsHalfword, - Multiplicity::Column(cols::MU), - vec![BusValue::Packed { - start_column: cols::state_ptr(lane_idx, hw), - packing: Packing::Direct, - }], - )); - } + interactions.extend(is_whh( + cols::state_ptr(lane_idx, 0), + Multiplicity::Column(cols::MU), + )); } // 5. Alignment: addr[0] & 7 = 0, which enforces addr % 8 == 0. @@ -385,7 +398,7 @@ pub fn bus_interactions() -> Vec { let x = lane_idx % 5; let y = lane_idx / 5; - // Address as DWordWL: lo32 = h0 + 2^16*h1, hi32 = h2 + 2^16*h3 + // Address as DWordWL: lo32 = half_lo + 2^16*half_mid, hi32 = word_hi let addr_lo = BusValue::linear(vec![ LinearTerm::Column { coefficient: 1, @@ -396,16 +409,10 @@ pub fn bus_interactions() -> Vec { column: cols::state_ptr(lane_idx, 1), }, ]); - let addr_hi = BusValue::linear(vec![ - LinearTerm::Column { - coefficient: 1, - column: cols::state_ptr(lane_idx, 2), - }, - LinearTerm::Column { - coefficient: 65536, - column: cols::state_ptr(lane_idx, 3), - }, - ]); + let addr_hi = BusValue::Packed { + start_column: cols::state_ptr(lane_idx, 2), + packing: Packing::Direct, + }; let mut values = Vec::with_capacity(24); // old[0..8] = input_state bytes (the value being read) @@ -458,7 +465,7 @@ pub fn bus_interactions() -> Vec { /// The KECCAK core table's 52 transition constraints as a single [`ConstraintSet`]: /// - idx 0-49: for `lane_idx ∈ 0..25`, the `ADD` carry pair (gated on `μ`) /// enforcing `state_ptr[lane] = addr + 8·lane_idx` (`addr` DWordBL, -/// `state_ptr` DWordHL); +/// `state_ptr` DWordWHH); /// - idx 50: `μ · carry_1 = 0` (top-lane no-overflow), where `carry_1` is the /// high carry of `addr + 192 = state_ptr[24]`; /// - idx 51: `μ · (1 − μ) = 0` (MuIsBit). @@ -482,7 +489,7 @@ impl ConstraintSet for KeccakConstraints { &[cols::MU], &AddOperand::from_dword_bl(cols::ADDR), &AddOperand::constant(offset), - &AddOperand::from_dword_hl(cols::state_ptr(lane_idx, 0)), + &AddOperand::from_dword_whh(cols::state_ptr(lane_idx, 0)), ); } @@ -498,9 +505,8 @@ impl ConstraintSet for KeccakConstraints { + b.main(0, cols::addr(5)) * c256 + b.main(0, cols::addr(6)) * c65536.clone() + b.main(0, cols::addr(7)) * c16777216; - let ptr_lo = - b.main(0, cols::state_ptr(24, 0)) + b.main(0, cols::state_ptr(24, 1)) * c65536.clone(); - let ptr_hi = b.main(0, cols::state_ptr(24, 2)) + b.main(0, cols::state_ptr(24, 3)) * c65536; + let ptr_lo = b.main(0, cols::state_ptr(24, 0)) + b.main(0, cols::state_ptr(24, 1)) * c65536; + let ptr_hi = b.main(0, cols::state_ptr(24, 2)); let inv_2_32 = b.const_base(INV_SHIFT_32); let c192 = b.const_base(192); diff --git a/prover/src/tables/mod.rs b/prover/src/tables/mod.rs index 4f125a0a2..db93007c3 100644 --- a/prover/src/tables/mod.rs +++ b/prover/src/tables/mod.rs @@ -10,6 +10,7 @@ //! - **DECODE**: Instruction decode table //! - **BRANCH**: Branch target calculation table //! - **HALT**: Single-row halt table +//! - **IS_WH**: Range check for the top 48 bits of `DWordWHH` addresses (one row per distinct pair) //! //! ## Memory Tables //! @@ -35,6 +36,7 @@ pub mod eq; pub mod global_memory; pub mod halt; pub mod hint; +pub mod is_wh; pub mod keccak; pub mod keccak_rc; pub mod keccak_rnd; diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index d10026f33..1569bda32 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -52,6 +52,7 @@ use super::ecsm; use super::eq; use super::halt; use super::hint; +use super::is_wh; use super::keccak::{self, KeccakOperation}; use super::keccak_rc; use super::keccak_rnd::{self, KeccakRoundOperation}; @@ -2575,19 +2576,18 @@ pub(crate) fn collect_bitwise_from_keccak(keccak_ops: &[KeccakOperation]) -> Vec )); } - // IS_HALF for state_ptr halfwords (100 per call) + // IS_HALF for state_ptr half_lo (25 per call). The top 48 bits go to + // IS_WH through `IS_WHH`; that table counts its own checks. for lane_idx in 0..25 { let ptr = state_addr .checked_add(lane_idx as u64 * 8) .expect("keccak state address range must be validated by the executor"); - for shift in [0, 16, 32, 48] { - let half = ((ptr >> shift) & 0xFFFF) as u16; - ops.push(BitwiseOperation::halfword( - BitwiseOperationType::IsHalf, - (half & 0xFF) as u8, - ((half >> 8) & 0xFF) as u8, - )); - } + let half = (ptr & 0xFFFF) as u16; + ops.push(BitwiseOperation::halfword( + BitwiseOperationType::IsHalf, + (half & 0xFF) as u8, + ((half >> 8) & 0xFF) as u8, + )); } // Replay keccak round computation to extract bitwise lookups @@ -2988,6 +2988,10 @@ pub struct Traces { /// makes no hint ecall. pub hints: Vec>, + /// IS_WH table (one row per distinct `(Word, Half)` address pair sent through + /// `IS_WHH`). Empty when no chip sends one. + pub is_whs: Vec>, + /// MEMW_R register-only fast-path traces (split into chunks of max_rows::MEMW_R) pub memw_registers: Vec>, /// Local-to-global boundary table for continuation epochs. Empty unless the @@ -3398,6 +3402,10 @@ fn build_traces( // sources fold their transient `collect_*` Vec in and drop it. The histogram is a // commutative monoid, so per-worker histograms tree-reduce to multiplicities that are // independent of accumulation order. + // `IS_WH` sends of every `IS_WHH` caller (today only KECCAK's state_ptr). Shared by + // the BITWISE collector below and the IS_WH trace in phase 5. + let is_wh_keys = keccak::is_wh_keys(&keccak_ops); + type Collector<'a> = Box; let lt_chunk = max_rows.lt; let branch_chunk = max_rows.branch; @@ -3449,6 +3457,7 @@ fn build_traces( Box::new(|h| h.add_ops(&collect_bitwise_from_ecsm(&ecsm_ops))), Box::new(|h| h.add_ops(&collect_bitwise_from_ecdas(&ecdas_ops))), Box::new(|h| h.add_ops(&collect_bitwise_from_hint(&hint_ops))), + Box::new(|h| is_wh::collect_bitwise_from_is_wh(&is_wh_keys, h)), Box::new(|h| add_padding_byte_checks(h, num_padding_rows)), ]; if let Some(image) = initial_image @@ -3777,6 +3786,15 @@ fn build_traces( storage_mode, ) }; + // IS_WH table. Absent entirely when no chip sends `IS_WH`. + let gen_is_whs = || { + generate_optional( + &is_wh_keys, + is_wh::generate_is_wh_trace, + #[cfg(feature = "disk-spill")] + storage_mode, + ) + }; let (mut cpus_slot, mut memws_slot, mut memw_aligneds_slot, mut memw_registers_slot) = (None, None, None, None); @@ -3790,6 +3808,7 @@ fn build_traces( (None, None, None, None); let (mut ecsms_slot, mut ecdases_slot) = (None, None); let mut hints_slot = None; + let mut is_whs_slot = None; #[cfg(feature = "disk-spill")] let sequential = storage_mode == StorageMode::Disk || cfg!(not(feature = "parallel")); @@ -3832,6 +3851,7 @@ fn build_traces( spawn_into!(ecsms_slot, gen_ecsms); spawn_into!(ecdases_slot, gen_ecdases); spawn_into!(hints_slot, gen_hints); + spawn_into!(is_whs_slot, gen_is_whs); }); } else { cpus_slot = Some(gen_cpus()); @@ -3860,6 +3880,7 @@ fn build_traces( ecsms_slot = Some(gen_ecsms()); ecdases_slot = Some(gen_ecdases()); hints_slot = Some(gen_hints()); + is_whs_slot = Some(gen_is_whs()); } const PHASE5_RAN: &str = "phase 5 generation ran in one of the branches above"; @@ -3894,6 +3915,7 @@ fn build_traces( let ecsms = ecsms_slot.expect(PHASE5_RAN)?; let ecdases = ecdases_slot.expect(PHASE5_RAN)?; let hints = hints_slot.expect(PHASE5_RAN)?; + let is_whs = is_whs_slot.expect(PHASE5_RAN)?; // Fixed-size and per-page tables aren't built through `chunk_and_generate`, // so spill them here before returning. @@ -3958,6 +3980,7 @@ fn build_traces( ecsms, ecdases, hints, + is_whs, memw_registers, local_to_global, touched_memory_cells, @@ -4329,6 +4352,7 @@ impl Traces { use super::eq::cols::NUM_COLUMNS as EQ_COLS; use super::halt::cols::NUM_COLUMNS as HALT_COLS; use super::hint::cols::NUM_COLUMNS as HINT_COLS; + use super::is_wh::cols::NUM_COLUMNS as IS_WH_COLS; use super::keccak::cols::NUM_COLUMNS as KECCAK_COLS; use super::keccak_rc::NUM_PRECOMPUTED_COLS as KECCAK_RC_PRECOMPUTED; use super::keccak_rc::cols::NUM_COLUMNS as KECCAK_RC_COLS; @@ -4368,6 +4392,7 @@ impl Traces { ecsms, ecdases, hints, + is_whs, memw_registers, eqs, bytewises, @@ -4448,6 +4473,9 @@ impl Traces { for t in hints { total += (t.num_rows() * HINT_COLS) as u64; } + for t in is_whs { + total += (t.num_rows() * IS_WH_COLS) as u64; + } total } @@ -4490,6 +4518,7 @@ impl Traces { let n_ecsm = aux_cols(super::ecsm::bus_interactions().len()); let n_ecdas = aux_cols(super::ecdas::bus_interactions().len()); let n_hint = aux_cols(super::hint::bus_interactions().len()); + let n_is_wh = aux_cols(super::is_wh::bus_interactions().len()); let Traces { cpus, @@ -4513,6 +4542,7 @@ impl Traces { ecsms, ecdases, hints, + is_whs, memw_registers, eqs, bytewises, @@ -4593,6 +4623,9 @@ impl Traces { for t in hints { total += (t.num_rows() * n_hint) as u64; } + for t in is_whs { + total += (t.num_rows() * n_is_wh) as u64; + } total } @@ -4619,6 +4652,7 @@ impl Traces { ecdas: self.ecdases.len(), hint: self.hints.len(), commit: self.commits.len(), + is_wh: self.is_whs.len(), } } diff --git a/prover/src/tables/types.rs b/prover/src/tables/types.rs index da98dddc0..ffe741d16 100644 --- a/prover/src/tables/types.rs +++ b/prover/src/tables/types.rs @@ -359,6 +359,15 @@ pub enum BusId { /// Cross-epoch memory bus: the local-to-global table's per-cell init/fini /// boundary claims, matched across epochs by the final aggregation LogUp. GlobalMemory = 31, + + // ========================================================================= + // Address range checks + // ========================================================================= + /// `IS_WH[Word, Half]`: range check that the top 48 bits of a `DWordWHH` + /// are a valid `(Word, Half)` pair. Callers send it through the `IS_WHH` + /// template; the IS_WH table receives it. ID 29 (the removed ECSM bus) is + /// left unused. + IsWh = 32, } impl BusId { @@ -388,6 +397,7 @@ impl BusId { BusId::Ecdas => "Ecdas", BusId::Bit => "Bit", BusId::GlobalMemory => "GlobalMemory", + BusId::IsWh => "IsWh", } } } @@ -420,6 +430,7 @@ impl TryFrom for BusId { 28 => Ok(BusId::Ecdas), 30 => Ok(BusId::Bit), 31 => Ok(BusId::GlobalMemory), + 32 => Ok(BusId::IsWh), other => Err(other), } } diff --git a/prover/src/test_utils.rs b/prover/src/test_utils.rs index b0d1d3977..608f8ee68 100644 --- a/prover/src/test_utils.rs +++ b/prover/src/test_utils.rs @@ -69,6 +69,7 @@ use crate::tables::halt::{bus_interactions as halt_bus_interactions, cols as hal use crate::tables::hint::{ HintConstraints, bus_interactions as hint_bus_interactions, cols as hint_cols, }; +use crate::tables::is_wh::{bus_interactions as is_wh_bus_interactions, cols as is_wh_cols}; use crate::tables::keccak::{ KeccakConstraints, bus_interactions as keccak_bus_interactions, cols as keccak_cols, }; @@ -921,6 +922,18 @@ pub fn create_hint_air(proof_options: &ProofOptions) -> ConcreteVmAir ConcreteVmAir { + build_air( + is_wh_cols::NUM_COLUMNS, + is_wh_bus_interactions(), + proof_options, + 1, + EmptyConstraints, + "IS_WH", + ) +} + /// Create COMMIT AIR with constraints and bus interactions. pub fn create_commit_air(proof_options: &ProofOptions) -> ConcreteVmAir { build_air( diff --git a/prover/src/tests/is_wh_tests.rs b/prover/src/tests/is_wh_tests.rs new file mode 100644 index 000000000..34e65186c --- /dev/null +++ b/prover/src/tests/is_wh_tests.rs @@ -0,0 +1,388 @@ +//! Tests for the IS_WH table and the IS_WHH template (spec #1003, #1015, #1017), +//! and for the KECCAK `state_ptr` that is their first caller. + +use crypto::fiat_shamir::default_transcript::DefaultTranscript; +use stark::prover::{IsStarkProver, Prover}; + +use crate::statement::{StatementKind, absorb_statement}; +use crate::tables::bitwise::{ + BitwiseHistogram, BitwiseOperation, BitwiseOperationType, cols as bw_cols, + row_index as bw_row_index, +}; +use crate::tables::is_wh::{ + self, IsWhKey, MU_MAX, collect_bitwise_from_is_wh, cols, dedup_is_wh_rows, generate_is_wh_trace, +}; +use crate::tables::keccak::{ + self, KeccakConstraints, KeccakOperation, cols as keccak_cols, generate_keccak_trace, +}; +use crate::tables::trace_builder::Traces; +use crate::tables::types::{BusId, FE}; +use crate::test_utils::{E, asm_elf_bytes, busless_air, validate_busless}; +use crate::{MaxRowsConfig, VmAirs, VmProof}; + +use executor::elf::Elf; +use executor::vm::execution::Executor; + +const A: IsWhKey = IsWhKey { + word: 0x0000_0001, + half: 0x0002, +}; +const B: IsWhKey = IsWhKey { + word: 0xFFFF_FFFF, + half: 0xFFFF, +}; + +fn keccak_op(state_addr: u64) -> KeccakOperation { + KeccakOperation { + timestamp: 7, + state_addr, + input: [0x0102_0304_0506_0708; 25], + output: [0; 25], + } +} + +// ========================================================================= +// Keys and dedup +// ========================================================================= + +#[test] +fn test_is_wh_key_of_dword_takes_the_top_48_bits() { + let key = IsWhKey::of_dword(0x1234_5678_9ABC_DEF0); + assert_eq!(key.word, 0x1234_5678, "word = bits 32..64"); + assert_eq!(key.half, 0x9ABC, "half = bits 16..32"); + // The low half is not part of the key: it is IS_HALF-checked by the caller. + assert_eq!(IsWhKey::of_dword(0x1234_5678_9ABC_0000), key); +} + +#[test] +fn test_is_wh_dedup_collapses_repeated_keys() { + let rows = dedup_is_wh_rows(&[B, A, A, B, A]); + // Sorted by key, so the trace does not depend on hash order. + assert_eq!(rows, vec![(A, 3), (B, 2)]); +} + +#[test] +fn test_is_wh_dedup_of_no_sends_is_empty() { + assert!(dedup_is_wh_rows(&[]).is_empty()); +} + +#[test] +fn test_is_wh_dedup_splits_mu_above_b20() { + let total_mu = |rows: &[(IsWhKey, u64)]| rows.iter().map(|(_, mu)| mu).sum::(); + + // Exactly MU_MAX fits one row. + let rows = dedup_is_wh_rows(&vec![A; MU_MAX as usize]); + assert_eq!(rows, vec![(A, MU_MAX)]); + + // One more spills into a second row of the same key. + let rows = dedup_is_wh_rows(&vec![A; MU_MAX as usize + 1]); + assert_eq!(rows, vec![(A, MU_MAX), (A, 1)]); + + // Two full rows and a remainder, next to an unrelated key. + let mut keys = vec![A; 2 * MU_MAX as usize + 3]; + keys.push(B); + let rows = dedup_is_wh_rows(&keys); + assert_eq!(rows, vec![(A, MU_MAX), (A, MU_MAX), (A, 3), (B, 1)]); + assert_eq!(total_mu(&rows), keys.len() as u64, "no send lost or added"); + assert!(rows.iter().all(|(_, mu)| *mu <= MU_MAX && *mu > 0)); +} + +// ========================================================================= +// Trace +// ========================================================================= + +#[test] +fn test_is_wh_trace_layout_and_padding() { + let trace = generate_is_wh_trace(&[A, B, A]); + let t = &trace.main_table; + assert_eq!( + trace.num_rows(), + 4, + "two real rows padded to the minimum of 4" + ); + + // value[0] = half, value[1..2] = word as two halves (low first). + assert_eq!(*t.get(0, cols::VALUE_0), FE::from(A.half as u64)); + assert_eq!(*t.get(0, cols::VALUE_1), FE::from((A.word & 0xFFFF) as u64)); + assert_eq!(*t.get(0, cols::VALUE_2), FE::from((A.word >> 16) as u64)); + assert_eq!(*t.get(0, cols::MU), FE::from(2u64)); + assert_eq!(*t.get(1, cols::VALUE_0), FE::from(0xFFFFu64)); + assert_eq!(*t.get(1, cols::VALUE_1), FE::from(0xFFFFu64)); + assert_eq!(*t.get(1, cols::VALUE_2), FE::from(0xFFFFu64)); + assert_eq!(*t.get(1, cols::MU), FE::one()); + + // Padding is all zero (spec pad = 0). + for row in 2..4 { + for col in 0..cols::NUM_COLUMNS { + assert_eq!(*t.get(row, col), FE::zero(), "padding ({row}, {col})"); + } + } +} + +#[test] +fn test_is_wh_trace_exact_power_of_two_has_no_padding() { + let keys: Vec = (0..8).map(|i| IsWhKey { word: i, half: 0 }).collect(); + let trace = generate_is_wh_trace(&keys); + assert_eq!(trace.num_rows(), 8); + for row in 0..8 { + assert_eq!(*trace.main_table.get(row, cols::MU), FE::one()); + } +} + +// ========================================================================= +// BITWISE collector +// ========================================================================= + +fn half_op(v: u16) -> BitwiseOperation { + BitwiseOperation::halfword(BitwiseOperationType::IsHalf, v as u8, (v >> 8) as u8) +} + +fn b20_op(v: u64) -> BitwiseOperation { + BitwiseOperation::b20(v as u8, (v >> 8) as u8, (v >> 16) as u8) +} + +/// Every lookup the collector counts, one op per unit of multiplicity. +fn expected_ops(rows: &[(IsWhKey, u64)], padding: usize) -> Vec { + let mut ops = Vec::new(); + for (key, mu) in rows { + for _ in 0..*mu { + ops.push(half_op(key.half)); + ops.push(half_op((key.word & 0xFFFF) as u16)); + ops.push(half_op((key.word >> 16) as u16)); + } + ops.push(b20_op(*mu)); + } + ops.extend(std::iter::repeat_n(b20_op(0), padding)); + ops +} + +#[test] +fn test_is_wh_collector_counts_mu_bound_and_padding() { + let keys = [A, B, A]; + let mut got = BitwiseHistogram::new(); + collect_bitwise_from_is_wh(&keys, &mut got); + + // Rows (A, 2), (B, 1), two padding rows each sending IS_B20[0]. + let mut want = BitwiseHistogram::new(); + want.add_ops(&expected_ops(&[(A, 2), (B, 1)], 2)); + assert!(got == want, "collector disagrees with the table's sends"); +} + +#[test] +fn test_is_wh_collector_counts_nothing_without_sends() { + // No sends: `generate_optional` emits no table, so no padding either. + let mut got = BitwiseHistogram::new(); + collect_bitwise_from_is_wh(&[], &mut got); + assert!(got == BitwiseHistogram::new()); +} + +// ========================================================================= +// KECCAK state_ptr as DWordWHH +// ========================================================================= + +/// `state_ptr[lane]` recomposed from its three limbs. +fn state_ptr_value(t: &stark::trace::TraceTable, lane: usize) -> u64 { + let limb = |l: usize| { + t.main_table + .get(0, keccak_cols::state_ptr(lane, l)) + .canonical_u64() + }; + limb(0) | (limb(1) << 16) | (limb(2) << 32) +} + +/// The ADD from `addr` to `state_ptr` has to carry across both limb boundaries +/// of a DWordWHH: into `half_mid` at 2^16 and into `word_hi` at 2^32. +#[test] +fn test_keccak_state_ptr_whh_across_limb_boundaries() { + let air = busless_air(keccak_cols::NUM_COLUMNS, KeccakConstraints); + for state_addr in [ + 0x1000, + 0x0000_0000_FFFF_FFA0, // lanes cross 2^32 (word_hi 0 → 1) + 0x0000_FFFF_FFFF_FFA0, // lanes cross 2^48 (word_hi low half wraps) + 0x0000_0000_0000_FFF8, // lane 1 crosses 2^16 (half_mid 0 → 1) + ] { + let op = keccak_op(state_addr); + let trace = generate_keccak_trace(std::slice::from_ref(&op)); + for lane in 0..25 { + assert_eq!( + state_ptr_value(&trace, lane), + state_addr + 8 * lane as u64, + "state_ptr[{lane}] for addr {state_addr:#x}" + ); + } + assert!( + validate_busless(&air, &trace), + "honest KECCAK trace must satisfy the ADD for addr {state_addr:#x}" + ); + + // The IS_WH keys are the top 48 bits of the same pointers. + let keys = keccak::is_wh_keys(std::slice::from_ref(&op)); + assert_eq!(keys.len(), 25); + for (lane, key) in keys.iter().enumerate() { + assert_eq!(*key, IsWhKey::of_dword(state_addr + 8 * lane as u64)); + } + } +} + +/// A `state_ptr` whose limbs describe a different address than `addr + 8·lane` +/// is rejected by the ADD, so the IS_WH keys cannot drift from the real pointers. +#[test] +fn test_keccak_state_ptr_whh_rejects_a_shifted_word() { + let air = busless_air(keccak_cols::NUM_COLUMNS, KeccakConstraints); + let mut trace = generate_keccak_trace(&[keccak_op(0x1000)]); + let col = keccak_cols::state_ptr(3, 2); + let old = *trace.main_table.get(0, col); + trace.main_table.set(0, col, old + FE::one()); + assert!(!validate_busless(&air, &trace)); +} + +// ========================================================================= +// End to end: the IS_B20 bound on μ +// ========================================================================= + +/// Moves `delta` onto the BITWISE `IS_B20` receiver row for `value`. +fn bump_b20(traces: &mut Traces, value: u64, delta: FE) { + let bw = &mut traces.bitwise.main_table; + let row = bw_row_index(value as u8, (value >> 8) as u8, (value >> 16) as u8); + let old = *bw.get(row, bw_cols::MU_IS_B20); + bw.set(row, bw_cols::MU_IS_B20, old + delta); +} + +/// Proves `test_keccak` after letting `rewrite` reshape the IS_WH rows, and +/// returns whether the verifier accepts. The prover is the malicious one: it +/// skips no check of its own, it simply builds a proof over the rewritten trace. +fn prove_keccak_with(rewrite: impl FnOnce(&mut Traces)) -> bool { + let elf_bytes = asm_elf_bytes("test_keccak"); + let options = crate::GoldilocksCubicProofOptions::with_blowup(2).expect("blowup=2 is valid"); + let program = Elf::load(&elf_bytes).expect("ELF load"); + let logs = Executor::new(&program, vec![]) + .expect("executor construction") + .run() + .expect("run") + .logs; + let mut traces = Traces::from_elf_and_logs( + &program, + &logs, + &MaxRowsConfig::default(), + &[], + #[cfg(feature = "disk-spill")] + stark::storage_mode::StorageMode::Ram, + ) + .expect("trace build"); + + rewrite(&mut traces); + + let table_counts = traces.table_counts(); + let airs = VmAirs::new( + &program, + &options, + false, + &traces.page_configs, + &table_counts, + None, + true, + None, + None, + None, + ); + let runtime_page_ranges = traces.runtime_page_ranges(); + let num_private_input_pages = traces + .page_configs + .iter() + .filter(|c| c.is_private_input) + .count(); + let mut transcript = DefaultTranscript::::new(&[]); + absorb_statement( + &mut transcript, + StatementKind::Monolithic, + &elf_bytes, + &traces.public_output_bytes, + &table_counts, + num_private_input_pages, + &runtime_page_ranges, + options.fri_final_poly_log_degree, + ); + let proof = Prover::multi_prove( + airs.air_trace_pairs(&mut traces), + &mut transcript, + #[cfg(feature = "disk-spill")] + stark::storage_mode::StorageMode::Ram, + ) + .expect("prover must build the crafted proof; a prover refusal is not a verifier rejection"); + let proof = VmProof { + proof, + runtime_page_ranges, + table_counts, + public_output: traces.public_output_bytes.clone(), + num_private_input_pages, + }; + crate::verify_with_options(&proof, &elf_bytes, &options, None, None) + .expect("verify must not error") +} + +/// Splits IS_WH row 0 (μ = m) into `m − moved` on row 0 and `moved` on the +/// first padding row, copying the key, and rebalances every BITWISE receiver +/// that has a row for the values involved. The IS_WH and IS_HALFWORD sums are +/// unchanged by construction (both rows carry the same key); only the IS_B20 +/// bound sees the split. +fn split_first_row(traces: &mut Traces, moved: FE) { + let t = &mut traces.is_whs[0].main_table; + let m = t.get(0, cols::MU).canonical_u64(); + assert!( + m >= 2, + "test_keccak's 25 lanes must share a key for this split" + ); + let pad = (0..t.height) + .find(|&r| *t.get(r, cols::MU) == FE::zero()) + .expect("a padding row"); + for col in [cols::VALUE_0, cols::VALUE_1, cols::VALUE_2] { + let v = *t.get(0, col); + t.set(pad, col, v); + } + let kept = FE::from(m) - moved; + t.set(0, cols::MU, kept); + t.set(pad, cols::MU, moved); + + // IS_B20 ×1 per row: row 0 moved from m to `kept`, the padding row from 0 + // to `moved`. A value outside [0, 2^20) has no BITWISE row to move onto. + // Read off the table's own sends, as the attacker would: with the bound + // removed there is nothing to rebalance and the forgery must go through, + // which is what keeps the negative test below falsifiable. + let b20: u64 = BusId::IsB20.into(); + if !is_wh::bus_interactions().iter().any(|bi| bi.bus_id == b20) { + return; + } + bump_b20(traces, m, -FE::one()); + bump_b20(traces, 0, -FE::one()); + for v in [kept, moved] { + let v = v.canonical_u64(); + if v <= MU_MAX { + bump_b20(traces, v, FE::one()); + } + } +} + +/// Control: a split into two positive multiplicities is an honest witness (the +/// same shape `dedup_is_wh_rows` produces past `MU_MAX`) and verifies. This is +/// what makes the rejection below attributable to the sign of μ alone. +#[test] +fn test_is_wh_positive_split_verifies() { + assert!(prove_keccak_with(|t| split_first_row(t, FE::one()))); +} + +/// The carrier #1017 closes: a μ = −1 row with an honest twin at μ = m + 1 +/// leaves the IS_WH and IS_HALFWORD sums untouched, so without the IS_B20 bound +/// the row would be free to receive range checks it never sends. The bound +/// sends `IS_B20[p − 1]`, which BITWISE has no row for. +#[test] +fn test_is_wh_negative_mu_is_rejected() { + assert!(!prove_keccak_with(|t| split_first_row(t, -FE::one()))); +} + +/// The honest proof of the fixture, so the two above are not both vacuous. +#[test] +fn test_is_wh_honest_keccak_verifies() { + assert!(prove_keccak_with(|t| { + assert_eq!(t.is_whs.len(), 1, "test_keccak must ship an IS_WH table"); + })); +} diff --git a/prover/src/tests/mod.rs b/prover/src/tests/mod.rs index 18e2fec22..021a74bca 100644 --- a/prover/src/tests/mod.rs +++ b/prover/src/tests/mod.rs @@ -55,6 +55,8 @@ pub mod hint_tests; #[cfg(test)] pub mod ir_stats_dump; #[cfg(test)] +pub mod is_wh_tests; +#[cfg(test)] pub mod keccak_rnd_tests; #[cfg(test)] pub mod keccak_tests; diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index 2707e67af..b8599f349 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -2845,6 +2845,7 @@ fn test_verify_rejects_zero_table_counts() { ecdas: 0, hint: 0, commit: 0, + is_wh: 0, }, ..vm_proof }; @@ -2936,6 +2937,7 @@ fn test_crafted_zero_count_proof_must_not_verify() { ecdas: 0, hint: 0, commit: 0, + is_wh: 0, }; let airs = VmAirs::new( &elf, diff --git a/prover/src/tests/skip_empty_tables_tests.rs b/prover/src/tests/skip_empty_tables_tests.rs index 0549562fd..f5a889e4e 100644 --- a/prover/src/tests/skip_empty_tables_tests.rs +++ b/prover/src/tests/skip_empty_tables_tests.rs @@ -100,6 +100,7 @@ fn every_table_participates_in_the_bus() { ecdas: 1, hint: 1, commit: 1, + is_wh: 1, }; let airs = VmAirs::new( &elf, @@ -158,6 +159,7 @@ fn droppable_air_names(counts: &TableCounts) -> Vec<&'static str> { ecdas, hint, commit, + is_wh, } = counts; [ ("CPU", cpu), @@ -180,6 +182,7 @@ fn droppable_air_names(counts: &TableCounts) -> Vec<&'static str> { ("ECDAS", ecdas), ("HINT", hint), ("COMMIT", commit), + ("IS_WH", is_wh), ] .into_iter() .map(|(name, _count)| name) @@ -374,12 +377,13 @@ fn dropping_a_used_table_through_the_real_verifier_is_rejected() { /// PAGE, REGISTER, GLOBAL_MEMORY — are exactly the mandatory ones, and nothing /// enforced that this stays true. /// -/// One exception, by bus: `ARE_BYTES` byte checks the spec gives no `cond` -/// (default 1) — the μ bounds of the dedup tables (spec #1017), SHIFT's -/// `shift[0..1]` bytes and KECCAK_RND's θ/ρ bytes. A padding row sends them as -/// `ARE_BYTES[0, 0]`, which is in range — the test checks this by requiring each -/// value to vanish on an all-zero row (columns only, no nonzero constant term) — -/// and their only counterparty is BITWISE, whose multiplicity columns are +/// One exception, by bus: `ARE_BYTES` and `IS_B20` range checks the spec gives +/// no `cond` (default 1) — the μ bounds of the dedup tables (spec #1017, an +/// `IS_B20[μ]` on IS_WH), SHIFT's `shift[0..1]` bytes and KECCAK_RND's θ/ρ +/// bytes. A padding row sends them as `ARE_BYTES[0, 0]` / `IS_B20[0]`, which is +/// in range — the test checks this by requiring each value to vanish on an +/// all-zero row (columns only, no nonzero constant term) — and their only +/// counterparty is BITWISE, whose multiplicity columns are /// free witnesses, so the contribution is absorbed whichever way an unused chip /// went; and an optional table with no ops is never shipped /// (`chunk_and_generate_optional`/`generate_optional` return no table), so the @@ -388,11 +392,11 @@ fn dropping_a_used_table_through_the_real_verifier_is_rejected() { #[test] fn no_optional_table_uses_a_constant_multiplicity() { use crate::tables::{ - branch, bytewise, commit, cpu32, dvrm, ecdas, ecsm, eq, hint, keccak, keccak_rnd, load, lt, - memw, memw_aligned, mul, shift, store, + branch, bytewise, commit, cpu32, dvrm, ecdas, ecsm, eq, hint, is_wh, keccak, keccak_rnd, + load, lt, memw, memw_aligned, mul, shift, store, }; use stark::lookup::{BusValue, LinearTerm}; - let optional: [(&str, Vec); 18] = [ + let optional: [(&str, Vec); 19] = [ ("LT", lt::bus_interactions()), ("MEMW", memw::bus_interactions()), ("MEMW_A", memw_aligned::bus_interactions()), @@ -411,6 +415,7 @@ fn no_optional_table_uses_a_constant_multiplicity() { ("ECDAS", ecdas::bus_interactions()), ("HINT", hint::bus_interactions()), ("COMMIT", commit::bus_interactions()), + ("IS_WH", is_wh::bus_interactions()), ]; for (name, interactions) in optional { assert!( @@ -418,13 +423,15 @@ fn no_optional_table_uses_a_constant_multiplicity() { "{name} declares no bus interactions" ); for (i, interaction) in interactions.iter().enumerate() { + use crate::tables::types::BusId; let zero_on_padding = interaction.values.iter().all(|value| match value { BusValue::Packed { .. } => true, BusValue::Linear(terms) => terms .iter() .all(|term| !matches!(term, LinearTerm::Constant(c) if *c != 0)), }); - let byte_check = interaction.bus_id == u64::from(crate::tables::types::BusId::AreBytes) + let byte_check = (interaction.bus_id == u64::from(BusId::AreBytes) + || interaction.bus_id == u64::from(BusId::IsB20)) && interaction.is_sender && zero_on_padding; assert!( @@ -587,13 +594,14 @@ fn an_accelerator_count_above_one_is_rejected() { // Named setters rather than a match with a catch-all: a wrong field would // otherwise pass the assertion below while testing the wrong count. type SetCount = fn(&mut TableCounts); - let inflate: [(&str, SetCount); 6] = [ + let inflate: [(&str, SetCount); 7] = [ ("keccak", |c| c.keccak = 2), ("keccak_rnd", |c| c.keccak_rnd = 2), ("ecsm", |c| c.ecsm = 2), ("ecdas", |c| c.ecdas = 2), ("hint", |c| c.hint = 2), ("commit", |c| c.commit = 2), + ("is_wh", |c| c.is_wh = 2), ]; for (name, set_to_two) in inflate { let mut counts = honest.clone(); @@ -717,6 +725,30 @@ fn omitting_a_chip_dispatched_by_another_chip_fails_the_bus_balance() { assert_only_the_bus_rejected(&weigh_the_bus(&elf, &mut traces, true), "KECCAK_RND"); } +/// IS_WH is reached from a chip's range checks rather than from a dispatch: it +/// receives the `IS_WH` half of every `IS_WHH` send (today KECCAK's +/// `state_ptr`). Dropping it leaves those sends unmatched — and unlike the +/// BITWISE range checks, whose receiver multiplicities are free witnesses, no +/// other table receives `IS_WH`, so nothing can absorb them. +#[test] +fn omitting_the_is_wh_table_fails_the_bus_balance() { + let (elf, logs, _instructions) = run_asm_elf("test_keccak"); + let mut traces = + Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[]).unwrap(); + assert!( + !traces.is_whs.is_empty(), + "test_keccak must exercise IS_WH for this to be a forgery" + ); + + assert!( + prove_and_verify_vm_minimal(&elf, &mut traces), + "the honest proof must verify first, or the negative below proves nothing" + ); + + traces.is_whs.clear(); + assert_only_the_bus_rejected(&weigh_the_bus(&elf, &mut traces, true), "IS_WH"); +} + /// COMMIT is the one newly-optional table whose bus target is not zero: the /// verifier computes it from the public output the prover supplies /// (`compute_expected_commit_bus_balance_view`), and nothing requires diff --git a/prover/src/tests/statement_tests.rs b/prover/src/tests/statement_tests.rs index 065d66ee7..8b11f8a91 100644 --- a/prover/src/tests/statement_tests.rs +++ b/prover/src/tests/statement_tests.rs @@ -29,6 +29,7 @@ fn sample_counts() -> TableCounts { ecdas: 1, hint: 1, commit: 1, + is_wh: 1, } } @@ -98,6 +99,7 @@ fn each_count_mut(counts: &mut TableCounts) -> Vec<(&'static str, &mut usize)> { ecdas, hint, commit, + is_wh, } = counts; vec![ ("cpu", cpu), @@ -120,6 +122,7 @@ fn each_count_mut(counts: &mut TableCounts) -> Vec<(&'static str, &mut usize)> { ("ecdas", ecdas), ("hint", hint), ("commit", commit), + ("is_wh", is_wh), ] } @@ -136,7 +139,7 @@ fn state_depends_on_every_table_count() { .into_iter() .map(|(name, _)| name) .collect(); - assert_eq!(names.len(), 20, "every count must be probed"); + assert_eq!(names.len(), 21, "every count must be probed"); for name in names { let mut counts = sample_counts(); diff --git a/prover/src/tests/trace_builder_tests.rs b/prover/src/tests/trace_builder_tests.rs index 7bc9026d4..dfd4b0277 100644 --- a/prover/src/tests/trace_builder_tests.rs +++ b/prover/src/tests/trace_builder_tests.rs @@ -630,8 +630,10 @@ mod keccak_tests { // θ/ρ halfword shifts are enforced by inline μ-gated identities on the // keccak_rnd chip, so no HWSL lookups are emitted (was 24 * 120). assert_eq!(hwsl, 0, "Hwsl count"); - assert_eq!(is_half, 100, "IsHalf count"); - assert_eq!(ops.len(), 105 + 24 * 1028, "Total bitwise ops"); + // IS_HALF only on each lane's state_ptr half_lo; the top 48 bits go to + // IS_WH (IS_WHH template), whose own checks the IS_WH collector counts. + assert_eq!(is_half, 25, "IsHalf count"); + assert_eq!(ops.len(), 30 + 24 * 1028, "Total bitwise ops"); } #[test] @@ -729,8 +731,8 @@ mod keccak_tests { fn test_keccak_bus_interaction_counts() { assert_eq!( keccak::bus_interactions().len(), - 134, - "KECCAK core: 1 ECALL + 1 MEMW read_addr + 25 MEMW lanes + 100 IS_HALF + 1 BYTE_ALU alignment + 4 ARE_BYTES addr pairs + 1 Keccak send + 1 Keccak recv" + 84, + "KECCAK core: 1 ECALL + 1 MEMW read_addr + 25 MEMW lanes + 25 IS_WHH (25 IS_WH + 25 IS_HALF) + 1 BYTE_ALU alignment + 4 ARE_BYTES addr pairs + 1 Keccak send + 1 Keccak recv" ); assert_eq!( keccak_rnd::bus_interactions().len(), @@ -749,7 +751,11 @@ mod keccak_tests { #[test] fn test_keccak_column_counts() { - assert_eq!(core_cols::NUM_COLUMNS, 511, "KECCAK core columns"); + assert_eq!( + core_cols::NUM_COLUMNS, + 486, + "KECCAK core columns (state_ptr DWordWHH)" + ); assert_eq!( rnd_cols::NUM_COLUMNS, 1480, From 7d73c78281414b92274bc00255a3cabff9142310 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Thu, 15 Oct 2026 12:26:05 -0300 Subject: [PATCH 24/29] Deduplicate the IS_WH rows once in the trace builder and pass them to both the trace and the BITWISE collector, instead of deduplicating the full key list twice. Also fix two comments: the at-most-one check is not only for accelerators, and bus ID 29 was the EC_SCALAR ServeK bus, not an ECSM one. --- prover/src/lib.rs | 10 +++++----- prover/src/tables/is_wh.rs | 22 +++++++++++----------- prover/src/tables/keccak.rs | 16 +++++++--------- prover/src/tables/trace_builder.rs | 11 ++++++----- prover/src/tables/types.rs | 4 ++-- prover/src/tests/is_wh_tests.rs | 18 +++++++++--------- 6 files changed, 40 insertions(+), 41 deletions(-) diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 7c3edb543..139e935c9 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -201,10 +201,10 @@ impl TableCounts { ))); } } - // The accelerators are not chunked: `generate_optional` emits one table - // or none, so a count above 1 is a shape no prover can produce. The - // sub-proof cross-check constrains only the *total*, so on its own it - // lets an inflated accelerator count through whenever another count is + // The accelerators and IS_WH are not chunked: `generate_optional` emits + // one table or none, so a count above 1 is a shape no prover can produce. + // The sub-proof cross-check constrains only the *total*, so on its own it + // lets an inflated count through whenever another count is // lowered to match; this pins the per-field shape instead. It is not // load-bearing for memory safety — both verifiers run the cross-check // before any count sizes an AIR set — and if one of them ever becomes @@ -224,7 +224,7 @@ impl TableCounts { for (name, count) in at_most_one { if count > 1 { return Err(Error::InvalidTableCounts(format!( - "{name} count is {count} — accelerator tables are not chunked" + "{name} count is {count} — this table is not chunked" ))); } } diff --git a/prover/src/tables/is_wh.rs b/prover/src/tables/is_wh.rs index 15dc92927..565edf2ac 100644 --- a/prover/src/tables/is_wh.rs +++ b/prover/src/tables/is_wh.rs @@ -77,12 +77,12 @@ impl IsWhKey { /// Deduplicates `IS_WH` sends into trace rows: `(key) -> μ`, splitting a key /// over several rows when its count exceeds [`MU_MAX`]. Rows come out sorted by -/// key so the trace is deterministic. Shared by trace generation and the -/// BITWISE collector so the per-row lookups they count cannot drift apart. -pub fn dedup_is_wh_rows(keys: &[IsWhKey]) -> Vec<(IsWhKey, u64)> { +/// key so the trace is deterministic. The trace builder calls it once and hands +/// the same rows to [`generate_is_wh_trace`] and [`collect_bitwise_from_is_wh`]. +pub fn dedup_is_wh_rows(keys: impl IntoIterator) -> Vec<(IsWhKey, u64)> { let mut counts: BTreeMap = BTreeMap::new(); for key in keys { - *counts.entry(*key).or_insert(0) += 1; + *counts.entry(key).or_insert(0) += 1; } let mut rows = Vec::with_capacity(counts.len()); @@ -101,11 +101,12 @@ fn padded_rows(real_rows: usize) -> usize { real_rows.next_power_of_two().max(4) } -/// Generates the IS_WH trace from the `IS_WH` sends of every caller. +/// Generates the IS_WH trace from the rows of [`dedup_is_wh_rows`]. /// /// Padding rows are all zero (`value = 0`, `μ = 0`, spec `pad = 0`). -pub fn generate_is_wh_trace(keys: &[IsWhKey]) -> TraceTable { - let rows = dedup_is_wh_rows(keys); +pub fn generate_is_wh_trace( + rows: &[(IsWhKey, u64)], +) -> TraceTable { let num_rows = padded_rows(rows.len()); let mut trace = TraceTable::new_main( crate::tables::types::zeroed_fe_vec(num_rows * cols::NUM_COLUMNS), @@ -127,12 +128,11 @@ pub fn generate_is_wh_trace(keys: &[IsWhKey]) -> TraceTable> 8) as u8, (v >> 16) as u8); - for (key, mu) in &rows { + for (key, mu) in rows { h.bump_n(half(key.half), *mu); h.bump_n(half((key.word & 0xFFFF) as u16), *mu); h.bump_n(half((key.word >> 16) as u16), *mu); diff --git a/prover/src/tables/keccak.rs b/prover/src/tables/keccak.rs index d961455ce..9463007b3 100644 --- a/prover/src/tables/keccak.rs +++ b/prover/src/tables/keccak.rs @@ -91,18 +91,16 @@ pub struct KeccakOperation { /// The `IS_WH` sends of the KECCAK table: one per lane of every call, the top /// 48 bits of `state_ptr[lane] = state_addr + 8·lane` (`keccak:c:range_state_ptr`). -pub fn is_wh_keys(ops: &[KeccakOperation]) -> Vec { - let mut keys = Vec::with_capacity(ops.len() * 25); - for op in ops { - for lane_idx in 0..25 { +pub fn is_wh_keys(ops: &[KeccakOperation]) -> impl Iterator + '_ { + ops.iter().flat_map(|op| { + (0..25u64).map(move |lane_idx| { let ptr = op .state_addr - .checked_add(lane_idx as u64 * 8) + .checked_add(lane_idx * 8) .expect("keccak state address range must be validated by the executor"); - keys.push(IsWhKey::of_dword(ptr)); - } - } - keys + IsWhKey::of_dword(ptr) + }) + }) } // ========================================================================= diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 1569bda32..cc5b55a29 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -3402,9 +3402,10 @@ fn build_traces( // sources fold their transient `collect_*` Vec in and drop it. The histogram is a // commutative monoid, so per-worker histograms tree-reduce to multiplicities that are // independent of accumulation order. - // `IS_WH` sends of every `IS_WHH` caller (today only KECCAK's state_ptr). Shared by - // the BITWISE collector below and the IS_WH trace in phase 5. - let is_wh_keys = keccak::is_wh_keys(&keccak_ops); + // IS_WH rows from the `IS_WH` sends of every `IS_WHH` caller (today only KECCAK's + // state_ptr), deduplicated once and shared by the BITWISE collector below and the + // IS_WH trace in phase 5, so the two read the same rows by construction. + let is_wh_rows = is_wh::dedup_is_wh_rows(keccak::is_wh_keys(&keccak_ops)); type Collector<'a> = Box; let lt_chunk = max_rows.lt; @@ -3457,7 +3458,7 @@ fn build_traces( Box::new(|h| h.add_ops(&collect_bitwise_from_ecsm(&ecsm_ops))), Box::new(|h| h.add_ops(&collect_bitwise_from_ecdas(&ecdas_ops))), Box::new(|h| h.add_ops(&collect_bitwise_from_hint(&hint_ops))), - Box::new(|h| is_wh::collect_bitwise_from_is_wh(&is_wh_keys, h)), + Box::new(|h| is_wh::collect_bitwise_from_is_wh(&is_wh_rows, h)), Box::new(|h| add_padding_byte_checks(h, num_padding_rows)), ]; if let Some(image) = initial_image @@ -3789,7 +3790,7 @@ fn build_traces( // IS_WH table. Absent entirely when no chip sends `IS_WH`. let gen_is_whs = || { generate_optional( - &is_wh_keys, + &is_wh_rows, is_wh::generate_is_wh_trace, #[cfg(feature = "disk-spill")] storage_mode, diff --git a/prover/src/tables/types.rs b/prover/src/tables/types.rs index ffe741d16..1e666c89a 100644 --- a/prover/src/tables/types.rs +++ b/prover/src/tables/types.rs @@ -365,8 +365,8 @@ pub enum BusId { // ========================================================================= /// `IS_WH[Word, Half]`: range check that the top 48 bits of a `DWordWHH` /// are a valid `(Word, Half)` pair. Callers send it through the `IS_WHH` - /// template; the IS_WH table receives it. ID 29 (the removed ECSM bus) is - /// left unused. + /// template; the IS_WH table receives it. ID 29 (the removed EC_SCALAR + /// `ServeK` bus) stays unused. IsWh = 32, } diff --git a/prover/src/tests/is_wh_tests.rs b/prover/src/tests/is_wh_tests.rs index 34e65186c..99f28a3f2 100644 --- a/prover/src/tests/is_wh_tests.rs +++ b/prover/src/tests/is_wh_tests.rs @@ -56,14 +56,14 @@ fn test_is_wh_key_of_dword_takes_the_top_48_bits() { #[test] fn test_is_wh_dedup_collapses_repeated_keys() { - let rows = dedup_is_wh_rows(&[B, A, A, B, A]); + let rows = dedup_is_wh_rows([B, A, A, B, A]); // Sorted by key, so the trace does not depend on hash order. assert_eq!(rows, vec![(A, 3), (B, 2)]); } #[test] fn test_is_wh_dedup_of_no_sends_is_empty() { - assert!(dedup_is_wh_rows(&[]).is_empty()); + assert!(dedup_is_wh_rows(std::iter::empty()).is_empty()); } #[test] @@ -71,17 +71,17 @@ fn test_is_wh_dedup_splits_mu_above_b20() { let total_mu = |rows: &[(IsWhKey, u64)]| rows.iter().map(|(_, mu)| mu).sum::(); // Exactly MU_MAX fits one row. - let rows = dedup_is_wh_rows(&vec![A; MU_MAX as usize]); + let rows = dedup_is_wh_rows(vec![A; MU_MAX as usize]); assert_eq!(rows, vec![(A, MU_MAX)]); // One more spills into a second row of the same key. - let rows = dedup_is_wh_rows(&vec![A; MU_MAX as usize + 1]); + let rows = dedup_is_wh_rows(vec![A; MU_MAX as usize + 1]); assert_eq!(rows, vec![(A, MU_MAX), (A, 1)]); // Two full rows and a remainder, next to an unrelated key. let mut keys = vec![A; 2 * MU_MAX as usize + 3]; keys.push(B); - let rows = dedup_is_wh_rows(&keys); + let rows = dedup_is_wh_rows(keys.iter().copied()); assert_eq!(rows, vec![(A, MU_MAX), (A, MU_MAX), (A, 3), (B, 1)]); assert_eq!(total_mu(&rows), keys.len() as u64, "no send lost or added"); assert!(rows.iter().all(|(_, mu)| *mu <= MU_MAX && *mu > 0)); @@ -93,7 +93,7 @@ fn test_is_wh_dedup_splits_mu_above_b20() { #[test] fn test_is_wh_trace_layout_and_padding() { - let trace = generate_is_wh_trace(&[A, B, A]); + let trace = generate_is_wh_trace(&dedup_is_wh_rows([A, B, A])); let t = &trace.main_table; assert_eq!( trace.num_rows(), @@ -122,7 +122,7 @@ fn test_is_wh_trace_layout_and_padding() { #[test] fn test_is_wh_trace_exact_power_of_two_has_no_padding() { let keys: Vec = (0..8).map(|i| IsWhKey { word: i, half: 0 }).collect(); - let trace = generate_is_wh_trace(&keys); + let trace = generate_is_wh_trace(&dedup_is_wh_rows(keys)); assert_eq!(trace.num_rows(), 8); for row in 0..8 { assert_eq!(*trace.main_table.get(row, cols::MU), FE::one()); @@ -160,7 +160,7 @@ fn expected_ops(rows: &[(IsWhKey, u64)], padding: usize) -> Vec = keccak::is_wh_keys(std::slice::from_ref(&op)).collect(); assert_eq!(keys.len(), 25); for (lane, key) in keys.iter().enumerate() { assert_eq!(*key, IsWhKey::of_dword(state_addr + 8 * lane as u64)); From 279cbf5e7ff64adb490c95bda0b12a2b27af0842 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Fri, 16 Oct 2026 14:41:52 -0300 Subject: [PATCH 25/29] Add end-to-end tests for the IS_HALF checks of IS_WH: a non-canonical Word in an IS_WH row, and a KECCAK state_ptr resplit that keeps the ADD and the memory address but puts half_mid out of range. Each one fails if its IS_HALF send is removed. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also add IS_WH to the IR, OOD and GPU differential test lists, rename its MU_MAX to IS_WH_MU_MAX and pin it to the IS_B20 range, and fix the at_most_one comment: for IS_WH the count is what bounds the μ sum. --- prover/src/lib.rs | 13 +- prover/src/tables/is_wh.rs | 17 +- .../tests/constraint_program_device_tests.rs | 1 + prover/src/tests/constraint_program_tests.rs | 1 + prover/src/tests/ir_stats_dump.rs | 1 + prover/src/tests/is_wh_tests.rs | 215 ++++++++++++++++-- prover/src/tests/ood_window_ir_tests.rs | 1 + 7 files changed, 222 insertions(+), 27 deletions(-) diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 139e935c9..81db59ad2 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -207,8 +207,9 @@ impl TableCounts { // lets an inflated count through whenever another count is // lowered to match; this pins the per-field shape instead. It is not // load-bearing for memory safety — both verifiers run the cross-check - // before any count sizes an AIR set — and if one of them ever becomes - // chunked, this list is what changes. + // before any count sizes an AIR set. For the accelerators it is only + // shape, and if one of them ever becomes chunked, this list is what + // changes. IS_WH is the exception, see its entry. let at_most_one = [ ("keccak", self.keccak), ("keccak_rnd", self.keccak_rnd), @@ -216,9 +217,11 @@ impl TableCounts { ("ecdas", self.ecdas), ("hint", self.hint), ("commit", self.commit), - // Also what bounds the IS_WH multiplicity sum per proof: one table - // of at most 2^k rows, each μ < 2^20, stays far below p, so no - // stack of repeated rows reaches μ = −1 (spec verifier.typ TODO). + // Load-bearing for soundness: this is what bounds the IS_WH + // multiplicity sum per proof. One table of at most 2^k rows, each + // μ < 2^20, stays far below p, so no stack of repeated rows reaches + // μ = −1 (spec verifier.typ TODO). Chunking IS_WH needs a bound on + // its total row count in place of this one. ("is_wh", self.is_wh), ]; for (name, count) in at_most_one { diff --git a/prover/src/tables/is_wh.rs b/prover/src/tables/is_wh.rs index 565edf2ac..446590c28 100644 --- a/prover/src/tables/is_wh.rs +++ b/prover/src/tables/is_wh.rs @@ -24,9 +24,9 @@ //! `μ` lives in the field, so `μ = −1` would turn a row from receiver into //! sender: it could cancel a forged `IS_WH` send while also receiving the row's //! own `IS_HALFWORD` checks. The `IS_B20[μ]` send (multiplicity 1, so it is not -//! itself flipped by `μ`) pins `μ ∈ [0, 2^20)`. A pair sent more than [`MU_MAX`] -//! times is split over several rows (see [`dedup_is_wh_rows`]). Padding rows send -//! `IS_B20[0]`, which the BITWISE collector counts (see +//! itself flipped by `μ`) pins `μ ∈ [0, 2^20)`. A pair sent more than +//! [`IS_WH_MU_MAX`] times is split over several rows (see [`dedup_is_wh_rows`]). +//! Padding rows send `IS_B20[0]`, which the BITWISE collector counts (see //! [`collect_bitwise_from_is_wh`]). //! //! The table has no polynomial constraints. @@ -54,7 +54,7 @@ pub mod cols { } /// Largest multiplicity a single row may carry: `μ` is a `B20`. -pub const MU_MAX: u64 = (1 << 20) - 1; +pub const IS_WH_MU_MAX: u64 = (1 << 20) - 1; /// A `(Word, Half)` pair as it travels on the `IsWh` bus. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -76,9 +76,10 @@ impl IsWhKey { } /// Deduplicates `IS_WH` sends into trace rows: `(key) -> μ`, splitting a key -/// over several rows when its count exceeds [`MU_MAX`]. Rows come out sorted by -/// key so the trace is deterministic. The trace builder calls it once and hands -/// the same rows to [`generate_is_wh_trace`] and [`collect_bitwise_from_is_wh`]. +/// over several rows when its count exceeds [`IS_WH_MU_MAX`]. Rows come out +/// sorted by key so the trace is deterministic. The trace builder calls it once +/// and hands the same rows to [`generate_is_wh_trace`] and +/// [`collect_bitwise_from_is_wh`]. pub fn dedup_is_wh_rows(keys: impl IntoIterator) -> Vec<(IsWhKey, u64)> { let mut counts: BTreeMap = BTreeMap::new(); for key in keys { @@ -88,7 +89,7 @@ pub fn dedup_is_wh_rows(keys: impl IntoIterator) -> Vec<(IsWhKey let mut rows = Vec::with_capacity(counts.len()); for (key, mut left) in counts { while left > 0 { - let mu = left.min(MU_MAX); + let mu = left.min(IS_WH_MU_MAX); left -= mu; rows.push((key, mu)); } diff --git a/prover/src/tests/constraint_program_device_tests.rs b/prover/src/tests/constraint_program_device_tests.rs index a29a7cb49..d599c2add 100644 --- a/prover/src/tests/constraint_program_device_tests.rs +++ b/prover/src/tests/constraint_program_device_tests.rs @@ -182,4 +182,5 @@ fn all_table_programs_lower_and_match_folders() { check_air_device(&create_ecsm_air(&opts), "ECSM"); check_air_device(&create_ecdas_air(&opts), "ECDAS"); check_air_device(&create_hint_air(&opts), "HINT"); + check_air_device(&create_is_wh_air(&opts), "IS_WH"); } diff --git a/prover/src/tests/constraint_program_tests.rs b/prover/src/tests/constraint_program_tests.rs index e227da53d..7d2e2b512 100644 --- a/prover/src/tests/constraint_program_tests.rs +++ b/prover/src/tests/constraint_program_tests.rs @@ -180,4 +180,5 @@ fn all_table_programs_match_folders() { check_air(&create_ecsm_air(&opts), "ECSM"); check_air(&create_ecdas_air(&opts), "ECDAS"); check_air(&create_hint_air(&opts), "HINT"); + check_air(&create_is_wh_air(&opts), "IS_WH"); } diff --git a/prover/src/tests/ir_stats_dump.rs b/prover/src/tests/ir_stats_dump.rs index a269127d9..51e54b327 100644 --- a/prover/src/tests/ir_stats_dump.rs +++ b/prover/src/tests/ir_stats_dump.rs @@ -130,4 +130,5 @@ fn dump_ir_stats() { stats(&create_keccak_rc_air(&opts), "KECCAK_RC"); stats(&create_ecsm_air(&opts), "ECSM"); stats(&create_ecdas_air(&opts), "ECDAS"); + stats(&create_is_wh_air(&opts), "IS_WH"); } diff --git a/prover/src/tests/is_wh_tests.rs b/prover/src/tests/is_wh_tests.rs index 99f28a3f2..c31cacc84 100644 --- a/prover/src/tests/is_wh_tests.rs +++ b/prover/src/tests/is_wh_tests.rs @@ -6,11 +6,12 @@ use stark::prover::{IsStarkProver, Prover}; use crate::statement::{StatementKind, absorb_statement}; use crate::tables::bitwise::{ - BitwiseHistogram, BitwiseOperation, BitwiseOperationType, cols as bw_cols, - row_index as bw_row_index, + BitwiseHistogram, BitwiseOperation, BitwiseOperationType, NUM_ROWS as BW_NUM_ROWS, + cols as bw_cols, row_index as bw_row_index, }; use crate::tables::is_wh::{ - self, IsWhKey, MU_MAX, collect_bitwise_from_is_wh, cols, dedup_is_wh_rows, generate_is_wh_trace, + self, IS_WH_MU_MAX, IsWhKey, collect_bitwise_from_is_wh, cols, dedup_is_wh_rows, + generate_is_wh_trace, }; use crate::tables::keccak::{ self, KeccakConstraints, KeccakOperation, cols as keccak_cols, generate_keccak_trace, @@ -70,21 +71,38 @@ fn test_is_wh_dedup_of_no_sends_is_empty() { fn test_is_wh_dedup_splits_mu_above_b20() { let total_mu = |rows: &[(IsWhKey, u64)]| rows.iter().map(|(_, mu)| mu).sum::(); - // Exactly MU_MAX fits one row. - let rows = dedup_is_wh_rows(vec![A; MU_MAX as usize]); - assert_eq!(rows, vec![(A, MU_MAX)]); + // Exactly IS_WH_MU_MAX fits one row. + let rows = dedup_is_wh_rows(vec![A; IS_WH_MU_MAX as usize]); + assert_eq!(rows, vec![(A, IS_WH_MU_MAX)]); // One more spills into a second row of the same key. - let rows = dedup_is_wh_rows(vec![A; MU_MAX as usize + 1]); - assert_eq!(rows, vec![(A, MU_MAX), (A, 1)]); + let rows = dedup_is_wh_rows(vec![A; IS_WH_MU_MAX as usize + 1]); + assert_eq!(rows, vec![(A, IS_WH_MU_MAX), (A, 1)]); // Two full rows and a remainder, next to an unrelated key. - let mut keys = vec![A; 2 * MU_MAX as usize + 3]; + let mut keys = vec![A; 2 * IS_WH_MU_MAX as usize + 3]; keys.push(B); let rows = dedup_is_wh_rows(keys.iter().copied()); - assert_eq!(rows, vec![(A, MU_MAX), (A, MU_MAX), (A, 3), (B, 1)]); + assert_eq!( + rows, + vec![(A, IS_WH_MU_MAX), (A, IS_WH_MU_MAX), (A, 3), (B, 1)] + ); assert_eq!(total_mu(&rows), keys.len() as u64, "no send lost or added"); - assert!(rows.iter().all(|(_, mu)| *mu <= MU_MAX && *mu > 0)); + assert!(rows.iter().all(|(_, mu)| *mu <= IS_WH_MU_MAX && *mu > 0)); +} + +/// The split point is the top of the `IS_B20` range: BITWISE row `i` receives +/// `IS_B20[i]`, so the last row takes `IS_WH_MU_MAX` and there is none past it. +/// A larger split point would build rows whose bound has no receiver. +#[test] +fn test_is_wh_mu_max_is_the_top_of_b20() { + let (x, y, z) = ( + IS_WH_MU_MAX as u8, + (IS_WH_MU_MAX >> 8) as u8, + (IS_WH_MU_MAX >> 16) as u8, + ); + assert_eq!(bw_row_index(x, y, z), BW_NUM_ROWS - 1); + assert_eq!(IS_WH_MU_MAX + 1, BW_NUM_ROWS as u64); } // ========================================================================= @@ -356,15 +374,15 @@ fn split_first_row(traces: &mut Traces, moved: FE) { bump_b20(traces, 0, -FE::one()); for v in [kept, moved] { let v = v.canonical_u64(); - if v <= MU_MAX { + if v <= IS_WH_MU_MAX { bump_b20(traces, v, FE::one()); } } } /// Control: a split into two positive multiplicities is an honest witness (the -/// same shape `dedup_is_wh_rows` produces past `MU_MAX`) and verifies. This is -/// what makes the rejection below attributable to the sign of μ alone. +/// same shape `dedup_is_wh_rows` produces past `IS_WH_MU_MAX`) and verifies. +/// This is what makes the rejection below attributable to the sign of μ alone. #[test] fn test_is_wh_positive_split_verifies() { assert!(prove_keccak_with(|t| split_first_row(t, FE::one()))); @@ -386,3 +404,172 @@ fn test_is_wh_honest_keccak_verifies() { assert_eq!(t.is_whs.len(), 1, "test_keccak must ship an IS_WH table"); })); } + +// ========================================================================= +// End to end: the IS_HALFWORD checks on the key +// ========================================================================= +// +// A key limb is only bound by IS_WH's own IS_HALFWORD sends: the `IS_WH` bus +// carries the Word packed (`value[1] + 2^16·value[2]`) and KECCAK's memory sends +// carry `half_lo + 2^16·half_mid`, so a non-canonical split of either sum keeps +// both buses and the ADD balanced. The forgeries below rebalance BITWISE from +// the IS_HALFWORD sends IS_WH declares, as the attacker would: with a send +// removed (and its count dropped from the collector) there is nothing to +// rebalance for that limb and the forgery must go through, which is what keeps +// the rejections falsifiable. + +/// IS_WH columns that IS_WH sends an `IS_HALFWORD` check on. +fn is_wh_half_checked_columns() -> Vec { + let half: u64 = BusId::IsHalfword.into(); + is_wh::bus_interactions() + .iter() + .filter(|bi| bi.bus_id == half && bi.is_sender) + .filter_map(|bi| match bi.values.as_slice() { + [stark::lookup::BusValue::Packed { start_column, .. }] => Some(*start_column), + _ => None, + }) + .collect() +} + +/// Moves `delta` onto the BITWISE `IS_HALFWORD` receiver row for `value`. A +/// value outside `[0, 2^16)` has no row to move onto, so it is left unbalanced. +fn bump_half(traces: &mut Traces, value: FE, delta: FE) { + let v = value.canonical_u64(); + if v >= 1 << 16 { + return; + } + let bw = &mut traces.bitwise.main_table; + let row = bw_row_index(v as u8, (v >> 8) as u8, 0); + let old = *bw.get(row, bw_cols::MU_IS_HALF); + bw.set(row, bw_cols::MU_IS_HALF, old + delta); +} + +/// `lo + 2^16·hi` split again with `lo` replaced by `new_lo`: `hi` absorbs the +/// difference, so the pair packs to the same field element. Unless +/// `new_lo == lo`, the new `hi` is far outside `[0, 2^16)`. +fn resplit_keeping_lo(lo: FE, hi: FE, new_lo: FE) -> (FE, FE) { + let inv_2_16 = FE::from(1u64 << 16).inv().expect("2^16 is invertible"); + (new_lo, hi + (lo - new_lo) * inv_2_16) +} + +/// Rewrites the Word of IS_WH row 0 into a non-canonical pair of halves with +/// the same packing, so it still receives the honest `IS_WH` sends. The limb at +/// `out_of_range` (`VALUE_1` or `VALUE_2`) leaves `[0, 2^16)`; the other limb +/// moves to a different in-range value. +fn resplit_word(traces: &mut Traces, out_of_range: usize) { + let t = &mut traces.is_whs[0].main_table; + let m = *t.get(0, cols::MU); + let (v1, v2) = (*t.get(0, cols::VALUE_1), *t.get(0, cols::VALUE_2)); + let flip = |v: FE| FE::from(v.canonical_u64() ^ 1); + let (n1, n2) = if out_of_range == cols::VALUE_2 { + resplit_keeping_lo(v1, v2, flip(v1)) + } else { + // `v1 + 2^16·v2` with `v2` replaced: `v1` absorbs `2^16·(v2 − v2')`. + let n2 = flip(v2); + (v1 + (v2 - n2) * FE::from(1u64 << 16), n2) + }; + assert_eq!( + n1 + n2 * FE::from(1u64 << 16), + v1 + v2 * FE::from(1u64 << 16) + ); + t.set(0, cols::VALUE_1, n1); + t.set(0, cols::VALUE_2, n2); + + for col in is_wh_half_checked_columns() { + let (old, new) = match col { + c if c == cols::VALUE_1 => (v1, n1), + c if c == cols::VALUE_2 => (v2, n2), + _ => continue, + }; + bump_half(traces, old, -m); + bump_half(traces, new, m); + } +} + +/// An IS_WH row whose Word is split into halves that pack to the honest Word +/// but are not both halfwords. It receives the honest `IS_WH` sends, so only its +/// own `IS_HALFWORD` check on the bad limb can reject it — once per Word limb. +#[test] +fn test_is_wh_noncanonical_word_is_rejected() { + for limb in [cols::VALUE_1, cols::VALUE_2] { + assert!( + !prove_keccak_with(|t| resplit_word(t, limb)), + "IS_WH column {limb} out of range must be rejected" + ); + } +} + +/// Moves lane 0 of the real KECCAK row onto its own IS_WH row: IS_WH row 0 +/// gives up one send (μ = m − 1) and the first padding row takes it (μ = 1). +/// With `noncanonical`, lane 0's `state_ptr` is also resplit as +/// `(half_lo ^ 1, half_mid')` with the same `half_lo + 2^16·half_mid`, so the +/// ADD and the memory address are untouched while `half_mid'` leaves +/// `[0, 2^16)`; the new IS_WH row carries that key. +fn move_lane_0_to_own_row(traces: &mut Traces, noncanonical: bool) { + let k = &mut traces.keccaks[0].main_table; + assert_eq!( + *k.get(0, keccak_cols::MU), + FE::one(), + "KECCAK row 0 is real" + ); + let (lo_col, mid_col) = (keccak_cols::state_ptr(0, 0), keccak_cols::state_ptr(0, 1)); + let (lo, mid) = (*k.get(0, lo_col), *k.get(0, mid_col)); + let (new_lo, new_mid) = if noncanonical { + resplit_keeping_lo(lo, mid, FE::from(lo.canonical_u64() ^ 1)) + } else { + (lo, mid) + }; + k.set(0, lo_col, new_lo); + k.set(0, mid_col, new_mid); + // KECCAK's own IS_HALFWORD[half_lo] (the IS_WHH template): both in range. + bump_half(traces, lo, -FE::one()); + bump_half(traces, new_lo, FE::one()); + + let t = &mut traces.is_whs[0].main_table; + assert_eq!( + *t.get(0, cols::VALUE_0), + mid, + "lane 0 sends IS_WH row 0's key" + ); + let m = t.get(0, cols::MU).canonical_u64(); + assert!(m >= 2, "test_keccak's 25 lanes must share a key"); + let pad = (0..t.height) + .find(|&r| *t.get(r, cols::MU) == FE::zero()) + .expect("a padding row"); + let row_0: Vec = (0..cols::MU).map(|c| *t.get(0, c)).collect(); + let mut new_row = row_0.clone(); + new_row[cols::VALUE_0] = new_mid; + for (c, v) in new_row.iter().enumerate() { + t.set(pad, c, *v); + } + t.set(0, cols::MU, FE::from(m - 1)); + t.set(pad, cols::MU, FE::one()); + + let b20: u64 = BusId::IsB20.into(); + if is_wh::bus_interactions().iter().any(|bi| bi.bus_id == b20) { + bump_b20(traces, m, -FE::one()); + bump_b20(traces, m - 1, FE::one()); + bump_b20(traces, 0, -FE::one()); + bump_b20(traces, 1, FE::one()); + } + for col in is_wh_half_checked_columns() { + bump_half(traces, row_0[col], -FE::one()); + bump_half(traces, new_row[col], FE::one()); + } +} + +/// Control: giving one lane its own row with the honest key is an honest +/// witness and verifies, so the rejection below is the resplit alone. +#[test] +fn test_keccak_lane_on_its_own_is_wh_row_verifies() { + assert!(prove_keccak_with(|t| move_lane_0_to_own_row(t, false))); +} + +/// A `state_ptr` resplit as `(half_lo ^ 1, half_mid')` that keeps +/// `half_lo + 2^16·half_mid`: the ADD, the memory sends and KECCAK's +/// `IS_HALFWORD[half_lo]` all still hold, and IS_WH has a row for the new key. +/// Only that row's `IS_HALFWORD[value[0]]` sees `half_mid'` out of range. +#[test] +fn test_keccak_noncanonical_state_ptr_is_rejected() { + assert!(!prove_keccak_with(|t| move_lane_0_to_own_row(t, true))); +} diff --git a/prover/src/tests/ood_window_ir_tests.rs b/prover/src/tests/ood_window_ir_tests.rs index 29d224627..4cb11b3a1 100644 --- a/prover/src/tests/ood_window_ir_tests.rs +++ b/prover/src/tests/ood_window_ir_tests.rs @@ -115,4 +115,5 @@ fn all_table_windows_match_captured_ir() { assert_ood_window_matches_ir(&create_ecsm_air(&opts), true, "ECSM"); assert_ood_window_matches_ir(&create_ecdas_air(&opts), true, "ECDAS"); assert_ood_window_matches_ir(&create_hint_air(&opts), true, "HINT"); + assert_ood_window_matches_ir(&create_is_wh_air(&opts), true, "IS_WH"); } From 7dfe59381c56c01635a4cbf344d2e36f4f160db7 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Mon, 19 Oct 2026 11:49:45 -0300 Subject: [PATCH 26/29] Cover IS_WH end to end with more than one key: a keccak state across a 64 KiB boundary (new test_keccak_64k program), a continuation run where only the keccak epoch ships IS_WH, and a recursion blob whose inner proof has IS_WH. The two IS_WH range forgeries now check that the bus balance is what rejects them, and the Word one gets its own control. Also add IS_WH to the GPU test list and fix a few stale bits: the TableCounts doc, KECCAK's interaction capacity, the debug bus legend (stopped at ID 21) and the no-accelerators test. --- executor/programs/asm/test_keccak_64k.s | 41 ++++++ prover/src/debug_report.rs | 2 +- prover/src/lib.rs | 4 +- prover/src/tables/keccak.rs | 2 +- prover/src/tests/is_wh_tests.rs | 154 +++++++++++++++++--- prover/src/tests/prove_elfs_tests.rs | 15 +- prover/src/tests/skip_empty_tables_tests.rs | 30 ++-- prover/tests/gpu_constraint_interp_real.rs | 1 + 8 files changed, 213 insertions(+), 36 deletions(-) create mode 100644 executor/programs/asm/test_keccak_64k.s diff --git a/executor/programs/asm/test_keccak_64k.s b/executor/programs/asm/test_keccak_64k.s new file mode 100644 index 000000000..32fbed05c --- /dev/null +++ b/executor/programs/asm/test_keccak_64k.s @@ -0,0 +1,41 @@ + .attribute 5, "rv64i2p1_m2p0_zmmul1p0" +.Lfunc_end0: + .globl main +main: + # Place the 200-byte Keccak state (25 × u64) below sp so that it straddles + # a 64 KiB boundary: lanes 0..11 sit below it and lanes 12..24 above, so + # the 25 state pointers carry two different (Word, Half) keys. + li t2, -65536 + and s0, sp, t2 + addi s0, s0, -96 + + # Zero out the state (200 bytes = 25 doublewords) + mv t0, s0 + li t1, 25 +.Lzero_loop: + sd zero, 0(t0) + addi t0, t0, 8 + addi t1, t1, -1 + bnez t1, .Lzero_loop + + # Call keccak-f[1600] permutation + # a0 = pointer to 200-byte state + # a7 = syscall number (0xFFFFFFFFFFFFFFFE = u64::MAX - 1) + mv a0, s0 + li a7, -2 + ecall + + # Commit the post-permutation state so the test can verify the KAT. + # Commit syscall: a0=fd(1), a1=buf_addr, a2=count, a7=64 + li a0, 1 + mv a1, s0 + li a2, 200 + li a7, 64 + ecall + + # Halt + li a0, 0 + li a7, 93 + ecall +.Lfunc_end1: + .size main, .Lfunc_end1-main diff --git a/prover/src/debug_report.rs b/prover/src/debug_report.rs index 81e71441c..a317d8331 100644 --- a/prover/src/debug_report.rs +++ b/prover/src/debug_report.rs @@ -9,7 +9,7 @@ use crate::tables::types::BusId; /// Print a legend mapping numeric bus IDs to their names. pub fn print_bus_legend() { eprintln!("=== BUS ID LEGEND ==="); - for id in 0u64..=21 { + for id in 0u64..=u64::from(u8::MAX) { if let Ok(bus) = BusId::try_from(id) { eprintln!(" Bus {:2} = {}", id, bus.name()); } diff --git a/prover/src/lib.rs b/prover/src/lib.rs index 81db59ad2..b1df57d78 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -92,8 +92,8 @@ pub struct RuntimePageRange { pub const FIXED_TABLE_COUNT: usize = 5; /// How many sub-proofs each counted table contributes. Chunked chips report -/// their chunk count; the six accelerators are not chunked and report 0 or 1 -/// (see `validate`). The verifier needs this to reconstruct matching AIRs. +/// their chunk count; the six accelerators and IS_WH are not chunked and report +/// 0 or 1 (see `validate`). The verifier needs this to reconstruct matching AIRs. #[derive(Debug, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] pub struct TableCounts { pub cpu: usize, diff --git a/prover/src/tables/keccak.rs b/prover/src/tables/keccak.rs index 9463007b3..d65dcce8f 100644 --- a/prover/src/tables/keccak.rs +++ b/prover/src/tables/keccak.rs @@ -175,7 +175,7 @@ pub fn generate_keccak_trace( pub fn bus_interactions() -> Vec { let syscall_lo = KECCAK_SYSCALL_NUMBER & 0xFFFF_FFFF; let syscall_hi = KECCAK_SYSCALL_NUMBER >> 32; - let mut interactions = Vec::with_capacity(160); + let mut interactions = Vec::with_capacity(84); // 1. ECALL receiver (shared bus, per spec keccak:c:output) // Payload: [ts_lo, ts_hi, syscall_lo32, syscall_hi32] in DWordWL [lo, hi] diff --git a/prover/src/tests/is_wh_tests.rs b/prover/src/tests/is_wh_tests.rs index c31cacc84..263fcf983 100644 --- a/prover/src/tests/is_wh_tests.rs +++ b/prover/src/tests/is_wh_tests.rs @@ -19,6 +19,8 @@ use crate::tables::keccak::{ use crate::tables::trace_builder::Traces; use crate::tables::types::{BusId, FE}; use crate::test_utils::{E, asm_elf_bytes, busless_air, validate_busless}; +use crate::tests::prove_elfs_tests::{BusOutcome, weigh_the_bus_with}; +use crate::tests::skip_empty_tables_tests::assert_only_the_bus_rejected; use crate::{MaxRowsConfig, VmAirs, VmProof}; use executor::elf::Elf; @@ -266,19 +268,17 @@ fn bump_b20(traces: &mut Traces, value: u64, delta: FE) { bw.set(row, bw_cols::MU_IS_B20, old + delta); } -/// Proves `test_keccak` after letting `rewrite` reshape the IS_WH rows, and -/// returns whether the verifier accepts. The prover is the malicious one: it -/// skips no check of its own, it simply builds a proof over the rewritten trace. -fn prove_keccak_with(rewrite: impl FnOnce(&mut Traces)) -> bool { - let elf_bytes = asm_elf_bytes("test_keccak"); - let options = crate::GoldilocksCubicProofOptions::with_blowup(2).expect("blowup=2 is valid"); +/// Runs the asm program `name` and builds its traces with the full +/// preprocessed BITWISE table, the one a range-check forgery has to get past. +fn full_traces(name: &str) -> (Vec, Elf, Traces) { + let elf_bytes = asm_elf_bytes(name); let program = Elf::load(&elf_bytes).expect("ELF load"); let logs = Executor::new(&program, vec![]) .expect("executor construction") .run() .expect("run") .logs; - let mut traces = Traces::from_elf_and_logs( + let traces = Traces::from_elf_and_logs( &program, &logs, &MaxRowsConfig::default(), @@ -287,6 +287,20 @@ fn prove_keccak_with(rewrite: impl FnOnce(&mut Traces)) -> bool { stark::storage_mode::StorageMode::Ram, ) .expect("trace build"); + (elf_bytes, program, traces) +} + +/// Proves `test_keccak` after letting `rewrite` reshape the IS_WH rows, and +/// returns whether the verifier accepts. The prover is the malicious one: it +/// skips no check of its own, it simply builds a proof over the rewritten trace. +fn prove_keccak_with(rewrite: impl FnOnce(&mut Traces)) -> bool { + prove_asm_with("test_keccak", rewrite) +} + +/// [`prove_keccak_with`] for any asm program. +fn prove_asm_with(name: &str, rewrite: impl FnOnce(&mut Traces)) -> bool { + let options = crate::GoldilocksCubicProofOptions::with_blowup(2).expect("blowup=2 is valid"); + let (elf_bytes, program, mut traces) = full_traces(name); rewrite(&mut traces); @@ -338,6 +352,15 @@ fn prove_keccak_with(rewrite: impl FnOnce(&mut Traces)) -> bool { .expect("verify must not error") } +/// The same forgery on `test_keccak`, weighed instead of only verified: tells a +/// rejection by the bus balance apart from one anywhere else in the verifier +/// (see `assert_only_the_bus_rejected`). +fn weigh_keccak_with(rewrite: impl FnOnce(&mut Traces)) -> BusOutcome { + let (_, program, mut traces) = full_traces("test_keccak"); + rewrite(&mut traces); + weigh_the_bus_with(&program, &mut traces, true, false) +} + /// Splits IS_WH row 0 (μ = m) into `m − moved` on row 0 and `moved` on the /// first padding row, copying the key, and rebalances every BITWISE receiver /// that has a row for the values involved. The IS_WH and IS_HALFWORD sums are @@ -455,18 +478,21 @@ fn resplit_keeping_lo(lo: FE, hi: FE, new_lo: FE) -> (FE, FE) { /// Rewrites the Word of IS_WH row 0 into a non-canonical pair of halves with /// the same packing, so it still receives the honest `IS_WH` sends. The limb at /// `out_of_range` (`VALUE_1` or `VALUE_2`) leaves `[0, 2^16)`; the other limb -/// moves to a different in-range value. -fn resplit_word(traces: &mut Traces, out_of_range: usize) { +/// moves to a different in-range value. `None` rewrites the row to itself +/// through the same steps. +fn resplit_word(traces: &mut Traces, out_of_range: Option) { let t = &mut traces.is_whs[0].main_table; let m = *t.get(0, cols::MU); let (v1, v2) = (*t.get(0, cols::VALUE_1), *t.get(0, cols::VALUE_2)); let flip = |v: FE| FE::from(v.canonical_u64() ^ 1); - let (n1, n2) = if out_of_range == cols::VALUE_2 { - resplit_keeping_lo(v1, v2, flip(v1)) - } else { - // `v1 + 2^16·v2` with `v2` replaced: `v1` absorbs `2^16·(v2 − v2')`. - let n2 = flip(v2); - (v1 + (v2 - n2) * FE::from(1u64 << 16), n2) + let (n1, n2) = match out_of_range { + None => (v1, v2), + Some(c) if c == cols::VALUE_2 => resplit_keeping_lo(v1, v2, flip(v1)), + Some(_) => { + // `v1 + 2^16·v2` with `v2` replaced: `v1` absorbs `2^16·(v2 − v2')`. + let n2 = flip(v2); + (v1 + (v2 - n2) * FE::from(1u64 << 16), n2) + } }; assert_eq!( n1 + n2 * FE::from(1u64 << 16), @@ -486,15 +512,22 @@ fn resplit_word(traces: &mut Traces, out_of_range: usize) { } } +/// Control: the rewrite path of the test below with the Word left canonical +/// verifies, so its BITWISE rebalancing is what an honest witness needs. +#[test] +fn test_is_wh_canonical_word_rewrite_verifies() { + assert!(prove_keccak_with(|t| resplit_word(t, None))); +} + /// An IS_WH row whose Word is split into halves that pack to the honest Word /// but are not both halfwords. It receives the honest `IS_WH` sends, so only its /// own `IS_HALFWORD` check on the bad limb can reject it — once per Word limb. #[test] fn test_is_wh_noncanonical_word_is_rejected() { for limb in [cols::VALUE_1, cols::VALUE_2] { - assert!( - !prove_keccak_with(|t| resplit_word(t, limb)), - "IS_WH column {limb} out of range must be rejected" + assert_only_the_bus_rejected( + &weigh_keccak_with(|t| resplit_word(t, Some(limb))), + &format!("an IS_WH row with column {limb} out of range"), ); } } @@ -571,5 +604,88 @@ fn test_keccak_lane_on_its_own_is_wh_row_verifies() { /// Only that row's `IS_HALFWORD[value[0]]` sees `half_mid'` out of range. #[test] fn test_keccak_noncanonical_state_ptr_is_rejected() { - assert!(!prove_keccak_with(|t| move_lane_0_to_own_row(t, true))); + assert_only_the_bus_rejected( + &weigh_keccak_with(|t| move_lane_0_to_own_row(t, true)), + "a KECCAK state_ptr with half_mid out of range", + ); +} + +// ========================================================================= +// End to end: more than one key, continuations and recursion +// ========================================================================= + +/// `test_keccak_64k` puts the state right across a 64 KiB boundary, so its +/// lanes carry two keys: IS_WH ships two real rows, 12 lanes below the +/// boundary and 13 above, and the proof verifies. +#[test] +fn test_keccak_state_across_64k_boundary_verifies() { + assert!(prove_asm_with("test_keccak_64k", |t| { + assert_eq!(t.is_whs.len(), 1); + let is_wh = &t.is_whs[0].main_table; + let rows: Vec<(u64, u64)> = (0..is_wh.height) + .map(|r| { + ( + is_wh.get(r, cols::VALUE_0).canonical_u64(), + is_wh.get(r, cols::MU).canonical_u64(), + ) + }) + .filter(|&(_, mu)| mu > 0) + .collect(); + assert_eq!(rows.len(), 2, "two keys: {rows:?}"); + assert_eq!(rows[1].0, (rows[0].0 + 1) & 0xFFFF, "adjacent halves"); + assert_eq!((rows[0].1, rows[1].1), (12, 13)); + })); +} + +/// Continuations ship IS_WH per epoch: the epoch that runs the keccak ecall +/// has an IS_WH table and the others do not, and the bundle verifies. +#[test] +fn test_keccak_continuation_carries_is_wh_in_its_epoch() { + let elf_bytes = asm_elf_bytes("test_keccak_64k"); + let opts = crate::recursion::MIN_PROOF_OPTIONS; + let bundle = crate::continuation::prove_continuation(&elf_bytes, &[], 4, &opts) + .expect("continuation prove"); + let is_wh: Vec = bundle + .epoch_table_counts() + .iter() + .map(|c| c.is_wh) + .collect(); + assert!(is_wh.len() > 1, "16-cycle epochs must split the program"); + assert_eq!( + is_wh.iter().filter(|&&n| n == 1).count(), + 1, + "exactly the keccak epoch ships IS_WH: {is_wh:?}" + ); + assert!(is_wh.iter().all(|&n| n <= 1)); + crate::continuation::verify_continuation(&elf_bytes, &bundle, &opts) + .expect("verify_continuation errored") + .expect("a keccak bundle must verify"); +} + +/// The recursion guest's verify path (mirrored on the host) on an inner proof +/// that contains IS_WH: the blob carries `is_wh = 1` and still verifies and +/// attests. +#[test] +fn test_recursion_blob_with_is_wh_verifies_on_host() { + use crate::recursion::{self, MIN_PROOF_OPTIONS}; + + let elf_bytes = asm_elf_bytes("test_keccak_64k"); + let inner = crate::prove_with_options_and_inputs( + &elf_bytes, + &[], + &MIN_PROOF_OPTIONS, + &MaxRowsConfig::default(), + ) + .expect("inner prove"); + assert_eq!(inner.table_counts.is_wh, 1); + + let blob = + recursion::encode_guest_input(&inner, &elf_bytes, &MIN_PROOF_OPTIONS).expect("encode"); + let attestation = recursion::verify_and_attest_blob(&blob, &MIN_PROOF_OPTIONS) + .expect("verify_and_attest_blob errored") + .expect("an inner proof with IS_WH must verify on the guest path"); + let output = recursion::check_attestation(&attestation, &elf_bytes, &MIN_PROOF_OPTIONS) + .expect("check_attestation errored") + .expect("attestation must bind to the inner ELF"); + assert_eq!(output, inner.public_output); } diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index b8599f349..5fc4eb357 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -101,6 +101,19 @@ pub(crate) fn weigh_the_bus( elf: &Elf, traces: &mut Traces, recheck_with_moved_target: bool, +) -> BusOutcome { + weigh_the_bus_with(elf, traces, recheck_with_moved_target, true) +} + +/// [`weigh_the_bus`] with the BITWISE table picked by `minimal_bitwise`: pass +/// `false` for traces from `Traces::from_elf_and_logs`, whose BITWISE is the +/// full preprocessed table. A forgery that leaves a range check out of range +/// needs that one: the minimal table's rows are the prover's to choose. +pub(crate) fn weigh_the_bus_with( + elf: &Elf, + traces: &mut Traces, + recheck_with_moved_target: bool, + minimal_bitwise: bool, ) -> BusOutcome { let _ = env_logger::builder().is_test(true).try_init(); let proof_options = ProofOptions::default_test_options(); @@ -110,7 +123,7 @@ pub(crate) fn weigh_the_bus( let airs = VmAirs::new( elf, &proof_options, - true, + minimal_bitwise, &traces.page_configs, &table_counts, None, diff --git a/prover/src/tests/skip_empty_tables_tests.rs b/prover/src/tests/skip_empty_tables_tests.rs index f5a889e4e..f310104dd 100644 --- a/prover/src/tests/skip_empty_tables_tests.rs +++ b/prover/src/tests/skip_empty_tables_tests.rs @@ -36,7 +36,8 @@ fn run_rust_elf(name: &str) -> (Elf, ExecutionResult) { (elf, result) } -/// The claim every drop-forgery below makes, in one place. +/// The claim every drop-forgery below makes, in one place (the IS_WH range +/// forgeries in `is_wh_tests` make it too). /// /// `multi_verify_views` returns `false` from about ten places, so /// `assert!(!accepted)` on its own would pass just as happily if the forged @@ -44,11 +45,11 @@ fn run_rust_elf(name: &str) -> (Elf, ExecutionResult) { /// check — the opposite of what these tests say they demonstrate. Moving the /// target onto the sum the forgery actually produced is what separates them: if /// the same proof then verifies, the balance was the only objection. -fn assert_only_the_bus_rejected(outcome: &BusOutcome, what: &str) { - assert!(!outcome.accepted, "dropping {what} must not verify"); +pub(crate) fn assert_only_the_bus_rejected(outcome: &BusOutcome, what: &str) { + assert!(!outcome.accepted, "{what} must not verify"); assert_ne!( outcome.contribution_sum, outcome.target, - "dropping {what} left the bus balanced, so the rejection came from \ + "{what} left the bus balanced, so the rejection came from \ elsewhere and the bus did not notice the forgery at all" ); assert!( @@ -540,7 +541,7 @@ fn omitting_a_table_whose_ops_ran_fails_the_bus_balance() { "validate deliberately lets this through — the bus is what rejects it" ); - assert_only_the_bus_rejected(&weigh_the_bus(&elf, &mut traces, true), "MUL"); + assert_only_the_bus_rejected(&weigh_the_bus(&elf, &mut traces, true), "dropping MUL"); } /// What `validate` still refuses: a proof with no CPU describes no execution, @@ -650,9 +651,9 @@ fn counts_that_wrap_have_no_total() { /// The accelerators are the ones a run most often never reaches, and each cost a /// four-row sub-proof regardless. A program with no keccak, no EC and no hint -/// ecall now carries none of the six. +/// ecall now carries none of the six, nor the IS_WH table KECCAK feeds. #[test] -fn a_run_without_accelerators_omits_all_six() { +fn a_run_without_accelerators_omits_all_six_and_is_wh() { let (elf, logs, _instructions) = run_asm_elf("xori"); let mut traces = Traces::from_elf_and_logs_minimal(&elf, &logs, &Default::default(), &[]).unwrap(); @@ -665,6 +666,8 @@ fn a_run_without_accelerators_omits_all_six() { ("ecdas", counts.ecdas), ("hint", counts.hint), ("commit", counts.commit), + // Not an accelerator, but only KECCAK feeds it today. + ("is_wh", counts.is_wh), ] { assert_eq!( count, 0, @@ -699,7 +702,7 @@ fn omitting_a_used_accelerator_fails_the_bus_balance() { ); traces.hints.clear(); - assert_only_the_bus_rejected(&weigh_the_bus(&elf, &mut traces, true), "HINT"); + assert_only_the_bus_rejected(&weigh_the_bus(&elf, &mut traces, true), "dropping HINT"); } /// The dispatch above is CPU-to-chip. KECCAK_RND is reached from KECCAK, not @@ -722,7 +725,10 @@ fn omitting_a_chip_dispatched_by_another_chip_fails_the_bus_balance() { ); traces.keccak_rnds.clear(); - assert_only_the_bus_rejected(&weigh_the_bus(&elf, &mut traces, true), "KECCAK_RND"); + assert_only_the_bus_rejected( + &weigh_the_bus(&elf, &mut traces, true), + "dropping KECCAK_RND", + ); } /// IS_WH is reached from a chip's range checks rather than from a dispatch: it @@ -746,7 +752,7 @@ fn omitting_the_is_wh_table_fails_the_bus_balance() { ); traces.is_whs.clear(); - assert_only_the_bus_rejected(&weigh_the_bus(&elf, &mut traces, true), "IS_WH"); + assert_only_the_bus_rejected(&weigh_the_bus(&elf, &mut traces, true), "dropping IS_WH"); } /// COMMIT is the one newly-optional table whose bus target is not zero: the @@ -771,7 +777,7 @@ fn omitting_the_commit_table_fails_the_bus_balance() { ); traces.commits.clear(); - assert_only_the_bus_rejected(&weigh_the_bus(&elf, &mut traces, true), "COMMIT"); + assert_only_the_bus_rejected(&weigh_the_bus(&elf, &mut traces, true), "dropping COMMIT"); } /// The variant the test above does not cover: a prover who drops COMMIT can @@ -794,5 +800,5 @@ fn omitting_the_commit_table_and_its_output_fails_the_bus_balance() { "clearing the public output must move the target, or this test is the \ previous one over again" ); - assert_only_the_bus_rejected(&outcome, "COMMIT together with its output"); + assert_only_the_bus_rejected(&outcome, "dropping COMMIT together with its output"); } diff --git a/prover/tests/gpu_constraint_interp_real.rs b/prover/tests/gpu_constraint_interp_real.rs index 4446fb446..1c8e0da57 100644 --- a/prover/tests/gpu_constraint_interp_real.rs +++ b/prover/tests/gpu_constraint_interp_real.rs @@ -272,4 +272,5 @@ fn all_table_programs_gpu_match_cpu_oracle() { check_air(&create_ecsm_air(&opts), "ECSM"); check_air(&create_ecdas_air(&opts), "ECDAS"); check_air(&create_hint_air(&opts), "HINT"); + check_air(&create_is_wh_air(&opts), "IS_WH"); } From 3f0ffb504260ae712bb3b4fc19d2db2b9c4a6132 Mon Sep 17 00:00:00 2001 From: jotabulacios Date: Wed, 21 Oct 2026 12:16:04 -0300 Subject: [PATCH 27/29] Check the test_keccak_64k output against the zero-state KAT in the executor tests, as its comment says, and have the two IS_WH forgery tests run the real verifier again next to the bus check. --- executor/tests/asm.rs | 20 +++++++++++++++----- prover/src/tests/is_wh_tests.rs | 15 +++++++++++---- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/executor/tests/asm.rs b/executor/tests/asm.rs index a1c9baf2b..5e6439b2e 100644 --- a/executor/tests/asm.rs +++ b/executor/tests/asm.rs @@ -880,11 +880,10 @@ fn test_sub_underflow() { // ==================== Keccak Precompile ==================== -#[test] -fn test_keccak() { - // Runs keccak-f[1600] on a zeroed state and commits the 200-byte result. - // Expected output is the FIPS-202 zero-input KAT. - let elf_data = std::fs::read("./program_artifacts/asm/test_keccak.elf").unwrap(); +/// Runs `name`, which applies keccak-f[1600] to a zeroed state and commits the +/// 200-byte result, and checks the output against the FIPS-202 zero-input KAT. +fn assert_keccak_zero_state_kat(name: &str) { + let elf_data = std::fs::read(format!("./program_artifacts/asm/{name}.elf")).unwrap(); let program = Elf::load(&elf_data).unwrap(); let executor = Executor::new(&program, vec![]).expect("Failed to create executor"); let result = executor.run().expect("Failed to run program"); @@ -924,6 +923,17 @@ fn test_keccak() { assert_eq!(result.return_values.register_values.0, 0); } +#[test] +fn test_keccak() { + assert_keccak_zero_state_kat("test_keccak"); +} + +/// Same KAT with the state straddling a 64 KiB boundary. +#[test] +fn test_keccak_64k() { + assert_keccak_zero_state_kat("test_keccak_64k"); +} + #[test] fn test_run_epochs_splits_execution_into_n_cycle_epochs() { let elf_data = std::fs::read("./program_artifacts/asm/basic_program.elf").unwrap(); diff --git a/prover/src/tests/is_wh_tests.rs b/prover/src/tests/is_wh_tests.rs index 263fcf983..e5989aac5 100644 --- a/prover/src/tests/is_wh_tests.rs +++ b/prover/src/tests/is_wh_tests.rs @@ -525,10 +525,12 @@ fn test_is_wh_canonical_word_rewrite_verifies() { #[test] fn test_is_wh_noncanonical_word_is_rejected() { for limb in [cols::VALUE_1, cols::VALUE_2] { - assert_only_the_bus_rejected( - &weigh_keccak_with(|t| resplit_word(t, Some(limb))), - &format!("an IS_WH row with column {limb} out of range"), + let what = format!("an IS_WH row with column {limb} out of range"); + assert!( + !prove_keccak_with(|t| resplit_word(t, Some(limb))), + "{what} must not verify" ); + assert_only_the_bus_rejected(&weigh_keccak_with(|t| resplit_word(t, Some(limb))), &what); } } @@ -604,9 +606,14 @@ fn test_keccak_lane_on_its_own_is_wh_row_verifies() { /// Only that row's `IS_HALFWORD[value[0]]` sees `half_mid'` out of range. #[test] fn test_keccak_noncanonical_state_ptr_is_rejected() { + let what = "a KECCAK state_ptr with half_mid out of range"; + assert!( + !prove_keccak_with(|t| move_lane_0_to_own_row(t, true)), + "{what} must not verify" + ); assert_only_the_bus_rejected( &weigh_keccak_with(|t| move_lane_0_to_own_row(t, true)), - "a KECCAK state_ptr with half_mid out of range", + what, ); } From 647c6b8d6de862084dd6f161efec3bfa439c7d11 Mon Sep 17 00:00:00 2001 From: MauroFab Date: Fri, 23 Oct 2026 15:20:36 -0300 Subject: [PATCH 28/29] Correct comments left stale by the per-row multiplicity counting - dvrm.rs / trace_builder.rs: DVRM's MSB16 and NEG ZERO lookups are counted once per row, so an op split at MU_MAX sends them once per row. - trace_builder.rs: KECCAK_RND no longer sends HWSL. - continuations_design.md: LT and MUL bound their own mu with a constant-1 ARE_BYTES send. --- docs/continuations_design.md | 3 ++- prover/src/tables/dvrm.rs | 5 +++-- prover/src/tables/trace_builder.rs | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/continuations_design.md b/docs/continuations_design.md index cfc821dfc..2fedef577 100644 --- a/docs/continuations_design.md +++ b/docs/continuations_design.md @@ -312,7 +312,8 @@ attack and why X blocks it. ### Design X `MU` gates **every** L2G interaction (matches the standard table pattern — -LT/MUL/MEMW each gate all their interactions with one multiplicity column). +LT/MUL/MEMW gate their lookups with their multiplicity columns or sums of them; the +only constant-1 sends are the `ARE_BYTES` checks that bound LT's and MUL's own μ). The crucial consequence — which we first mistook for redundancy — is that gating the **Memory bus bookend** with `MU` forces `MU = 1` on every *touched* cell: diff --git a/prover/src/tables/dvrm.rs b/prover/src/tables/dvrm.rs index 1ce2b8991..50c0faf27 100644 --- a/prover/src/tables/dvrm.rs +++ b/prover/src/tables/dvrm.rs @@ -495,8 +495,9 @@ pub fn bus_interactions() -> Vec { // ------------------------------------------------------------------------- // DVRM-C18 (SIGN): MSB16[sign_n; n[3]] when signed=1 - // Multiplicity: Column(SIGNED) = 0 or 1 per unique row. - // The trace builder deduplicates MSB16 lookups per unique op. + // Multiplicity: Column(SIGNED) = 0 or 1 per row. + // The trace builder counts MSB16 once per row, so an op split over two rows + // by `MU_MAX` sends it twice. // ------------------------------------------------------------------------- interactions.push(BusInteraction::sender( BusId::Msb16, diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 465c1a109..88531e0f8 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -2100,7 +2100,7 @@ pub(crate) fn collect_bitwise_from_mul( /// Collects bitwise lookups from DVRM operations. /// /// Generates: IS_HALF (×20: n, d, r, n_sub_r, q) and ZERO (×2) per raw op, plus -/// MSB16 (up to ×3) and NEG ZERO (up to ×4) per unique signed op per chunk. +/// MSB16 (up to ×3) and NEG ZERO (up to ×4) per row of each chunk whose sign bit is set. /// /// DVRM-A1 (IS_HALF[n]) and DVRM-A2 (IS_HALF[d]) are range-checked by the DVRM /// table itself (n/d IS_HALF senders in dvrm::bus_interactions), so their lookups @@ -3588,7 +3588,7 @@ fn build_traces( // used to split them into instances so multiplicities match the per-instance sends. // MEMW_R sends IS_HALFWORD[timestamp_0 - old_timestamp_lo - 1]. PAGE does a // batched ARE_BYTES[init, fini] per row (skipped in continuation epochs, which the L2G - // table owns). COPY sends IsHalfword; KECCAK_RND sends XOR/AND/ARE_BYTES/HWSL; + // table owns). COPY sends IsHalfword; KECCAK_RND sends XOR/AND/ARE_BYTES; // HINT sends ARE_BYTES for its 32 output cells. // We never concatenate the lookups into one giant `Vec` (~140 M ops / // ~560 MB at 10-tx whose only consumer is the multiplicity count). Each collector bumps From eaae76d67c434650b59dfdb4ab6a2fc80a9e16e9 Mon Sep 17 00:00:00 2001 From: MauroFab Date: Fri, 23 Oct 2026 15:55:52 -0300 Subject: [PATCH 29/29] Correct comments the IS_WH merge left stale - lib.rs: eighteen optional chips, not nineteen (COMMIT left, IS_WH joined). - copy.rs: the prover now has an IS_WH table; COPY keeps four IS_HALF lookups where the spec uses IS_WHH, and moving them is a follow-up. - skip_empty_tables_tests.rs: the cap covers the five accelerators and IS_WH; COPY is among the always-present tables. - gpu_constraint_interp_real.rs: 27 tables. - continuations_design.md: validate() also caps IS_WH at one table, which is what bounds its multiplicity sum. --- docs/continuations_design.md | 5 +++-- prover/src/lib.rs | 2 +- prover/src/tables/copy.rs | 15 ++++++++------- prover/src/tests/skip_empty_tables_tests.rs | 10 +++++----- prover/tests/gpu_constraint_interp_real.rs | 2 +- 5 files changed, 18 insertions(+), 16 deletions(-) diff --git a/docs/continuations_design.md b/docs/continuations_design.md index 2fedef577..ce3ddddea 100644 --- a/docs/continuations_design.md +++ b/docs/continuations_design.md @@ -577,8 +577,9 @@ becomes an **explicit verifier action**: - **Reconstruct the output** by concatenating the per-epoch commit slices (each commit-bus-bound, contiguous via the x254 chain). - The verifier also `validate()`s `table_counts` — which since per-epoch table skipping - means *only* that CPU and MEMW_R are present and that no accelerator claims more than - one table, not that every table is there — and never trusts a prover-supplied + means *only* that CPU and MEMW_R are present and that no accelerator, and no IS_WH, + claims more than one table (for IS_WH that cap is what bounds its multiplicity sum), + not that every table is there — and never trusts a prover-supplied page config (continuation epochs have none — PAGE is skipped under the L2G bookend, so `page_configs` is always empty). diff --git a/prover/src/lib.rs b/prover/src/lib.rs index f048ffa59..184ae2392 100644 --- a/prover/src/lib.rs +++ b/prover/src/lib.rs @@ -171,7 +171,7 @@ impl TableCounts { /// /// What keeps a zero count honest is the LogUp bus, not this check. A chip /// influences the run only through its bus interactions, and none of the - /// nineteen optional chips carries boundary constraints of its own, so one + /// eighteen optional chips carries boundary constraints of its own, so one /// with no rows contributes zero to the bus and removing it changes nothing. /// (The mandatory tables do constrain boundaries — that is part of why they /// are mandatory.) A prover that omits a table whose operations *did* diff --git a/prover/src/tables/copy.rs b/prover/src/tables/copy.rs index 98caeb1a8..6be9156fc 100644 --- a/prover/src/tables/copy.rs +++ b/prover/src/tables/copy.rs @@ -91,10 +91,10 @@ //! Three divergences in the columns from the spec's 34, all representational and all //! recorded in `shape_tests`: the spec types `timestamp` as a `Word` where this code uses a //! `DWordWL`; it types the three increments and `dst_res` as `DWordWHH` -//! (`Half, Half, Word`) where this code uses `DWordHL` (four `Half`s). `IS_WHH` -//! decomposes into an `IS_WH` chip this prover does not have; four `IS_HALF` -//! lookups prove the identical bound with the primitive it does have, at one extra -//! column each. `mu_com`/`mu_com_wide` are the COMMIT-bus gate the spec does not +//! (`Half, Half, Word`) where this code uses `DWordHL` (four `Half`s). Four `IS_HALF` +//! lookups prove the same bound `IS_WHH` would, at one extra column each; moving these +//! four to `IS_WHH` and the `IS_WH` table that KECCAK's `state_ptr` already uses would +//! drop those columns. `mu_com`/`mu_com_wide` are the COMMIT-bus gate the spec does not //! need because it writes the committed bytes through `MEMW` in domain `2·is_write`. //! //! A fourth divergence is not representational: constraints 37, 38 and 39 pin the @@ -1041,9 +1041,10 @@ mod shape_tests { /// `COPY_NEXT` propagates it), so it carries no information. /// - `src_incr`, `dst_incr`, `count_decr` and `dst_res` are `DWordWHH` /// (`Half, Half, Word`) in the spec and `DWordHL` (four `Half`s) here (+4). - /// The spec range-checks them with `IS_WHH`, which decomposes into the `IS_WH` - /// chip; this prover has no such chip, and four `IS_HALF` lookups prove exactly - /// the same bound with the primitive it does have. + /// The spec range-checks them with `IS_WHH`, which sends their top 48 bits to + /// the `IS_WH` table. This chip keeps four `IS_HALF` lookups instead, which prove + /// the same bound at one extra column each; moving them to `IS_WHH`, as KECCAK's + /// `state_ptr` already is, would drop those four columns. /// - `mu_com` and `mu_com_wide` (+2) gate the COMMIT bus. The spec has no such /// bus: it writes committed bytes through `MEMW` in domain `2 · is_write` and /// lets the verifier initialise and finalise that domain in the memory diff --git a/prover/src/tests/skip_empty_tables_tests.rs b/prover/src/tests/skip_empty_tables_tests.rs index 67612e407..b1d6401ef 100644 --- a/prover/src/tests/skip_empty_tables_tests.rs +++ b/prover/src/tests/skip_empty_tables_tests.rs @@ -128,7 +128,7 @@ fn every_table_participates_in_the_bus() { } /// The tables a prover can declare away, under the names the AIRs carry. The -/// always-present ones (BITWISE, DECODE, KECCAK_RC, REGISTER, HALT, the PAGEs, +/// always-present ones (BITWISE, DECODE, KECCAK_RC, COPY, REGISTER, HALT, the PAGEs, /// L2G) have no `TableCounts` field, and they legitimately contribute zero when /// the run never reaches them — KECCAK_RC does exactly that in a program with no /// keccak, which is the padding this change is about and cannot be dropped. @@ -565,8 +565,8 @@ fn validate_still_requires_cpu_and_the_register_file() { } /// The counts ride in the proof, so they are the prover's to choose, and the -/// sub-proof cross-check constrains only their *sum*. The five accelerators are -/// not chunked — `generate_optional` emits one table or none — so any count +/// sub-proof cross-check constrains only their *sum*. The five accelerators and +/// IS_WH are not chunked — `generate_optional` emits one table or none — so any count /// above 1 describes a table set no prover can build, and the sum alone does not /// catch it: lower another count by the same amount and the total still matches. /// This pins the per-field shape. @@ -608,8 +608,8 @@ fn an_accelerator_count_above_one_is_rejected() { ); } - // The chunked chips keep taking any count: this is a cap on the five, not a - // cap on chunking. + // The chunked chips keep taking any count: this is a cap on the five + // accelerators and IS_WH, not a cap on chunking. let mut many_mul = honest.clone(); many_mul.mul = 7; assert!(many_mul.validate().is_ok(), "chunked chips still chunk"); diff --git a/prover/tests/gpu_constraint_interp_real.rs b/prover/tests/gpu_constraint_interp_real.rs index da41cd629..86a1abbbf 100644 --- a/prover/tests/gpu_constraint_interp_real.rs +++ b/prover/tests/gpu_constraint_interp_real.rs @@ -7,7 +7,7 @@ //! per-constraint eval matrix is bit-for-bit identical to the CPU reference //! oracle `eval_device_program`. The oracle is pinned bit-for-bit to the //! compiled prover folder by `tests::constraint_program_device_tests`, so -//! GPU == oracle closes the chain GPU == folder across all 26 tables. +//! GPU == oracle closes the chain GPU == folder across all 27 tables. //! //! Complements the synthetic all-ops coverage in //! `stark`'s `tests/gpu_constraint_interp.rs`: this exercises the kernel on the