Skip to main content

The Permutation Argument

1. Why this chapter exists

Any time you write region.constrain_equal(a, b) or AssignedCell::copy_advice(...) you are using the permutation argument. It is the mechanism that lets a circuit author treat the table as a graph of cells with edges, rather than a flat collection of rows. The argument is a routine generalization of Plonk's copy-constraint argument, but the multi-column and chunking machinery is non-obvious. If you ever change anything in plonk/permutation/, you need to know what each polynomial in the proof represents.

2. Definitions

Fix n=2kn = 2^k, H={1,ω,,ωn1}H = \{1, \omega, \dots, \omega^{n-1}\}.

Definition (Wire indexing). Suppose the columns enrolled in the permutation argument are P0,,Pc1P_0, \dots, P_{c-1} (each a column of the table). The set of wires is the product {0,1,,c1}×H\{0, 1, \dots, c-1\} \times H of column indices and row labels, giving cnc \cdot n wires total.

Definition (Permutation σ\sigma). A permutation σ:{0,,c1}×H{0,,c1}×H\sigma : \{0, \dots, c-1\} \times H \to \{0, \dots, c-1\} \times H. The cycles of σ\sigma are the equivalence classes of cells joined by constrain_equal. The chunking required when cc is large is described below.

Definition (Cycle-product identity). Define the wire identity P(j,ωi)=(value at column j,row i)P(j, \omega^i) = (\text{value at column } j, \text{row } i). The permutation σ\sigma is consistent with the witness iff, for every pair of (β,γ)Fp2(\beta, \gamma) \in \mathbb{F}_p^2,

j,i(P(j,ωi)+βδjωi+γ)  =  j,i(P(j,ωi)+βsσ(j,ωi)+γ)\prod_{j, i} (P(j, \omega^i) + \beta \cdot \delta^j \cdot \omega^i + \gamma) \;=\; \prod_{j, i} (P(j, \omega^i) + \beta \cdot s_{\sigma}(j, \omega^i) + \gamma)

where δ\delta is a fixed coset shift (a quadratic non-residue) and sσs_\sigma is a polynomial encoding of σ\sigma. The argument is encoded by committing to a running product polynomial zz that realizes this identity.

Definition (Chunking). With many columns, the polynomial zz would have unbounded degree. The argument splits the columns into chunks of size at most degree(z)1\text{degree}(z) - 1 and accumulates a separate grand product z0,z1,z_0, z_1, \dots per chunk. Chunk boundaries introduce additional boundary identities, which is why the comment block in plonk/permutation.rs lists "intermediate" and "final" boundary constraints separately.

3. The code

3.1 The argument struct

halo2_proofs/src/plonk/permutation.rs
use super::circuit::{Any, Column};
use crate::{
arithmetic::CurveAffine,
poly::{Coeff, ExtendedLagrangeCoeff, LagrangeCoeff, Polynomial},
};

pub(crate) mod keygen;
pub(crate) mod prover;
pub(crate) mod verifier;

/// A permutation argument.
#[derive(Debug, Clone)]
pub(crate) struct Argument {
/// A sequence of columns involved in the argument.
columns: Vec<Column<Any>>,
}

impl Argument {
pub(crate) fn new() -> Self {
Argument { columns: vec![] }
}

/// Returns the minimum circuit degree required by the permutation argument.
/// The argument may use larger degree gates depending on the actual
/// circuit's degree and how many columns are involved in the permutation.
pub(crate) fn required_degree(&self) -> usize {
// degree 2:
// l_0(X) * (1 - z(X)) = 0
//
// We will fit as many polynomials p_i(X) as possible
// into the required degree of the circuit, so the
// following will not affect the required degree of
// this middleware.
//
// (1 - (l_last(X) + l_blind(X))) * (
// z(\omega X) \prod (p(X) + \beta s_i(X) + \gamma)
// - z(X) \prod (p(X) + \delta^i \beta X + \gamma)
// )
//
// On the first sets of columns, except the first
// set, we will do
//
// l_0(X) * (z(X) - z'(\omega^(last) X)) = 0
//
// where z'(X) is the permutation for the previous set
// of columns.
//
// On the final set of columns, we will do
//
// degree 3:
// l_last(X) * (z'(X)^2 - z'(X)) = 0
//
// which will allow the last value to be zero to
// ensure the argument is perfectly complete.

// There are constraints of degree 3 regardless of the
// number of columns involved.
3
}

pub(crate) fn add_column(&mut self, column: Column<Any>) {
if !self.columns.contains(&column) {
self.columns.push(column);
}
}

pub(crate) fn get_columns(&self) -> Vec<Column<Any>> {
self.columns.clone()
}
}

/// The verifying key for a single permutation argument.
#[derive(Clone, Debug)]
pub(crate) struct VerifyingKey<C: CurveAffine> {
commitments: Vec<C>,
}

