openvm_recursion_circuit/gkr/sumcheck/
air.rs

1use core::borrow::Borrow;
2
3use openvm_circuit_primitives::{
4    utils::assert_array_eq, ColumnsAir, StructReflection, StructReflectionHelper, SubAir,
5};
6use openvm_recursion_circuit_derive::AlignedBorrow;
7use openvm_stark_backend::{
8    interaction::InteractionBuilder, BaseAirWithPublicValues, PartitionedBaseAir,
9};
10use openvm_stark_sdk::config::baby_bear_poseidon2::D_EF;
11use p3_air::{Air, AirBuilder, BaseAir};
12use p3_field::{extension::BinomiallyExtendable, Field, PrimeCharacteristicRing};
13use p3_matrix::Matrix;
14
15use crate::{
16    bus::{TranscriptBus, XiRandomnessBus, XiRandomnessMessage},
17    gkr::bus::{
18        GkrSumcheckChallengeBus, GkrSumcheckChallengeMessage, GkrSumcheckInputBus,
19        GkrSumcheckInputMessage, GkrSumcheckOutputBus, GkrSumcheckOutputMessage,
20    },
21    subairs::nested_for_loop::{NestedForLoopIoCols, NestedForLoopSubAir},
22    utils::{
23        assert_one_ext, ext_field_add, ext_field_multiply, ext_field_multiply_scalar,
24        ext_field_one_minus, ext_field_subtract,
25    },
26};
27
28#[repr(C)]
29#[derive(AlignedBorrow, Debug, StructReflection)]
30pub struct GkrLayerSumcheckCols<T> {
31    /// Whether the current row is enabled (i.e. not padding)
32    pub is_enabled: T,
33    pub proof_idx: T,
34    pub layer_idx: T,
35    pub is_proof_start: T,
36    pub is_first_round: T,
37
38    /// An enabled row which is not involved in any interactions
39    /// but should satisfy air constraints
40    pub is_dummy: T,
41
42    pub is_last_layer: T,
43
44    /// Sumcheck sub-round index within this layer_idx (0..layer_idx-1)
45    // perf(ayush): can probably remove round if XiRandomnessMessage takes tidx instead
46    pub round: T,
47
48    /// Transcript index
49    pub tidx: T,
50
51    /// s(1) in extension field
52    pub ev1: [T; D_EF],
53    /// s(2) in extension field
54    pub ev2: [T; D_EF],
55    /// s(3) in extension field
56    pub ev3: [T; D_EF],
57
58    /// The claim coming into this sub-round (either from previous sub-round or initial)
59    pub claim_in: [T; D_EF],
60    /// The claim going out of this sub-round (result of cubic interpolation)
61    pub claim_out: [T; D_EF],
62
63    /// Component `round` of the original point ΞΎ^{(j-1)}
64    /// (corresponding to `gkr_r[round]`)
65    pub prev_challenge: [T; D_EF],
66    /// The sampled challenge for this sub-round (corresponds to `ri`)
67    pub challenge: [T; D_EF],
68
69    /// The eq value coming into this sub-round
70    pub eq_in: [T; D_EF],
71    /// The eq value going out (updated for this round)
72    pub eq_out: [T; D_EF],
73}
74
75#[derive(ColumnsAir)]
76#[columns_via(GkrLayerSumcheckCols<u8>)]
77pub struct GkrLayerSumcheckAir {
78    pub transcript_bus: TranscriptBus,
79    pub xi_randomness_bus: XiRandomnessBus,
80    pub sumcheck_input_bus: GkrSumcheckInputBus,
81    pub sumcheck_output_bus: GkrSumcheckOutputBus,
82    pub sumcheck_challenge_bus: GkrSumcheckChallengeBus,
83}
84
85impl GkrLayerSumcheckAir {
86    pub fn new(
87        transcript_bus: TranscriptBus,
88        xi_randomness_bus: XiRandomnessBus,
89        sumcheck_input_bus: GkrSumcheckInputBus,
90        sumcheck_output_bus: GkrSumcheckOutputBus,
91        sumcheck_challenge_bus: GkrSumcheckChallengeBus,
92    ) -> Self {
93        Self {
94            transcript_bus,
95            xi_randomness_bus,
96            sumcheck_input_bus,
97            sumcheck_output_bus,
98            sumcheck_challenge_bus,
99        }
100    }
101}
102
103impl<F: Field> BaseAir<F> for GkrLayerSumcheckAir {
104    fn width(&self) -> usize {
105        GkrLayerSumcheckCols::<F>::width()
106    }
107}
108
109impl<F: Field> BaseAirWithPublicValues<F> for GkrLayerSumcheckAir {}
110impl<F: Field> PartitionedBaseAir<F> for GkrLayerSumcheckAir {}
111
112impl<AB: AirBuilder + InteractionBuilder> Air<AB> for GkrLayerSumcheckAir
113where
114    <AB::Expr as PrimeCharacteristicRing>::PrimeSubfield: BinomiallyExtendable<{ D_EF }>,
115{
116    fn eval(&self, builder: &mut AB) {
117        let main = builder.main();
118        let (local, next) = (
119            main.row_slice(0).expect("window should have two elements"),
120            main.row_slice(1).expect("window should have two elements"),
121        );
122        let local: &GkrLayerSumcheckCols<AB::Var> = (*local).borrow();
123        let next: &GkrLayerSumcheckCols<AB::Var> = (*next).borrow();
124
125        ///////////////////////////////////////////////////////////////////////
126        // Boolean Constraints
127        ///////////////////////////////////////////////////////////////////////
128
129        builder.assert_bool(local.is_dummy);
130        builder.assert_bool(local.is_last_layer);
131
132        ///////////////////////////////////////////////////////////////////////
133        // Proof Index and Loop Constraints
134        ///////////////////////////////////////////////////////////////////////
135
136        type LoopSubAir = NestedForLoopSubAir<2>;
137        LoopSubAir {}.eval(
138            builder,
139            (
140                NestedForLoopIoCols {
141                    is_enabled: local.is_enabled.into(),
142                    counter: [local.proof_idx.into(), local.layer_idx - AB::F::ONE],
143                    is_first: [local.is_proof_start.into(), local.is_first_round.into()],
144                }
145                .map_into(),
146                NestedForLoopIoCols {
147                    is_enabled: next.is_enabled.into(),
148                    counter: [next.proof_idx.into(), next.layer_idx - AB::F::ONE],
149                    is_first: [next.is_proof_start.into(), next.is_first_round.into()],
150                },
151            ),
152        );
153
154        let is_transition_round =
155            LoopSubAir::local_is_transition(next.is_enabled, next.is_first_round);
156        let is_last_round =
157            LoopSubAir::local_is_last(local.is_enabled, next.is_enabled, next.is_first_round);
158        let is_transition_proof = next.is_enabled - next.is_proof_start;
159
160        // Sumcheck round flag starts at 0
161        builder.when(local.is_first_round).assert_zero(local.round);
162        // Sumcheck round flag increments by 1
163        builder
164            .when(is_transition_round.clone())
165            .assert_eq(next.round, local.round + AB::Expr::ONE);
166        // Sumcheck round flag end
167        builder
168            .when(is_last_round.clone())
169            .assert_eq(local.round, local.layer_idx - AB::Expr::ONE);
170
171        ///////////////////////////////////////////////////////////////////////
172        // Dummy Row Constraints
173        ///////////////////////////////////////////////////////////////////////
174
175        // A proof can't contribute both dummy and non-dummy rows
176        builder
177            .when(is_transition_proof.clone())
178            .assert_eq(next.is_dummy, local.is_dummy);
179        // Any proof segment with more than one row must be non-dummy
180        builder
181            .when(is_transition_proof.clone())
182            .assert_zero(local.is_dummy);
183
184        // Dummy rows are only allowed as a singleton placeholder row
185        builder
186            .when(local.is_dummy)
187            .assert_one(local.is_proof_start);
188        builder
189            .when(local.is_dummy)
190            .assert_one(local.is_first_round);
191        builder
192            .when(local.is_dummy)
193            .assert_one(is_last_round.clone());
194        builder.when(local.is_dummy).assert_one(local.is_last_layer);
195        // Dummy rows must be strict no-ops
196        assert_array_eq(&mut builder.when(local.is_dummy), local.eq_out, local.eq_in);
197        assert_array_eq(
198            &mut builder.when(local.is_dummy),
199            local.claim_out,
200            local.claim_in,
201        );
202
203        ///////////////////////////////////////////////////////////////////////
204        // Round Constraints
205        ///////////////////////////////////////////////////////////////////////
206
207        // Layer metadata must remain same
208        builder
209            .when(is_transition_round.clone())
210            .assert_eq(next.is_last_layer, local.is_last_layer);
211
212        // Eq initialization: eq_in = 1 at first round
213        assert_one_ext(&mut builder.when(local.is_first_round), local.eq_in);
214
215        // Eq update: incrementally compute eq *= (xi * ri + (1-xi) * (1-ri))
216        let eq_out: [AB::Expr; D_EF] =
217            update_eq(local.eq_in, local.prev_challenge, local.challenge);
218        assert_array_eq(&mut builder.when(local.is_enabled), local.eq_out, eq_out);
219
220        // Eq propagation
221        assert_array_eq(
222            &mut builder.when(is_transition_round.clone()),
223            local.eq_out,
224            next.eq_in,
225        );
226
227        // Compute s(0) = claim_in - s(1)
228        let ev0: [AB::Expr; D_EF] = ext_field_subtract(local.claim_in, local.ev1);
229
230        // Cubic interpolation: compute claim_out from polynomial evals at 0,1,2,3
231        let claim_out: [AB::Expr; D_EF] =
232            interpolate_cubic_at_0123(ev0, local.ev1, local.ev2, local.ev3, local.challenge);
233        assert_array_eq(builder, local.claim_out, claim_out);
234
235        // Claim propagation
236        assert_array_eq(
237            &mut builder.when(is_transition_round.clone()),
238            local.claim_out,
239            next.claim_in,
240        );
241
242        // Transcript index increment
243        builder.when(is_transition_round.clone()).assert_eq(
244            next.tidx,
245            local.tidx.into() + AB::Expr::from_usize(4 * D_EF),
246        );
247
248        ///////////////////////////////////////////////////////////////////////
249        // Module Interactions
250        ///////////////////////////////////////////////////////////////////////
251
252        let is_not_dummy = AB::Expr::ONE - local.is_dummy;
253
254        // 1. GkrSumcheckInputBus
255        // 1a. Receive initial sumcheck input on first round
256        self.sumcheck_input_bus.receive(
257            builder,
258            local.proof_idx,
259            GkrSumcheckInputMessage {
260                layer_idx: local.layer_idx,
261                is_last_layer: local.is_last_layer,
262                tidx: local.tidx,
263                claim: local.claim_in,
264            },
265            local.is_first_round * is_not_dummy.clone(),
266        );
267        // 2. GkrSumcheckOutputBus
268        // 2a. Send output back to GkrLayerAir on final round
269        self.sumcheck_output_bus.send(
270            builder,
271            local.proof_idx,
272            GkrSumcheckOutputMessage {
273                layer_idx: local.layer_idx.into(),
274                tidx: local.tidx.into() + AB::Expr::from_usize(4 * D_EF),
275                claim_out: local.claim_out.map(Into::into),
276                eq_at_r_prime: local.eq_out.map(Into::into),
277            },
278            is_last_round.clone() * is_not_dummy.clone(),
279        );
280
281        // 3. GkrSumcheckChallengeBus
282        // 3a. Receive challenge from previous GKR layer_idx sumcheck
283        self.sumcheck_challenge_bus.receive(
284            builder,
285            local.proof_idx,
286            GkrSumcheckChallengeMessage {
287                layer_idx: local.layer_idx - AB::Expr::ONE,
288                sumcheck_round: local.round.into(),
289                challenge: local.prev_challenge.map(Into::into),
290            },
291            local.is_enabled * is_not_dummy.clone(),
292        );
293        // 3b. Send challenge to next GKR layer_idx sumcheck for eq calculation
294        self.sumcheck_challenge_bus.send(
295            builder,
296            local.proof_idx,
297            GkrSumcheckChallengeMessage {
298                layer_idx: local.layer_idx.into(),
299                sumcheck_round: local.round.into() + AB::Expr::ONE,
300                challenge: local.challenge.map(Into::into),
301            },
302            local.is_enabled * (AB::Expr::ONE - local.is_last_layer) * is_not_dummy.clone(),
303        );
304
305        ///////////////////////////////////////////////////////////////////////
306        // External Interactions
307        ///////////////////////////////////////////////////////////////////////
308
309        // 1. TranscriptBus
310        // 1a. Observe evaluations
311        let mut tidx = local.tidx.into();
312        for eval in [local.ev1, local.ev2, local.ev3].into_iter() {
313            self.transcript_bus.observe_ext(
314                builder,
315                local.proof_idx,
316                tidx.clone(),
317                eval,
318                local.is_enabled * is_not_dummy.clone(),
319            );
320            tidx += AB::Expr::from_usize(D_EF);
321        }
322        // 1b. Sample challenge `ri`
323        self.transcript_bus.sample_ext(
324            builder,
325            local.proof_idx,
326            tidx,
327            local.challenge,
328            local.is_enabled * is_not_dummy.clone(),
329        );
330
331        // 2. XiRandomnessBus
332        // 2a. Send last challenge
333        self.xi_randomness_bus.send(
334            builder,
335            local.proof_idx,
336            XiRandomnessMessage {
337                idx: local.round + AB::Expr::ONE,
338                xi: local.challenge.map(Into::into),
339            },
340            local.is_enabled * local.is_last_layer * is_not_dummy.clone(),
341        );
342    }
343}
344
345/// Interpolates a cubic polynomial at a point using evaluations at 0, 1, 2, 3.
346///
347/// Given evaluations `claim_in, ev1, ev2, ev3` (where ev0 = claim_in - ev1) and a point `x`,
348/// computes `f(x)` using Lagrange interpolation optimized for these specific points.
349pub(super) fn interpolate_cubic_at_0123<F, FA>(
350    ev0: [FA; D_EF],
351    ev1: [F; D_EF],
352    ev2: [F; D_EF],
353    ev3: [F; D_EF],
354    x: [F; D_EF],
355) -> [FA; D_EF]
356where
357    F: Into<FA> + Copy,
358    FA: PrimeCharacteristicRing,
359    FA::PrimeSubfield: BinomiallyExtendable<{ D_EF }>,
360{
361    let three: FA = FA::from_usize(3);
362    let inv2: FA = FA::from_prime_subfield(FA::PrimeSubfield::from_usize(2).inverse());
363    let inv6: FA = FA::from_prime_subfield(FA::PrimeSubfield::from_usize(6).inverse());
364
365    // s1 = ev1 - ev0
366    let s1: [FA; D_EF] = ext_field_subtract(ev1, ev0.clone());
367    // s2 = ev2 - ev0
368    let s2: [FA; D_EF] = ext_field_subtract(ev2, ev0.clone());
369    // s3 = ev3 - ev0
370    let s3: [FA; D_EF] = ext_field_subtract(ev3, ev0.clone());
371
372    // d3 = s3 - (s2 - s1) * 3
373    let d3: [FA; D_EF] = ext_field_subtract::<FA>(
374        s3,
375        ext_field_multiply_scalar::<FA>(ext_field_subtract::<FA>(s2.clone(), s1.clone()), three),
376    );
377
378    // p = d3 / 6
379    let p: [FA; D_EF] = ext_field_multiply_scalar(d3.clone(), inv6);
380
381    // q = (s2 - d3) / 2 - s1
382    let q: [FA; D_EF] = ext_field_subtract::<FA>(
383        ext_field_multiply_scalar::<FA>(ext_field_subtract::<FA>(s2, d3), inv2),
384        s1.clone(),
385    );
386
387    // r = s1 - p - q
388    let r: [FA; D_EF] = ext_field_subtract::<FA>(s1, ext_field_add::<FA>(p.clone(), q.clone()));
389
390    // result = ((p * x + q) * x + r) * x + ev0
391    ext_field_add::<FA>(
392        ext_field_multiply::<FA>(
393            ext_field_add::<FA>(
394                ext_field_multiply::<FA>(ext_field_add::<FA>(ext_field_multiply::<FA>(p, x), q), x),
395                r,
396            ),
397            x,
398        ),
399        ev0,
400    )
401}
402
403/// Updates the eq evaluation incrementally for one sumcheck round.
404///
405/// Computes: `eq_out = eq_in * (prev_challenge * challenge + (1 - prev_challenge) * (1 -
406/// challenge))` where `prev_challenge` is xi and `challenge` is ri.
407pub(super) fn update_eq<F, FA>(
408    eq_in: [F; D_EF],
409    prev_challenge: [F; D_EF],
410    challenge: [F; D_EF],
411) -> [FA; D_EF]
412where
413    F: Into<FA> + Copy,
414    FA: PrimeCharacteristicRing,
415    FA::PrimeSubfield: BinomiallyExtendable<{ D_EF }>,
416{
417    ext_field_multiply::<FA>(
418        eq_in,
419        ext_field_add::<FA>(
420            ext_field_multiply::<FA>(prev_challenge, challenge),
421            ext_field_multiply::<FA>(
422                ext_field_one_minus::<FA>(prev_challenge),
423                ext_field_one_minus::<FA>(challenge),
424            ),
425        ),
426    )
427}