Skip to main content

midnight_circuits/parsing/scanner/
substring.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//! Substring verification via packed lookup arguments.
15//!
16//! # Overview
17//!
18//! [`ScannerChip::check_bytes`] asserts that `sub` (a sequence of
19//! [`AssignedByte`]) is a contiguous subsequence of `sequence` starting at
20//! index `idx` ([`AssignedNative`], 0-based).
21//!
22//! The general idea is to index the positions of both `sequence` and `sub`,
23//! and use a dynamic lookup to verify containment. For example, checking that
24//! `"wor"` appears in `"hello world"` at index 6 can be done via the lookup:
25//!
26//! ```text
27//! table:       queries:
28//! | 0  | h |   | 6 | w |  (idx)
29//! | 1  | e |   | 7 | o |  (idx+1)
30//! | 2  | l |   | 8 | r |  (idx+2)
31//! | 3  | l |
32//! | 4  | o |
33//! | 5  |   |
34//! | 6  | w |
35//! | 7  | o |
36//! | 8  | r |
37//! | 9  | l |
38//! | 10 | d |
39//! ```
40//!
41//! In practice, the lookup argument is a bit more complex, as detailed step by
42//! step below. The implemented lookup layout is the one described in the last
43//! section.
44//!
45//! # Tagging (soundness requirement)
46//!
47//! In addition to the base idea, a tag column is needed to isolate independent
48//! substring checks that share the same lookup argument. For example, `wor <=
49//! hello world` and `mun <= hola mundo` would be laid out as:
50//!
51//! ```text
52//! table:           queries:
53//! | 1 | 0  | h |   | 1 | 6 | w |
54//! | 1 | 1  | e |   | 1 | 7 | o |
55//! | 1 | 2  | l |   | 1 | 8 | r |
56//! | 1 | 3  | l |
57//! | 1 | 4  | o |
58//! | 1 | 5  |   |
59//! | 1 | 6  | w |
60//! | 1 | 7  | o |
61//! | 1 | 8  | r |
62//! | 1 | 9  | l |
63//! | 1 | 10 | d |
64//! | 2 | 0  | h |   | 2 | 5 | m |
65//! | 2 | 1  | o |   | 2 | 6 | u |
66//! | 2 | 2  | l |   | 2 | 7 | n |
67//! | 2 | 3  | a |
68//! | 2 | 4  |   |
69//! | 2 | 5  | m |
70//! | 2 | 6  | u |
71//! | 2 | 7  | n |
72//! | 2 | 8  | d |
73//! | 2 | 9  | o |
74//! ```
75//!
76//! Tags and the table index column are written in fixed columns; the
77//! remaining columns (table bytes and query entries) are advice columns. The
78//! invariant is that the tag is >0 in substring-check regions, and 0 in
79//! irrelevant rows (which is why the tag column cannot be shared with other
80//! chips).
81//!
82//! # Sequence sharing (Optimisation 1)
83//!
84//! When several calls share the same `sequence` argument, the sequence is
85//! assigned only once and all corresponding `sub` arguments get the same tag.
86//! For example, checking both `wor <= hello world` at index 6 and
87//! `hel <= hello world` at index 0:
88//!
89//! ```text
90//! table:           queries:
91//! | 1 | 0  | h |   | 1 | 6 | w |
92//! | 1 | 1  | e |   | 1 | 7 | o |
93//! | 1 | 2  | l |   | 1 | 8 | r |
94//! | 1 | 3  | l |   | 1 | 0 | h |
95//! | 1 | 4  | o |   | 1 | 1 | e |
96//! | 1 | 5  |   |   | 1 | 2 | l |
97//! | 1 | 6  | w |
98//! | 1 | 7  | o |
99//! | 1 | 8  | r |
100//! | 1 | 9  | l |
101//! | 1 | 10 | d |
102//! ```
103//!
104//! To achieve this, calls to [`ScannerChip::check_bytes`] are deferred and
105//! recorded in the `SequenceCache` without performing
106//! circuit operations. At the end of circuit synthesis,
107//! [`ScannerChip::finalise_substring_checks`] drains the cache, groups calls
108//! by their `sequence` argument, assigns tags, and lays out the region.
109//!
110//! # Packing (Optimisation 2)
111//!
112//! To save columns, each `(index, byte)` pair is packed into a single field
113//! element: `index * 257 + byte` (where 257 = `ALPHABET_MAX_SIZE + 1`).
114//! The index `idx` is range-checked (`idx < 2^PARSING_MAX_LEN_BITS`) to
115//! guarantee that the packing is injective over the field.
116//!
117//! ```text
118//! table:                   queries:
119//! | 1 | 257 * 0  + 'h' |   | 1 | 257 * 6 + 'w' |
120//! | 1 | 257 * 1  + 'e' |   | 1 | 257 * 7 + 'o' |
121//! | 1 | 257 * 2  + 'l' |   | 1 | 257 * 8 + 'r' |
122//! | 1 | 257 * 3  + 'l' |   | 1 | 257 * 0 + 'h' |
123//! | 1 | 257 * 4  + 'o' |   | 1 | 257 * 1 + 'e' |
124//! | 1 | 257 * 5  + ' ' |   | 1 | 257 * 2 + 'l' |
125//! | 1 | 257 * 6  + 'w' |
126//! | 1 | 257 * 7  + 'o' |
127//! | 1 | 257 * 8  + 'r' |
128//! | 1 | 257 * 9  + 'l' |
129//! | 1 | 257 * 10 + 'd' |
130//! ```
131//!
132//! The packing of queries is computed in-circuit via
133//! `ScannerChip::index_and_pack_sequence` using `linear_combination`. The
134//! packing of table entries is performed inside the lookup expression itself
135//! (see `ScannerChip::configure`).
136//!
137//! # Parallelisation (Optimisation 3)
138//!
139//! If the value of `SUBSTRING_PARALLELISM` is greater than 1, several of these
140//! lookup arguments can be done in parallel. For example, checking
141//! `wor@6 + hel@0 <= hello world` and `hol@0 + mund@5 <= hola mundo`
142//! simultaneously (with `SUBSTRING_PARALLELISM = 2`) uses the same rows for
143//! both. Tags are all 1 (same chunk) and omitted for brevity.
144//!
145//! ```text
146//! table 1:              queries 1:          table 2:              queries 2:
147//! | 257 * 0  + 'h'  |   | 257 * 6 + 'w' |   | 257 * 0  + 'h'  |   | 257 * 0 + 'h' |
148//! | 257 * 1  + 'e'  |   | 257 * 7 + 'o' |   | 257 * 1  + 'o'  |   | 257 * 1 + 'o' |
149//! | 257 * 2  + 'l'  |   | 257 * 8 + 'r' |   | 257 * 2  + 'l'  |   | 257 * 2 + 'l' |
150//! | 257 * 3  + 'l'  |   | 257 * 0 + 'h' |   | 257 * 3  + 'a'  |   | 257 * 5 + 'm' |
151//! | 257 * 4  + 'o'  |   | 257 * 1 + 'e' |   | 257 * 4  + ' '  |   | 257 * 6 + 'u' |
152//! | 257 * 5  + ' '  |   | 257 * 2 + 'l' |   | 257 * 5  + 'm'  |   | 257 * 7 + 'n' |
153//! | 257 * 6  + 'w'  |                       | 257 * 6  + 'u'  |   | 257 * 8 + 'd' |
154//! | 257 * 7  + 'o'  |                       | 257 * 7  + 'n'  |
155//! | 257 * 8  + 'r'  |                       | 257 * 8  + 'd'  |
156//! | 257 * 9  + 'l'  |                       | 257 * 9  + 'o'  |
157//! | 257 * 10 + 'd'  |                       | 257 * 10 + 256  |
158//! ```
159//!
160//! The fixed columns (tag and index) are shared across parallel lookups.
161//!
162//! **Padding.** Since the table and query columns must have the same number of
163//! rows, shorter sides are padded. Sequence (table) columns are padded with 256
164//! (`ALPHABET_MAX_SIZE`), which no real query can match. Query columns are
165//! padded by repeating the first packed query entry. Unused parallel slots are
166//! filled with zeros on both sides. Padding rows are omitted from the diagrams
167//! above for clarity.
168
169use midnight_proofs::{
170    circuit::{Layouter, Region, Value},
171    plonk::Error,
172};
173use num_bigint::BigUint;
174
175use super::{ScannerChip, NB_SUBSTRING_COLS, PARSING_MAX_LEN_BITS, SUBSTRING_PARALLELISM};
176use crate::{
177    field::AssignedNative,
178    instructions::{
179        ArithInstructions, AssertionInstructions, AssignmentInstructions, ControlFlowInstructions,
180        RangeCheckInstructions,
181    },
182    parsing::scanner::{Sequence, ALPHABET_MAX_SIZE},
183    types::AssignedByte,
184    CircuitField,
185};
186
187/// Structure of assigned cells for verifying substring checks.
188type SubstringCheckLayout<F> = Vec<[Sequence<F>; NB_SUBSTRING_COLS * SUBSTRING_PARALLELISM]>;
189
190impl<F> ScannerChip<F>
191where
192    F: CircuitField + Ord,
193{
194    /// Generic version of `check_bytes`. Cannot be exposed publicly because
195    /// it is unsound without range-checks on the elements of `sequence` and
196    /// `sub` (they are packed with indexes, so values outside `[0, 255]`
197    /// would break injectivity).
198    fn check_subsequence(
199        &self,
200        layouter: &mut impl Layouter<F>,
201        sequence: &[AssignedNative<F>],
202        idx: &AssignedNative<F>,
203        sub: &[AssignedNative<F>],
204    ) -> Result<(), Error> {
205        if sub.is_empty() {
206            // The circuit logic will assume `sub` is not empty for padding purposes, hence
207            // handling it separately.
208            return Ok(());
209        }
210        // Range-check idx to ensure packing injectivity.
211        self.native_gadget.assert_lower_than_fixed(
212            layouter,
213            idx,
214            &(BigUint::from(1u8) << PARSING_MAX_LEN_BITS),
215        )?;
216
217        self.sequence_cache
218            .borrow_mut()
219            .entry(sequence.to_vec())
220            .and_modify(|(calls, len)| {
221                *len += sub.len();
222                calls.push((idx.clone(), vec![], sub.to_vec(), None))
223            })
224            .or_insert_with(|| {
225                let sub_entry = (idx.clone(), vec![], sub.to_vec(), None);
226                (vec![sub_entry], sub.len())
227            });
228
229        Ok(())
230    }
231
232    /// Asserts that `sub` is a contiguous subsequence of `sequence` starting at
233    /// index `idx` (0-indexed). This function defers the actual circuit work:
234    /// it records the call in the `SequenceCache`,
235    /// grouping entries with the same `sequence` argument under a single tag.
236    /// The circuit assignment happens later in
237    /// `Self::finalise_substring_checks`.
238    ///
239    /// # Cost
240    ///
241    /// The cost of one call is of the order of `|sequence| + |sub|` rows.
242    /// Due to caching, multiple calls with the same `sequence` argument only
243    /// pay the `sequence`-related cost once.
244    ///
245    /// # Range check
246    ///
247    /// The starting index is range-checked (`idx < 2^PARSING_MAX_LEN_BITS`)
248    /// so that the packed lookup value `(idx + i) * (ALPHABET_MAX_SIZE + 1) +
249    /// byte` is injective over the field.
250    pub fn check_bytes(
251        &self,
252        layouter: &mut impl Layouter<F>,
253        sequence: &[AssignedByte<F>],
254        idx: &AssignedNative<F>,
255        sub: &[AssignedByte<F>],
256    ) -> Result<(), Error> {
257        let sequence: Sequence<F> = sequence.iter().map(AssignedNative::from).collect();
258        let sub: Sequence<F> = sub.iter().map(AssignedNative::from).collect();
259        self.check_subsequence(layouter, &sequence, idx, &sub)
260    }
261
262    /// More permissive version of [`Self::check_bytes`] that allows both
263    /// `sequence` and `sub` to include the value 256. This function
264    /// range-checks all of their cells by 257 to enable that.
265    ///
266    /// Less resilient, and slightly less efficient than [`Self::check_bytes`],
267    /// but permits to encode more complex tests on bytes by using 256 as
268    /// separator arbitrarily. E.g., the positions at which `sub` may start may
269    /// be restricted by putting 256 blockers in `sequence`, and at the
270    /// beginning and the end of `sub`.
271    pub fn check_bytes_ext(
272        &self,
273        layouter: &mut impl Layouter<F>,
274        sequence: &[AssignedNative<F>],
275        idx: &AssignedNative<F>,
276        sub: &[AssignedNative<F>],
277    ) -> Result<(), Error> {
278        for x in sequence.iter().chain(sub) {
279            self.native_gadget.assert_lower_than_fixed(layouter, x, &257u16.into())?;
280        }
281        self.check_subsequence(layouter, sequence, idx, sub)
282    }
283
284    /// Asserts that two byte slices of fixed length are element-wise equal.
285    /// Proceeds by iterating and asserting equality over cells.
286    pub fn assert_equal_fixlen(
287        &self,
288        layouter: &mut impl Layouter<F>,
289        v1: &[AssignedByte<F>],
290        v2: &[AssignedByte<F>],
291    ) -> Result<(), Error> {
292        assert_eq!(v1.len(), v2.len(), "byte slices must have equal length");
293        for (x, y) in v1.iter().zip(v2.iter()) {
294            self.native_gadget.assert_equal(layouter, x, y)?;
295        }
296        Ok(())
297    }
298}
299
300impl<F> ScannerChip<F>
301where
302    F: CircuitField,
303{
304    /// Packs a sequence of assigned bytes into indexed field elements:
305    /// `packed[i] = (start_idx + sum(offsets) + i + base_offset) *
306    /// (ALPHABET_MAX_SIZE + 1) + byte[i]`
307    ///
308    /// The `base_offset` accounts for sentinel rows prepended to each
309    /// sequence column during finalisation. The `idx_offsets` are additional
310    /// `(coefficient, value)` terms folded into the same linear combination,
311    /// avoiding a dedicated row for precomputing the adjusted index.
312    fn index_and_pack_sequence(
313        &self,
314        layouter: &mut impl Layouter<F>,
315        sequence: &[AssignedNative<F>],
316        start_idx: &AssignedNative<F>,
317        idx_offsets: &[(F, AssignedNative<F>)],
318        base_offset: u64,
319    ) -> Result<Sequence<F>, Error> {
320        let shift = F::from(ALPHABET_MAX_SIZE as u64 + 1);
321        (sequence.iter().enumerate())
322            .map(|(i, byte)| {
323                let constant = F::from(i as u64 + base_offset);
324                let mut terms = vec![(shift, start_idx.clone()), (F::ONE, byte.clone())];
325                for (coeff, val) in idx_offsets {
326                    terms.push((*coeff * shift, val.clone()));
327                }
328                self.native_gadget.linear_combination(layouter, &terms, constant * shift)
329            })
330            .collect()
331    }
332
333    /// Drains the sequence cache, sorts entries by decreasing sequence length
334    /// (then decreasing cumulative sub length), and packs query entries with
335    /// their index. Returns one `(packed_sequence, flattened_packed_subs)` per
336    /// unique sequence. Each sequences and subs have been padded and organised
337    /// so that it only remains to assign them in circuit.
338    ///
339    /// Each table column is prepended with a zero sentinel row (index 0), so
340    /// `(tag, 0)` is always in the lookup table and zero-padded queries pass
341    /// trivially. Sub entries are packed with `base_offset = 1` to account for
342    /// this shift. Subs with variable-length metadata have their filler
343    /// positions masked to zero.
344    fn index_and_pack_calls(
345        &self,
346        layouter: &mut impl Layouter<F>,
347    ) -> Result<SubstringCheckLayout<F>, Error> {
348        let ng = &self.native_gadget;
349        let mut calls: Vec<_> = self.sequence_cache.borrow_mut().drain().collect();
350        calls.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then(b.1 .1.cmp(&a.1 .1)));
351
352        // Padding for tables: a value that cannot be a valid byte.
353        let sequence_padding: AssignedNative<F> =
354            ng.assign_fixed(layouter, F::from(ALPHABET_MAX_SIZE as u64))?;
355        // Zero cell used for sentinels, masking, and unused parallel columns.
356        let zero: AssignedNative<F> = ng.assign_fixed(layouter, F::ZERO)?;
357        // The calls divided in regions of `SUBSTRING_PARALLELISM` parallel executions.
358        let mut layout: SubstringCheckLayout<F> =
359            Vec::with_capacity(calls.len().div_ceil(SUBSTRING_PARALLELISM));
360
361        for chunk in calls.chunks(SUBSTRING_PARALLELISM) {
362            let mut local_layout = Vec::with_capacity(NB_SUBSTRING_COLS * SUBSTRING_PARALLELISM);
363            let region_size = chunk.iter().map(|(s, (_, len))| s.len().max(*len)).max().unwrap();
364            // +1 for the sentinel row at index 0.
365            let padded_size = region_size + 1;
366
367            // Process real entries.
368            for (sequence, (subs, _)) in chunk {
369                // Table column: zero sentinel + sequence + padding.
370                let mut padded_sequence: Sequence<F> = Vec::with_capacity(padded_size);
371                padded_sequence.push(zero.clone());
372                padded_sequence.extend_from_slice(sequence);
373                padded_sequence.resize(padded_size, sequence_padding.clone());
374
375                // Query column: pack subs with base_offset=1, mask varlen fillers.
376                let subs_packed: Sequence<F> = (subs.iter())
377                    .map(|(idx, idx_offsets, sub, padding_flags)| {
378                        let packed =
379                            self.index_and_pack_sequence(layouter, sub, idx, idx_offsets, 1)?;
380                        if let Some(flags) = padding_flags {
381                            packed
382                                .iter()
383                                .zip(flags.iter())
384                                .map(|(val, is_pad)| ng.select(layouter, is_pad, &zero, val))
385                                .collect()
386                        } else {
387                            Ok(packed)
388                        }
389                    })
390                    .collect::<Result<Vec<Sequence<F>>, _>>()?
391                    .into_iter()
392                    .flatten()
393                    .collect();
394
395                // Zero sentinel + packed subs + zero padding.
396                let mut padded_subs_packed: Sequence<F> = Vec::with_capacity(padded_size);
397                padded_subs_packed.push(zero.clone());
398                padded_subs_packed.extend(subs_packed);
399                padded_subs_packed.resize(padded_size, zero.clone());
400
401                local_layout.extend_from_slice(&[padded_sequence, padded_subs_packed]);
402            }
403
404            // Fill unused parallel slots with zeros. These match the (tag, 0)
405            // sentinel entry.
406            let zero_col = vec![zero.clone(); padded_size];
407            local_layout.resize(NB_SUBSTRING_COLS * SUBSTRING_PARALLELISM, zero_col);
408
409            layout.push(local_layout.try_into().unwrap());
410        }
411        Ok(layout)
412    }
413
414    /// Assigns a single row of the substring region.
415    fn assign_substring_row(
416        &self,
417        region: &mut Region<'_, F>,
418        lookups: &[AssignedNative<F>; NB_SUBSTRING_COLS * SUBSTRING_PARALLELISM],
419        offset: usize,
420        index: usize,
421        tag: usize,
422    ) -> Result<(), Error> {
423        self.config.q_substring.enable(region, offset)?;
424        region.assign_fixed(
425            || "substring check (index)",
426            self.config.index_col,
427            offset,
428            || Value::known(F::from(index as u64)),
429        )?;
430        region.assign_fixed(
431            || "substring check (tag)",
432            self.config.tag_col,
433            offset,
434            || Value::known(F::from(tag as u64)),
435        )?;
436        for (i, cell) in lookups.iter().enumerate() {
437            cell.copy_advice(
438                || {
439                    format!(
440                        "substring check ({} #{offset})",
441                        if i.is_multiple_of(2) {
442                            "table"
443                        } else {
444                            "query"
445                        }
446                    )
447                },
448                region,
449                self.config.advice_cols[i],
450                offset,
451            )?;
452        }
453        Ok(())
454    }
455
456    /// Finalises all deferred `check_bytes` calls. Called from
457    /// `ComposableChip::load` at the end of circuit synthesis.
458    ///
459    /// The sequence cache is drained and each unique sequence, together with
460    /// all its associated `(idx, sub)` pairs, is packed into indexed field
461    /// elements. Each unique sequence is assigned a fresh non-zero tag and
462    /// laid out row by row. The selector is enabled on every row so that each
463    /// row contributes both a table entry (packed sequence byte) and a query
464    /// (packed sub byte).
465    pub(super) fn finalise_substring_checks(
466        &self,
467        layouter: &mut impl Layouter<F>,
468    ) -> Result<(), Error> {
469        // Pack all cached calls into indexed field elements.
470        let packed_calls = self.index_and_pack_calls(layouter)?;
471
472        // Build the row layout and assign in a single region.
473        layouter.assign_region(
474            || "substring checks",
475            |mut region| {
476                let mut offset = 1;
477                for (tag, parallel_calls) in packed_calls.iter().enumerate() {
478                    for row in 0..parallel_calls[0].len() {
479                        let lookups = core::array::from_fn(|col| parallel_calls[col][row].clone());
480                        self.assign_substring_row(&mut region, &lookups, offset, row, tag + 1)?;
481                        offset += 1;
482                    }
483                }
484                Ok(())
485            },
486        )
487    }
488}
489
490#[cfg(test)]
491mod test {
492    use ff::FromUniformBytes;
493    use midnight_proofs::{
494        circuit::{Layouter, SimpleFloorPlanner, Value},
495        dev::MockProver,
496        plonk::{Circuit, ConstraintSystem, Error},
497    };
498
499    use super::super::ScannerChip;
500    use crate::{
501        instructions::AssignmentInstructions, testing_utils::FromScratch, types::AssignedByte,
502        utils::circuit_modeling::circuit_to_json, CircuitField,
503    };
504
505    /// Check bytes test circuit with two witnesses, so that the isolation of
506    /// successive calls to `check_bytes` is also tested.
507    #[derive(Clone, Debug)]
508    struct CheckBytesTestCircuit<F: CircuitField> {
509        full1: Vec<Value<u8>>,
510        sub1: Vec<Value<u8>>,
511        idx1: Value<F>,
512        full2: Vec<Value<u8>>,
513        sub2: Vec<Value<u8>>,
514        idx2: Value<F>,
515    }
516
517    impl<F: CircuitField> CheckBytesTestCircuit<F> {
518        fn new(case1: (&str, &str, usize), case2: (&str, &str, usize)) -> Self {
519            let (full1, sub1, idx1) = case1;
520            let (full2, sub2, idx2) = case2;
521            CheckBytesTestCircuit {
522                full1: full1.bytes().map(Value::known).collect(),
523                sub1: sub1.bytes().map(Value::known).collect(),
524                idx1: Value::known(F::from(idx1 as u64)),
525                full2: full2.bytes().map(Value::known).collect(),
526                sub2: sub2.bytes().map(Value::known).collect(),
527                idx2: Value::known(F::from(idx2 as u64)),
528            }
529        }
530    }
531
532    impl<F> Circuit<F> for CheckBytesTestCircuit<F>
533    where
534        F: CircuitField + FromUniformBytes<64> + Ord,
535    {
536        type Config = <ScannerChip<F> as FromScratch<F>>::Config;
537        type FloorPlanner = SimpleFloorPlanner;
538        type Params = ();
539
540        fn without_witnesses(&self) -> Self {
541            unreachable!()
542        }
543
544        fn configure(meta: &mut ConstraintSystem<F>) -> Self::Config {
545            let instance_columns = [meta.instance_column(), meta.instance_column()];
546            ScannerChip::<F>::configure_from_scratch(
547                meta,
548                &mut vec![],
549                &mut vec![],
550                &instance_columns,
551            )
552        }
553
554        fn synthesize(
555            &self,
556            config: Self::Config,
557            mut layouter: impl Layouter<F>,
558        ) -> Result<(), Error> {
559            let scanner = ScannerChip::<F>::new_from_scratch(&config);
560            let native_gadget = &scanner.native_gadget;
561
562            let full1: Vec<AssignedByte<F>> =
563                native_gadget.assign_many(&mut layouter, &self.full1)?;
564            let sub1: Vec<AssignedByte<F>> =
565                native_gadget.assign_many(&mut layouter, &self.sub1)?;
566            let idx1 = native_gadget.assign(&mut layouter, self.idx1)?;
567            let full2: Vec<AssignedByte<F>> =
568                native_gadget.assign_many(&mut layouter, &self.full2)?;
569            let sub2: Vec<AssignedByte<F>> =
570                native_gadget.assign_many(&mut layouter, &self.sub2)?;
571            let idx2 = native_gadget.assign(&mut layouter, self.idx2)?;
572
573            // Two separate check_bytes calls — each gets a different sequence
574            // key in the cache, so they will be assigned different tags.
575            scanner.check_bytes(&mut layouter, &full1, &idx1, &sub1)?;
576            scanner.check_bytes(&mut layouter, &full2, &idx2, &sub2)?;
577
578            // Load triggers finalise_substring_checks (deferred execution model).
579            scanner.load_from_scratch(&mut layouter)
580        }
581    }
582
583    fn check_bytes_test(
584        cost_model: bool,
585        case1: (&str, &str, usize),
586        case2: (&str, &str, usize),
587        must_pass: bool,
588    ) {
589        assert!(
590            !cost_model || must_pass,
591            "(bug) if cost_model is set to true, must_pass should be set to true"
592        );
593        type F = midnight_curves::Fq;
594
595        let circuit = CheckBytesTestCircuit::<F>::new(case1, case2);
596        println!(
597            ">> [test check_bytes] [must{} pass] on\n\t- input1: \"{}\" = \"{}\"[{}..{}]\n\t- input2: \"{}\" = \"{}\"[{}..{}]",
598            if must_pass { "" } else { " not" },
599            case1.1,
600            case1.0,
601            case1.2,
602            case1.2 + case1.1.len(),
603            case2.1,
604            case2.0,
605            case2.2,
606            case2.2 + case2.1.len(),
607        );
608        let result = MockProver::run(&circuit, vec![vec![], vec![]]);
609        match result {
610            Ok(p) => {
611                let verified = p.verify();
612                if must_pass {
613                    verified.expect("the test should have passed")
614                } else {
615                    assert!(verified.is_err(), "the test should have failed");
616                }
617            }
618            Err(e) => {
619                assert!(!must_pass, "Prover failed unexpectedly: {:?}", e);
620            }
621        }
622        println!("... test successful!");
623
624        if cost_model {
625            circuit_to_json::<F>(
626                "Scanner",
627                &format!(
628                    "substring perf (full length = {}, sub length = {})",
629                    case1.0.len(),
630                    case1.1.len()
631                ),
632                circuit,
633            );
634        }
635    }
636
637    #[test]
638    fn test_check_bytes() {
639        // Test of a trivial case.
640        let trivial = ("", "", 0);
641        check_bytes_test(false, trivial, trivial, true);
642
643        // Basic tests (with trivial second case).
644        check_bytes_test(false, ("hello world", "hello", 0), trivial, true); // At beginning.
645        check_bytes_test(false, ("hello world", "lo wo", 3), trivial, true); // In middle.
646        check_bytes_test(false, ("hello world", "world", 6), trivial, true); // At end.
647        check_bytes_test(false, ("abcdef", "d", 3), trivial, true); // Single char.
648        check_bytes_test(false, ("test", "test", 0), trivial, true); // Full string.
649        check_bytes_test(false, ("hello world", "xyz", 0), trivial, false); // Off-Topic.
650        check_bytes_test(false, ("hello world", "world", 0), trivial, false); // Wrong idx.
651
652        // Tag isolation tests.
653        check_bytes_test(false, ("a", "b", 0), ("b", "a", 0), false);
654        check_bytes_test(
655            false,
656            ("hello world", "hello", 0),
657            ("world", " world", 1),
658            false,
659        );
660        check_bytes_test(false, ("hello", "ell", 1), ("world", "orl", 1), true);
661
662        // Performance test for the golden files (full=16, sub=5 to match varlen tests).
663        let full = "abcdefghij abcde";
664        let sub = &full[6..11]; // 5 bytes
665        check_bytes_test(true, (full, sub, 6), ("world", "orl", 1), true);
666    }
667}