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

Commit 169cebd

Browse files
TomWambsgansclaude
andcommitted
Hash the flock R1CS matrices densely, and rename the digest
The circuit digest absorbed the matrices in sparse form, eight bytes per nonzero, which at 13% to 20% density was seven times longer than the dense bit image: ~715 MB hashed against ~101 MB. Absorb the dense image instead, a row at a time so the scatter stays L1-resident and the 96 MiB image is never materialized. Computing the digest drops from 890 ms to 196 ms. The dense form is also canonical. Over GF(2) a repeated column index cancels and a reordered row is the same matrix, so the sparse form committed to an encoding rather than to the matrix. "Family" is renamed to "r1cs" throughout (FAMILY_DIGEST, family_digest, and the prose in lean_vm, rec_aggregation and the end-to-end doc section). What the word was carrying, that the digest omits the instance count and so covers every block-diagonal instance over these matrices, is now said outright in the doc comment. The in-hash domain tag becomes flock-r1cs-digest-v2, v2 because v1 named the sparse encoding. Also inlines the python verifier's transcript_statement into its one caller. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 030564c commit 169cebd

8 files changed

Lines changed: 44 additions & 54 deletions

File tree

crates/flock/src/blake2s.rs

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -357,16 +357,16 @@ fn build_matrices() -> (SparseBinaryMatrix, SparseBinaryMatrix) {
357357
(to_mat(a_rows), to_mat(b_rows))
358358
}
359359

