Skip to main content

miden_air/
lib.rs

1#![no_std]
2
3#[macro_use]
4extern crate alloc;
5
6#[cfg(feature = "std")]
7extern crate std;
8
9use alloc::vec::Vec;
10use core::borrow::Borrow;
11
12use miden_core::{
13    WORD_SIZE, Word,
14    deferred::DeferredRoot,
15    field::ExtensionField,
16    program::{
17        KernelDescriptor, MIN_STACK_DEPTH, NUM_CLAIM_ELEMENTS, ProgramInfo, StackInputs,
18        StackOutputs,
19    },
20};
21use miden_crypto::stark::{
22    air::{ReductionError, WindowAccess},
23    challenger::CanObserve,
24};
25#[cfg(feature = "arbitrary")]
26use proptest::prelude::*;
27
28pub mod ace;
29pub mod config;
30mod constraints;
31pub mod lookup;
32mod proof_order;
33pub mod trace;
34
35/// Miden VM-specific LogUp lookup argument: bus identifiers and bus message types.
36///
37/// [`crate::MidenAir`] is the single `LiftedAir`/`LookupAir` type for the multi-AIR
38/// statement; it dispatches per-trace work to Core, Chiplets, and Poseidon2 permutation AIRs.
39/// [`crate::MidenMultiAir`] is the `MultiAir` carrying the cross-AIR reduction.
40/// The generic LogUp framework lives in [`crate::lookup`].
41pub mod logup {
42    pub use crate::constraints::lookup::{
43        BusId, MIDEN_MAX_MESSAGE_WIDTH, messages::*, miden_air::NUM_LOGUP_COMMITTED_FINALS,
44    };
45}
46
47use constraints::lookup::{
48    chiplet_air::ChipletLookupBuilder,
49    main_air::{MainLookupAir, MainLookupBuilder},
50    poseidon2_permutation_air::Poseidon2PermutationLookupBuilder,
51};
52pub use constraints::{
53    chiplets::columns::{
54        AceCols, AceEvalCols, AceReadCols, BitwiseCols, ControllerCols, KernelRomCols, MemoryCols,
55    },
56    columns::{ChipletCols, CoreCols},
57    decoder::columns::DecoderCols,
58    ext_field::QuadFeltExpr,
59    poseidon2_permutation::columns::{
60        CYCLE_INPUT_ROW, CYCLE_OUTPUT_ROW, INITIAL_EXTERNAL_ROUND_END,
61        INITIAL_EXTERNAL_ROUND_START, INTERNAL_PLUS_EXTERNAL_ROW, LAST_INTERNAL_ROUND_ARK_IDX,
62        NUM_PACKED_INTERNAL_ROUND_ROWS, NUM_SBOX_WITNESSES, NUM_TRAILING_EXTERNAL_ROUND_ROWS,
63        PACKED_INTERNAL_ROUND_START, Poseidon2PermutationCols, Poseidon2PermutationPeriodicCols,
64    },
65    range::columns::RangeCols,
66    stack::columns::StackCols,
67    system::columns::SystemCols,
68};
69use logup::{BusId, MIDEN_MAX_MESSAGE_WIDTH};
70use lookup::{
71    BoundaryBuilder, Challenges, ConstraintLookupBuilder, LookupAir, LookupMessage,
72    build_logup_aux_trace,
73};
74use miden_core::utils::RowMajorMatrix;
75
76// RE-EXPORTS
77// ================================================================================================
78mod export {
79    pub use miden_core::{
80        Felt,
81        serde::{ByteReader, ByteWriter, Deserializable, DeserializationError, Serializable},
82        utils::ToElements,
83    };
84    pub use miden_crypto::stark::{
85        StarkConfig,
86        air::{
87            AirBuilder, BaseAir, ConstraintDegrees, ExtensionBuilder, LiftedAir, LiftedAirBuilder,
88            MultiAir, PermutationAirBuilder, ProverStatement, Statement,
89        },
90        debug,
91    };
92}
93
94pub use export::*;
95pub use proof_order::{
96    AIRS, MIDEN_AIR_COUNT, PROOF_ORDER_COUNT, PROOF_ORDER_REGISTRY_DEPTH, ProofOrder,
97};
98
99// MIDEN AIR BUILDER
100// ================================================================================================
101
102/// Convenience super-trait that pins `LiftedAirBuilder` to the Miden base field.
103///
104/// Constraint functions use `AB: MidenAirBuilder` instead of the longer
105/// `LiftedAirBuilder<F = Felt>` bound.
106pub trait MidenAirBuilder: LiftedAirBuilder<F = Felt> {}
107impl<T: LiftedAirBuilder<F = Felt>> MidenAirBuilder for T {}
108
109// PUBLIC INPUTS
110// ================================================================================================
111
112#[derive(Debug, Clone, PartialEq, Eq)]
113#[cfg_attr(
114    all(feature = "arbitrary", test),
115    miden_test_serde_macros::serde_test(binary_serde(true), serde_test(false))
116)]
117pub struct PublicInputs {
118    program_info: ProgramInfo,
119    stack_inputs: StackInputs,
120    stack_outputs: StackOutputs,
121    deferred_root: DeferredRoot,
122}
123
124impl PublicInputs {
125    /// Creates a new instance of `PublicInputs` from program information, stack inputs and outputs,
126    /// and the final deferred root.
127    pub fn new(
128        program_info: ProgramInfo,
129        stack_inputs: StackInputs,
130        stack_outputs: StackOutputs,
131        deferred_root: DeferredRoot,
132    ) -> Self {
133        Self {
134            program_info,
135            stack_inputs,
136            stack_outputs,
137            deferred_root,
138        }
139    }
140
141    pub fn stack_inputs(&self) -> StackInputs {
142        self.stack_inputs
143    }
144
145    pub fn stack_outputs(&self) -> StackOutputs {
146        self.stack_outputs
147    }
148
149    pub fn program_info(&self) -> ProgramInfo {
150        self.program_info.clone()
151    }
152
153    /// Returns the final deferred root.
154    pub fn deferred_root(&self) -> DeferredRoot {
155        self.deferred_root
156    }
157
158    /// Returns the canonical commitment to the kernel of the verified statement: the value the
159    /// recursive verifier observes into the transcript in place of the raw kernel-procedure
160    /// digest list. See
161    /// [`KernelDescriptor::commitment`](miden_core::program::KernelDescriptor::commitment).
162    pub fn kernel_commitment(&self) -> Word {
163        self.program_info.kernel_commitment()
164    }
165
166    /// Returns the AIR public values (`air_inputs`) and statement inputs (`aux_inputs`).
167    ///
168    /// `air_inputs` (the values read by the AIR constraints) layout:
169    ///   [0..16]  stack inputs
170    ///   [16..32] stack outputs
171    ///
172    /// `aux_inputs` (statement inputs not read by the AIRs, consumed only by `observe` and
173    /// `eval_external`) layout:
174    ///   [0..4]   program hash
175    ///   [4..8]   final deferred root
176    ///   [8..]    kernel procedure digests (concatenated words, variable length)
177    pub fn to_air_inputs(&self) -> (Vec<Felt>, Vec<Felt>) {
178        let mut air_inputs = Vec::with_capacity(NUM_PUBLIC_VALUES);
179        air_inputs.extend_from_slice(self.stack_inputs.as_ref());
180        air_inputs.extend_from_slice(self.stack_outputs.as_ref());
181
182        let kernel_felts = Word::words_as_elements(self.program_info.kernel_procedures());
183        let mut aux_inputs = Vec::with_capacity(AUX_KERNEL_DIGESTS + kernel_felts.len());
184        aux_inputs.extend_from_slice(self.program_info.program_hash().as_elements());
185        aux_inputs.extend_from_slice(self.deferred_root.as_ref());
186        aux_inputs.extend_from_slice(kernel_felts);
187
188        (air_inputs, aux_inputs)
189    }
190
191    /// Converts public inputs into a vector of field elements (Felt) in the canonical order:
192    /// - program info elements (including kernel procedure hashes)
193    /// - stack inputs
194    /// - stack outputs
195    /// - final deferred root
196    pub fn to_elements(&self) -> Vec<Felt> {
197        let mut result = self.program_info.to_elements();
198        result.extend_from_slice(self.stack_inputs.as_ref());
199        result.extend_from_slice(self.stack_outputs.as_ref());
200        result.extend_from_slice(self.deferred_root.as_ref());
201        result
202    }
203}
204
205#[cfg(feature = "arbitrary")]
206impl Arbitrary for PublicInputs {
207    type Parameters = ();
208    type Strategy = BoxedStrategy<Self>;
209
210    fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
211        fn felt_strategy() -> impl Strategy<Value = Felt> {
212            any::<u32>().prop_map(Felt::from)
213        }
214
215        fn word_strategy() -> impl Strategy<Value = Word> {
216            any::<[u32; WORD_SIZE]>().prop_map(|values| Word::new(values.map(Felt::from)))
217        }
218
219        let program_info = word_strategy()
220            .prop_map(|program_hash| ProgramInfo::new(program_hash, KernelDescriptor::default()));
221        let stack_inputs = proptest::collection::vec(felt_strategy(), 0..=MIN_STACK_DEPTH)
222            .prop_map(|values| StackInputs::new(&values).expect("generated stack inputs fit"));
223        let stack_outputs = proptest::collection::vec(felt_strategy(), 0..=MIN_STACK_DEPTH)
224            .prop_map(|values| StackOutputs::new(&values).expect("generated stack outputs fit"));
225
226        (program_info, stack_inputs, stack_outputs, word_strategy())
227            .prop_map(|(program_info, stack_inputs, stack_outputs, deferred_root)| {
228                Self::new(program_info, stack_inputs, stack_outputs, deferred_root)
229            })
230            .boxed()
231    }
232}
233
234// SERIALIZATION
235// ================================================================================================
236
237impl Serializable for PublicInputs {
238    fn write_into<W: ByteWriter>(&self, target: &mut W) {
239        self.program_info.write_into(target);
240        self.stack_inputs.write_into(target);
241        self.stack_outputs.write_into(target);
242        self.deferred_root.write_into(target);
243    }
244}
245
246impl Deserializable for PublicInputs {
247    fn read_from<R: ByteReader>(source: &mut R) -> Result<Self, DeserializationError> {
248        let program_info = ProgramInfo::read_from(source)?;
249        let stack_inputs = StackInputs::read_from(source)?;
250        let stack_outputs = StackOutputs::read_from(source)?;
251        let deferred_root = DeferredRoot::read_from(source)?;
252
253        Ok(PublicInputs {
254            program_info,
255            stack_inputs,
256            stack_outputs,
257            deferred_root,
258        })
259    }
260}
261
262// PROCESSOR AIR
263// ================================================================================================
264
265/// Number of public values read by the Miden VM AIRs: the `air_inputs` shared by every AIR.
266///
267/// Layout (32 Felts total):
268///   [0..16]  stack inputs
269///   [16..32] stack outputs
270///
271/// The program hash and final deferred root are not read by any AIR constraint, so they
272/// are carried as `aux_inputs` and consumed only by [`MidenMultiAir::observe`] and
273/// [`MidenMultiAir::eval_external`].
274pub const NUM_PUBLIC_VALUES: usize = MIN_STACK_DEPTH + MIN_STACK_DEPTH;
275
276/// LogUp aux trace width: 4 core columns + 3 chiplet columns + 1 Poseidon2 column.
277pub const LOGUP_AUX_TRACE_WIDTH: usize = 8;
278
279// `aux_inputs` layout offsets — statement inputs that the AIRs do not read. The fixed program
280// hash and final deferred root occupy the first two words; the variable-length kernel-procedure
281// digests follow.
282const AUX_PROGRAM_HASH: usize = 0;
283const AUX_DEFERRED_ROOT: usize = WORD_SIZE;
284const AUX_KERNEL_DIGESTS: usize = 2 * WORD_SIZE;
285
286// CORE AIR
287// ================================================================================================
288
289/// Core trace AIR.
290///
291/// Enforces the system, decoder, stack, and range-check constraints.
292#[derive(Copy, Clone, Debug, Default)]
293pub struct CoreAir;
294
295impl CoreAir {
296    fn width(self) -> usize {
297        constraints::columns::NUM_CORE_COLS
298    }
299
300    fn periodic_columns(self) -> Vec<Vec<Felt>> {
301        Vec::new()
302    }
303
304    fn aux_width(self) -> usize {
305        constraints::lookup::main_air::MAIN_COLUMN_SHAPE.len()
306    }
307
308    /// LogUp boundary correction for the core trace: the running sum of every
309    /// boundary interaction reduced to its denominator contribution. The single var-len slice
310    /// carries `[program_hash (4) | deferred_root (4)]`, the statement inputs the core
311    /// boundary cancels against the block-hash and log-deferred buses.
312    fn boundary_correction<EF: ExtensionField<Felt>>(
313        self,
314        challenges: &Challenges<EF>,
315        public_values: &[Felt],
316        boundary_inputs: &[&[Felt]],
317    ) -> Result<EF, ReductionError> {
318        if boundary_inputs.len() != 1 {
319            return Err(format!(
320                "CoreAir expects 1 boundary input slice, got {}",
321                boundary_inputs.len()
322            )
323            .into());
324        }
325        if boundary_inputs[0].len() != 2 * WORD_SIZE {
326            return Err(format!(
327                "CoreAir expects {} boundary felts (program hash + deferred root), got {}",
328                2 * WORD_SIZE,
329                boundary_inputs[0].len()
330            )
331            .into());
332        }
333
334        let mut reducer = ReduceBoundaryBuilder {
335            challenges,
336            public_values,
337            var_len_public_inputs: boundary_inputs,
338            sum: EF::ZERO,
339            error: None,
340        };
341        constraints::lookup::miden_air::emit_core_boundary(&mut reducer);
342        reducer.finalize()
343    }
344
345    fn eval<AB: MidenAirBuilder>(self, builder: &mut AB) {
346        let main = builder.main();
347        let local: &CoreCols<AB::Var> = (*main.current_slice()).borrow();
348        let next: &CoreCols<AB::Var> = (*main.next_slice()).borrow();
349
350        let op_flags =
351            constraints::op_flags::OpFlags::new(&local.decoder, &local.stack, &next.decoder);
352
353        constraints::enforce_core(builder, local, next, &op_flags);
354        constraints::public_inputs::enforce_main(builder, local);
355
356        let mut lb = ConstraintLookupBuilder::new(builder, &MidenAir::Core);
357        self.lookup_eval(&mut lb);
358    }
359
360    fn lookup_num_columns(self) -> usize {
361        constraints::lookup::main_air::MAIN_COLUMN_SHAPE.len()
362    }
363
364    fn lookup_column_shape(self) -> &'static [usize] {
365        &constraints::lookup::main_air::MAIN_COLUMN_SHAPE
366    }
367
368    fn lookup_max_message_width(self) -> usize {
369        MIDEN_MAX_MESSAGE_WIDTH
370    }
371
372    fn lookup_num_bus_ids(self) -> usize {
373        BusId::COUNT
374    }
375
376    fn lookup_eval<LB: MainLookupBuilder>(self, builder: &mut LB) {
377        MainLookupAir.eval(builder);
378    }
379
380    fn lookup_eval_boundary<B: BoundaryBuilder>(self, boundary: &mut B) {
381        constraints::lookup::miden_air::emit_core_boundary(boundary);
382    }
383}
384
385// CHIPLETS AIR
386// ================================================================================================
387
388/// Chiplets trace AIR.
389///
390/// Enforces the chiplet selector hierarchy, chiplet transition constraints, and
391/// chiplet-side LogUp buses.
392#[derive(Copy, Clone, Debug, Default)]
393pub struct ChipletsAir;
394
395impl ChipletsAir {
396    fn width(self) -> usize {
397        constraints::columns::NUM_CHIPLETS_COLS
398    }
399
400    fn periodic_columns(self) -> Vec<Vec<Felt>> {
401        constraints::chiplets::columns::PeriodicCols::periodic_columns()
402    }
403
404    fn aux_width(self) -> usize {
405        constraints::lookup::chiplet_air::CHIPLET_COLUMN_SHAPE.len()
406    }
407
408    /// LogUp boundary correction for the chiplets trace.
409    ///
410    /// The boundary input slice contains the kernel-procedure digests; these statement values
411    /// close the kernel-ROM bus.
412    fn boundary_correction<EF: ExtensionField<Felt>>(
413        self,
414        challenges: &Challenges<EF>,
415        public_values: &[Felt],
416        boundary_inputs: &[&[Felt]],
417    ) -> Result<EF, ReductionError> {
418        if boundary_inputs.len() != 1 {
419            return Err(format!(
420                "ChipletsAir expects 1 boundary input slice, got {}",
421                boundary_inputs.len()
422            )
423            .into());
424        }
425        if !boundary_inputs[0].len().is_multiple_of(WORD_SIZE) {
426            return Err(format!(
427                "kernel digest felts length {} is not a multiple of {}",
428                boundary_inputs[0].len(),
429                WORD_SIZE
430            )
431            .into());
432        }
433
434        let mut reducer = ReduceBoundaryBuilder {
435            challenges,
436            public_values,
437            var_len_public_inputs: boundary_inputs,
438            sum: EF::ZERO,
439            error: None,
440        };
441        constraints::lookup::miden_air::emit_chiplets_boundary(&mut reducer);
442        reducer.finalize()
443    }
444
445    fn eval<AB: MidenAirBuilder>(self, builder: &mut AB) {
446        let main = builder.main();
447        let local: &ChipletCols<AB::Var> = (*main.current_slice()).borrow();
448        let next: &ChipletCols<AB::Var> = (*main.next_slice()).borrow();
449
450        let selectors =
451            constraints::chiplets::selectors::build_chiplet_selectors(builder, local, next);
452
453        constraints::enforce_chiplets(builder, local, next, &selectors);
454
455        let mut lb = ConstraintLookupBuilder::new(builder, &MidenAir::Chiplets);
456        self.lookup_eval(&mut lb);
457    }
458
459    fn lookup_num_columns(self) -> usize {
460        constraints::lookup::chiplet_air::CHIPLET_COLUMN_SHAPE.len()
461    }
462
463    fn lookup_column_shape(self) -> &'static [usize] {
464        &constraints::lookup::chiplet_air::CHIPLET_COLUMN_SHAPE
465    }
466
467    fn lookup_max_message_width(self) -> usize {
468        MIDEN_MAX_MESSAGE_WIDTH
469    }
470
471    fn lookup_num_bus_ids(self) -> usize {
472        BusId::COUNT
473    }
474
475    fn lookup_eval<LB: ChipletLookupBuilder>(self, builder: &mut LB) {
476        let main = builder.main();
477        let local: &ChipletCols<_> = main.current_slice().borrow();
478        let next: &ChipletCols<_> = main.next_slice().borrow();
479
480        constraints::lookup::chiplet_air::emit_chiplet_lookup_columns(builder, local, next);
481    }
482
483    fn lookup_eval_boundary<B: BoundaryBuilder>(self, boundary: &mut B) {
484        constraints::lookup::miden_air::emit_chiplets_boundary(boundary);
485    }
486}
487
488// POSEIDON2 PERMUTATION AIR
489// ================================================================================================
490
491/// Poseidon2 permutation trace AIR.
492///
493/// Enforces 16-row permutation cycles and the permutation-side LogUp bus.
494#[derive(Copy, Clone, Debug, Default)]
495pub struct Poseidon2PermutationAir;
496
497impl Poseidon2PermutationAir {
498    fn width(self) -> usize {
499        constraints::poseidon2_permutation::columns::NUM_POSEIDON2_PERMUTATION_COLS
500    }
501
502    fn periodic_columns(self) -> Vec<Vec<Felt>> {
503        Poseidon2PermutationPeriodicCols::periodic_columns()
504    }
505
506    fn aux_width(self) -> usize {
507        constraints::lookup::poseidon2_permutation_air::POSEIDON2_PERMUTATION_COLUMN_SHAPE.len()
508    }
509
510    fn boundary_correction<EF: ExtensionField<Felt>>(
511        self,
512        _challenges: &Challenges<EF>,
513        _public_values: &[Felt],
514        boundary_inputs: &[&[Felt]],
515    ) -> Result<EF, ReductionError> {
516        if !boundary_inputs.is_empty() {
517            return Err(format!(
518                "Poseidon2PermutationAir expects 0 boundary input slices, got {}",
519                boundary_inputs.len()
520            )
521            .into());
522        }
523        Ok(EF::ZERO)
524    }
525
526    fn eval<AB: MidenAirBuilder>(self, builder: &mut AB) {
527        constraints::enforce_poseidon2_permutation(builder);
528
529        let mut lb = ConstraintLookupBuilder::new(builder, &MidenAir::Poseidon2Permutation);
530        self.lookup_eval(&mut lb);
531    }
532
533    fn lookup_num_columns(self) -> usize {
534        constraints::lookup::poseidon2_permutation_air::POSEIDON2_PERMUTATION_COLUMN_SHAPE.len()
535    }
536
537    fn lookup_column_shape(self) -> &'static [usize] {
538        &constraints::lookup::poseidon2_permutation_air::POSEIDON2_PERMUTATION_COLUMN_SHAPE
539    }
540
541    fn lookup_max_message_width(self) -> usize {
542        MIDEN_MAX_MESSAGE_WIDTH
543    }
544
545    fn lookup_num_bus_ids(self) -> usize {
546        BusId::COUNT
547    }
548
549    fn lookup_eval<LB: Poseidon2PermutationLookupBuilder>(self, builder: &mut LB) {
550        let main = builder.main();
551        let local: &Poseidon2PermutationCols<_> = main.current_slice().borrow();
552
553        constraints::lookup::poseidon2_permutation_air::emit_poseidon2_permutation_lookup_columns(
554            builder, local,
555        );
556    }
557
558    fn lookup_eval_boundary<B: BoundaryBuilder>(self, _boundary: &mut B) {}
559}
560
561// MIDEN AIR
562// ================================================================================================
563
564/// AIR instance identifier for the Miden multi-AIR statement.
565///
566/// [`MultiAir::Air`](miden_crypto::stark::air::MultiAir) is a single associated type, so every
567/// instance in the multi-AIR proof must have the same type. This enum identifies which concrete
568/// AIR logic to dispatch to.
569#[derive(Copy, Clone, Debug, Eq, PartialEq)]
570pub enum MidenAir {
571    Core,
572    Chiplets,
573    Poseidon2Permutation,
574}
575
576impl MidenAir {
577    pub const fn instance_index(self) -> usize {
578        match self {
579            Self::Core => 0,
580            Self::Chiplets => 1,
581            Self::Poseidon2Permutation => 2,
582        }
583    }
584
585    pub const fn name(self) -> &'static str {
586        match self {
587            Self::Core => "Core",
588            Self::Chiplets => "Chiplets",
589            Self::Poseidon2Permutation => "Poseidon2Permutation",
590        }
591    }
592
593    pub const fn file_token(self) -> &'static str {
594        match self {
595            Self::Core => "core",
596            Self::Chiplets => "chiplets",
597            Self::Poseidon2Permutation => "poseidon2_permutation",
598        }
599    }
600
601    fn boundary_correction<EF: ExtensionField<Felt>>(
602        self,
603        challenges: &Challenges<EF>,
604        public_values: &[Felt],
605        aux_inputs: &[Felt],
606    ) -> Result<EF, ReductionError> {
607        if aux_inputs.len() < AUX_KERNEL_DIGESTS {
608            return Err(format!(
609                "aux_inputs length {} is shorter than the fixed prefix {AUX_KERNEL_DIGESTS}",
610                aux_inputs.len()
611            )
612            .into());
613        }
614
615        match self {
616            Self::Core => CoreAir.boundary_correction(
617                challenges,
618                public_values,
619                &[&aux_inputs[..AUX_KERNEL_DIGESTS]],
620            ),
621            Self::Chiplets => ChipletsAir.boundary_correction(
622                challenges,
623                public_values,
624                &[&aux_inputs[AUX_KERNEL_DIGESTS..]],
625            ),
626            Self::Poseidon2Permutation => {
627                Poseidon2PermutationAir.boundary_correction(challenges, public_values, &[])
628            },
629        }
630    }
631
632    /// Evaluate the hand-written constraint definitions.
633    ///
634    /// [`LiftedAir::eval`] runs the generated evaluators instead; use this entry
635    /// point (via [`HandwrittenMidenAir`] where an AIR value is expected) when
636    /// the hand-written source itself is required — regenerating the evaluators,
637    /// or checking them for drift.
638    pub fn eval_handwritten<AB: LiftedAirBuilder<F = Felt>>(&self, builder: &mut AB) {
639        match self {
640            Self::Core => CoreAir.eval(builder),
641            Self::Chiplets => ChipletsAir.eval(builder),
642            Self::Poseidon2Permutation => Poseidon2PermutationAir.eval(builder),
643        }
644    }
645}
646
647/// [`MidenAir`] with `eval` routed to the hand-written constraint definitions.
648///
649/// Symbolic capture that feeds artifact generation, or anchors an equivalence
650/// oracle, must consume the hand-written source — a generated evaluator as input
651/// makes regeneration self-referential and severs the chain back to the auditable
652/// source. Passing this wrapper enforces that by construction.
653#[derive(Copy, Clone, Debug)]
654pub struct HandwrittenMidenAir(pub MidenAir);
655
656impl BaseAir<Felt> for HandwrittenMidenAir {
657    fn width(&self) -> usize {
658        self.0.width()
659    }
660
661    fn num_public_values(&self) -> usize {
662        BaseAir::<Felt>::num_public_values(&self.0)
663    }
664
665    fn periodic_columns(&self) -> Vec<Vec<Felt>> {
666        self.0.periodic_columns()
667    }
668}
669
670impl<EF: ExtensionField<Felt>> LiftedAir<Felt, EF> for HandwrittenMidenAir {
671    fn num_randomness(&self) -> usize {
672        LiftedAir::<Felt, EF>::num_randomness(&self.0)
673    }
674
675    fn aux_width(&self) -> usize {
676        LiftedAir::<Felt, EF>::aux_width(&self.0)
677    }
678
679    fn num_aux_values(&self) -> usize {
680        LiftedAir::<Felt, EF>::num_aux_values(&self.0)
681    }
682
683    fn build_aux_trace(
684        &self,
685        main: &RowMajorMatrix<Felt>,
686        air_inputs: &[Felt],
687        aux_inputs: &[Felt],
688        challenges: &[EF],
689    ) -> (RowMajorMatrix<EF>, Vec<EF>) {
690        self.0.build_aux_trace(main, air_inputs, aux_inputs, challenges)
691    }
692
693    fn constraint_degree(&self) -> ConstraintDegrees {
694        LiftedAir::<Felt, EF>::constraint_degree(&self.0)
695    }
696
697    fn eval<AB: LiftedAirBuilder<F = Felt>>(&self, builder: &mut AB) {
698        self.0.eval_handwritten(builder)
699    }
700}
701
702impl BaseAir<Felt> for MidenAir {
703    fn width(&self) -> usize {
704        match self {
705            Self::Core => CoreAir.width(),
706            Self::Chiplets => ChipletsAir.width(),
707            Self::Poseidon2Permutation => Poseidon2PermutationAir.width(),
708        }
709    }
710
711    fn num_public_values(&self) -> usize {
712        NUM_PUBLIC_VALUES
713    }
714
715    fn periodic_columns(&self) -> Vec<Vec<Felt>> {
716        match self {
717            Self::Core => CoreAir.periodic_columns(),
718            Self::Chiplets => ChipletsAir.periodic_columns(),
719            Self::Poseidon2Permutation => Poseidon2PermutationAir.periodic_columns(),
720        }
721    }
722}
723
724impl<EF: ExtensionField<Felt>> LiftedAir<Felt, EF> for MidenAir {
725    fn num_randomness(&self) -> usize {
726        // Instance-level: every AIR shares the same LogUp challenge set.
727        trace::AUX_TRACE_RAND_CHALLENGES
728    }
729
730    fn aux_width(&self) -> usize {
731        match self {
732            Self::Core => CoreAir.aux_width(),
733            Self::Chiplets => ChipletsAir.aux_width(),
734            Self::Poseidon2Permutation => Poseidon2PermutationAir.aux_width(),
735        }
736    }
737
738    fn num_aux_values(&self) -> usize {
739        // One committed normalized LogUp sum `sigma_prime = sigma / n` per AIR instance.
740        1
741    }
742
743    fn build_aux_trace(
744        &self,
745        main: &RowMajorMatrix<Felt>,
746        _air_inputs: &[Felt],
747        _aux_inputs: &[Felt],
748        challenges: &[EF],
749    ) -> (RowMajorMatrix<EF>, Vec<EF>) {
750        let (aux_trace, committed) = build_logup_aux_trace(self, main, challenges);
751        debug_assert_eq!(
752            committed.len(),
753            1,
754            "build_logup_aux_trace returns one normalized LogUp sum per AIR"
755        );
756        (aux_trace, committed)
757    }
758
759    fn constraint_degree(&self) -> ConstraintDegrees {
760        match self {
761            Self::Core | Self::Chiplets => ConstraintDegrees { base: 9, ext: 9 },
762            Self::Poseidon2Permutation => ConstraintDegrees { base: 8, ext: 3 },
763        }
764    }
765
766    fn eval<AB: LiftedAirBuilder<F = Felt>>(&self, builder: &mut AB) {
767        // The generated, globally-CSE'd evaluators: same constraint values in
768        // the same global order as the hand-written definitions, machine-checked
769        // by tests/generated_drift.rs. The definitions themselves remain
770        // available via `eval_handwritten`.
771        match self {
772            Self::Core => constraints::generated::eval_core(builder),
773            Self::Chiplets => constraints::generated::eval_chiplets(builder),
774            Self::Poseidon2Permutation => {
775                constraints::generated::eval_poseidon2_permutation(builder)
776            },
777        }
778    }
779}
780
781impl<LB> LookupAir<LB> for MidenAir
782where
783    LB: MainLookupBuilder + ChipletLookupBuilder + Poseidon2PermutationLookupBuilder,
784{
785    fn num_columns(&self) -> usize {
786        match self {
787            Self::Core => CoreAir.lookup_num_columns(),
788            Self::Chiplets => ChipletsAir.lookup_num_columns(),
789            Self::Poseidon2Permutation => Poseidon2PermutationAir.lookup_num_columns(),
790        }
791    }
792
793    fn column_shape(&self) -> &[usize] {
794        match self {
795            Self::Core => CoreAir.lookup_column_shape(),
796            Self::Chiplets => ChipletsAir.lookup_column_shape(),
797            Self::Poseidon2Permutation => Poseidon2PermutationAir.lookup_column_shape(),
798        }
799    }
800
801    fn max_message_width(&self) -> usize {
802        match self {
803            Self::Core => CoreAir.lookup_max_message_width(),
804            Self::Chiplets => ChipletsAir.lookup_max_message_width(),
805            Self::Poseidon2Permutation => Poseidon2PermutationAir.lookup_max_message_width(),
806        }
807    }
808
809    fn num_bus_ids(&self) -> usize {
810        match self {
811            Self::Core => CoreAir.lookup_num_bus_ids(),
812            Self::Chiplets => ChipletsAir.lookup_num_bus_ids(),
813            Self::Poseidon2Permutation => Poseidon2PermutationAir.lookup_num_bus_ids(),
814        }
815    }
816
817    fn eval(&self, builder: &mut LB) {
818        match self {
819            Self::Core => CoreAir.lookup_eval(builder),
820            Self::Chiplets => ChipletsAir.lookup_eval(builder),
821            Self::Poseidon2Permutation => Poseidon2PermutationAir.lookup_eval(builder),
822        }
823    }
824
825    fn eval_boundary<B>(&self, boundary: &mut B)
826    where
827        B: BoundaryBuilder<F = LB::F, EF = LB::EF>,
828    {
829        match self {
830            Self::Core => CoreAir.lookup_eval_boundary(boundary),
831            Self::Chiplets => ChipletsAir.lookup_eval_boundary(boundary),
832            Self::Poseidon2Permutation => Poseidon2PermutationAir.lookup_eval_boundary(boundary),
833        }
834    }
835}
836
837// MIDEN MULTI-AIR
838// ================================================================================================
839
840/// The cross-AIR statement for the Miden VM proof.
841///
842/// AIR instances come from [`AIRS`], and the external reduction combines the trace-length-weighted
843/// normalized LogUp sums with the open-bus boundary corrections.
844///
845/// Instance order is `[Core, Chiplets, Poseidon2Permutation]`; every per-AIR slice follows that
846/// ordering.
847#[derive(Copy, Clone, Debug)]
848pub struct MidenMultiAir;
849
850impl MidenMultiAir {
851    /// Construct the Miden multi-AIR statement marker.
852    pub const fn new() -> Self {
853        Self
854    }
855}
856
857impl Default for MidenMultiAir {
858    fn default() -> Self {
859        Self::new()
860    }
861}
862
863impl<EF: ExtensionField<Felt>> MultiAir<Felt, EF> for MidenMultiAir {
864    type Air = MidenAir;
865
866    fn airs(&self) -> &[MidenAir] {
867        &AIRS
868    }
869
870    fn num_air_inputs(&self) -> usize {
871        NUM_PUBLIC_VALUES
872    }
873
874    fn max_aux_inputs(&self) -> usize {
875        // aux_inputs = program hash (1 word) + deferred root (1 word) + the var-len
876        // kernel-digest group: one `Word` per kernel procedure, capped at
877        // `KernelDescriptor::MAX_NUM_PROCEDURES`.
878        AUX_KERNEL_DIGESTS + KernelDescriptor::MAX_NUM_PROCEDURES * WORD_SIZE
879    }
880
881    /// Absorb statement-owned public inputs into the Fiat-Shamir challenger.
882    ///
883    /// One rate-aligned block: `[CLAIM_HASH (4) | deferred_root (4)]`, where `CLAIM_HASH` is the
884    /// canonical execution-claim commitment (see `miden_core::program::ExecutionClaim`) over
885    /// `program_hash ‖ kernel_H ‖ stack_inputs ‖ stack_outputs`. With the relation digest
886    /// pre-loaded in the challenger (see `config`), the transcript state after this block
887    /// realizes the factored statement binding `H(RELATION_DIGEST ‖ CLAIM_HASH ‖ D)`.
888    ///
889    /// The kernel digests enter the transcript only through `kernel_H` (see
890    /// [`hash_kernel_digests`]); the raw stack I/O and digest list remain public values for
891    /// constraint evaluation and are not separately absorbed.
892    fn observe<C: CanObserve<Felt>>(
893        &self,
894        challenger: &mut C,
895        air_inputs: &[Felt],
896        aux_inputs: &[Felt],
897        _log_trace_heights: &[u8],
898    ) {
899        assert_eq!(air_inputs.len(), NUM_PUBLIC_VALUES, "unexpected public-value count");
900        assert!(
901            aux_inputs.len() >= AUX_KERNEL_DIGESTS,
902            "aux inputs shorter than the fixed program-hash + deferred-root prefix"
903        );
904
905        let kernel_h = hash_kernel_digests(&aux_inputs[AUX_KERNEL_DIGESTS..]);
906        let program_hash = &aux_inputs[AUX_PROGRAM_HASH..AUX_PROGRAM_HASH + WORD_SIZE];
907        let deferred_root = &aux_inputs[AUX_DEFERRED_ROOT..AUX_DEFERRED_ROOT + WORD_SIZE];
908
909        // Canonical claim encoding P ‖ K ‖ I ‖ O; the offset layout of
910        // `ExecutionClaim::to_elements`, pinned by `observe_matches_execution_claim_commitment`.
911        let mut claim = [Felt::ZERO; NUM_CLAIM_ELEMENTS];
912        claim[0..WORD_SIZE].copy_from_slice(program_hash);
913        claim[WORD_SIZE..2 * WORD_SIZE].copy_from_slice(&kernel_h);
914        claim[2 * WORD_SIZE..].copy_from_slice(air_inputs);
915        let claim_hash = miden_core::program::claim_commitment(&claim);
916
917        for &v in claim_hash.as_elements().iter().chain(deferred_root) {
918            challenger.observe(v);
919        }
920    }
921
922    /// Cross-AIR LogUp closure: each AIR commits `sigma_prime_i = sigma_i / n_i`, so the
923    /// trace-length-weighted sum `sum_i(n_i * sigma_prime_i)` plus the per-trace boundary
924    /// corrections must vanish. Boundary corrections are added once and are not trace-length
925    /// scaled. `aux_values` and `log_trace_heights` are parallel in instance order. `aux_inputs`
926    /// carries the program hash and deferred root followed by kernel digests.
927    fn eval_external(
928        &self,
929        challenges: &[EF],
930        air_inputs: &[Felt],
931        aux_inputs: &[Felt],
932        aux_values: &[&[EF]],
933        log_trace_heights: &[u8],
934    ) -> Result<Vec<EF>, ReductionError> {
935        if aux_values.len() != AIRS.len() {
936            return Err(format!(
937                "expected aux values for {} AIRs, got {}",
938                AIRS.len(),
939                aux_values.len()
940            )
941            .into());
942        }
943        if log_trace_heights.len() != AIRS.len() {
944            return Err(format!(
945                "expected log heights for {} AIRs, got {}",
946                AIRS.len(),
947                log_trace_heights.len()
948            )
949            .into());
950        }
951        if challenges.len() != trace::AUX_TRACE_RAND_CHALLENGES {
952            return Err(format!(
953                "expected {} aux trace challenges, got {}",
954                trace::AUX_TRACE_RAND_CHALLENGES,
955                challenges.len()
956            )
957            .into());
958        }
959        if air_inputs.len() != NUM_PUBLIC_VALUES {
960            return Err(format!(
961                "expected {NUM_PUBLIC_VALUES} public values, got {}",
962                air_inputs.len()
963            )
964            .into());
965        }
966        if aux_inputs.len() < AUX_KERNEL_DIGESTS {
967            return Err(format!(
968                "aux_inputs length {} is shorter than the fixed prefix {AUX_KERNEL_DIGESTS}",
969                aux_inputs.len()
970            )
971            .into());
972        }
973        let max_aux_inputs = <MidenMultiAir as MultiAir<Felt, EF>>::max_aux_inputs(self);
974        if aux_inputs.len() > max_aux_inputs {
975            return Err(format!(
976                "aux_inputs length {} exceeds maximum {max_aux_inputs}",
977                aux_inputs.len()
978            )
979            .into());
980        }
981        let challenges = Challenges::<EF>::new(
982            challenges[0],
983            challenges[1],
984            MIDEN_MAX_MESSAGE_WIDTH,
985            BusId::COUNT,
986        );
987
988        let mut weighted_aux_sum = EF::ZERO;
989        let mut boundary_correction = EF::ZERO;
990        for ((air, values), &log_height) in
991            AIRS.iter().copied().zip(aux_values.iter()).zip(log_trace_heights)
992        {
993            boundary_correction += air.boundary_correction(&challenges, air_inputs, aux_inputs)?;
994            let expected = <MidenAir as LiftedAir<Felt, EF>>::num_aux_values(&air);
995            if values.len() != expected {
996                return Err(format!(
997                    "{} expects {expected} aux boundary values, got {}",
998                    air.name(),
999                    values.len()
1000                )
1001                .into());
1002            }
1003
1004            let trace_length = 1_u64.checked_shl(u32::from(log_height)).ok_or_else(|| {
1005                ReductionError::from(format!(
1006                    "{} log trace height {log_height} does not fit in u64",
1007                    air.name()
1008                ))
1009            })?;
1010            weighted_aux_sum +=
1011                values.iter().copied().sum::<EF>() * Felt::new_unchecked(trace_length);
1012        }
1013
1014        Ok(vec![weighted_aux_sum + boundary_correction])
1015    }
1016}
1017
1018// KERNEL DIGEST SUMMARY HASH
1019// ================================================================================================
1020
1021/// Computes `kernel_H`, the fixed-size commitment to the kernel-procedure digests.
1022///
1023/// This is the canonical [`KernelDescriptor::commitment`] value expressed over the flattened digest
1024/// felts: the domain-tagged linear hash (`hash_elements_in_domain` with
1025/// [`miden_core::program::KERNEL_DOMAIN_TAG`]) of `kernel_felts`. The empty digest list yields
1026/// the canonical empty-input value under the same domain.
1027///
1028/// `kernel_H` is absorbed into the Fiat-Shamir transcript in place of the unbounded kernel
1029/// digest list, committing to the kernel with a fixed-size value.
1030pub fn hash_kernel_digests(kernel_felts: &[Felt]) -> [Felt; WORD_SIZE] {
1031    assert!(
1032        kernel_felts.len().is_multiple_of(WORD_SIZE),
1033        "kernel digest felts must be whole words"
1034    );
1035    assert!(
1036        kernel_felts.len() <= KernelDescriptor::MAX_NUM_PROCEDURES * WORD_SIZE,
1037        "kernel digest felts exceed KernelDescriptor::MAX_NUM_PROCEDURES"
1038    );
1039
1040    hash_kernel_input_felts(kernel_felts)
1041}
1042
1043fn hash_kernel_input_felts(kernel_felts: &[Felt]) -> [Felt; WORD_SIZE] {
1044    miden_core::chiplets::hasher::hash_elements_in_domain(
1045        kernel_felts,
1046        miden_core::program::KERNEL_DOMAIN_TAG,
1047    )
1048    .into()
1049}
1050
1051// REDUCED-AUX BOUNDARY BUILDER
1052// ================================================================================================
1053
1054/// `BoundaryBuilder` impl that reduces each emitted interaction to its LogUp denominator
1055/// contribution `multiplicity / encode(msg)` and sums them into a running `EF` accumulator.
1056///
1057/// Boundary correction is computed from the structured boundary messages emitted by each AIR.
1058///
1059/// Denominators are `alpha + sum_i beta^i * field_i` with random `alpha, beta`; on any
1060/// legitimate proof they are non-zero with overwhelming probability. A malformed proof can still
1061/// drive a denominator to zero, so the reducer captures the first failure and surfaces it as a
1062/// [`ReductionError`] to the verifier rather than panicking.
1063struct ReduceBoundaryBuilder<'a, EF: ExtensionField<Felt>> {
1064    challenges: &'a Challenges<EF>,
1065    public_values: &'a [Felt],
1066    var_len_public_inputs: &'a [&'a [Felt]],
1067    sum: EF,
1068    error: Option<ReductionError>,
1069}
1070
1071impl<'a, EF: ExtensionField<Felt>> ReduceBoundaryBuilder<'a, EF> {
1072    fn finalize(self) -> Result<EF, ReductionError> {
1073        match self.error {
1074            Some(err) => Err(err),
1075            None => Ok(self.sum),
1076        }
1077    }
1078}
1079
1080impl<'a, EF: ExtensionField<Felt>> BoundaryBuilder for ReduceBoundaryBuilder<'a, EF> {
1081    type F = Felt;
1082    type EF = EF;
1083
1084    fn public_values(&self) -> &[Felt] {
1085        self.public_values
1086    }
1087
1088    fn var_len_public_inputs(&self) -> &[&[Felt]] {
1089        self.var_len_public_inputs
1090    }
1091
1092    fn insert<M>(&mut self, _name: &'static str, multiplicity: Felt, msg: M)
1093    where
1094        M: LookupMessage<Felt, EF>,
1095    {
1096        if self.error.is_some() {
1097            return;
1098        }
1099        match msg.encode(self.challenges).try_inverse() {
1100            Some(inv) => self.sum += inv * multiplicity,
1101            None => {
1102                self.error = Some("LogUp boundary denominator was zero".into());
1103            },
1104        }
1105    }
1106}
1107
1108// TESTS
1109// ================================================================================================
1110
1111#[cfg(test)]
1112mod tests {
1113    use alloc::string::ToString;
1114
1115    use miden_core::field::{PrimeCharacteristicRing, QuadFelt};
1116
1117    use super::*;
1118
1119    /// Guards the static `constraint_degree` override: if an AIR change moves the symbolic
1120    /// degree away from the declared value, the override must be updated.
1121    #[test]
1122    fn constraint_degree_override_matches_symbolic() {
1123        for air in AIRS {
1124            let symbolic = ConstraintDegrees::from_air::<Felt, QuadFelt, _>(&air);
1125            let declared = <MidenAir as LiftedAir<Felt, QuadFelt>>::constraint_degree(&air);
1126            assert_eq!(declared, symbolic, "static constraint_degree override is stale");
1127        }
1128    }
1129
1130    #[test]
1131    fn eval_external_weights_normalized_sums_by_trace_length() {
1132        let multi_air = MidenMultiAir::new();
1133        let raw_challenges = [QuadFelt::from_u32(7), QuadFelt::from_u32(11)];
1134        let air_inputs = vec![Felt::ZERO; NUM_PUBLIC_VALUES];
1135        let aux_inputs = vec![Felt::ZERO; AUX_KERNEL_DIGESTS];
1136        let normalized_sums =
1137            [QuadFelt::from_u32(13), QuadFelt::from_u32(17), QuadFelt::from_u32(19)];
1138        let core_values = [normalized_sums[0]];
1139        let chiplets_values = [normalized_sums[1]];
1140        let poseidon2_values = [normalized_sums[2]];
1141        let aux_values: [&[QuadFelt]; MIDEN_AIR_COUNT] =
1142            [&core_values, &chiplets_values, &poseidon2_values];
1143        let log_trace_heights = [6, 9, 7];
1144
1145        let lookup_challenges = Challenges::new(
1146            raw_challenges[0],
1147            raw_challenges[1],
1148            MIDEN_MAX_MESSAGE_WIDTH,
1149            BusId::COUNT,
1150        );
1151        let mut boundary_correction = QuadFelt::ZERO;
1152        for air in AIRS {
1153            boundary_correction +=
1154                air.boundary_correction(&lookup_challenges, &air_inputs, &aux_inputs).unwrap();
1155        }
1156
1157        let result = <MidenMultiAir as MultiAir<Felt, QuadFelt>>::eval_external(
1158            &multi_air,
1159            &raw_challenges,
1160            &air_inputs,
1161            &aux_inputs,
1162            &aux_values,
1163            &log_trace_heights,
1164        )
1165        .unwrap();
1166
1167        let weighted_sum = normalized_sums
1168            .iter()
1169            .zip(log_trace_heights)
1170            .map(|(&value, log_height)| value * Felt::new_unchecked(1_u64 << u32::from(log_height)))
1171            .sum::<QuadFelt>();
1172        assert_eq!(result, vec![weighted_sum + boundary_correction]);
1173    }
1174
1175    #[test]
1176    fn eval_external_rejects_partial_kernel_digest() {
1177        let challenges =
1178            [QuadFelt::from(Felt::new_unchecked(3)), QuadFelt::from(Felt::new_unchecked(5))];
1179        let air_inputs = vec![Felt::ZERO; NUM_PUBLIC_VALUES];
1180        let mut aux_inputs = vec![Felt::ZERO; AUX_KERNEL_DIGESTS];
1181        aux_inputs.push(Felt::ONE);
1182        let zero = QuadFelt::from(Felt::ZERO);
1183        let core_aux = [zero];
1184        let chiplets_aux = [zero];
1185        let poseidon2_aux = [zero];
1186        let aux_values = [core_aux.as_slice(), chiplets_aux.as_slice(), poseidon2_aux.as_slice()];
1187
1188        let err = MidenMultiAir::new()
1189            .eval_external(&challenges, &air_inputs, &aux_inputs, &aux_values, &[8, 8, 8])
1190            .unwrap_err();
1191
1192        assert!(err.to_string().contains("kernel digest felts length 1 is not a multiple of 4"));
1193    }
1194
1195    #[test]
1196    fn eval_external_rejects_too_many_kernel_digests() {
1197        let challenges =
1198            [QuadFelt::from(Felt::new_unchecked(3)), QuadFelt::from(Felt::new_unchecked(5))];
1199        let air_inputs = vec![Felt::ZERO; NUM_PUBLIC_VALUES];
1200        let max_aux_inputs = AUX_KERNEL_DIGESTS + KernelDescriptor::MAX_NUM_PROCEDURES * WORD_SIZE;
1201        let actual_aux_inputs = max_aux_inputs + WORD_SIZE;
1202        let aux_inputs = vec![Felt::ZERO; actual_aux_inputs];
1203        let zero = QuadFelt::from(Felt::ZERO);
1204        let core_aux = [zero];
1205        let chiplets_aux = [zero];
1206        let poseidon2_aux = [zero];
1207        let aux_values = [core_aux.as_slice(), chiplets_aux.as_slice(), poseidon2_aux.as_slice()];
1208
1209        let err = MidenMultiAir::new()
1210            .eval_external(&challenges, &air_inputs, &aux_inputs, &aux_values, &[8, 8, 8])
1211            .unwrap_err();
1212
1213        assert!(err.to_string().contains(&format!(
1214            "aux_inputs length {actual_aux_inputs} exceeds maximum {max_aux_inputs}"
1215        )));
1216    }
1217
1218    #[test]
1219    fn hash_kernel_digests_matches_kernel_descriptor_commitment() {
1220        // The transcript-side helper and `KernelDescriptor::commitment` are two computations of
1221        // the same normative value; this pins them together (including the empty kernel).
1222        use miden_core::Word;
1223
1224        let word = |a: u64| -> Word {
1225            [Felt::new_unchecked(a), Felt::new_unchecked(a + 1), Felt::ZERO, Felt::ONE].into()
1226        };
1227        for procs in [vec![], vec![word(10)], vec![word(10), word(20), word(30)]] {
1228            let descriptor = KernelDescriptor::from_hashes(procs).unwrap();
1229            let flattened: Vec<Felt> =
1230                descriptor.proc_hashes().iter().flat_map(|w| w.as_elements().to_vec()).collect();
1231            assert_eq!(
1232                Word::new(hash_kernel_digests(&flattened)),
1233                descriptor.commitment(),
1234                "hash_kernel_digests diverged from KernelDescriptor::commitment"
1235            );
1236        }
1237    }
1238
1239    #[test]
1240    #[should_panic(expected = "kernel digest felts exceed KernelDescriptor::MAX_NUM_PROCEDURES")]
1241    fn hash_kernel_digests_rejects_too_many_digest_felts() {
1242        let kernel_felts = vec![Felt::ZERO; (KernelDescriptor::MAX_NUM_PROCEDURES + 1) * WORD_SIZE];
1243
1244        let _ = hash_kernel_digests(&kernel_felts);
1245    }
1246
1247    #[test]
1248    fn observe_matches_execution_claim_commitment() {
1249        // The transcript's statement block must open with exactly
1250        // `ExecutionClaim::commitment()` for the same statement, followed by the deferred
1251        // root — pinning `observe`'s inline claim encoding to the canonical one.
1252        use miden_core::{field::QuadFelt, program::ExecutionClaim};
1253
1254        #[derive(Default)]
1255        struct FeltSink {
1256            observed: Vec<Felt>,
1257        }
1258        impl CanObserve<Felt> for FeltSink {
1259            fn observe(&mut self, value: Felt) {
1260                self.observed.push(value);
1261            }
1262        }
1263
1264        let word = |a: u64| -> Word {
1265            [
1266                Felt::new_unchecked(a),
1267                Felt::new_unchecked(a + 1),
1268                Felt::new_unchecked(a + 2),
1269                Felt::new_unchecked(a + 3),
1270            ]
1271            .into()
1272        };
1273        let kernel = KernelDescriptor::from_hashes(vec![word(50), word(60)]).unwrap();
1274        let program_hash = word(1);
1275        let stack_inputs =
1276            StackInputs::new(&[Felt::new_unchecked(5), Felt::new_unchecked(6)]).unwrap();
1277        let stack_outputs = StackOutputs::new(&[Felt::new_unchecked(7)]).unwrap();
1278        let deferred_root = word(90);
1279
1280        let claim = ExecutionClaim::from_program_info(
1281            ProgramInfo::new(program_hash, kernel.clone()),
1282            stack_inputs,
1283            stack_outputs,
1284        );
1285
1286        // air_inputs = I ‖ O; aux_inputs = P ‖ D ‖ kernel digest felts.
1287        let mut air_inputs = [Felt::ZERO; NUM_PUBLIC_VALUES];
1288        air_inputs[0..MIN_STACK_DEPTH].copy_from_slice(&stack_inputs[..]);
1289        air_inputs[MIN_STACK_DEPTH..].copy_from_slice(&stack_outputs[..]);
1290        let mut aux_inputs: Vec<Felt> = Vec::new();
1291        aux_inputs.extend(program_hash.as_elements());
1292        aux_inputs.extend(deferred_root.as_elements());
1293        aux_inputs.extend(Word::words_as_elements(kernel.proc_hashes()));
1294
1295        let mut sink = FeltSink::default();
1296        <MidenMultiAir as MultiAir<Felt, QuadFelt>>::observe(
1297            &MidenMultiAir::new(),
1298            &mut sink,
1299            &air_inputs,
1300            &aux_inputs,
1301            &[10, 10, 10],
1302        );
1303
1304        let mut expected: Vec<Felt> = claim.commitment().as_elements().to_vec();
1305        expected.extend(deferred_root.as_elements());
1306        assert_eq!(sink.observed, expected, "observe must emit [CLAIM_HASH | D]");
1307    }
1308
1309    #[test]
1310    #[should_panic(
1311        expected = "aux inputs shorter than the fixed program-hash + deferred-root prefix"
1312    )]
1313    fn observe_rejects_short_aux_inputs() {
1314        #[derive(Default)]
1315        struct FeltSink {
1316            observed: Vec<Felt>,
1317        }
1318
1319        impl CanObserve<Felt> for FeltSink {
1320            fn observe(&mut self, value: Felt) {
1321                self.observed.push(value);
1322            }
1323        }
1324
1325        let mut challenger = FeltSink::default();
1326        let air_inputs = vec![Felt::ZERO; NUM_PUBLIC_VALUES];
1327        let multi_air = MidenMultiAir::new();
1328
1329        <MidenMultiAir as MultiAir<Felt, QuadFelt>>::observe(
1330            &multi_air,
1331            &mut challenger,
1332            &air_inputs,
1333            &[],
1334            &[8, 8, 8],
1335        );
1336    }
1337}