AIR design

A Register File Without an Algebraic Mux

Four registers, addressed by LOAD/STORE. The obvious way to prove "this instruction touched register 2, not register 0" needs a selection argument. This VM asserts it instead — and tests specifically that lying about it gets caught.

2026-08-31 · crates/zkvm-stark/src/lib.rs

Adding a register file to a STARK VM is a natural second step after straight-line arithmetic — examples/counter.zkasm is the first program that needs to hold a value across unrelated computation. The obvious design question: when a row executes STORE r2, how does the AIR prove that register 2, specifically, got updated, and the other three didn't?

The one-hot column

Each row carries a one-hot reg_sel array — four field elements, one per register, exactly one of them equal to 1:

pub struct ProgramRow {
    ...
    pub reg_sel: [BaseElement; NUM_REGISTERS],
}

The same argument from the no-secret-witness post applies directly here: since the program is public, the verifier already knows which register every LOAD/STORE in the program addresses, at parse time. So reg_sel isn't derived by the AIR — it's asserted, one boundary constraint per register per row, exactly like the opcode selectors and the active flag.

What's actually constrained

Given reg_sel as already-known-correct, the register file's transition constraint for register k reads, roughly:

write_k = active * s_store * reg_sel[k]
next_register[k] = if write_k { acc } else { current_register[k] }

One constraint per register, degree 4 in the worst case (active * s_store * reg_sel[k] multiplied against the value being written) — the same shape as the accumulator constraint, just gated by an extra one-hot factor. Four registers means four of these constraints running in parallel, each one only firing on the rows where its own reg_sel[k] is 1.

The negative test that actually matters

The soundness claim here isn't "the honest prover produces a correct proof" — that's the easy direction. It's "a prover cannot claim a different register was touched and still verify." The test suite checks this directly, not just conceptually:

#[test]
fn rejects_a_claimed_register_selector_that_does_not_match_the_proof() {
    // ... generate a real proof, then tamper with reg_sel in the
    // public inputs before verifying ...
    assert!(verify_program(proof, tampered_inputs).is_err());
}

That test exists specifically because the register selector is a value the AIR trusts without algebraic derivation — the whole soundness argument rests on the verifier building it correctly from the program, and on a mismatched one causing rejection every time. That's what the test actually exercises.

Why four, and why static

Four registers is a small, deliberately narrow slice: enough to prove something needs more than one live value (the accumulator alone isn't sufficient once a program has to hold a result across unrelated arithmetic), not enough to look like general memory. reg_sel being one-hot over a fixed set of four only works because the register a given instruction touches is baked into the instruction at parse time — STORE r2 is syntactically fixed, not a runtime-computed address. That's exactly the boundary the soundness-trick post flags: real memory, where the address is itself a trace value, is a materially different problem.

Full source and tests: crates/zkvm-stark/src/lib.rs, examples/counter.zkasm.