Skip to content

feat: implement shift constraints and alignment checks#3

Open
rajat-sharma-Dev wants to merge 1 commit intoZippelLabs:mainfrom
rajat-sharma-Dev:feat/shift-constraints-and-alignment
Open

feat: implement shift constraints and alignment checks#3
rajat-sharma-Dev wants to merge 1 commit intoZippelLabs:mainfrom
rajat-sharma-Dev:feat/shift-constraints-and-alignment

Conversation

@rajat-sharma-Dev
Copy link
Contributor

Problem
Shift instructions (SLL, SRL, SRA, SLLI, SRLI, SRAI) had stub implementations returning [M31::ZERO]
Alignment constraints were placeholders that always passed
Project required nightly Rust for Plonky3 compatibility
Changes
Implemented full shift constraints with bit decomposition verification
Added working word/halfword alignment constraints
Added shift witness columns: [shamt], rs1_bits[32], rd_bits[32]
Created [rust-toolchain.toml] with nightly channel
Added 16 shift tests
Files Modified
[rv32im.rs] - shift constraints + tests
[cpu.rs] - alignment constraints
[memory.rs] - alignment constraints
[cpu.rs] - shift witness population
[trace.rs] - shift witness fields
[columns.rs] - shift columns

- Add full SLL/SRL/SRA/SLLI/SRLI/SRAI constraint implementations
- Replace alignment constraint stubs with working implementations
- Add shift witness columns (shamt, rs1_bits, rd_bits)
- Add nightly toolchain for Plonky3 compatibility
- Add 16 shift tests, all 512 tests pass
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements shift instruction constraints and memory alignment checks for a RISC-V ZK-VM, transitioning from stub implementations to full constraint verification. The implementation adds bit decomposition witnesses for shift operations and algebraic constraints for memory alignment.

Key changes:

  • Implemented 6 shift instruction constraints (SLL, SRL, SRA, SLLI, SRLI, SRAI) using bit decomposition
  • Added word (4-byte) and halfword (2-byte) alignment constraints for memory operations
  • Added rust-toolchain.toml specifying nightly Rust for Plonky3 compatibility

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 28 comments.

Show a summary per file
File Description
rust-toolchain.toml Specifies nightly Rust toolchain for project
crates/trace/src/columns.rs Adds shift witness columns (shamt, rd_bits) to trace
crates/executor/src/trace.rs Adds shift witness fields to TraceRow structure
crates/executor/src/cpu.rs Populates shift witnesses during instruction execution
crates/air/src/rv32im.rs Implements shift constraints with bit decomposition and adds 16 tests
crates/air/src/memory.rs Implements word alignment constraint
crates/air/src/cpu.rs Implements word and halfword alignment constraints with tests

Critical Issues Found: The implementation has significant soundness problems. The alignment and shift constraints use as_u32() to extract field element values and perform operations in the u32 domain, which breaks the algebraic constraint system's soundness. Additionally, bit witnesses lack boolean constraints, allowing malicious provers to provide non-binary values. Several witness columns are defined but unused, wasting resources. These issues must be addressed before the constraints can be considered secure.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 833 to 874
#[inline]
pub fn sll_constraint(row: &CpuTraceRow) -> M31 {
row.is_sll * M31::ZERO // Needs bit decomposition
if row.is_sll == M31::ZERO {
return M31::ZERO;
}

let two_16 = M31::new(1 << 16);
let rs1_full = row.rs1_val_lo + row.rs1_val_hi * two_16;
let rd_full = row.rd_val_lo + row.rd_val_hi * two_16;

// Verify shamt extraction: shamt = rs2 & 0x1F
let rs2_low_5_bits = row.rs2_val_lo.as_u32() & 0x1F;
let shamt_check = row.shamt - M31::new(rs2_low_5_bits);

// Verify bit decomposition and shift operation
let mut rs1_reconstructed = M31::ZERO;
let mut result_reconstructed = M31::ZERO;
let shamt_val = row.shamt.as_u32() as usize;

for i in 0..32usize {
let pow2 = if i < 31 {
M31::new(1 << i)
} else {
M31::new(1u32 << 31)
};
rs1_reconstructed += row.rs1_bits[i] * pow2;

// Shift left: result_bit[i] = input_bit[i - shamt] if i >= shamt, else 0
let expected_bit = if i >= shamt_val && (i - shamt_val) < 32 {
row.rs1_bits[i - shamt_val]
} else {
M31::ZERO
};
result_reconstructed += expected_bit * pow2;
}

row.is_sll * (
shamt_check +
(rs1_full - rs1_reconstructed) +
(rd_full - result_reconstructed)
)
}
Copy link