/// The proving key for a single permutation argument.
#[derive(Clone, Debug)]
pub(crate) struct ProvingKey<C: CurveAffine> {
permutations: Vec<Polynomial<C::Scalar, LagrangeCoeff>>,
polys: Vec<Polynomial<C::Scalar, Coeff>>,
pub(super) cosets: Vec<Polynomial<C::Scalar, ExtendedLagrangeCoeff>>,
}

Argument carries the list of columns enrolled in the permutation; required_degree records the four constraint patterns the verifier checks (in the same style as the lookup chapter):

  • L0(X)(1z(X))=0L_0(X) \cdot (1 - z(X)) = 0,
  • a chunk-internal grand-product recurrence over j(pj(X)+βsj(X)+γ)/j(pj(X)+βδjX+γ)\prod_j (p_j(X) + \beta s_j(X) + \gamma) / \prod_j (p_j(X) + \beta \delta^j X + \gamma),
  • a "chain" constraint L0(X)(zk(X)zk1(ωn1X))=0L_0(X) \cdot (z_k(X) - z_{k-1}(\omega^{n-1} X)) = 0 for k1k \geq 1, linking chunk kk to the end of chunk k1k - 1,
  • Llast(X)(zlast(X)2zlast(X))=0L_{\text{last}}(X) \cdot (z_\text{last}(X)^2 - z_\text{last}(X)) = 0, ensuring the last value is in {0,1}\{0, 1\}.

3.2 Enrolling columns

The user-facing API:

halo2_proofs/src/plonk/circuit.rs
/// Enable the ability to enforce equality over cells in this column
pub fn enable_equality<C: Into<Column<Any>>>(&mut self, column: C) {
let column = column.into();
self.query_any_index(column, Rotation::cur());
self.permutation.add_column(column);
}

/// Add a lookup argument for some input expressions and table columns.
///
/// `table_map` returns a map between input expressions and the table columns
/// they need to match.

meta.enable_equality(column) is the only entry point. It adds the column to the permutation argument's column list, after which Region::constrain_equal can target cells in that column. The order of enable_equality calls matters: it determines the wire indexing jj, which appears in the δj\delta^j term of the constraint and therefore in the verifying key. Reordering it breaks verifying-key compatibility.

3.3 Keygen

halo2_proofs/src/plonk/permutation/keygen.rs
use group::{
ff::{Field, PrimeField},
Curve,
};

use super::{Argument, ProvingKey, VerifyingKey};
use crate::{
arithmetic::CurveAffine,
plonk::{Any, Column, Error},
poly::{
commitment::{Blind, Params},
EvaluationDomain,
},
};

#[derive(Debug)]
pub(crate) struct Assembly {
columns: Vec<Column<Any>>,
pub(crate) mapping: Vec<Vec<(usize, usize)>>,
aux: Vec<Vec<(usize, usize)>>,
sizes: Vec<Vec<usize>>,
}

