Skip to main content

lib_q_zkp/air/
mod.rs

1//! AIR (Algebraic Intermediate Representation) module
2//!
3//! This module provides standalone AIR implementations for common proof types
4//! used in zero-knowledge proofs. Each AIR defines constraints that can be
5//! verified using STARK proving systems.
6//!
7//! # Available AIRs
8//!
9//! - [`crate::air::arithmetic::ArithmeticAir`] - Basic arithmetic operations (multiplication constraints)
10//! - [`crate::air::range_proof::RangeProofAir`] - Proves a value is within a specified range
11//! - [`crate::air::hash_preimage::HashPreimageAir`] - Proves knowledge of a Poseidon-128 preimage (industry-standard for STARK constraint encoding)
12//! - [`crate::air::merkle_inclusion::MerkleInclusionAir`] - Proves membership in a Merkle tree
13//!
14//! # Security
15//!
16//! All AIR implementations follow these security principles:
17//! - Input validation to prevent DoS attacks
18//! - Constant-time operations where applicable
19//!
20//! # Example
21//!
22//! ```rust,ignore
23//! use lib_q_zkp::air::{ArithmeticAir, TraceGenerator};
24//! use lib_q_stark_field::extension::Complex;
25//! use lib_q_stark_mersenne31::Mersenne31;
26//!
27//! type Val = Complex<Mersenne31>;
28//!
29//! // Create an AIR for 3 multiplication operations
30//! let air = ArithmeticAir::new(3).unwrap();
31//!
32//! // Generate a trace
33//! let inputs = vec![(Val::from(2u32), Val::from(3u32))];
34//! let trace = air.generate_trace(&inputs)?;
35//! ```
36
37extern crate alloc;
38
39use alloc::string::{
40    String,
41    ToString,
42};
43use alloc::vec::Vec;
44use core::fmt;
45
46use lib_q_poseidon::{
47    PoseidonField,
48    PoseidonParams,
49    sbox,
50};
51use lib_q_stark_field::{
52    BasedVectorSpace,
53    Field,
54    PrimeCharacteristicRing,
55};
56use lib_q_stark_matrix::dense::RowMajorMatrix;
57use lib_q_stark_mersenne31::Mersenne31;
58
59pub mod anonymous_auth;
60pub mod arithmetic;
61pub mod batch_stark_verifier;
62pub mod commitment_verifier;
63pub mod constraint_verifier;
64pub mod credential;
65pub mod fri_verifier;
66pub mod hash_preimage;
67pub mod hash_preimage_nist;
68pub mod identity_proof;
69pub mod merkle_inclusion;
70pub mod opening_verifier;
71pub mod poseidon_gadget;
72pub mod poseidon_hash;
73pub mod range_proof;
74pub mod recovery_policy;
75pub mod recovery_policy_hybrid;
76pub mod recursive_types;
77pub mod session_key;
78pub mod stark_verifier;
79pub mod state_transition;
80pub mod transaction;
81pub mod verifier_utils;
82
83pub use anonymous_auth::{
84    AnonymousAuthAir,
85    AnonymousAuthInput,
86};
87pub use arithmetic::ArithmeticAir;
88pub use batch_stark_verifier::{
89    BatchRecursiveStarkVerificationInput,
90    BatchStarkVerifierAir,
91    batch_recursive_verifier_public_values,
92};
93#[cfg(all(feature = "recursive-proofs-experimental", feature = "std"))]
94pub use commitment_verifier::debug_commitment_trace_sanity_check;
95pub use commitment_verifier::{
96    CommitmentVerificationInput,
97    CommitmentVerifierAir,
98};
99pub use constraint_verifier::{
100    ConstraintVerificationInput,
101    ConstraintVerifierAir,
102};
103pub use credential::{
104    CredentialAir,
105    CredentialInput,
106    CredentialSchema,
107};
108pub use fri_verifier::{
109    FriVerificationInput,
110    FriVerifierAir,
111};
112pub use hash_preimage::HashPreimageAir;
113pub use hash_preimage_nist::{
114    HASH_OUTPUT_BYTES,
115    HashPreimageNistAir,
116    HashPreimageNistInput,
117    expected_hash_to_public_values,
118};
119pub use identity_proof::{
120    IdentityProofAir,
121    IdentityProofInput,
122    MlDsaLevel,
123};
124pub use merkle_inclusion::{
125    MerkleHash,
126    MerkleInclusionAir,
127    MerkleProofInput,
128};
129pub use opening_verifier::{
130    OpeningVerificationInput,
131    OpeningVerifierAir,
132};
133pub use poseidon_gadget::PoseidonGadget;
134pub use poseidon_hash::PoseidonHashAir;
135pub use range_proof::RangeProofAir;
136pub use recovery_policy::{
137    RECOVERY_POLICY_AIR_ID,
138    RECOVERY_POLICY_COMMIT_DOMAIN,
139    RECOVERY_PUBLIC_INPUTS_LEN,
140    RECOVERY_VK_COMMIT_DOMAIN,
141    RecoveryPolicyAir,
142    RecoveryPolicyInput,
143    RecoveryPolicyKey,
144    RecoveryPolicyPublicInputs,
145    policy_commitment,
146    shake256_commit,
147    vk_commitment,
148};
149pub use recovery_policy_hybrid::{
150    RECOVERY_HYBRID_POLICY_COMMIT_DOMAIN,
151    RECOVERY_HYBRID_PUBLIC_INPUTS_LEN,
152    RECOVERY_HYBRID_VK_COMMIT_DOMAIN,
153    RECOVERY_POLICY_HYBRID_AIR_ID,
154    RecoveryPolicyHybridAir,
155    RecoveryPolicyHybridInput,
156    RecoveryPolicyHybridPublicInputs,
157    hybrid_policy_commitment,
158};
159pub use recursive_types::{
160    RecursiveStarkInput,
161    SerializedFriRound,
162    SerializedStarkProof,
163};
164pub use session_key::{
165    KdfAlgorithm,
166    KdfParams,
167    SessionKeyDerivationAir,
168    SessionKeyInput,
169};
170/// Trait for PCS commitments that are Poseidon Merkle roots. Used by the recursive verifier.
171/// The only implementation is when `recursive-proofs-experimental` is enabled (Hash in stark_verifier).
172pub trait PoseidonCommitmentRoot {
173    fn to_poseidon_root_bytes(&self) -> [u8; recursive_types::COMMITMENT_HASH_SIZE];
174}
175
176#[cfg(feature = "recursive-proofs-experimental")]
177pub use stark_verifier::{
178    MerklePathExtractable,
179    build_recursive_verification_input_from_proof,
180    build_recursive_verification_input_from_proof_with_poseidon,
181};
182pub use stark_verifier::{
183    RecursiveStarkVerificationInput,
184    StarkVerifierAir,
185    build_recursive_verification_input,
186};
187
188/// Maximum number of operations allowed in a single AIR instance
189/// to prevent memory exhaustion attacks.
190pub const MAX_OPERATIONS: usize = 1 << 20; // ~1 million operations
191
192/// Maximum trace width to prevent excessive memory allocation.
193/// Recursive StarkVerifierAir can exceed 65536; raised to 131072 for aggregation.
194pub const MAX_TRACE_WIDTH: usize = 1 << 17; // 131072 columns
195
196/// Maximum trace height (number of rows) to prevent memory exhaustion.
197pub const MAX_TRACE_HEIGHT: usize = 1 << 24; // ~16 million rows
198
199/// Error type for AIR operations
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub enum AirError {
202    /// AIR configuration has invalid dimensions
203    InvalidDimensions {
204        /// Description of the dimension error
205        reason: String,
206    },
207
208    /// AIR exceeds maximum allowed size
209    ExceedsMaxSize {
210        /// Name of the parameter that exceeded limits
211        parameter: String,
212        /// Maximum allowed value
213        max: usize,
214        /// Actual value provided
215        actual: usize,
216    },
217
218    /// Invalid input data for trace generation
219    InvalidInput {
220        /// Description of what was invalid
221        reason: String,
222    },
223
224    /// Trace dimensions don't match AIR requirements
225    TraceMismatch {
226        /// Expected width
227        expected_width: usize,
228        /// Actual width
229        actual_width: usize,
230    },
231
232    /// Witness values don't satisfy constraints
233    InvalidWitness {
234        /// Description of which constraint failed
235        constraint: String,
236    },
237
238    /// Internal error during AIR evaluation
239    InternalError {
240        /// Description of the error
241        reason: String,
242    },
243
244    /// Feature required but not enabled
245    NotSupported {
246        /// Description of what is not supported
247        reason: String,
248    },
249
250    /// FRI commit-phase openings missing for the query index
251    MissingFriCommitPhaseOpenings,
252
253    /// FRI commit-phase step count does not match number of rounds
254    FriRoundCountMismatch,
255}
256
257impl fmt::Display for AirError {
258    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
259        match self {
260            AirError::InvalidDimensions { reason } => {
261                write!(f, "Invalid AIR dimensions: {}", reason)
262            }
263            AirError::ExceedsMaxSize {
264                parameter,
265                max,
266                actual,
267            } => {
268                write!(
269                    f,
270                    "AIR parameter '{}' exceeds maximum: max={}, actual={}",
271                    parameter, max, actual
272                )
273            }
274            AirError::InvalidInput { reason } => {
275                write!(f, "Invalid input for trace generation: {}", reason)
276            }
277            AirError::TraceMismatch {
278                expected_width,
279                actual_width,
280            } => {
281                write!(
282                    f,
283                    "Trace width mismatch: expected {}, got {}",
284                    expected_width, actual_width
285                )
286            }
287            AirError::InvalidWitness { constraint } => {
288                write!(
289                    f,
290                    "Invalid witness: constraint '{}' not satisfied",
291                    constraint
292                )
293            }
294            AirError::InternalError { reason } => {
295                write!(f, "Internal AIR error: {}", reason)
296            }
297            AirError::NotSupported { reason } => {
298                write!(f, "Not supported: {}", reason)
299            }
300            AirError::MissingFriCommitPhaseOpenings => {
301                write!(f, "FRI commit-phase openings missing for query index")
302            }
303            AirError::FriRoundCountMismatch => {
304                write!(
305                    f,
306                    "FRI commit-phase step count does not match number of rounds"
307                )
308            }
309        }
310    }
311}
312
313impl From<AirError> for lib_q_core::Error {
314    fn from(err: AirError) -> Self {
315        lib_q_core::Error::InternalError {
316            operation: "AIR operation".into(),
317            details: err.to_string(),
318        }
319    }
320}
321
322/// Trait for AIRs that can generate execution traces from inputs
323///
324/// This trait extends the basic AIR functionality with the ability to
325/// generate valid execution traces from given inputs. The trace can then
326/// be used with STARK proving to generate proofs.
327///
328/// # Type Parameters
329///
330/// - `F`: The field type for trace values
331/// - `I`: The input type for trace generation
332pub trait TraceGenerator<F: Field, I> {
333    /// Generate an execution trace from the given inputs
334    ///
335    /// # Arguments
336    ///
337    /// * `inputs` - The inputs to generate the trace from
338    ///
339    /// # Returns
340    ///
341    /// A `RowMajorMatrix<F>` containing the trace, or an error if trace
342    /// generation fails.
343    ///
344    /// # Errors
345    ///
346    /// Returns `AirError` if:
347    /// - Input dimensions are invalid
348    /// - Input values don't produce a valid trace
349    /// - Memory allocation fails
350    fn generate_trace(&self, inputs: &I) -> Result<RowMajorMatrix<F>, AirError>;
351
352    /// Get the public values from the given inputs
353    ///
354    /// Public values are the values that are shared between prover and verifier.
355    /// These are typically outputs or commitments that are part of the statement
356    /// being proven.
357    ///
358    /// # Arguments
359    ///
360    /// * `inputs` - The inputs to extract public values from
361    ///
362    /// # Returns
363    ///
364    /// A vector of public field elements
365    fn public_values(&self, inputs: &I) -> Vec<F> {
366        let _ = inputs;
367        Vec::new()
368    }
369}
370
371/// Helper function to validate trace dimensions
372///
373/// # Arguments
374///
375/// * `width` - Trace width (number of columns)
376/// * `height` - Trace height (number of rows)
377///
378/// # Returns
379///
380/// `Ok(())` if dimensions are valid, `Err(AirError)` otherwise
381pub fn validate_trace_dimensions(width: usize, height: usize) -> Result<(), AirError> {
382    if width == 0 {
383        return Err(AirError::InvalidDimensions {
384            reason: "Trace width must be greater than 0".into(),
385        });
386    }
387
388    if width > MAX_TRACE_WIDTH {
389        return Err(AirError::ExceedsMaxSize {
390            parameter: "width".into(),
391            max: MAX_TRACE_WIDTH,
392            actual: width,
393        });
394    }
395
396    if height == 0 {
397        return Err(AirError::InvalidDimensions {
398            reason: "Trace height must be greater than 0".into(),
399        });
400    }
401
402    if height > MAX_TRACE_HEIGHT {
403        return Err(AirError::ExceedsMaxSize {
404            parameter: "height".into(),
405            max: MAX_TRACE_HEIGHT,
406            actual: height,
407        });
408    }
409
410    if !height.is_power_of_two() {
411        return Err(AirError::InvalidDimensions {
412            reason: "Trace height must be a power of 2".into(),
413        });
414    }
415
416    Ok(())
417}
418
419/// Round up to the next power of 2
420///
421/// # Arguments
422///
423/// * `n` - The number to round up
424///
425/// # Returns
426///
427/// The smallest power of 2 that is >= n
428pub fn next_power_of_two(n: usize) -> usize {
429    if n == 0 {
430        return 1;
431    }
432    n.next_power_of_two()
433}
434
435/// Convert PoseidonField to any Field F that supports u32 conversion
436///
437/// Converts PoseidonField (`Complex<Mersenne31>`) to the target field type,
438/// preserving both real and imaginary parts via basis decomposition.
439///
440/// # Arguments
441///
442/// * `pf` - The PoseidonField (`Complex<Mersenne31>`) to convert
443///
444/// # Returns
445///
446/// The field element in type F
447pub fn poseidon_to_field<F: Field + BasedVectorSpace<Mersenne31>>(pf: &PoseidonField) -> F {
448    let coeffs: &[Mersenne31] = pf.as_basis_coefficients_slice();
449    F::from_basis_coefficients_fn(|i| {
450        if i < coeffs.len() {
451            coeffs[i]
452        } else {
453            <Mersenne31 as PrimeCharacteristicRing>::ZERO
454        }
455    })
456}
457
458/// Convert slice of PoseidonField to `Vec<F>`
459///
460/// # Arguments
461///
462/// * `slice` - Slice of PoseidonField elements
463///
464/// # Returns
465///
466/// Vector of field elements in type F
467pub fn poseidon_slice_to_field<F: Field + BasedVectorSpace<Mersenne31>>(
468    slice: &[PoseidonField],
469) -> Vec<F> {
470    slice.iter().map(poseidon_to_field).collect()
471}
472
473/// Convert PoseidonField hash output to bytes
474///
475/// Uses RawDataSerializable to convert `Complex<Mersenne31>` elements to bytes.
476/// Each Complex element produces 8 bytes (4 for real, 4 for imag).
477///
478/// # Arguments
479///
480/// * `hash` - Slice of PoseidonField elements (hash output)
481///
482/// # Returns
483///
484/// Vector of bytes representing the hash
485pub fn poseidon_field_to_bytes(hash: &[PoseidonField]) -> Vec<u8> {
486    use lib_q_stark_field::RawDataSerializable;
487    // Complex<Mersenne31> has NUM_BYTES = 8 (4 real + 4 imag)
488    hash.iter().flat_map(|f| (*f).into_bytes()).collect()
489}
490
491/// Serialize a Merkle root (single PoseidonField) to a fixed 32-byte array.
492///
493/// Uses RawDataSerializable: one Complex&lt;Mersenne31&gt; produces 8 bytes (4 real + 4 imag, LE).
494/// The result is zero-padded to 32 bytes for a fixed-size root representation.
495///
496/// # Arguments
497///
498/// * `root` - The Merkle root as a Poseidon field element
499///
500/// # Returns
501///
502/// 32-byte array suitable for `verify_membership` and related APIs
503#[must_use]
504pub fn merkle_root_to_bytes(root: &PoseidonField) -> [u8; 32] {
505    use lib_q_stark_field::RawDataSerializable;
506    let mut out = [0u8; 32];
507    let bytes: Vec<u8> = (*root).into_bytes().into_iter().collect();
508    let n = core::cmp::min(bytes.len(), 32);
509    out[..n].copy_from_slice(&bytes[..n]);
510    out
511}
512
513/// Deserialize a Merkle root from bytes back to a PoseidonField.
514///
515/// Expects at least 8 bytes: first 4 bytes (u32 LE) = real part, next 4 = imag part
516/// of Complex&lt;Mersenne31&gt;. Used by verifiers to reconstruct the expected public value.
517///
518/// # Arguments
519///
520/// * `bytes` - At least 8 bytes (extra bytes are ignored)
521///
522/// # Returns
523///
524/// The root as PoseidonField, or InvalidInput if bytes.len() &lt; 8
525pub fn merkle_root_from_bytes(bytes: &[u8]) -> Result<PoseidonField, AirError> {
526    use lib_q_stark_field::extension::Complex;
527    use lib_q_stark_field::integers::QuotientMap;
528    use lib_q_stark_mersenne31::Mersenne31;
529
530    if bytes.len() < 8 {
531        return Err(AirError::InvalidInput {
532            reason: alloc::format!(
533                "Merkle root bytes must have at least 8 bytes, got {}",
534                bytes.len()
535            ),
536        });
537    }
538    let mut real_bytes = [0u8; 4];
539    let mut imag_bytes = [0u8; 4];
540    real_bytes.copy_from_slice(&bytes[0..4]);
541    imag_bytes.copy_from_slice(&bytes[4..8]);
542    let real = Mersenne31::from_int(u32::from_le_bytes(real_bytes));
543    let imag = Mersenne31::from_int(u32::from_le_bytes(imag_bytes));
544    Ok(Complex::new_complex(real, imag))
545}
546
547/// Compute one Poseidon permutation row: state in, intermediates, state out.
548///
549/// Uses `params.state_width` (e.g. 5 for Poseidon-128). Caller must pass at least
550/// `params.state_width` elements in `state`. Returns (final_state, intermediates).
551pub fn compute_poseidon_row(
552    state: &[PoseidonField],
553    params: &PoseidonParams,
554) -> (Vec<PoseidonField>, Vec<PoseidonField>) {
555    use lib_q_stark_field::extension::Complex;
556    use lib_q_stark_mersenne31::Mersenne31;
557
558    let n = params.state_width;
559    assert!(state.len() >= n, "state must have at least {} elements", n);
560    let zero = Complex::<Mersenne31>::new_complex(Mersenne31::ZERO, Mersenne31::ZERO);
561    let mut intermediates = Vec::new();
562    let mut round_idx = 0usize;
563    let mut s: Vec<PoseidonField> = state[0..n].to_vec();
564    let full_half = params.full_rounds / 2;
565
566    for _ in 0..full_half {
567        let after_arc: Vec<PoseidonField> = (0..n)
568            .map(|i| s[i] + params.round_constants[round_idx + i])
569            .collect();
570        round_idx += n;
571        intermediates.extend(after_arc.iter().cloned());
572        let after_sbox: Vec<PoseidonField> = (0..n).map(|i| sbox(after_arc[i])).collect();
573        intermediates.extend(after_sbox.iter().cloned());
574        let mut next_s = alloc::vec![zero; n];
575        for (i, next_s_i) in next_s.iter_mut().enumerate().take(n) {
576            for (j, &after_sbox_j) in after_sbox.iter().enumerate().take(n) {
577                *next_s_i += params.mds_matrix[i][j] * after_sbox_j;
578            }
579        }
580        intermediates.extend(next_s.iter().cloned());
581        s = next_s;
582    }
583    for _ in 0..params.partial_rounds {
584        let after_arc: Vec<PoseidonField> = (0..n)
585            .map(|i| s[i] + params.round_constants[round_idx + i])
586            .collect();
587        round_idx += n;
588        intermediates.extend(after_arc.iter().cloned());
589        let mut after_sbox = alloc::vec![zero; n];
590        after_sbox[0] = sbox(after_arc[0]);
591        after_sbox[1..n].copy_from_slice(&after_arc[1..n]);
592        intermediates.extend(after_sbox.iter().cloned());
593        let mut next_s = alloc::vec![zero; n];
594        for (i, next_s_i) in next_s.iter_mut().enumerate().take(n) {
595            for (j, &after_sbox_j) in after_sbox.iter().enumerate().take(n) {
596                *next_s_i += params.mds_matrix[i][j] * after_sbox_j;
597            }
598        }
599        intermediates.extend(next_s.iter().cloned());
600        s = next_s;
601    }
602    for _ in 0..full_half {
603        let after_arc: Vec<PoseidonField> = (0..n)
604            .map(|i| s[i] + params.round_constants[round_idx + i])
605            .collect();
606        round_idx += n;
607        intermediates.extend(after_arc.iter().cloned());
608        let after_sbox: Vec<PoseidonField> = (0..n).map(|i| sbox(after_arc[i])).collect();
609        intermediates.extend(after_sbox.iter().cloned());
610        let mut next_s = alloc::vec![zero; n];
611        for (i, next_s_i) in next_s.iter_mut().enumerate().take(n) {
612            for (j, &after_sbox_j) in after_sbox.iter().enumerate().take(n) {
613                *next_s_i += params.mds_matrix[i][j] * after_sbox_j;
614            }
615        }
616        intermediates.extend(next_s.iter().cloned());
617        s = next_s;
618    }
619    (s, intermediates)
620}
621
622/// Convert bytes to PoseidonField elements
623///
624/// This is a helper function to consistently convert byte slices to PoseidonField
625/// (`Complex<Mersenne31>`) elements. Each byte is converted to a field element.
626///
627/// # Arguments
628///
629/// * `bytes` - Slice of bytes to convert
630///
631/// # Returns
632///
633/// Vector of PoseidonField elements
634pub fn bytes_to_poseidon_field(bytes: &[u8]) -> Vec<PoseidonField> {
635    use lib_q_stark_field::extension::Complex;
636    use lib_q_stark_mersenne31::Mersenne31;
637    bytes
638        .iter()
639        .map(|b| Complex::<Mersenne31>::from(Mersenne31::new(*b as u32)))
640        .collect()
641}
642
643/// Decode the first 8 bytes of an Identity Token (IT) to the expected public value.
644/// The IT is the first 16 bytes of the encoding of the Poseidon hash output; the first 8 bytes
645/// encode one `Complex<Mersenne31>` (4 bytes real + 4 bytes imag, little-endian).
646pub fn it_bytes_to_public_value<F: Field + BasedVectorSpace<Mersenne31>>(it: &[u8; 16]) -> F {
647    use lib_q_stark_field::extension::Complex;
648    use lib_q_stark_mersenne31::Mersenne31;
649    let mut real_bytes = [0u8; 4];
650    let mut imag_bytes = [0u8; 4];
651    real_bytes.copy_from_slice(&it[0..4]);
652    imag_bytes.copy_from_slice(&it[4..8]);
653    let real = Mersenne31::new(u32::from_le_bytes(real_bytes));
654    let imag = Mersenne31::new(u32::from_le_bytes(imag_bytes));
655    let c = Complex::new_complex(real, imag);
656    poseidon_to_field::<F>(&c)
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    #[test]
664    fn test_validate_trace_dimensions_valid() {
665        assert!(validate_trace_dimensions(8, 16).is_ok());
666        assert!(validate_trace_dimensions(1, 1).is_ok());
667        assert!(validate_trace_dimensions(100, 1024).is_ok());
668    }
669
670    #[test]
671    fn test_validate_trace_dimensions_zero_width() {
672        let result = validate_trace_dimensions(0, 16);
673        assert!(matches!(result, Err(AirError::InvalidDimensions { .. })));
674    }
675
676    #[test]
677    fn test_validate_trace_dimensions_zero_height() {
678        let result = validate_trace_dimensions(8, 0);
679        assert!(matches!(result, Err(AirError::InvalidDimensions { .. })));
680    }
681
682    #[test]
683    fn test_validate_trace_dimensions_not_power_of_two() {
684        let result = validate_trace_dimensions(8, 15);
685        assert!(matches!(result, Err(AirError::InvalidDimensions { .. })));
686    }
687
688    #[test]
689    fn test_next_power_of_two() {
690        assert_eq!(next_power_of_two(0), 1);
691        assert_eq!(next_power_of_two(1), 1);
692        assert_eq!(next_power_of_two(2), 2);
693        assert_eq!(next_power_of_two(3), 4);
694        assert_eq!(next_power_of_two(5), 8);
695        assert_eq!(next_power_of_two(16), 16);
696    }
697
698    #[test]
699    fn test_air_error_display() {
700        let err = AirError::InvalidDimensions {
701            reason: "test".into(),
702        };
703        assert!(err.to_string().contains("Invalid AIR dimensions"));
704
705        let err = AirError::ExceedsMaxSize {
706            parameter: "width".into(),
707            max: 100,
708            actual: 200,
709        };
710        assert!(err.to_string().contains("exceeds maximum"));
711    }
712}