360-
/// [`BlockR1cs::family_digest`] of this module's circuit, baked as a constant:
360+
/// [`BlockR1cs::r1cs_digest`] of this module's circuit, baked as a constant:
361361
/// recomputing it means building ~89M matrix entries and hashing their 96 MiB
362362
/// bit image, which embedding protocols would otherwise pay inside their first
363-
/// prove. The `family_digest_matches_baked` test recomputes and compares: a
363+
/// prove. The `r1cs_digest_matches_baked` test recomputes and compares: a
364364
/// circuit change fails it until this constant is updated alongside. The same
365365
/// digest is mirrored in `python-verifier/verifier.py`, which cannot rebuild
366366
/// the matrices at all.
367-
pub const FAMILY_DIGEST: [u8; 32] = [
368-
0x85, 0x3d, 0xce, 0x57, 0x41, 0x67, 0x01, 0x82, 0x17, 0x97, 0x55, 0xd5, 0xa0, 0x51, 0xd4, 0xf5, 0x63, 0x1b, 0x3c,
369-
0x0f, 0x10, 0x3d, 0x63, 0x3d, 0xda, 0x3a, 0xce, 0xcf, 0x6a, 0x88, 0x60, 0x1e,
367+
pub const R1CS_DIGEST: [u8; 32] = [
368+
0xec, 0x91, 0xe9, 0xd8, 0xd9, 0xca, 0x4e, 0x30, 0x62, 0x05, 0x90, 0x7a, 0x0d, 0x23, 0x6e, 0x53, 0xa6, 0xcd, 0xbd,
369+
0xa0, 0x38, 0x2e, 0xf6, 0xc4, 0x33, 0xef, 0x93, 0x63, 0xed, 0xfe, 0x04, 0x2e,
370370
];
371371

372372
/// Build a [`BlockR1cs`] batching `2^n_blocks_log` independent BLAKE2s
@@ -838,7 +838,7 @@ impl Blake2sSetup {
838838
/// the shared transcript, reducing R1CS validity of `blocks` to two
839839
/// evaluation claims on the committed packed witness `q_flock`. (The
840840
/// statement is already transcript-bound: the embedding protocol seeds
841-
/// with the circuit family digest and announces the count.) Returns:
841+
/// with the R1CS digest and announces the count.) Returns:
842842
/// - `z_packed`: the regenerated packed witness the PCS later opens against;
843843
/// - the [`PackedWitnessClaims`] `(ab, c)` on `q_flock`, with ring-switch weights.
844844
///
@@ -893,7 +893,7 @@ impl Blake2sSetup {
893893
assert_eq!(z_packed_lincheck.len(), packed_len * 8, "wrong lincheck stripe length");
894894

895895
// No bind_statement here: the embedding protocol (leanVM-b) seeds its
896-
// transcript with the circuit-FAMILY digest and binds the instance
896+
// transcript with the R1CS digest and binds the instance
897897
// count and commitment root before any challenge, so the statement is
898898
// already fully transcript-bound.
899899

@@ -963,7 +963,7 @@ impl Blake2sSetup {
963963
vs: &mut fiat_shamir::transcript::VerifierState<'_>,
964964
) -> Result<ReductionReplay, verifier::VerifyError> {
965965
// Mirror of prove_reduction: the statement is bound by the embedding
966-
// protocol's seed (family digest) + announced count + commitment root.
966+
// protocol's seed (R1CS digest) + announced count + commitment root.
967967

968968
let zc_claim = crate::zerocheck::verify(self.r1cs.m, vs).map_err(verifier::VerifyError::Zerocheck)?;
969969
let inner_rest_len = self.r1cs.k_log - self.r1cs.k_skip;
@@ -1060,11 +1060,11 @@ mod tests {
10601060
}
10611061

10621062
#[test]
1063-
fn family_digest_matches_baked() {
1063+
fn r1cs_digest_matches_baked() {
10641064
assert_eq!(
1065-
build_block_r1cs(3).family_digest(),
1066-
FAMILY_DIGEST,
1067-
"circuit family changed - update FAMILY_DIGEST"
1065+
build_block_r1cs(3).r1cs_digest(),
1066+
R1CS_DIGEST,
1067+
"R1CS changed - update R1CS_DIGEST"
10681068
);
10691069
}
10701070

crates/flock/src/r1cs.rs

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
//! We further specialize to **block-diagonal `A` and `B`**:
99
//! `A = I_{2^n_log} ⊗ A_0`, etc. The base matrices are `k × k` sparse
1010
//! boolean (`k = 2^k_log`). `C_0 = I_k` is implicit, but the materialized
11-
//! `c_0` matrix is still carried because [`BlockR1cs::family_digest`] absorbs
11+
//! `c_0` matrix is still carried because [`BlockR1cs::r1cs_digest`] absorbs
1212
//! it.
1313
1414
/// Sparse boolean matrix. `rows[i]` lists the column indices where the entry is 1.
@@ -22,7 +22,7 @@ pub struct SparseBinaryMatrix {
2222
/// Memory/variable layout of the committed witness (address bit `i` of the
2323
/// packed buffer = MLE variable `i`): `addr = [k_log inner bits | n_log batch
2424
/// bits]`, each instance one contiguous `2^k_log`-bit block. A one-variant
25-
/// enum so the layout byte stays an explicit part of [`BlockR1cs::family_digest`].
25+
/// enum so the layout byte stays an explicit part of [`BlockR1cs::r1cs_digest`].
2626
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
2727
pub enum WitnessLayout {
2828
#[default]
@@ -53,7 +53,7 @@ pub struct BlockR1cs {
5353
pub b_0: SparseBinaryMatrix,
5454
pub c_0: SparseBinaryMatrix,
5555
/// Memory/variable layout of the committed witness (see [`WitnessLayout`]).
56-
/// Bound into [`Self::family_digest`].
56+
/// Bound into [`Self::r1cs_digest`].
5757
pub layout: WitnessLayout,
5858
/// Column of a constant-one wire to pin to 1 across all blocks, or `None`.
5959
/// Drives the lincheck constant-wire pin (see
@@ -136,15 +136,18 @@ impl BlockR1cs {
136136
.all(|((ai, bi), ci)| (*ai & *bi) == *ci)
137137
}
138138

139-
/// BLAKE2s hash of the circuit FAMILY: the per-block matrices and the
140-
/// shape parameters, explicitly WITHOUT the instance count `m`. The full
141-
/// instance is block-diagonal (`m` copies of these matrices), so a
139+
/// BLAKE2s hash of the R1CS itself: the per-block matrices and the shape
140+
/// parameters, explicitly WITHOUT the instance count `m`. It therefore
141+
/// identifies every block-diagonal instance built from these matrices at
142+
/// once, whatever the count. The full instance is `m` copies of them, so a
142143
/// protocol that binds this digest and `m` separately has bound the whole
143144
/// statement; embedding protocols (leanVM-b) seed their transcript with it
144145
/// and announce the count.
145-
pub fn family_digest(&self) -> [u8; 32] {
146+
pub fn r1cs_digest(&self) -> [u8; 32] {
146147
let mut h = primitives::blake2s::Hasher::new();
147-
h.update(b"flock-r1cs-family-v1");
148+
// v2: v1 absorbed the matrices in sparse form, this one absorbs their
149+
// dense bit image (see `absorb_matrix`).
150+
h.update(b"flock-r1cs-digest-v2");
148151
h.update(&(self.k_log as u64).to_le_bytes());
149152
h.update(&(self.k_skip as u64).to_le_bytes());
150153
// The layout determines which polynomial a given witness commits
@@ -167,7 +170,7 @@ impl BlockR1cs {
167170
/// repeated column index cancels and a reordered row is the same matrix, so the
168171
/// sparse form commits to an encoding rather than to the matrix. And at these
169172
/// densities (13% to 20%) it is seven times shorter, one bit per entry against
170-
/// eight bytes per nonzero, which is the whole cost of [`BlockR1cs::family_digest`].
173+
/// eight bytes per nonzero, which is the whole cost of [`BlockR1cs::r1cs_digest`].
171174
fn absorb_matrix(h: &mut primitives::blake2s::Hasher, m: &SparseBinaryMatrix) {
172175
assert_eq!(m.rows.len(), m.num_rows);
173176
h.update(&(m.num_rows as u64).to_le_bytes());

crates/lean_vm/src/blake2s_flock.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -276,16 +276,16 @@ pub fn warm_setup(n_blocks: usize) {
276276
let _ = setup_for(n_blocks.max(1));
277277
}
278278

279-
/// The flock BLAKE2s circuit-FAMILY digest: a hash of the per-block R1CS
280-
/// matrices and shape parameters ([`family_digest`] on the R1CS), independent
279+
/// The flock BLAKE2s R1CS digest: a hash of the per-block R1CS matrices and
280+
/// shape parameters ([`flock::r1cs::BlockR1cs::r1cs_digest`]), independent
281281
/// of the instance count. The full instance is block-diagonal (the count is
282282
/// announced and absorbed with the other sizes), so a transcript seeded with
283283
/// this digest (via [`crate::cpu::fs_seed`]) binds the whole statement up
284284
/// front. Baked in flock (test-guarded): recomputing it costs ~200 ms of
285285
/// hashing on top of building the matrices, which used to land inside the
286286
/// first `prove`.
287-
pub fn family_digest() -> [u8; 32] {
288-
flock::blake2s::FAMILY_DIGEST
287+
pub fn r1cs_digest() -> [u8; 32] {
288+
flock::blake2s::R1CS_DIGEST
289289
}
290290

291291
/// **Flock reduction only** (prover): run flock's BLAKE2s zerocheck + lincheck

crates/lean_vm/src/cpu/mod.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@ const MAX_LOG_ROWS: usize = 32;
6363
const MAX_LOG_BYTECODE: usize = 32;
6464

6565
/// The Fiat-Shamir seed: ONE 32-byte digest, as two field words, committing
66-
/// to everything fixed about the proving environment: the flock circuit
67-
/// family (its per-block R1CS matrices, [`crate::blake2s_flock::family_digest`])
66+
/// to everything fixed about the proving environment: the flock BLAKE2s R1CS
67+
/// (its per-block matrices, [`crate::blake2s_flock::r1cs_digest`])
6868
/// and the bytecode, via the hash cached on `Program`: BLAKE2s over the stacked
6969
/// multilinear ([`layout::bytecode_table`]) rather than over an assembler
7070
/// digest, so a verifier holding only that polynomial reproduces the seed. That
@@ -77,7 +77,7 @@ const MAX_LOG_BYTECODE: usize = 32;
7777
pub fn fs_seed(program: &Program) -> [F192; 2] {
7878
let mut h = primitives::blake2s::Hasher::new();
7979
h.update(b"leanvm-b-fs-seed-v2-blake2s");
80-
h.update(&crate::blake2s_flock::family_digest());
80+
h.update(&crate::blake2s_flock::r1cs_digest());
8181
h.update(&program.bytecode_hash);
8282
let d = h.finalize();
8383
let word = |o: usize| u64::from_le_bytes(d[o..o + 8].try_into().unwrap());

crates/rec_aggregation/guests/recursion.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@
239239
# The seed is NOT baked into the guest: it rides the recursion's PUBLIC INPUT
240240
# (the fs_seed hint folded into own_pi in main), so ONE compiled guest verifies
241241
# proofs of any inner program of this VM — the outer statement fixes the whole
242-
# proving environment (circuit family + program), via own_pi.
242+
# proving environment (flock R1CS + program), via own_pi.
243243
# The arity is hinted too, as g^nsub, and absorbed by both aggregation transcripts
244244
# ahead of every variable-length sequence, so the outer statement fixes it as well.
245245
# NSUB_BOUND (nsub < NSUB_BOUND) is the compile-time range-check bound that makes
@@ -1080,7 +1080,7 @@ def verify_sub(pi_0, pi_1, seed_0, seed_1, g_logs_pow2, g_squares, defer_out):
10801080

10811081
# ---- seed (statement pre-bound: hinted sub pi + baked program digest) ----
10821082
fs = [TRANSCRIPT_SEED_0, TRANSCRIPT_SEED_1]
1083-
fs = obs(fs, seed_0) # the FS seed: H(flock circuit family, inner program
1083+
fs = obs(fs, seed_0) # the FS seed: H(flock BLAKE2s R1CS, inner program
10841084
fs = obs(fs, seed_1) # bytecode, ...) — from the recursion's public input
10851085
fs = obs(fs, pi_0) # bind the sub-proof's statement (its public input)
10861086
fs = obs(fs, pi_1)
@@ -2240,7 +2240,7 @@ def main():
22402240
assert log(nsub_g) < NSUB_BOUND
22412241
sub_pis = HeapBuf(nsub_g * nsub_g) # 2 statement words per sub
22422242
# The FS seed — ONE digest of everything fixed about the inner environment
2243-
# (the flock circuit family, the inner program bytecode) — rides the
2243+
# (the flock BLAKE2s R1CS, the inner program bytecode) — rides the
22442244
# recursion's public input: hinted here, it leads every sub's transcript
22452245
# and is folded into own_pi below, so the outer statement fixes the whole
22462246
# proving environment with one word pair.

crates/rec_aggregation/src/recursion.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -543,7 +543,7 @@ fn aggregate_deferred_claims(
543543
assert_eq!(mrun, v_a * wam + v_b * wbm, "guest terminal-weight formulas");
544544
}
545545

546-
// The inner proving environment (flock circuit family + program bytecode)
546+
// The inner proving environment (flock BLAKE2s R1CS + program bytecode)
547547
// is identified by ONE seed digest in the recursion's PUBLIC INPUT (not
548548
// baked into the guest), so one compiled guest serves any inner program.
549549
let seed = lean_vm::cpu::fs_seed(program);

doc/leanvm/body/08-end-to-end-protocol.tex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ \subsection{The unrolled protocol}\label{sec:e2e-unrolled}
7070

7171
Each reduction below adds evaluation claims on columns to a shared \emph{claim pool}; the final \textsc{Opening} phase proves all of them with a single PCS opening.
7272

73-
\paragraph{Setup and statement binding.} Fixed and shared in advance (\S\ref{sec:e2e-const} through \S\ref{sec:e2e-pi}): the fields and constants; the instance caps (\S\ref{sec:memchan}); the public program and its length; the initial state $(\pc_0,\fp_0)$ and final state $(\pc_{\mathrm{final}},\fp_{\mathrm{final}})$; and the public input $\mem[0],\mem[1]$. Before any challenge, the transcript is seeded by the public input and one environment digest that binds both the exact bytecode and flock's BLAKE2s circuit family. The prover then announces the memory log-size $h$ and the seven table log-heights $\tau_j$; the verifier checks them against the caps and derives every table, stack, and leaf shape.
73+
\paragraph{Setup and statement binding.} Fixed and shared in advance (\S\ref{sec:e2e-const} through \S\ref{sec:e2e-pi}): the fields and constants; the instance caps (\S\ref{sec:memchan}); the public program and its length; the initial state $(\pc_0,\fp_0)$ and final state $(\pc_{\mathrm{final}},\fp_{\mathrm{final}})$; and the public input $\mem[0],\mem[1]$. Before any challenge, the transcript is seeded by the public input and one environment digest that binds both the exact bytecode and flock's BLAKE2s R1CS. The prover then announces the memory log-size $h$ and the seven table log-heights $\tau_j$; the verifier checks them against the caps and derives every table, stack, and leaf shape.
7474

7575
\paragraph{Commitment.}
7676
\begin{enumerate}

python-verifier/verifier.py

Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -292,13 +292,7 @@ def blake2s_hash(data: bytes) -> Digest:
292292

293293
@dataclass(frozen=True, slots=True)
294294
class Digest:
295-
"""256 bits: a BLAKE2s output, a Merkle node, the sponge state, the public input.
296-
297-
One transcript encoding, everywhere: two 128-bit halves, each an E element
298-
carrying a K pair with a spare top lane (`pcs::merkle::hash_to_scalars`).
299-
128 bits is the VM's digest unit, which is what `PACK64X2` builds, so the
300-
recursion guest reads every digest with the same two-cell shape.
301-
"""
295+
"""256 bits"""
302296

303297
value: bytes
304298

@@ -976,7 +970,7 @@ def verify_constraints(
976970

977971
# VM statement, layout, and AIR -----------------------------------------------
978972

979-
FAMILY_DIGEST = bytes.fromhex("853dce5741670182179755d5a051d4f5631b3c0f103d633dda3acecf6a88601e")
973+
R1CS_DIGEST = bytes.fromhex("ec91e9d8d9ca4e306205907a0d236e53a6cdbda0382ef6c433ef9363edfe042e")
980974
MAX_LOG_BYTECODE = 32
981975

982976
# The bytecode's nine public columns (opcode + eight operand/immediate slots)
@@ -1004,18 +998,6 @@ def bytecode_columns(bytecode: Sequence[K]) -> tuple[tuple[tuple[K, ...], ...],
1004998
return columns, kbc
1005999

10061000

1007-
def transcript_statement(bytecode: Sequence[K], public_input: Sequence[E]) -> tuple[E, ...]:
1008-
"""The public statement, bound before any challenge (`lean_vm::cpu::fs_seed`).
1009-
1010-
The seed hashes the bytecode multilinear itself, not a structured program, so
1011-
a verifier holding only the polynomial can reproduce it. Rust caches that
1012-
inner hash on `Program`, which is why it is a separate step here.
1013-
"""
1014-
bytecode_hash = blake2s_hash(b"".join(word.to_bytes() for word in bytecode))
1015-
seed = blake2s_hash(b"leanvm-b-fs-seed-v2-blake2s" + FAMILY_DIGEST + bytecode_hash.value)
1016-
return (*seed.halves(), *public_input)
1017-
1018-
10191001
# The columns no instruction table owns (doc sec:e2e-unrolled, Commitment): the
10201002
# memory image's three limbs, the two finalize counts, and flock's packed
10211003
# witness. They come first in the global column numbering, the tables after.
@@ -2163,7 +2145,12 @@ def linear_rows(values: Sequence[E], base: int) -> None:
21632145
def verify_execution(bytecode: Sequence[K], public_input: Digest, proof: Proof) -> None:
21642146
pi = public_input.halves()
21652147
public_columns, bytecode_log = bytecode_columns(bytecode)
2166-
transcript = Transcript(proof, b"leanvm-b", transcript_statement(bytecode, pi))
2148+
# The public statement, bound before any challenge (`lean_vm::cpu::fs_seed`).
2149+
# The seed hashes the bytecode multilinear itself, not a structured program,
2150+
# so a verifier holding only the polynomial can reproduce it.
2151+
bytecode_hash = blake2s_hash(b"".join(word.to_bytes() for word in bytecode))
2152+
seed = blake2s_hash(b"leanvm-b-fs-seed-v2-blake2s" + R1CS_DIGEST + bytecode_hash.value)
2153+
transcript = Transcript(proof, b"leanvm-b", (*seed.halves(), *pi))
21672154

21682155
# 1] Statement binding: the announced instance shape, checked against the
21692156
# public caps before any reduction runs on it.

0 commit comments

Comments
 (0)