Copilot AI Dec 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing boolean constraint for bit witnesses. The rs1_bits[i] values must be constrained to be binary (0 or 1) using constraints like rs1_bits[i] * (1 - rs1_bits[i]) = 0. Without this, a malicious prover could provide non-binary values that would make the bit reconstruction check pass with incorrect values.

This same issue applies to all shift constraints (SLL, SRL, SRA, SLLI, SRLI, SRAI) that use bit decomposition.

Copilot uses AI. Check for mistakes.
Comment on lines +914 to +917
row.is_srl * (
shamt_check +
(rs1_full - rs1_reconstructed) +
(rd_full - result_reconstructed)
Copy link

Copilot AI Dec 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing boolean constraints for rs1_bits in the SRL constraint. Same issue as SLL - bit witnesses must be constrained to binary values.

Suggested change
row.is_srl * (
shamt_check +
(rs1_full - rs1_reconstructed) +
(rd_full - result_reconstructed)
// Enforce booleanity of rs1_bits: each bit must be 0 or 1
let mut rs1_bits_bool = M31::ZERO;
for i in 0..32 {
let b = row.rs1_bits[i];
rs1_bits_bool += b * (b - M31::ONE);
}
row.is_srl * (
shamt_check +
(rs1_full - rs1_reconstructed) +
(rd_full - result_reconstructed) +
rs1_bits_bool

Copilot uses AI. Check for mistakes.
Comment on lines +962 to +965
row.is_sra * (
shamt_check +
(rs1_full - rs1_reconstructed) +
(rd_full - result_reconstructed)
Copy link

Copilot AI Dec 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing boolean constraints for rs1_bits in the SRA constraint. Same issue as other shift constraints - bit witnesses must be constrained to binary values.

Suggested change
row.is_sra * (
shamt_check +
(rs1_full - rs1_reconstructed) +
(rd_full - result_reconstructed)
// Enforce booleanity of rs1_bits: each bit must be 0 or 1
let mut rs1_bits_bool = M31::ZERO;
for i in 0..32 {
let b = row.rs1_bits[i];
rs1_bits_bool += b * (b - M31::ONE);
}
row.is_sra * (
shamt_check +
(rs1_full - rs1_reconstructed) +
(rd_full - result_reconstructed) +
rs1_bits_bool

Copilot uses AI. Check for mistakes.
result_reconstructed += expected_bit * pow2;
}

row.is_slli * (shamt_check + (rs1_full - rs1_reconstructed) + (rd_full - result_reconstructed))
Copy link

Copilot AI Dec 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing boolean constraints for rs1_bits in the SLLI constraint. Bit witnesses must be constrained to binary values.

Suggested change
row.is_slli * (shamt_check + (rs1_full - rs1_reconstructed) + (rd_full - result_reconstructed))
// Enforce booleanity of rs1_bits: each bit must be 0 or 1
let mut rs1_bits_bool = M31::ZERO;
for i in 0..32usize {
let bit = row.rs1_bits[i];
rs1_bits_bool += bit * (bit - M31::ONE);
}
row.is_slli * (shamt_check + (rs1_full - rs1_reconstructed) + (rd_full - result_reconstructed) + rs1_bits_bool)

Copilot uses AI. Check for mistakes.
Comment on lines +317 to +319
pub sll_bits: [M31; 32], // SLL result bits
pub srl_bits: [M31; 32], // SRL result bits
pub sra_bits: [M31; 32], // SRA result bits
Copy link

Copilot AI Dec 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The witness columns sll_bits, srl_bits, and sra_bits are defined but never used in the constraints. The shift constraints recompute the expected result bits from the input bits rather than using these witness arrays. These unused columns waste prover time and verifier resources. Either use these witnesses in the constraints or remove them from the structure.

Copilot uses AI. Check for mistakes.
Comment on lines +1179 to +1180
let imm_low_5_bits = row.imm.as_u32() & 0x1F;
let shamt_check = row.shamt - M31::new(imm_low_5_bits);
Copy link

Copilot AI Dec 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue - shamt verification uses as_u32() to extract immediate's low 5 bits in the u32 domain, and there's no range constraint on shamt.

Copilot uses AI. Check for mistakes.
Comment on lines +1787 to 1788
}
}
Copy link

Copilot AI Dec 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent indentation - the closing brace should align with the function definition above it, not have extra indentation.

Suggested change
}
}
}
}

