binius_iop/fri/fold.rs
1// Copyright 2024-2025 Irreducible Inc.
2// Copyright 2026 The Binius Developers
3
4use binius_field::{BinaryField, ExtensionField};
5use binius_math::{line::extrapolate_line, ntt::AdditiveNTT};
6
7use super::{FRIParams, error::Error};
8use crate::merkle_channel::MerkleIPVerifierChannel;
9
10/// Calculate fold of `values` at `index` with `r` random coefficient.
11///
12/// See [DP24], Def. 3.6.
13///
14/// [DP24]: <https://eprint.iacr.org/2024/504>
15#[inline]
16fn fold_pair<F, FS, NTT>(ntt: &NTT, round: usize, index: usize, values: (F, F), r: F) -> F
17where
18 F: BinaryField + ExtensionField<FS>,
19 FS: BinaryField,
20 NTT: AdditiveNTT<Field = FS>,
21{
22 // Perform inverse additive NTT butterfly
23 let t = ntt.twiddle(round - 1, index);
24 let (mut u, mut v) = values;
25 v += u;
26 u += v * t;
27 extrapolate_line(u, v, r)
28}
29
30/// Calculate FRI fold of `values` at a `chunk_index` with random folding challenges.
31///
32/// Folds a coset of a Reed–Solomon codeword into a single value using the FRI folding algorithm.
33/// The coset has size $2^n$, where $n$ is the number of challenges.
34///
35/// See [DP24], Def. 3.6 and Lemma 3.9 for more details.
36///
37/// NB: This method is on a hot path and does not perform any allocations or
38/// precondition checks.
39///
40/// ## Arguments
41///
42/// * `math` - the NTT instance, used to look up the twiddle values.
43/// * `log_len` - the binary logarithm of the code length.
44/// * `chunk_index` - the index of the chunk, of size $2^n$, in the full codeword.
45/// * `values` - mutable slice of values to fold, modified in place.
46/// * `challenges` - the sequence of folding challenges, with length $n$.
47///
48/// ## Pre-conditions
49///
50/// - `challenges.len() <= log_len`.
51/// - `log_len <= math.log_domain_size()`, so that the NTT domain is large enough.
52/// - `values.len() == 1 << challenges.len()`.
53///
54/// [DP24]: <https://eprint.iacr.org/2024/504>
55#[inline]
56pub fn fold_chunk<F, FS, NTT>(
57 ntt: &NTT,
58 mut log_len: usize,
59 chunk_index: usize,
60 values: &mut [F],
61 challenges: &[F],
62) -> F
63where
64 F: BinaryField + ExtensionField<FS>,
65 FS: BinaryField,
66 NTT: AdditiveNTT<Field = FS>,
67{
68 let mut log_size = challenges.len();
69
70 // Preconditions
71 debug_assert!(log_size <= log_len);
72 debug_assert!(log_len <= ntt.log_domain_size());
73 debug_assert_eq!(values.len(), 1 << log_size);
74
75 // FRI-fold the values in place.
76 for &challenge in challenges {
77 // Fold the (2i) and (2i+1)th cells of the scratch buffer in-place into the i-th cell
78 for index_offset in 0..1 << (log_size - 1) {
79 let pair = (values[index_offset << 1], values[(index_offset << 1) | 1]);
80 values[index_offset] = fold_pair(
81 ntt,
82 log_len,
83 (chunk_index << (log_size - 1)) | index_offset,
84 pair,
85 challenge,
86 );
87 }
88
89 log_len -= 1;
90 log_size -= 1;
91 }
92
93 values[0]
94}
95
96/// One commitment the prover is expected to send during the fold phase.
97struct ExpectedCommitment {
98 /// The fold round this commitment arrives in.
99 round: usize,
100 /// Number of field elements packed into a single leaf of the Merkle tree.
101 leaf_size: usize,
102 /// Base-2 logarithm of the number of leaves in the Merkle tree.
103 depth: usize,
104}
105
106/// Receives the prover's fold-phase commitments in the order the protocol sends them.
107///
108/// The fold phase spends one round per variable of the committed message.
109/// Most rounds only absorb a folding challenge and carry no message from the prover.
110///
111/// A round that completes a fold shrinks the codeword.
112/// The prover commits to that new codeword, and this type reads it off the channel.
113pub struct FRIFoldVerifier<'a, F, C>
114where
115 F: BinaryField,
116{
117 /// Every commitment the fold phase expects, ordered by the round it arrives in.
118 commit_schedule: Vec<ExpectedCommitment>,
119 /// Commitments read off the channel so far, in arrival order.
120 round_commitments: Vec<C>,
121 /// The round about to be processed, counted from zero.
122 curr_round: usize,
123 /// Number of rounds the fold phase runs.
124 n_rounds: usize,
125 _phantom: std::marker::PhantomData<&'a F>,
126}
127
128impl<'a, F, C> FRIFoldVerifier<'a, F, C>
129where
130 F: BinaryField,
131 C: Clone,
132{
133 /// Derives the schedule of expected commitments from the protocol parameters.
134 pub fn new(params: &'a FRIParams<F>) -> Self {
135 // One commitment per codeword the fold phase sends: batched, one per fold, then terminal.
136 let expected_oracles = params.n_oracles();
137
138 // A fold absorbs one challenge per round, then commits to the codeword it produced.
139 // So commitments land on the running total of the fold widths, never in between.
140 //
141 // Concrete run: batch width 4, folds of arity 4 then 3, over a 2^18-symbol codeword.
142 //
143 // rounds 0..3 batch fold commit at 4 leaf 2^4 2^14 leaves
144 // rounds 4..7 fold of arity 4 commit at 8 leaf 2^3 2^11 leaves
145 // rounds 8..10 fold of arity 3 commit at 11 leaf 2^9 2^2 leaves
146 //
147 // A leaf holds the whole coset that the *next* fold reads.
148 // So each tree is shorter than its own codeword by that fold's arity.
149 let mut commit_schedule = Vec::with_capacity(expected_oracles);
150
151 // The opening fold absorbs the batching challenges.
152 // So nothing is committed until it ends.
153 let mut round = params.log_batch_size();
154
155 // Ahead of any fold, the tree holds one leaf per codeword symbol.
156 let mut depth = params.index_bits();
157
158 for &arity in params.fold_arities() {
159 // This fold reads cosets of its own arity.
160 // So packing one per leaf costs the tree that many levels.
161 depth -= arity;
162 commit_schedule.push(ExpectedCommitment {
163 round,
164 leaf_size: 1 << arity,
165 depth,
166 });
167
168 // The next commitment waits for this fold to absorb every one of its challenges.
169 round += arity;
170 }
171
172 // The terminal codeword is sent in full rather than folded again.
173 // So its leaf spans a whole message, not a coset, leaving one leaf per rate position.
174 commit_schedule.push(ExpectedCommitment {
175 round,
176 leaf_size: 1 << params.n_final_challenges(),
177 depth: params.rs_code().log_inv_rate(),
178 });
179
180 Self {
181 commit_schedule,
182 round_commitments: Vec::with_capacity(expected_oracles),
183 curr_round: 0,
184 // The opening round precedes the first challenge.
185 // So the phase runs one round longer than it has challenges.
186 n_rounds: params.n_fold_rounds() + 1,
187 _phantom: std::marker::PhantomData,
188 }
189 }
190
191 /// The next commitment still awaited, or nothing once every one of them has arrived.
192 fn next_commitment(&self) -> Option<&ExpectedCommitment> {
193 // Commitments arrive in schedule order.
194 // So the number received so far is the read cursor.
195 self.commit_schedule.get(self.round_commitments.len())
196 }
197
198 /// Advances one fold round, reading a commitment off the channel if this round carries one.
199 ///
200 /// # Returns
201 ///
202 /// The commitment received, or nothing when this round carries none.
203 ///
204 /// # Panics
205 ///
206 /// Panics once the fold phase has already run all of its rounds.
207 pub fn process_round<Channel>(&mut self, channel: &mut Channel) -> Result<Option<C>, Error>
208 where
209 Channel: MerkleIPVerifierChannel<F, Commitment = C>,
210 {
211 // The fold phase has a length fixed by the parameters.
212 // Stepping past it would read a message the prover never sent.
213 assert!(
214 self.curr_round < self.n_rounds(),
215 "precondition: process_round must not be called more than n_rounds() times"
216 );
217
218 // Only the round named by the next scheduled commitment carries one.
219 let commitment = match self.next_commitment() {
220 Some(&ExpectedCommitment {
221 round,
222 leaf_size,
223 depth,
224 }) if round == self.curr_round => {
225 // Demanding the scheduled shape pins the prover to the prescribed layout.
226 // A mis-shaped tree is rejected here rather than at query time.
227 let commitment = channel.recv_merkle_commitment(leaf_size, depth)?;
228 self.round_commitments.push(commitment.clone());
229 Some(commitment)
230 }
231 _ => None,
232 };
233
234 self.curr_round += 1;
235 Ok(commitment)
236 }
237
238 /// Reports whether every fold round has been processed.
239 pub const fn is_complete(&self) -> bool {
240 self.curr_round == self.n_rounds()
241 }
242
243 /// Consumes the verifier and yields the commitments it collected, in arrival order.
244 ///
245 /// # Panics
246 ///
247 /// Panics unless every fold round has been processed.
248 /// Stopping early would drop commitments the query phase needs to open.
249 pub fn finalize(self) -> Vec<C> {
250 // Every scheduled commitment lands on some round, so an unfinished phase is a short list.
251 assert!(
252 self.is_complete(),
253 "precondition: all fold rounds must be processed before finalize"
254 );
255
256 self.round_commitments
257 }
258
259 /// Number of rounds the fold phase runs.
260 pub const fn n_rounds(&self) -> usize {
261 self.n_rounds
262 }
263}