impl Assembly {
pub(crate) fn new(n: usize, p: &Argument) -> Self {
// Initialize the copy vector to keep track of copy constraints in all
// the permutation arguments.
let mut columns = vec![];
for i in 0..p.columns.len() {
// Computes [(i, 0), (i, 1), ..., (i, n - 1)]
columns.push((0..n).map(|j| (i, j)).collect());
}

// Before any equality constraints are applied, every cell in the permutation is
// in a 1-cycle; therefore mapping and aux are identical, because every cell is
// its own distinguished element.
Assembly {
columns: p.columns.clone(),
mapping: columns.clone(),
aux: columns,
sizes: vec![vec![1usize; n]; p.columns.len()],
}
}

pub(crate) fn copy(
&mut self,
left_column: Column<Any>,
left_row: usize,
right_column: Column<Any>,
right_row: usize,
) -> Result<(), Error> {
let left_column = self
.columns
.iter()
.position(|c| c == &left_column)
.ok_or(Error::ColumnNotInPermutation(left_column))?;
let right_column = self
.columns
.iter()
.position(|c| c == &right_column)
.ok_or(Error::ColumnNotInPermutation(right_column))?;

// Check bounds
if left_row >= self.mapping[left_column].len()
|| right_row >= self.mapping[right_column].len()
{
return Err(Error::BoundsFailure);
}

// See book/src/design/permutation.md for a description of this algorithm.

let mut left_cycle = self.aux[left_column][left_row];
let mut right_cycle = self.aux[right_column][right_row];

// If left and right are in the same cycle, do nothing.
if left_cycle == right_cycle {
return Ok(());
}

if self.sizes[left_cycle.0][left_cycle.1] < self.sizes[right_cycle.0][right_cycle.1] {
std::mem::swap(&mut left_cycle, &mut right_cycle);
}

// Merge the right cycle into the left one.
self.sizes[left_cycle.0][left_cycle.1] += self.sizes[right_cycle.0][right_cycle.1];
let mut i = right_cycle;
loop {
self.aux[i.0][i.1] = left_cycle;
i = self.mapping[i.0][i.1];
if i == right_cycle {
break;
}
}

let tmp = self.mapping[left_column][left_row];
self.mapping[left_column][left_row] = self.mapping[right_column][right_row];
self.mapping[right_column][right_row] = tmp;

Ok(())
}

pub(crate) fn build_vk<C: CurveAffine>(
self,
params: &Params<C>,
domain: &EvaluationDomain<C::Scalar>,
p: &Argument,
) -> VerifyingKey<C> {
// Compute [omega^0, omega^1, ..., omega^{params.n - 1}]
let mut omega_powers = Vec::with_capacity(params.n as usize);
{
let mut cur = C::Scalar::ONE;
for _ in 0..params.n {
omega_powers.push(cur);
cur *= &domain.get_omega();
}
}

// Compute [omega_powers * \delta^0, omega_powers * \delta^1, ..., omega_powers * \delta^m]
let mut deltaomega = Vec::with_capacity(p.columns.len());
{
let mut cur = C::Scalar::ONE;
for _ in 0..p.columns.len() {
let mut omega_powers = omega_powers.clone();
for o in &mut omega_powers {
*o *= &cur;
}

deltaomega.push(omega_powers);

cur *= &C::Scalar::DELTA;
}
}

// Pre-compute commitments for the URS.
let mut commitments = Vec::with_capacity(p.columns.len());
for i in 0..p.columns.len() {
// Computes the permutation polynomial based on the permutation
// description in the assembly.
let mut permutation_poly = domain.empty_lagrange();
for (j, p) in permutation_poly.iter_mut().enumerate() {
let (permuted_i, permuted_j) = self.mapping[i][j];
*p = deltaomega[permuted_i][permuted_j];
}

// Compute commitment to permutation polynomial
commitments.push(
params
.commit_lagrange(&permutation_poly, Blind::default())
.to_affine(),
);
}
VerifyingKey { commitments }
}

pub(crate) fn build_pk<C: CurveAffine>(
self,
params: &Params<C>,
domain: &EvaluationDomain<C::Scalar>,
p: &Argument,
) -> ProvingKey<C> {
// Compute [omega^0, omega^1, ..., omega^{params.n - 1}]
let mut omega_powers = Vec::with_capacity(params.n as usize);
{
let mut cur = C::Scalar::ONE;
for _ in 0..params.n {
omega_powers.push(cur);
cur *= &domain.get_omega();
}
}

// Compute [omega_powers * \delta^0, omega_powers * \delta^1, ..., omega_powers * \delta^m]
let mut deltaomega = Vec::with_capacity(p.columns.len());
{
let mut cur = C::Scalar::ONE;
for _ in 0..p.columns.len() {
let mut omega_powers = omega_powers.clone();
for o in &mut omega_powers {
*o *= &cur;
}

deltaomega.push(omega_powers);

cur *= &C::Scalar::DELTA;
}
}

// Compute permutation polynomials, convert to coset form.
let mut permutations = vec![];
let mut polys = vec![];
let mut cosets = vec![];
for i in 0..p.columns.len() {
// Computes the permutation polynomial based on the permutation
// description in the assembly.
let mut permutation_poly = domain.empty_lagrange();
for (j, p) in permutation_poly.iter_mut().enumerate() {
let (permuted_i, permuted_j) = self.mapping[i][j];
*p = deltaomega[permuted_i][permuted_j];
}

// Store permutation polynomial and precompute its coset evaluation
permutations.push(permutation_poly.clone());
let poly = domain.lagrange_to_coeff(permutation_poly);
polys.push(poly.clone());
cosets.push(domain.coeff_to_extended(poly));
}
ProvingKey {
permutations,
polys,
cosets,
}
}
}

The keygen step:

  1. Walks the equality-constraint graph produced by the layouter, computes the union-find of cycles.
  2. Encodes each cycle as a permutation σ\sigma on the (cn)(c \cdot n)-wire space.
  3. Builds the cc permutation polynomials s0,,sc1s_0, \dots, s_{c-1} in Lagrange basis, where sj(ωi)s_j(\omega^i) holds the next wire identity in σ\sigma's cycle starting from (j,ωi)(j, \omega^i).
  4. Commits to each sjs_j. The commitments become the permutation VerifyingKey.

3.4 The prover

halo2_proofs/src/plonk/permutation/prover.rs
use group::{
ff::{BatchInvert, Field, PrimeField},
Curve,
};
use rand_core::RngCore;
use std::iter::{self, ExactSizeIterator};

use super::super::{circuit::Any, ChallengeBeta, ChallengeGamma, ChallengeX};
use super::{Argument, ProvingKey};
use crate::{
arithmetic::{eval_polynomial, parallelize, CurveAffine},
plonk::{self, Error},
poly::{
self,
commitment::{Blind, Params},
multiopen::ProverQuery,
Coeff, ExtendedLagrangeCoeff, LagrangeCoeff, Polynomial, Rotation,
},
transcript::{EncodedChallenge, TranscriptWrite},
};

pub struct CommittedSet<C: CurveAffine, Ev> {
permutation_product_poly: Polynomial<C::Scalar, Coeff>,
permutation_product_coset: poly::AstLeaf<Ev, ExtendedLagrangeCoeff>,
permutation_product_blind: Blind<C::Scalar>,
}

