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}