diff --git a/CHANGELOG.md b/CHANGELOG.md index 26b76b3..d786cac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Major revision: unified API, one verifier, one proof type, 7 provers, SIMD accel - **Single proof type** — `SumcheckProof` replaces `Sumcheck` and `ProductSumcheck`. - **Transcript redesigned** — `send()`/`receive()`/`challenge()` replace `read()`/`write()`. - **Legacy entry points demoted** — use `runner::sumcheck()` with a prover type. +- **Wire format: EvalsInfty.** `d` values per round instead of `d + 1`; consistency is now structural. Details in [docs/design.md §7a](docs/design.md). ### Added diff --git a/README.md b/README.md index 87d6378..59f9db0 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ Each prover comes in two variants: | `MultilinearProver` | `MultilinearProverLSB` | | `InnerProductProver` | `InnerProductProverLSB` | | `CoefficientProver` | `CoefficientProverLSB` | +| `EqFactoredProver` | — | | `GkrProver` | — | See [`docs/design.md`](docs/design.md) for details. diff --git a/docs/design.md b/docs/design.md index bcc6f8b..09ef17e 100644 --- a/docs/design.md +++ b/docs/design.md @@ -330,8 +330,9 @@ where ```rust pub struct SumcheckProof { - /// g_j evaluations per round: round_polys[j] has degree + 1 entries. - pub round_polynomials: Vec>, + /// Round polynomial values per round, EvalsInfty wire format. + /// `round_polys[j]` has `d = degree()` entries (see §7a). + pub round_polys: Vec>, /// Verifier challenges r_1, ..., r_v. pub challenges: Vec, /// g(r_1, ..., r_v) -- the prover's claimed final evaluation. @@ -339,9 +340,44 @@ pub struct SumcheckProof { } ``` -This matches the protocol description exactly. The verifier can -reconstruct any round's consistency check from `round_polynomials` -and `challenges`. +The verifier can reconstruct any round's consistency check from +`round_polys` and `challenges`. + +### 7a. Wire format: EvalsInfty + +All provers emit round polynomials in **EvalsInfty** form — `d` values +per round for a degree-`d` polynomial, one fewer than a full +evaluation table: + +| d | Wire | Verifier recovers | +|---|------|-------------------| +| 1 | `[h(0)]` | `h(1) = claim − h(0)` | +| ≥ 2 | `[h(0), h(∞), h(2), ..., h(d−1)]` | `h(1) = claim − h(0)` | + +`h(∞)` is the leading coefficient (coefficient of `x^d`). The +verifier reconstructs `h(r)` by Lagrange-interpolating the +degree-`(d−1)` residual `q(x) = h(x) − h(∞)·x^d` over the finite +points `{0, 1, ..., d−1}` and then adding `h(∞)·r^d`. + +**Why this shape.** + +1. *Cheapest for product-structured summands.* The leading + coefficient is a difference-of-shifted-evaluations form + (see [BDDT25](https://eprint.iacr.org/2025/1117.pdf), Algorithm 3), + which is the minimal additional work beyond computing `h(0)`. +2. *Consistency is structural.* The check `h(0) + h(1) = claim` is + enforced by the wire format rather than a runtime equality test. + The consequence is that a dishonest prover's misbehaviour no + longer surfaces as a mid-protocol `ConsistencyCheck` error — it + surfaces at the caller's oracle check, where `final_claim` + diverges from `proof.final_value`. Soundness is preserved; the + detection point moves by one step. +3. *One byte saved per round* (compared to sending `d + 1` + evaluations), worth noting for transcript-size-sensitive callers. + +The runner and the verifier are both written once against this +format; every concrete prover (multilinear, inner-product, GKR, +eq-factored, coefficient of any degree, MSB or LSB) produces it. ## 8. The verifier @@ -351,8 +387,11 @@ polynomial's degree: ```rust /// Verify a sum-check proof against a claimed sum. /// -/// Checks per round: g_j(0) + g_j(1) = previous claim, and -/// deg(g_j) <= expected_degree. +/// Per round (EvalsInfty wire — see §7a): receives `d` values, +/// derives `h_j(1) = claim − h_j(0)` from the consistency constraint, +/// reconstructs the round polynomial from the received finite-point +/// evaluations plus the leading coefficient, and evaluates at the +/// challenge `r_j` to obtain the next round's claim. /// /// Returns the final claimed value and the challenge vector on success. /// The caller is responsible for the oracle check: verifying that @@ -539,7 +578,7 @@ impl SumcheckInstanceProver for Adapter