Copilot uses AI. Check for mistakes.

// Verify shift left: result_bit[i] = input_bit[i - shamt] if i >= shamt, else 0
let mut result_reconstructed = M31::ZERO;
let shamt_val = row.shamt.as_u32() as usize;
Copy link

Copilot AI Dec 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same soundness issue - unconstrained use of shamt_val extracted via as_u32() for array indexing in SLLI constraint.

Suggested change
let shamt_val = row.shamt.as_u32() as usize;
let shamt_val = (row.shamt.as_u32() & 0x1F) as usize;

Copilot uses AI. Check for mistakes.

// Verify shift right logical: result_bit[i] = input_bit[i + shamt] if (i + shamt) < 32, else 0
let mut result_reconstructed = M31::ZERO;
let shamt_val = row.shamt.as_u32() as usize;
Copy link

Copilot AI Dec 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same soundness issue - unconstrained use of shamt_val extracted via as_u32() for array indexing in SRLI constraint.

Suggested change
let shamt_val = row.shamt.as_u32() as usize;
let shamt_val = (row.shamt.as_u32() & 0x1F) as usize;

Copilot uses AI. Check for mistakes.

// Verify arithmetic shift right: result_bit[i] = input_bit[i + shamt] if (i + shamt) < 32, else sign_bit
let mut result_reconstructed = M31::ZERO;
let shamt_val = row.shamt.as_u32() as usize;
Copy link

Copilot AI Dec 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same soundness issue - unconstrained use of shamt_val extracted via as_u32() for array indexing in SRAI constraint.

Suggested change
let shamt_val = row.shamt.as_u32() as usize;
let shamt_val = (row.shamt.as_u32() & 0x1F) as usize;

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 11 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@@ -0,0 +1,2 @@
[toolchain]
channel = "nightly"
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using an unpinned channel = "nightly" can make builds non-reproducible and can break CI unexpectedly when nightly changes. Consider pinning to a known-good dated nightly (e.g., nightly-YYYY-MM-DD) and, if required by the repo, specify any needed components (rustfmt/clippy) to keep developer environments consistent.

Suggested change
channel = "nightly"
channel = "nightly-2024-01-01"

Copilot uses AI. Check for mistakes.
Comment on lines +1178 to +1180
// For SLLI, shamt comes from imm[4:0]
let imm_low_5_bits = row.imm.as_u32() & 0x1F;
let shamt_check = row.shamt - M31::new(imm_low_5_bits);
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The immediate shift constraints also rely on as_u32() and masking (imm.as_u32() & 0x1F). As with the register shift variant, this is not an algebraic operation over the field and breaks the AIR’s polynomial/low-degree assumption. Please rework the imm[4:0]/shamt linkage using field-only constraints (e.g., constrain a 5-bit decomposition of imm / shamt).

Suggested change
// For SLLI, shamt comes from imm[4:0]
let imm_low_5_bits = row.imm.as_u32() & 0x1F;
let shamt_check = row.shamt - M31::new(imm_low_5_bits);
// Link the shift amount to the immediate value using field-only arithmetic
let shamt_check = row.shamt - row.imm;

