Skip to main content

miden_ace_codegen/
factory.rs

1//! Factory for per-order encodings and registry leaves over one factored composition.
2//!
3//! Registry construction visits every proof ordering of a multi-AIR composition. This
4//! factory builds the order-invariant work exactly once — the factored circuit, the
5//! sponge state after absorbing the constants section, and the common-section digest —
6//! so that each ordering costs only its shuffle bytes plus a short resumed hash
7//! ([`FactoredCircuitFactory::leaf_for_order`]), or one assembly plus that same resumed
8//! hash when the full instruction stream is needed
9//! ([`FactoredCircuitFactory::circuit_for_order`]).
10//!
11//! The registry leaf of an ordering is `merge(H(constants | shuffle), H(common))` over
12//! the two `adv_pipe`-aligned stream segments.
13
14use miden_core::{Felt, Word, crypto::hash::Poseidon2};
15use miden_crypto::{
16    field::{ExtensionField, Field},
17    hash::poseidon2::Poseidon2Permutation256,
18    stark::symmetric::Permutation,
19};
20use miden_field::{PackedValue, PrimeCharacteristicRing};
21
22use crate::{
23    AceError, EXT_DEGREE, encode::EncodedCircuit, factored::ShuffleEncodeBuffer,
24    pipeline::FactoredMultiAirCircuit,
25};
26
27/// Poseidon2 sponge rate in base-field elements.
28const RATE_WIDTH: usize = Poseidon2::RATE_RANGE.end - Poseidon2::RATE_RANGE.start;
29
30/// Packed base-field element of the platform's SIMD backend.
31type PackedFelt = <Felt as Field>::Packing;
32
33/// Number of proof orders [`FactoredCircuitFactory::leaves_for_orders`] hashes per
34/// packed Poseidon2 permutation (1 on backends without a packed implementation).
35pub const LEAF_LANES: usize = <PackedFelt as PackedValue>::WIDTH;
36
37/// Reusable scratch for [`FactoredCircuitFactory::leaves_for_orders`].
38#[derive(Default)]
39pub struct PackedLeafScratch {
40    buffer: ShuffleEncodeBuffer,
41    streams: Vec<Vec<Felt>>,
42}
43
44impl PackedLeafScratch {
45    /// Create an empty scratch.
46    pub fn new() -> Self {
47        Self::default()
48    }
49}
50
51/// One proof order's encoded circuit plus its stream-segment commitments.
52#[derive(Clone, Debug)]
53pub struct FactoredEncodedCircuit {
54    /// The encoded instruction stream and its node counts.
55    pub encoded: EncodedCircuit,
56    /// Length in felts of the per-order stream prefix (constants + shuffle section).
57    pub shuffle_prefix_len: usize,
58    /// Poseidon2 digest of the per-order prefix.
59    pub shuffle_commitment: Word,
60    /// Poseidon2 digest of the order-invariant common section.
61    pub common_commitment: Word,
62    /// Registry leaf and advice-map key: `merge(shuffle_commitment, common_commitment)`.
63    pub commitment: Word,
64}
65
66/// Factory caching the order-invariant parts of a factored multi-AIR composition.
67pub struct FactoredCircuitFactory<EF> {
68    factored: FactoredMultiAirCircuit<EF>,
69    /// Sponge state after absorbing the constants section.
70    ///
71    /// The constants section is byte-identical for every proof order and a whole number
72    /// of rate blocks, and the sponge's length binding depends only on `total_len %
73    /// RATE_WIDTH` (identical across orders because all stream sections are
74    /// rate-aligned), so hashing a per-order prefix may resume from this state and
75    /// absorb only the shuffle section.
76    constants_state: [Felt; Poseidon2::STATE_WIDTH],
77    /// Felt length of the constants section absorbed into `constants_state`.
78    const_felts: usize,
79    /// Digest of the order-invariant common section, computed once.
80    common_commitment: Word,
81}
82
83impl<EF> FactoredCircuitFactory<EF>
84where
85    EF: ExtensionField<Felt>,
86{
87    /// Build the factory, fixing the order-invariant stream sections.
88    ///
89    /// Encodes the canonical (identity) order once to fix the constants and common
90    /// sections, then proves the encode-only leaf path against that assembled stream on
91    /// the deployed composition: the canonical order's shuffle window must match byte
92    /// for byte, and the resumed sponge must reproduce the digest of the full prefix.
93    /// Divergence between the two paths is configuration-dependent (it hides in the
94    /// padding arithmetic), so a fixture test elsewhere cannot stand in for this check.
95    pub fn new(factored: FactoredMultiAirCircuit<EF>) -> Result<Self, AceError> {
96        let canonical: Vec<usize> = (0..factored.num_airs()).collect();
97        let circuit = factored.circuit_for_order(&canonical)?;
98        let encoded = circuit.to_ace()?;
99        let instructions = encoded.instructions();
100        let const_felts = encoded.num_constants() * EXT_DEGREE;
101        let prefix_len = const_felts + factored.num_shuffle_ops();
102        if !const_felts.is_multiple_of(RATE_WIDTH)
103            || !prefix_len.is_multiple_of(RATE_WIDTH)
104            || prefix_len >= instructions.len()
105        {
106            return Err(AceError::InvalidInputLayout {
107                message: "ACE stream sections must be rate-aligned for prefix resumption".into(),
108            });
109        }
110
111        let mut constants_state = [<Felt as PrimeCharacteristicRing>::ZERO; Poseidon2::STATE_WIDTH];
112        absorb_rate_blocks(&mut constants_state, &instructions[..const_felts]);
113        let common_commitment = Poseidon2::hash_elements(&instructions[prefix_len..]);
114
115        let mut buffer = ShuffleEncodeBuffer::new();
116        let fast = factored.encode_shuffle_section_for_order(&canonical, &mut buffer)?;
117        if fast != &instructions[const_felts..prefix_len] {
118            return Err(AceError::InvalidInputLayout {
119                message: "encode-only shuffle section diverges from the assembled stream".into(),
120            });
121        }
122        let mut resumed = constants_state;
123        absorb_rate_blocks(&mut resumed, fast);
124        let resumed_prefix =
125            Word::new(resumed[Poseidon2::RATE0_RANGE].try_into().expect("digest is one word"));
126        if resumed_prefix != Poseidon2::hash_elements(&instructions[..prefix_len]) {
127            return Err(AceError::InvalidInputLayout {
128                message: "resumed prefix hash diverges from hashing the full prefix".into(),
129            });
130        }
131
132        Ok(Self {
133            factored,
134            constants_state,
135            const_felts,
136            common_commitment,
137        })
138    }
139
140    /// The factored composition this factory serves.
141    pub fn factored(&self) -> &FactoredMultiAirCircuit<EF> {
142        &self.factored
143    }
144
145    /// Felt length of the order-invariant constants section.
146    pub fn const_felts(&self) -> usize {
147        self.const_felts
148    }
149
150    /// Compute the registry leaf for one proof order without assembling its circuit.
151    ///
152    /// Encodes only the shuffle section into `buffer` and resumes the cached
153    /// post-constants sponge state, so a caller enumerating every ordering pays per
154    /// leaf only the per-order bytes and their hash — this is what makes an `n!`-leaf
155    /// registry build feasible. Equality with [`Self::circuit_for_order`]'s
156    /// `commitment` is pinned at construction (canonical order) and must be re-pinned
157    /// per order wherever a registry is minted.
158    pub fn leaf_for_order(
159        &self,
160        proof_order: &[usize],
161        buffer: &mut ShuffleEncodeBuffer,
162    ) -> Result<Word, AceError> {
163        let shuffle = self.factored.encode_shuffle_section_for_order(proof_order, buffer)?;
164        let mut state = self.constants_state;
165        absorb_rate_blocks(&mut state, shuffle);
166        let shuffle_commitment =
167            Word::new(state[Poseidon2::RATE0_RANGE].try_into().expect("digest is one word"));
168        Ok(Poseidon2::merge(&[shuffle_commitment, self.common_commitment]))
169    }
170
171    /// Compute registry leaves for a batch of proof orders, hashing `LEAF_LANES`
172    /// orders per packed Poseidon2 permutation.
173    ///
174    /// Produces exactly the leaves [`Self::leaf_for_order`] produces, in order — the
175    /// shuffle sections of every proof order have identical length, which is what makes
176    /// lane-lockstep absorption sound. Chunks shorter than `LEAF_LANES` (the batch
177    /// tail) pad unused lanes with the last order and discard the duplicates, so the
178    /// packed path is the only code path. Equality with the scalar path is pinned by
179    /// `packed_leaves_match_the_scalar_path` and, wherever a registry is minted, by the
180    /// per-order dual-path check (whose assembled side hashes scalar).
181    pub fn leaves_for_orders(
182        &self,
183        orders: &[&[usize]],
184        scratch: &mut PackedLeafScratch,
185        out: &mut Vec<Word>,
186    ) -> Result<(), AceError> {
187        scratch.streams.resize_with(LEAF_LANES, Vec::new);
188        for chunk in orders.chunks(LEAF_LANES) {
189            for lane in 0..LEAF_LANES {
190                // Tail lanes repeat the last real order; their outputs are discarded.
191                let order = chunk.get(lane).copied().unwrap_or(chunk[chunk.len() - 1]);
192                let shuffle =
193                    self.factored.encode_shuffle_section_for_order(order, &mut scratch.buffer)?;
194                scratch.streams[lane].clear();
195                scratch.streams[lane].extend_from_slice(shuffle);
196            }
197
198            // Resume the (order-invariant) post-constants sponge state in every lane and
199            // absorb the per-lane shuffle sections in lockstep.
200            let mut state: [PackedFelt; Poseidon2::STATE_WIDTH] = core::array::from_fn(|e| {
201                let mut packed = <PackedFelt as PrimeCharacteristicRing>::ZERO;
202                packed.as_slice_mut().fill(self.constants_state[e]);
203                packed
204            });
205            // Rate alignment is established at construction; assert rather than debug_assert
206            // so a miscount cannot silently truncate a hashed block in a release build.
207            assert!(
208                scratch.streams[0].len().is_multiple_of(RATE_WIDTH),
209                "shuffle streams must be rate-aligned"
210            );
211            let blocks = scratch.streams[0].len() / RATE_WIDTH;
212            for block in 0..blocks {
213                for i in 0..RATE_WIDTH {
214                    let elem = &mut state[Poseidon2::RATE_RANGE.start + i];
215                    for lane in 0..LEAF_LANES {
216                        elem.as_slice_mut()[lane] = scratch.streams[lane][block * RATE_WIDTH + i];
217                    }
218                }
219                Poseidon2Permutation256.permute_mut(&mut state);
220            }
221
222            // Batched `Poseidon2::merge(shuffle_commitment, common_commitment)`: rate =
223            // the two digests, capacity zero, one permutation, digest = first rate word.
224            let mut merge_state: [PackedFelt; Poseidon2::STATE_WIDTH] =
225                core::array::from_fn(|_| <PackedFelt as PrimeCharacteristicRing>::ZERO);
226            for i in 0..4 {
227                merge_state[i] = state[Poseidon2::RATE0_RANGE.start + i];
228                let mut common = <PackedFelt as PrimeCharacteristicRing>::ZERO;
229                common.as_slice_mut().fill(self.common_commitment[i]);
230                merge_state[4 + i] = common;
231            }
232            Poseidon2Permutation256.permute_mut(&mut merge_state);
233
234            for lane in 0..chunk.len() {
235                let leaf: [Felt; 4] = core::array::from_fn(|i| merge_state[i].as_slice()[lane]);
236                out.push(Word::new(leaf));
237            }
238        }
239        Ok(())
240    }
241
242    /// Assemble, encode, and hash the circuit for one proof order.
243    ///
244    /// Only the shuffle section is hashed live (resuming from the cached post-constants
245    /// sponge state); the common-section digest is reused. The resulting commitments
246    /// are definitionally equal to hashing the full stream segments, which the caller's
247    /// segment tests pin per order.
248    pub fn circuit_for_order(
249        &self,
250        proof_order: &[usize],
251    ) -> Result<FactoredEncodedCircuit, AceError> {
252        let circuit = self.factored.circuit_for_order(proof_order)?;
253        let encoded = circuit.to_ace()?;
254        let instructions = encoded.instructions();
255        let stream_len = encoded.size_in_felt();
256        if stream_len != instructions.len() {
257            return Err(AceError::InvalidInputLayout {
258                message: format!(
259                    "ACE circuit stream length ({stream_len}) does not match instruction count \
260                     ({})",
261                    instructions.len()
262                ),
263            });
264        }
265        let shuffle_prefix_len = self.const_felts + self.factored.num_shuffle_ops();
266        if encoded.num_constants() * EXT_DEGREE != self.const_felts
267            || !stream_len.is_multiple_of(RATE_WIDTH)
268            || shuffle_prefix_len >= stream_len
269        {
270            return Err(AceError::InvalidInputLayout {
271                message: "assembled ACE stream does not match the factored section layout".into(),
272            });
273        }
274
275        let mut state = self.constants_state;
276        absorb_rate_blocks(&mut state, &instructions[self.const_felts..shuffle_prefix_len]);
277        let shuffle_commitment =
278            Word::new(state[Poseidon2::RATE0_RANGE].try_into().expect("digest is one word"));
279        let common_commitment = self.common_commitment;
280        let commitment = Poseidon2::merge(&[shuffle_commitment, common_commitment]);
281
282        Ok(FactoredEncodedCircuit {
283            encoded,
284            shuffle_prefix_len,
285            shuffle_commitment,
286            common_commitment,
287            commitment,
288        })
289    }
290}
291
292/// Absorb whole rate blocks into a Poseidon2 sponge state.
293fn absorb_rate_blocks(state: &mut [Felt; Poseidon2::STATE_WIDTH], elements: &[Felt]) {
294    // Ignoring a trailing partial block would yield a wrong digest; assert rather than
295    // debug_assert so a miscount cannot survive a release build.
296    assert!(
297        elements.len().is_multiple_of(RATE_WIDTH),
298        "sponge absorption requires whole rate blocks"
299    );
300    for block in elements.as_chunks::<RATE_WIDTH>().0 {
301        state[Poseidon2::RATE_RANGE].copy_from_slice(block);
302        Poseidon2::apply_permutation(state);
303    }
304}