p3_challenger/
multi_field_challenger.rs

1use alloc::string::String;
2use alloc::vec;
3use alloc::vec::Vec;
4
5use p3_field::{
6    BasedVectorSpace, PrimeField, PrimeField32, absorb_radix_bits, max_absorb_injective_limbs,
7    reduce_packed, split_pf_to_field_order_limbs, squeeze_field_order_num_limbs,
8};
9use p3_symmetric::{CryptographicPermutation, Hash};
10
11use crate::{CanObserve, CanSample, CanSampleBits, DuplexChallenger, FieldChallenger};
12
13/// A challenger that samples in `F: PrimeField32` while the transcript sponge lives in `PF`.
14///
15/// Wraps [`DuplexChallenger<PF>`](DuplexChallenger): all permutations and `PF` rate state are
16/// exactly those of `inner`. This type only adapts
17///
18/// - **`F` → `PF`**: pending scalars are packed with [`reduce_packed`] (radix
19///   $2^{\texttt{absorb\_radix\_bits::<F>()}}$) into up to `RATE` `PF` rate slots, then
20///   [`DuplexChallenger::absorb_rate_padded_with_tag`](DuplexChallenger::absorb_rate_padded_with_tag)
21///   runs (zero-padded tail, length tag = number of `F`s absorbed).
22/// - **`PF` → `F`**: after each duplex, each rate cell is split with
23///   [`split_pf_to_field_order_limbs`] (base `|F|`, [`squeeze_field_order_num_limbs`] limbs per
24///   cell) into a flat queue consumed by [`CanSample::sample`]. Each extracted limb is uniform
25///   over the **entire** `F` domain (bias `< 1/|F|`). The inner `output_buffer` is then cleared
26///   so the next empty batch triggers a new duplex like [`DuplexChallenger::sample`].
27///
28/// **`observe(Hash)` / `observe(MerkleCap)`** flush pending `F`s through that packed absorb, then
29/// absorb digest words natively via the same `absorb_rate_padded_with_tag` (length tag = number of
30/// `PF` words in the block)—no PF → `F` → repack detour.
31#[derive(Clone, Debug)]
32pub struct MultiField32Challenger<F, PF, P, const WIDTH: usize, const RATE: usize>
33where
34    F: PrimeField32,
35    PF: PrimeField,
36    P: CryptographicPermutation<[PF; WIDTH]>,
37{
38    /// The underlying `PF` duplex sponge.
39    inner: DuplexChallenger<PF, P, WIDTH, RATE>,
40    f_buffer: Vec<F>,
41    /// Expanded `F` limbs from `inner.output_buffer` (same pop order as the pre-wrapper design).
42    f_squeeze_buffer: Vec<F>,
43}
44
45impl<F, PF, P, const WIDTH: usize, const RATE: usize> MultiField32Challenger<F, PF, P, WIDTH, RATE>
46where
47    F: PrimeField32,
48    PF: PrimeField,
49    P: CryptographicPermutation<[PF; WIDTH]>,
50{
51    /// Radix bit-width $b$ for packing observed `F` values via [`reduce_packed`]: the smallest
52    /// `b` with `F::ORDER_U32 - 1 < 2^b` (see [`p3_field::absorb_radix_bits`]).
53    #[inline]
54    #[must_use]
55    pub const fn absorb_radix_bits(&self) -> u32 {
56        absorb_radix_bits::<F>()
57    }
58
59    /// Maximum number of `F` elements packed into a single `PF` rate slot injectively (see
60    /// [`p3_field::max_absorb_injective_limbs`]). Pending scalars are absorbed in chunks of this
61    /// size; at most `RATE` such packed words are written per duplex step.
62    #[inline]
63    #[must_use]
64    pub fn absorb_num_f_elms(&self) -> usize {
65        max_absorb_injective_limbs::<F, PF>()
66    }
67
68    /// Number of base-`|F|` limbs taken from each squeezed `PF` rate cell when refilling the
69    /// `F` queue (see [`p3_field::squeeze_field_order_num_limbs`] and
70    /// [`p3_field::split_pf_to_field_order_limbs`]). Chooses near-uniform limbs over `F` for
71    /// uniform `PF`.
72    #[inline]
73    #[must_use]
74    pub fn squeeze_num_f_elms(&self) -> usize {
75        squeeze_field_order_num_limbs::<PF, F>()
76    }
77
78    /// Number of `F` challenges still queued from the current squeeze batch (after `sample` pops).
79    #[inline]
80    #[must_use]
81    pub const fn pending_f_squeeze_len(&self) -> usize {
82        self.f_squeeze_buffer.len()
83    }
84
85    pub fn new(permutation: P) -> Result<Self, String> {
86        if F::order() >= PF::order() {
87            return Err(String::from("F::order() must be less than PF::order()"));
88        }
89        if RATE >= WIDTH {
90            return Err(String::from("RATE must be less than WIDTH"));
91        }
92
93        Ok(Self {
94            inner: DuplexChallenger::new(permutation),
95            f_buffer: vec![],
96            f_squeeze_buffer: vec![],
97        })
98    }
99
100    fn flush_f_if_non_empty(&mut self) {
101        if self.f_buffer.is_empty() {
102            return;
103        }
104        let n_in = self.f_buffer.len();
105        let absorb_n = self.absorb_num_f_elms();
106        assert!(n_in <= absorb_n * RATE);
107        let rb = self.absorb_radix_bits();
108        let packed: Vec<PF> = self
109            .f_buffer
110            .chunks(absorb_n)
111            .map(|chunk| reduce_packed(chunk, rb))
112            .collect();
113        self.inner.absorb_rate_padded_with_tag(&packed, n_in as u8);
114        self.f_buffer.clear();
115        self.f_squeeze_buffer.clear();
116    }
117
118    fn refill_f_squeeze_from_inner(&mut self) {
119        self.f_squeeze_buffer.clear();
120        let squeeze_n = self.squeeze_num_f_elms();
121        for &pf in &self.inner.output_buffer {
122            self.f_squeeze_buffer
123                .extend(split_pf_to_field_order_limbs::<PF, F>(pf, squeeze_n));
124        }
125        // Match `DuplexChallenger` semantics: squeezing consumes the current rate row. Until these
126        // `F` limbs are exhausted, `inner.output_buffer` must read as empty so the next `sample`
127        // triggers a fresh duplex when needed.
128        self.inner.output_buffer.clear();
129    }
130}
131
132impl<F, PF, P, const WIDTH: usize, const RATE: usize> FieldChallenger<F>
133    for MultiField32Challenger<F, PF, P, WIDTH, RATE>
134where
135    F: PrimeField32,
136    PF: PrimeField,
137    P: CryptographicPermutation<[PF; WIDTH]>,
138{
139}
140
141impl<F, PF, P, const WIDTH: usize, const RATE: usize> CanObserve<F>
142    for MultiField32Challenger<F, PF, P, WIDTH, RATE>
143where
144    F: PrimeField32,
145    PF: PrimeField,
146    P: CryptographicPermutation<[PF; WIDTH]>,
147{
148    fn observe(&mut self, value: F) {
149        self.inner.output_buffer.clear();
150        self.f_squeeze_buffer.clear();
151        self.f_buffer.push(value);
152        if self.f_buffer.len() == self.absorb_num_f_elms() * RATE {
153            self.flush_f_if_non_empty();
154        }
155    }
156}
157
158impl<F, PF, const N: usize, P, const WIDTH: usize, const RATE: usize> CanObserve<[F; N]>
159    for MultiField32Challenger<F, PF, P, WIDTH, RATE>
160where
161    F: PrimeField32,
162    PF: PrimeField,
163    P: CryptographicPermutation<[PF; WIDTH]>,
164{
165    fn observe(&mut self, values: [F; N]) {
166        for value in values {
167            self.observe(value);
168        }
169    }
170}
171
172impl<F, PF, const N: usize, P, const WIDTH: usize, const RATE: usize> CanObserve<Hash<F, PF, N>>
173    for MultiField32Challenger<F, PF, P, WIDTH, RATE>
174where
175    F: PrimeField32,
176    PF: PrimeField,
177    P: CryptographicPermutation<[PF; WIDTH]>,
178{
179    fn observe(&mut self, values: Hash<F, PF, N>) {
180        self.inner.output_buffer.clear();
181        self.f_squeeze_buffer.clear();
182        self.flush_f_if_non_empty();
183
184        let words: &[PF; N] = values.as_ref();
185
186        for chunk in words.chunks(RATE) {
187            self.inner
188                .absorb_rate_padded_with_tag(chunk, chunk.len() as u8);
189            self.f_squeeze_buffer.clear();
190        }
191    }
192}
193
194// for TrivialPcs
195impl<F, PF, P, const WIDTH: usize, const RATE: usize> CanObserve<Vec<Vec<F>>>
196    for MultiField32Challenger<F, PF, P, WIDTH, RATE>
197where
198    F: PrimeField32,
199    PF: PrimeField,
200    P: CryptographicPermutation<[PF; WIDTH]>,
201{
202    fn observe(&mut self, valuess: Vec<Vec<F>>) {
203        for values in valuess {
204            for value in values {
205                self.observe(value);
206            }
207        }
208    }
209}
210
211impl<F, EF, PF, P, const WIDTH: usize, const RATE: usize> CanSample<EF>
212    for MultiField32Challenger<F, PF, P, WIDTH, RATE>
213where
214    F: PrimeField32,
215    EF: BasedVectorSpace<F>,
216    PF: PrimeField,
217    P: CryptographicPermutation<[PF; WIDTH]>,
218{
219    fn sample(&mut self) -> EF {
220        EF::from_basis_coefficients_fn(|_| {
221            self.flush_f_if_non_empty();
222            if self.f_squeeze_buffer.is_empty() {
223                if !self.inner.input_buffer.is_empty() || self.inner.output_buffer.is_empty() {
224                    self.inner.duplexing();
225                }
226                self.refill_f_squeeze_from_inner();
227            }
228            self.f_squeeze_buffer
229                .pop()
230                .expect("Output buffer should be non-empty")
231        })
232    }
233}
234
235impl<F, PF, P, const WIDTH: usize, const RATE: usize> CanSampleBits<usize>
236    for MultiField32Challenger<F, PF, P, WIDTH, RATE>
237where
238    F: PrimeField32,
239    PF: PrimeField,
240    P: CryptographicPermutation<[PF; WIDTH]>,
241{
242    /// The sampled bits are not perfectly uniform, but we can bound the error: every sequence
243    /// appears with probability 1/p-close to uniform (1/2^b).
244    ///
245    /// Proof:
246    /// We denote p = F::ORDER_U32, and b = `bits`.
247    /// If X follows a uniform distribution over F, if we consider the first b bits of X, each
248    /// sequence appears either with probability P1 = ⌊p / 2^b⌋ / p or P2 = (1 + ⌊p / 2^b⌋) / p.
249    /// We have 1/2^b - 1/p ≤ P1, P2 ≤ 1/2^b + 1/p
250    fn sample_bits(&mut self, bits: usize) -> usize {
251        assert!(bits < (usize::BITS as usize));
252        assert!((1 << bits) < F::ORDER_U32);
253        let rand_f: F = self.sample();
254        let rand_usize = rand_f.as_canonical_u32() as usize;
255        rand_usize & ((1 << bits) - 1)
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use p3_baby_bear::BabyBear;
262    use p3_field::{
263        Field, PrimeCharacteristicRing, PrimeField, injective_pack_bits, split_pf_to_packed_limbs,
264        squeeze_field_order_num_limbs,
265    };
266    use p3_goldilocks::Goldilocks;
267    use p3_symmetric::Permutation;
268
269    use super::*;
270
271    const WIDTH: usize = 8;
272    const RATE: usize = 4;
273
274    type F = BabyBear;
275    type PF = Goldilocks;
276
277    #[derive(Clone)]
278    struct TestPermutation;
279
280    impl Permutation<[PF; WIDTH]> for TestPermutation {
281        fn permute_mut(&self, input: &mut [PF; WIDTH]) {
282            for (i, val) in input.iter_mut().enumerate() {
283                *val = PF::from_u8((i + 1) as u8);
284            }
285        }
286    }
287
288    impl CryptographicPermutation<[PF; WIDTH]> for TestPermutation {}
289
290    /// A permutation where each output depends on all inputs, suitable for
291    /// tests that need to detect state changes (e.g. finalize).
292    #[derive(Clone)]
293    struct MixingPermutation;
294
295    impl Permutation<[PF; WIDTH]> for MixingPermutation {
296        fn permute_mut(&self, input: &mut [PF; WIDTH]) {
297            let sum: PF = input.iter().copied().sum();
298            for (i, val) in input.iter_mut().enumerate() {
299                *val = sum + PF::from_u8((i + 1) as u8);
300            }
301        }
302    }
303
304    impl CryptographicPermutation<[PF; WIDTH]> for MixingPermutation {}
305
306    #[test]
307    fn test_packing() {
308        let c = MultiField32Challenger::<F, PF, _, WIDTH, RATE>::new(MixingPermutation).unwrap();
309        assert_eq!(c.absorb_radix_bits(), 31);
310        assert_eq!(c.absorb_num_f_elms(), 2);
311        assert_eq!(c.squeeze_num_f_elms(), 1);
312        assert_eq!(squeeze_field_order_num_limbs::<PF, F>(), 1);
313    }
314
315    #[test]
316    fn test_output_buffer_excludes_capacity() {
317        let permutation = TestPermutation;
318        let mut challenger =
319            MultiField32Challenger::<F, PF, _, WIDTH, RATE>::new(permutation).unwrap();
320
321        let squeeze_n = challenger.squeeze_num_f_elms();
322
323        let _: F = challenger.sample();
324
325        let expected_output_size = RATE * squeeze_n;
326
327        assert_eq!(
328            challenger.pending_f_squeeze_len(),
329            expected_output_size - 1,
330            "Pending F squeeze should be RATE * squeeze_num_f_elms minus one sample"
331        );
332        assert_eq!(
333            challenger.inner.output_buffer.len(),
334            0,
335            "After refill, inner PF output buffer is drained like popped F outputs"
336        );
337    }
338
339    #[test]
340    fn test_finalize() {
341        let new_chal =
342            || MultiField32Challenger::<F, PF, _, WIDTH, RATE>::new(MixingPermutation).unwrap();
343
344        // Deterministic: same observations produce same digest.
345        let mut c1 = new_chal();
346        let mut c2 = new_chal();
347        for i in 0..5u8 {
348            c1.observe(F::from_u8(i));
349            c2.observe(F::from_u8(i));
350        }
351
352        // Finalize: flush pending F values so the absorb actually lands in the sponge state.
353        c1.flush_f_if_non_empty();
354        c2.flush_f_if_non_empty();
355        let c1_digest = &c1.inner.sponge_state[..RATE];
356        let c2_digest = &c2.inner.sponge_state[..RATE];
357        assert_eq!(c1_digest, c2_digest);
358
359        // Different observations produce different digests.
360        let mut c1 = new_chal();
361        let mut c2 = new_chal();
362        for i in 0..5u8 {
363            c1.observe(F::from_u8(i));
364            c2.observe(F::from_u8(i + 1));
365        }
366
367        // Finalize
368        c1.flush_f_if_non_empty();
369        c2.flush_f_if_non_empty();
370        let c1_digest = &c1.inner.sponge_state[..RATE];
371        let c2_digest = &c2.inner.sponge_state[..RATE];
372        assert_ne!(c1_digest, c2_digest);
373    }
374
375    /// Document how sampling interacts with finalize.
376    ///
377    /// Same principle as DuplexChallenger: sampling only pops from the
378    /// output buffer without modifying sponge state. The digest changes
379    /// when a sample triggers a new duplexing. Each duplexing produces
380    /// `num_f_elms * RATE` output elements (here 1 * 4 = 4 BabyBear
381    /// elements for Goldilocks/BabyBear), so the digest is stable within
382    /// each batch of that many samples.
383    #[test]
384    fn test_finalize_sample_interaction() {
385        let batch_size = {
386            let c =
387                MultiField32Challenger::<F, PF, _, WIDTH, RATE>::new(MixingPermutation).unwrap();
388            c.squeeze_num_f_elms() * RATE
389        };
390
391        let digest = |n_samples: usize| {
392            let mut c =
393                MultiField32Challenger::<F, PF, _, WIDTH, RATE>::new(MixingPermutation).unwrap();
394            for i in 0..3u8 {
395                c.observe(F::from_u8(i));
396            }
397            for _ in 0..n_samples {
398                let _: F = c.sample();
399            }
400            c.inner.duplexing();
401            let d: [PF; RATE] = c.inner.sponge_state[..RATE].try_into().unwrap();
402            d
403        };
404
405        // The first sample triggers duplexing (absorbs pending input),
406        // so finalize's duplexing is an extra permutation — different digest.
407        assert_ne!(digest(0), digest(1));
408
409        // Samples within the same batch don't trigger another duplexing.
410        assert_eq!(digest(1), digest(2));
411        assert_eq!(digest(1), digest(batch_size));
412
413        // Exhausting the output buffer triggers a fresh duplexing.
414        assert_ne!(digest(batch_size), digest(batch_size + 1));
415
416        // Stable within the second batch.
417        assert_eq!(digest(batch_size + 1), digest(batch_size + 2));
418    }
419
420    #[test]
421    fn test_partial_absorb_length_distinct_from_padded_equivalent() {
422        let ne = {
423            let c =
424                MultiField32Challenger::<F, PF, _, WIDTH, RATE>::new(MixingPermutation).unwrap();
425            c.absorb_num_f_elms()
426        };
427        assert_eq!(ne, 2);
428
429        let mut a =
430            MultiField32Challenger::<F, PF, _, WIDTH, RATE>::new(MixingPermutation).unwrap();
431        a.observe(F::ONE);
432
433        let mut b =
434            MultiField32Challenger::<F, PF, _, WIDTH, RATE>::new(MixingPermutation).unwrap();
435        b.observe(F::ONE);
436        for _ in 1..ne {
437            b.observe(F::ZERO);
438        }
439
440        // Finalize: flush pending F values so the absorb actually lands in the sponge state.
441        a.flush_f_if_non_empty();
442        b.flush_f_if_non_empty();
443        let a_digest = &a.inner.sponge_state[..RATE];
444        let b_digest = &b.inner.sponge_state[..RATE];
445        assert_ne!(a_digest, b_digest);
446    }
447
448    #[test]
449    fn test_absorb_no_radix_overflow_collision() {
450        let mut a =
451            MultiField32Challenger::<F, PF, _, WIDTH, RATE>::new(MixingPermutation).unwrap();
452        a.observe(F::from_u32(1 << 30));
453        a.observe(F::ZERO);
454
455        let mut b =
456            MultiField32Challenger::<F, PF, _, WIDTH, RATE>::new(MixingPermutation).unwrap();
457        b.observe(F::ZERO);
458        b.observe(F::ONE);
459
460        // Finalize: flush pending F values so the absorb actually lands in the sponge state.
461        a.flush_f_if_non_empty();
462        b.flush_f_if_non_empty();
463        let a_digest = &a.inner.sponge_state[..RATE];
464        let b_digest = &b.inner.sponge_state[..RATE];
465        assert_ne!(a_digest, b_digest);
466    }
467
468    #[test]
469    fn test_duplexing_respects_rate() {
470        let permutation = TestPermutation;
471        let mut challenger =
472            MultiField32Challenger::<F, PF, _, WIDTH, RATE>::new(permutation).unwrap();
473
474        let absorb_n = challenger.absorb_num_f_elms();
475
476        for i in 0..(absorb_n * RATE) {
477            challenger.observe(F::from_u8(i as u8));
478        }
479
480        assert_eq!(
481            challenger.inner.output_buffer.len(),
482            RATE,
483            "After a full F batch flush, inner holds one rate row of PF elements"
484        );
485        assert_eq!(
486            challenger.pending_f_squeeze_len(),
487            0,
488            "F limbs are produced on sample() via split_pf_to_packed_limbs, not on observe"
489        );
490    }
491
492    #[test]
493    fn test_squeeze_covers_full_f_range() {
494        // With base-2^30, challenges are confined to [0, 2^30) ≈ 50% of BabyBear.
495        // With base-|F|, the c0 limb = v mod p_BB is near-uniform over all of BabyBear.
496        // Verify that values above 2^30 can appear as challenges by constructing a Goldilocks
497        // rate output whose canonical form mod p_BB exceeds 2^30.
498        //
499        // injective_pack_bits::<BabyBear>() = 30, so [2^30, p_BB) was previously unreachable.
500        use p3_field::split_pf_to_field_order_limbs;
501        let pack_bits = injective_pack_bits::<F>();
502        let threshold = 1u32 << pack_bits; // 2^30
503
504        // Build a Goldilocks value v such that v mod p_BB > 2^30.
505        // p_BB + 2^30 < Goldilocks::ORDER (since p_BB ≈ 2^30.9 and p_GL ≈ 2^64),
506        // so v = p_BB + threshold + 1 is a valid small Goldilocks element.
507        let v_raw = F::ORDER_U32 as u64 + threshold as u64 + 1;
508        let pf_val = PF::from_u64(v_raw);
509        let limbs = split_pf_to_field_order_limbs::<PF, F>(pf_val, 1);
510        // c0 = v_raw mod p_BB = threshold + 1 (since v_raw = p_BB + threshold + 1 ≡ threshold + 1).
511        assert_eq!(limbs[0].as_canonical_u32(), threshold + 1);
512        assert!(
513            limbs[0].as_canonical_u32() > threshold,
514            "c0 must exceed the old base-2^30 ceiling"
515        );
516    }
517
518    #[test]
519    fn test_observe_hash_native_pf_high_bits_distinct() {
520        use num_bigint::BigUint;
521        use p3_bn254::Bn254;
522        use p3_field::split_pf_to_packed_limbs;
523        use p3_symmetric::Hash;
524
525        type PF254 = Bn254;
526
527        #[derive(Clone)]
528        struct Bn254MixingPermutation;
529
530        impl Permutation<[PF254; WIDTH]> for Bn254MixingPermutation {
531            fn permute_mut(&self, input: &mut [PF254; WIDTH]) {
532                let sum: PF254 = input.iter().copied().sum();
533                for (i, val) in input.iter_mut().enumerate() {
534                    *val = sum + PF254::from_u8((i + 1) as u8);
535                }
536            }
537        }
538
539        impl CryptographicPermutation<[PF254; WIDTH]> for Bn254MixingPermutation {}
540
541        let pack_bits = injective_pack_bits::<F>();
542        let observe_n = PF254::bits().div_ceil(pack_bits as usize);
543
544        let a = PF254::from_biguint(BigUint::from(1u32)).unwrap();
545        let b = PF254::from_biguint(BigUint::from(1u32) + (BigUint::from(1u32) << 200)).unwrap();
546        assert_ne!(a, b);
547
548        let digest = |h: PF254| {
549            let mut c =
550                MultiField32Challenger::<F, PF254, _, WIDTH, RATE>::new(Bn254MixingPermutation)
551                    .unwrap();
552            c.observe(Hash::<F, PF254, 1>::from([h]));
553            c.inner.duplexing();
554            let d: [PF254; RATE] = c.inner.sponge_state[..RATE].try_into().unwrap();
555            d
556        };
557
558        assert_ne!(digest(a), digest(b));
559
560        let limbs_a = split_pf_to_packed_limbs::<PF254, F>(a, observe_n, pack_bits);
561        let limbs_b = split_pf_to_packed_limbs::<PF254, F>(b, observe_n, pack_bits);
562        assert_ne!(limbs_a, limbs_b);
563
564        let d_a = a.as_canonical_biguint().to_u64_digits();
565        let d_b = b.as_canonical_biguint().to_u64_digits();
566        let take3 = |d: &[u64]| {
567            let mut v = [0u64; 3];
568            for (i, x) in d.iter().take(3).enumerate() {
569                v[i] = *x;
570            }
571            v
572        };
573        assert_eq!(take3(&d_a), take3(&d_b));
574    }
575
576    #[test]
577    fn test_observe_hash_native_vs_expanded_f_not_equal() {
578        use p3_symmetric::Hash;
579
580        let g = PF::from_u64(123456789);
581        let mut native =
582            MultiField32Challenger::<F, PF, _, WIDTH, RATE>::new(MixingPermutation).unwrap();
583        native.observe(Hash::<F, PF, 1>::from([g]));
584
585        let mut via_f =
586            MultiField32Challenger::<F, PF, _, WIDTH, RATE>::new(MixingPermutation).unwrap();
587        let pb = injective_pack_bits::<F>();
588        let n = PF::bits().div_ceil(pb as usize);
589        for f in split_pf_to_packed_limbs::<PF, F>(g, n, pb) {
590            via_f.observe(f);
591        }
592
593        // Finalize
594        native.inner.duplexing();
595        via_f.inner.duplexing();
596        let native_digest = &native.inner.sponge_state[..RATE];
597        let via_f_digest = &via_f.inner.sponge_state[..RATE];
598        assert_ne!(native_digest, via_f_digest);
599    }
600
601    #[test]
602    fn test_inner_sponge_matches_manual_absorb_chain() {
603        let mut m =
604            MultiField32Challenger::<F, PF, _, WIDTH, RATE>::new(MixingPermutation).unwrap();
605        for i in 0..8u8 {
606            m.observe(F::from_u8(i));
607        }
608        let d_m = m.inner.sponge_state;
609
610        let mut inner = DuplexChallenger::<PF, _, WIDTH, RATE>::new(MixingPermutation);
611        let packed: Vec<PF> = (0..8)
612            .step_by(2)
613            .map(|j| {
614                reduce_packed::<F, PF>(
615                    &[F::from_u8(j), F::from_u8(j + 1)],
616                    absorb_radix_bits::<F>(),
617                )
618            })
619            .collect();
620        inner.absorb_rate_padded_with_tag(&packed, 8);
621        assert_eq!(d_m, inner.sponge_state);
622    }
623}