Copilot uses AI. Check for mistakes.
Comment on lines 1781 to +1784
constraints.push(ConstraintEvaluator::rem_constraint(row));
constraints.push(ConstraintEvaluator::div_remainder_range_constraint(row));
constraints.push(ConstraintEvaluator::limb_range_constraint(row));
// TODO: Implement these range constraints
// constraints.push(ConstraintEvaluator::div_remainder_range_constraint(row));
// constraints.push(ConstraintEvaluator::limb_range_constraint(row));
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

evaluate_all no longer includes div_remainder_range_constraint and limb_range_constraint (now commented out). If these constraints are intentionally disabled, their tests/constraint lists should be updated accordingly; otherwise, please restore them (or replace them with sound range checks), since removing range constraints can weaken soundness assumptions elsewhere (e.g., any place using limb values as canonical integers).

Copilot uses AI. Check for mistakes.
Comment on lines +870 to +872
// Add shift witnesses (33 columns: 1 shamt + 32 rd_bits)
.chain(std::iter::once(self.shamt.clone()))
.chain(self.rd_bits.iter().map(|v| v.clone()))
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TraceColumns::to_columns appends 33 new shift-related columns at the end. Any code that deserializes a row by fixed indices (e.g., CpuTraceRow::from_slice in crates/air/src/rv32im.rs) must be updated to read these new columns; otherwise the AIR will ignore them and constraints for shifts will fail. Ensure the column ordering/offsets are updated together with the AIR row parser.

Copilot uses AI. Check for mistakes.
Comment on lines +715 to +721
let four = M31::new(4);
let quotient = M31::new(addr_lo.as_u32() / 4); // Integer division
let remainder = addr_lo - quotient * four; // addr_lo % 4

// Constraint: when is_word_access = 1, remainder must be 0
// If remainder != 0, constraint is non-zero → proof fails
is_word_access * remainder
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These alignment constraints compute addr_lo % {2,4} via as_u32() + integer division. In AIR code, constraints are expected to be polynomial functions of trace columns; floor-division on a field element is not polynomial and can break low-degree guarantees. Prefer adding a small witness for the remainder (or the low address bits) and constrain it algebraically (e.g., addr_lo = 4*q + r, r ∈ {0,1,2,3}, and is_word*r = 0).

Copilot uses AI. Check for mistakes.
Comment on lines +843 to +846
// Verify shamt extraction: shamt = rs2 & 0x1F
let rs2_low_5_bits = row.rs2_val_lo.as_u32() & 0x1F;
let shamt_check = row.shamt - M31::new(rs2_low_5_bits);

Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shift constraints use non-algebraic operations (as_u32() plus integer bit-masking) to derive rs2 & 0x1F / imm & 0x1F. In a STARK AIR, constraints must be low-degree polynomials over field elements; converting field elements to integers is not a polynomial operation and can break the stated “degree ≤ 2” guarantee. Prefer constraining the low 5 bits via dedicated witness bits / byte lookups (or an shamt decomposition) using only field operations.

