openvm_recursion_circuit/stacking/claims/
air.rs

1use std::borrow::Borrow;
2
3use openvm_circuit_primitives::{
4    utils::{and, assert_array_eq, not},
5    ColumnsAir, StructReflection, StructReflectionHelper, SubAir,
6};
7use openvm_recursion_circuit_derive::AlignedBorrow;
8use openvm_stark_backend::{
9    interaction::InteractionBuilder, BaseAirWithPublicValues, PartitionedBaseAir,
10};
11use openvm_stark_sdk::config::baby_bear_poseidon2::{D_EF, F};
12use p3_air::{Air, AirBuilder, BaseAir};
13use p3_field::{extension::BinomiallyExtendable, Field, PrimeCharacteristicRing, PrimeField32};
14use p3_matrix::Matrix;
15
16use crate::{
17    bus::{
18        StackingIndexMessage, StackingIndicesBus, TranscriptBus, TranscriptBusMessage,
19        WhirModuleBus, WhirModuleMessage, WhirMuBus, WhirMuMessage,
20    },
21    primitives::bus::{ExpBitsLenBus, ExpBitsLenMessage},
22    stacking::bus::{
23        ClaimCoefficientsBus, ClaimCoefficientsMessage, StackingModuleTidxBus,
24        StackingModuleTidxMessage, SumcheckClaimsBus, SumcheckClaimsMessage,
25    },
26    subairs::nested_for_loop::{NestedForLoopIoCols, NestedForLoopSubAir},
27    utils::{assert_one_ext, ext_field_add, ext_field_multiply, pow_tidx_count},
28};
29
30#[repr(C)]
31#[derive(AlignedBorrow, StructReflection)]
32pub struct StackingClaimsCols<F> {
33    // Proof index columns for continuations
34    pub proof_idx: F,
35    /// Row has a real stacking claim (bus interactions fire).
36    pub is_valid: F,
37    /// Row is padding within a proof block (no bus interactions).
38    pub is_padding: F,
39    pub is_first: F,
40    /// Last row of the proof block (valid + padding). Triggers proof_idx
41    /// transition and the w_stack check.
42    pub is_last: F,
43
44    // Correspond to stacking_claim
45    pub commit_idx: F,
46    pub stacked_col_idx: F,
47
48    // Sampled transcript values
49    pub tidx: F,
50    pub mu: [F; D_EF],
51    pub mu_pow: [F; D_EF],
52
53    // μ PoW witness and sample for proof-of-work check
54    pub mu_pow_witness: F,
55    pub mu_pow_sample: F,
56
57    // Global column index (0-indexed, increments by 1 per row within a proof
58    // block, covering both valid and padding rows).
59    pub global_col_idx: F,
60
61    // Stacking claim and batched coefficient computed in OpeningClaimsCols
62    pub stacking_claim: [F; D_EF],
63    pub claim_coefficient: [F; D_EF],
64
65    // Sum of each stacking_claim * claim_coefficient
66    pub final_s_eval: [F; D_EF],
67
68    // RLC of stacking claims using mu
69    pub whir_claim: [F; D_EF],
70}
71
72#[derive(ColumnsAir)]
73#[columns_via(StackingClaimsCols<u8>)]
74pub struct StackingClaimsAir {
75    // External buses
76    pub stacking_indices_bus: StackingIndicesBus,
77    pub whir_module_bus: WhirModuleBus,
78    pub whir_mu_bus: WhirMuBus,
79    pub transcript_bus: TranscriptBus,
80    pub exp_bits_len_bus: ExpBitsLenBus,
81
82    // Internal buses
83    pub stacking_tidx_bus: StackingModuleTidxBus,
84    pub claim_coefficients_bus: ClaimCoefficientsBus,
85    pub sumcheck_claims_bus: SumcheckClaimsBus,
86
87    pub stacking_index_mult: usize,
88    /// Maximum number of stacking columns per proof.
89    pub w_stack: usize,
90    /// Number of PoW bits for μ batching challenge.
91    pub mu_pow_bits: usize,
92}
93
94impl BaseAirWithPublicValues<F> for StackingClaimsAir {}
95impl PartitionedBaseAir<F> for StackingClaimsAir {}
96
97impl<F> BaseAir<F> for StackingClaimsAir {
98    fn width(&self) -> usize {
99        StackingClaimsCols::<F>::width()
100    }
101}
102
103impl<AB: AirBuilder + InteractionBuilder> Air<AB> for StackingClaimsAir
104where
105    AB::F: PrimeField32,
106    <AB::Expr as PrimeCharacteristicRing>::PrimeSubfield: BinomiallyExtendable<{ D_EF }>,
107{
108    fn eval(&self, builder: &mut AB) {
109        let main = builder.main();
110        let (local, next) = (
111            main.row_slice(0).expect("window should have two elements"),
112            main.row_slice(1).expect("window should have two elements"),
113        );
114
115        let local: &StackingClaimsCols<AB::Var> = (*local).borrow();
116        let next: &StackingClaimsCols<AB::Var> = (*next).borrow();
117
118        let is_in_block = local.is_valid + local.is_padding;
119        let next_is_in_block = next.is_valid + next.is_padding;
120
121        NestedForLoopSubAir::<2> {}.eval(
122            builder,
123            (
124                NestedForLoopIoCols {
125                    is_enabled: is_in_block.clone(),
126                    counter: [local.proof_idx.into(), local.global_col_idx.into()],
127                    is_first: [local.is_first.into(), is_in_block.clone()],
128                },
129                NestedForLoopIoCols {
130                    is_enabled: next_is_in_block.clone(),
131                    counter: [next.proof_idx.into(), next.global_col_idx.into()],
132                    is_first: [next.is_first.into(), next_is_in_block.clone()],
133                },
134            ),
135        );
136
137        // Last valid row in a proof block:
138        // - valid row before padding starts, OR
139        // - valid row at block end (num_valid == w_stack).
140        // Degree-2 selectors to stay within max AIR degree.
141        let is_last_valid = and(local.is_valid, next.is_padding + local.is_last);
142        // Valid row that continues to another valid row in the same proof block:
143        // excludes the terminal valid row (before padding or block end).
144        let is_continuing_valid = and(
145            local.is_valid,
146            AB::Expr::ONE - next.is_padding - local.is_last,
147        );
148
149        builder.assert_bool(local.is_valid);
150        builder.assert_bool(local.is_padding);
151        builder.assert_bool(local.is_last);
152        // Last row in a proof block is exactly the nested-loop boundary for proof_idx.
153        builder.when(is_in_block.clone()).assert_eq(
154            local.is_last,
155            NestedForLoopSubAir::<2>::local_is_last(
156                is_in_block.clone(),
157                next_is_in_block.clone(),
158                next.is_first,
159            ),
160        );
161        // Once padding starts within a proof block, it stays padding
162        builder
163            .when(local.is_padding * not(local.is_last))
164            .assert_one(next.is_padding);
165        builder.when_first_row().assert_zero(local.proof_idx);
166        builder.when(local.is_first).assert_one(local.is_valid);
167
168        /*
169         * Constrain that commit_idx and stacked_col_idx increment correctly.
170         */
171        builder.when(local.is_first).assert_zero(local.commit_idx);
172        builder
173            .when(local.is_first)
174            .assert_zero(local.stacked_col_idx);
175
176        let mut when_same_proof = builder.when(is_continuing_valid.clone());
177        let commit_delta = next.commit_idx - local.commit_idx;
178
179        when_same_proof.assert_bool(commit_delta.clone());
180        when_same_proof
181            .when(commit_delta.clone())
182            .assert_zero(next.stacked_col_idx);
183        when_same_proof
184            .when(not::<AB::Expr>(commit_delta))
185            .assert_one(next.stacked_col_idx - local.stacked_col_idx);
186
187        /*
188         * Constrain global_col_idx: starts at 0, is forced by NestedForLoopSubAir
189         * to increment by exactly 1 within each proof block, and ends at w_stack - 1.
190         */
191        builder
192            .when(local.is_first)
193            .assert_zero(local.global_col_idx);
194        builder
195            .when(local.is_last * is_in_block)
196            .assert_eq(local.global_col_idx, AB::Expr::from_usize(self.w_stack - 1));
197
198        self.stacking_indices_bus.add_key_with_lookups(
199            builder,
200            local.proof_idx,
201            StackingIndexMessage {
202                commit_idx: local.commit_idx,
203                col_idx: local.stacked_col_idx,
204            },
205            local.is_valid * AB::Expr::from_usize(self.stacking_index_mult),
206        );
207
208        /*
209         * Compute the running sum of stacking_claim * claim_coefficient values and then
210         * constrain the final result to be equal to s_{n_stack}(u_{n_stack}), which is
211         * sent from SumcheckRoundsAir.
212         */
213        self.claim_coefficients_bus.receive(
214            builder,
215            local.proof_idx,
216            ClaimCoefficientsMessage {
217                commit_idx: local.commit_idx,
218                stacked_col_idx: local.stacked_col_idx,
219                coefficient: local.claim_coefficient,
220            },
221            local.is_valid,
222        );
223
224        assert_array_eq(
225            &mut builder.when(local.is_first),
226            ext_field_multiply(local.stacking_claim, local.claim_coefficient),
227            local.final_s_eval,
228        );
229
230        assert_array_eq(
231            &mut builder.when(is_continuing_valid.clone()),
232            ext_field_add(
233                ext_field_multiply(next.stacking_claim, next.claim_coefficient),
234                local.final_s_eval,
235            ),
236            next.final_s_eval,
237        );
238
239        self.sumcheck_claims_bus.receive(
240            builder,
241            local.proof_idx,
242            SumcheckClaimsMessage {
243                module_idx: AB::Expr::TWO,
244                value: local.final_s_eval.map(Into::into),
245            },
246            is_last_valid.clone(),
247        );
248
249        /*
250         * Constrain transcript operations and send the final tidx to the WHIR module.
251         */
252        self.stacking_tidx_bus.receive(
253            builder,
254            local.proof_idx,
255            StackingModuleTidxMessage {
256                module_idx: AB::Expr::TWO,
257                tidx: local.tidx.into(),
258            },
259            local.is_first,
260        );
261
262        builder
263            .when(is_continuing_valid.clone())
264            .assert_eq(local.tidx + AB::F::from_usize(D_EF), next.tidx);
265
266        let mu_pow_offset = pow_tidx_count(self.mu_pow_bits);
267
268        for i in 0..D_EF {
269            // Observe stacking_claim at tidx + 0..D_EF
270            self.transcript_bus.receive(
271                builder,
272                local.proof_idx,
273                TranscriptBusMessage {
274                    tidx: AB::Expr::from_usize(i) + local.tidx,
275                    value: local.stacking_claim[i].into(),
276                    is_sample: AB::Expr::ZERO,
277                },
278                local.is_valid,
279            );
280
281            // Sample μ at tidx + D_EF + mu_pow_offset + i (after μ PoW observe/sample if any)
282            self.transcript_bus.receive(
283                builder,
284                local.proof_idx,
285                TranscriptBusMessage {
286                    tidx: AB::Expr::from_usize(i + D_EF + mu_pow_offset) + local.tidx,
287                    value: local.mu[i].into(),
288                    is_sample: AB::Expr::ONE,
289                },
290                is_last_valid.clone(),
291            );
292        }
293
294        if self.mu_pow_bits > 0 {
295            // μ PoW: observe mu_pow_witness at tidx + D_EF (on last valid row only)
296            self.transcript_bus.receive(
297                builder,
298                local.proof_idx,
299                TranscriptBusMessage {
300                    tidx: AB::Expr::from_usize(D_EF) + local.tidx,
301                    value: local.mu_pow_witness.into(),
302                    is_sample: AB::Expr::ZERO,
303                },
304                is_last_valid.clone(),
305            );
306
307            // μ PoW: sample mu_pow_sample at tidx + D_EF + 1 (on last valid row only)
308            self.transcript_bus.receive(
309                builder,
310                local.proof_idx,
311                TranscriptBusMessage {
312                    tidx: AB::Expr::from_usize(D_EF + 1) + local.tidx,
313                    value: local.mu_pow_sample.into(),
314                    is_sample: AB::Expr::ONE,
315                },
316                is_last_valid.clone(),
317            );
318
319            // μ PoW check: g^{mu_pow_sample[0:mu_pow_bits]} = 1
320            self.exp_bits_len_bus.lookup_key(
321                builder,
322                ExpBitsLenMessage {
323                    base: AB::F::GENERATOR.into(),
324                    bit_src: local.mu_pow_sample.into(),
325                    num_bits: AB::Expr::from_usize(self.mu_pow_bits),
326                    result: AB::Expr::ONE,
327                },
328                is_last_valid.clone(),
329            );
330        }
331
332        /*
333         * Compute the RLC of the stacking claims and send it to the WHIR module.
334         * Running sums propagate through valid rows only (not(is_last_valid)),
335         * since padding rows have zero claims and don't affect the accumulators.
336         */
337        assert_one_ext(&mut builder.when(local.is_first), local.mu_pow);
338        assert_array_eq(
339            &mut builder.when(is_continuing_valid.clone()),
340            local.mu,
341            next.mu,
342        );
343        assert_array_eq(
344            &mut builder.when(is_continuing_valid.clone()),
345            ext_field_multiply(local.mu, local.mu_pow),
346            next.mu_pow,
347        );
348
349        assert_array_eq(
350            &mut builder.when(local.is_first),
351            local.stacking_claim,
352            local.whir_claim,
353        );
354
355        assert_array_eq(
356            &mut builder.when(is_continuing_valid.clone()),
357            ext_field_add(
358                ext_field_multiply(next.stacking_claim, next.mu_pow),
359                local.whir_claim,
360            ),
361            next.whir_claim,
362        );
363
364        // Send to WHIR module with tidx after all transcript operations
365        self.whir_module_bus.send(
366            builder,
367            local.proof_idx,
368            WhirModuleMessage {
369                tidx: AB::Expr::from_usize(2 * D_EF + mu_pow_offset) + local.tidx,
370                claim: local.whir_claim.map(Into::into),
371            },
372            is_last_valid.clone(),
373        );
374        self.whir_mu_bus.send(
375            builder,
376            local.proof_idx,
377            WhirMuMessage {
378                mu: local.mu.map(Into::into),
379            },
380            is_last_valid,
381        );
382    }
383}