Skip to main content

p3_batch_stark/
verifier.rs

1use alloc::string::String;
2use alloc::vec::Vec;
3use alloc::{format, vec};
4use core::fmt::Debug;
5
6use hashbrown::HashMap;
7use p3_air::symbolic::{AirLayout, SymbolicAirBuilder, SymbolicExpressionExt};
8use p3_air::{Air, RowWindow};
9use p3_challenger::{CanObserve, FieldChallenger};
10use p3_commit::{Pcs, PolynomialSpace};
11use p3_field::{Algebra, BasedVectorSpace, PrimeCharacteristicRing};
12use p3_lookup::AirWithLookups;
13use p3_lookup::folder::VerifierConstraintFolderWithLookups;
14use p3_lookup::logup::LogUpGadget;
15use p3_lookup::lookup_traits::{Lookup, LookupGadget};
16use p3_matrix::dense::RowMajorMatrixView;
17use p3_matrix::stack::VerticalPair;
18use p3_uni_stark::{VerificationError, VerifierConstraintFolder, recompose_quotient_from_chunks};
19use p3_util::zip_eq::zip_eq;
20use tracing::{info_span, instrument};
21
22use crate::common::{CommonData, get_perm_challenges};
23use crate::config::{
24    Challenge, Domain, PcsError, StarkGenericConfig as SGC, Val, observe_instance_binding,
25};
26use crate::proof::BatchProof;
27use crate::symbolic::get_log_num_quotient_chunks;
28
29#[instrument(skip_all)]
30pub fn verify_batch<SC, A>(
31    config: &SC,
32    airs: &[A],
33    proof: &BatchProof<SC>,
34    public_values: &[Vec<Val<SC>>],
35    common: &CommonData<SC>,
36) -> Result<(), VerificationError<PcsError<SC>>>
37where
38    SC: SGC,
39    SymbolicExpressionExt<Val<SC>, SC::Challenge>: Algebra<SC::Challenge>,
40    A: Air<SymbolicAirBuilder<Val<SC>, SC::Challenge>>
41        + for<'a> Air<VerifierConstraintFolderWithLookups<'a, SC>>,
42    Challenge<SC>: BasedVectorSpace<Val<SC>>,
43{
44    // TODO: Extend if additional lookup gadgets are added.
45    let lookup_gadget = LogUpGadget::new();
46
47    let BatchProof {
48        commitments,
49        opened_values,
50        opening_proof,
51        global_lookup_data,
52        degree_bits,
53    } = proof;
54
55    let all_lookups = &common.lookups;
56
57    let pcs = config.pcs();
58    let mut challenger = config.initialise_challenger();
59
60    // Sanity checks
61    if airs.len() != opened_values.instances.len()
62        || airs.len() != public_values.len()
63        || airs.len() != degree_bits.len()
64        || airs.len() != global_lookup_data.len()
65    {
66        return Err(VerificationError::InvalidProofShape);
67    }
68
69    // Check that the random commitments are/are not present depending on the ZK setting.
70    // - If ZK is enabled, the prover should have random commitments.
71    // - If ZK is not enabled, the prover should not have random commitments.
72    if (opened_values
73        .instances
74        .iter()
75        .any(|ov| ov.base_opened_values.random.is_some() != SC::Pcs::ZK))
76        || (commitments.random.is_some() != SC::Pcs::ZK)
77    {
78        return Err(VerificationError::RandomizationError);
79    }
80
81    // Observe the number of instances up front to match the prover's transcript.
82    let n_instances = airs.len();
83    challenger.observe_base_as_algebra_element::<Challenge<SC>>(Val::<SC>::from_usize(n_instances));
84
85    // Validate opened values shape per instance and observe per-instance binding data.
86    // Precompute per-instance preprocessed widths and number of quotient chunks.
87    let mut preprocessed_widths = Vec::with_capacity(airs.len());
88    // Number of quotient chunks per instance before ZK randomization.
89    let mut log_num_quotient_chunks = Vec::with_capacity(airs.len());
90    // The total number of quotient chunks, including ZK randomization.
91    let mut num_quotient_chunks = Vec::with_capacity(airs.len());
92
93    for (i, air) in airs.iter().enumerate() {
94        let pre_w = common
95            .preprocessed
96            .as_ref()
97            .and_then(|g| g.instances[i].as_ref().map(|m| m.width))
98            .unwrap_or(0);
99        preprocessed_widths.push(pre_w);
100
101        let layout = AirLayout {
102            preprocessed_width: pre_w,
103            main_width: air.width(),
104            num_public_values: air.num_public_values(),
105            ..Default::default()
106        };
107        let log_num_chunks =
108            info_span!("infer log of constraint degree", air_idx = i).in_scope(|| {
109                get_log_num_quotient_chunks::<Val<SC>, SC::Challenge, A, LogUpGadget>(
110                    air,
111                    layout,
112                    &all_lookups[i],
113                    config.is_zk(),
114                    &lookup_gadget,
115                )
116            });
117        log_num_quotient_chunks.push(log_num_chunks);
118
119        let n_chunks = 1 << (log_num_chunks + config.is_zk());
120        num_quotient_chunks.push(n_chunks);
121    }
122
123    for (i, air) in airs.iter().enumerate() {
124        let air_width = A::width(air);
125        let inst_opened_vals = &opened_values.instances[i];
126        let inst_base_opened_vals = &inst_opened_vals.base_opened_values;
127
128        // Validate trace widths match the AIR
129        if inst_base_opened_vals.trace_local.len() != air_width {
130            return Err(VerificationError::InvalidProofShape);
131        }
132        if !airs[i].main_next_row_columns().is_empty() {
133            if inst_base_opened_vals
134                .trace_next
135                .as_ref()
136                .is_none_or(|v| v.len() != air_width)
137            {
138                return Err(VerificationError::InvalidProofShape);
139            }
140        } else if inst_base_opened_vals.trace_next.is_some() {
141            return Err(VerificationError::InvalidProofShape);
142        }
143
144        // Validate quotient chunks structure
145        let n_chunks = num_quotient_chunks[i];
146        if inst_base_opened_vals.quotient_chunks.len() != n_chunks {
147            return Err(VerificationError::InvalidProofShape);
148        }
149
150        for chunk in &inst_base_opened_vals.quotient_chunks {
151            if chunk.len() != Challenge::<SC>::DIMENSION {
152                return Err(VerificationError::InvalidProofShape);
153            }
154        }
155
156        // Validate random commit
157        if inst_opened_vals
158            .base_opened_values
159            .random
160            .as_ref()
161            .is_some_and(|r_comm| r_comm.len() != SC::Challenge::DIMENSION)
162        {
163            return Err(VerificationError::RandomizationError);
164        }
165
166        // Validate that any preprocessed width implied by CommonData matches the opened shapes.
167        let pre_w = preprocessed_widths[i];
168        let pre_local_len = inst_base_opened_vals
169            .preprocessed_local
170            .as_ref()
171            .map_or(0, |v| v.len());
172        let pre_next_len = inst_base_opened_vals
173            .preprocessed_next
174            .as_ref()
175            .map_or(0, |v| v.len());
176        if pre_w == 0 {
177            if pre_local_len != 0 || pre_next_len != 0 {
178                return Err(VerificationError::InvalidProofShape);
179            }
180        } else if !airs[i].preprocessed_next_row_columns().is_empty() {
181            if pre_local_len != pre_w || pre_next_len != pre_w {
182                return Err(VerificationError::InvalidProofShape);
183            }
184        } else if pre_local_len != pre_w || pre_next_len != 0 {
185            return Err(VerificationError::InvalidProofShape);
186        }
187
188        // Observe per-instance binding data: (log_ext_degree, log_degree), width, num quotient chunks.
189        let ext_db = degree_bits[i];
190        let base_db = ext_db - config.is_zk();
191        let width = A::width(air);
192        observe_instance_binding::<SC>(&mut challenger, ext_db, base_db, width, n_chunks);
193    }
194
195    // Observe main commitment and public values (in instance order).
196    challenger.observe(commitments.main.clone());
197    for pv in public_values {
198        challenger.observe_slice(pv);
199    }
200
201    // Observe preprocessed widths for each instance. If a global
202    // preprocessed commitment exists, observe it once.
203    for &pre_w in preprocessed_widths.iter() {
204        challenger.observe_base_as_algebra_element::<Challenge<SC>>(Val::<SC>::from_usize(pre_w));
205    }
206    if let Some(global) = &common.preprocessed {
207        challenger.observe(global.commitment.clone());
208    }
209
210    // Validate the shape of the lookup commitment.
211    let is_lookup = commitments.permutation.is_some();
212
213    if is_lookup != all_lookups.iter().any(|c| !c.is_empty()) {
214        return Err(VerificationError::InvalidProofShape);
215    }
216
217    // Fetch lookups and sample their challenges.
218    let challenges_per_instance =
219        get_perm_challenges::<SC, LogUpGadget>(&mut challenger, all_lookups, &lookup_gadget);
220
221    // Then, observe the permutation tables, if any.
222    if is_lookup {
223        challenger.observe(
224            commitments
225                .permutation
226                .clone()
227                .expect("We checked that the commitment exists"),
228        );
229        for data in global_lookup_data.iter().flatten() {
230            challenger.observe_algebra_element(data.expected_cumulated);
231        }
232    }
233
234    // Sample alpha for constraint folding
235    let alpha = challenger.sample_algebra_element();
236
237    // Observe quotient chunks commitment
238    challenger.observe(commitments.quotient_chunks.clone());
239
240    // We've already checked that commitments.random is present if and only if ZK is enabled.
241    // Observe the random commitment if it is present.
242    if let Some(r_commit) = commitments.random.clone() {
243        challenger.observe(r_commit);
244    }
245
246    // Sample OOD point
247    let zeta = challenger.sample_algebra_element();
248
249    // Build commitments_with_opening_points to verify openings.
250    let mut coms_to_verify = vec![];
251
252    // Trace round: per instance, open at zeta and zeta_next
253    let (trace_domains, ext_trace_domains): (Vec<Domain<SC>>, Vec<Domain<SC>>) = degree_bits
254        .iter()
255        .map(|&ext_db| {
256            let base_db = ext_db - config.is_zk();
257            (
258                pcs.natural_domain_for_degree(1 << base_db),
259                pcs.natural_domain_for_degree(1 << ext_db),
260            )
261        })
262        .unzip();
263
264    if let Some(random_commit) = &commitments.random {
265        coms_to_verify.push((
266            random_commit.clone(),
267            ext_trace_domains
268                .iter()
269                .zip(opened_values.instances.iter())
270                .map(|(domain, inst_opened_vals)| {
271                    // We already checked that random is present for each instance when ZK is enabled.
272                    let random_vals = inst_opened_vals.base_opened_values.random.as_ref().unwrap();
273                    (*domain, vec![(zeta, random_vals.clone())])
274                })
275                .collect::<Vec<_>>(),
276        ));
277    }
278
279    let trace_round: Vec<_> = ext_trace_domains
280        .iter()
281        .zip(opened_values.instances.iter())
282        .enumerate()
283        .map(|(i, (ext_dom, inst_opened_vals))| {
284            let mut points = vec![(
285                zeta,
286                inst_opened_vals.base_opened_values.trace_local.clone(),
287            )];
288            if !airs[i].main_next_row_columns().is_empty() {
289                let zeta_next = trace_domains[i]
290                    .next_point(zeta)
291                    .ok_or(VerificationError::NextPointUnavailable)?;
292                points.push((
293                    zeta_next,
294                    inst_opened_vals
295                        .base_opened_values
296                        .trace_next
297                        .clone()
298                        .expect("checked in shape validation"),
299                ));
300            }
301            Ok((*ext_dom, points))
302        })
303        .collect::<Result<Vec<_>, VerificationError<PcsError<SC>>>>()?;
304    coms_to_verify.push((commitments.main.clone(), trace_round));
305
306    // Quotient chunks round: flatten per-instance chunks to match commit order.
307    // Use extended domains for the outer commit domain, with size = base_degree * num_quotient_chunks.
308    let quotient_domains: Vec<Vec<Domain<SC>>> = (0..degree_bits.len())
309        .map(|i| {
310            let ext_db = degree_bits[i];
311            let log_num_chunks = log_num_quotient_chunks[i];
312            let n_chunks = num_quotient_chunks[i];
313            let ext_dom = ext_trace_domains[i];
314            let qdom = ext_dom.create_disjoint_domain(1 << (ext_db + log_num_chunks));
315            qdom.split_domains(n_chunks)
316        })
317        .collect();
318
319    // When ZK is enabled, the size of the quotient chunks' domains doubles.
320    let randomized_quotient_chunks_domains = quotient_domains
321        .iter()
322        .map(|doms| {
323            doms.iter()
324                .map(|dom| pcs.natural_domain_for_degree(dom.size() << (config.is_zk())))
325                .collect::<Vec<_>>()
326        })
327        .collect::<Vec<_>>();
328
329    // Build the per-matrix openings for the aggregated quotient commitment.
330    let mut qc_round = Vec::new();
331    for (i, domains) in randomized_quotient_chunks_domains.iter().enumerate() {
332        let inst_qcs = &opened_values.instances[i]
333            .base_opened_values
334            .quotient_chunks;
335        if inst_qcs.len() != domains.len() {
336            return Err(VerificationError::InvalidProofShape);
337        }
338        for (d, vals) in zip_eq(
339            domains.iter(),
340            inst_qcs,
341            VerificationError::InvalidProofShape,
342        )? {
343            qc_round.push((*d, vec![(zeta, vals.clone())]));
344        }
345    }
346    coms_to_verify.push((commitments.quotient_chunks.clone(), qc_round));
347
348    // Preprocessed rounds: a single global commitment with one matrix per
349    // instance that has preprocessed columns.
350    if let Some(global) = &common.preprocessed {
351        let mut pre_round = Vec::new();
352
353        for (matrix_index, &inst_idx) in global.matrix_to_instance.iter().enumerate() {
354            let pre_w = preprocessed_widths[inst_idx];
355            if pre_w == 0 {
356                return Err(VerificationError::InvalidProofShape);
357            }
358
359            let inst = &opened_values.instances[inst_idx];
360            let local = inst
361                .base_opened_values
362                .preprocessed_local
363                .as_ref()
364                .ok_or(VerificationError::InvalidProofShape)?;
365
366            // Validate that the preprocessed data's base degree matches what we expect.
367            let ext_db = degree_bits[inst_idx];
368
369            let meta = global.instances[inst_idx]
370                .as_ref()
371                .ok_or(VerificationError::InvalidProofShape)?;
372            if meta.matrix_index != matrix_index || meta.degree_bits != ext_db {
373                return Err(VerificationError::InvalidProofShape);
374            }
375
376            let meta_db = meta.degree_bits;
377            let pre_domain = pcs.natural_domain_for_degree(1 << meta_db);
378            if !airs[inst_idx].preprocessed_next_row_columns().is_empty() {
379                let next = inst
380                    .base_opened_values
381                    .preprocessed_next
382                    .as_ref()
383                    .ok_or(VerificationError::InvalidProofShape)?;
384                let zeta_next_i = trace_domains[inst_idx]
385                    .next_point(zeta)
386                    .ok_or(VerificationError::NextPointUnavailable)?;
387
388                pre_round.push((
389                    pre_domain,
390                    vec![(zeta, local.clone()), (zeta_next_i, next.clone())],
391                ));
392            } else {
393                pre_round.push((pre_domain, vec![(zeta, local.clone())]));
394            }
395        }
396
397        coms_to_verify.push((global.commitment.clone(), pre_round));
398    }
399
400    if is_lookup {
401        let permutation_commit = commitments.permutation.clone().unwrap();
402        let mut permutation_round = Vec::new();
403        for (i, (ext_dom, inst_opened_vals)) in ext_trace_domains
404            .iter()
405            .zip(opened_values.instances.iter())
406            .enumerate()
407        {
408            if inst_opened_vals.permutation_local.len() != inst_opened_vals.permutation_next.len() {
409                return Err(VerificationError::InvalidProofShape);
410            }
411            if !inst_opened_vals.permutation_local.is_empty() {
412                let zeta_next = trace_domains[i]
413                    .next_point(zeta)
414                    .ok_or(VerificationError::NextPointUnavailable)?;
415                permutation_round.push((
416                    *ext_dom,
417                    vec![
418                        (zeta, inst_opened_vals.permutation_local.clone()),
419                        (zeta_next, inst_opened_vals.permutation_next.clone()),
420                    ],
421                ));
422            }
423        }
424        coms_to_verify.push((permutation_commit, permutation_round));
425    }
426
427    // Verify all openings via PCS.
428    pcs.verify(coms_to_verify, opening_proof, &mut challenger)
429        .map_err(VerificationError::InvalidOpeningArgument)?;
430
431    // Now check constraint equality per instance.
432    // For each instance, recombine quotient from chunks at zeta and compare to folded constraints.
433    for (i, air) in airs.iter().enumerate() {
434        let _air_span = info_span!("verify constraints", air_idx = i).entered();
435
436        let qc_domains = &quotient_domains[i];
437
438        // Recompose quotient(zeta) from chunks using utility function.
439        let quotient = recompose_quotient_from_chunks::<SC>(
440            qc_domains,
441            &opened_values.instances[i]
442                .base_opened_values
443                .quotient_chunks,
444            zeta,
445        );
446
447        // Recompose permutation openings from base-flattened columns into extension field columns.
448        // The permutation commitment is a base-flattened matrix with `width = aux_width * DIMENSION`.
449        // For constraint evaluation, we need an extension field matrix with width `aux_width``.
450        let aux_width = all_lookups[i]
451            .iter()
452            .flat_map(|ctx| ctx.columns.iter().cloned())
453            .max()
454            .map(|m| m + 1)
455            .unwrap_or(0);
456
457        let recompose = |flat: &[Challenge<SC>]| -> Vec<Challenge<SC>> {
458            if aux_width == 0 {
459                return vec![];
460            }
461            let ext_degree = Challenge::<SC>::DIMENSION;
462            assert!(
463                flat.len() == aux_width * ext_degree,
464                "flattened permutation opening length ({}) must equal aux_width ({}) * DIMENSION ({})",
465                flat.len(),
466                aux_width,
467                ext_degree
468            );
469            // Chunk the flattened coefficients into groups of size `dim`.
470            // Each chunk represents the coefficients of one extension field element.
471            flat.chunks_exact(ext_degree)
472                .map(|coeffs| {
473                    // Dot product: sum(coeff_j * basis_j)
474                    coeffs
475                        .iter()
476                        .enumerate()
477                        .map(|(j, &coeff)| {
478                            coeff
479                                * Challenge::<SC>::ith_basis_element(j)
480                                    .expect("Basis element should exist")
481                        })
482                        .sum()
483                })
484                .collect()
485        };
486
487        let perm_local_ext = recompose(&opened_values.instances[i].permutation_local);
488        let perm_next_ext = recompose(&opened_values.instances[i].permutation_next);
489
490        // Verify constraints at zeta using utility function.
491        let init_trace_domain = trace_domains[i];
492        let trace_next_zeros;
493        let trace_next_ref = match &opened_values.instances[i].base_opened_values.trace_next {
494            Some(v) => v.as_slice(),
495            None => {
496                trace_next_zeros = vec![SC::Challenge::ZERO; A::width(air)];
497                &trace_next_zeros
498            }
499        };
500        let pre_next_zeros;
501        let pre_next_ref = match &opened_values.instances[i]
502            .base_opened_values
503            .preprocessed_next
504        {
505            Some(v) => v.as_slice(),
506            None => {
507                pre_next_zeros = vec![SC::Challenge::ZERO; preprocessed_widths[i]];
508                &pre_next_zeros
509            }
510        };
511        let perm_vals: Vec<SC::Challenge> = global_lookup_data[i]
512            .iter()
513            .map(|ld| ld.expected_cumulated)
514            .collect();
515        let verifier_data = VerifierData {
516            trace_local: &opened_values.instances[i].base_opened_values.trace_local,
517            trace_next: trace_next_ref,
518            preprocessed_local: opened_values.instances[i]
519                .base_opened_values
520                .preprocessed_local
521                .as_ref()
522                .map_or(&[], |v| v),
523            preprocessed_next: pre_next_ref,
524            permutation_local: &perm_local_ext,
525            permutation_next: &perm_next_ext,
526            permutation_challenges: &challenges_per_instance[i],
527            permutation_values: &perm_vals,
528            lookups: &all_lookups[i],
529            public_values: &public_values[i],
530            trace_domain: init_trace_domain,
531            zeta,
532            alpha,
533            quotient,
534        };
535
536        verify_constraints_with_lookups::<SC, A, LogUpGadget, PcsError<SC>>(
537            air,
538            &verifier_data,
539            &lookup_gadget,
540        )
541        .map_err(|e| match e {
542            VerificationError::OodEvaluationMismatch { .. } => {
543                VerificationError::OodEvaluationMismatch { index: Some(i) }
544            }
545            other => other,
546        })?;
547    }
548
549    let mut global_cumulative = HashMap::<&String, Vec<_>>::new();
550    for data in global_lookup_data.iter().flatten() {
551        global_cumulative
552            .entry(&data.name)
553            .or_default()
554            .push(data.expected_cumulated);
555    }
556
557    for (name, all_expected_cumulative) in global_cumulative {
558        lookup_gadget
559            .verify_global_final_value(&all_expected_cumulative)
560            .map_err(|e| VerificationError::LookupError(format!("{e:?}: {name}")))?;
561    }
562
563    Ok(())
564}
565
566/// Structure storing all data needed for verifying one instance's constraints at the out-of-domain point.
567pub struct VerifierData<'a, SC: SGC> {
568    // Out-of-domain point at which constraints are evaluated
569    zeta: SC::Challenge,
570    // Challenge used to fold constraints
571    alpha: SC::Challenge,
572    // Main trace evaluated at `zeta`
573    trace_local: &'a [SC::Challenge],
574    // Main trace evaluated at the following point `g * zeta`, where `g` is the subgroup generator
575    trace_next: &'a [SC::Challenge],
576    // Preprocessed trace evaluated at `zeta`
577    preprocessed_local: &'a [SC::Challenge],
578    // Preprocessed trace evaluated at the following point `g * zeta`, where `g` is the subgroup generator
579    preprocessed_next: &'a [SC::Challenge],
580    // Permutation trace evaluated at `zeta`
581    permutation_local: &'a [SC::Challenge],
582    // Permutation trace evaluated at the following point `g * zeta`, where `g` is the subgroup generator
583    permutation_next: &'a [SC::Challenge],
584    // Challenges used for the lookup argument
585    permutation_challenges: &'a [SC::Challenge],
586    // Expected cumulated values for global lookup arguments
587    permutation_values: &'a [SC::Challenge],
588    // Lookup contexts for this instance
589    lookups: &'a [Lookup<Val<SC>>],
590    // Public values for this instance
591    public_values: &'a [Val<SC>],
592    // Trace domain for this instance
593    trace_domain: Domain<SC>,
594    // Quotient polynomial evaluated at `zeta`
595    quotient: SC::Challenge,
596}
597
598/// Verifies that the folded constraints match the quotient polynomial at zeta.
599///
600/// This evaluates the AIR constraints at the out-of-domain point and checks
601/// that constraints(zeta) / Z_H(zeta) = quotient(zeta).
602#[allow(clippy::too_many_arguments)]
603pub fn verify_constraints_with_lookups<'a, SC, A, LG: LookupGadget, PcsErr: Debug>(
604    air: &A,
605    verifier_data: &VerifierData<'a, SC>,
606    lookup_gadget: &LG,
607) -> Result<(), VerificationError<PcsErr>>
608where
609    SC: SGC,
610    A: for<'b> Air<VerifierConstraintFolderWithLookups<'b, SC>>,
611{
612    let VerifierData {
613        trace_local,
614        trace_next,
615        preprocessed_local,
616        preprocessed_next,
617        permutation_local,
618        permutation_next,
619        permutation_challenges,
620        permutation_values,
621        lookups,
622        public_values,
623        trace_domain,
624        zeta,
625        alpha,
626        quotient,
627    } = verifier_data;
628
629    let sels = trace_domain.selectors_at_point(*zeta);
630
631    let main = VerticalPair::new(
632        RowMajorMatrixView::new_row(trace_local),
633        RowMajorMatrixView::new_row(trace_next),
634    );
635
636    let preprocessed = VerticalPair::new(
637        RowMajorMatrixView::new_row(preprocessed_local),
638        RowMajorMatrixView::new_row(preprocessed_next),
639    );
640
641    let preprocessed_window =
642        RowWindow::from_two_rows(preprocessed.top.values, preprocessed.bottom.values);
643    let inner_folder = VerifierConstraintFolder {
644        main,
645        preprocessed,
646        preprocessed_window,
647        public_values,
648        is_first_row: sels.is_first_row,
649        is_last_row: sels.is_last_row,
650        is_transition: sels.is_transition,
651        alpha: *alpha,
652        accumulator: SC::Challenge::ZERO,
653    };
654    let mut folder = VerifierConstraintFolderWithLookups {
655        inner: inner_folder,
656        permutation: VerticalPair::new(
657            RowMajorMatrixView::new_row(permutation_local),
658            RowMajorMatrixView::new_row(permutation_next),
659        ),
660        permutation_challenges,
661        permutation_values,
662    };
663    // Evaluate AIR and lookup constraints.
664    air.eval_with_lookups(&mut folder, lookups, lookup_gadget);
665    let folded_constraints = folder.inner.accumulator;
666
667    // Check that constraints(zeta) / Z_H(zeta) = quotient(zeta)
668    if folded_constraints * sels.inv_vanishing != *quotient {
669        return Err(VerificationError::OodEvaluationMismatch { index: None });
670    }
671
672    Ok(())
673}