midnight_circuits/parsing/scanner/mod.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//! A chip combining lookup-based parsing techniques. The chip supports:
15//!
16//! - **Static automaton parsing** (`ScannerChip::parse`): verifies that a byte
17//! sequence matches a regular expression, using a fixed lookup table
18//! pre-loaded with transitions from a library of automata ([`StdLibParser`]),
19//! and/or dynamically-provided regular expressions. See the `automaton_chip`
20//! module for details.
21//!
22//! - **Substring checks** (`ScannerChip::check_bytes`): verifies that a
23//! sub-sequence appears at a given position inside a larger sequence, using a
24//! dynamic lookup argument. Calls are deferred and batched at the end of
25//! circuit synthesis for efficiency. See the `substring` module for details.
26
27pub(crate) mod automaton;
28mod automaton_chip;
29/// A module to specify languages as regular expressions and convert them into
30/// finite automata.
31pub mod regex;
32mod serialization;
33pub mod static_specs;
34mod substring;
35mod substring_varlen;
36pub(crate) mod varlen;
37
38use std::{
39 cell::RefCell,
40 collections::{BTreeMap, BTreeSet},
41 rc::Rc,
42};
43
44use automaton::Automaton;
45use midnight_proofs::{
46 circuit::{Chip, Layouter},
47 plonk::{Advice, Column, ConstraintSystem, Error, Expression, Fixed, Selector, TableColumn},
48 poly::Rotation,
49};
50use regex::Regex;
51use rustc_hash::FxHashMap;
52pub use static_specs::StdLibParser;
53
54/// A test vector pairing an input with expected marker-grouped outputs.
55#[cfg(test)]
56pub(crate) type MarkerTestVector<'a> = (&'a [u8], &'a [(usize, &'a [u8])]);
57
58/// A test vector pairing an input with the expected raw output sequence.
59#[cfg(test)]
60#[allow(dead_code)]
61pub(crate) type OutputTestVector<'a> = (&'a [u8], &'a [usize]);
62#[cfg(test)]
63use {
64 crate::field::decomposition::chip::P2RDecompositionConfig,
65 crate::field::decomposition::pow2range::Pow2RangeChip, crate::field::native::NB_ARITH_COLS,
66 crate::testing_utils::FromScratch, midnight_proofs::plonk::Instance, regex::RegexInstructions,
67};
68
69use crate::{
70 field::{
71 decomposition::chip::P2RDecompositionChip, native::AssignedBit, AssignedNative, NativeChip,
72 NativeGadget,
73 },
74 utils::ComposableChip,
75 vec::vector_gadget::VectorGadget,
76 CircuitField,
77};
78
79/// Maximal size of the alphabet of an automaton/regex (input bytes are in
80/// `[0, 255]`). Also used to encode final states in the transition table as
81/// dummy transitions labelled with `ALPHABET_MAX_SIZE` (see the
82/// `automaton_chip` module), and as the packing shift for substring checks
83/// (see the `substring` module).
84const ALPHABET_MAX_SIZE: usize = 256;
85
86/// Number of advice columns used per automaton lookup (source, letter, output).
87const NB_AUTOMATON_COLS: usize = 3;
88/// Number of advice columns used per substring lookup argument (packed
89/// sequence+index, packed sub+index).
90const NB_SUBSTRING_COLS: usize = 2;
91
92/// Number of parallel lookups performed by automata based parsers.
93const AUTOMATON_PARALLELISM: usize = 2;
94/// Number of parallel query lookups for substring checks. The total advice
95/// columns is `(1 + SUBSTRING_PARALLELISM) * NB_SUBSTRING_COLS`.
96const SUBSTRING_PARALLELISM: usize = 3;
97
98/// Maximum bit-length for the longer sequence length in substring checks. This
99/// value must be chosen lower or equal than `F::CAPACITY - 9`.
100const PARSING_MAX_LEN_BITS: u32 = 64;
101
102/// Number of advice columns for the scanner chip.
103pub const NB_SCANNER_ADVICE_COLS: usize = {
104 let automaton = NB_AUTOMATON_COLS * AUTOMATON_PARALLELISM;
105 let substring = SUBSTRING_PARALLELISM * NB_SUBSTRING_COLS;
106 if automaton > substring {
107 automaton
108 } else {
109 substring
110 }
111};
112/// Number of shared fixed columns necessary for the scanner chip.
113pub const NB_SCANNER_FIXED_COLS: usize = 1;
114
115type NG<F> = NativeGadget<F, P2RDecompositionChip<F>, NativeChip<F>>;
116
117/// A simple map from the automaton structure to handle field elements, and thus
118/// precompute all transition operations on the prover code.
119#[derive(Clone, Debug)]
120pub struct NativeAutomaton<F> {
121 /// The number of states of the automaton.
122 pub nb_states: usize,
123 /// The initial state of the automaton.
124 pub initial_state: F,
125 /// The final states of the automaton.
126 pub final_states: BTreeSet<F>,
127 /// When `transitions[source_state][letter] = (target_state, output)`, it
128 /// means that in state `source_state`, upon reading the byte `letter`, the
129 /// automaton run moves to state `target_state` and tags `letter` with
130 /// `output`. If the entry is undefined, the automaton run gets stuck.
131 pub transitions: BTreeMap<F, BTreeMap<F, (F, F)>>,
132}
133
134impl<F> From<&Automaton> for NativeAutomaton<F>
135where
136 F: CircuitField + Ord,
137{
138 fn from(value: &Automaton) -> Self {
139 let mut transitions = BTreeMap::new();
140 for (&source, inner) in &value.transitions {
141 let native_inner: BTreeMap<F, (F, F)> = inner
142 .iter()
143 .map(|(&letter, &(target, output))| {
144 (
145 F::from(letter as u64),
146 (F::from(target as u64), F::from(output as u64)),
147 )
148 })
149 .collect();
150 transitions.insert(F::from(source as u64), native_inner);
151 }
152
153 let initial_state = F::from(value.initial_state as u64);
154 let final_states: BTreeSet<F> =
155 (value.final_states.iter()).map(|s| F::from(*s as u64)).collect();
156
157 // Self-loop transitions on the filler letter (ALPHABET_MAX_SIZE) for
158 // the initial and final states. These allow `parse_varlen` to skip
159 // filler elements in [`ScannerVec`] buffers, and are also loaded into
160 // the fixed lookup table unconditionally.
161 let filler = F::from(ALPHABET_MAX_SIZE as u64);
162 transitions
163 .entry(initial_state)
164 .or_default()
165 .insert(filler, (initial_state, F::ZERO));
166 for &state in &final_states {
167 transitions.entry(state).or_default().insert(filler, (state, F::ZERO));
168 }
169
170 NativeAutomaton {
171 nb_states: value.nb_states,
172 initial_state,
173 final_states,
174 transitions,
175 }
176 }
177}
178
179impl<F> From<Automaton> for NativeAutomaton<F>
180where
181 F: CircuitField + Ord,
182{
183 fn from(value: Automaton) -> Self {
184 (&value).into()
185 }
186}
187
188impl<F> NativeAutomaton<F>
189where
190 F: CircuitField + Ord,
191{
192 /// Looks up the transition from `state` reading `letter`.
193 fn get_transition(&self, state: &F, letter: &F) -> Option<(F, F)> {
194 self.transitions.get(state).and_then(|inner| inner.get(letter)).copied()
195 }
196}
197
198#[derive(Clone, Debug, PartialEq, Eq, Hash)]
199/// A reference for parsing methods for the function `parse`. Either an entry of
200/// the static automaton library (more efficient, but limited library), or a
201/// dynamic regular expression (more costly, but supports arbitrary regexes).
202pub enum AutomatonParser {
203 /// Static automaton library, as defined in `parsing::static_specs` (see the
204 /// documentation of each object of type `StdLibParser` to get the exact
205 /// regular expression they check). The off-circuit conversion
206 /// Regex->Automaton has been pre-computed and is serialised.
207 Static(StdLibParser),
208 /// Parses an arbitrary regular expression. Induces the same circuit logic
209 /// and performances as `Static`, but the conversion Regex->Automaton will
210 /// be performed by the prover (off-circuit).
211 Dynamic(Regex),
212}
213
214impl From<&StdLibParser> for AutomatonParser {
215 fn from(value: &StdLibParser) -> Self {
216 AutomatonParser::Static(*value)
217 }
218}
219
220impl From<StdLibParser> for AutomatonParser {
221 fn from(value: StdLibParser) -> Self {
222 AutomatonParser::from(&value)
223 }
224}
225
226impl From<Regex> for AutomatonParser {
227 fn from(value: Regex) -> Self {
228 AutomatonParser::Dynamic(value)
229 }
230}
231
232/// A static library of serialised automata for parsing common regexes. The
233/// automaton states start from 0 and may overlap one with each other.
234type ParsingLibrary = FxHashMap<StdLibParser, (Regex, Automaton)>;
235/// Set of automata (with offset states) called by [`ScannerChip::parse`] or
236/// [`ScannerChip::parse_varlen`].
237type AutomatonCache<F> = FxHashMap<AutomatonParser, NativeAutomaton<F>>;
238/// A sequence of assigned elements.
239type Sequence<F> = Vec<AssignedNative<F>>;
240
241/// Optional per-element padding flags for variable-length subs. When present,
242/// filler positions (flag = true) are masked to zero during packing.
243type PaddingFlags<F> = Option<Vec<AssignedBit<F>>>;
244
245/// A cached sub entry: (idx, index offsets, sub content, padding flags).
246/// Index offsets are additional `(coefficient, value)` terms folded into the
247/// packing linear combination alongside `idx`, saving a dedicated row per sub.
248type CachedSub<F> = (
249 AssignedNative<F>,
250 Vec<(F, AssignedNative<F>)>,
251 Sequence<F>,
252 PaddingFlags<F>,
253);
254
255/// Cache of assigned sequences passed as arguments to `check_subsequence` or
256/// `check_subsequence_varlen`. Each sequence (keyed by its cells) is mapped to
257/// the list of sub entries and the cumulative sub length.
258///
259/// **Cell-identity assumption**: the cache key is based on
260/// [`AssignedCell`](`midnight_proofs::circuit::AssignedCell`) identity (column
261/// and row), not on the cell's value. This means two sequences holding the same
262/// byte values but assigned at different circuit positions are distinct cache
263/// entries. Callers that introduce new ways to build sequences must ensure
264/// fresh cells are produced if a distinct cache entry is intended.
265type SequenceCache<F> = FxHashMap<Sequence<F>, (Vec<CachedSub<F>>, usize)>;
266
267/// Scanner gate configuration.
268#[derive(Clone, Debug)]
269pub struct ScannerConfig {
270 // Shared advice columns used by scanner operations.
271 advice_cols: [Column<Advice>; NB_SCANNER_ADVICE_COLS],
272
273 /// Pre-computed library of automata. If some are not used in the circuit,
274 /// their table will not be loaded wastingly.
275 static_library: ParsingLibrary,
276
277 // Automaton circuit resources.
278 q_automaton: Selector,
279 t_source: TableColumn,
280 t_letter: TableColumn,
281 t_target: TableColumn,
282 t_output: TableColumn,
283
284 // Substring resources. The tag column is used for domain separation and cannot be shared.
285 q_substring: Selector,
286 index_col: Column<Fixed>,
287 tag_col: Column<Fixed>,
288}
289
290/// Chip for scanning: automaton parsing and substring verification.
291#[derive(Clone, Debug)]
292pub struct ScannerChip<F>
293where
294 F: CircuitField,
295{
296 config: ScannerConfig,
297 native_gadget: NG<F>,
298 vector_gadget: VectorGadget<F>,
299
300 /// Unified cache of all resolved automata (both static library and dynamic
301 /// regexes), with their states already offset. Populated on demand by
302 /// `resolve_automaton` when `parse` is called for the first time with a
303 /// given `AutomatonParser`.
304 automaton_cache: Rc<RefCell<AutomatonCache<F>>>,
305 /// Tracks the next available state offset. Starts at 1 (state 0 is
306 /// reserved as the dummy state for soundness).
307 max_state: Rc<RefCell<usize>>,
308
309 /// Cache mapping a sequence of cells to the list of `(idx, sub)` pairs
310 /// it was called with, so that repeated `check_bytes` calls with the same
311 /// `sequence` argument share the table cost. Tags are assigned later
312 /// during finalisation.
313 sequence_cache: Rc<RefCell<SequenceCache<F>>>,
314}
315
316impl<F> ScannerChip<F>
317where
318 F: CircuitField,
319{
320 /// Gets the regex associated to a `StdLibParser`, as stored in the static
321 /// library of `self`.
322 pub fn specs_regex(&self, parser: &StdLibParser) -> &Regex {
323 let (regex, _) = self
324 .config
325 .static_library
326 .get(parser)
327 .unwrap_or_else(|| panic!("parser {:?} not found", parser));
328 regex
329 }
330}
331
332impl<F> Chip<F> for ScannerChip<F>
333where
334 F: CircuitField,
335{
336 type Config = ScannerConfig;
337 type Loaded = ();
338 fn config(&self) -> &Self::Config {
339 &self.config
340 }
341 fn loaded(&self) -> &Self::Loaded {
342 &()
343 }
344}
345
346impl<F> ComposableChip<F> for ScannerChip<F>
347where
348 F: CircuitField + Ord,
349{
350 type InstructionDeps = NG<F>;
351
352 type SharedResources = (
353 [Column<Advice>; NB_SCANNER_ADVICE_COLS],
354 Column<Fixed>,
355 FxHashMap<StdLibParser, (Regex, Automaton)>,
356 );
357
358 fn new(config: &ScannerConfig, deps: &Self::InstructionDeps) -> Self {
359 Self {
360 config: config.clone(),
361 vector_gadget: VectorGadget::new(deps),
362 native_gadget: deps.clone(),
363 automaton_cache: Rc::new(RefCell::new(FxHashMap::default())),
364 max_state: Rc::new(RefCell::new(1)),
365 sequence_cache: Rc::new(RefCell::new(FxHashMap::default())),
366 }
367 }
368
369 fn configure(
370 meta: &mut ConstraintSystem<F>,
371 shared_res: &Self::SharedResources,
372 ) -> ScannerConfig {
373 #[allow(clippy::assertions_on_constants)]
374 {
375 assert!(
376 AUTOMATON_PARALLELISM > 0 && SUBSTRING_PARALLELISM > 0,
377 "at least 1 lookup required for automata and substring checks"
378 );
379 assert!(
380 PARSING_MAX_LEN_BITS <= F::CAPACITY - (u8::BITS + 1),
381 "check_subsequence batching exceeds field capacity ({} / {})",
382 PARSING_MAX_LEN_BITS + u8::BITS + 1,
383 F::CAPACITY
384 )
385 }
386
387 let (advice_cols, index_col, automata) = shared_res;
388
389 // Enable equality on all advice columns.
390 for &col in advice_cols {
391 meta.enable_equality(col);
392 }
393
394 // Automaton resources (shared fixed lookup table).
395 let q_automaton = meta.complex_selector();
396 let t_source = meta.lookup_table_column();
397 let t_letter = meta.lookup_table_column();
398 let t_target = meta.lookup_table_column();
399 let t_output = meta.lookup_table_column();
400
401 // Automaton lookup by batch: AUTOMATON_PARALLELISM transitions per row.
402 for batch in 0..AUTOMATON_PARALLELISM {
403 meta.lookup(
404 format!("automaton transition check (batch {batch})"),
405 |meta| {
406 let q = meta.query_selector(q_automaton);
407 let base = NB_AUTOMATON_COLS * batch;
408 let [source, letter, output] = core::array::from_fn(|i| {
409 meta.query_advice(advice_cols[base + i], Rotation::cur())
410 });
411 let target = if batch + 1 < AUTOMATON_PARALLELISM {
412 meta.query_advice(advice_cols[base + NB_AUTOMATON_COLS], Rotation::cur())
413 } else {
414 meta.query_advice(advice_cols[0], Rotation::next())
415 };
416 vec![
417 (q.clone() * source, t_source),
418 (q.clone() * letter, t_letter),
419 (q.clone() * target, t_target),
420 (q * output, t_output),
421 ]
422 },
423 );
424 }
425
426 // Substring resources.
427 let tag_col = meta.fixed_column();
428 let q_substring = meta.complex_selector();
429
430 // Substring lookup arguments (see the `substring` module for full details).
431 //
432 // There are `SUBSTRING_PARALLELISM` independent lookup arguments, each
433 // operating on 2 advice columns: `advice_cols[2*batch]` (table byte)
434 // and `advice_cols[2*batch + 1]` (packed query), plus 2 shared fixed
435 // columns: index and tag.
436 //
437 // Example: checking `wor` appears in `hello world` at index 6 (batch 0):
438 //
439 // fixed advice
440 // tag | index cols[2*i] | cols[2*i+1]
441 // ----------- -----------------------
442 // 1 | 0 'h' | 257*6 + 'w' <- query for sub[0]
443 // 1 | 1 'e' | 257*7 + 'o' <- query for sub[1]
444 // 1 | 2 'l' | 257*8 + 'r' <- query for sub[2]
445 // 1 | 3 'l' | (padding)
446 // 1 | 4 'o' | (padding)
447 // 1 | 5 ' ' | (padding)
448 // 1 | 6 'w' | (padding)
449 // 1 | 7 'o' | (padding)
450 // 1 | 8 'r' | (padding)
451 // 1 | 9 'l' | (padding)
452 // 1 | 10 'd' | (padding)
453 //
454 // The packed query `257 * (idx + i) + sub[i]` is pre-computed in
455 // circuit (see `index_and_pack_sequence` in `substring`). The table
456 // packing is done in the expression below:
457 //
458 // table_packed = 257 * index + table_byte
459 //
460 // The lookup checks: (tag, packed_query) ∈ {(tag, table_packed)}.
461 //
462 // When sel=OFF, both sides reduce to (tag, query), i.e., a tautology, so rows
463 // not used by substring checks are unconstrained.
464 //
465 // Invariant: the tag column is 0 on every row that is not part of a substring
466 // check region, and non-zero (a unique positive integer) inside each region.
467 // This isolates independent substring checks from each other and from
468 // unrelated rows: a query tagged T can only match table entries with the
469 // same tag T, and rows with tag 0 never participate in any lookup.
470 for batch in 0..SUBSTRING_PARALLELISM {
471 meta.lookup_any(format!("substring lookup (batch {batch})"), |meta| {
472 let sel = meta.query_selector(q_substring);
473 let not_sel = Expression::Constant(F::ONE) - sel.clone();
474 let index = meta.query_fixed(*index_col, Rotation::cur());
475 let tag = meta.query_fixed(tag_col, Rotation::cur());
476 let shift = Expression::Constant(F::from(ALPHABET_MAX_SIZE as u64 + 1));
477
478 let base = NB_SUBSTRING_COLS * batch;
479 let table = meta.query_advice(advice_cols[base], Rotation::cur());
480 let query = meta.query_advice(advice_cols[base + 1], Rotation::cur());
481
482 vec![
483 (tag.clone(), sel.clone() * tag.clone()),
484 (
485 query.clone(),
486 sel * (index * shift + table) + not_sel * query,
487 ),
488 ]
489 });
490 }
491
492 ScannerConfig {
493 advice_cols: *advice_cols,
494 static_library: automata.clone(),
495 q_automaton,
496 t_source,
497 t_letter,
498 t_target,
499 t_output,
500 q_substring,
501 index_col: *index_col,
502 tag_col,
503 }
504 }
505
506 /// Loads the automaton transition table and finalises all deferred
507 /// substring checks. Must be called at the end of circuit synthesis.
508 fn load(&self, layouter: &mut impl Layouter<F>) -> Result<(), Error> {
509 self.load_automata_table(layouter)?;
510 self.finalise_substring_checks(layouter)
511 }
512}
513
514#[cfg(test)]
515impl Regex {
516 // "hello hello [...] hello \( world , world , [...] , world \) !!!!!" with
517 // 1. arbitrary spaces whenever there is one
518 // 2. at least one "hello" and one "world"
519 // 3. an arbitrary sequence of characters different from '!' at the end of the
520 // string.
521 // The definition of the regex purposely performs some non succinct operations
522 // to test several constructions of the library.
523 fn hard_coded_example0() -> Self {
524 let hellos = Regex::word("hello").separated_non_empty_list(Regex::blanks_strict());
525 let worlds = Regex::word("world").separated_non_empty_list(Regex::cat([
526 Regex::blanks(),
527 ",".into(),
528 Regex::blanks(),
529 ]));
530 let marks5 = Regex::word("!").repeat(5);
531 let trail = Regex::any_byte().minus("!".into()).list();
532 Regex::separated_cat(
533 [
534 hellos.terminated(Regex::one_blank()),
535 worlds
536 .delimited(Regex::blanks(), Regex::blanks())
537 .delimited("(".into(), ")".into()),
538 marks5,
539 trail,
540 ],
541 Regex::blanks(),
542 )
543 }
544
545 fn hard_coded_example1() -> Self {
546 // `output_regex` accepts any character, marking 'l' as 2, and
547 // any other non-blank character different from 'h' as 1.
548 let output_regex = Regex::any_byte()
549 .output(&|b| {
550 if b == b'l' {
551 Some(2)
552 } else if !b"h\n\t ".contains(&b) {
553 Some(1)
554 } else {
555 None
556 }
557 })
558 .list();
559 let holy = Regex::word("holy").terminated(Regex::word("y").list());
560 let hell = Regex::word("hell");
561 let marks = Regex::word("!").non_empty_list();
562 let sentence = Regex::separated_cat([holy, hell, marks], Regex::blanks_strict());
563 sentence.and(output_regex)
564 }
565}
566
567#[cfg(test)]
568impl<F> FromScratch<F> for ScannerChip<F>
569where
570 F: CircuitField + Ord,
571{
572 type Config = (P2RDecompositionConfig, ScannerConfig);
573
574 fn new_from_scratch(config: &Self::Config) -> Self {
575 let max_bit_len = 8;
576 let native_chip = NativeChip::new(&config.0.native_config, &());
577 let core_decomposition_chip = P2RDecompositionChip::new(&config.0, &max_bit_len);
578 let native_gadget = NG::<F>::new(core_decomposition_chip, native_chip);
579 <ScannerChip<F> as ComposableChip<F>>::new(&config.1, &native_gadget)
580 }
581
582 fn configure_from_scratch(
583 meta: &mut ConstraintSystem<F>,
584 advice_columns: &mut Vec<Column<Advice>>,
585 fixed_columns: &mut Vec<Column<Fixed>>,
586 instance_columns: &[Column<Instance>; 2],
587 ) -> Self::Config {
588 let nb_advice_needed = std::cmp::max(NB_SCANNER_ADVICE_COLS, NB_ARITH_COLS);
589 let nb_fixed_needed = NB_ARITH_COLS + 4;
590 while advice_columns.len() < nb_advice_needed {
591 advice_columns.push(meta.advice_column());
592 }
593 while fixed_columns.len() < nb_fixed_needed {
594 fixed_columns.push(meta.fixed_column());
595 }
596 let advice_cols = &advice_columns[..nb_advice_needed];
597 let fixed_cols = &fixed_columns[..nb_fixed_needed];
598
599 let native_config = NativeChip::configure(
600 meta,
601 &(
602 advice_cols[..NB_ARITH_COLS].try_into().unwrap(),
603 fixed_cols[..NB_ARITH_COLS + 4].try_into().unwrap(),
604 *instance_columns,
605 ),
606 );
607
608 let scanner_config = ScannerChip::configure(
609 meta,
610 &(
611 advice_cols[..NB_SCANNER_ADVICE_COLS].try_into().unwrap(),
612 fixed_cols[0],
613 FxHashMap::default(),
614 ),
615 );
616
617 let pow2range_config = Pow2RangeChip::configure(meta, &advice_cols[1..=4]);
618
619 let native_gadget_config = P2RDecompositionConfig {
620 native_config,
621 pow2range_config,
622 };
623
624 (native_gadget_config, scanner_config)
625 }
626
627 fn load_from_scratch(&self, layouter: &mut impl Layouter<F>) -> Result<(), Error> {
628 self.native_gadget.load_from_scratch(layouter)?;
629 self.load(layouter)
630 }
631}