Skip to content

zorch.sumcheck.prover

Sumcheck prover rounds: the product StandardRound, its compressed-wire sibling CompressedProductRound, and the summand seam they read.

A sumcheck round splits each MLE on the current variable, sends the round polynomial sampled over an EvalDomain, then folds every MLE at the verifier's challenge (P0 + r*(P1 - P0)). Split and fold are summand-independent, and the round poly is built by zorch.sumcheck.domain.summand_evals over the stacked (m, N) state -- generic over BOTH the summand (SumcheckSummand._combine: product, LogUp, ...) and the sampling domain. StandardRound (here) is the plain materialized round: it holds the full factor table and does split -> sample -> combine -> sum, the linear-time reference the memory-optimized siblings (sqrt_space.SqrtSpaceRound, eq.SmallValueRound) specialize. Its summand defaults to the product (ProductSummand) and its domain to the natural {0..degree} evals.

The dense round binds MSB-first (domain.fold); the jagged engines bind LSB-first (domain.fold(..., msb=False)). The split/fold primitives and the round-poly builder (summand_evals) live in zorch.sumcheck.domain; the verifier dual in zorch.sumcheck.verifier.

Rounds run under the scheme-agnostic zorch.prove.fold_rounds host loop (any Round, any message shape) -- one round per variable, folding the state down each step. SumcheckSummand (degree + _combine) is the summand seam the round-poly builder reads, so the product ProductSummand and the LogUp logup_gkr.prover.LogupSummand drive it interchangeably.

FoldingClaim dataclass

What a sumcheck round threads: the engine's folding state and the claim.

state is whatever the engine folds — stacked factor tables, or the √-space engine's deferred (factors, eq) pair. The claim rides beside it because a round's challenge is derived rather than received, and the point and running value it builds live nowhere else.

Source code in zorch/sumcheck/prover.py
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
@partial(
    frx.tree_util.register_dataclass,
    data_fields=["state", "claim"],
    meta_fields=[],
)
@dataclass(frozen=True)
class FoldingClaim:
    """What a sumcheck round threads: the engine's folding state and the claim.

    `state` is whatever the engine folds — stacked factor tables, or the √-space
    engine's deferred `(factors, eq)` pair. The claim rides beside it because a
    round's challenge is derived rather than received, and the point and running
    value it builds live nowhere else.
    """

    state: Any
    claim: RunningClaim

    def advance(self, state: Any, reduced: Array, challenge: Array) -> FoldingClaim:
        """The folded state beside the claim this round reduced it to."""
        return FoldingClaim(state, self.claim.bind(reduced, challenge))

advance

advance(
    state: Any, reduced: Array, challenge: Array
) -> FoldingClaim

The folded state beside the claim this round reduced it to.

Source code in zorch/sumcheck/prover.py
71
72
73
def advance(self, state: Any, reduced: Array, challenge: Array) -> FoldingClaim:
    """The folded state beside the claim this round reduced it to."""
    return FoldingClaim(state, self.claim.bind(reduced, challenge))

ProductSummand dataclass

The product sumcheck summand s = Σ_x Πₖ Pₖ(x): the combine math alone, no round machinery. The default summand of StandardRound; the eq / sqrt_space engines hold one to weight their eq-product. Pairs with the LogUp logup_gkr.prover.LogupSummand under the SumcheckSummand seam.

Source code in zorch/sumcheck/prover.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
@dataclass(frozen=True)
class ProductSummand:
    """The product sumcheck summand `s = Σ_x Πₖ Pₖ(x)`: the combine math alone, no
    round machinery. The default summand of `StandardRound`; the `eq` / `sqrt_space`
    engines hold one to weight their eq-product. Pairs with the LogUp
    `logup_gkr.prover.LogupSummand` under the `SumcheckSummand` seam."""

    degree: int

    def __post_init__(self) -> None:
        if self.degree < 1:
            raise ValueError("degree must be >= 1")

    def combine_scalars(self) -> tuple[Array, ...]:
        """No loop-invariant scalars: the product reads only its factors."""
        return ()

    def combine(self, scalars: Sequence[Array], *factors: Array) -> Array:
        """Product `Πₖ fₖ` (the scalar-explicit seam; product takes no scalars).
        Single source of the combine math: `_combine` and any marked path route
        here, so they cannot drift."""
        del scalars  # product has none
        return reduce(operator.mul, factors)

    def _combine(self, *factors: Array) -> Array:
        """Product bound to its (empty) scalars; the round-poly builder reads only
        this, so callers stay summand-generic."""
        return self.combine(self.combine_scalars(), *factors)

combine_scalars

combine_scalars() -> tuple[Array, ...]

No loop-invariant scalars: the product reads only its factors.

Source code in zorch/sumcheck/prover.py
110
111
112
def combine_scalars(self) -> tuple[Array, ...]:
    """No loop-invariant scalars: the product reads only its factors."""
    return ()

combine

combine(scalars: Sequence[Array], *factors: Array) -> Array