Copilot uses AI. Check for mistakes.
Comment on lines +860 to +864
// Shift left: result_bit[i] = input_bit[i - shamt] if i >= shamt, else 0
let expected_bit = if i >= shamt_val && (i - shamt_val) < 32 {
row.rs1_bits[i - shamt_val]
} else {
M31::ZERO
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shift constraint branches on a witness-derived value (shamt_val) and uses it to index/select bits. That makes the constraint evaluation piecewise/non-polynomial, which typically violates AIR low-degree requirements and can cause proof generation/verification failures. Consider enforcing the shift relation algebraically (e.g., constrain rd_bits via a selector polynomial over the 5 shamt bits, or use a small lookup table) instead of if-branching on shamt.

Copilot uses AI. Check for mistakes.
Comment on lines +636 to +640
// Shift instruction witnesses
cols.shamt.push(M31::new(row.shamt));
for i in 0..32 {
cols.rd_bits[i].push(M31::new(row.rd_bits[i] as u32));
}
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trace adds shift witness columns as shamt + rd_bits[32], but the AIR-side CpuTraceRow defines shift witnesses as shamt + sll_bits/srl_bits/sra_bits (and from_slice currently doesn’t read any of them). Please make the column schema consistent end-to-end (executor/trace -> TraceColumns::to_columns -> CpuTraceRow::from_slice) so the AIR is actually constraining the provided witness columns.

Suggested change
// Shift instruction witnesses
cols.shamt.push(M31::new(row.shamt));
for i in 0..32 {
cols.rd_bits[i].push(M31::new(row.rd_bits[i] as u32));
}
// Shift instruction witnesses: currently only the shift amount is
// exposed here. Per-bit shift witnesses must be wired up
// consistently with the AIR (e.g. sll_bits/srl_bits/sra_bits)
// in the end-to-end column schema.
cols.shamt.push(M31::new(row.shamt));

Copilot uses AI. Check for mistakes.
Comment on lines +60 to +66
let four = M31::new(4);
let quotient = M31::new(addr_lo.as_u32() / 4); // Integer division
let remainder = addr_lo - quotient * four; // addr_lo % 4

// Constraint: when is_word = 1, remainder must be 0
// If remainder != 0, constraint is non-zero → proof fails
is_word * remainder
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as CpuAir::word_alignment_constraint: computing the remainder using as_u32()/integer division is not an algebraic (polynomial) constraint over the field, which is generally incompatible with STARK AIR low-degree requirements. This should be expressed via witness remainder/bit-decomposition constraints instead of floor(addr/4) in Rust.

Copilot uses AI. Check for mistakes.
Comment on lines +98 to +102
/// Shift amount (rs2 & 0x1F or imm[4:0]) for shift instructions.
pub shamt: u32,
/// Bit decomposition of rs1 for shift verification.
pub rs1_bits: [u8; 32],
/// Bit decomposition of shift result.
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TraceRow now stores rs1_bits/rd_bits arrays (and shamt) which significantly increases per-row trace size and serialization cost. Since TraceColumns::from_execution_trace already recomputes rs1_bits from row.regs[...] and could compute rd_bits from row.rd_val, consider removing these fields from TraceRow and deriving the bit witnesses directly during column generation to avoid bloating the execution trace.

Suggested change
/// Shift amount (rs2 & 0x1F or imm[4:0]) for shift instructions.
pub shamt: u32,
/// Bit decomposition of rs1 for shift verification.
pub rs1_bits: [u8; 32],
/// Bit decomposition of shift result.
/// Shift amount (rs2 & 0x1F or imm[4:0]) for shift instructions.
#[serde(skip, default)]
pub shamt: u32,
/// Bit decomposition of rs1 for shift verification.
#[serde(skip, default)]
pub rs1_bits: [u8; 32],
/// Bit decomposition of shift result.
#[serde(skip, default)]

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +737 to +747
// Check 2-byte alignment: addr % 2 == 0
// In M31 field: compute addr_lo mod 2
// We extract bottom bit by: addr_lo - 2 * floor(addr_lo / 2)

// Placeholder
is_half_access * (addr_lo - addr_lo)
let two = M31::new(2);
let quotient = M31::new(addr_lo.as_u32() / 2); // Integer division
let remainder = addr_lo - quotient * two; // addr_lo % 2

// Constraint: when is_half_access = 1, remainder must be 0
// If remainder != 0 (i.e., odd address), constraint is non-zero → proof fails
is_half_access * remainder
Comment on lines +843 to +866
// Verify shamt extraction: shamt = rs2 & 0x1F
let rs2_low_5_bits = row.rs2_val_lo.as_u32() & 0x1F;
let shamt_check = row.shamt - M31::new(rs2_low_5_bits);

// Verify bit decomposition and shift operation
let mut rs1_reconstructed = M31::ZERO;
let mut result_reconstructed = M31::ZERO;
let shamt_val = row.shamt.as_u32() as usize;

for i in 0..32usize {
let pow2 = if i < 31 {
M31::new(1 << i)
} else {
M31::new(1u32 << 31)
};
rs1_reconstructed += row.rs1_bits[i] * pow2;

// Shift left: result_bit[i] = input_bit[i - shamt] if i >= shamt, else 0
let expected_bit = if i >= shamt_val && (i - shamt_val) < 32 {
row.rs1_bits[i - shamt_val]
} else {
M31::ZERO
};
result_reconstructed += expected_bit * pow2;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants