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