Product Πₖ fₖ (the scalar-explicit seam; product takes no scalars). Single source of the combine math: _combine and any marked path route here, so they cannot drift.

Source code in zorch/sumcheck/prover.py
114
115
116
117
118
119
def combine(self, scalars: Sequence[Array], *factors: Array) -> Array:
    """Product `Πₖ fₖ` (the scalar-explicit seam; product takes no scalars).
    Single source of the combine math: `_combine` and any marked path route
    here, so they cannot drift."""
    del scalars  # product has none
    return reduce(operator.mul, factors)

StandardRound

Bases: ProverRound[Any, Array, TranscriptT], Generic[TranscriptT]

The plain materialized sumcheck round: send the summand's round poly over domain, sample the challenge, fold the stacked (m, N) state. Bound to a SumcheckSummand (product by default) and an EvalDomain (the natural {0..degree} evals when None). This is the linear-time reference the √-space / eq engines specialize; driven by fold_rounds.

challenges names the field challenges are drawn in. Naming the transcript's own field is the one-squeeze schedule; an extension policy lets a tail whose earlier round bound in that extension continue folding in the same field.

Source code in zorch/sumcheck/prover.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
class StandardRound(ProverRound[Any, Array, TranscriptT], Generic[TranscriptT]):
    """The plain materialized sumcheck round: send the summand's round poly over
    `domain`, sample the challenge, fold the stacked `(m, N)` state. Bound to a
    `SumcheckSummand` (product by default) and an `EvalDomain` (the natural
    {0..degree} evals when None). This is the linear-time reference the √-space /
    eq engines specialize; driven by `fold_rounds`.

    `challenges` names the field challenges are drawn in. Naming the
    transcript's own field is the one-squeeze schedule; an extension policy lets
    a tail whose earlier round bound in that extension continue folding in the
    same field."""

    def __init__(
        self,
        summand: SumcheckSummand,
        domain: EvalDomain | None = None,
        *,
        challenges: ChallengePolicy,
    ) -> None:
        self.summand = summand
        self.domain = domain
        self.challenges = challenges

    def _round_poly(self, folded: Array) -> Array:
        """s sampled at `domain` (the natural {0..degree} evals by default), shape
        (num_points, *batch): one batched `summand_evals` reduction over the stacked
        factors, so it lowers toward a single reduction kernel."""
        domain = self.domain or natural_domain(self.summand.degree, folded.dtype)
        return summand_evals(folded, self.summand._combine, domain)

    def __call__(
        self, carry: FoldingClaim, transcript: TranscriptT
    ) -> tuple[FoldingClaim, TranscriptT, Array]:
        domain = self.domain or natural_domain(self.summand.degree, carry.state.dtype)
        msg = summand_evals(carry.state, self.summand._combine, domain)
        transcript, r = self.challenges.observe_and_sample(transcript, msg)
        if self.domain is None:
            return (
                _fold_and_advance(carry, msg, r, self.summand.degree),
                transcript,
                msg,
            )
        # The default domain reduces by direct Lagrange evaluation — the same
        # arithmetic `verifier.SumcheckRound` does. Going through the domain's
        # value→coefficient map instead would rebuild an (n, n) Lagrange matrix
        # every round, which costs more than the fold it rides along with.
        reduced, _ = reduce_domain(carry.claim.value, msg, r, domain)
        return carry.advance(fold(carry.state, r), reduced, r), transcript, msg

CompressedProductRound

Bases: ProverRound[Any, Array, TranscriptT], Generic[TranscriptT]

Two-factor product round with the compressed coefficient wire: the message is [c_0, c_2] — the degree-2 round polynomial's constant and leading coefficients — and the linear coefficient stays off the wire (the verifier dual, verifier.CompressedCoeffsSumcheckRound, reconstructs it from the running claim via s(0) + s(1) = claim). Split and fold match StandardRound(ProductSummand(2)) exactly (the MSB variable binds); only the message form differs, so a scheme whose wire fixes this form swaps rounds without touching the fold. c_2 = Σ (P1_f - P0_f)·(P1_b - P0_b) is the honest leading coefficient in any characteristic; over char 2 it coincides with the (P0 + P1) products some wire specs write it as.

