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

Commit a549472

Browse files
TomWambsgansclaude
andcommitted
Send sumcheck round polynomials whole to the consumers, minimal on the wire
Every sumcheck round polynomial has one evaluation the running claim already fixes: h(0) + h(1) = claim, or (1 + r)·h(0) + r·h(1) = claim where the round's eq weight was factored out. Three of the four sumchecks exploited that, each hand-rolling it in its own notation and its own basis; the table sumcheck in constraints.rs did not, and sent a value it then checked was redundant. Name it once. Transmitter::add_round_poly takes the whole polynomial as evaluations, h(0) first, and drops h(0) from the wire; Receiver::next_round_poly reads the rest, recovers h(0), and binds all of it. Every site now speaks the one convention, so the wire is minimal everywhere, including the 18 rounds of the table sumcheck that were not. The sponge binds every evaluation, so RawProof carries every evaluation, and the two consumers that read RawProof become plain textbook sumcheck: read the round polynomial, check it answers the claim, evaluate at the challenge. No reconstruction, no eq-split algebra, no basis conversion. That deletes an in-circuit field inversion per zerocheck round from the guest and the ONE_PLUS_CHALLENGE_INV table that fed it. Paying for that means the provers now track their running claim, since they have to bind an h(0) the verifier will recompute. whir mirrors the verifier's (claim, quad) pair, which it had a field for but never maintained; lincheck takes one O(k) inner product for round 0; zerocheck reuses the interpolation it already computes. The recursion harness no longer touches the wire proof at all. It reads the verifier's raw stream, so gen_verify lost its Proof parameter and run_recursion stopped carrying one. Measured on the pinning test: 1206 wire scalars against 1278 raw, the 72-scalar gap being exactly one per sumcheck round. Guest cycles 727,970 -> 728,287 (+0.04%): three more stream reads per round against one fewer inversion. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d690e1b commit a549472

8 files changed

Lines changed: 269 additions & 157 deletions

File tree

crates/fiat_shamir/src/transcript.rs

Lines changed: 79 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -49,18 +49,19 @@ pub struct Proof {
4949
}
5050

5151
/// The proof the recursion guest and the Python verifier consume: nothing
52-
/// shared, nothing pruned.
52+
/// shared, nothing pruned, nothing to reconstruct.
5353
///
54-
/// Same protocol, redundant encoding. Each query carries its own full Merkle
55-
/// path instead of an octopus over the batch, so a consumer walks one path per
56-
/// query with no dedup bookkeeping, which is the difference between a page of
57-
/// index arithmetic and a loop in the zkDSL. [`Proof`] is what goes over the
58-
/// wire; a verifier run produces this as a by-product
59-
/// ([`VerifierState::into_raw_proof`]), so the expansion is written once.
54+
/// Same protocol, redundant encoding. [`Proof`] is minimal because it drops
55+
/// every value a verifier can recompute; this is the same proof with all of
56+
/// them written out, which is what lets a consumer be one read-and-absorb loop.
57+
/// A verifier run produces it as a by-product
58+
/// ([`VerifierState::into_raw_proof`]), so each expansion is written once, in
59+
/// Rust, instead of three times in three languages.
6060
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
6161
pub struct RawProof {
62-
/// Identical to [`Proof::stream`]: no scalar is omitted from the wire form
63-
/// today. Carried here anyway so a consumer needs this struct alone.
62+
/// Every scalar a verifier reads AND binds, in binding order. Longer than
63+
/// [`Proof::stream`] by one evaluation per sumcheck round, the `h(0)` the
64+
/// wire omits.
6465
pub stream: Vec<F192>,
6566
/// One opening per query, phases concatenated in the order they ran.
6667
pub merkle_openings: Vec<MerkleOpening>,
@@ -118,6 +119,18 @@ pub trait Transmitter: Challenger {
118119
fn add_root(&mut self, root: &Hash) {
119120
self.add_scalars(&hash_to_scalars(root));
120121
}
122+
123+
/// Send one sumcheck round polynomial, as its evaluations with `h(0)` FIRST.
124+
///
125+
/// `h(0)` does not ride the wire: the running claim already fixes it (see
126+
/// [`Receiver::next_round_poly`]), and a minimal proof never repeats a value
127+
/// the verifier can recompute. It IS bound, along with every other
128+
/// evaluation, in this order.
129+
fn add_round_poly(&mut self, evals: &[F192]) {
130+
assert!(evals.len() >= 2, "a round polynomial has at least h(0) and h(1)");
131+
self.observe_scalar(evals[0]);
132+
self.add_scalars(&evals[1..]);
133+
}
121134
}
122135

123136
/// The verifier half, mirroring [`Transmitter`] call for call.
@@ -141,6 +154,15 @@ pub trait Receiver: Challenger {
141154
fn next_root(&mut self) -> Result<Hash, Error> {
142155
scalars_to_hash(&[self.next_scalar()?, self.next_scalar()?])
143156
}
157+
158+
/// Mirror of [`Transmitter::add_round_poly`]: read the `n_evals - 1`
159+
/// transmitted evaluations, recover `h(0)`, and bind the whole polynomial.
160+
///
161+
/// The running `claim` is what fixes `h(0)`. A plain round splits it as
162+
/// `h(0) + h(1) = claim` (char 2), so `h(0) = claim + h(1)`. A round whose
163+
/// `eq` weight `r` the caller factored out of `h` splits it as
164+
/// `(1 + r)·h(0) + r·h(1) = claim` instead.
165+
fn next_round_poly(&mut self, n_evals: usize, claim: F192, eq: Option<F192>) -> Result<Vec<F192>, Error>;
144166
fn grind_check(&mut self, bits: u32) -> Result<(), Error>;
145167
}
146168

@@ -177,6 +199,7 @@ pub struct VerifierState<'a> {
177199
offset: usize,
178200
merkle_paths: &'a [MerklePaths],
179201
phase: usize,
202+
raw_stream: Vec<F192>,
180203
raw_openings: Vec<MerkleOpening>,
181204
}
182205

