Skip to main content

p3_sumcheck/ring_switch/
mod.rs

1//! Ring switching (IACR eprint 2024/504, Construction 3.1): a reduction from an evaluation
2//! claim about a small-field multilinear to a claim about its packed extension-field
3//! multilinear.
4//!
5//! The prover holds `t` over `F` and its packing `t'` over `EF`, and the verifier holds a claim
6//! `t(r) = s`. Splitting `r = (r_high, r_low)` at the `κ = log2([EF:F])` variables one packed
7//! element absorbs, the reduction runs one degree-2 sumcheck of `ℓ' = ℓ − κ` rounds and leaves
8//! a claim `t'(r') = s'` about the packed polynomial alone. Discharging that claim against a
9//! commitment to `t'` is the caller's business.
10//!
11//! The tensor element `ŝ` is the only prover message the two checks share.
12//!
13//! ```text
14//!     read by columns  ->  tests the incoming claim
15//!     read by rows     ->  gives the sumcheck its initial sum
16//! ```
17//!
18//! The verifier derives that initial sum itself.
19//!
20//! It never takes the sum from the prover.
21//!
22//! # A reduction, not a filter
23//!
24//! A false input claim is not rejected outright.
25//!
26//! It survives as a false surviving claim `t'(r') = s'`.
27//!
28//! The probability of anything else is bounded by the error below.
29//!
30//! # Two things this does not catch
31//!
32//! The degenerate case `A(r') = 0` is one.
33//!
34//! There the final check reads `sum == 0`.
35//!
36//! That constrains `s'` not at all.
37//!
38//! A false input claim then survives as a *true* surviving claim.
39//!
40//! That is the one direction discharging `s'` against a commitment cannot catch.
41//!
42//! The `2ℓ'/|EF|` term of the bound covers it.
43//!
44//! The second is a prover who tampers with `ŝ`.
45//!
46//! Adapting the rest of the proof to the implied sum defeats the checks here.
47//!
48//! Only the caller catches it, by discharging `s'` against a commitment to `t'`.
49//!
50//! # What the row reading buys
51//!
52//! It buys the `κ/|EF|` term of the bound.
53//!
54//! A tampered `ŝ` shifts the derived initial sum away from an honest packing's value.
55//!
56//! It does so at the Schwartz–Zippel rate of the batching draw.
57//!
58//! # Soundness
59//!
60//! The reduction's own soundness error is `(κ + 2ℓ') / |EF|` (eprint 2024/504 §3.2, Theorem
61//! 3.5), split as:
62//!
63//! - `κ/|EF|` from the Schwartz–Zippel argument on the `r''` batching draw, which collapses
64//!   the `2^κ` row claims into one — the draw names `κ` variables, which is where the `κ`
65//!   comes from; and
66//! - `2ℓ'/|EF|` for the `ℓ'` rounds of degree-2 sumcheck.
67//!
68//! Both terms are stated for the interactive protocol. This module is Fiat–Shamir compiled and
69//! offers no grinding knob — `pow_bits` is zero on both sides and a proof carrying PoW
70//! witnesses is rejected — so the bound as a whole is a per-attempt probability rather than a
71//! total one: the `r''` draw and every round challenge alike are functions of messages the
72//! prover chooses, and a prover may resample any of them. That is out of reach at
73//! `|EF| = 2^128`; a composing protocol instantiating this over a small `EF` must supply the
74//! grinding itself, outside this call.
75//!
76//! `r` is bound by the reduction. Both sides observe it before `ŝ`, so the bound above holds
77//! unconditionally rather than resting on the caller having bound `r` first. Without that
78//! binding the proof would be replayable: `ŝ` is a function of `r_high` alone, so the same
79//! proof verifies verbatim at every `r_low`, and the verifier's one `r_high`-dependent test —
80//! `A(r')` in the final check — is multi-affine over `EF` in the coordinates of `r_high`. Move
81//! a single coordinate by `δ` and the change it induces in `A(r')` is `F`-linear in `δ`, so
82//! wherever that map is singular a prover can pick a nonzero `δ` from its kernel and have a
83//! false input claim accepted alongside a *true* surviving claim, which is the one direction a
84//! caller discharging `s'` against a commitment cannot catch.
85//!
86//! That figure is wired into **no** soundness estimator: nothing here touches `p3-security`,
87//! and neither the reduction nor its callers subtract it from any security target. A caller
88//! composing this reduction into a proof system must account for it in that system's own
89//! error budget, on top of whatever the commitment scheme discharging `t'(r') = s'` costs.
90
91use p3_challenger::fs::TranscriptField;
92use p3_challenger::{FieldChallenger, GrindingChallenger};
93use p3_field::{ExtensionField, Field};
94use p3_multilinear_util::point::Point;
95use p3_multilinear_util::poly::Poly;
96use serde::{Deserialize, Serialize};
97use thiserror::Error;
98
99use crate::data::SumcheckData;
100use crate::error::SumcheckError;
101use crate::product_polynomial::ProductPolynomial;
102use crate::strategy::{Basis, SumcheckProver, VariableOrder};
103
104pub mod bits;
105pub mod equality;
106pub mod packing;
107pub mod tensor;
108pub mod transcript;
109pub mod weights;
110
111pub use equality::equality_element;
112use packing::compute_s_hat_with_eq;
113pub use packing::{compute_s_hat, pack, packed_vars};
114pub use tensor::TensorAlgebra;
115pub use transcript::{RingSwitchProverTranscript, RingSwitchShape, RingSwitchVerifierTranscript};
116use weights::batched_weights_with_eq;
117pub use weights::{batch_rows, batched_weights};
118
119#[cfg(test)]
120pub(crate) mod test_util;
121
122/// The messages one ring-switching reduction puts on the wire.
123#[derive(Clone, Debug, Serialize, Deserialize)]
124#[serde(bound(
125    serialize = "F: Field, EF: ExtensionField<F>",
126    deserialize = "F: Field, EF: ExtensionField<F>"
127))]
128pub struct RingSwitchProof<F, EF> {
129    /// `ŝ = Σ_w eq̃(r_high, w) ⊗ t'(w)`, read by columns and by rows.
130    pub s_hat: TensorAlgebra<F, EF>,
131    /// The `ℓ'` rounds of the batched degree-2 sumcheck.
132    pub sumcheck: SumcheckData<F, EF>,
133    /// `s' = t'(r')`, the value of the surviving claim.
134    pub final_eval: EF,
135}
136
137/// Why a ring-switching proof was rejected.
138#[derive(Error, Debug, PartialEq, Eq)]
139pub enum RingSwitchError {
140    /// `ŝ` does not carry `DIMENSION²` base coefficients, so neither of its readings is defined.
141    #[error("Ring switching: s_hat carries {actual} base coefficients, expected {expected}")]
142    MalformedTensor {
143        /// The number of coefficients a tensor element must carry.
144        expected: usize,
145        /// The number the proof supplied.
146        actual: usize,
147    },
148
149    /// The evaluation point does not name the coordinate count the description fixes.
150    ///
151    /// Neither side can then bind it as the step the description names.
152    #[error("Ring switching: the evaluation point names {actual} coordinates, expected {expected}")]
153    PointWidthMismatch {
154        /// The coordinate count the description fixes.
155        expected: usize,
156        /// The number the point supplied.
157        actual: usize,
158    },
159
160    /// The claimed evaluation is not the `eq̃(·, r_low)`-combination of `ŝ`'s columns.
161    #[error("Ring switching: the claimed evaluation is not the column reading of s_hat")]
162    ClaimMismatch,
163
164    /// A round of the batched sumcheck failed.
165    #[error(transparent)]
166    Sumcheck(#[from] SumcheckError),
167
168    /// The surviving claim does not close the sumcheck against the equality element.
169    #[error("Ring switching: the surviving claim does not close the sumcheck")]
170    FinalCheck,
171
172    /// The sumcheck carries PoW witnesses this reduction never grinds for, so a proof carrying
173    /// junk witnesses would otherwise verify identically to one carrying none.
174    #[error("Ring switching: the sumcheck carries {actual} PoW witnesses, expected none")]
175    NonEmptyPowWitnesses {
176        /// The number of witnesses the proof supplied.
177        actual: usize,
178    },
179}
180
181/// Proves the reduction of `t(r) = s` to a claim about `packed`, the packing of `t`.
182///
183/// # Returns
184///
185/// - The proof.
186/// - The sumcheck's random point `r'`.
187/// - The surviving claim's value `s' = t'(r')`.
188///
189/// # Why only the packed polynomial
190///
191/// Every value that reaches the proof is computed from it.
192///
193/// The claim it proves is a statement about it.
194///
195/// Callers hold `t` and pack it themselves.
196///
197/// Pack the wrong `t` and the claim proves a statement about a different multilinear.
198///
199/// Nothing here can detect that.
200///
201/// The caller's commitment to `packed` is what pins it down.
202///
203/// # Packing
204///
205/// The sumcheck runs on a scalar product polynomial.
206///
207/// ```text
208///     binary tower       ->  extension packing is the extension itself, nothing to gain
209///     wider packing      ->  throughput left unclaimed
210/// ```
211///
212/// # What the transcript binds
213///
214/// The evaluation point and the surviving claim are both bound here.
215///
216/// ```text
217///     r            absorbed before s_hat
218///     final_eval   absorbed after the sumcheck rounds
219/// ```
220///
221/// A caller composing this into a larger protocol need not bind `r` itself.
222///
223/// # Panics
224///
225/// Unless `r` names at least the `κ` packed variables.
226///
227/// Unless `packed` has exactly the `ℓ − κ` variables that leaves.
228pub fn prove_ring_switch<F, EF, Challenger>(
229    packed: &Poly<EF>,
230    r: &Point<EF>,
231    challenger: &mut Challenger,
232) -> (RingSwitchProof<F, EF>, Point<EF>, EF)
233where
234    F: TranscriptField,
235    EF: ExtensionField<F>,
236    Challenger: FieldChallenger<F> + GrindingChallenger<Witness = F>,
237{
238    // The description is derived from the point handed in, never from a proof.
239    //
240    // Every width the transcript declares follows from it and from the field pair:
241    //
242    //     point width      ->  the coordinates `r` names
243    //     tensor width     ->  the extension degree, squared
244    //     batching width   ->  log2 of the extension degree
245    let shape = RingSwitchShape::new(r.num_variables());
246    let ell_prime = shape.sumcheck_rounds::<F, EF>();
247    assert_eq!(
248        packed.num_variables(),
249        ell_prime,
250        "the packed polynomial must have the {ell_prime} variables the evaluation point leaves \
251         once the packed ones are removed"
252    );
253    let (r_high, _) = r.split_at(ell_prime);
254
255    // `ŝ` and the weight multilinear are both readings of the same `eq̃(r_high, ·)` table, so it
256    // is built once, shared, and freed before the rounds rather than held across them.
257    let eq = Poly::<EF>::new_from_point(r_high.as_slice(), EF::ONE);
258
259    // `ŝ` is a function of `r_high` alone.
260    //
261    // It is computed before the transcript needs it.
262    let s_hat = compute_s_hat_with_eq::<F, EF>(packed, &eq);
263
264    // Seeding folds the description's fingerprint into the borrowed sponge.
265    let mut transcript = RingSwitchProverTranscript::<Challenger, F, EF>::new(challenger, shape);
266
267    // One call binds the point, binds `ŝ`'s base coefficients, and draws `r''`.
268    //
269    // The point goes first.
270    //
271    // Every later check the verifier forms from `r` it forms locally.
272    //
273    // An unbound `r` would leave the proof replayable at a different point.
274    //
275    // What is bound for `ŝ` is the coefficients that cross the wire.
276    //
277    // Neither derived reading is bound directly.
278    //
279    // Both are still pinned: both are readings of that one step.
280    let r_batch = transcript.statement(r, s_hat.coefficients());
281
282    // `ℓ'` rounds on `h(X) = A(X) · t'(X)`, whose sum over the hypercube is the batched row
283    // reading of `ŝ`.
284    let weights = batched_weights_with_eq::<F, EF>(&eq, &r_batch);
285    drop(eq);
286    let poly = ProductPolynomial::new_unpacked(VariableOrder::Prefix, packed.clone(), weights);
287    let mut prover = SumcheckProver::new(poly, batch_rows::<F, EF>(&s_hat, &r_batch));
288    let mut sumcheck = SumcheckData::default();
289
290    // The rounds are a sub-protocol.
291    //
292    // They seed a transcript of their own from this sponge.
293    //
294    // The bracket records the delegation and hands the sponge over for it.
295    let r_prime = transcript.batched_sumcheck(|challenger| {
296        prover.compute_sumcheck_polynomials(&mut sumcheck, challenger, ell_prime, 0, None)
297    });
298
299    // After the last round the evaluation side has been folded to a single value, which is
300    // `t'(r')` — the same number a fresh `packed.eval_ext(&r_prime)` would recompute in
301    // another full pass over the packed evaluations.
302    let final_eval = prover.evals().as_slice()[0];
303
304    // The surviving claim is bound before the sponge goes back.
305    //
306    // A caller discharging it discharges the value this run produced.
307    transcript.surviving_claim(final_eval);
308    transcript.finish();
309
310    (
311        RingSwitchProof {
312            s_hat,
313            sumcheck,
314            final_eval,
315        },
316        r_prime,
317        final_eval,
318    )
319}
320
321/// Verifies the reduction of `claimed_sum = t(r)`.
322///
323/// # Returns
324///
325/// The surviving claim `t'(r') = s'`, as the pair `(r', s')`.
326///
327/// # Where the round count comes from
328///
329/// It is taken from `r`, the point the verifier owns.
330///
331/// A proof carrying a different number of rounds is rejected by the round replay.
332///
333/// # What the transcript binds
334///
335/// ```text
336///     r            absorbed before s_hat
337///     final_eval   absorbed after the rounds
338/// ```
339///
340/// Both land at the same two points the prover uses.
341///
342/// A proof is therefore usable only against the point it was produced for.
343///
344/// The claimed sum is bound indirectly, by the column check against `ŝ`.
345///
346/// Both structural rejections below happen before the challenger is touched.
347///
348/// A malformed proof cannot leave a half-advanced transcript.
349///
350/// # Panics
351///
352/// Unless `r` names at least the `κ` packed variables.
353///
354/// Everything read out of the proof is validated and reported as an error instead.
355///
356/// # Errors
357///
358/// - A malformed `ŝ`.
359/// - A non-empty proof-of-work witness list, since this reduction never grinds.
360/// - A claim that disagrees with `ŝ`'s columns.
361/// - A failed sumcheck round.
362/// - A final claim that does not close the sumcheck.
363///
364/// The point width is never among them.
365///
366/// The description is derived from `r` itself.
367///
368/// The two cannot disagree.
369pub fn verify_ring_switch<F, EF, Challenger>(
370    proof: &RingSwitchProof<F, EF>,
371    r: &Point<EF>,
372    claimed_sum: EF,
373    challenger: &mut Challenger,
374) -> Result<(Point<EF>, EF), RingSwitchError>
375where
376    F: TranscriptField,
377    EF: ExtensionField<F>,
378    Challenger: FieldChallenger<F> + GrindingChallenger<Witness = F>,
379{
380    // The description is derived from the point this side holds, never from the proof.
381    //
382    // Both sides therefore declare the same widths.
383    //
384    // A proof disagreeing with any of them is rejected.
385    //
386    // It is never left to desynchronise the sponge.
387    let shape = RingSwitchShape::new(r.num_variables());
388    let ell_prime = shape.sumcheck_rounds::<F, EF>();
389
390    // `columns` and `rows` index a `DIMENSION × DIMENSION` matrix, so a short `ŝ` must be
391    // rejected before either reading is taken.
392    if !proof.s_hat.is_well_formed() {
393        return Err(RingSwitchError::MalformedTensor {
394            expected: RingSwitchShape::tensor_coefficients::<F, EF>(),
395            actual: proof.s_hat.coefficients().len(),
396        });
397    }
398
399    // This reduction never grinds, so a proof carrying PoW witnesses is carrying data that is
400    // neither checked nor bound to the transcript; reject it outright instead of ignoring it.
401    if !proof.sumcheck.pow_witnesses.is_empty() {
402        return Err(RingSwitchError::NonEmptyPowWitnesses {
403            actual: proof.sumcheck.pow_witnesses.len(),
404        });
405    }
406
407    let (r_high, r_low) = r.split_at(ell_prime);
408
409    // Seeding folds the description's fingerprint into the borrowed sponge.
410    let mut transcript = RingSwitchVerifierTranscript::<Challenger, F, EF>::new(challenger, shape);
411
412    // One call absorbs the point, absorbs `ŝ`'s base coefficients, and redraws `r''`.
413    //
414    // A width the description does not allow is reported here.
415    //
416    // The failing step releases the driver's completeness check on its way out.
417    let r_batch = transcript.statement(r, proof.s_hat.coefficients())?;
418
419    // The incoming claim must be the `eq̃(·, r_low)`-combination of `ŝ`'s columns. This is the
420    // only use of `r_low`.
421    let eq_low = Poly::<EF>::new_from_point(r_low.as_slice(), EF::ONE);
422    let combined: EF = proof
423        .s_hat
424        .columns()
425        .iter()
426        .zip(eq_low.as_slice())
427        .map(|(&column, &weight)| column * weight)
428        .sum();
429    if combined != claimed_sum {
430        // Two described steps are still unplayed, and this rejection plays neither.
431        //
432        // Releasing the completeness check keeps this the only failure reported.
433        transcript.abort();
434        return Err(RingSwitchError::ClaimMismatch);
435    }
436
437    // The sumcheck's initial sum is derived from `ŝ`'s rows, never sent, which is what makes a
438    // dishonest `ŝ` catchable: the two readings are of the same coefficients.
439    let mut sum = batch_rows::<F, EF>(&proof.s_hat, &r_batch);
440
441    // The rounds are a sub-protocol.
442    //
443    // They seed a transcript of their own from this sponge.
444    //
445    // The bracket closes whatever the delegated replay returned.
446    //
447    // A rejection inside it leaves only the closing step unplayed.
448    let rounds = transcript.batched_sumcheck(|challenger| {
449        proof
450            .sumcheck
451            .verify_rounds(challenger, &mut sum, ell_prime, 0, Basis::Evaluation)
452    });
453    let r_prime = match rounds {
454        Ok(r_prime) => r_prime,
455        Err(error) => {
456            transcript.abort();
457            return Err(error.into());
458        }
459    };
460
461    // The surviving claim is absorbed where the prover absorbed it.
462    //
463    // The sponge is handed back in the state the prover left it in.
464    transcript.surviving_claim(proof.final_eval);
465    transcript.finish();
466
467    // The batched rows of the equality element are `A(r')`, so the sumcheck closes on
468    // `A(r') · t'(r')`.
469    let e = equality_element::<F, EF>(&r_high, &r_prime);
470    if sum != batch_rows::<F, EF>(&e, &r_batch) * proof.final_eval {
471        return Err(RingSwitchError::FinalCheck);
472    }
473
474    Ok((r_prime, proof.final_eval))
475}
476
477#[cfg(test)]
478mod tests {
479    use p3_challenger::CanSample;
480    use p3_field::PrimeCharacteristicRing;
481    use p3_multilinear_util::point::Point;
482    use proptest::prelude::*;
483    use rand::SeedableRng;
484    use rand::rngs::SmallRng;
485
486    use super::test_util::{Challenger, EF, F, base_poly, challenger};
487    use super::*;
488
489    /// Prover and verifier agree, and the surviving claim is true.
490    #[test]
491    fn the_reduction_round_trips() {
492        let ell = 7;
493        let t = base_poly(ell, 13);
494        let packed = pack::<F, EF>(t.clone());
495        let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(14), ell);
496        let s = t.eval_base(&r);
497
498        let mut p_chal = challenger();
499        let (proof, r_prime_p, s_prime_p) = prove_ring_switch::<F, EF, _>(&packed, &r, &mut p_chal);
500
501        let mut v_chal = challenger();
502        let (r_prime_v, s_prime_v) =
503            verify_ring_switch::<F, EF, _>(&proof, &r, s, &mut v_chal).unwrap();
504
505        assert_eq!(r_prime_p, r_prime_v);
506        assert_eq!(s_prime_p, s_prime_v);
507        // The surviving claim is the truth about the committed polynomial.
508        assert_eq!(s_prime_v, packed.eval_ext::<F>(&r_prime_v));
509    }
510
511    proptest! {
512        // Sixteen cases keep the suite fast.
513        //
514        // Each one runs a full reduction on both sides.
515        #![proptest_config(ProptestConfig { cases: 16, ..ProptestConfig::default() })]
516
517        #[test]
518        fn the_reduction_round_trips_over_random_inputs(
519            ell in 5usize..=8,
520            poly_seed in any::<u64>(),
521            point_seed in any::<u64>(),
522        ) {
523            // Completeness: an honest run replays, and the claim it leaves is the truth.
524            //
525            // Fixture state: 4 packed variables.
526            //
527            // `ell` between 5 and 8 then leaves 1 to 4 rounds.
528            //
529            //     ell = 5  ->  32 base evaluations  ->  2 packed elements  ->  1 round
530            //     ell = 8  ->  256 base evaluations ->  16 packed elements ->  4 rounds
531            let t = base_poly(ell, poly_seed);
532            let packed = pack::<F, EF>(t.clone());
533
534            // The point is the verifier's own input, and the claim is the truth at it.
535            let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(point_seed), ell);
536            let s = t.eval_base(&r);
537
538            // Both sides start from the same fresh sponge, as a composing protocol would.
539            let mut p_chal = challenger();
540            let (proof, r_prime_p, s_prime_p) =
541                prove_ring_switch::<F, EF, _>(&packed, &r, &mut p_chal);
542
543            let mut v_chal = challenger();
544            let (r_prime_v, s_prime_v) =
545                verify_ring_switch::<F, EF, _>(&proof, &r, s, &mut v_chal)
546                    .expect("an honest reduction must verify");
547
548            // The two sides walked one description.
549            //
550            // They landed on one surviving claim.
551            prop_assert_eq!(&r_prime_p, &r_prime_v);
552            prop_assert_eq!(s_prime_p, s_prime_v);
553
554            // The surviving claim is the truth about the committed polynomial.
555            prop_assert_eq!(s_prime_v, packed.eval_ext::<F>(&r_prime_v));
556
557            // The sponge is handed back in one state.
558            //
559            // The caller stays in step afterwards.
560            prop_assert_eq!(
561                CanSample::<F>::sample(&mut p_chal),
562                CanSample::<F>::sample(&mut v_chal),
563            );
564        }
565    }
566
567    /// A wrong claimed value is rejected at the column check.
568    #[test]
569    fn a_wrong_claim_is_rejected() {
570        let ell = 6;
571        let t = base_poly(ell, 15);
572        let packed = pack::<F, EF>(t.clone());
573        let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(16), ell);
574        let s = t.eval_base(&r);
575
576        let mut p_chal = challenger();
577        let (proof, _, _) = prove_ring_switch::<F, EF, _>(&packed, &r, &mut p_chal);
578
579        let mut v_chal = challenger();
580        assert_eq!(
581            verify_ring_switch::<F, EF, _>(&proof, &r, s + EF::ONE, &mut v_chal),
582            Err(RingSwitchError::ClaimMismatch)
583        );
584    }
585
586    /// A `ŝ` perturbed after proving is rejected. The claim handed to the verifier is the
587    /// perturbed element's own column reading, formed the way the verifier forms it, so the
588    /// column check passes by construction; the rejection comes from the final check instead.
589    ///
590    /// This does not, on its own, demonstrate that the reduction catches a prover who tampers
591    /// with `ŝ` and adapts the rest of the proof to it: perturbing `ŝ` after the honest sumcheck
592    /// was already recorded moves the verifier's derived `r''` and every later round challenge
593    /// away from the values those recorded messages were generated under, so verification fails
594    /// here even in a hypothetical design where the sumcheck's initial sum were prover-supplied.
595    /// An adaptive prover — one who tampers with `ŝ` first and then runs the genuine sumcheck
596    /// from the sum that tampered `ŝ` implies — is not caught by this check; see the module
597    /// docs for what does and does not stop that prover.
598    #[test]
599    fn a_perturbed_tensor_element_is_rejected() {
600        let ell = 6;
601        let t = base_poly(ell, 17);
602        let packed = pack::<F, EF>(t);
603        let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(18), ell);
604
605        let mut p_chal = challenger();
606        let (mut proof, _, _) = prove_ring_switch::<F, EF, _>(&packed, &r, &mut p_chal);
607        proof.s_hat.perturb_for_test(0, F::ONE);
608
609        let (_, r_low) = r.split_at(ell - packed_vars::<F, EF>());
610        let eq_low = Poly::<EF>::new_from_point(r_low.as_slice(), EF::ONE);
611        let tampered_claim: EF = proof
612            .s_hat
613            .columns()
614            .iter()
615            .zip(eq_low.as_slice())
616            .map(|(&column, &weight)| column * weight)
617            .sum();
618
619        let mut v_chal = challenger();
620        assert_eq!(
621            verify_ring_switch::<F, EF, _>(&proof, &r, tampered_claim, &mut v_chal),
622            Err(RingSwitchError::FinalCheck)
623        );
624    }
625
626    /// The surviving claim is pinned by the final check: perturbing `s'` on an otherwise
627    /// honest proof is rejected.
628    #[test]
629    fn a_perturbed_final_claim_is_rejected() {
630        let ell = 6;
631        let t = base_poly(ell, 33);
632        let packed = pack::<F, EF>(t.clone());
633        let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(34), ell);
634        let s = t.eval_base(&r);
635
636        let mut p_chal = challenger();
637        let (mut proof, _, _) = prove_ring_switch::<F, EF, _>(&packed, &r, &mut p_chal);
638        proof.final_eval += EF::ONE;
639
640        let mut v_chal = challenger();
641        assert_eq!(
642            verify_ring_switch::<F, EF, _>(&proof, &r, s, &mut v_chal),
643            Err(RingSwitchError::FinalCheck)
644        );
645    }
646
647    /// Perturbing one round message desynchronises every challenge sampled after it, so the
648    /// proof fails at the final check rather than being caught round-by-round.
649    #[test]
650    fn a_perturbed_round_message_is_rejected() {
651        let ell = 6;
652        let t = base_poly(ell, 35);
653        let packed = pack::<F, EF>(t.clone());
654        let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(36), ell);
655        let s = t.eval_base(&r);
656
657        let mut p_chal = challenger();
658        let (mut proof, _, _) = prove_ring_switch::<F, EF, _>(&packed, &r, &mut p_chal);
659        proof.sumcheck.polynomial_evaluations[0][0] += EF::ONE;
660
661        let mut v_chal = challenger();
662        assert_eq!(
663            verify_ring_switch::<F, EF, _>(&proof, &r, s, &mut v_chal),
664            Err(RingSwitchError::FinalCheck)
665        );
666    }
667
668    /// A proof is bound to the evaluation point it was produced for. `ŝ` is a function of
669    /// `r_high` alone, so without `r` in the transcript the same proof would verify verbatim
670    /// at every `r_low`, against the honest claim at each of those points — a true statement
671    /// each time, but it makes the proof a transferable object rather than one tied to a
672    /// point. Observing `r` is what rejects it. The column check still passes here, since the
673    /// claim supplied really is `ŝ`'s column reading at the new `r_low`; the rejection comes
674    /// from the challenges the observed `r` moved.
675    #[test]
676    fn a_proof_does_not_verify_against_a_different_low_point() {
677        let ell = 6;
678        let kappa = packed_vars::<F, EF>();
679        let t = base_poly(ell, 37);
680        let packed = pack::<F, EF>(t.clone());
681        let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(38), ell);
682
683        let mut p_chal = challenger();
684        let (proof, _, _) = prove_ring_switch::<F, EF, _>(&packed, &r, &mut p_chal);
685
686        // Same `r_high`, one coordinate of `r_low` moved, with the honest claim there.
687        let (r_high, r_low) = r.split_at(ell - kappa);
688        let mut moved = r_low.as_slice().to_vec();
689        moved[0] += EF::ONE;
690        let mut coords = r_high.as_slice().to_vec();
691        coords.extend_from_slice(&moved);
692        let other_r = Point::new(coords);
693        let other_s = t.eval_base(&other_r);
694
695        let mut v_chal = challenger();
696        assert_eq!(
697            verify_ring_switch::<F, EF, _>(&proof, &other_r, other_s, &mut v_chal),
698            Err(RingSwitchError::FinalCheck)
699        );
700    }
701
702    /// The `r_high` half is bound too, and this is the arm that matters. The verifier's only
703    /// `r_high`-dependent test is `A(r')` in the final check; moving one coordinate of `r_high`
704    /// by `δ` changes it by an `F`-linear function of `δ`, and wherever that function is
705    /// singular an unbound `r` would let a prover pick a nonzero `δ` from its kernel and have a
706    /// false input claim accepted alongside a *true* surviving claim — the one direction a
707    /// caller discharging `s'` against a commitment cannot catch. Here the claim is `ŝ`'s
708    /// column reading at the unchanged `r_low`, so the column arm passes by construction and
709    /// only the transcript can reject.
710    #[test]
711    fn a_proof_does_not_verify_against_a_different_high_point() {
712        let ell = 6;
713        let kappa = packed_vars::<F, EF>();
714        let t = base_poly(ell, 45);
715        let packed = pack::<F, EF>(t.clone());
716        let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(46), ell);
717        let s = t.eval_base(&r);
718
719        let mut p_chal = challenger();
720        let (proof, _, _) = prove_ring_switch::<F, EF, _>(&packed, &r, &mut p_chal);
721
722        let (r_high, r_low) = r.split_at(ell - kappa);
723        let mut moved = r_high.as_slice().to_vec();
724        moved[0] += EF::ONE;
725        moved.extend_from_slice(r_low.as_slice());
726        let other_r = Point::new(moved);
727
728        let mut v_chal = challenger();
729        assert_eq!(
730            verify_ring_switch::<F, EF, _>(&proof, &other_r, s, &mut v_chal),
731            Err(RingSwitchError::FinalCheck)
732        );
733    }
734
735    /// This reduction never grinds, so a proof carrying PoW witnesses must be rejected rather
736    /// than accepted with junk data nothing checks.
737    #[test]
738    fn a_proof_with_pow_witnesses_is_rejected() {
739        let ell = 6;
740        let t = base_poly(ell, 40);
741        let packed = pack::<F, EF>(t.clone());
742        let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(41), ell);
743        let s = t.eval_base(&r);
744
745        let mut p_chal = challenger();
746        let (mut proof, _, _) = prove_ring_switch::<F, EF, _>(&packed, &r, &mut p_chal);
747        proof.sumcheck.pow_witnesses.push(F::ONE);
748
749        let mut v_chal = challenger();
750        assert_eq!(
751            verify_ring_switch::<F, EF, _>(&proof, &r, s, &mut v_chal),
752            Err(RingSwitchError::NonEmptyPowWitnesses { actual: 1 })
753        );
754    }
755
756    /// An adaptive prover who tampers with `ŝ` before proving, then runs a genuine sumcheck from
757    /// the sum that tampered `ŝ` implies, is accepted by `verify_ring_switch` — with a surviving
758    /// claim that is false. This is the reduction working as documented, not a soundness gap: a
759    /// false input claim survives as a false surviving claim, caught only when the caller
760    /// discharges it against a commitment to the real packed polynomial, which this test does
761    /// not do. Contrast with `a_perturbed_tensor_element_is_rejected`, which perturbs `ŝ` after
762    /// proving and is rejected for an unrelated reason — transcript desynchronisation.
763    #[test]
764    fn an_adaptive_cheat_on_s_hat_is_accepted_with_a_false_surviving_claim() {
765        let ell = 6;
766        let t = base_poly(ell, 43);
767        let packed = pack::<F, EF>(t);
768        let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(44), ell);
769        let kappa = packed_vars::<F, EF>();
770        let ell_prime = ell - kappa;
771        let (r_high, r_low) = r.split_at(ell_prime);
772
773        // Tamper with `ŝ` before it ever reaches the transcript, unlike the post-hoc
774        // perturbation above.
775        let mut s_hat = compute_s_hat::<F, EF>(&packed, &r_high);
776        s_hat.perturb_for_test(0, F::ONE);
777
778        // The claim that makes the column check pass for the tampered element, formed the way
779        // the verifier forms it.
780        let eq_low = Poly::<EF>::new_from_point(r_low.as_slice(), EF::ONE);
781        let claimed_sum: EF = s_hat
782            .columns()
783            .iter()
784            .zip(eq_low.as_slice())
785            .map(|(&column, &weight)| column * weight)
786            .sum();
787
788        // Driven through the same transcript the honest prover uses.
789        //
790        // The tampered element reaches the sponge where an honest one would.
791        let mut chal = challenger();
792        let shape = RingSwitchShape::new(ell);
793        let mut transcript = RingSwitchProverTranscript::<Challenger, F, EF>::new(&mut chal, shape);
794        let r_batch = transcript.statement(&r, s_hat.coefficients());
795
796        // The genuine sumcheck's initial sum, derived from the tampered `ŝ` exactly as the
797        // verifier will derive it — not the true dot product of any real polynomial.
798        let shifted_sum = batch_rows::<F, EF>(&s_hat, &r_batch);
799
800        // A product polynomial that actually dots to `shifted_sum`: nothing downstream of this
801        // point checks that the evaluations are `packed`'s, only that the recorded rounds are
802        // internally consistent, so perturbing one evaluation is enough.
803        let weights = batched_weights::<F, EF>(&r_high, &r_batch);
804        let honest_sum = batch_rows::<F, EF>(&compute_s_hat::<F, EF>(&packed, &r_high), &r_batch);
805        let index = weights
806            .as_slice()
807            .iter()
808            .position(|&w| w != EF::ZERO)
809            .expect("a generic r_high, r_batch pair leaves no weight identically zero");
810        let mut fake_evals = packed.as_slice().to_vec();
811        fake_evals[index] += (shifted_sum - honest_sum) * weights.as_slice()[index].inverse();
812        let fake_packed = Poly::new(fake_evals);
813
814        let poly = ProductPolynomial::new_unpacked(VariableOrder::Prefix, fake_packed, weights);
815        let mut prover = SumcheckProver::new(poly, shifted_sum);
816        let mut sumcheck = SumcheckData::default();
817        let r_prime = transcript.batched_sumcheck(|challenger| {
818            prover.compute_sumcheck_polynomials(&mut sumcheck, challenger, ell_prime, 0, None)
819        });
820        let sum_final = prover.claimed_sum();
821
822        // `final_eval` set to whatever the final identity needs, computed the same way the
823        // verifier computes it.
824        let e = equality_element::<F, EF>(&r_high, &r_prime);
825        let a_r_prime = batch_rows::<F, EF>(&e, &r_batch);
826        let final_eval = sum_final * a_r_prime.inverse();
827
828        // The description is only fully played once the surviving claim is bound.
829        transcript.surviving_claim(final_eval);
830        transcript.finish();
831
832        let proof = RingSwitchProof {
833            s_hat,
834            sumcheck,
835            final_eval,
836        };
837
838        let mut v_chal = challenger();
839        let (r_prime_v, s_prime_v) =
840            verify_ring_switch::<F, EF, _>(&proof, &r, claimed_sum, &mut v_chal).unwrap();
841
842        assert_eq!(r_prime_v, r_prime);
843        assert_eq!(s_prime_v, final_eval);
844        assert_ne!(s_prime_v, packed.eval_ext::<F>(&r_prime_v));
845    }
846
847    /// The evaluation point must name at least the packed variables, or the split that
848    /// separates `r_high` from `r_low` is not defined.
849    #[test]
850    #[should_panic(expected = "the evaluation point must name at least the 4 packed variables")]
851    fn prove_rejects_a_point_shorter_than_the_packed_variables() {
852        let t = base_poly(6, 19);
853        let packed = pack::<F, EF>(t);
854        let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(20), 3);
855        let mut chal = challenger();
856        let _ = prove_ring_switch::<F, EF, _>(&packed, &r, &mut chal);
857    }
858
859    /// A packed polynomial of the wrong arity must panic rather than be zipped against a
860    /// weight table of a different length.
861    #[test]
862    #[should_panic(expected = "the packed polynomial must have the 2 variables")]
863    fn prove_rejects_a_packed_polynomial_of_the_wrong_arity() {
864        let t = base_poly(7, 21);
865        let packed = pack::<F, EF>(t);
866        let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(22), 6);
867        let mut chal = challenger();
868        let _ = prove_ring_switch::<F, EF, _>(&packed, &r, &mut chal);
869    }
870
871    /// The verifier's own point is subject to the same requirement.
872    #[test]
873    #[should_panic(expected = "the evaluation point must name at least the 4 packed variables")]
874    fn verify_rejects_a_point_shorter_than_the_packed_variables() {
875        let ell = 6;
876        let t = base_poly(ell, 26);
877        let packed = pack::<F, EF>(t);
878        let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(27), ell);
879
880        let mut p_chal = challenger();
881        let (proof, _, _) = prove_ring_switch::<F, EF, _>(&packed, &r, &mut p_chal);
882
883        let short = Point::<EF>::rand(&mut SmallRng::seed_from_u64(28), 3);
884        let mut v_chal = challenger();
885        let _ = verify_ring_switch::<F, EF, _>(&proof, &short, EF::ZERO, &mut v_chal);
886    }
887
888    /// The round count is taken from `r`, so a proof carrying a different one is rejected
889    /// rather than silently desynchronising the transcript.
890    #[test]
891    fn verify_rejects_a_proof_with_the_wrong_round_count() {
892        let ell = 6;
893        let t = base_poly(ell, 29);
894        let packed = pack::<F, EF>(t.clone());
895        let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(30), ell);
896        let s = t.eval_base(&r);
897
898        let mut p_chal = challenger();
899        let (mut proof, _, _) = prove_ring_switch::<F, EF, _>(&packed, &r, &mut p_chal);
900        proof.sumcheck.polynomial_evaluations.pop();
901
902        let mut v_chal = challenger();
903        assert_eq!(
904            verify_ring_switch::<F, EF, _>(&proof, &r, s, &mut v_chal),
905            Err(RingSwitchError::Sumcheck(
906                SumcheckError::RoundCountMismatch {
907                    expected: ell - 4,
908                    actual: ell - 5,
909                }
910            ))
911        );
912    }
913
914    /// A `ŝ` of the wrong size is reported rather than read out of range: both of its
915    /// readings index a `DIMENSION × DIMENSION` matrix.
916    #[test]
917    fn verify_rejects_a_malformed_tensor() {
918        let ell = 6;
919        let t = base_poly(ell, 31);
920        let packed = pack::<F, EF>(t.clone());
921        let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(32), ell);
922        let s = t.eval_base(&r);
923
924        let mut p_chal = challenger();
925        let (mut proof, _, _) = prove_ring_switch::<F, EF, _>(&packed, &r, &mut p_chal);
926        proof.s_hat.truncate_for_test(1);
927
928        let dimension = TensorAlgebra::<F, EF>::DIMENSION;
929        let mut v_chal = challenger();
930        assert_eq!(
931            verify_ring_switch::<F, EF, _>(&proof, &r, s, &mut v_chal),
932            Err(RingSwitchError::MalformedTensor {
933                expected: dimension * dimension,
934                actual: 1,
935            })
936        );
937    }
938}