Source code in zorch/sumcheck/prover.py
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
class CompressedProductRound(
    ProverRound[Any, Array, TranscriptT], Generic[TranscriptT]
):
    """Two-factor product round with the compressed coefficient wire: the message
    is `[c_0, c_2]` — the degree-2 round polynomial's constant and leading
    coefficients — and the linear coefficient stays off the wire (the verifier
    dual, `verifier.CompressedCoeffsSumcheckRound`, reconstructs it from the
    running claim via `s(0) + s(1) = claim`). Split and fold match
    `StandardRound(ProductSummand(2))` exactly (the MSB variable binds); only the
    message form differs, so a scheme whose wire fixes this form swaps rounds
    without touching the fold. `c_2 = Σ (P1_f - P0_f)·(P1_b - P0_b)` is the honest
    leading coefficient in any characteristic; over char 2 it coincides with the
    `(P0 + P1)` products some wire specs write it as."""

    def __init__(self, challenges: ChallengePolicy) -> None:
        self.challenges = challenges

    def _round_poly(self, folded: Array) -> Array:
        """`[c_0, c_2]` of `s(X) = Σ_x' f(X, x')·b(X, x')`, shape (2, *batch):
        one stacked element-wise product per coefficient, then the single
        inherent Σ. The two stacked factors split MSB-first into (f0, f1), (b0, b1)."""
        if folded.shape[0] != 2:
            raise ValueError(
                f"compressed product round takes exactly 2 factors, got "
                f"{folded.shape[0]}"
            )
        (f0, f1), (b0, b1) = fnp.reshape(folded, (2, 2, -1))
        return fnp.sum(fnp.stack([f0 * b0, (f1 - f0) * (b1 - b0)]), axis=-1)

    def __call__(
        self, carry: FoldingClaim, transcript: TranscriptT
    ) -> tuple[FoldingClaim, TranscriptT, Array]:
        msg = self._round_poly(carry.state)
        transcript, r = self.challenges.observe_and_sample(transcript, msg)
        reduced, _ = reduce_compressed(carry.claim.value, msg, r)
        return carry.advance(fold(carry.state, r), reduced, r), transcript, msg

RoundMsg dataclass

One per-variable sumcheck round's message: the round polynomial sent plus the Fiat-Shamir challenge it induced — the round_polys form the proof and the challenges the evaluation point. The challenge is re-derivable from round_poly, so it never goes on the wire; it rides here only to spare the prover a transcript replay. LogupSumcheckRound.__call__ emits one per round for the fold_rounds driver. Registered as a pytree because a downstream lax.scan stacks it as its output — a scan output leaf must be a valid JAX type.

Source code in zorch/sumcheck/prover.py
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
@partial(
    frx.tree_util.register_dataclass,
    data_fields=["round_poly", "challenge"],
    meta_fields=[],
)
@dataclass(frozen=True)
class RoundMsg:
    """One per-variable sumcheck round's message: the round polynomial sent plus
    the Fiat-Shamir challenge it induced — the round_polys form the proof and the
    challenges the evaluation point. The challenge is re-derivable from `round_poly`,
    so it never goes on the wire; it rides here only to spare the prover a transcript
    replay. `LogupSumcheckRound.__call__` emits one per round for the `fold_rounds`
    driver. Registered as a pytree because a downstream `lax.scan` stacks it as its
    output — a scan output leaf must be a valid JAX type."""

    round_poly: Array
    challenge: Array

SumcheckSummand

Bases: Protocol

The summand seam a per-variable round exposes: the round-poly degree, and _combine — the summand over the lifted factors. The round-poly builder reads only this, so one builder serves every sumcheck; ProductSummand (product) and logup_gkr.prover.LogupSummand (LogUp) both satisfy it, and the host-loop engines (sqrt_space / eq) pass a summand to summand_evals.

degree is a read-only property here so a frozen-dataclass field (product) and a @property (LogUp) both match — a plain degree: int would demand a settable attribute that neither provides.

combine is the scalar-explicit form of the summand and combine_scalars the loop-invariant scalars it reads (LogUp's λ; empty for product), so they bind once rather than per round.

Source code in zorch/sumcheck/prover.py
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
class SumcheckSummand(Protocol):
    """The summand seam a per-variable round exposes: the round-poly `degree`, and
    `_combine` — the summand over the lifted factors. The round-poly builder reads
    only this, so one builder serves every sumcheck; `ProductSummand` (product) and
    `logup_gkr.prover.LogupSummand` (LogUp) both satisfy it, and the host-loop
    engines (sqrt_space / eq) pass a summand to `summand_evals`.

    `degree` is a read-only property here so a frozen-dataclass field (product)
    and a `@property` (LogUp) both match — a plain `degree: int` would demand a
    settable attribute that neither provides.

    `combine` is the scalar-explicit form of the summand and `combine_scalars` the
    loop-invariant scalars it reads (LogUp's λ; empty for product), so they bind once
    rather than per round."""

    @property
    def degree(self) -> int: ...

    def combine_scalars(self) -> tuple[Array, ...]: ...

    def combine(self, scalars: Sequence[Array], *factors: Array) -> Array: ...

    def _combine(self, *factors: Array) -> Array: ...

initial_claim

initial_claim(
    state: Any, value: Array, rounds: int
) -> FoldingClaim

Start a fold from state with nothing yet bound into value.

Source code in zorch/sumcheck/prover.py
86
87
88
89
90
def initial_claim(state: Any, value: Array, rounds: int) -> FoldingClaim:
    """Start a fold from `state` with nothing yet bound into `value`."""
    return FoldingClaim(
        state, RunningClaim(value, fnp.zeros((rounds,), value.dtype), fnp.int32(0))
    )