@@ -190,32 +213,44 @@ impl<'a> VerifierState<'a> {
190213
offset: 0,
191214
merkle_paths: &proof.merkle_paths,
192215
phase: 0,
216+
raw_stream: Vec::new(),
193217
raw_openings: Vec::new(),
194218
}
195219
}
196220

197-
/// Advance the stream cursor by one **without** binding into the sponge: the
198-
/// read counterpart of the raw nonce push in [`ProverState::grind`].
221+
/// Advance the wire cursor by one **without** binding or recording: the read
222+
/// counterpart of the raw nonce push in [`ProverState::grind`], and the
223+
/// first half of reading a round polynomial (whose evaluations bind only
224+
/// once `h(0)` is known).
199225
fn take_raw(&mut self) -> Result<F192, Error> {
200226
let x = *self.stream.get(self.offset).ok_or(Error::ExceededStream)?;
201227
self.offset += 1;
202228
Ok(x)
203229
}
204230

231+
/// Bind a scalar read off the wire, and record it: [`RawProof::stream`] IS
232+
/// the sequence of these, in this order.
233+
#[inline]
234+
fn bind(&mut self, x: F192) {
235+
self.sponge.observe(x);
236+
self.raw_stream.push(x);
237+
}
238+
205239
/// The redundant form of the proof just verified: every scalar it read, plus
206240
/// one unpruned opening per query in phase order. Meaningful only after a
207241
/// verification that accepted, since a rejected one stops part way.
208242
pub fn into_raw_proof(self) -> RawProof {
209243
RawProof {
210-
stream: self.stream.to_vec(),
244+
stream: self.raw_stream,
211245
merkle_openings: self.raw_openings,
212246
}
213247
}
214248

215-
/// How many stream words have been read so far: the cursor a caller needs
216-
/// to locate a sub-protocol's scalars without counting back from the tail.
249+
/// How many scalars have been read and bound so far: the cursor into
250+
/// [`RawProof::stream`] a caller needs to locate a sub-protocol's scalars
251+
/// without counting back from the tail.
217252
pub fn stream_offset(&self) -> usize {
218-
self.offset
253+
self.raw_stream.len()
219254
}
220255