pub(crate) struct Committed<C: CurveAffine, Ev> {
sets: Vec<CommittedSet<C, Ev>>,
}

pub struct ConstructedSet<C: CurveAffine> {
permutation_product_poly: Polynomial<C::Scalar, Coeff>,
permutation_product_blind: Blind<C::Scalar>,
}

pub(crate) struct Constructed<C: CurveAffine> {
sets: Vec<ConstructedSet<C>>,
}

pub(crate) struct Evaluated<C: CurveAffine> {
constructed: Constructed<C>,
}

impl Argument {
#[allow(clippy::too_many_arguments)]
pub(in crate::plonk) fn commit<
C: CurveAffine,
E: EncodedChallenge<C>,
Ev: Copy + Send + Sync,
R: RngCore,
T: TranscriptWrite<C, E>,
>(
&self,
params: &Params<C>,
pk: &plonk::ProvingKey<C>,
pkey: &ProvingKey<C>,
advice: &[Polynomial<C::Scalar, LagrangeCoeff>],
fixed: &[Polynomial<C::Scalar, LagrangeCoeff>],
instance: &[Polynomial<C::Scalar, LagrangeCoeff>],
beta: ChallengeBeta<C>,
gamma: ChallengeGamma<C>,
evaluator: &mut poly::Evaluator<Ev, C::Scalar, ExtendedLagrangeCoeff>,
mut rng: R,
transcript: &mut T,
) -> Result<Committed<C, Ev>, Error> {
let domain = &pk.vk.domain;

// How many columns can be included in a single permutation polynomial?
// We need to multiply by z(X) and (1 - (l_last(X) + l_blind(X))). This
// will never underflow because of the requirement of at least a degree
// 3 circuit for the permutation argument.
assert!(pk.vk.cs_degree >= 3);
let chunk_len = pk.vk.cs_degree - 2;
let blinding_factors = pk.vk.cs.blinding_factors();

// Each column gets its own delta power.
let mut deltaomega = C::Scalar::ONE;

// Track the "last" value from the previous column set
let mut last_z = C::Scalar::ONE;

let mut sets = vec![];

for (columns, permutations) in self
.columns
.chunks(chunk_len)
.zip(pkey.permutations.chunks(chunk_len))
{
// Goal is to compute the products of fractions
//
// (p_j(\omega^i) + \delta^j \omega^i \beta + \gamma) /
// (p_j(\omega^i) + \beta s_j(\omega^i) + \gamma)
//
// where p_j(X) is the jth column in this permutation,
// and i is the ith row of the column.

let mut modified_values = vec![C::Scalar::ONE; params.n as usize];

// Iterate over each column of the permutation
for (&column, permuted_column_values) in columns.iter().zip(permutations.iter()) {
let values = match column.column_type() {
Any::Advice => advice,
Any::Fixed => fixed,
Any::Instance => instance,
};
parallelize(&mut modified_values, |modified_values, start| {
for ((modified_values, value), permuted_value) in modified_values
.iter_mut()
.zip(values[column.index()][start..].iter())
.zip(permuted_column_values[start..].iter())
{
*modified_values *= &(*beta * permuted_value + &*gamma + value);
}
});
}

// Invert to obtain the denominator for the permutation product polynomial
modified_values.batch_invert();

// Iterate over each column again, this time finishing the computation
// of the entire fraction by computing the numerators
for &column in columns.iter() {
let omega = domain.get_omega();
let values = match column.column_type() {
Any::Advice => advice,
Any::Fixed => fixed,
Any::Instance => instance,
};
parallelize(&mut modified_values, |modified_values, start| {
let mut deltaomega = deltaomega * &omega.pow_vartime([start as u64]);
for (modified_values, value) in modified_values
.iter_mut()
.zip(values[column.index()][start..].iter())
{
// Multiply by p_j(\omega^i) + \delta^j \omega^i \beta
*modified_values *= &(deltaomega * &*beta + &*gamma + value);
deltaomega *= &omega;
}
});
deltaomega *= &C::Scalar::DELTA;
}

// The modified_values vector is a vector of products of fractions
// of the form
//
// (p_j(\omega^i) + \delta^j \omega^i \beta + \gamma) /
// (p_j(\omega^i) + \beta s_j(\omega^i) + \gamma)
//
// where i is the index into modified_values, for the jth column in
// the permutation

// Compute the evaluations of the permutation product polynomial
// over our domain, starting with z[0] = 1
let mut z = vec![last_z];
for row in 1..(params.n as usize) {
let mut tmp = z[row - 1];

tmp *= &modified_values[row - 1];
z.push(tmp);
}
let mut z = domain.lagrange_from_vec(z);
// Set blinding factors
for z in &mut z[params.n as usize - blinding_factors..] {
*z = C::Scalar::random(&mut rng);
}
// Set new last_z
last_z = z[params.n as usize - (blinding_factors + 1)];

let blind = Blind(C::Scalar::random(&mut rng));

let permutation_product_commitment_projective = params.commit_lagrange(&z, blind);
let permutation_product_blind = blind;
let z = domain.lagrange_to_coeff(z);
let permutation_product_poly = z.clone();

let permutation_product_coset =
evaluator.register_poly(domain.coeff_to_extended(z.clone()));

let permutation_product_commitment =
permutation_product_commitment_projective.to_affine();

// Hash the permutation product commitment
transcript.write_point(permutation_product_commitment)?;

sets.push(CommittedSet {
permutation_product_poly,
permutation_product_coset,
permutation_product_blind,
});
}

Ok(Committed { sets })
}
}

impl<C: CurveAffine, Ev: Copy + Send + Sync> Committed<C, Ev> {
#[allow(clippy::too_many_arguments)]
pub(in crate::plonk) fn construct<'a>(
self,
pk: &'a plonk::ProvingKey<C>,
p: &'a Argument,
advice_cosets: &'a [poly::AstLeaf<Ev, ExtendedLagrangeCoeff>],
fixed_cosets: &'a [poly::AstLeaf<Ev, ExtendedLagrangeCoeff>],
instance_cosets: &'a [poly::AstLeaf<Ev, ExtendedLagrangeCoeff>],
permutation_cosets: &'a [poly::AstLeaf<Ev, ExtendedLagrangeCoeff>],
l0: poly::AstLeaf<Ev, ExtendedLagrangeCoeff>,
l_blind: poly::AstLeaf<Ev, ExtendedLagrangeCoeff>,
l_last: poly::AstLeaf<Ev, ExtendedLagrangeCoeff>,
beta: ChallengeBeta<C>,
gamma: ChallengeGamma<C>,
) -> (
Constructed<C>,
impl Iterator<Item = poly::Ast<Ev, C::Scalar, ExtendedLagrangeCoeff>> + 'a,
) {
let chunk_len = pk.vk.cs_degree - 2;
let blinding_factors = pk.vk.cs.blinding_factors();
let last_rotation = Rotation(-((blinding_factors + 1) as i32));

let constructed = Constructed {
sets: self
.sets
.iter()
.map(|set| ConstructedSet {
permutation_product_poly: set.permutation_product_poly.clone(),
permutation_product_blind: set.permutation_product_blind,
})
.collect(),
};

let expressions = iter::empty()
// Enforce only for the first set.
// l_0(X) * (1 - z_0(X)) = 0
.chain(
self.sets
.first()
.map(|first_set| (poly::Ast::one() - first_set.permutation_product_coset) * l0),
)
// Enforce only for the last set.
// l_last(X) * (z_l(X)^2 - z_l(X)) = 0
.chain(self.sets.last().map(|last_set| {
((poly::Ast::from(last_set.permutation_product_coset)
* last_set.permutation_product_coset)
- last_set.permutation_product_coset)
* l_last
}))
// Except for the first set, enforce.
// l_0(X) * (z_i(X) - z_{i-1}(\omega^(last) X)) = 0
.chain(
self.sets
.iter()
.skip(1)
.zip(self.sets.iter())
.map(|(set, last_set)| {
(poly::Ast::from(set.permutation_product_coset)
- last_set
.permutation_product_coset
.with_rotation(last_rotation))
* l0
})
.collect::<Vec<_>>(),
)
// And for all the sets we enforce:
// (1 - (l_last(X) + l_blind(X))) * (
// z_i(\omega X) \prod_j (p(X) + \beta s_j(X) + \gamma)
// - z_i(X) \prod_j (p(X) + \delta^j \beta X + \gamma)
// )
.chain(
self.sets
.into_iter()
.zip(p.columns.chunks(chunk_len))
.zip(permutation_cosets.chunks(chunk_len))
.enumerate()
.map(move |(chunk_index, ((set, columns), cosets))| {
let mut left = poly::Ast::<_, C::Scalar, _>::from(
set.permutation_product_coset
.with_rotation(Rotation::next()),
);
for (values, permutation) in columns
.iter()
.map(|&column| match column.column_type() {
Any::Advice => &advice_cosets[column.index()],
Any::Fixed => &fixed_cosets[column.index()],
Any::Instance => &instance_cosets[column.index()],
})
.zip(cosets.iter())
{
left *= poly::Ast::<_, C::Scalar, _>::from(*values)
+ (poly::Ast::ConstantTerm(*beta) * poly::Ast::from(*permutation))
+ poly::Ast::ConstantTerm(*gamma);
}

let mut right = poly::Ast::from(set.permutation_product_coset);
let mut current_delta = *beta
* &(C::Scalar::DELTA.pow_vartime([(chunk_index * chunk_len) as u64]));
for values in columns.iter().map(|&column| match column.column_type() {
Any::Advice => &advice_cosets[column.index()],
Any::Fixed => &fixed_cosets[column.index()],
Any::Instance => &instance_cosets[column.index()],
}) {
right *= poly::Ast::from(*values)
+ poly::Ast::LinearTerm(current_delta)
+ poly::Ast::ConstantTerm(*gamma);
current_delta *= &C::Scalar::DELTA;
}

(left - right) * (poly::Ast::one() - (poly::Ast::from(l_last) + l_blind))
}),
);

(constructed, expressions)
}
}

impl<C: CurveAffine> super::ProvingKey<C> {
pub(in crate::plonk) fn open(
&self,
x: ChallengeX<C>,
) -> impl Iterator<Item = ProverQuery<'_, C>> + Clone {
self.polys.iter().map(move |poly| ProverQuery {
point: *x,
poly,
blind: Blind::default(),
})
}

pub(in crate::plonk) fn evaluate<E: EncodedChallenge<C>, T: TranscriptWrite<C, E>>(
&self,
x: ChallengeX<C>,
transcript: &mut T,
) -> Result<(), Error> {
// Hash permutation evals
for eval in self.polys.iter().map(|poly| eval_polynomial(poly, *x)) {
transcript.write_scalar(eval)?;
}

Ok(())
}
}

impl<C: CurveAffine> Constructed<C> {
pub(in crate::plonk) fn evaluate<E: EncodedChallenge<C>, T: TranscriptWrite<C, E>>(
self,
pk: &plonk::ProvingKey<C>,
x: ChallengeX<C>,
transcript: &mut T,
) -> Result<Evaluated<C>, Error> {
let domain = &pk.vk.domain;
let blinding_factors = pk.vk.cs.blinding_factors();

{
let mut sets = self.sets.iter();

while let Some(set) = sets.next() {
let permutation_product_eval = eval_polynomial(&set.permutation_product_poly, *x);

let permutation_product_next_eval = eval_polynomial(
&set.permutation_product_poly,
domain.rotate_omega(*x, Rotation::next()),
);

// Hash permutation product evals
for eval in iter::empty()
.chain(Some(&permutation_product_eval))
.chain(Some(&permutation_product_next_eval))
{
transcript.write_scalar(*eval)?;
}

// If we have any remaining sets to process, evaluate this set at omega^u
// so we can constrain the last value of its running product to equal the
// first value of the next set's running product, chaining them together.
if sets.len() > 0 {
let permutation_product_last_eval = eval_polynomial(
&set.permutation_product_poly,
domain.rotate_omega(*x, Rotation(-((blinding_factors + 1) as i32))),
);

transcript.write_scalar(permutation_product_last_eval)?;
}
}
}

Ok(Evaluated { constructed: self })
}
}

impl<C: CurveAffine> Evaluated<C> {
pub(in crate::plonk) fn open<'a>(
&'a self,
pk: &'a plonk::ProvingKey<C>,
x: ChallengeX<C>,
) -> impl Iterator<Item = ProverQuery<'a, C>> + Clone {
let blinding_factors = pk.vk.cs.blinding_factors();
let x_next = pk.vk.domain.rotate_omega(*x, Rotation::next());
let x_last = pk
.vk
.domain
.rotate_omega(*x, Rotation(-((blinding_factors + 1) as i32)));

iter::empty()
.chain(self.constructed.sets.iter().flat_map(move |set| {
iter::empty()
// Open permutation product commitments at x and \omega x
.chain(Some(ProverQuery {
point: *x,
poly: &set.permutation_product_poly,
blind: set.permutation_product_blind,
}))
.chain(Some(ProverQuery {
point: x_next,
poly: &set.permutation_product_poly,
blind: set.permutation_product_blind,
}))
}))
// Open it at \omega^{last} x for all but the last set. This rotation is only
// sensical for the first row, but we only use this rotation in a constraint
// that is gated on l_0.
.chain(
self.constructed
.sets
.iter()
.rev()
.skip(1)
.flat_map(move |set| {
Some(ProverQuery {
point: x_last,
poly: &set.permutation_product_poly,
blind: set.permutation_product_blind,
})
}),
)
}
}

The prover (per chunk):

  1. Computes the numerator product j(Pj(X)+βδjX+γ)\prod_j (P_j(X) + \beta \delta^j X + \gamma) and the denominator j(Pj(X)+βsj(X)+γ)\prod_j (P_j(X) + \beta s_j(X) + \gamma) on the small domain.
  2. Inverts the denominator using a batch inversion.
  3. Walks the rows building zkz_k as the running product.
  4. Commits to zkz_k.

3.5 The verifier

halo2_proofs/src/plonk/permutation/verifier.rs
use group::ff::{Field, PrimeField};

use std::iter;

use super::super::{circuit::Any, ChallengeBeta, ChallengeGamma, ChallengeX};
use super::{Argument, VerifyingKey};
use crate::{
arithmetic::CurveAffine,
plonk::{self, Error},
poly::{multiopen::VerifierQuery, Rotation},
transcript::{EncodedChallenge, TranscriptRead},
};

pub struct Committed<C: CurveAffine> {
permutation_product_commitments: Vec<C>,
}