{ performance. The adapter converts via `Into` at the boundary. - **Return type**: Jolt expects `UniPoly` (coefficients). Our trait - returns evaluations at {0, 1, ..., d}. Convert via interpolation or + returns the EvalsInfty wire (§7a). Convert via reconstruction or have the prover return coefficients directly. - **Batching**: Jolt's `BatchedSumcheck::prove` combines multiple instances @@ -587,9 +626,9 @@ and use Toom-Cook to reduce the number of ss multiplications from **Trait compatibility**: this is entirely internal to `round()`. The prover maintains richer internal state (categorized accumulator tables) and uses -cheaper multiplication routines, but returns the same `Vec` of degree+1 -evaluations. A `SmallValueProver` implements `SumcheckProver` with -unchanged `degree()` and `final_value()`. +cheaper multiplication routines, but returns the same EvalsInfty wire (§7a). +A `SmallValueProver` implements `SumcheckProver` with unchanged +`degree()` and `final_value()`. ### Algorithms 5--6: EqPoly optimization diff --git a/docs/slides.md b/docs/slides.md index ea18f33..c9bfaac 100644 --- a/docs/slides.md +++ b/docs/slides.md @@ -159,8 +159,9 @@ pub fn sumcheck_verify>( ) -> Result, SumcheckError> ``` -- Checks g_j(0) + g_j(1) = claim each round -- Evaluates g_j(r_j) via Lagrange interpolation (any degree) +- Receives `d` values per round (EvalsInfty wire format — next slide) +- Consistency `g_j(0) + g_j(1) = claim` is structural, not a runtime check +- Evaluates g_j(r_j) via polynomial reconstruction (any degree) - Returns `SumcheckResult { challenges, final_claim }` The verifier doesn't know g ([Thaler Remark 4.2](https://people.cs.georgetown.edu/jthaler/ProofsArgsAndZK.pdf)), so the oracle check @@ -174,11 +175,34 @@ next_claim = result.final_claim; // WHIR, GKR (next layer) --- +## Wire Format — EvalsInfty + +All provers emit round polynomials in the **EvalsInfty** format: +`d` values per round for a degree-`d` polynomial, one fewer than a +full evaluation table. + +| d | Wire | Recovered by verifier | +|---|------|-----------------------| +| 1 | `[h(0)]` | `h(1) = claim − h(0)` | +| ≥ 2 | `[h(0), h(∞), h(2), …, h(d−1)]` | `h(1) = claim − h(0)` | + +`h(∞)` is the leading coefficient (coefficient of `x^d`). + +- **Cheapest for product-structured summands** — the leading + coefficient is a difference-of-shifted-evaluations form + ([BDDT25](https://eprint.iacr.org/2025/1117.pdf), Algorithm 3). +- **Consistency is structural.** `h(0) + h(1) = claim` is enforced by + the wire format rather than a runtime check — a dishonest prover's + misbehavior surfaces at the caller's oracle check rather than + mid-protocol. + +--- + ## Unified Proof Type ```rust pub struct SumcheckProof { - pub round_polys: Vec>, // g_j at {0,1,...,d} + pub round_polys: Vec>, // g_j in EvalsInfty wire format pub challenges: Vec, // r_1, ..., r_v pub final_value: F, // g(r_1, ..., r_v) } @@ -199,8 +223,10 @@ lives on the prover via `&mut P` ownership, not in the proof. | `InnerProductProver` | 2 | `final_evaluations() -> (F, F)` | | `CoefficientProver` | d | — | | `GkrProver` | 2 | `claimed_w_values() -> (F, F)` | +| `EqFactoredProver` *(available)* | 2 | `final_factors() -> (F, F)` | -Each has MSB and LSB variants (except GkrProver: MSB only). +MSB and LSB variants exist for the first three; `GkrProver` and +`EqFactoredProver` are MSB-only. Same runner. Same verifier. Same proof type. @@ -331,6 +357,69 @@ Reduce-to-one is a separate composable sub-protocol (Thaler §4.5.2). --- +## Eq-Factored Sumcheck *(available)* + +Proves `H = Σ_{x ∈ {0,1}^v} eq(w, x) · p(x)` for fixed `w ∈ F^v` and +multilinear `p`. Degree 2. Shows up in lookup arguments and any +reduction that couples a public point to a witness polynomial via the +multilinear equality predicate. + +```rust +let mut prover = EqFactoredProver::new(w, p_evals); +let proof = sumcheck(&mut prover, v, &mut t, noop_hook); +let (p_r, eq_wr) = prover.final_factors(); +// proof.final_value == p_r · eq_wr +``` + +**Split-Value Optimization ([BDDT25](https://eprint.iacr.org/2025/1117.pdf) Algorithm 5).** +`eq` factors over any split of the variables: + +``` +eq(w, x) = eq(w_L, x_L) · eq(w_R, x_R) +``` + +`eq(w, ·)` is stored as two half-tables of `2^{v/2}` entries each +rather than a single `2^v` table, and round-polynomial contributions +are streamed through a nested-sum kernel without materializing the +product. + +- **Eq storage:** `O(2^v)` → `O(2^{v/2})` +- First `v_L` rounds fold `eq_L`; remaining `v_R` rounds fold `eq_R`. +- After the last round, `eq(w, r) = eq_L[0] · eq_R[0]`. + +--- + +## BatchedEqFactoredProver *(future possibility)* + +WHIR's covector is an RLC of equality polynomials: + +``` +b(x) = Σ_i α_i · eq(ρ_i, x) +``` + +A batched split-value prover would take `Vec<(ρ_i, α_i)>` + witness +`a` instead of a materialized covector, store each `eq(ρ_i, ·)` in +split form, and stream all `k` factors through the nested-sum kernel +per round. + +| | Time | Memory | +|---|------|--------| +| Current WHIR (`InnerProductProver` + materialized `b`) | `O(k · 2^v)` | `O(2^v)` | +| Batched split-value | `O(k · 2^v)` | `O(k · 2^{v/2})` | + +- **Memory win** when `k < 2^{v/2}` — typical for WHIR (`v ~ 20`, `k` + in the dozens). +- **Time essentially unchanged.** You skip the upfront covector + build but pay `k×` more per sumcheck round. Split-value is a + space optimization, not a time optimization — per-round + multiplication count is the same. + +**Status.** Not implemented. Requires coordination with the WHIR crate +to thread `(ρ, α)` pairs through `update_covector` rather than RLC'ing +into a flat table each round. + +--- + ## Two Orthogonal Axes Prover design has two independent choices: diff --git a/docs/slides.pdf b/docs/slides.pdf index a6d161c..6319b6f 100644 Binary files a/docs/slides.pdf and b/docs/slides.pdf differ diff --git a/src/proof.rs b/src/proof.rs index 6f89069..980c530 100644 --- a/src/proof.rs +++ b/src/proof.rs @@ -16,8 +16,14 @@ use core::fmt; /// `final_value == g(r_1, ..., r_v)`) is the caller's responsibility. #[derive(Clone, Debug)] pub struct SumcheckProof { - /// Round polynomial evaluations: `round_polys[j]` contains - /// `g_j(0), g_j(1), ..., g_j(degree)`. + /// Round polynomial values, EvalsInfty wire format: `round_polys[j]` + /// contains `d = degree` values per round. + /// + /// - `d == 1`: `[g_j(0)]`. `g_j(1)` is derived from the consistency + /// check `g_j(0) + g_j(1) = claim`. + /// - `d >= 2`: `[g_j(0), g_j(∞), g_j(2), g_j(3), ..., g_j(d-1)]` where + /// `g_j(∞)` is the leading coefficient (coefficient of `x^d`). + /// `g_j(1)` is derived from the consistency check as above. pub round_polys: Vec>, /// Verifier challenges `r_1, ..., r_v`. diff --git a/src/provers/coefficient.rs b/src/provers/coefficient.rs index b7c46e6..d559678 100644 --- a/src/provers/coefficient.rs +++ b/src/provers/coefficient.rs @@ -261,10 +261,20 @@ where } let coeffs = self.evaluate_coefficients(); - - let mut evals = Vec::with_capacity(self.deg + 1); - for i in 0..=self.deg { - evals.push(eval_poly_at(&coeffs, F::from(i as u64))); + let d = self.deg; + + // EvalsInfty wire format: + // d == 0: [h(0)] + // d == 1: [h(0)] (h(∞) derived from claim) + // d >= 2: [h(0), h(∞), h(2), h(3), ..., h(d-1)] + if d <= 1 { + return vec![eval_poly_at(&coeffs, F::ZERO)]; + } + let mut evals = Vec::with_capacity(d); + evals.push(eval_poly_at(&coeffs, F::ZERO)); // h(0) = coeffs[0] + evals.push(coeffs[d]); // h(∞) = leading coefficient + for i in 2..d { + evals.push(eval_poly_at(&coeffs, F::from(i as u64))); // h(i) } evals } @@ -340,15 +350,13 @@ mod tests { let mut t = SanityTranscript::new(&mut trng); let proof = sumcheck(&mut prover, 4, &mut t, |_, _| {}); - assert_eq!( - proof.round_polys[0][0] + proof.round_polys[0][1], - claimed_sum - ); - + // EvalsInfty: degree-1 round poly → wire is [h(0)]; h(1) = claim - h(0). let mut claim = claimed_sum; for (rp, &r) in proof.round_polys.iter().zip(&proof.challenges) { - assert_eq!(rp[0] + rp[1], claim); - claim = rp[0] + r * (rp[1] - rp[0]); + assert_eq!(rp.len(), 1, "EvalsInfty degree-1 wire length"); + let h0 = rp[0]; + let h1 = claim - h0; + claim = h0 + r * (h1 - h0); } assert_eq!(proof.final_value, claim); } @@ -378,15 +386,16 @@ mod tests { // Same challenges (same transcript seed). assert_eq!(ml_proof.challenges, cp_proof.challenges); - // Same round polynomial evaluations. + // Same wire (EvalsInfty): both provers emit [h(0)] per round. for (i, (ml_rp, cp_rp)) in ml_proof .round_polys .iter() .zip(&cp_proof.round_polys) .enumerate() { - assert_eq!(ml_rp[0], cp_rp[0], "round {i}: q(0) mismatch"); - assert_eq!(ml_rp[1], cp_rp[1], "round {i}: q(1) mismatch"); + assert_eq!(ml_rp.len(), 1, "round {i}: EvalsInfty degree-1 wire length"); + assert_eq!(cp_rp.len(), 1, "round {i}: EvalsInfty degree-1 wire length"); + assert_eq!(ml_rp[0], cp_rp[0], "round {i}: h(0) mismatch"); } } } diff --git a/src/provers/coefficient_lsb.rs b/src/provers/coefficient_lsb.rs index 13b7a55..9713ec7 100644 --- a/src/provers/coefficient_lsb.rs +++ b/src/provers/coefficient_lsb.rs @@ -171,11 +171,19 @@ where // Compute coefficient representation. let coeffs = self.evaluate_coefficients(); + let d = self.deg; - // Convert coefficients → evaluations at {0, 1, ..., degree}. - let mut evals = Vec::with_capacity(self.deg + 1); - for i in 0..=self.deg { - evals.push(eval_poly_at(&coeffs, F::from(i as u64))); + // EvalsInfty wire format: + // d <= 1: [h(0)] + // d >= 2: [h(0), h(∞), h(2), h(3), ..., h(d-1)] + if d <= 1 { + return vec![eval_poly_at(&coeffs, F::ZERO)]; + } + let mut evals = Vec::with_capacity(d); + evals.push(eval_poly_at(&coeffs, F::ZERO)); // h(0) = coeffs[0] + evals.push(coeffs[d]); // h(∞) = leading coefficient + for i in 2..d { + evals.push(eval_poly_at(&coeffs, F::from(i as u64))); // h(i) } evals } @@ -185,38 +193,31 @@ where } fn final_value(&self) -> F { - // After all rounds, each pairwise table should have 1 element, - // each tablewise table should have 1 row. The final value is - // the evaluator applied to these singletons. - let mut coeffs = vec![F::ZERO; self.deg + 1]; - let n_pairs = self.n_pairs(); - if n_pairs > 0 { - sequential_evaluate_into( - self.evaluator, - &self.tablewise, - &self.pairwise, - self.n_tw, - self.n_pw, - n_pairs, - &mut coeffs, - ); - } - // final_value = h(0) + h(1) = sum of evaluations at 0 and 1 - // Actually: the "final value" for coefficient sumcheck is the - // claimed sum at the last point, which is eval of the polynomial - // at the last challenge. But after finalize(), the tables are - // fully reduced — there's only 1 "pair" left (of size 1). - // The claim is h(0) + h(1) from the last round's perspective, - // but that's the *next* claim, not the evaluation. - // - // For consistency with the other provers: final_value should be - // the polynomial evaluated at the random point. After full - // reduction, the single remaining element in pairwise[0] IS - // the evaluation (for degree-1 single-pairwise case). + // Degree-1 single-pairwise fast path: the singleton *is* the evaluation. if self.is_degree1_simd_path && !self.pairwise.is_empty() && self.pairwise[0].len() == 1 { return self.pairwise[0][0]; } - // General case: sum the contributions. + + // General case: after full reduction each pairwise table holds one + // element and each tablewise table holds one row. Feed these + // singletons to the evaluator as `(singleton, F::ZERO)` pairs and + // return `h(0) + h(1)`. For product-shaped evaluators this evaluates + // to `Π_i singleton_i`, matching the MSB variant. + let mut coeffs = vec![F::ZERO; self.deg + 1]; + let mut tw_buf: [(&[F], &[F]); 16] = [(&[], &[]); 16]; + let mut pw_buf: [(F, F); 16] = [(F::ZERO, F::ZERO); 16]; + for (i, table) in self.tablewise.iter().enumerate() { + if table.len() == 1 { + tw_buf[i] = (&table[0], &[]); + } + } + for (i, table) in self.pairwise.iter().enumerate() { + if table.len() == 1 { + pw_buf[i] = (table[0], F::ZERO); + } + } + self.evaluator + .accumulate_pair(&mut coeffs, &tw_buf[..self.n_tw], &pw_buf[..self.n_pw]); eval_poly_at(&coeffs, F::ZERO) + eval_poly_at(&coeffs, F::ONE) } } @@ -420,18 +421,13 @@ mod tests { let mut t = SanityTranscript::new(&mut trng); let proof = sumcheck(&mut prover, 4, &mut t, |_, _| {}); - // Round-0 consistency. - assert_eq!( - proof.round_polys[0][0] + proof.round_polys[0][1], - claimed_sum - ); - - // All-round consistency via Lagrange. + // EvalsInfty: degree-1 → wire is [h(0)]; derive h(1) = claim - h(0). let mut claim = claimed_sum; for (rp, &r) in proof.round_polys.iter().zip(&proof.challenges) { - assert_eq!(rp[0] + rp[1], claim, "consistency check failed"); - // degree 1: q(r) = q(0) + r*(q(1) - q(0)) - claim = rp[0] + r * (rp[1] - rp[0]); + assert_eq!(rp.len(), 1, "EvalsInfty degree-1 wire length"); + let h0 = rp[0]; + let h1 = claim - h0; + claim = h0 + r * (h1 - h0); } assert_eq!(proof.final_value, claim); } @@ -466,9 +462,8 @@ mod tests { // Same challenges (same transcript seed). assert_eq!(old_result.verifier_messages, new_result.challenges); - // Round polynomials: old is coefficients, new is evaluations. - // Verify consistency: old coeffs evaluated at {0,1} should match - // new evals[0] and evals[1]. + // Round polynomials: old is coefficients, new is EvalsInfty [h(0)] + // for degree 1. Check h(0) agrees. for (i, (old_poly, new_evals)) in old_result .prover_messages .iter() @@ -476,10 +471,13 @@ mod tests { .enumerate() { use ark_poly::Polynomial; + assert_eq!( + new_evals.len(), + 1, + "round {i}: EvalsInfty degree-1 wire length" + ); let old_at_0 = old_poly.evaluate(&F64::from(0u64)); - let old_at_1 = old_poly.evaluate(&F64::from(1u64)); - assert_eq!(old_at_0, new_evals[0], "round {i}: q(0) mismatch"); - assert_eq!(old_at_1, new_evals[1], "round {i}: q(1) mismatch"); + assert_eq!(old_at_0, new_evals[0], "round {i}: h(0) mismatch"); } } @@ -497,12 +495,12 @@ mod tests { let mut t = SanityTranscript::new(&mut trng); let proof = sumcheck(&mut prover, 3, &mut t, |_, _| {}); + // EvalsInfty: degree-2 wire is [q(0), q(∞)] — 2 values per round. for rp in &proof.round_polys { - assert_eq!(rp.len(), 3, "degree-2 should have 3 evaluations"); + assert_eq!(rp.len(), 2, "degree-2 EvalsInfty wire length"); } - assert_eq!( - proof.round_polys[0][0] + proof.round_polys[0][1], - claimed_sum - ); + // Consistency: q(0) + q(1) = claim, with q(1) = claim - q(0) by construction. + let q1 = claimed_sum - proof.round_polys[0][0]; + assert_eq!(proof.round_polys[0][0] + q1, claimed_sum); } } diff --git a/src/provers/eq_factored.rs b/src/provers/eq_factored.rs new file mode 100644 index 0000000..46dea52 --- /dev/null +++ b/src/provers/eq_factored.rs @@ -0,0 +1,450 @@ +//! Eq-factored sumcheck prover: `g(x) = eq(w, x) · p(x)`, degree 2. +//! +//! Implements [`SumcheckProver`] for the common eq-factored sumcheck +//! +//! ```text +//! ∑_{x ∈ {0,1}^v} eq(w, x) · p(x) = H +//! ``` +//! +//! where `w` is a point in `F^v` fixed at setup and `p` is a multilinear +//! polynomial given by its evaluations over the Boolean hypercube. +//! +//! Eq-factored sumchecks appear in lookup arguments and in any reduction +//! that couples a public point `w` to a witness polynomial via the +//! multilinear equality predicate. +//! +//! # Space: Split-Value Optimization (BDDT25 Algorithm 5) +//! +//! The multilinear equality polynomial factors as a tensor product over +//! any split of the variables: +//! +//! ```text +//! eq(w, x) = eq(w_L, x_L) · eq(w_R, x_R), w = (w_L, w_R), x = (x_L, x_R) +//! ``` +//! +//! This prover splits `w` down the middle (`v_L = ⌊v/2⌋`, `v_R = v - v_L`) +//! and stores only the two half-tables `eq(w_L, ·)` and `eq(w_R, ·)` of +//! `2^{v_L}` and `2^{v_R}` entries respectively — `O(2^{v/2})` eq storage +//! instead of `O(2^v)`. Round-polynomial contributions are streamed +//! directly from the two half-tables without ever materializing their +//! product. +//! +//! During sumcheck (MSB layout), the first `v_L` rounds bind left-half +//! variables and fold `eq_L`; the remaining `v_R` rounds bind right-half +//! variables and fold `eq_R`. After the last round, +//! `eq(w, r) = eq_L[0] · eq_R[0]`. +//! +//! # Wire format +//! +//! EvalsInfty for degree 2: `[q(0), q(∞)]`. The verifier derives +//! `q(1) = claim - q(0)` and reconstructs the round polynomial. + +use crate::field::SumcheckField; +use crate::inner_product_sumcheck as ip; +use crate::sumcheck_prover::SumcheckProver; + +extern crate alloc; +use alloc::vec; +use alloc::vec::Vec; + +/// Eq-factored sumcheck prover for `∑_x eq(w, x) · p(x)` (degree 2). +/// +/// Uses the Split-Value Optimization (BDDT25 Algorithm 5): `eq` is stored +/// as two half-tables `eq(w_L, ·)` and `eq(w_R, ·)` of `2^{v/2}` entries +/// each, rather than a single `2^v` table. Round polynomials are +/// assembled on the fly by a nested-sum kernel that reads the two +/// half-tables — see the module docs for the math. +/// +/// # Construction +/// +/// ```ignore +/// use effsc::provers::eq_factored::EqFactoredProver; +/// use effsc::runner::sumcheck; +/// +/// let mut prover = EqFactoredProver::new(w, p_evals); +/// let proof = sumcheck(&mut prover, num_vars, &mut transcript, noop_hook); +/// // final_value() = eq(w, r) · p(r) +/// let (p_r, eq_wr) = prover.final_factors(); +/// ``` +pub struct EqFactoredProver { + /// `p` evaluations (MSB layout), padded to `2^v` on construction. + /// Folded in every round. + p: Vec, + /// `eq(w_L, ·)` over `{0,1}^{v_L}` (MSB layout). Folded during + /// left-half rounds; becomes a scalar after round `v_L - 1`. + eq_l: Vec, + /// `eq(w_R, ·)` over `{0,1}^{v_R}` (MSB layout). Unchanged during + /// left-half rounds; folded during right-half rounds. + eq_r: Vec, + /// Number of left-half variables `v_L`. `v_R = v - v_L = v - (v/2)`. + v_l: usize, + /// Total number of variables. + v: usize, + /// Number of completed [`round`](SumcheckProver::round) calls. + rounds_elapsed: usize, +} + +impl EqFactoredProver { + /// Construct a prover for `∑_x eq(w, x) · p(x)`. + /// + /// `p_evals.len()` must be `≤ 2^{w.len()}`; shorter inputs are + /// zero-padded to `2^{w.len()}`. + pub fn new(w: Vec, p_evals: Vec) -> Self { + let v = w.len(); + let n = 1usize << v; + assert!( + p_evals.len() <= n, + "p_evals length {} exceeds 2^{} = {}", + p_evals.len(), + v, + n + ); + let v_l = v / 2; + let (w_l, w_r) = w.split_at(v_l); + let eq_l = build_eq_table(w_l); + let eq_r = build_eq_table(w_r); + let mut p = p_evals; + p.resize(n, F::ZERO); + Self { + p, + eq_l, + eq_r, + v_l, + v, + rounds_elapsed: 0, + } + } + + /// After full sumcheck: `(p(r), eq(w, r))`. + pub fn final_factors(&self) -> (F, F) { + if self.p.len() == 1 { + (self.p[0], self.eq_l[0] * self.eq_r[0]) + } else { + (F::ZERO, F::ZERO) + } + } + + /// Round polynomial during the left-half phase (`rounds_elapsed < v_L`). + /// + /// Let `j = rounds_elapsed` and split the remaining variables as + /// `(x_j, a, b)` where `a ∈ {0,1}^{v_L − j − 1}` and `b ∈ {0,1}^{v_R}`. + /// Then + /// + /// ```text + /// q(x_j) = Σ_a eq_L(x_j, a) · Σ_b eq_R(b) · p(x_j, a, b). + /// ``` + /// + /// Splitting `eq_L = [eq_L_lo, eq_L_hi]` and `p = [p_lo, p_hi]` by the + /// leading bit `x_j`, each `a` indexes a `2^{v_R}`-sized slice of both + /// halves of `p`. The kernel contracts the `b` dimension against + /// `eq_R` once per `a`, then accumulates the outer sum. + fn round_poly_left(&self) -> Vec { + let eq_l_half = self.eq_l.len() >> 1; + let (eq_l_lo, eq_l_hi) = self.eq_l.split_at(eq_l_half); + + let p_half = self.p.len() >> 1; + let (p_lo, p_hi) = self.p.split_at(p_half); + + let m = self.eq_r.len(); + debug_assert_eq!(eq_l_half * m, p_half); + + let mut q0 = F::ZERO; + let mut q_inf = F::ZERO; + for a in 0..eq_l_half { + let p_lo_slice = &p_lo[a * m..(a + 1) * m]; + let p_hi_slice = &p_hi[a * m..(a + 1) * m]; + + let mut inner_0 = F::ZERO; + let mut inner_delta = F::ZERO; + for b in 0..m { + let er = self.eq_r[b]; + let pl = p_lo_slice[b]; + let ph = p_hi_slice[b]; + inner_0 += er * pl; + inner_delta += er * (ph - pl); + } + + let el_lo = eq_l_lo[a]; + let el_hi = eq_l_hi[a]; + q0 += el_lo * inner_0; + q_inf += (el_hi - el_lo) * inner_delta; + } + + vec![q0, q_inf] + } + + /// Round polynomial during the right-half phase + /// (`rounds_elapsed ≥ v_L`). `eq_L` has already folded to a scalar; + /// the remaining work is a standard inner-product round polynomial on + /// `eq_R` and `p`, scaled by that scalar. + fn round_poly_right(&self) -> Vec { + let n = self.p.len(); + debug_assert_eq!(self.eq_l.len(), 1); + debug_assert_eq!(self.eq_r.len(), n); + + let scalar = self.eq_l[0]; + if n <= 1 { + let v = if n == 1 { + scalar * self.eq_r[0] * self.p[0] + } else { + F::ZERO + }; + return vec![v, F::ZERO]; + } + + let half = n >> 1; + let (eq_r_lo, eq_r_hi) = self.eq_r.split_at(half); + let (p_lo, p_hi) = self.p.split_at(half); + + let mut q0 = F::ZERO; + let mut q_inf = F::ZERO; + for i in 0..half { + let el = eq_r_lo[i]; + let eh = eq_r_hi[i]; + let pl = p_lo[i]; + let ph = p_hi[i]; + q0 += el * pl; + q_inf += (eh - el) * (ph - pl); + } + + vec![q0 * scalar, q_inf * scalar] + } +} + +/// Build the multilinear-extension table of `eq(w, ·)` over `{0,1}^v` in +/// MSB layout: `table[idx]` is indexed bit-by-bit MSB-first. +/// +/// Runs in `O(2^v)` time with `O(2^v)` space. With `w.len() = v/2` this +/// produces one of the split-value half-tables. +pub(crate) fn build_eq_table(w: &[F]) -> Vec { + let v = w.len(); + if v == 0 { + return vec![F::ONE]; + } + let size = 1usize << v; + let mut table = vec![F::ZERO; size]; + table[0] = F::ONE; + // Process variables from most-significant to least-significant so that + // the final layout has w[0] as the MSB (topmost bit of the index). + for (j, &wj) in w.iter().enumerate() { + let stride = 1usize << (v - 1 - j); + let block = 2 * stride; + let populated_blocks = 1usize << j; + for b in 0..populated_blocks { + let base = b * block; + let parent = table[base]; + table[base] = parent * (F::ONE - wj); + table[base + stride] = parent * wj; + } + } + table +} + +#[cfg(feature = "arkworks")] +impl SumcheckProver for EqFactoredProver +where + F: ark_ff::Field, +{ + fn degree(&self) -> usize { + 2 + } + + fn round(&mut self, challenge: Option) -> Vec { + // Apply the previous round's challenge to the correct half of eq + // (plus p, which folds every round). + if let Some(r) = challenge { + let prev_var = self.rounds_elapsed - 1; + ip::fold(&mut self.p, r); + if prev_var < self.v_l { + ip::fold(&mut self.eq_l, r); + } else { + ip::fold(&mut self.eq_r, r); + } + } + + let j = self.rounds_elapsed; + let round_poly = if j < self.v_l { + self.round_poly_left() + } else { + self.round_poly_right() + }; + self.rounds_elapsed += 1; + round_poly + } + + fn finalize(&mut self, last_challenge: F) { + let last_var = self.v - 1; + ip::fold(&mut self.p, last_challenge); + if last_var < self.v_l { + ip::fold(&mut self.eq_l, last_challenge); + } else { + ip::fold(&mut self.eq_r, last_challenge); + } + } + + fn final_value(&self) -> F { + if self.p.len() == 1 { + self.p[0] * self.eq_l[0] * self.eq_r[0] + } else { + F::ZERO + } + } +} + +#[cfg(all(test, feature = "arkworks"))] +mod tests { + use super::*; + use crate::runner::sumcheck; + use crate::tests::F64; + use crate::transcript::SanityTranscript; + use ark_ff::UniformRand; + use ark_std::rand::{rngs::StdRng, SeedableRng}; + + /// Evaluate multilinear `eq(w, x)` at Boolean `x` (as bit pattern). + fn eq_at_boolean(w: &[F64], x_bits: usize) -> F64 { + let mut acc = F64::from(1u64); + let v = w.len(); + for j in 0..v { + // MSB-first indexing: bit (v-1-j) of x_bits corresponds to w[j]. + let xj = (x_bits >> (v - 1 - j)) & 1; + acc *= if xj == 1 { + w[j] + } else { + F64::from(1u64) - w[j] + }; + } + acc + } + + #[test] + fn build_eq_table_matches_brute_force() { + let mut rng = StdRng::seed_from_u64(0xE01); + let v = 4; + let w: Vec = (0..v).map(|_| F64::rand(&mut rng)).collect(); + let table = build_eq_table(&w); + for idx in 0..(1 << v) { + assert_eq!( + table[idx], + eq_at_boolean(&w, idx), + "eq(w, x={idx:04b}) mismatch" + ); + } + } + + #[test] + fn eq_table_sums_to_one() { + let mut rng = StdRng::seed_from_u64(0xE02); + let v = 5; + let w: Vec = (0..v).map(|_| F64::rand(&mut rng)).collect(); + let table = build_eq_table(&w); + let s: F64 = table.iter().copied().sum(); + assert_eq!(s, F64::from(1u64), "Σ_x eq(w, x) = 1"); + } + + #[test] + fn eq_factored_prover_completes_and_verifies() { + let mut rng = StdRng::seed_from_u64(0xE03); + let v = 5; + let n = 1usize << v; + let w: Vec = (0..v).map(|_| F64::rand(&mut rng)).collect(); + let p_evals: Vec = (0..n).map(|_| F64::rand(&mut rng)).collect(); + + // Claimed sum: H = Σ_x eq(w, x) · p(x). + let mut claimed_sum = F64::from(0u64); + for x in 0..n { + claimed_sum += eq_at_boolean(&w, x) * p_evals[x]; + } + + let mut prover = EqFactoredProver::new(w.clone(), p_evals.clone()); + let mut trng = StdRng::seed_from_u64(0xBEEF); + let mut t = SanityTranscript::new(&mut trng); + let proof = sumcheck(&mut prover, v, &mut t, |_, _| {}); + + // EvalsInfty: degree-2 wire is [q(0), q(∞)]. + for rp in &proof.round_polys { + assert_eq!(rp.len(), 2, "EvalsInfty degree-2 wire length"); + } + + // Replay reductions: q(r) = q(0) + r·(q(1) − q(0) − q(∞)) + q(∞)·r². + let mut claim = claimed_sum; + for (rp, &r) in proof.round_polys.iter().zip(&proof.challenges) { + let q0 = rp[0]; + let q_inf = rp[1]; + let q1 = claim - q0; + claim = q0 + r * (q1 - q0 - q_inf) + q_inf * r * r; + } + assert_eq!(claim, proof.final_value); + + // Final-factor sanity: final_value = p(r) · eq(w, r). + let (p_r, eq_wr) = prover.final_factors(); + assert_eq!(proof.final_value, p_r * eq_wr); + } + + #[test] + fn eq_factored_matches_inner_product_with_eq_table() { + use crate::provers::inner_product::InnerProductProver; + + let mut rng = StdRng::seed_from_u64(0xE04); + let v = 4; + let n = 1usize << v; + let w: Vec = (0..v).map(|_| F64::rand(&mut rng)).collect(); + let p_evals: Vec = (0..n).map(|_| F64::rand(&mut rng)).collect(); + + // Run EqFactoredProver. + let mut eq_prover = EqFactoredProver::new(w.clone(), p_evals.clone()); + let mut trng1 = StdRng::seed_from_u64(0xCAFE); + let mut t1 = SanityTranscript::new(&mut trng1); + let eq_proof = sumcheck(&mut eq_prover, v, &mut t1, |_, _| {}); + + // Run InnerProductProver with p and explicitly-computed eq(w, ·). + let eq_table = build_eq_table(&w); + let mut ip_prover = InnerProductProver::new(p_evals, eq_table); + let mut trng2 = StdRng::seed_from_u64(0xCAFE); + let mut t2 = SanityTranscript::new(&mut trng2); + let ip_proof = sumcheck(&mut ip_prover, v, &mut t2, |_, _| {}); + + // Same transcript seed → same challenges, same wire, same final value. + assert_eq!(eq_proof.challenges, ip_proof.challenges); + assert_eq!(eq_proof.round_polys, ip_proof.round_polys); + assert_eq!(eq_proof.final_value, ip_proof.final_value); + } + + /// Split-value correctness across a range of `v`, including odd `v` + /// (where `v_L ≠ v_R`) and small edge cases. + #[test] + fn eq_factored_matches_inner_product_various_v() { + use crate::provers::inner_product::InnerProductProver; + + for &v in &[1usize, 2, 3, 4, 5, 6, 7, 8] { + let mut rng = StdRng::seed_from_u64(0xE05 ^ v as u64); + let n = 1usize << v; + let w: Vec = (0..v).map(|_| F64::rand(&mut rng)).collect(); + let p_evals: Vec = (0..n).map(|_| F64::rand(&mut rng)).collect(); + + let mut eq_prover = EqFactoredProver::new(w.clone(), p_evals.clone()); + let mut trng1 = StdRng::seed_from_u64(0xC0FFEE ^ v as u64); + let mut t1 = SanityTranscript::new(&mut trng1); + let eq_proof = sumcheck(&mut eq_prover, v, &mut t1, |_, _| {}); + + let eq_table = build_eq_table(&w); + let mut ip_prover = InnerProductProver::new(p_evals, eq_table); + let mut trng2 = StdRng::seed_from_u64(0xC0FFEE ^ v as u64); + let mut t2 = SanityTranscript::new(&mut trng2); + let ip_proof = sumcheck(&mut ip_prover, v, &mut t2, |_, _| {}); + + assert_eq!(eq_proof.challenges, ip_proof.challenges, "v={v}"); + assert_eq!(eq_proof.round_polys, ip_proof.round_polys, "v={v}"); + assert_eq!(eq_proof.final_value, ip_proof.final_value, "v={v}"); + + // Eq storage is sub-linear: eq_l + eq_r ≤ 2·2^⌈v/2⌉. + let (_p_r, eq_wr) = eq_prover.final_factors(); + // Sanity: eq(w, r) as a scalar equals the product of the two + // half-table scalars. + let mut expected_eq_wr = F64::from(1u64); + for (j, &rj) in eq_proof.challenges.iter().enumerate() { + expected_eq_wr *= rj * w[j] + (F64::from(1u64) - rj) * (F64::from(1u64) - w[j]); + } + assert_eq!(eq_wr, expected_eq_wr, "v={v}: eq(w,r) mismatch"); + } + } +} diff --git a/src/provers/gkr.rs b/src/provers/gkr.rs index 63f95c4..31e9ba5 100644 --- a/src/provers/gkr.rs +++ b/src/provers/gkr.rs @@ -114,6 +114,11 @@ where ip::fold(&mut self.w_c, w); } + // EvalsInfty wire format: emit [q(0), q(∞)] for degree-2 round poly. + // Strategy: compute (q(0), q(1), q(2)) as before, then convert via + // q(∞) = (q(0) + q(2) − 2·q(1)) / 2 + // This preserves tail-case semantics identical to the prior + // implementation and avoids per-round algebraic rederivation. let n = self.add_evals.len(); if n <= 1 { let v = if n == 1 { @@ -123,7 +128,7 @@ where } else { F::ZERO }; - return vec![v, F::ZERO, F::ZERO]; + return vec![v, F::ZERO]; } let half = n.next_power_of_two() >> 1; @@ -148,13 +153,9 @@ where let wcl = wc_lo[i]; let wch = wc_hi[i]; - // q(0): all factors at t=0 (low half) q0 += al * (wbl + wcl) + ml * (wbl * wcl); - - // q(1): all factors at t=1 (high half) q1 += ah * (wbh + wch) + mh * (wbh * wch); - // q(2): linear extension to t=2: val_2 = 2*hi - lo let a2 = ah + ah - al; let m2 = mh + mh - ml; let wb2 = wbh + wbh - wbl; @@ -162,9 +163,7 @@ where q2 += a2 * (wb2 + wc2) + m2 * (wb2 * wc2); } - // Tail: hi is implicitly zero, so at t=2 each factor is -lo. - // add term: (-al)*(-wbl + -wcl) = al*(wbl + wcl) [even number of negations] - // mult term: (-ml)*(-wbl)*(-wcl) = -ml*wbl*wcl [odd number of negations] + // Tail: hi is implicitly zero. for i in paired..half.min(n) { let al = add_lo[i]; let ml = mult_lo[i]; @@ -176,7 +175,14 @@ where q2 += al * (wbl + wcl) - ml * (wbl * wcl); } - vec![q0, q1, q2] + // Convert {q(0), q(1), q(2)} → {q(0), q(∞)}. + // q(∞) = (q(0) + q(2) − 2·q(1)) / 2 + let two_inv = F::from(2u64) + .inverse() + .expect("field characteristic must not be 2"); + let q_inf = (q0 + q2 - q1.double()) * two_inv; + + vec![q0, q_inf] } fn finalize(&mut self, last_challenge: F) { @@ -200,7 +206,6 @@ where #[cfg(test)] mod tests { use super::*; - use crate::polynomial::eval_from_evals; use crate::runner::sumcheck; use crate::tests::F64; use crate::transcript::SanityTranscript; @@ -235,15 +240,20 @@ mod tests { let mut transcript = SanityTranscript::new(&mut trng); let proof = sumcheck(&mut prover, num_rounds, &mut transcript, |_, _| {}); - // Verify: q(0) + q(1) = claim each round, update via Lagrange. + // EvalsInfty: wire is [q(0), q(∞)]. Derive q(1) = claim - q(0); + // reconstruct q(r) = q(0) + r·(q(1) - q(0) - q(∞)) + q(∞)·r². let mut claim = expected_sum; for (round, evals) in proof.round_polys.iter().enumerate() { assert_eq!( - evals[0] + evals[1], - claim, - "k={k}: round {round}: q(0) + q(1) != claim" + evals.len(), + 2, + "k={k}: round {round}: EvalsInfty degree-2 wire length" ); - claim = eval_from_evals(evals, proof.challenges[round]); + let q0 = evals[0]; + let q_inf = evals[1]; + let q1 = claim - q0; + let r = proof.challenges[round]; + claim = q0 + r * (q1 - q0 - q_inf) + q_inf * r * r; } assert_eq!(claim, proof.final_value, "k={k}: final claim mismatch"); @@ -344,9 +354,9 @@ mod tests { let mut transcript = SanityTranscript::new(&mut trng); let proof = sumcheck(&mut prover, 2 * k, &mut transcript, |_, _| {}); - assert_eq!( - proof.round_polys[0][0] + proof.round_polys[0][1], - expected_sum, - ); + // EvalsInfty: wire is [q(0), q(∞)]; derive q(1) = claim - q(0). + let q0 = proof.round_polys[0][0]; + let q1 = expected_sum - q0; + assert_eq!(q0 + q1, expected_sum); } } diff --git a/src/provers/inner_product.rs b/src/provers/inner_product.rs index 2c62ba3..aaef996 100644 --- a/src/provers/inner_product.rs +++ b/src/provers/inner_product.rs @@ -11,8 +11,9 @@ use crate::sumcheck_prover::SumcheckProver; /// Computes `∑_x f(x)·g(x)` where `f` and `g` are multilinear polynomials /// specified by their evaluations over the Boolean hypercube. /// -/// Wire format: evaluations `[q(0), q(1), q(2)]` where `q` is the degree-2 -/// round polynomial. +/// Wire format (EvalsInfty): `[q(0), q(∞)]` where `q` is the degree-2 +/// round polynomial and `q(∞) = Σ (a_hi − a_lo)·(b_hi − b_lo)` is the +/// leading coefficient. Verifier derives `q(1) = claim - q(0)`. /// /// # Construction /// @@ -67,15 +68,11 @@ where ip::fold(&mut self.b, w); } - // Compute round polynomial evaluations at {0, 1, 2}. - // - // The round polynomial q(X) = Σ_{x'} f(r, X, x') · g(r, X, x'). - // With MSB half-split: - // f(r, X, x') = (1−X)·a_lo[x'] + X·a_hi[x'] - // - // q(0) = dot(a_lo, b_lo) - // q(1) = dot(a_hi, b_hi) - // q(2) = dot(2·a_hi − a_lo, 2·b_hi − b_lo) + // EvalsInfty: emit [q(0), q(∞)] where + // q(0) = dot(a_lo, b_lo) + // q(∞) = [x²] q(x) = dot(a_hi − a_lo, b_hi − b_lo) + // = Σ (ah − al)·(bh − bl) + // The verifier derives q(1) = claim - q(0). let n = self.a.len(); if n <= 1 { let v = if n == 1 { @@ -83,7 +80,7 @@ where } else { F::ZERO }; - return vec![v, F::ZERO, F::ZERO]; + return vec![v, F::ZERO]; } let half = n.next_power_of_two() >> 1; @@ -98,28 +95,24 @@ where let b_lo_tail = &b_lo[paired..]; let mut q0 = F::ZERO; - let mut q1 = F::ZERO; - let mut q2 = F::ZERO; + let mut q_inf = F::ZERO; for i in 0..paired { let al = a_lo_paired[i]; let ah = a_hi[i]; let bl = b_lo_paired[i]; let bh = b_hi[i]; q0 += al * bl; - q1 += ah * bh; - // f(2) = 2·ah − al, g(2) = 2·bh − bl - let a2 = ah + ah - al; - let b2 = bh + bh - bl; - q2 += a2 * b2; + q_inf += (ah - al) * (bh - bl); } - // Tail (hi is implicitly zero): contributes to q0 only. - // q(0) += dot(tail_a, tail_b), q(1) += 0, q(2) += dot(-tail_a, -tail_b) = dot(tail_a, tail_b). + // Tail (hi implicitly zero): ah = bh = 0, so + // q(0) += al·bl + // q(∞) += (0 − al)·(0 − bl) = al·bl let tail_dot: F = a_lo_tail.iter().zip(b_lo_tail).map(|(&a, &b)| a * b).sum(); q0 += tail_dot; - q2 += tail_dot; + q_inf += tail_dot; - vec![q0, q1, q2] + vec![q0, q_inf] } fn finalize(&mut self, last_challenge: F) { @@ -175,7 +168,8 @@ mod tests { let mut t_new = SanityTranscript::new(&mut trng2); let new_result = sumcheck(&mut prover, num_rounds, &mut t_new, |_, _| {}); - // Compare round-by-round consistency. + // Both APIs now emit the same wire format: (c0, c2) in difference + // form (= EvalsInfty for degree 2 where c2 is the x² coefficient). assert_eq!( old_result.prover_messages.len(), new_result.round_polys.len() @@ -187,20 +181,13 @@ mod tests { .enumerate() { let (c0, c2) = *old_msg; - // Old API: c0 = q(0), and q(0) + q(1) = claim. - // New API: [q(0), q(1), q(2)]. - assert_eq!(c0, new_evals[0], "round {i}: q(0) mismatch"); - // c2 from old = x² coefficient. Verify via: - // q(X) = c0 + c1·X + c2·X² - // q(2) = c0 + 2·c1 + 4·c2 - // c1 = q(1) - c0 - c2 - let q1 = new_evals[1]; - let c1_derived = q1 - c0 - c2; - let q2_expected = c0 + c1_derived.double() + c2.double().double(); assert_eq!( - q2_expected, new_evals[2], - "round {i}: q(2) inconsistent with (c0, c2)" + new_evals.len(), + 2, + "round {i}: EvalsInfty degree-2 wire length" ); + assert_eq!(c0, new_evals[0], "round {i}: q(0) mismatch"); + assert_eq!(c2, new_evals[1], "round {i}: q(∞) mismatch"); } // Compare challenges (should be identical since same transcript seed). diff --git a/src/provers/inner_product_lsb.rs b/src/provers/inner_product_lsb.rs index 8121c4f..14267bf 100644 --- a/src/provers/inner_product_lsb.rs +++ b/src/provers/inner_product_lsb.rs @@ -50,36 +50,30 @@ impl InnerProductProverLSB { // ─── LSB fold and compute ────────────────────────────────────────────────── -/// Compute round polynomial evaluations at {0, 1, 2} from LSB pair-split layout. +/// Compute EvalsInfty round polynomial `(q(0), q(∞))` from LSB pair-split layout. /// -/// q(0) = sum a[2k] * b[2k] -/// q(1) = sum a[2k+1] * b[2k+1] -/// q(2) = sum (2*a[2k+1] - a[2k]) * (2*b[2k+1] - b[2k]) -fn compute_lsb(a: &[F], b: &[F]) -> (F, F, F) { +/// q(0) = sum a[2k] * b[2k] +/// q(∞) = [x²] q(x) = sum (a[2k+1] - a[2k]) * (b[2k+1] - b[2k]) +fn compute_lsb(a: &[F], b: &[F]) -> (F, F) { debug_assert_eq!(a.len(), b.len()); if a.is_empty() { - return (F::ZERO, F::ZERO, F::ZERO); + return (F::ZERO, F::ZERO); } if a.len() == 1 { - return (a[0] * b[0], F::ZERO, F::ZERO); + return (a[0] * b[0], F::ZERO); } let mut q0 = F::ZERO; - let mut q1 = F::ZERO; - let mut q2 = F::ZERO; + let mut q_inf = F::ZERO; for i in (0..a.len()).step_by(2) { let a_even = a[i]; let a_odd = a[i + 1]; let b_even = b[i]; let b_odd = b[i + 1]; q0 += a_even * b_even; - q1 += a_odd * b_odd; - // f(2) = 2*a_odd - a_even, g(2) = 2*b_odd - b_even - let a2 = a_odd + a_odd - a_even; - let b2 = b_odd + b_odd - b_even; - q2 += a2 * b2; + q_inf += (a_odd - a_even) * (b_odd - b_even); } - (q0, q1, q2) + (q0, q_inf) } /// In-place LSB fold: `new[k] = f[2k] + w * (f[2k+1] - f[2k])`. @@ -97,12 +91,12 @@ fn fold_lsb(v: &mut Vec, weight: F) { } /// Fused fold + compute: fold both vectors with `weight`, then compute -/// the next round's (q0, q1, q2) in one pass over quads. +/// the next round's EvalsInfty `(q(0), q(∞))` in one pass over quads. fn fused_fold_and_compute_lsb( a: &mut Vec, b: &mut Vec, weight: F, -) -> (F, F, F) { +) -> (F, F) { let n = a.len(); debug_assert_eq!(n, b.len()); if n < 4 { @@ -113,8 +107,7 @@ fn fused_fold_and_compute_lsb( let new_len = n / 2; let mut q0 = F::ZERO; - let mut q1 = F::ZERO; - let mut q2 = F::ZERO; + let mut q_inf = F::ZERO; // Process quads: indices (4k, 4k+1, 4k+2, 4k+3) // Fold produces: new_a[2k] = a[4k] + w*(a[4k+1] - a[4k]) @@ -134,10 +127,7 @@ fn fused_fold_and_compute_lsb( b[2 * q + 1] = nb_odd; q0 += na_even * nb_even; - q1 += na_odd * nb_odd; - let a2 = na_odd + na_odd - na_even; - let b2 = nb_odd + nb_odd - nb_even; - q2 += a2 * b2; + q_inf += (na_odd - na_even) * (nb_odd - nb_even); } // Handle remainder if new_len is odd. @@ -152,7 +142,7 @@ fn fused_fold_and_compute_lsb( a.truncate(new_len); b.truncate(new_len); - (q0, q1, q2) + (q0, q_inf) } // ─── SumcheckProver impl ─────────────────────────────────────────────────── @@ -167,12 +157,13 @@ where } fn round(&mut self, challenge: Option) -> Vec { - let (q0, q1, q2) = if let Some(w) = challenge { + // EvalsInfty: degree 2 → emit [q(0), q(∞)]. + let (q0, q_inf) = if let Some(w) = challenge { fused_fold_and_compute_lsb(&mut self.a, &mut self.b, w) } else { compute_lsb(&self.a, &self.b) }; - vec![q0, q1, q2] + vec![q0, q_inf] } fn finalize(&mut self, last_challenge: F) { @@ -213,22 +204,15 @@ mod tests { let mut t = SanityTranscript::new(&mut trng); let proof = sumcheck(&mut prover, num_vars, &mut t, |_, _| {}); - // Round-0 consistency. - assert_eq!( - proof.round_polys[0][0] + proof.round_polys[0][1], - claimed_sum - ); - - // All-round consistency via Lagrange on {0, 1, 2}. + // EvalsInfty: degree-2 wire is [q(0), q(∞)]. Derive q(1) = claim - q(0), + // then reconstruct q(r) = q(0) + r·(q(1) - q(0) - q(∞)) + q(∞)·r². let mut claim = claimed_sum; for (rp, &r) in proof.round_polys.iter().zip(&proof.challenges) { - assert_eq!(rp.len(), 3); - assert_eq!(rp[0] + rp[1], claim, "consistency check failed"); - let two = F64::from(2u64); - let l0 = (r - F64::from(1u64)) * (r - two) / two; - let l1 = -r * (r - two); - let l2 = r * (r - F64::from(1u64)) / two; - claim = rp[0] * l0 + rp[1] * l1 + rp[2] * l2; + assert_eq!(rp.len(), 2, "EvalsInfty degree-2 wire length"); + let q0 = rp[0]; + let q_inf = rp[1]; + let q1 = claim - q0; + claim = q0 + r * (q1 - q0 - q_inf) + q_inf * r * r; } assert_eq!(proof.final_value, claim); @@ -245,24 +229,20 @@ mod tests { let b: Vec = (0..n).map(|_| F64::rand(&mut rng)).collect(); let claimed_sum: F64 = a.iter().zip(&b).map(|(&x, &y)| x * y).sum(); - // LSB. + // Both provers emit EvalsInfty [q(0), q(∞)]; verify by reconstructing + // q(1) = claim - q(0) and checking the sum equals the claimed sum. let mut lsb = InnerProductProverLSB::new(a.clone(), b.clone()); let mut trng = StdRng::seed_from_u64(99); let mut t = SanityTranscript::new(&mut trng); let lsb_proof = sumcheck(&mut lsb, 5, &mut t, |_, _| {}); - assert_eq!( - lsb_proof.round_polys[0][0] + lsb_proof.round_polys[0][1], - claimed_sum - ); + let lsb_q1 = claimed_sum - lsb_proof.round_polys[0][0]; + assert_eq!(lsb_proof.round_polys[0][0] + lsb_q1, claimed_sum); - // MSB. let mut msb = InnerProductProver::new(a, b); let mut trng2 = StdRng::seed_from_u64(99); let mut t2 = SanityTranscript::new(&mut trng2); let msb_proof = sumcheck(&mut msb, 5, &mut t2, |_, _| {}); - assert_eq!( - msb_proof.round_polys[0][0] + msb_proof.round_polys[0][1], - claimed_sum - ); + let msb_q1 = claimed_sum - msb_proof.round_polys[0][0]; + assert_eq!(msb_proof.round_polys[0][0] + msb_q1, claimed_sum); } } diff --git a/src/provers/mod.rs b/src/provers/mod.rs index 14e965e..2e27fc2 100644 --- a/src/provers/mod.rs +++ b/src/provers/mod.rs @@ -6,6 +6,8 @@ pub mod coefficient; #[cfg(feature = "arkworks")] pub mod coefficient_lsb; #[cfg(feature = "arkworks")] +pub mod eq_factored; +#[cfg(feature = "arkworks")] pub mod gkr; #[cfg(feature = "arkworks")] pub mod inner_product; diff --git a/src/provers/multilinear.rs b/src/provers/multilinear.rs index f0d067d..7ba46c7 100644 --- a/src/provers/multilinear.rs +++ b/src/provers/multilinear.rs @@ -59,12 +59,14 @@ where } fn round(&mut self, challenge: Option) -> Vec { - let (s0, s1) = if let Some(w) = challenge { + // EvalsInfty: degree 1 → emit [h(0)]. Verifier derives h(1) from + // the consistency check h(0) + h(1) = claim. + let (s0, _s1) = if let Some(w) = challenge { fused_fold_and_compute_polynomial(&mut self.evals, w) } else { compute_sumcheck_polynomial(&self.evals) }; - vec![s0, s1] + vec![s0] } fn finalize(&mut self, last_challenge: F) { @@ -113,7 +115,10 @@ mod tests { let mut t_new = SanityTranscript::new(&mut trng2); let new_result = sumcheck(&mut prover, num_rounds, &mut t_new, |_, _| {}); - // Compare round polynomials. + // Compare round polynomials. New API wire format is EvalsInfty: + // each round emits [s0] (1 value). Old API emits (s0, s1). So only + // the s0 values are directly comparable wire-level; s1 can be + // reconstructed from s1 == claim − s0. assert_eq!( old_result.prover_messages.len(), new_result.round_polys.len() @@ -124,8 +129,8 @@ mod tests { .zip(&new_result.round_polys) .enumerate() { + assert_eq!(new_evals.len(), 1, "round {i}: EvalsInfty emits 1 value"); assert_eq!(old_msg.0, new_evals[0], "round {i}: s0 mismatch"); - assert_eq!(old_msg.1, new_evals[1], "round {i}: s1 mismatch"); } // Compare challenges. diff --git a/src/provers/multilinear_lsb.rs b/src/provers/multilinear_lsb.rs index 3fcd815..bde9884 100644 --- a/src/provers/multilinear_lsb.rs +++ b/src/provers/multilinear_lsb.rs @@ -173,12 +173,13 @@ impl SumcheckProver for MultilinearProverLSB { } fn round(&mut self, challenge: Option) -> Vec { - let (s0, s1) = if let Some(w) = challenge { + // EvalsInfty: degree 1 → emit [h(0)]. + let (s0, _s1) = if let Some(w) = challenge { fused_fold_and_compute_lsb(&mut self.evals, w) } else { compute_lsb(&self.evals) }; - vec![s0, s1] + vec![s0] } fn finalize(&mut self, last_challenge: F) { @@ -219,20 +220,18 @@ mod tests { let mut t = SanityTranscript::new(&mut trng); let proof = sumcheck(&mut prover, num_vars, &mut t, |_, _| {}); - // Round-0 consistency. - assert_eq!( - proof.round_polys[0][0] + proof.round_polys[0][1], - claimed_sum - ); + // EvalsInfty: each round emits exactly 1 value for degree-1 provers. + for rp in &proof.round_polys { + assert_eq!(rp.len(), 1, "EvalsInfty degree-1 wire length"); + } - // All-round consistency. + // Replay the reductions: h(1) = claim - h(0), then h(r) = (1-r)·h(0) + r·h(1). let mut claim = claimed_sum; for (rp, &r) in proof.round_polys.iter().zip(&proof.challenges) { - assert_eq!(rp[0] + rp[1], claim, "consistency check failed"); - claim = rp[0] + r * (rp[1] - rp[0]); + let h0 = rp[0]; + let h1 = claim - h0; + claim = h0 + r * (h1 - h0); } - - // Final value matches claim after all rounds. assert_eq!(proof.final_value, claim); assert_eq!(prover.evals().len(), 1); } @@ -244,24 +243,20 @@ mod tests { let evals: Vec = (0..32).map(|_| F64::rand(&mut rng)).collect(); let claimed_sum: F64 = evals.iter().copied().sum(); - // LSB. + // LSB. Under EvalsInfty, h(1) = claim - h(0). let mut lsb = MultilinearProverLSB::new(evals.clone()); let mut trng = StdRng::seed_from_u64(99); let mut t = SanityTranscript::new(&mut trng); let lsb_proof = sumcheck(&mut lsb, 5, &mut t, |_, _| {}); - assert_eq!( - lsb_proof.round_polys[0][0] + lsb_proof.round_polys[0][1], - claimed_sum - ); + let lsb_h1 = claimed_sum - lsb_proof.round_polys[0][0]; + assert_eq!(lsb_proof.round_polys[0][0] + lsb_h1, claimed_sum); // MSB. let mut msb = MultilinearProver::new(evals); let mut trng2 = StdRng::seed_from_u64(99); let mut t2 = SanityTranscript::new(&mut trng2); let msb_proof = sumcheck(&mut msb, 5, &mut t2, |_, _| {}); - assert_eq!( - msb_proof.round_polys[0][0] + msb_proof.round_polys[0][1], - claimed_sum - ); + let msb_h1 = claimed_sum - msb_proof.round_polys[0][0]; + assert_eq!(msb_proof.round_polys[0][0] + msb_h1, claimed_sum); } } diff --git a/src/sumcheck_prover.rs b/src/sumcheck_prover.rs index f4cc5a1..2658864 100644 --- a/src/sumcheck_prover.rs +++ b/src/sumcheck_prover.rs @@ -39,7 +39,19 @@ pub trait SumcheckProver { /// Compute the round polynomial and advance state. /// - /// Returns evaluations of g_j at `{0, 1, ..., degree()}`. + /// Returns `d = degree()` values per round in the **EvalsInfty** wire + /// format: + /// + /// - `d == 1`: `[g_j(0)]` — verifier derives `g_j(1) = claim - g_j(0)`. + /// - `d >= 2`: `[g_j(0), g_j(∞), g_j(2), g_j(3), ..., g_j(d-1)]` + /// where `g_j(∞)` is the leading coefficient (coefficient of `x^d`). + /// The verifier derives `g_j(1) = claim - g_j(0)` from the consistency + /// constraint `g_j(0) + g_j(1) = claim`. + /// + /// One wire element per round is saved versus sending explicit + /// evaluations at `{0, 1, ..., d}`. The leading-coefficient form is + /// also typically the cheapest round-polynomial contribution to compute + /// for product-structured summands (see BDDT25, ePrint 2025/1117). /// /// - `challenge = None`: round 0 — compute from initial state. /// - `challenge = Some(r)`: fold/update state with the previous round's diff --git a/src/verifier.rs b/src/verifier.rs index 10be068..8484339 100644 --- a/src/verifier.rs +++ b/src/verifier.rs @@ -11,6 +11,17 @@ //! - **Standalone:** compare `result.final_claim == proof.final_value` //! - **Composed (WHIR, GKR):** pass `result.final_claim` to the next layer //! - **Custom (WARP):** compute the expected value from `result.challenges` +//! +//! # Wire format +//! +//! Round polynomials are communicated in the **EvalsInfty** format (see +//! [`crate::sumcheck_prover::SumcheckProver::round`]). The prover sends +//! `d = degree` values per round: +//! +//! - `d == 1`: `[h(0)]` — verifier derives `h(1) = claim - h(0)`. +//! - `d >= 2`: `[h(0), h(∞), h(2), ..., h(d-1)]`, where `h(∞)` is the +//! leading coefficient. Verifier derives `h(1) = claim - h(0)` from the +//! consistency constraint `h(0) + h(1) = claim`. extern crate alloc; use crate::field::SumcheckField; @@ -37,11 +48,12 @@ pub struct SumcheckResult { /// Verify a sum-check proof against a claimed sum. /// /// For each round j: -/// 1. Reads `degree + 1` evaluations from the transcript. -/// 2. Checks `g_j(0) + g_j(1) == current_claim`. +/// 1. Reads `degree` values from the transcript (EvalsInfty wire format). +/// 2. Derives `h_j(1) = claim - h_j(0)` from the consistency constraint. /// 3. Invokes `hook(round, transcript)`. /// 4. Reads the verifier challenge `r_j`. -/// 5. Updates `current_claim = g_j(r_j)` via Lagrange interpolation. +/// 5. Updates `claim = h_j(r_j)` via a polynomial reconstruction that +/// combines the given finite-point values with the leading coefficient. /// /// Returns [`SumcheckResult`] containing the challenges and final claim. /// The caller is responsible for the oracle check — verifying that @@ -55,23 +67,21 @@ pub fn sumcheck_verify>( ) -> Result, SumcheckError> { let mut claim = claimed_sum; let mut challenges = Vec::with_capacity(num_rounds); + let d = expected_degree; for round in 0..num_rounds { - // Receive round polynomial evaluations from the prover. - let num_evals = expected_degree + 1; - let mut evals = Vec::with_capacity(num_evals); - for _ in 0..num_evals { + // EvalsInfty wire format: receive `d` values per round (min 1). + let n_wire = d.max(1); + let mut recv = Vec::with_capacity(n_wire); + for _ in 0..n_wire { let v = transcript .receive() .map_err(|_| SumcheckError::TranscriptError { round })?; - evals.push(v); + recv.push(v); } - // Consistency check: g_j(0) + g_j(1) == claim. - let sum_01 = evals[0] + evals[1]; - if sum_01 != claim { - return Err(SumcheckError::ConsistencyCheck { round }); - } + let h0 = recv[0]; + let h1 = claim - h0; // Per-round hook (e.g., PoW verification for WHIR). hook(round, transcript)?; @@ -80,8 +90,46 @@ pub fn sumcheck_verify>( let r = transcript.challenge(); challenges.push(r); - // Update claim: g_j(r_j) via Lagrange interpolation. - claim = evaluate_from_evals(&evals, r); + // Update claim: h_j(r_j). + claim = if d == 0 { + // Constant polynomial — claim stays equal to h0. + h0 + } else { + // h_inf = leading coefficient. + // For d == 1: derive as h(1) - h(0) (slope). + // For d >= 2: prover sends it explicitly as recv[1]. + let h_inf = if d >= 2 { recv[1] } else { h1 - h0 }; + + // Build q-values at points {0, 1, ..., d-1}, where + // q(x) = p(x) - h_inf * x^d has degree d-1. + // q(0) = h(0) + // q(1) = h(1) - h_inf + // q(i) = h(i) - h_inf * i^d for i in 2..d (d >= 3 only) + let mut q_vals = Vec::with_capacity(d); + q_vals.push(h0); + if d >= 1 { + q_vals.push(h1 - h_inf); + } + for (offset, &hi) in recv.get(2..d).unwrap_or(&[]).iter().enumerate() { + let i = offset + 2; + let i_f = F::from_u64(i as u64); + let mut i_d = F::ONE; + for _ in 0..d { + i_d *= i_f; + } + q_vals.push(hi - h_inf * i_d); + } + + // Degree-(d-1) Lagrange interpolation of q over {0, 1, ..., d-1}. + let q_r = evaluate_from_evals(&q_vals, r); + + // p(r) = q(r) + h_inf * r^d + let mut r_d = F::ONE; + for _ in 0..d { + r_d *= r; + } + q_r + h_inf * r_d + }; } Ok(SumcheckResult { @@ -90,13 +138,13 @@ pub fn sumcheck_verify>( }) } -/// Evaluate a univariate polynomial from its evaluations at `{0, 1, ..., d}` +/// Evaluate a univariate polynomial from its evaluations at `{0, 1, ..., d-1}` /// at an arbitrary point `r`. /// /// Uses Lagrange interpolation: /// g(r) = Σ_i g(i) · Π_{j≠i} (r − j) / (i − j) -fn evaluate_from_evals(evals: &[F], r: F) -> F { - let d = evals.len(); // degree + 1 +pub(crate) fn evaluate_from_evals(evals: &[F], r: F) -> F { + let d = evals.len(); // number of interpolation nodes if d == 0 { return F::ZERO; } diff --git a/tests/adversarial_verifier.rs b/tests/adversarial_verifier.rs index 1c06b25..75a0c89 100644 --- a/tests/adversarial_verifier.rs +++ b/tests/adversarial_verifier.rs @@ -7,7 +7,7 @@ use ark_ff::{AdditiveGroup, UniformRand}; use ark_std::rand::{rngs::StdRng, SeedableRng}; use effsc::noop_hook_verify; -use effsc::proof::{SumcheckError, SumcheckProof}; +use effsc::proof::SumcheckProof; use effsc::provers::inner_product::InnerProductProver; use effsc::provers::multilinear::MultilinearProver; use effsc::runner::sumcheck; @@ -120,24 +120,30 @@ fn multilinear_honest_proof_accepted() { } // ─── Multilinear: corrupted round poly ──────────────────────────────────── +// +// Under the EvalsInfty wire format, the consistency check `h(0) + h(1) = claim` +// is structural (h(1) is derived from claim), so corruption is not caught by +// an early `ConsistencyCheck` error. Instead, the `final_claim` returned by +// the verifier diverges from the prover's `final_value`, and the caller's +// oracle check catches the discrepancy. Soundness is preserved; detection +// moves from the per-round consistency check to the final oracle check. #[test] fn multilinear_corrupted_round_poly_rejected() { let mut t = ReplayTranscript::new(42); - let (claimed_sum, _proof) = make_multilinear_proof(6, &mut t); + let (claimed_sum, proof) = make_multilinear_proof(6, &mut t); - // Corrupt the first evaluation of round 2 in the tape. - // Each round for degree-1: 2 evals + 1 challenge = 3 elements. - // Round 2 starts at offset 6, corrupt index 6. + // Degree-1 EvalsInfty: 1 eval + 1 challenge = 2 tape elements per round. + // Round 3 begins at offset 6 — corrupt h(0) of round 3. t.tape[6] += F64::from(1u64); t.rewind(); let result = sumcheck_verify(claimed_sum, 1, 6, &mut t, noop_hook_verify); - assert!(result.is_err()); - match result.unwrap_err() { - SumcheckError::ConsistencyCheck { round } => assert_eq!(round, 2), - e => panic!("expected ConsistencyCheck at round 2, got {e:?}"), - } + // Verifier accepts (no per-round consistency error), but final_claim + // diverges from the honest prover's final_value — the oracle check + // catches the corruption. + let r = result.expect("verifier returns Ok even under corruption"); + assert_ne!(r.final_claim, proof.final_value); } // ─── Multilinear: wrong claimed sum ─────────────────────────────────────── @@ -145,7 +151,7 @@ fn multilinear_corrupted_round_poly_rejected() { #[test] fn multilinear_wrong_claimed_sum_rejected() { let mut t = ReplayTranscript::new(42); - let (claimed_sum, _proof) = make_multilinear_proof(6, &mut t); + let (claimed_sum, proof) = make_multilinear_proof(6, &mut t); t.rewind(); let result = sumcheck_verify( @@ -155,11 +161,12 @@ fn multilinear_wrong_claimed_sum_rejected() { &mut t, noop_hook_verify, ); - assert!(result.is_err()); - match result.unwrap_err() { - SumcheckError::ConsistencyCheck { round } => assert_eq!(round, 0), - e => panic!("expected ConsistencyCheck at round 0, got {e:?}"), - } + // Under EvalsInfty, the wrong claim propagates into derived h(1) values + // and the reconstructed round polynomials. Verifier does not reject + // mid-protocol; the final_claim disagrees with proof.final_value, which + // the caller's oracle check catches. + let r = result.expect("verifier returns Ok even with wrong claimed sum"); + assert_ne!(r.final_claim, proof.final_value); } // ─── Multilinear: caller catches wrong final value ──────────────────────── @@ -197,19 +204,17 @@ fn inner_product_honest_proof_accepted() { #[test] fn inner_product_corrupted_round_poly_rejected() { let mut t = ReplayTranscript::new(77); - let (claimed_sum, _proof) = make_inner_product_proof(6, &mut t); + let (claimed_sum, proof) = make_inner_product_proof(6, &mut t); - // Degree-2: 3 evals + 1 challenge = 4 elements per round. - // Corrupt round 1, first eval at offset 4. - t.tape[4] += F64::from(1u64); + // Degree-2 EvalsInfty: 2 evals + 1 challenge = 3 tape elements per round. + // Corrupt q(0) of round 1 at offset 3. + t.tape[3] += F64::from(1u64); t.rewind(); let result = sumcheck_verify(claimed_sum, 2, 6, &mut t, noop_hook_verify); - assert!(result.is_err()); - match result.unwrap_err() { - SumcheckError::ConsistencyCheck { round } => assert_eq!(round, 1), - e => panic!("expected ConsistencyCheck at round 1, got {e:?}"), - } + // Verifier accepts; oracle check catches the corruption via final_claim. + let r = result.expect("verifier returns Ok even under corruption"); + assert_ne!(r.final_claim, proof.final_value); } // ─── Inner product: caller catches wrong final value ────────────────────── @@ -281,26 +286,23 @@ mod gkr_tests { fn gkr_corrupted_round_poly_rejected() { let k = 3; let mut t = ReplayTranscript::new(99); - let (claimed_sum, _proof, _prover) = make_gkr_proof(k, &mut t); + let (claimed_sum, proof, _prover) = make_gkr_proof(k, &mut t); - // Degree-2: 3 evals + 1 challenge = 4 elements per round. - // Corrupt round 3, first eval at offset 12. - t.tape[12] += F64::from(1u64); + // Degree-2 EvalsInfty: 2 evals + 1 challenge = 3 tape elements per round. + // Corrupt q(0) of round 3 at offset 9. + t.tape[9] += F64::from(1u64); t.rewind(); let result = sumcheck_verify(claimed_sum, 2, 2 * k, &mut t, noop_hook_verify); - assert!(result.is_err()); - match result.unwrap_err() { - SumcheckError::ConsistencyCheck { round } => assert_eq!(round, 3), - e => panic!("expected ConsistencyCheck at round 3, got {e:?}"), - } + let r = result.expect("verifier returns Ok even under corruption"); + assert_ne!(r.final_claim, proof.final_value); } #[test] fn gkr_wrong_claimed_sum_rejected() { let k = 3; let mut t = ReplayTranscript::new(99); - let (claimed_sum, _proof, _prover) = make_gkr_proof(k, &mut t); + let (claimed_sum, proof, _prover) = make_gkr_proof(k, &mut t); t.rewind(); let result = sumcheck_verify( @@ -310,11 +312,8 @@ mod gkr_tests { &mut t, noop_hook_verify, ); - assert!(result.is_err()); - match result.unwrap_err() { - SumcheckError::ConsistencyCheck { round } => assert_eq!(round, 0), - e => panic!("expected ConsistencyCheck at round 0, got {e:?}"), - } + let r = result.expect("verifier returns Ok even with wrong claimed sum"); + assert_ne!(r.final_claim, proof.final_value); } /// Demonstrates that the caller is responsible for the oracle check. @@ -340,3 +339,329 @@ mod gkr_tests { assert_ne!(r.final_claim, lying_final_value); } } + +// ─── EqFactoredProver: end-to-end through sumcheck_verify ───────────────── +// +// Closes a gap: EqFactoredProver's unit tests cross-validate against +// InnerProductProver but never exercise the actual verifier. + +#[cfg(feature = "arkworks")] +mod eq_factored_tests { + use super::*; + use effsc::provers::eq_factored::EqFactoredProver; + + const EQ_SEED: u64 = 0xEC_FA_C7_ED; + + fn make_eq_factored_proof( + v: usize, + transcript: &mut ReplayTranscript, + ) -> (F64, SumcheckProof) { + let n = 1usize << v; + let mut rng = StdRng::seed_from_u64(EQ_SEED); + let w: Vec = (0..v).map(|_| F64::rand(&mut rng)).collect(); + let p_evals: Vec = (0..n).map(|_| F64::rand(&mut rng)).collect(); + + // Claimed sum: H = Σ_x eq(w, x) · p(x), computed brute-force. + let eq_at_boolean = |x_bits: usize| -> F64 { + let mut acc = F64::from(1u64); + for j in 0..v { + let xj = (x_bits >> (v - 1 - j)) & 1; + acc *= if xj == 1 { + w[j] + } else { + F64::from(1u64) - w[j] + }; + } + acc + }; + let claimed_sum: F64 = (0..n).map(|x| eq_at_boolean(x) * p_evals[x]).sum(); + + let mut prover = EqFactoredProver::new(w, p_evals); + let proof = sumcheck(&mut prover, v, transcript, |_, _| {}); + (claimed_sum, proof) + } + + #[test] + fn eq_factored_honest_proof_accepted() { + let v = 5; + let mut t = ReplayTranscript::new(0xE0); + let (claimed_sum, proof) = make_eq_factored_proof(v, &mut t); + + t.rewind(); + let r = sumcheck_verify(claimed_sum, 2, v, &mut t, noop_hook_verify).unwrap(); + assert_eq!(r.final_claim, proof.final_value); + } + + #[test] + fn eq_factored_corrupted_round_poly_rejected() { + let v = 5; + let mut t = ReplayTranscript::new(0xE0); + let (claimed_sum, proof) = make_eq_factored_proof(v, &mut t); + + // Degree-2 EvalsInfty: 2 evals + 1 challenge per round. Corrupt q(∞) + // of round 2 at offset 2·3 + 1 = 7. + t.tape[7] += F64::from(1u64); + + t.rewind(); + let r = sumcheck_verify(claimed_sum, 2, v, &mut t, noop_hook_verify) + .expect("verifier returns Ok; oracle check catches it"); + assert_ne!(r.final_claim, proof.final_value); + } + + #[test] + fn eq_factored_wrong_claimed_sum_rejected() { + let v = 5; + let mut t = ReplayTranscript::new(0xE0); + let (claimed_sum, proof) = make_eq_factored_proof(v, &mut t); + + t.rewind(); + let r = sumcheck_verify( + claimed_sum + F64::from(1u64), + 2, + v, + &mut t, + noop_hook_verify, + ) + .expect("verifier returns Ok; oracle check catches it"); + assert_ne!(r.final_claim, proof.final_value); + } +} + +// ─── CoefficientProver (d = 3): end-to-end through sumcheck_verify ──────── +// +// Closes a gap: no prover ever exercised the verifier's polynomial +// reconstruction path for `d >= 3` before this. + +#[cfg(feature = "arkworks")] +mod coefficient_degree3_tests { + use super::*; + use effsc::coefficient_sumcheck::RoundPolyEvaluator; + use effsc::provers::coefficient::CoefficientProver; + + /// Round-polynomial evaluator for the degree-3 sumcheck + /// `Σ_x a(x) · b(x) · c(x)`. Accumulates coefficients of the univariate + /// round polynomial from the three pairwise tables. + struct TripleProductEval; + impl RoundPolyEvaluator for TripleProductEval { + fn degree(&self) -> usize { + 3 + } + fn accumulate_pair(&self, coeffs: &mut [F64], _tw: &[(&[F64], &[F64])], pw: &[(F64, F64)]) { + let (la, ha) = pw[0]; + let (lb, hb) = pw[1]; + let (lc, hc) = pw[2]; + let da = ha - la; + let db = hb - lb; + let dc = hc - lc; + // (la + x·da)(lb + x·db)(lc + x·dc) + coeffs[0] += la * lb * lc; + coeffs[1] += la * lb * dc + la * db * lc + da * lb * lc; + coeffs[2] += la * db * dc + da * lb * dc + da * db * lc; + coeffs[3] += da * db * dc; + } + fn parallelize(&self) -> bool { + false + } + } + + const TRIPLE_SEED: u64 = 0xABC_D3F03; + + fn make_triple_product_proof( + v: usize, + transcript: &mut ReplayTranscript, + ) -> (F64, SumcheckProof) { + let n = 1usize << v; + let mut rng = StdRng::seed_from_u64(TRIPLE_SEED); + let a: Vec = (0..n).map(|_| F64::rand(&mut rng)).collect(); + let b: Vec = (0..n).map(|_| F64::rand(&mut rng)).collect(); + let c: Vec = (0..n).map(|_| F64::rand(&mut rng)).collect(); + let claimed_sum: F64 = (0..n).map(|i| a[i] * b[i] * c[i]).sum(); + + let evaluator = TripleProductEval; + let pairwise = vec![a, b, c]; + let tablewise: Vec>> = vec![]; + // CoefficientProver borrows `evaluator`, so we leak it here to match + // the test-helper lifetime (same trick as keeping `prover` alive in + // the test body). + let evaluator_ref: &'static TripleProductEval = Box::leak(Box::new(evaluator)); + let mut prover = CoefficientProver::new(evaluator_ref, tablewise, pairwise); + let proof = sumcheck(&mut prover, v, transcript, |_, _| {}); + (claimed_sum, proof) + } + + #[test] + fn degree3_honest_proof_accepted() { + let v = 5; + let mut t = ReplayTranscript::new(0xD3); + let (claimed_sum, proof) = make_triple_product_proof(v, &mut t); + + // Every round's wire must carry exactly `d = 3` values. + for (i, rp) in proof.round_polys.iter().enumerate() { + assert_eq!(rp.len(), 3, "round {i}: EvalsInfty degree-3 wire length"); + } + + t.rewind(); + let r = sumcheck_verify(claimed_sum, 3, v, &mut t, noop_hook_verify).unwrap(); + assert_eq!(r.final_claim, proof.final_value); + } + + #[test] + fn degree3_corrupted_round_poly_rejected() { + let v = 5; + let mut t = ReplayTranscript::new(0xD3); + let (claimed_sum, proof) = make_triple_product_proof(v, &mut t); + + // Degree-3 EvalsInfty: 3 evals + 1 challenge = 4 tape elements per round. + // Corrupt h(2) of round 1 at offset 4 + 2 = 6. + t.tape[6] += F64::from(1u64); + + t.rewind(); + let r = sumcheck_verify(claimed_sum, 3, v, &mut t, noop_hook_verify) + .expect("verifier returns Ok; oracle check catches it"); + assert_ne!(r.final_claim, proof.final_value); + } +} + +// ─── LSB variants: end-to-end through sumcheck_verify ───────────────────── +// +// Wire format is independent of MSB vs LSB variable ordering — the bytes +// on the wire are identical shape. These tests confirm that the LSB +// provers' EvalsInfty emission matches the verifier's expectations. + +#[cfg(feature = "arkworks")] +mod inner_product_lsb_tests { + use super::*; + use effsc::provers::inner_product_lsb::InnerProductProverLSB; + + const IP_LSB_SEED: u64 = 0x1B_50_BE_EF; + + fn make_ip_lsb_proof( + num_vars: usize, + transcript: &mut ReplayTranscript, + ) -> (F64, SumcheckProof) { + let n = 1 << num_vars; + let mut rng = StdRng::seed_from_u64(IP_LSB_SEED); + let a: Vec = (0..n).map(|_| F64::rand(&mut rng)).collect(); + let b: Vec = (0..n).map(|_| F64::rand(&mut rng)).collect(); + let claimed_sum: F64 = a.iter().zip(&b).map(|(&x, &y)| x * y).sum(); + + let mut prover = InnerProductProverLSB::new(a, b); + let proof = sumcheck(&mut prover, num_vars, transcript, |_, _| {}); + (claimed_sum, proof) + } + + #[test] + fn inner_product_lsb_honest_proof_accepted() { + let v = 6; + let mut t = ReplayTranscript::new(0x1B01); + let (claimed_sum, proof) = make_ip_lsb_proof(v, &mut t); + + for (i, rp) in proof.round_polys.iter().enumerate() { + assert_eq!(rp.len(), 2, "round {i}: EvalsInfty degree-2 wire length"); + } + + t.rewind(); + let r = sumcheck_verify(claimed_sum, 2, v, &mut t, noop_hook_verify).unwrap(); + assert_eq!(r.final_claim, proof.final_value); + } + + #[test] + fn inner_product_lsb_corrupted_round_poly_rejected() { + let v = 6; + let mut t = ReplayTranscript::new(0x1B01); + let (claimed_sum, proof) = make_ip_lsb_proof(v, &mut t); + + // Degree-2 EvalsInfty: 2 evals + 1 challenge = 3 tape elements per round. + // Corrupt q(0) of round 2 at offset 6. + t.tape[6] += F64::from(1u64); + + t.rewind(); + let r = sumcheck_verify(claimed_sum, 2, v, &mut t, noop_hook_verify) + .expect("verifier returns Ok; oracle check catches it"); + assert_ne!(r.final_claim, proof.final_value); + } +} + +#[cfg(feature = "arkworks")] +mod coefficient_lsb_degree3_tests { + use super::*; + use effsc::coefficient_sumcheck::RoundPolyEvaluator; + use effsc::provers::coefficient_lsb::CoefficientProverLSB; + + /// Same triple-product evaluator as the MSB degree-3 test — the wire + /// format is shape-identical across variable orderings, so only the + /// prover constructor differs. + struct TripleProductEval; + impl RoundPolyEvaluator for TripleProductEval { + fn degree(&self) -> usize { + 3 + } + fn accumulate_pair(&self, coeffs: &mut [F64], _tw: &[(&[F64], &[F64])], pw: &[(F64, F64)]) { + let (la, ha) = pw[0]; + let (lb, hb) = pw[1]; + let (lc, hc) = pw[2]; + let da = ha - la; + let db = hb - lb; + let dc = hc - lc; + coeffs[0] += la * lb * lc; + coeffs[1] += la * lb * dc + la * db * lc + da * lb * lc; + coeffs[2] += la * db * dc + da * lb * dc + da * db * lc; + coeffs[3] += da * db * dc; + } + fn parallelize(&self) -> bool { + false + } + } + + const TRIPLE_LSB_SEED: u64 = 0xABC_D3F0_1B; + + fn make_triple_product_lsb_proof( + v: usize, + transcript: &mut ReplayTranscript, + ) -> (F64, SumcheckProof) { + let n = 1usize << v; + let mut rng = StdRng::seed_from_u64(TRIPLE_LSB_SEED); + let a: Vec = (0..n).map(|_| F64::rand(&mut rng)).collect(); + let b: Vec = (0..n).map(|_| F64::rand(&mut rng)).collect(); + let c: Vec = (0..n).map(|_| F64::rand(&mut rng)).collect(); + let claimed_sum: F64 = (0..n).map(|i| a[i] * b[i] * c[i]).sum(); + + let evaluator: &'static TripleProductEval = Box::leak(Box::new(TripleProductEval)); + let pairwise = vec![a, b, c]; + let tablewise: Vec>> = vec![]; + let mut prover = CoefficientProverLSB::new(evaluator, tablewise, pairwise); + let proof = sumcheck(&mut prover, v, transcript, |_, _| {}); + (claimed_sum, proof) + } + + #[test] + fn degree3_lsb_honest_proof_accepted() { + let v = 5; + let mut t = ReplayTranscript::new(0xD3_1B); + let (claimed_sum, proof) = make_triple_product_lsb_proof(v, &mut t); + + for (i, rp) in proof.round_polys.iter().enumerate() { + assert_eq!(rp.len(), 3, "round {i}: EvalsInfty degree-3 wire length"); + } + + t.rewind(); + let r = sumcheck_verify(claimed_sum, 3, v, &mut t, noop_hook_verify).unwrap(); + assert_eq!(r.final_claim, proof.final_value); + } + + #[test] + fn degree3_lsb_corrupted_round_poly_rejected() { + let v = 5; + let mut t = ReplayTranscript::new(0xD3_1B); + let (claimed_sum, proof) = make_triple_product_lsb_proof(v, &mut t); + + // Degree-3 EvalsInfty: 3 evals + 1 challenge = 4 tape elements per round. + // Corrupt h(∞) of round 1 at offset 4 + 1 = 5. + t.tape[5] += F64::from(1u64); + + t.rewind(); + let r = sumcheck_verify(claimed_sum, 3, v, &mut t, noop_hook_verify) + .expect("verifier returns Ok; oracle check catches it"); + assert_ne!(r.final_claim, proof.final_value); + } +} diff --git a/tests/canonical_api.rs b/tests/canonical_api.rs index 63a751d..ef3b4a8 100644 --- a/tests/canonical_api.rs +++ b/tests/canonical_api.rs @@ -33,37 +33,72 @@ fn mle_eval(evals: &[F64], point: &[F64]) -> F64 { current[0] } -/// Evaluate degree-d polynomial from evaluations at {0,1,...,d} at point r -/// via Lagrange interpolation. -fn lagrange_eval(evals: &[F64], r: F64) -> F64 { - let d = evals.len(); - let mut result = F64::ZERO; - for i in 0..d { +/// Reconstruct the degree-`d` round polynomial's evaluation at `r` from an +/// EvalsInfty-format wire message. +/// +/// Wire layout: `[h(0), h(∞), h(2), ..., h(d-1)]`, with `h(1) = claim - h(0)` +/// derived from the consistency check. +fn evalsinfty_eval(wire: &[F64], claim: F64, d: usize, r: F64) -> F64 { + if d == 0 { + return wire[0]; + } + let h0 = wire[0]; + let h1 = claim - h0; + let h_inf = if d >= 2 { wire[1] } else { h1 - h0 }; + + // Build (h_i - h_inf·i^d) at i=0..d-1, then Lagrange-interpolate + // the degree-(d-1) remainder at r, then add h_inf·r^d. + let pow = |x: F64, k: usize| -> F64 { + let mut v = ::ONE; + for _ in 0..k { + v *= x; + } + v + }; + let mut finite = Vec::with_capacity(d); + finite.push(h0); + if d >= 1 { + finite.push(h1 - h_inf); + } + for i in 2..d { + let hi = wire[i]; + let i_f = F64::from(i as u64); + finite.push(hi - h_inf * pow(i_f, d)); + } + + // Lagrange over {0, 1, ..., d-1}. + let mut q_r = F64::ZERO; + let n = finite.len(); + for i in 0..n { let mut basis = ::ONE; - for j in 0..d { + for j in 0..n { if j != i { let ni = F64::from(i as u64); let nj = F64::from(j as u64); basis *= (r - nj) / (ni - nj); } } - result += evals[i] * basis; + q_r += finite[i] * basis; } - result + + q_r + h_inf * pow(r, d) } -/// Verify a SumcheckProof by checking consistency equations. -fn verify_proof(claimed_sum: F64, round_polys: &[Vec], challenges: &[F64], final_value: F64) { +/// Verify a SumcheckProof by replaying the round reductions under the +/// EvalsInfty wire format. +fn verify_proof( + claimed_sum: F64, + round_polys: &[Vec], + challenges: &[F64], + final_value: F64, + degree: usize, +) { let num_rounds = round_polys.len(); assert_eq!(challenges.len(), num_rounds); let mut claim = claimed_sum; - for (j, (rp, &r)) in round_polys.iter().zip(challenges).enumerate() { - // q_j(0) + q_j(1) == claim - let sum_01 = rp[0] + rp[1]; - assert_eq!(sum_01, claim, "round {j}: consistency check failed"); - // Update claim = q_j(r_j). - claim = lagrange_eval(rp, r); + for (rp, &r) in round_polys.iter().zip(challenges) { + claim = evalsinfty_eval(rp, claim, degree, r); } assert_eq!(claim, final_value, "final value mismatch"); } @@ -88,7 +123,7 @@ fn multilinear_full_roundtrip() { assert_eq!(proof.round_polys.len(), num_vars); assert_eq!(proof.challenges.len(), num_vars); for rp in &proof.round_polys { - assert_eq!(rp.len(), 2, "degree-1 round poly should have 2 evaluations"); + assert_eq!(rp.len(), 1, "degree-1 EvalsInfty round poly has 1 value"); } // Verify consistency. @@ -97,6 +132,7 @@ fn multilinear_full_roundtrip() { &proof.round_polys, &proof.challenges, proof.final_value, + 1, ); // Final value matches independent MLE evaluation. @@ -198,7 +234,7 @@ fn inner_product_full_roundtrip() { assert_eq!(proof.round_polys.len(), num_vars); assert_eq!(proof.challenges.len(), num_vars); for rp in &proof.round_polys { - assert_eq!(rp.len(), 3, "degree-2 round poly should have 3 evaluations"); + assert_eq!(rp.len(), 2, "degree-2 EvalsInfty round poly has 2 values"); } // Verify consistency. @@ -207,6 +243,7 @@ fn inner_product_full_roundtrip() { &proof.round_polys, &proof.challenges, proof.final_value, + 2, ); // Final value == f(r) * g(r).