Skip to main content

miden_ace_codegen/
encode.rs

1//! ACE circuit encoding for the chiplet format.
2//!
3//! Encoding rules:
4//! - The READ section stores extension-field (EF) elements; each EF occupies two base-field
5//!   elements.
6//! - Each ACE READ row consumes two EF elements (four base-field elements or a `Word`).
7//! - The EVAL section stores one operation per row, encoded as a single base-field element.
8//!
9//! The encoded stream concatenates constants (EF) followed by operations
10//! (base-field), then pads to an `adv_pipe` block boundary.
11
12use miden_core::{Felt, Word, crypto::hash::Poseidon2};
13use miden_crypto::field::ExtensionField;
14
15use crate::{
16    AceError,
17    circuit::{AceCircuit, AceNode, AceOp, AceOpNode},
18};
19
20// NOTE: `num_vars`/`num_const_nodes` count extension-field (EF) nodes, while the
21// instruction stream (`instructions.len()`) is measured in base field elements.
22
23/// Number of base field elements per extension field element.
24const BASE_FELTS_PER_EF: usize = crate::EXT_DEGREE;
25/// Number of EF nodes read per ACE READ row (two EF per row).
26const ACE_READ_ROW_EF_NODES: usize = 2;
27/// Constants are padded to an even number of EF nodes (full READ rows).
28pub(crate) const CONST_EF_ALIGN: usize = 2;
29/// Instruction stream padding unit in base felts (adv_pipe block size), so that
30/// the constants+ops stream can be read in aligned chunks.
31pub(crate) const ADV_PIPE_BLOCK_FELTS: usize = 8;
32/// Maximum number of circuit nodes accepted by the ACE runtime.
33///
34/// Packed node ids occupy 30 bits, but `eval_circuit` requires the total number of READ and EVAL
35/// nodes to be strictly less than `2^30`.
36const MAX_NUM_ACE_NODES: usize = (1 << 30) - 1;
37
38/// Encoded ACE circuit ready for chiplet consumption.
39///
40/// This packs the circuit into the chiplet instruction stream and exposes
41/// helpers for stream sizing. `num_vars` counts extension-field nodes
42/// (inputs + constants + padding). `num_ops` and `num_eval_rows` count
43/// base-field operation rows (including padding ops).
44#[derive(Debug, Clone)]
45pub struct EncodedCircuit {
46    num_vars: usize,
47    num_ops: usize,
48    instructions: Vec<Felt>,
49}
50
51impl EncodedCircuit {
52    /// Number of ACE READ rows (two EF nodes per row).
53    pub fn num_read_rows(&self) -> usize {
54        self.num_vars() / ACE_READ_ROW_EF_NODES
55    }
56
57    /// Number of rows needed to evaluate operations (one op per base-field row).
58    pub fn num_eval_rows(&self) -> usize {
59        self.num_ops
60    }
61
62    /// Total number of variable slots (inputs + constants + padding), counted in EF nodes.
63    pub fn num_vars(&self) -> usize {
64        self.num_vars
65    }
66
67    /// Number of input slots in the READ section.
68    pub fn num_inputs(&self) -> usize {
69        self.num_vars - self.num_constants()
70    }
71
72    /// Number of constants encoded into the circuit stream, counted in EF nodes.
73    pub fn num_constants(&self) -> usize {
74        (self.instructions.len() - self.num_ops) / BASE_FELTS_PER_EF
75    }
76
77    /// Total number of nodes (inputs + constants + ops).
78    pub fn num_nodes(&self) -> usize {
79        self.num_vars + self.num_ops
80    }
81
82    /// Raw instruction stream (constants + ops).
83    pub fn instructions(&self) -> &[Felt] {
84        &self.instructions
85    }
86
87    /// Instruction stream length in base field elements.
88    pub fn size_in_felt(&self) -> usize {
89        self.instructions.len()
90    }
91
92    /// Poseidon2 digest of the whole instruction stream.
93    ///
94    /// Note this is not the recursive verifier's registry leaf: a factored circuit is committed
95    /// as `merge(H(constants | shuffle), H(common))` over the two stream segments.
96    pub fn circuit_hash(&self) -> Word {
97        Poseidon2::hash_elements(self.instructions())
98    }
99}
100
101/// Node-id bases and operation packing for one encoded circuit shape.
102///
103/// The chiplet numbers nodes downward from `num_nodes - 1`: inputs first, then constants,
104/// then operations. Every circuit assembled from one factored composition shares these
105/// bases, so a caller that only wants part of the stream can encode it without building
106/// the whole circuit — see `FactoredMultiAirCircuit::encode_shuffle_section_for_order`.
107#[derive(Debug, Clone, Copy)]
108pub(crate) struct StreamGeometry {
109    input_start: usize,
110    constants_start: usize,
111    ops_start: usize,
112}
113
114impl StreamGeometry {
115    /// Derive the bases from UNPADDED counts, applying the chiplet padding rules:
116    /// constants are rounded up to full READ rows and the constants+ops stream is padded
117    /// to whole `adv_pipe` blocks. The single authority for this arithmetic — `to_ace`
118    /// and `emit_factored_circuit` must agree on node ids, so both derive them here.
119    pub(crate) fn from_counts(num_inputs: usize, num_constants: usize, num_ops: usize) -> Self {
120        let num_const_nodes = num_constants.next_multiple_of(CONST_EF_ALIGN);
121        let const_felts = num_const_nodes * BASE_FELTS_PER_EF;
122        let num_ops_padded =
123            (const_felts + num_ops).next_multiple_of(ADV_PIPE_BLOCK_FELTS) - const_felts;
124        Self::new(num_inputs, num_const_nodes, num_ops_padded)
125    }
126
127    /// Derive the bases from the final (padded) node counts.
128    fn new(num_inputs: usize, num_constants: usize, num_ops: usize) -> Self {
129        let num_nodes = num_inputs + num_constants + num_ops;
130        let input_start = num_nodes - 1;
131        let constants_start = input_start - num_inputs;
132        let ops_start = constants_start - num_constants;
133        Self { input_start, constants_start, ops_start }
134    }
135
136    /// Number of input nodes in the READ section.
137    fn num_inputs(&self) -> usize {
138        self.input_start - self.constants_start
139    }
140
141    /// Number of constant nodes (EF), including READ-row padding.
142    pub(crate) fn num_const_nodes(&self) -> usize {
143        self.constants_start - self.ops_start
144    }
145
146    /// Number of operations, including the trailing block padding.
147    pub(crate) fn num_padded_ops(&self) -> usize {
148        self.ops_start + 1
149    }
150
151    /// Total nodes these bases were derived from.
152    fn num_nodes(&self) -> usize {
153        self.input_start + 1
154    }
155
156    /// Reject shapes the ACE chiplet cannot consume: READ layouts that do not fill whole
157    /// rows, and node counts beyond the id-packing bound. Shared by `to_ace` and the
158    /// encode-only registry path so the two cannot drift apart.
159    pub(crate) fn validate(&self) -> Result<(), AceError> {
160        if !self.num_inputs().is_multiple_of(ACE_READ_ROW_EF_NODES) {
161            return Err(AceError::InvalidInputLayout {
162                message: "ACE READ layout must be aligned to two EF nodes (use LayoutKind::Masm or pad inputs)"
163                    .to_string(),
164            });
165        }
166        if self.num_nodes() > MAX_NUM_ACE_NODES {
167            return Err(AceError::InvalidInputLayout {
168                message: format!(
169                    "ACE circuit has {} nodes, must be less than 2^30",
170                    self.num_nodes()
171                ),
172            });
173        }
174        Ok(())
175    }
176
177    fn node_id(&self, node: AceNode) -> Result<u64, AceError> {
178        let id = match node {
179            AceNode::Input(idx) => self.input_start.checked_sub(idx),
180            AceNode::Constant(idx) => self.constants_start.checked_sub(idx),
181            AceNode::Operation(idx) => self.ops_start.checked_sub(idx),
182        }
183        .ok_or_else(|| AceError::InvalidInputLayout {
184            message: format!("ACE circuit node index out of range: {node:?}"),
185        })?;
186        Ok(id as u64)
187    }
188
189    /// Pack one operation as `lhs_id + rhs_id * 2^30 + op_tag * 2^60`.
190    pub(crate) fn encode_operation(&self, op: &AceOpNode) -> Result<Felt, AceError> {
191        const RHS_NODE_OFFSET: u64 = 1 << 30;
192        const OP_TAG_OFFSET: u64 = 1 << 60;
193        let tag = match op.op {
194            AceOp::Sub => 0,
195            AceOp::Mul => 1,
196            AceOp::Add => 2,
197        };
198        let lhs_id = self.node_id(op.lhs)?;
199        let rhs_id = self.node_id(op.rhs)?;
200        Ok(Felt::new_unchecked(lhs_id + rhs_id * RHS_NODE_OFFSET + tag * OP_TAG_OFFSET))
201    }
202}
203
204impl<EF> AceCircuit<EF>
205where
206    EF: ExtensionField<Felt>,
207{
208    /// Encode the circuit into the ACE chiplet format.
209    pub fn to_ace(&self) -> Result<EncodedCircuit, AceError> {
210        let num_input_nodes = self.layout.total_inputs;
211        let num_op_nodes = self.operations.len();
212        if num_op_nodes == 0 {
213            return Err(AceError::InvalidInputLayout {
214                message: "ACE circuit has no operations to encode".to_string(),
215            });
216        }
217        if self.root != AceNode::Operation(num_op_nodes - 1) {
218            return Err(AceError::InvalidInputLayout {
219                message: "ACE circuit root must be the last operation before padding".to_string(),
220            });
221        }
222
223        let geometry =
224            StreamGeometry::from_counts(num_input_nodes, self.constants.len(), num_op_nodes);
225        geometry.validate()?;
226
227        // The instruction stream is measured in base felts:
228        // - constants are EF-encoded (2 base felts each)
229        // - ops are 1 base felt each
230        let num_const_nodes = geometry.num_const_nodes();
231        let num_const_felts = num_const_nodes * BASE_FELTS_PER_EF;
232        let len_circuit_padded = num_const_felts + geometry.num_padded_ops();
233
234        let mut instructions = Vec::with_capacity(len_circuit_padded);
235        for constant in &self.constants {
236            let coeffs = constant.as_basis_coefficients_slice();
237            instructions.push(coeffs[0]);
238            instructions.push(coeffs[1]);
239        }
240        instructions.resize(num_const_felts, Felt::ZERO);
241
242        for op in &self.operations {
243            instructions.push(geometry.encode_operation(op)?);
244        }
245
246        // The ACE chiplet checks the last EVAL row. Padding preserves zero-ness by repeatedly
247        // squaring the current root, so the unpadded root must be the last emitted operation.
248        let mut last_node_index = num_op_nodes - 1;
249        while instructions.len() < len_circuit_padded {
250            let last_node = AceNode::Operation(last_node_index);
251            let dummy_op = AceOpNode {
252                op: AceOp::Mul,
253                lhs: last_node,
254                rhs: last_node,
255            };
256            instructions.push(geometry.encode_operation(&dummy_op)?);
257            last_node_index += 1;
258        }
259
260        let num_vars = num_input_nodes + num_const_nodes;
261        let num_ops = geometry.num_padded_ops();
262        Ok(EncodedCircuit { num_vars, num_ops, instructions })
263    }
264
265    /// Return true if inputs/constants/ops satisfy chiplet padding rules:
266    /// - inputs/constants are aligned to full READ rows (EF nodes)
267    /// - constants+ops stream is aligned to adv_pipe blocks (base felts)
268    pub fn is_padded(&self) -> bool {
269        if !self.layout.total_inputs.is_multiple_of(ACE_READ_ROW_EF_NODES) {
270            return false;
271        }
272        if !self.constants.len().is_multiple_of(CONST_EF_ALIGN) {
273            return false;
274        }
275        let const_felts = self.constants.len() * BASE_FELTS_PER_EF;
276        let op_felts = self.operations.len();
277        (const_felts + op_felts).is_multiple_of(ADV_PIPE_BLOCK_FELTS)
278    }
279}