pub struct EvaluatedSet<C: CurveAffine> {
permutation_product_commitment: C,
permutation_product_eval: C::Scalar,
permutation_product_next_eval: C::Scalar,
permutation_product_last_eval: Option<C::Scalar>,
}

pub struct CommonEvaluated<C: CurveAffine> {
permutation_evals: Vec<C::Scalar>,
}

pub struct Evaluated<C: CurveAffine> {
sets: Vec<EvaluatedSet<C>>,
}

impl Argument {
pub(crate) fn read_product_commitments<
C: CurveAffine,
E: EncodedChallenge<C>,
T: TranscriptRead<C, E>,
>(
&self,
vk: &plonk::VerifyingKey<C>,
transcript: &mut T,
) -> Result<Committed<C>, Error> {
let chunk_len = vk.cs_degree - 2;

let permutation_product_commitments = self
.columns
.chunks(chunk_len)
.map(|_| transcript.read_point())
.collect::<Result<Vec<_>, _>>()?;

Ok(Committed {
permutation_product_commitments,
})
}
}

impl<C: CurveAffine> VerifyingKey<C> {
pub(in crate::plonk) fn evaluate<E: EncodedChallenge<C>, T: TranscriptRead<C, E>>(
&self,
transcript: &mut T,
) -> Result<CommonEvaluated<C>, Error> {
let permutation_evals = self
.commitments
.iter()
.map(|_| transcript.read_scalar())
.collect::<Result<Vec<_>, _>>()?;

Ok(CommonEvaluated { permutation_evals })
}
}

impl<C: CurveAffine> Committed<C> {
pub(crate) fn evaluate<E: EncodedChallenge<C>, T: TranscriptRead<C, E>>(
self,
transcript: &mut T,
) -> Result<Evaluated<C>, Error> {
let mut sets = vec![];

let mut iter = self.permutation_product_commitments.into_iter();

while let Some(permutation_product_commitment) = iter.next() {
let permutation_product_eval = transcript.read_scalar()?;
let permutation_product_next_eval = transcript.read_scalar()?;
let permutation_product_last_eval = if iter.len() > 0 {
Some(transcript.read_scalar()?)
} else {
None
};

sets.push(EvaluatedSet {
permutation_product_commitment,
permutation_product_eval,
permutation_product_next_eval,
permutation_product_last_eval,
});
}

Ok(Evaluated { sets })
}
}

impl<C: CurveAffine> Evaluated<C> {
#[allow(clippy::too_many_arguments)]
pub(in crate::plonk) fn expressions<'a>(
&'a self,
vk: &'a plonk::VerifyingKey<C>,
p: &'a Argument,
common: &'a CommonEvaluated<C>,
advice_evals: &'a [C::Scalar],
fixed_evals: &'a [C::Scalar],
instance_evals: &'a [C::Scalar],
l_0: C::Scalar,
l_last: C::Scalar,
l_blind: C::Scalar,
beta: ChallengeBeta<C>,
gamma: ChallengeGamma<C>,
x: ChallengeX<C>,
) -> impl Iterator<Item = C::Scalar> + 'a {
let chunk_len = vk.cs_degree - 2;
iter::empty()
// Enforce only for the first set.
// l_0(X) * (1 - z_0(X)) = 0
.chain(
self.sets
.first()
.map(|first_set| l_0 * &(C::Scalar::ONE - &first_set.permutation_product_eval)),
)
// Enforce only for the last set.
// l_last(X) * (z_l(X)^2 - z_l(X)) = 0
.chain(self.sets.last().map(|last_set| {
(last_set.permutation_product_eval.square() - &last_set.permutation_product_eval)
* &l_last
}))
// Except for the first set, enforce.
// l_0(X) * (z_i(X) - z_{i-1}(\omega^(last) X)) = 0
.chain(
self.sets
.iter()
.skip(1)
.zip(self.sets.iter())
.map(|(set, last_set)| {
(
set.permutation_product_eval,
last_set.permutation_product_last_eval.unwrap(),
)
})
.map(move |(set, prev_last)| (set - &prev_last) * &l_0),
)
// And for all the sets we enforce:
// (1 - (l_last(X) + l_blind(X))) * (
// z_i(\omega X) \prod (p(X) + \beta s_i(X) + \gamma)
// - z_i(X) \prod (p(X) + \delta^i \beta X + \gamma)
// )
.chain(
self.sets
.iter()
.zip(p.columns.chunks(chunk_len))
.zip(common.permutation_evals.chunks(chunk_len))
.enumerate()
.map(move |(chunk_index, ((set, columns), permutation_evals))| {
let mut left = set.permutation_product_next_eval;
for (eval, permutation_eval) in columns
.iter()
.map(|&column| match column.column_type() {
Any::Advice => advice_evals[vk.cs.get_any_query_index(column)],
Any::Fixed => fixed_evals[vk.cs.get_any_query_index(column)],
Any::Instance => instance_evals[vk.cs.get_any_query_index(column)],
})
.zip(permutation_evals.iter())
{
left *= &(eval + &(*beta * permutation_eval) + &*gamma);
}

let mut right = set.permutation_product_eval;
let mut current_delta = (*beta * &*x)
* &(C::Scalar::DELTA.pow_vartime([(chunk_index * chunk_len) as u64]));
for eval in columns.iter().map(|&column| match column.column_type() {
Any::Advice => advice_evals[vk.cs.get_any_query_index(column)],
Any::Fixed => fixed_evals[vk.cs.get_any_query_index(column)],
Any::Instance => instance_evals[vk.cs.get_any_query_index(column)],
}) {
right *= &(eval + &current_delta + &*gamma);
current_delta *= &C::Scalar::DELTA;
}

(left - &right) * (C::Scalar::ONE - &(l_last + &l_blind))
}),
)
}

