Skip to main content

midnight_circuits/parsing/scanner/
automaton_chip.rs

1// This file is part of MIDNIGHT-ZK.
2// Copyright (C) 2025 Midnight Foundation
3// SPDX-License-Identifier: Apache-2.0
4// Licensed under the Apache License, Version 2.0 (the "License");
5// You may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7// http://www.apache.org/licenses/LICENSE-2.0
8// Unless required by applicable law or agreed to in writing, software
9// distributed under the License is distributed on an "AS IS" BASIS,
10// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11// See the License for the specific language governing permissions and
12// limitations under the License.
13
14//! Static automaton parsing via a fixed lookup table.
15//!
16//! # Overview
17//!
18//! An automaton is a regular expression compiled into a transition system (see
19//! [`super::regex`]). Each transition is a tuple
20//! `(source_state, input_byte, output, target_state)`.
21//!
22//! The full transition table for all configured automata is loaded once as a
23//! fixed lookup table (see [`ScannerChip::load_automata_table`]). It has the
24//! following structure:
25//!
26//! ```text
27//! source   letter   output   target
28//! s1     | i1     | o1     | t1      <-- regular transitions
29//! s2     | i2     | o2     | t2
30//! ..     | ..     | ..     | ..
31//! sn     | in     | on     | tn
32//! f1     | 256    | 0      | 0       <-- final-state markers
33//! f2     | 256    | 0      | 0
34//! ```
35//!
36//! Final states are encoded as dummy transitions labelled with the invalid
37//! byte 256 (= `ALPHABET_MAX_SIZE`), pointing to state 0 with output 0.
38//! Since input bytes are range-checked to `[0, 255]`, these transitions can
39//! only be triggered explicitly by [`ScannerChip::assert_final_state`].
40//!
41//! State 0 is reserved: all automaton states are offset by 1 during
42//! construction so that 0 is never a reachable state. This ensures that the
43//! dummy row `(0, 0, 0, 0)` (needed for unused lookup rows) never collides
44//! with a real transition.
45//!
46//! # Parsing in circuit
47//!
48//! [`ScannerChip::parse`] verifies that a byte sequence matches a given
49//! automaton. In circuit, this looks like:
50//!
51//! ```text
52//! state | letter | output
53//! ------+--------+-------
54//! s1    | 'h'    | o1       <-- each row is looked up in the transition table.
55//! s2    | 'e'    | o2           Here: (s1, 'h', o1, s1) ∈ Table
56//! s3    | 'l'    | o3
57//! s4    | 'l'    | o4
58//! s5    | 'o'    | o5
59//! s6    | 256    | 0        <-- final-state check (`assert_final_state`).
60//! 0     |        |
61//! ```
62//!
63//! Each row enables the automaton selector, which triggers a lookup of
64//! `(state, letter, output, next_state)` into the fixed transition table.
65//! The last two rows assert that the final state is accepting, by looking up
66//! the dummy final-state transition `(s_final, 256, 0, 0)`.
67//!
68//! The function returns the outputs, which can be used to extract information
69//! about which characters matched which parts of the regex, or more generally,
70//! perform computations on the input.
71//!
72//! # Parallelisation
73//!
74//! The automaton lookup is batched: `AUTOMATON_PARALLELISM` transitions are
75//! checked per row, each using `NB_AUTOMATON_COLS` (= 3) advice columns.
76//! Transitions chain within a row: batch *k*'s target state is stored in
77//! batch *k+1*'s source column (same row), and the last batch's target is
78//! the first batch's source on the next row. This reduces the number of
79//! rows by a factor of `AUTOMATON_PARALLELISM`.
80//!
81//! ```text
82//! AUTOMATON_PARALLELISM = 2, NB_AUTOMATON_COLS = 3
83//!
84//!         batch 0               batch 1
85//!   src | letter | output   src | letter | output
86//!   ----+--------+-------   ----+--------+-------
87//!   s0  |  'h'   |  o0      s1  |  'e'   |  o1      <- row 0, 2 transitions
88//!   s2  |  'l'   |  o2      s3  |  'l'   |  o3      <- row 1, 2 transitions
89//!   s4  |  'o'   |  o4      s5  |  256   |  0       <- row 2, transition + final check
90//!   0   |        |                                   <- padding
91//! ```
92//!
93//! Batch 0's target is read from batch 1's source column (same row).
94//! Batch 1's target (last batch) is read from batch 0's source column
95//! (next row). Unused batch slots default to `(0, 0, 0, 0)`, which
96//! matches the dummy transition in the table.
97
98use midnight_proofs::{
99    circuit::{Layouter, Region, Value},
100    plonk::Error,
101};
102
103use super::{
104    varlen::ScannerVec, NativeAutomaton, ScannerChip, ALPHABET_MAX_SIZE, AUTOMATON_PARALLELISM,
105    NB_AUTOMATON_COLS,
106};
107use crate::{
108    field::AssignedNative, instructions::AssignmentInstructions, parsing::scanner::AutomatonParser,
109    types::AssignedByte, vec::AssignedVector, CircuitField,
110};
111
112impl<F> NativeAutomaton<F>
113where
114    F: CircuitField + Ord,
115{
116    /// Computes a transition off-circuit: given the current state and a letter,
117    /// returns `(target, output)`.
118    fn next_transition(
119        &self,
120        state: &AssignedNative<F>,
121        letter: &AssignedNative<F>,
122    ) -> Result<(Value<F>, Value<F>), Error> {
123        let target_opt = state.value().zip(letter.value()).map(|(s, l)| self.get_transition(s, l));
124        target_opt.error_if_known_and(|o| o.is_none())?;
125        let target = target_opt.map(|o| o.unwrap());
126        Ok((target.map(|t| t.0), target.map(|t| t.1)))
127    }
128}
129
130impl<F> ScannerChip<F>
131where
132    F: CircuitField + Ord,
133{
134    /// Verifies that an input matches the regular expression represented by the
135    /// given automaton, using parallel lookups
136    /// (`AUTOMATON_PARALLELISM` transitions per row).
137    ///
138    /// Layout per row (q_automaton ON):
139    ///  - Group g: `adv[N*g]`=source, `adv[N*g+1]`=letter, `adv[N*g+2]`=output
140    ///    (N=`NB_AUTOMATON_COLS`).
141    ///  - Target of group g: `adv[N*(g+1)]` (cur) for non-last groups, `adv[0]`
142    ///    (next) for last.
143    ///
144    /// The final row handles remaining bytes (< AUTOMATON_PARALLELISM), the
145    /// final-state check, and zero-padding for unused groups.
146    pub(super) fn parse_automaton(
147        &self,
148        layouter: &mut impl Layouter<F>,
149        automaton: &NativeAutomaton<F>,
150        input: &[AssignedNative<F>],
151    ) -> Result<Vec<AssignedNative<F>>, Error> {
152        let init_state: AssignedNative<F> =
153            self.native_gadget.assign_fixed(layouter, automaton.initial_state)?;
154        let invalid_letter: AssignedNative<F> =
155            self.native_gadget.assign_fixed(layouter, F::from(ALPHABET_MAX_SIZE as u64))?;
156        let zero: AssignedNative<F> = self.native_gadget.assign_fixed(layouter, F::ZERO)?;
157
158        layouter.assign_region(
159            || "parsing layout",
160            |mut region| {
161                let mut offset = 0;
162                let mut outputs = Vec::with_capacity(input.len());
163
164                // Assign initial state.
165                let mut state = init_state.copy_advice(
166                    || "initial state",
167                    &mut region,
168                    self.config.advice_cols[0],
169                    offset,
170                )?;
171
172                // Process AUTOMATON_PARALLELISM bytes per row.
173                for chunk in input.chunks(AUTOMATON_PARALLELISM) {
174                    for (batch, letter) in chunk.iter().enumerate() {
175                        self.apply_one_transition(
176                            &mut region,
177                            automaton,
178                            &mut state,
179                            letter,
180                            batch,
181                            &mut outputs,
182                            &mut offset,
183                        )?;
184                    }
185                }
186
187                // Final-state check + padding on the last row.
188                #[allow(clippy::modulo_one)]
189                self.assert_final_state(
190                    &mut region,
191                    &invalid_letter,
192                    &zero,
193                    input.len() % AUTOMATON_PARALLELISM,
194                    &mut offset,
195                )?;
196
197                Ok(outputs)
198            },
199        )
200    }
201
202    #[allow(clippy::too_many_arguments)]
203    /// Applies one automaton transition at position `batch` within the current
204    /// row. Assumes that `state` (the source) is already assigned at the
205    /// correct cell.
206    ///
207    /// Copies the `letter`, assigns the `output` and the next state, then
208    /// updates `state`. When `batch` is the last group in the row, the offset
209    /// is incremented and the next state is placed at adv\[0\] of the following
210    /// row.
211    fn apply_one_transition(
212        &self,
213        region: &mut Region<'_, F>,
214        automaton: &NativeAutomaton<F>,
215        state: &mut AssignedNative<F>,
216        letter: &AssignedNative<F>,
217        batch: usize,
218        outputs: &mut Vec<AssignedNative<F>>,
219        offset: &mut usize,
220    ) -> Result<(), Error> {
221        self.config.q_automaton.enable(region, *offset)?;
222
223        let base = NB_AUTOMATON_COLS * batch;
224        letter.copy_advice(
225            || format!("letter batch {batch}"),
226            region,
227            self.config.advice_cols[base + 1],
228            *offset,
229        )?;
230
231        let (next_state_val, output_val) = automaton.next_transition(state, letter)?;
232
233        let output = region.assign_advice(
234            || format!("output batch {batch}"),
235            self.config.advice_cols[base + 2],
236            *offset,
237            || output_val,
238        )?;
239        outputs.push(output);
240
241        let target_col = if batch == AUTOMATON_PARALLELISM - 1 {
242            *offset += 1;
243            0
244        } else {
245            base + NB_AUTOMATON_COLS
246        };
247        *state = region.assign_advice(
248            || format!("next state batch {batch}"),
249            self.config.advice_cols[target_col],
250            *offset,
251            || next_state_val,
252        )?;
253
254        Ok(())
255    }
256
257    /// Checks that the current state is a final state, by looking up the dummy
258    /// transition `(state, 256, 0, 0)`. Fills remaining groups with zeros
259    /// (matching the dummy `(0,0,0,0)` table entry) and assigns the terminal
260    /// row (`adv[0] = 0`, target of the last group).
261    fn assert_final_state(
262        &self,
263        region: &mut Region<'_, F>,
264        invalid_letter: &AssignedNative<F>,
265        zero: &AssignedNative<F>,
266        batch: usize,
267        offset: &mut usize,
268    ) -> Result<(), Error> {
269        self.config.q_automaton.enable(region, *offset)?;
270
271        // Final-state check (letter=256; output=0 and target=0 will be assigned as part
272        // of the trailing zeroes).
273        let base = NB_AUTOMATON_COLS * batch;
274        invalid_letter.copy_advice(
275            || format!("final check letter ({ALPHABET_MAX_SIZE})"),
276            region,
277            self.config.advice_cols[base + 1],
278            *offset,
279        )?;
280
281        // Zero-fill remaining columns on the current row + terminal 0.
282        for col in (base + 2)..(NB_AUTOMATON_COLS * AUTOMATON_PARALLELISM) {
283            zero.copy_advice(
284                || "parsing trailing 0",
285                region,
286                self.config.advice_cols[col],
287                *offset,
288            )?;
289        }
290        *offset += 1;
291        zero.copy_advice(
292            || "parsing trailing 0",
293            region,
294            self.config.advice_cols[0],
295            *offset,
296        )?;
297
298        Ok(())
299    }
300}
301
302impl<F> ScannerChip<F>
303where
304    F: CircuitField + Ord,
305{
306    /// Loads the automaton data (both static library and dynamic regexes) into
307    /// a single fixed lookup table. Notably:
308    ///
309    ///  - The dummy transition `(0,0,0,0)` is added since the empty lookup rows
310    ///    will be filled by it.
311    ///  - Self-loop transitions `(s, 256, s, 0)` for initial and final states
312    ///    are part of every [`NativeAutomaton`] (added during conversion from
313    ///    [`Automaton`](super::automaton::Automaton)). These allow
314    ///    [`parse_varlen`](`ScannerChip::parse_varlen`) to skip [`ScannerVec`]
315    ///    filler elements.
316    ///  - Dummy transitions `(s, 256, 0, 0)` are added for all final states `s`
317    ///    to emulate final-state checking.
318    pub(crate) fn load_automata_table(&self, layouter: &mut impl Layouter<F>) -> Result<(), Error> {
319        let cache = self.automaton_cache.borrow();
320        layouter.assign_table(
321            || "automaton table",
322            |mut table| {
323                let mut offset = 0;
324                let mut add_entry =
325                    |source: F, letter: F, target: F, output: F| -> Result<(), Error> {
326                        table.assign_cell(
327                            || "t_source",
328                            self.config.t_source,
329                            offset,
330                            || Value::known(source),
331                        )?;
332                        table.assign_cell(
333                            || "t_letter",
334                            self.config.t_letter,
335                            offset,
336                            || Value::known(letter),
337                        )?;
338                        table.assign_cell(
339                            || "t_target",
340                            self.config.t_target,
341                            offset,
342                            || Value::known(target),
343                        )?;
344                        table.assign_cell(
345                            || "t_output",
346                            self.config.t_output,
347                            offset,
348                            || Value::known(output),
349                        )?;
350                        offset += 1;
351                        Ok(())
352                    };
353
354                // Dummy transition for empty rows.
355                add_entry(F::ZERO, F::ZERO, F::ZERO, F::ZERO)?;
356
357                let filler = F::from(ALPHABET_MAX_SIZE as u64);
358                // Transitions and final-state checks for every used automaton.
359                // Self-loop transitions on the filler letter for initial/final
360                // states are already part of the NativeAutomaton (added during
361                // conversion from Automaton).
362                for automaton in cache.values() {
363                    for (source, inner) in automaton.transitions.iter() {
364                        for (letter, (target, output_extr)) in inner.iter() {
365                            assert!(
366                                *source != F::ZERO && *target != F::ZERO,
367                                "sanity check failed: the circuit requires that state 0 \
368                                 is not used, but the automaton generation failed to \
369                                 ensure it."
370                            );
371                            add_entry(*source, *letter, *target, *output_extr)?
372                        }
373                    }
374                    for state in automaton.final_states.iter() {
375                        // Dummy transition to the stuck state 0 to represent
376                        // final-state checks.
377                        add_entry(*state, filler, F::ZERO, F::ZERO)?
378                    }
379                }
380                Ok(())
381            },
382        )
383    }
384}
385
386impl<F> ScannerChip<F>
387where
388    F: CircuitField + Ord,
389{
390    /// Resolves an [`AutomatonParser`] to a [`NativeAutomaton`], caching the
391    /// result. On first use the raw automaton (from the static library or from
392    /// a regex) is offset so that its states don't collide with any previously
393    /// resolved automaton.
394    fn resolve_automaton(&self, parser: &AutomatonParser) -> NativeAutomaton<F> {
395        if let Some(aut) = self.automaton_cache.borrow().get(parser) {
396            return aut.clone();
397        }
398
399        let raw_automaton = match parser {
400            AutomatonParser::Static(spec) => self.config.static_library[spec].clone().1,
401            AutomatonParser::Dynamic(regex) => regex.to_automaton(),
402        };
403
404        let offset = {
405            let mut ms = self.max_state.borrow_mut();
406            let o = *ms;
407            *ms += raw_automaton.nb_states;
408            o
409        };
410        let native: NativeAutomaton<F> = raw_automaton.offset_states(offset).into();
411        self.automaton_cache.borrow_mut().insert(parser.clone(), native.clone());
412        native
413    }
414
415    /// Parses `input` in-circuit w.r.t. a regular expression / transducer and
416    /// outputs the sequence of integers it produces. The parser may either be
417    /// part of a static library (faster to parse) or an arbitrary regex (more
418    /// costly but supports any regex). Both variants use the same fixed lookup
419    /// table mechanism.
420    pub fn parse(
421        &self,
422        layouter: &mut impl Layouter<F>,
423        parser: AutomatonParser,
424        input: &[AssignedByte<F>],
425    ) -> Result<Vec<AssignedNative<F>>, Error> {
426        let automaton = self.resolve_automaton(&parser);
427        let native_input: Vec<AssignedNative<F>> = input.iter().map(AssignedNative::from).collect();
428        self.parse_automaton(layouter, &automaton, &native_input)
429    }
430
431    /// Parses the variable-length `input` in-circuit w.r.t. a regular
432    /// expression / transducer and returns the sequence of markers it produces.
433    ///
434    /// The returned vector has the same length as `input`'s buffer (`M`
435    /// elements). It inherits the same
436    /// `get_limits`, and
437    /// `padding_flags` as `input`. Filler
438    /// positions in the output are constrained to 0 (the self-loop transitions
439    /// on initial/final states output marker 0).
440    pub fn parse_varlen<const M: usize, const A: usize>(
441        &self,
442        layouter: &mut impl Layouter<F>,
443        parser: AutomatonParser,
444        input: &ScannerVec<F, M, A>,
445    ) -> Result<AssignedVector<F, AssignedNative<F>, M, A>, Error> {
446        let automaton = self.resolve_automaton(&parser);
447
448        // Parse the buffer directly. Filler positions read `ALPHABET_MAX_SIZE`
449        // and hit the self-loop transitions (added during NativeAutomaton
450        // construction), outputting marker 0.
451        let buffer = self.parse_automaton(layouter, &automaton, &*input.buffer)?;
452        Ok(AssignedVector {
453            buffer: Box::new(buffer.try_into().unwrap()),
454            len: input.len().clone(),
455        })
456    }
457}
458
459#[cfg(test)]
460mod test {
461    use itertools::Itertools;
462    use midnight_proofs::{
463        circuit::{Layouter, SimpleFloorPlanner, Value},
464        dev::MockProver,
465        plonk::{Circuit, ConstraintSystem, Error},
466    };
467
468    use super::{
469        super::{regex::Regex, AutomatonParser},
470        ScannerChip,
471    };
472    use crate::{
473        field::AssignedNative,
474        instructions::{AssertionInstructions, AssignmentInstructions},
475        testing_utils::FromScratch,
476        types::AssignedByte,
477        utils::circuit_modeling::{circuit_to_json, cost_measure_end, cost_measure_start},
478        CircuitField,
479    };
480
481    #[derive(Clone, Debug)]
482    struct RegexCircuit<F> {
483        input: Vec<Value<u8>>,
484        output: Vec<Value<F>>,
485        regex: Regex,
486    }
487
488    impl<F: CircuitField> RegexCircuit<F> {
489        fn new(s: &str, output: &[usize], regex: Regex) -> Self {
490            let input = s.bytes().map(Value::known).collect::<Vec<_>>();
491            let output =
492                output.iter().map(|&x| Value::known(F::from(x as u64))).collect::<Vec<_>>();
493            RegexCircuit {
494                input,
495                output,
496                regex,
497            }
498        }
499    }
500
501    impl<F> Circuit<F> for RegexCircuit<F>
502    where
503        F: CircuitField + Ord,
504    {
505        type Config = <ScannerChip<F> as FromScratch<F>>::Config;
506
507        type FloorPlanner = SimpleFloorPlanner;
508
509        type Params = ();
510
511        fn without_witnesses(&self) -> Self {
512            unreachable!()
513        }
514
515        fn configure(meta: &mut ConstraintSystem<F>) -> Self::Config {
516            let committed_instance_column = meta.instance_column();
517            let instance_column = meta.instance_column();
518            ScannerChip::configure_from_scratch(
519                meta,
520                &mut vec![],
521                &mut vec![],
522                &[committed_instance_column, instance_column],
523            )
524        }
525
526        fn synthesize(
527            &self,
528            config: Self::Config,
529            mut layouter: impl Layouter<F>,
530        ) -> Result<(), Error> {
531            let scanner_chip = ScannerChip::<F>::new_from_scratch(&config);
532
533            let input: Vec<AssignedByte<F>> =
534                scanner_chip.native_gadget.assign_many(&mut layouter, &self.input.clone())?;
535            let output: Vec<AssignedNative<F>> =
536                scanner_chip.native_gadget.assign_many(&mut layouter, &self.output)?;
537
538            cost_measure_start(&mut layouter);
539            let parsed_output = scanner_chip.parse(
540                &mut layouter,
541                AutomatonParser::Dynamic(self.regex.clone()),
542                &input,
543            )?;
544            cost_measure_end(&mut layouter);
545            assert!(
546                parsed_output.len() == output.len(),
547                "test failed: the lengths of the
548            parsed output (len = {}) and of the expected output (len = {}) are
549            different",
550                parsed_output.len(),
551                output.len()
552            );
553            parsed_output.iter().zip_eq(output.iter()).try_for_each(|(o1, o2)| {
554                scanner_chip.native_gadget.assert_equal(&mut layouter, o1, o2)
555            })?;
556
557            scanner_chip.load_from_scratch(&mut layouter)
558        }
559    }
560
561    fn parsing_one_test(
562        test_index: usize,
563        cost_model: bool,
564        input: &str,
565        output: &[usize],
566        circuit: &RegexCircuit<midnight_curves::Fq>,
567        must_pass: bool,
568    ) {
569        assert!(
570            !cost_model || must_pass,
571            ">> [test {test_index}] (bug) if cost_model is set to true, must_pass should be set to true"
572        );
573        let prover = MockProver::<midnight_curves::Fq>::run(circuit, vec![vec![], vec![]]);
574        if must_pass {
575            println!(
576                ">> [test {test_index}] Parsing input {}, which should pass (output: {:?})",
577                input, output
578            );
579            prover.unwrap().assert_satisfied()
580        } else {
581            match prover {
582                Ok(prover) => {
583                    if let Ok(()) = prover.verify() {
584                        panic!(
585                            ">> [test {test_index}] (bug) input {} is incorrectly accepted (output {:?})",
586                            input, output
587                        )
588                    } else {
589                        println!(
590                            ">> [test {test_index}] The verifier failed on input {}, which is expected",
591                            input
592                        )
593                    }
594                }
595                Err(_) => println!(
596                    ">> [test {test_index}] The prover failed on input {}, which is (supposedly) expected",
597                    input
598                ),
599            }
600        }
601
602        if cost_model {
603            circuit_to_json::<midnight_curves::Fq>(
604                "Scanner",
605                &format!(
606                    "automaton parsing perf (input length = {})",
607                    circuit.input.len()
608                ),
609                circuit.clone(),
610            );
611        }
612    }
613
614    // A test to check the validity of the circuit.
615    fn basic_test(test_index: usize, input: &str, output: &[usize], regex: Regex, must_pass: bool) {
616        parsing_one_test(
617            test_index,
618            false,
619            input,
620            output,
621            &RegexCircuit::new(input, output, regex),
622            must_pass,
623        )
624    }
625
626    // A test for inputs that do not match the tested regex.
627    fn basic_fail_test(test_index: usize, input: &str, regex: Regex) {
628        basic_test(test_index, input, &vec![0; input.len()], regex, false)
629    }
630
631    // A test to record the performances of the circuit in the golden files.
632    fn perf_test(test_index: usize, input: &str, regex: Regex) {
633        println!("\n>> Performance test, input size {}:", input.len());
634        let output = vec![0; input.len()];
635        parsing_one_test(
636            test_index,
637            true,
638            input,
639            &output,
640            &RegexCircuit::new(input, &output, regex),
641            true,
642        )
643    }
644
645    #[test]
646    // Tests automaton parsing with a single regex.
647    fn parsing_test() {
648        let regex0 = Regex::hard_coded_example0();
649        let regex1 = Regex::hard_coded_example1();
650
651        // Correct inputs for automaton 0.
652        basic_test(0, "hello (world)!!!!!", &[0; 18], regex0.clone(), true);
653        basic_test(0, "hello (world)!!!!!", &[1; 18], regex0.clone(), false); // Variant with a wrong output.
654        basic_test(
655            1,
656            "hello (world)!!!!!oipdsfihs32,;'p'';@",
657            &[0; 37],
658            regex0.clone(),
659            true,
660        );
661        basic_test(2, "hello (world)  !!!!!", &[0; 20], regex0.clone(), true);
662        basic_test(2, "hello (world)  !!!!!", &[1; 20], regex0.clone(), false); // Variant with a wrong output.
663        basic_test(3, "hello (world  )!!!!!", &[0; 20], regex0.clone(), true);
664        basic_test(4, "hello (  world)!!!!!", &[0; 20], regex0.clone(), true);
665        basic_test(
666            5,
667            "hello  hello hello  (world , world ) !!!!!",
668            &[0; 42],
669            regex0.clone(),
670            true,
671        );
672        basic_test(
673            6,
674            "hello  hello hello  (world , world ) !!!!!  ;'{][0(*&6235%  /.,><",
675            &[0; 65],
676            regex0.clone(),
677            true,
678        );
679        basic_test(
680            7,
681            "hello   hello  hello ( world,world  , world )!!!!!",
682            &[0; 50],
683            regex0.clone(),
684            true,
685        );
686
687        // Incorrect inputs for automaton 0:
688        // Missing '!'.
689        basic_fail_test(8, "hello (world)!!!!", regex0.clone());
690        // Additional '!'.
691        basic_fail_test(9, "hello (world)!!!!!!", regex0.clone());
692        // Missing '('.
693        basic_fail_test(10, "hello world)!!!!!", regex0.clone());
694        // Spelling.
695        basic_fail_test(11, "hello (warudo)!!!!!", regex0.clone());
696        // Missing space before '('.
697        basic_fail_test(12, "hello hello hello(world)!!!!!", regex0.clone());
698        // "world"s should be separated by ','.
699        basic_fail_test(
700            13,
701            "hello  hello hello  (world  world ) !!!!!",
702            regex0.clone(),
703        );
704        // Missing space.
705        basic_fail_test(14, "hello hellohello ( world,world )!!!!!", regex0.clone());
706        // Spaces between '!'s.
707        basic_fail_test(15, "hello hellohello ( world,world )!!! !!", regex0.clone());
708
709        // Correct inputs for automaton 1.
710        basic_test(
711            16,
712            "holy hell !!!",
713            &[0, 1, 2, 1, 0, 0, 1, 2, 2, 0, 1, 1, 1],
714            regex1.clone(),
715            true,
716        );
717        basic_test(16, "holy hell !!!", &[0; 13], regex1.clone(), false); // Variant with a wrong output.
718        basic_test(
719            17,
720            "holy   hell    !!!!!!",
721            &[
722                0, 1, 2, 1, 0, 0, 0, 0, 1, 2, 2, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1,
723            ],
724            regex1.clone(),
725            true,
726        );
727        basic_test(17, "holy   hell    !!!!!!", &[0; 21], regex1.clone(), false); // Variant with a wrong output.
728        basic_test(
729            18,
730            "holyyyy hell !!!",
731            &[0, 1, 2, 1, 1, 1, 1, 0, 0, 1, 2, 2, 0, 1, 1, 1],
732            regex1.clone(),
733            true,
734        );
735        basic_test(
736            19,
737            "holyyyy   hell    !!!!!!",
738            &[
739                0, 1, 2, 1, 1, 1, 1, 0, 0, 0, 0, 1, 2, 2, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1,
740            ],
741            regex1.clone(),
742            true,
743        );
744
745        // Incorrect inputs for automaton 1:
746        // Missing space.
747        basic_fail_test(20, "holy hell!!!", regex1.clone());
748        basic_fail_test(21, "holyhell !!!", regex1.clone());
749        basic_fail_test(22, "holyhell!!!", regex1.clone());
750        basic_fail_test(23, "holyyyy hell!!!", regex1.clone());
751        basic_fail_test(24, "holyyyyhell    !!!!!!", regex1.clone());
752        // Missing '!'.
753        basic_fail_test(25, "holy hell ", regex1.clone());
754        basic_fail_test(26, "holyyyy      hell   ", regex1.clone());
755        // Additional 'l'.
756        basic_fail_test(27, "holy hellllll !!!", regex1.clone());
757
758        // Performance inputs for the golden files, using automaton 0, for an input of
759        // 50 bytes.
760        perf_test(
761            28,
762            "hello hello  hello (world, world  , world )  !!!!!",
763            regex0,
764        );
765    }
766
767    // ---- Multi-regex / caching tests ----
768
769    /// A circuit that parses two inputs against dynamically-provided regexes.
770    /// When both regexes are equal, the second call should hit the cache.
771    /// `must_cache` controls whether this is asserted.
772    #[derive(Clone, Debug)]
773    struct DynamicRegexCircuit<F: CircuitField> {
774        regex1: Regex,
775        input1: Vec<Value<u8>>,
776        output1: Vec<Value<F>>,
777        regex2: Regex,
778        input2: Vec<Value<u8>>,
779        output2: Vec<Value<F>>,
780        must_cache: bool,
781    }
782
783    impl<F: CircuitField> DynamicRegexCircuit<F> {
784        fn new(
785            regex1: Regex,
786            input1: &str,
787            output1: &[usize],
788            regex2: Regex,
789            input2: &str,
790            output2: &[usize],
791            must_cache: bool,
792        ) -> Self {
793            Self {
794                regex1,
795                input1: input1.bytes().map(Value::known).collect(),
796                output1: output1.iter().map(|&x| Value::known(F::from(x as u64))).collect(),
797                regex2,
798                input2: input2.bytes().map(Value::known).collect(),
799                output2: output2.iter().map(|&x| Value::known(F::from(x as u64))).collect(),
800                must_cache,
801            }
802        }
803    }
804
805    impl<F> Circuit<F> for DynamicRegexCircuit<F>
806    where
807        F: CircuitField + Ord,
808    {
809        type Config = <ScannerChip<F> as FromScratch<F>>::Config;
810        type FloorPlanner = SimpleFloorPlanner;
811        type Params = ();
812
813        fn without_witnesses(&self) -> Self {
814            unreachable!()
815        }
816
817        fn configure(meta: &mut ConstraintSystem<F>) -> Self::Config {
818            let committed_instance_column = meta.instance_column();
819            let instance_column = meta.instance_column();
820            ScannerChip::configure_from_scratch(
821                meta,
822                &mut vec![],
823                &mut vec![],
824                &[committed_instance_column, instance_column],
825            )
826        }
827
828        fn synthesize(
829            &self,
830            config: Self::Config,
831            mut layouter: impl Layouter<F>,
832        ) -> Result<(), Error> {
833            let scanner_chip = ScannerChip::<F>::new_from_scratch(&config);
834
835            // First parse.
836            let input1: Vec<AssignedByte<F>> =
837                scanner_chip.native_gadget.assign_many(&mut layouter, &self.input1)?;
838            let output1: Vec<AssignedNative<F>> =
839                scanner_chip.native_gadget.assign_many(&mut layouter, &self.output1)?;
840            let parsed1 = scanner_chip.parse(
841                &mut layouter,
842                AutomatonParser::Dynamic(self.regex1.clone()),
843                &input1,
844            )?;
845            assert_eq!(parsed1.len(), output1.len(), "first output length mismatch");
846            parsed1.iter().zip_eq(output1.iter()).try_for_each(|(o1, o2)| {
847                scanner_chip.native_gadget.assert_equal(&mut layouter, o1, o2)
848            })?;
849
850            // Second parse.
851            let input2: Vec<AssignedByte<F>> =
852                scanner_chip.native_gadget.assign_many(&mut layouter, &self.input2)?;
853            let output2: Vec<AssignedNative<F>> =
854                scanner_chip.native_gadget.assign_many(&mut layouter, &self.output2)?;
855            let parsed2 = scanner_chip.parse(
856                &mut layouter,
857                AutomatonParser::Dynamic(self.regex2.clone()),
858                &input2,
859            )?;
860            assert_eq!(
861                parsed2.len(),
862                output2.len(),
863                "second output length mismatch"
864            );
865            parsed2.iter().zip_eq(output2.iter()).try_for_each(|(o1, o2)| {
866                scanner_chip.native_gadget.assert_equal(&mut layouter, o1, o2)
867            })?;
868
869            // Check caching: with the same regex used twice, only 1 entry
870            // should be in the cache. With 2 distinct regexes, 2 entries.
871            let cache_size = scanner_chip.automaton_cache.borrow().len();
872            if self.must_cache {
873                assert_eq!(cache_size, 1, "expected 1 cached regex, got {cache_size}");
874            } else {
875                assert_eq!(cache_size, 2, "expected 2 cached regexes, got {cache_size}");
876            }
877
878            scanner_chip.load_from_scratch(&mut layouter)
879        }
880    }
881
882    fn dynamic_basic_test(
883        test_index: usize,
884        cost_model: bool,
885        entry1: (Regex, &str, &[usize]),
886        entry2: (Regex, &str, &[usize]),
887        must_pass: bool,
888        must_cache: bool,
889    ) {
890        assert!(
891            !cost_model || must_pass,
892            ">> [dynamic test {test_index}] (bug) if cost_model is set to true, must_pass should be set to true"
893        );
894        let circuit = DynamicRegexCircuit::<midnight_curves::Fq>::new(
895            entry1.0, entry1.1, entry1.2, entry2.0, entry2.1, entry2.2, must_cache,
896        );
897        let prover = MockProver::<midnight_curves::Fq>::run(&circuit, vec![vec![], vec![]]);
898        if must_pass {
899            println!(
900                ">> [dynamic test {test_index}] Parsing inputs '{}' and '{}', which should pass (cache: {must_cache})",
901                entry1.1, entry2.1
902            );
903            prover.unwrap().assert_satisfied()
904        } else {
905            match prover {
906                Ok(prover) => {
907                    if let Ok(()) = prover.verify() {
908                        panic!(
909                            ">> [dynamic test {test_index}] inputs '{}' / '{}' incorrectly accepted",
910                            entry1.1, entry2.1
911                        )
912                    } else {
913                        println!(">> [dynamic test {test_index}] verifier failed (expected)",)
914                    }
915                }
916                Err(_) => println!(">> [dynamic test {test_index}] prover failed (expected)",),
917            }
918        }
919
920        if cost_model {
921            circuit_to_json::<midnight_curves::Fq>(
922                "Scanner",
923                &format!(
924                    "multi-regex parsing perf (input length = {})",
925                    entry1.1.len()
926                ),
927                circuit,
928            );
929        }
930    }
931
932    #[test]
933    fn dynamic_parsing_test() {
934        let regex1 = Regex::hard_coded_example1();
935        let regex2 = Regex::hard_coded_example0();
936
937        // Two correct inputs with the same regex, cache expected.
938        dynamic_basic_test(
939            0,
940            false,
941            (
942                regex1.clone(),
943                "holy hell !!!",
944                &[0, 1, 2, 1, 0, 0, 1, 2, 2, 0, 1, 1, 1],
945            ),
946            (
947                regex1.clone(),
948                "holyyyy hell !!!",
949                &[0, 1, 2, 1, 1, 1, 1, 0, 0, 1, 2, 2, 0, 1, 1, 1],
950            ),
951            true,
952            true,
953        );
954
955        // Same regex, wrong outputs on second input.
956        dynamic_basic_test(
957            1,
958            false,
959            (
960                regex1.clone(),
961                "holy hell !!!",
962                &[0, 1, 2, 1, 0, 0, 1, 2, 2, 0, 1, 1, 1],
963            ),
964            (regex1.clone(), "holy hell !!!", &[0; 13]),
965            false,
966            true,
967        );
968
969        // Same regex, second input doesn't match (missing space).
970        dynamic_basic_test(
971            2,
972            false,
973            (
974                regex1.clone(),
975                "holy hell !!!",
976                &[0, 1, 2, 1, 0, 0, 1, 2, 2, 0, 1, 1, 1],
977            ),
978            (regex1.clone(), "holy hell!!!", &[0; 12]),
979            false,
980            true,
981        );
982
983        // Two different regexes, no cache expected.
984        dynamic_basic_test(
985            3,
986            false,
987            (
988                regex1.clone(),
989                "holy hell !!!",
990                &[0, 1, 2, 1, 0, 0, 1, 2, 2, 0, 1, 1, 1],
991            ),
992            (regex2, "hello (world)!!!!!", &[0; 18]),
993            true,
994            false,
995        );
996
997        // Performance test for the golden files, using an input of 50 bytes.
998        let perf_input = "holyyyyyyyyy   hell    !!!!!!!!!!!!!!!!!!!!!!!!!!!";
999        #[rustfmt::skip]
1000        let perf_output: &[usize] = &[
1001            0, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 2, 2, 0, 0, 0, 0,
1002            1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1003        ];
1004        dynamic_basic_test(
1005            4,
1006            true,
1007            (regex1.clone(), perf_input, perf_output),
1008            (regex1, perf_input, perf_output),
1009            true,
1010            true,
1011        );
1012    }
1013
1014    // ---- parse_varlen tests ----
1015
1016    #[derive(Clone, Debug)]
1017    struct VarlenParseCircuit<F: CircuitField> {
1018        input: Value<Vec<u8>>,
1019        /// Full M-element expected output buffer (0 for fillers, markers for
1020        /// payload).
1021        expected_buffer: [Value<F>; 32],
1022        regex: Regex,
1023    }
1024
1025    impl<F: CircuitField> VarlenParseCircuit<F> {
1026        fn new(input: &[u8], payload_output: &[usize], regex: Regex) -> Self {
1027            use crate::vec::get_lims;
1028
1029            // Compute where the payload lands in the M=32, A=1 buffer.
1030            let range = get_lims::<32, 1>(input.len());
1031            assert_eq!(
1032                payload_output.len(),
1033                range.len(),
1034                "payload_output length must match input length"
1035            );
1036            let mut buffer = [Value::known(F::ZERO); 32];
1037            for (pos, &marker) in range.zip(payload_output.iter()) {
1038                buffer[pos] = Value::known(F::from(marker as u64));
1039            }
1040
1041            Self {
1042                input: Value::known(input.to_vec()),
1043                expected_buffer: buffer,
1044                regex,
1045            }
1046        }
1047    }
1048
1049    impl<F> Circuit<F> for VarlenParseCircuit<F>
1050    where
1051        F: CircuitField + Ord,
1052    {
1053        type Config = <ScannerChip<F> as FromScratch<F>>::Config;
1054        type FloorPlanner = SimpleFloorPlanner;
1055        type Params = ();
1056
1057        fn without_witnesses(&self) -> Self {
1058            unreachable!()
1059        }
1060
1061        fn configure(meta: &mut ConstraintSystem<F>) -> Self::Config {
1062            let instance_columns = [meta.instance_column(), meta.instance_column()];
1063            ScannerChip::configure_from_scratch(meta, &mut vec![], &mut vec![], &instance_columns)
1064        }
1065
1066        fn synthesize(
1067            &self,
1068            config: Self::Config,
1069            mut layouter: impl Layouter<F>,
1070        ) -> Result<(), Error> {
1071            let scanner = ScannerChip::<F>::new_from_scratch(&config);
1072            let ng = &scanner.native_gadget;
1073
1074            let input = scanner.assign_scanner_vec::<32, 1>(&mut layouter, self.input.clone())?;
1075            let expected: Vec<AssignedNative<F>> =
1076                ng.assign_many(&mut layouter, &self.expected_buffer)?;
1077
1078            let parsed = scanner.parse_varlen(
1079                &mut layouter,
1080                AutomatonParser::Dynamic(self.regex.clone()),
1081                &input,
1082            )?;
1083
1084            // Pointwise equality on the full buffer.
1085            for (out_cell, exp_cell) in parsed.buffer.iter().zip(expected.iter()) {
1086                ng.assert_equal(&mut layouter, out_cell, exp_cell)?;
1087            }
1088
1089            scanner.load_from_scratch(&mut layouter)
1090        }
1091    }
1092
1093    fn varlen_parse_test(input: &[u8], output: &[usize], regex: Regex, must_pass: bool) {
1094        type F = midnight_curves::Fq;
1095        let circuit = VarlenParseCircuit::<F>::new(input, output, regex);
1096        println!(
1097            ">> [varlen_parse] [must{} pass] input len={}",
1098            if must_pass { "" } else { " not" },
1099            input.len(),
1100        );
1101        let result = MockProver::run(&circuit, vec![vec![], vec![]]);
1102        match result {
1103            Ok(p) => {
1104                let verified = p.verify();
1105                if must_pass {
1106                    verified.expect("should have passed")
1107                } else {
1108                    assert!(verified.is_err(), "should have failed");
1109                }
1110            }
1111            Err(e) => assert!(!must_pass, "Prover failed unexpectedly: {:?}", e),
1112        }
1113        println!("... ok!");
1114    }
1115
1116    #[test]
1117    fn parse_varlen_test() {
1118        let regex1 = Regex::hard_coded_example1();
1119
1120        // Same test data as the fixed-length parsing_test, but via varlen.
1121        varlen_parse_test(
1122            b"holy hell !!!",
1123            &[0, 1, 2, 1, 0, 0, 1, 2, 2, 0, 1, 1, 1],
1124            regex1.clone(),
1125            true,
1126        );
1127
1128        // Wrong output markers.
1129        varlen_parse_test(b"holy hell !!!", &[0; 13], regex1.clone(), false);
1130
1131        // Invalid input (missing space).
1132        varlen_parse_test(b"holy hell!!!", &[0; 12], regex1.clone(), false);
1133
1134        // Short input (single-chunk payload to exercise the padding_flag fix).
1135        varlen_parse_test(
1136            b"holyyyy hell !!!",
1137            &[0, 1, 2, 1, 1, 1, 1, 0, 0, 1, 2, 2, 0, 1, 1, 1],
1138            regex1,
1139            true,
1140        );
1141    }
1142}