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