pub(in crate::plonk) fn queries<'r, 'params: 'r>(
&'r self,
vk: &'r plonk::VerifyingKey<C>,
x: ChallengeX<C>,
) -> impl Iterator<Item = VerifierQuery<'r, 'params, C>> + Clone {
let blinding_factors = vk.cs.blinding_factors();
let x_next = vk.domain.rotate_omega(*x, Rotation::next());
let x_last = vk
.domain
.rotate_omega(*x, Rotation(-((blinding_factors + 1) as i32)));

iter::empty()
.chain(self.sets.iter().flat_map(move |set| {
iter::empty()
// Open permutation product commitments at x and \omega^{-1} x
// Open permutation product commitments at x and \omega x
.chain(Some(VerifierQuery::new_commitment(
&set.permutation_product_commitment,
*x,
set.permutation_product_eval,
)))
.chain(Some(VerifierQuery::new_commitment(
&set.permutation_product_commitment,
x_next,
set.permutation_product_next_eval,
)))
}))
// Open it at \omega^{last} x for all but the last set
.chain(self.sets.iter().rev().skip(1).flat_map(move |set| {
Some(VerifierQuery::new_commitment(
&set.permutation_product_commitment,
x_last,
set.permutation_product_last_eval.unwrap(),
))
}))
}
}

impl<C: CurveAffine> CommonEvaluated<C> {
pub(in crate::plonk) fn queries<'r, 'params: 'r>(
&'r self,
vkey: &'r VerifyingKey<C>,
x: ChallengeX<C>,
) -> impl Iterator<Item = VerifierQuery<'r, 'params, C>> + Clone {
// Open permutation commitments for each permutation argument at x
vkey.commitments
.iter()
.zip(self.permutation_evals.iter())
.map(move |(commitment, &eval)| VerifierQuery::new_commitment(commitment, *x, eval))
}
}

The verifier expects to find one commitment per chunk in the transcript, queries each zkz_k at xx and ωx\omega \cdot x, then checks the four identities listed in 3.1 above. The check at LlastL_{\text{last}} that zlast{0,1}z_\text{last} \in \{0, 1\} is what closes the loop, since the recurrence and boundary identities together imply zlast=1z_\text{last} = 1 exactly when the multiset equality holds.

4. Failure modes

  • Forgetting enable_equality. The most frequent contributor mistake. The error is Error::ColumnNotInPermutation; the fix is to add meta.enable_equality(col) in the configure block.
  • Changing the enrollment order. Adding a new enable_equality(col) call between two existing ones shifts every later column's wire index by one, which changes the δj\delta^j term in the proof identity and breaks every existing verifying key for that circuit. Add new columns at the end of the enrollment list.
  • Mixing chunking sizes. The chunk size is implied by the circuit's degree. If you increase the gate degree, the chunk size grows, the number of chunks shrinks, and the verifying key changes. This is one of the surprising consequences of "adding a high-degree custom gate".
  • Inappropriate use of assign_advice_from_constant. This path piggybacks on the permutation argument via the ConstraintSystem::constants columns. If the constants column was not enrolled (the user forgot to add it via meta.enable_constant), the helper returns NotEnoughColumnsForConstants.

5. Spec pointers

6. Exercises

  1. Open halo2_proofs/src/plonk/permutation.rs and read the comment in required_degree. Identify each of the four constraint patterns by name.
  2. Use MockProver to verify a circuit, then manually mutate one advice cell after synthesis (you will need to use the dev tools' internal API). Confirm the failure surface is Permutation { column, location } and matches the cell you broke.
  3. Re-read halo2_proofs/src/plonk/permutation/keygen.rs and describe in two sentences how the cycles in the equality graph become the permutation σ\sigma.
  4. Run circuit-layout and compute (by hand or with CircuitCost) how the permutation polynomial count changes when you add another advice column to the simple-example circuit.

Answers in the code

  • Exercise 1: L0(X)(1z(X))L_0(X)(1 - z(X)) boundary; chunk-internal recurrence; chunk-chaining constraint L0(X)(zk(X)zk1(ωn1X))L_0(X)(z_k(X) - z_{k-1}(\omega^{n-1} X)); final boundary Llast(X)(zlast(X)2zlast(X))L_{\text{last}}(X)(z_\text{last}(X)^2 - z_\text{last}(X)).

7. Further reading