221256
/// Assert the whole proof was consumed (no trailing/extra data).
@@ -289,17 +324,43 @@ impl<'a> Receiver for VerifierState<'a> {
289324
/// Read the next scalar, binding it into the sponge (mirrors `add_scalar`).
290325
#[inline]
291326
fn next_scalar(&mut self) -> Result<F192, Error> {
292-
let x = *self.stream.get(self.offset).ok_or(Error::ExceededStream)?;
293-
self.offset += 1;
294-
self.sponge.observe(x);
327+
let x = self.take_raw()?;
328+
self.bind(x);
295329
Ok(x)
296330
}
297331

332+
fn next_round_poly(&mut self, n_evals: usize, claim: F192, eq: Option<F192>) -> Result<Vec<F192>, Error> {
333+
assert!(n_evals >= 2, "a round polynomial has at least h(0) and h(1)");
334+
let mut evals = vec![F192::ZERO; n_evals];
335+
for e in &mut evals[1..] {
336+
*e = self.take_raw()?;
337+
}
338+
evals[0] = match eq {
339+
None => claim + evals[1],
340+
// `(1 + r)·h(0) + r·h(1) = claim`. At `r = 1` that leaves h(0) free,
341+
// so it is not a usable weight; every caller's `r` is a challenge.
342+
Some(r) => {
343+
let one_plus_r = F192::ONE + r;
344+
if one_plus_r.is_zero() {
345+
return Err(Error::NonCanonicalEncoding);
346+
}
347+
(claim + r * evals[1]) * one_plus_r.inv()
348+
}
349+
};
350+
for &e in &evals {
351+
self.bind(e);
352+
}
353+
Ok(evals)
354+
}
355+
298356
/// Verifier mirror of [`Transmitter::grind`]: read the transmitted nonce and
299357
/// check it clears the `bits` proof-of-work, then bind it (so the sponge
300358
/// stays in lockstep). Rejects a proof that skipped or under-did the grind.
301359
fn grind_check(&mut self, bits: u32) -> Result<(), Error> {
302360
let nonce = self.take_raw()?;
361+
// Bound by the PoW absorb inside `verify_pow_field` rather than by
362+
// `observe`, but still a scalar the consumer reads at this position.
363+
self.raw_stream.push(nonce);
303364
if self.sponge.verify_pow_field(nonce, bits) {
304365
Ok(())
305366
} else {

crates/flock/src/lincheck.rs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1226,10 +1226,16 @@ pub fn prove_padded_capture_s_hat_v(
12261226
// round's message falls out of binding the previous round (fold +
12271227
// next-eval fused into one pass, see `sumcheck_bind_both_and_eval_next`).
12281228
let (mut e1, mut einf) = sumcheck_round_eval_par(&comb_vec, &z_vec);
1229+
// The running claim, mirrored from the verifier: `q(0) + q(1) = claim`
1230+
// is what lets the wire drop `q(0)`, so the prover has to know it too.
1231+
// Round 0's claim is the whole inner product, one O(k) pass over the
1232+
// column vectors and negligible beside the sumcheck itself.
1233+
let mut running = inner_product_ext(&comb_vec, &z_vec);
12291234
for t in 0..inner_rest_len {
1230-
ps.add_scalar(e1);
1231-
ps.add_scalar(einf);
1235+
let e0 = running + e1;
1236+
ps.add_round_poly(&[e0, e1, einf]);
12321237
let r = ps.sample();
1238+
running = (einf * r + (e0 + e1 + einf)) * r + e0;
12331239
r_rounds.push(r);
12341240
if t + 1 < inner_rest_len {
12351241
// Fused: bind both tables at r AND compute round (t+1)'s message.
@@ -1351,11 +1357,11 @@ pub fn verify(
13511357
let mut running = target;
13521358
let mut r_rounds = Vec::with_capacity(inner_rest_len);
13531359
for _ in 0..inner_rest_len {
1354-
let e1 = vs.next_scalar().map_err(VerifyError::Transcript)?;
1355-
let einf = vs.next_scalar().map_err(VerifyError::Transcript)?;
1360+
// `q(0) + q(1) = claim` in char 2, so `q(0)` never rides the wire.
1361+
let q = vs.next_round_poly(3, running, None).map_err(VerifyError::Transcript)?;
1362+
let (e0, e1, einf) = (q[0], q[1], q[2]);
13561363
let r = vs.sample();
1357-
// q(0) = claim + q(1) in char 2; q(X) = einf·X² + c1·X + e0.
1358-
let e0 = running + e1;
1364+
// q(X) = einf·X² + c1·X + e0.
13591365
let c1 = e0 + e1 + einf;
13601366
running = (einf * r + c1) * r + e0;
13611367
r_rounds.push(r);

crates/flock/src/zerocheck.rs

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,18 @@ pub enum VerifyError {
119119
// API: prove / verify.
120120
// ---------------------------------------------------------------------------
121121

122+
/// Send one multilinear round and advance the running claim, the prover mirror
123+
/// of the verifier's loop. `G(0)` never rides the wire: the eq split
124+
/// `(1 + r_eq)·G(0) + r_eq·G(1) = claim` fixes it. All three evaluations bind.
125+
fn send_round(ps: &mut impl Transmitter, claim: F192, r_eq: F192, g1: F192, g_inf: F192, rhos: &mut Vec<F192>) -> F192 {
126+
let g0 = (claim + r_eq * g1) * (F192::ONE + r_eq).inv();
127+
ps.add_round_poly(&[g0, g1, g_inf]);
128+
let rho = ps.sample();
129+
rhos.push(rho);
130+
// G(X) = G(0)·(1+X) + G(1)·X + G(inf)·X·(1+X).
131+
g0 + rho * (g0 + g1 + (F192::ONE + rho) * g_inf)
132+
}
133+
122134
/// THE zerocheck prover entry: proves `a·b ⊕ c = 0` over the padded cube and
123135
/// ALSO returns the canonical `s_hat_v_c` produced by the fused two-bank
124136
/// round-1 kernel
@@ -221,10 +233,15 @@ pub fn prove_packed_padded_capture_s_hat_v_c(
221233
);
222234
}
223235
let t_tail = std::time::Instant::now();
224-
ps.add_scalar(msg_1);
225-
ps.add_scalar(msg_inf);
236+
// The running claim, mirrored from the verifier exactly (same interpolation
237+
// of the same round-1 values at the same z). `(1+r)·G(0) + r·G(1) = claim`
238+
// is what lets the wire drop `G(0)`, so the prover has to know it too.
239+
let mut c_running = {
240+
let combined: Vec<F192> = round1_ab.iter().zip(&round1_c).map(|(x, y)| *x + *y).collect();
241+
interpolate_at_z_combined(&combined, k_skip, z) + final_c_eval
242+
};
226243
let mut mlv_rhos: Vec<F192> = Vec::with_capacity(n_mlv);
227-
mlv_rhos.push(ps.sample());
244+
c_running = send_round(ps, c_running, r_rest[0], msg_1, msg_inf, &mut mlv_rhos);
228245

229246
// ---- Rounds 3..(n_mlv + 1): AB only (c is done) ----
230247
//
@@ -283,9 +300,7 @@ pub fn prove_packed_padded_capture_s_hat_v_c(
283300
round_pair_naive(&a_mlv, &b_mlv, r_eq)
284301
};
285302

286-
ps.add_scalar(m1);
287-
ps.add_scalar(mi);
288-
mlv_rhos.push(ps.sample());
303+
c_running = send_round(ps, c_running, r_rest[i + 1], m1, mi, &mut mlv_rhos);
289304
}
290305

291306
// ---- Final binding at ρ_{n_mlv} (the last challenge) ----
@@ -399,14 +414,11 @@ pub fn verify(log_n: usize, vs: &mut VerifierState<'_>) -> Result<ZerocheckClaim
399414
// interpolation through G(0), G(1), G(∞)).
400415
let mut mlv_rhos: Vec<F192> = Vec::with_capacity(n_mlv);
401416
for i in 0..n_mlv {
402-
let msg_1 = vs.next_scalar().map_err(VerifyError::Transcript)?;
403-
let msg_inf = vs.next_scalar().map_err(VerifyError::Transcript)?;
404417
let r_eq = r_rest[i];
405-
let one_plus_r_eq = F192::ONE + r_eq;
406-
407-
let g1 = msg_1;
408-
let g_inf = msg_inf;
409-
let g0 = (c_running + r_eq * g1) * one_plus_r_eq.inv();
418+
let g = vs
419+
.next_round_poly(3, c_running, Some(r_eq))
420+
.map_err(VerifyError::Transcript)?;
421+
let (g0, g1, g_inf) = (g[0], g[1], g[2]);
410422

411423
let rho = vs.sample();
412424
mlv_rhos.push(rho);

crates/lean_vm/src/constraints.rs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ pub struct Claims {
5353
#[derive(Clone, Debug, PartialEq, Eq)]
5454
pub enum Error {
5555
Truncated,
56-
RoundInconsistent { round: usize },
5756
FinalMismatch,
5857
}
5958

@@ -195,7 +194,8 @@ pub fn prove(
195194
let p4 = [msg[0], msg[1], msg[2], lagrange_eval(&nd, &msg, q[3])];
196195
let h: [F192; 4] = std::array::from_fn(|i| (F192::ONE + zeta[m] + q[i]) * p4[i] + q[i] * u);
197196
// A separate pass: the challenge only exists once the message is bound.
198-
ps.add_scalars(&h);
197+
// `h(0)` does not ride the wire; `h(0) + h(1) = claim` fixes it.
198+
ps.add_round_poly(&h);
199199
let rk = ps.sample();
200200
rho[m] = rk;
201201
k *= rk;
@@ -264,10 +264,9 @@ pub fn verify(
264264
let mut rho = vec![F192::ZERO; n];
265265
for j in 0..n {
266266
let m = n - 1 - j;
267-
let h = vs.next_scalars(4).map_err(|_| Error::Truncated)?;
268-
if h[0] + h[1] != claim {
269-
return Err(Error::RoundInconsistent { round: j });
270-
}
267+
// `h(0)` is derived from the running claim rather than transmitted, so
268+
// the round-consistency check it used to enable holds by construction.
269+
let h = vs.next_round_poly(4, claim, None).map_err(|_| Error::Truncated)?;
271270
let rk = vs.sample();
272271
rho[m] = rk;
273272
claim = lagrange_eval(&nd, &h, rk);

0 commit comments

Comments
 (0)