Skip to content
This repository was archived by the owner on Sep 12, 2026. It is now read-only.

Fixing stuff - #13

Merged
TomWambsgans merged 13 commits into
mainfrom
fix-compiler
Aug 22, 2026
Merged

Fixing stuff#13
TomWambsgans merged 13 commits into
mainfrom
fix-compiler

Conversation

@TomWambsgans

Copy link
Copy Markdown
Contributor

No description provided.

TomWambsgans and others added 13 commits August 22, 2026 15:02
A dropped constraint is the compiler's quietest failure: the happy path
passes, nothing is diagnosed, and the only symptom is a proof of something
weaker than the source says. Positive tests cannot catch it, so attack the
absence of a constraint from three sides.

- Perturbation (soundness/cases.rs), the shape of ../leanVM's own
  test_soundness_suite: one valid trial per program plus a table of
  single-cell pokes at the public input or a witness stream, each of which
  must make the run fail. Six cases covering the arithmetic relations, both
  assert forms, the division back-solve, the exponent range check,
  match_range dispatch, an if/else join, a mul_range loop with a runtime
  bound, pack64x2's K-range assertion, and the digest-as-verification idiom.

- Equivalence (soundness/pairs.rs): two spellings zkDSL.md documents as
  interchangeable must accept exactly the same trials. This is the layer
  that finds dropped stores. A dropped store is invisible alone, since the
  program still runs and its honest witness still passes; it is obvious
  against a spelling that kept it, and the more permissive side is the buggy
  one. Each pair carries the promise it tests, to be quoted in the bug
  report.

- Unconstrained reads (Execution::unconstrained_reads, asserted in
  cpu::prove): a cell an instruction read that nothing ever wrote. zkDSL.md
  says don't; this is what says whether the emitted code did. Read off the
  count and written vectors after the run rather than recorded in Mem::get,
  which is in the opcode loop, so it costs the prover nothing. Scoped to the
  program's own cells, below where the fill's frames begin: fill rows read
  cells nobody writes as a matter of course and are soundness-neutral for
  it. A hard assert rather than a debug_assert, since release is the only
  profile the VM runs in.

The invariant holds today for every program in the repo, including the
recursion guest: aggregate_one_signer and aggregate_two_to_one prove clean
under it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`take_inline_ret_cell` handed back `RetBind::Stack(base, 1)`'s raw frame cell
while its `RetBind::Gaddr` sibling arm materialized. If the `@inline` body had
filled that cell with a deferred copy or constant, `stack_store` emitted
nothing, so no instruction ever wrote it and every expression-position
consumer read a cell outside the constraint system: `expr`'s call arm (an
`assert` operand), `expr_into`'s call arm (any store RHS), and
`lower_match_range`'s inline-arm join, whose `copy` also reads its source raw.

An unwritten cell is not zero, it is free. Memory is a committed array and the
memory bus only forces accesses to one address to agree, never that the
address was written, so the prover fixes it at commit time and the honest
runner back-solves it to whatever the statement demands. Before this commit
`assert pick(v[0]) != v[1]` with `v[0] == v[1]` proved and verified, and the
same program published three different values for `pick(v[0])` under one
witness. The `let` spelling of the same source rejected all of it, which is
the asymmetry: `ret_binding` hands over a `Binding::Stack` whose reads go
through `word_src`, and an expression use has to agree with it.

Fix is that one call, in both consumers. No instance existed in the shipped
guest, whose `@inline` StackBuf returns are all two cells, so nothing
previously provable changes.

Regression test: soundness::pairs::inline_stackbuf_return_in_expression_position.
Both of the new infrastructure's relevant layers catch it, which is why the
pair is the whole test: the equivalence check sees the two spellings disagree
on an equal-valued witness, and the unconstrained-read check sees the raw cell
on every trial including the accepting ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`stack_store` deferred a copy-or-constant RHS as an `Alias` unconditionally and
emitted nothing, while the two consumers that name a stack run by its physical
cells did not follow the alias: the `BLAKE2s` output arm and `hint_witness`'s
destination. So the physical cell and the alias source were joined by no
constraint, and a `StackBuf` store was never the write-once equality assertion
that zkDSL.md §Memory promises and §BLAKE2s recommends by name ("If `out` was
already written, the statement *asserts* the digest equals it ... which is
exactly what a signature verifier wants"). It did not. Pinning a hint with
`s[k] = <checked value>` dropped the check, a pre-written `StackBuf` digest was
written where nothing read it, and two stores of different values to one cell
produced no conflict at all.

The alias is sound only while nothing else gives the cell a value, so track
that and stop the two from colliding, in both orders:

- `emit` records the stack cells an instruction writes (`Deref`'s local cell
  included, since the interpreter fills whichever side of its equality is
  unset), and `stack_store` emits instead of aliasing when `dst` is one of
  them. That is the store-after-write order.
- `materialize_run` gives a raw destination run real values in its own cells
  before a consumer names them, and marks the run written. That is the
  write-after-store order. Called from the `BLAKE2s` output arm,
  `hint_witness`, and `hint_f192_limbs`, which is the third consumer of the
  same shape and had the same defect.

zkDSL.md said both things in different places; it now states the precondition
where it describes the alias, so the two halves agree.

Nothing in the shipped guest collided, so the emitted bytecode is unchanged
there: the whole workspace passes, including aggregate_two_to_one and the
adversarial aggregate_statement_binds / aggregate_hints_bind.

Regression tests: soundness::pairs::stack_store_pins_a_hint_like_a_heap_store
and ::prewritten_blake2s_out_asserts_the_digest, both verified red before this
commit. The first publishes the PIN rather than the hint on purpose: publishing
the hint hides a dropped pin, because the publication then forwards through the
very alias that dropped it and both spellings agree by accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects with one root, both in the deferred-alias path.

A second store into a cell whose value was itself deferred just replaced the
first alias, so the two values never met: `s[0] = a; s[0] = b` emitted a single
`SET` of `b` and asserted nothing, where the same pair of stores into a HeapBuf
cell is the write-once equality assertion zkDSL.md §Memory promises. The
previous commit's message listed this case while describing the bug, but its
fix only covered a store into a PHYSICALLY written cell; a store into a
deferred one stayed silent. This is that case.

`scoped` then inherited it. A branch storing into a cell that carried a
pre-branch alias took the replace path, so the branch's value lived only in the
branch-local alias; `scoped` materialized it at the join and immediately
restored the pre-branch alias over it, leaving the write orphaned and every
post-join read forwarding to the pre-branch source. The published value was the
pre-branch one whichever arm ran.

The rule that fixes both: a store into a cell that already has a value,
deferred or physical, is the assertion, so materialize what the cell already
stood for and then emit. Assembly is untouched, since assembling a BLAKE2s
operand or a list literal stores each cell once. `scoped` needs no change of
its own: the in-branch store now asserts against the pre-branch value on both
arms symmetrically, which is what makes restoring the pre-branch alias correct
rather than lossy, since the assertion is exactly what says the two agree.

Also sort `branch_outputs` before emitting its copies. It was iterating a
HashMap, so two builds of one source could emit them in different orders and
produce different bytecode. The bytecode digest leads the Fiat--Shamir
transcript, so that is a verifier that disagrees with itself across processes.

Guest unaffected: bytecode byte-identical (DBG_PROF_DUMP diff clean) and the
recursion benchmark unchanged at 321,127 instructions / 796,006 cycles.

Regression tests: soundness::pairs::two_stack_stores_to_one_cell_assert_equality
and ::store_inside_a_branch_asserts_against_the_pre_branch_value, both verified
red before this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`lower_dispatched_call`'s join emits one `DEREF` per bound name without ever
comparing that count to any callee's declared arity, while the non-fused path
enforces exactly that (`call_into`). So one source was rejected by one lowering
of `match_range` and silently miscompiled by the other.

A name past a callee's arity reads a callee-frame offset nothing on the taken
path writes, and because the shared frame is sized to the LARGEST callee the
offset exists inside the allocation: the surplus name binds a prover-chosen
word. Memory is a committed array and the memory bus only forces accesses to
one address to agree, never that the address was written.

Broader than mixed-arity arms, which is how it was first described: a SINGLE
over-bound callee fuses with no diagnostic too, and that is the shape all three
guest sites use, so the guest was correct only because its author counted
right, with nothing checking.

The check has to look the callee up in the queue as well as in `defs`, which is
the reason a `defs`-only version of it passes on everything: `specialize`
registers a `Const` specialization in the queue under a mangled name and never
puts it in `defs`, and a dispatched `match_range` names specializations
exclusively. `return_shapes_of` covers both.

Regression tests: soundness::cases::dispatched_call_rejects_a_mixed_arity_arm
and ::dispatched_call_rejects_an_over_bound_callee.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
zkDSL.md §Global constants reserves a top-level constant's name: "do not reuse
it as a parameter or local name". A SCALAR constant enforced that by
construction, since the parser substitutes its value textually and the
shadowing binding becomes a literal, which fails loudly. A constant ARRAY is
carried to lowering instead, where `const_array_elem` resolves `NAME[i]`
against `const_arrays` without consulting the scope and `expr` folds constants
before its index arm could see the local. So the collision was silent, and the
local's compile-time-indexed reads were folded to baked literals.

That is the catastrophic direction for a hint. A `hint_witness` into a shadowed
buffer had its range check evaluated against the constant: `Q = [8, 32]` with a
local `Q = StackBuf(2)` compiled `assert log(Q[0]) < 8` into the 3-cycle gadget
applied to the literal `g^3`, which passes, while the actual witness `g^40` was
never bounded and its cells never read.

Checked at the three places a name is bound: `rebind` for locals and
assignments, the inline-callee parameter binds, and the function-entry
parameter binds. Rejecting is right rather than letting the local win, since
picking a winner silently changes the meaning of existing sources and the doc
already says the name is not available.

The guest defines many constant arrays (the LIG_* tables) and collides with
none of them.

Regression tests: soundness::cases::a_local_may_not_shadow_a_constant_array and
::a_parameter_may_not_shadow_a_constant_array.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rifiers

A proof announces table heights, a memory size and a rate. Each was capped on
its own, but what the PCS has to be configured for is the stacked size 2^mu
they IMPLY, and the caps do not bound it: maxing all of them gives mu = 41, and
the memory cap alone gives 35 while the bytecode cap alone gives 33. So the
documented instance caps describe shapes the WHIR ladder has no config for at
all, and nothing checked it: the missing config surfaced as a panic inside the
opening.

`pcs::MAX_MU` is the single knob, and everything else derives from it.
`cpu::read_public` checks `MIN_MU..=MAX_MU` before any reduction runs against
the layout, and `rec_aggregation::MU_MAX` IS `pcs::MAX_MU`, so the recursion
guest's compiled opening arms follow the knob through its existing placeholder
(`LIG_N_LOG_SIZES`) with nothing to keep in step by hand. Verified by moving it:
at 29 the guest recompiles to 364,481 instructions from 321,127, the four extra
arms, and the whole suite passes.

`python-verifier` is standalone and dependency-free, so it cannot read the Rust
constant and keeps a literal. `whir_query_table.rs` already dumped its two ends
without comparing them; it now asserts them against the Rust constants and its
failure message names the edit.

MAX_MU = 28 is a policy cap, not the ladder's ceiling, which is higher and
rate-dependent (36 at log_inv_rate 1, 32 at 4). A test keeps the window inside
what the ladder supports at every rate, which is what lets a plain range check
stand in for re-deriving it. Note that 28 leaves one bit over the non-recursive
XMSS path, which commits 2^26.195 at 900 signatures and scales linearly with
the signature count: doubling that workload needs the knob raised, and raising
it costs ~4 guest opening arms of ~12k instructions per size.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`from_parts` recomputed the two deferred claims before anything else, so
deserializing ran a pass over the whole stacked bytecode plus a walk of the
BLAKE2s circuit on points a peer chose. Anything decidable without that has to
be decided first: `recompute` already checked the point dimensions, and
`check_signer_set` now runs ahead of it, so a malformed signer set costs a sort
check instead of the two evaluations. `verify` still checks it too, since
`aggregate` builds the object directly rather than through `from_parts`.

Also reject trailing bytes. `bincode`'s free functions allow them, so every
padding of an aggregate's encoding decoded to the same aggregate, and anything
downstream that dedupes or indexes on the serialized bytes could be shown one
aggregate as many. `wire()` keeps the same fixed-width integer encoding and
rejects the trailing bytes; the encoding was checked to be byte-identical to
what the free function produces, since a wire-format change would have to move
every encoder and decoder with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It was zero everywhere and had never run. The only two sources of
`fold_grinding_bits` were `vec![0usize; n_levels]` in the fallback config and
the literal `0` in the production derivation, and two assertions pinned it
there, so four implementations of the mechanism (the prover, the native
verifier, the recursive verifier, and the guest's zkDSL) had never executed a
single time in any test. Untested code that claims a security capability is
worse than absent code, because it reads as coverage.

It is also not the lever it looks like. The eta search keeps the proximity-gap
term at or above the 128-bit target on its own across every feasible size
(measured: min pg_bits is 128..137 over the whole ladder at every rate), so
grinding the fold challenges had nothing to close. Where the ladder does stop,
it stops because no eta satisfies the Johnson terms simultaneously, at
committed sizes well past anything reachable here: every leaf has to be
guest-verifiable, so MU_MAX = 28 caps the whole topology while fold grinding
would only have bought roughly 32 to 37.

Removal is transcript-neutral by construction, since every grind sat behind
`if bits > 0` and the bits were always 0. Confirmed: bytecode 321,127
instructions and 796,006 cycles for the recursion benchmark, 2^26.195 committed
for the XMSS one, all byte-identical to before, and the adversarial
aggregate_statement_binds / aggregate_hints_bind / aggregate_three_levels pass.

Query-phase grinding is untouched: it is a live, load-bearing 17 bits.

Side effect worth noting: `pow_bits_ok`'s `debug_assert!(bits < 64)` precondition
now has one caller instead of two, and the survivor is a compile-time constant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`lean_vm::cpu::read_public` rejects a bytecode that is not a power of two or
longer than 2^MAX_LOG_BYTECODE; `python-verifier` checked neither. The
power-of-two half was reachable only through `Framework.log_bytecode`, a
property, so it fired wherever that was first read rather than up front, and
nothing bounded the length at all.

Checked in `build_layout`, with the memory-size, table-height and BLAKE2s-floor
caps it belongs to. `0 <= log_bytecode` also rejects a bytecode shorter than one
bus row, which used to yield a negative log and carry on.

The 2^32 ceiling is not exercisable in a test: reaching it needs 2^36 K-words of
stacked bytecode. Same on the Rust side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Protocol 1 step 4 described a final level that checks
`Enc(f_final)[x_q] == c_q` for each query directly. All three verifiers instead
turn each consistency check into a weighted claim about `f_final`, which
Lemma `lem:colweight` already says it is, batch those with the residual claim
under a fresh lambda drawn after the columns and their opened rows are bound,
and discharge the batch with the level's remaining sumcheck rounds, closing on
one evaluation of the transmitted table. That is one evaluation of `f_final`
where the written version is `n_q` encodings of it.

Same claims, so the case analysis is unchanged; it now ends by putting
`f_final` in violation of one of the `n_q + 1` batched claims rather than
failing a check outright. The extra batching challenge the implementations draw
was missing from `thm:rbr`, so add its term: `n_q / |E|`, with no union over a
list, since `f_final` is a single transmitted table rather than one of the
codewords near an oracle. At the query counts in use that term is far below the
128-bit target, so the security level does not move; the point is that the
theorem now accounts for every challenge the protocol draws.

The RBR invariant gains the matching step, since the transcript no longer ends
at the final query message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`johnson_algebraic_bits_for` took the level's OWN query count as the degree of
its batching polynomial. The claims that batch carries are the ones the
PREVIOUS level's query phase raised: `thm:rbr` has `J_i = n_{i-1} + 2`, one per
query plus the residual and the OOD claim. Query counts fall with depth (at
rate 1/2, log_n 28: 228, 56, 38, 28, 23, 19, 16), so the level's own count is
the smaller one, and substituting it understates the degree and overstates the
bound, by about 2 bits at L1.

Thread the previous level's count through instead: the eta search takes it as a
parameter, and `validate()` and the production-profile test read it off the
level list.

Inert either way today, which is the reason to fix it rather than leave it: the
degree is `max(RING_SWITCH_SOUNDNESS_DEGREE, prev_queries + ood_samples, 2)`,
and the ring-switch map's degree is about 2^31, some 23 bits above any query
count, so it takes the max at every level. A wrong term with no effect is the
kind that survives a refactor; if that degree is ever tightened, or a level
skips ring switching, the bound now degrades correctly.

Derivation unchanged, and checked rather than assumed:
`whir_query_table_matches_rust` compares the derived query counts against the
pinned table at every rate and size in the window, and still passes.

L0 is passed 0 and still does not model `J_0`, which is the outer protocol's
claim pool rather than a query count. Recorded in the comment; accounting for
it would mean teaching `whir_config` about `lean_vm`'s pool.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@TomWambsgans
TomWambsgans merged commit eca9667 into main Aug 22, 2026
@TomWambsgans
TomWambsgans deleted the fix-compiler branch August 22, 2026 15:17
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant