vyre_driver_wgpu/megakernel/segmentation.rs
1//! Single-input segmentation for the megakernel scan.
2//!
3//! Today a work-item is `(file_idx, rule_idx)` and each lane scans the WHOLE
4//! file for one rule (`dispatcher.rs`: `file_idx = claim / rule_count`). For one
5//! large file that leaves occupancy bounded by `rule_count`, with every busy lane
6//! walking all N bytes sequentially, the reason the GPU loses to Hyperscan on a
7//! single 8 MiB scan. Splitting each file into many overlapping windows turns the
8//! work-item into `(segment_idx, rule_idx)`, so a single file saturates the whole
9//! device (see `docs/GPU_OOM_SEGMENTATION.md`).
10//!
11//! ## Soundness (why overlapping windows are exact)
12//!
13//! For an Aho-Corasick / failure-function DFA, the state after consuming the byte
14//! at offset `i` is a function of at most the last `overlap` bytes (the longest
15//! pattern), independent of the state the scan started in. So a window that
16//! begins scanning `overlap` bytes BEFORE the region it owns reaches the exact
17//! state a full-file scan would have at `emit_start`. Each window therefore:
18//! * scans `[scan_start, emit_end)` from state 0, the `[scan_start, emit_start)`
19//! prefix is warm-up only, it emits nothing; and
20//! * emits only matches whose END offset lies in `[emit_start, emit_end)`.
21//!
22//! The emit ranges tile `[0, file_len)` exactly, contiguous, gap-free, and
23//! disjoint, so every match is produced by exactly one window: no double count,
24//! no miss. `plan_segments` is the host-side planner; the kernel reads the
25//! resulting table to derive `(file_idx, scan_start, emit_start, emit_end)` from
26//! a claim, and guards emission with `end >= emit_start && end < emit_end`.
27
28/// One scan window of a file.
29///
30/// All offsets are file-relative bytes. Invariant:
31/// `scan_start <= emit_start < emit_end` and `scan_start == emit_start - overlap`
32/// clamped at 0.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct Segment {
35 /// Index into the file table this window belongs to.
36 pub file_idx: u32,
37 /// File-relative offset where scanning begins (DFA-state warm-up start).
38 pub scan_start: u32,
39 /// First file-relative offset this window OWNS: a match whose end offset is
40 /// in `[emit_start, emit_end)` is emitted here; an end `< emit_start` belongs
41 /// to the previous window's owned region.
42 pub emit_start: u32,
43 /// Exclusive end of both the owned (emit) range and the scan range.
44 pub emit_end: u32,
45}
46
47/// Number of `u32` words per segment in the device segment table.
48pub const SEGMENT_WORDS: usize = 4;
49
50impl Segment {
51 /// Total bytes this window scans (warm-up prefix + owned region).
52 #[must_use]
53 pub const fn scan_len(&self) -> u32 {
54 self.emit_end - self.scan_start
55 }
56
57 /// Bytes this window owns (emits matches for).
58 #[must_use]
59 pub const fn emit_len(&self) -> u32 {
60 self.emit_end - self.emit_start
61 }
62
63 /// The device-ABI words for this segment, in the exact order the kernel
64 /// decodes them from `segments[seg_idx * SEGMENT_WORDS ..]`:
65 /// `[file_idx, scan_start, emit_start, emit_end]` (offsets file-relative
66 /// the kernel adds `file_offsets[file_idx]` to reach the packed haystack).
67 #[must_use]
68 pub const fn abi_words(&self) -> [u32; SEGMENT_WORDS] {
69 [
70 self.file_idx,
71 self.scan_start,
72 self.emit_start,
73 self.emit_end,
74 ]
75 }
76}
77
78/// Build the flat device segment table (`segment_count * SEGMENT_WORDS` u32s) for
79/// a batch of files at the given window geometry, the buffer the segmented
80/// megakernel binds and decodes a claim's `(file_idx, scan_start, emit_start,
81/// emit_end)` from. Row order matches [`plan_segments`], so `seg_idx` indexes it
82/// directly. See [`plan_segments`] for the soundness of the tiling.
83///
84/// # Panics
85/// Panics if `seg_len == 0` (via [`plan_segments`]).
86#[must_use]
87pub fn segment_table(file_lens: &[u32], seg_len: u32, overlap: u32) -> Vec<u32> {
88 let segments = plan_segments(file_lens, seg_len, overlap);
89 let mut words = Vec::with_capacity(segments.len() * SEGMENT_WORDS);
90 for seg in &segments {
91 words.extend_from_slice(&seg.abi_words());
92 }
93 words
94}
95
96/// Plan the scan windows for a batch of files.
97///
98/// `seg_len` is the owned (emit) width per window and MUST be positive;
99/// `overlap` is the warm-up width and should equal the catalog's longest pattern
100/// length so each window converges to the correct DFA state before its owned
101/// region. A file of length `L` yields `ceil(L / seg_len)` windows; a zero-length
102/// file yields none (it can match nothing).
103///
104/// # Panics
105/// Panics if `seg_len == 0` (a zero-width owned region cannot tile a file).
106#[must_use]
107pub fn plan_segments(file_lens: &[u32], seg_len: u32, overlap: u32) -> Vec<Segment> {
108 assert!(
109 seg_len > 0,
110 "segment owned-width (seg_len) must be positive"
111 );
112 let mut segments = Vec::new();
113 for (file_idx, &len) in file_lens.iter().enumerate() {
114 // File count is bounded to u32 upstream (FileMetadata::size_bytes etc.).
115 let file_idx = file_idx as u32;
116 let mut emit_start = 0u32;
117 while emit_start < len {
118 let emit_end = emit_start.saturating_add(seg_len).min(len);
119 let scan_start = emit_start.saturating_sub(overlap);
120 segments.push(Segment {
121 file_idx,
122 scan_start,
123 emit_start,
124 emit_end,
125 });
126 emit_start = emit_end;
127 }
128 }
129 segments
130}
131
132/// Number of windows `plan_segments` will produce for `file_lens` at `seg_len`,
133/// without allocating the table, for sizing the device work queue
134/// (`queue_len = segment_count * rule_count`).
135///
136/// # Panics
137/// Panics if `seg_len == 0`.
138#[must_use]
139pub fn segment_count(file_lens: &[u32], seg_len: u32) -> u64 {
140 assert!(
141 seg_len > 0,
142 "segment owned-width (seg_len) must be positive"
143 );
144 let seg_len = u64::from(seg_len);
145 file_lens
146 .iter()
147 .map(|&len| u64::from(len).div_ceil(seg_len))
148 .sum()
149}
150
151/// Dense byte-DFA columns per state in a [`BatchRuleProgram`] transition table
152/// (`transitions[state * 256 + byte] -> next_state`).
153const DFA_BYTE_COLUMNS: usize = 256;
154
155/// Maximum reachable off-diagonal product-automaton pairs [`dfa_sync_distance`]
156/// will explore before conservatively reporting "not provably bounded" (`None`).
157/// Bounds the analysis at ~`BUDGET * 256` edge steps per rule so a pathologically
158/// large DFA cannot stall catalog compilation; secret-token DFAs are orders of
159/// magnitude smaller and always analyze exactly.
160const PRODUCT_PAIR_BUDGET: usize = 1_000_000;
161
162/// Outcome of the [`dfa_sync_class`] synchronization-distance analysis. The three
163/// arms are operationally distinct for the caller's diagnostics: only
164/// [`SyncClass::Bounded`] is GPU-segmentable, but a [`SyncClass::UnboundedCycle`]
165/// (the DFA genuinely never re-synchronizes, e.g. a `.*` body that must remember
166/// unbounded context) can NEVER move to GPU, whereas a [`SyncClass::BudgetExceeded`]
167/// (the analysis hit [`PRODUCT_PAIR_BUDGET`] before proving bounded/unbounded)
168/// MIGHT segment with a larger budget, the catalog builder logs the split so the
169/// over-rejection from budget vs. true unbounded memory is never conflated.
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub enum SyncClass {
172 /// The DFA synchronizes within this many bytes; a warm-up `overlap >= d`
173 /// reconstructs the exact full-scan state at `emit_start`. GPU-segmentable
174 /// iff `d <= overlap`.
175 Bounded(u32),
176 /// A cycle of off-diagonal product pairs is reachable: two scans starting at
177 /// different states never provably converge ⇒ infinite memory ⇒ whole-file.
178 UnboundedCycle,
179 /// The reachable product automaton exceeded [`PRODUCT_PAIR_BUDGET`] before the
180 /// analysis terminated. Conservatively treated as not-segmentable, but
181 /// DISTINCT from `UnboundedCycle`: a larger budget could still prove it
182 /// bounded. Surfaced separately so the budget can be tuned to the real
183 /// distribution instead of silently capping recall onto the host path.
184 BudgetExceeded,
185}
186
187impl SyncClass {
188 /// The synchronization distance when proven bounded, else `None` (the legacy
189 /// [`dfa_sync_distance`] contract: `UnboundedCycle` and `BudgetExceeded` both
190 /// collapse to "not provably segmentable").
191 #[must_use]
192 pub const fn bounded(self) -> Option<u32> {
193 match self {
194 Self::Bounded(d) => Some(d),
195 Self::UnboundedCycle | Self::BudgetExceeded => None,
196 }
197 }
198}
199
200/// SOUNDNESS GATE for tuned segmentation: the DFA's finite-memory /
201/// **synchronization distance**: the smallest `O` such that for every state
202/// reachable from the start and every string `w` with `|w| >= O`, `δ*(0, w) ==
203/// δ*(q, w)`. After `O` common bytes the state no longer depends on where the
204/// scan started, so a window that warms up over `overlap >= O` bytes
205/// reconstructs the exact full-scan state at `emit_start`. Returns `None` when
206/// the distance is UNBOUNDED (infinite memory), the rule cannot be segmented
207/// and must be scanned whole-file (one segment), fail-safe and logged.
208///
209/// Why not "longest start→accept path": an unanchored search DFA self-loops at
210/// the start on non-matching bytes, so that path is always infinite, and
211/// branching/overlapping patterns make longest-path ≠ synchronization distance
212/// regardless (proved by the `*_sync_distance_*` tests). The correct analysis is
213/// a PAIRWISE PRODUCT automaton over UNORDERED state pairs `{a, b}` (the
214/// convergence relation is symmetric): a diagonal pair `{a, a}` is absorbing
215/// (the runs have met and stay met). Seeding from every reachable `{0, q}` and
216/// following common-byte transitions `{a,b} →_c {δ(a,c), δ(b,c)}`, the rule has
217/// finite memory iff NO cycle of off-diagonal pairs is reachable; then `O` is the
218/// longest off-diagonal path before the diagonal is first reached (0 when the
219/// only reachable seed is the diagonal `{0,0}`).
220///
221/// `transitions` is the dense `state * 256 + byte -> next_state` table of a
222/// `BatchRuleProgram` (`vyre_runtime::megakernel::rule_catalog`). The accept
223/// table is deliberately NOT a parameter: synchronization depends only on the
224/// transition structure, not on which states accept.
225///
226/// # Panics
227/// Panics if `transitions.len() < state_count * 256` (a malformed table the
228/// device decode would also read out of bounds); callers pass validated
229/// [`BatchRuleProgram`] tables.
230/// Thin wrapper over [`dfa_sync_class`] returning just the bounded distance
231/// (`None` for `UnboundedCycle` or `BudgetExceeded`). Callers that need to tell
232/// "genuinely unbounded" from "budget-capped", e.g. for diagnostics, call
233/// [`dfa_sync_class`] directly.
234#[must_use]
235pub fn dfa_sync_distance(transitions: &[u32], state_count: u32) -> Option<u32> {
236 dfa_sync_class(transitions, state_count).bounded()
237}
238
239/// Classify a dense byte-DFA by its bounded synchronization distance.
240///
241/// Returns [`SyncClass::Bounded`] when every reachable start state converges
242/// within a finite overlap, [`SyncClass::UnboundedCycle`] when some off-diagonal
243/// state pair can keep diverging forever, and [`SyncClass::BudgetExceeded`] when
244/// the product-automaton analysis exceeds its defensive work budget.
245///
246/// # Panics
247/// Panics if `transitions.len() < state_count * 256`.
248#[must_use]
249pub fn dfa_sync_class(transitions: &[u32], state_count: u32) -> SyncClass {
250 let n = state_count as usize;
251 if n <= 1 {
252 // 0 states: nothing to scan. 1 state: every byte self-loops, so all
253 // start states already coincide (synchronization distance 0).
254 return SyncClass::Bounded(0);
255 }
256 assert!(
257 transitions.len() >= n * DFA_BYTE_COLUMNS,
258 "transition table shorter than state_count * 256"
259 );
260 let delta = |s: usize, b: usize| -> usize {
261 let t = transitions[s * DFA_BYTE_COLUMNS + b] as usize;
262 // A malformed out-of-range target would index out of bounds on-device;
263 // clamp into range so the analysis stays total (it can only make the
264 // bound LARGER / more conservative, never spuriously "synchronized").
265 if t < n {
266 t
267 } else {
268 0
269 }
270 };
271
272 // States reachable from the start (the only states a real scan can be in,
273 // hence the only second coordinates worth seeding). Restricting to these
274 // avoids spurious off-diagonal cycles between unreachable states.
275 let mut reachable = vec![false; n];
276 reachable[0] = true;
277 let mut stack = vec![0usize];
278 while let Some(s) = stack.pop() {
279 for b in 0..DFA_BYTE_COLUMNS {
280 let t = delta(s, b);
281 if !reachable[t] {
282 reachable[t] = true;
283 stack.push(t);
284 }
285 }
286 }
287
288 // Canonical unordered off-diagonal pair key (lo < hi).
289 let key = |a: usize, b: usize| -> (usize, usize) {
290 if a < b {
291 (a, b)
292 } else {
293 (b, a)
294 }
295 };
296
297 // BFS the product automaton from every reachable seed {0, q}, collecting the
298 // reachable OFF-DIAGONAL pairs and their off-diagonal successor edges. A
299 // diagonal successor is a "terminal" exit (the runs have met).
300 use std::collections::HashMap;
301 let mut index: HashMap<(usize, usize), usize> = HashMap::new();
302 let mut off_succ: Vec<Vec<usize>> = Vec::new();
303 let mut frontier: Vec<(usize, usize)> = Vec::new();
304 let intern = |pair: (usize, usize),
305 index: &mut HashMap<(usize, usize), usize>,
306 off_succ: &mut Vec<Vec<usize>>,
307 frontier: &mut Vec<(usize, usize)>|
308 -> usize {
309 if let Some(&id) = index.get(&pair) {
310 return id;
311 }
312 let id = off_succ.len();
313 index.insert(pair, id);
314 off_succ.push(Vec::new());
315 frontier.push(pair);
316 id
317 };
318 for q in 0..n {
319 if reachable[q] && q != 0 {
320 intern(key(0, q), &mut index, &mut off_succ, &mut frontier);
321 }
322 }
323 let mut head = 0;
324 while head < frontier.len() {
325 // Conservative cost ceiling: the product automaton has up to `n^2 / 2`
326 // off-diagonal pairs, so a large DFA could explode the BFS. A rule whose
327 // reachable product exceeds the budget is treated as NOT provably
328 // bounded (return `None`), the caller scans it whole-file. This can only
329 // FORGO a segmentation opportunity, never produce an unsound one, so it
330 // is a safe (recall-preserving) ceiling, not a silent correctness
331 // fallback. Typical secret DFAs are far below it and analyze exactly.
332 if off_succ.len() > PRODUCT_PAIR_BUDGET {
333 return SyncClass::BudgetExceeded;
334 }
335 let (a, b) = frontier[head];
336 let id = head;
337 head += 1;
338 for byte in 0..DFA_BYTE_COLUMNS {
339 let na = delta(a, byte);
340 let nb = delta(b, byte);
341 if na == nb {
342 continue; // diagonal exit, the runs have synchronized
343 }
344 let succ = intern(key(na, nb), &mut index, &mut off_succ, &mut frontier);
345 off_succ[id].push(succ);
346 }
347 }
348 // Dedup successor edges so Kahn in-degrees / longest-path count each once.
349 for succs in &mut off_succ {
350 succs.sort_unstable();
351 succs.dedup();
352 }
353
354 let pair_count = off_succ.len();
355 if pair_count == 0 {
356 // Only the diagonal {0,0} was reachable as a seed ⇒ already synchronized.
357 return SyncClass::Bounded(0);
358 }
359
360 // Kahn topological sort over the off-diagonal subgraph; a remaining node ⇒ a
361 // cycle of off-diagonal pairs ⇒ unbounded memory ⇒ not segmentable.
362 let mut indegree = vec![0u32; pair_count];
363 for succs in &off_succ {
364 for &t in succs {
365 indegree[t] += 1;
366 }
367 }
368 let mut topo: Vec<usize> = Vec::new();
369 let mut queue: Vec<usize> = (0..pair_count).filter(|&p| indegree[p] == 0).collect();
370 while let Some(p) = queue.pop() {
371 topo.push(p);
372 for &t in &off_succ[p] {
373 indegree[t] -= 1;
374 if indegree[t] == 0 {
375 queue.push(t);
376 }
377 }
378 }
379 if topo.len() != pair_count {
380 return SyncClass::UnboundedCycle; // off-diagonal cycle ⇒ infinite memory
381 }
382
383 // Longest path to first diagonal, in reverse topological order so every
384 // off-diagonal successor is finalized before its predecessor. Each step is
385 // one byte; a pair with only diagonal successors has distance 1.
386 let mut dist = vec![0u32; pair_count];
387 for &p in topo.iter().rev() {
388 let mut best = 0u32;
389 for &t in &off_succ[p] {
390 best = best.max(dist[t]);
391 }
392 dist[p] = 1 + best;
393 }
394
395 // O = the worst seed's distance (diagonal seed {0,0} contributes 0).
396 let mut sync = 0u32;
397 for q in 0..n {
398 if reachable[q] && q != 0 {
399 if let Some(&id) = index.get(&key(0, q)) {
400 sync = sync.max(dist[id]);
401 }
402 }
403 }
404 SyncClass::Bounded(sync)
405}
406
407/// The minimum warm-up `overlap` that keeps intra-file segmentation EXACT for an
408/// entire rule catalog: the maximum [`dfa_sync_distance`] over every rule.
409/// Returns `None` when ANY rule has infinite memory (an unbounded-gap pattern)
410/// the catalog cannot be soundly segmented at a shorter window than the whole
411/// file, so the caller MUST fall back to one segment per file (and should log
412/// which rule forced it; that is a recall-preserving slow path, not a silent
413/// fallback). An empty catalog returns `Some(0)` (nothing to warm up).
414///
415/// This is the single value the host needs to pick a sound `seg_len`: any
416/// `seg_len` paired with `overlap >= this` produces byte-identical results to a
417/// dense whole-file scan (proved transitively by `dfa_sync_distance`'s
418/// `*_overlap_makes_segmentation_exact` proptest).
419#[must_use]
420pub fn catalog_sync_overlap(rules: &[vyre_runtime::megakernel::BatchRuleProgram]) -> Option<u32> {
421 let mut overlap = 0u32;
422 for rule in rules {
423 let sync = dfa_sync_distance(&rule.transitions, rule.state_count)?;
424 overlap = overlap.max(sync);
425 }
426 Some(overlap)
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432 use proptest::prelude::*;
433 use std::collections::BTreeSet;
434 use vyre_libs::scan::classic_ac::{classic_ac_compile, classic_ac_scan, ClassicAcAutomaton};
435
436 // ---- Model DFA for the dense-vs-segmented scan parity oracle ----
437 //
438 // The GPU kernel walks a byte DFA: `state = transitions[state][byte]`, and at
439 // each position emits the pattern ids accepted in the new state, reporting the
440 // match END offset. The whole GPU-OOM segmentation rests on ONE claim: scanning
441 // each window from state 0 over `[scan_start, emit_end)` and emitting only
442 // matches whose END is in `[emit_start, emit_end)` yields the EXACT same
443 // (pattern, end) set as one dense full-buffer scan, provided the warm-up
444 // `overlap` is at least the longest pattern. This models that DFA with a
445 // multi-pattern Aho-Corasick over short byte literals and proves the claim on
446 // real automata (not just the offset tiling), so the WGSL kernel can mirror it.
447
448 /// Minimal Aho-Corasick byte DFA: `goto`/`fail`/`out` over a literal set.
449 struct AcDfa {
450 goto: Vec<[i32; 256]>, // -1 = no edge
451 fail: Vec<usize>,
452 out: Vec<Vec<usize>>, // pattern ids accepted at this state
453 max_len: u32,
454 }
455
456 impl AcDfa {
457 fn build(patterns: &[&[u8]]) -> Self {
458 let mut goto = vec![[-1i32; 256]];
459 let mut out: Vec<Vec<usize>> = vec![Vec::new()];
460 let mut max_len = 0u32;
461 for (pid, pat) in patterns.iter().enumerate() {
462 max_len = max_len.max(pat.len() as u32);
463 let mut s = 0usize;
464 for &b in pat.iter() {
465 let nx = goto[s][b as usize];
466 if nx == -1 {
467 let new = goto.len();
468 goto.push([-1i32; 256]);
469 out.push(Vec::new());
470 goto[s][b as usize] = new as i32;
471 s = new;
472 } else {
473 s = nx as usize;
474 }
475 }
476 out[s].push(pid);
477 }
478 // BFS failure links.
479 let mut fail = vec![0usize; goto.len()];
480 let mut queue = std::collections::VecDeque::new();
481 for b in 0..256 {
482 let t = goto[0][b];
483 if t > 0 {
484 fail[t as usize] = 0;
485 queue.push_back(t as usize);
486 } else if t == -1 {
487 goto[0][b] = 0;
488 }
489 }
490 while let Some(r) = queue.pop_front() {
491 for b in 0..256 {
492 let t = goto[r][b];
493 if t == -1 {
494 continue;
495 }
496 let t = t as usize;
497 queue.push_back(t);
498 let mut f = fail[r];
499 while goto[f][b] == -1 {
500 f = fail[f];
501 }
502 fail[t] = goto[f][b] as usize;
503 let merged = out[fail[t]].clone();
504 out[t].extend(merged);
505 }
506 }
507 AcDfa {
508 goto,
509 fail,
510 out,
511 max_len,
512 }
513 }
514
515 /// Step the failure-function DFA: from `state`, consume `byte`.
516 fn step(&self, mut state: usize, byte: u8) -> usize {
517 while self.goto[state][byte as usize] == -1 {
518 state = self.fail[state];
519 }
520 self.goto[state][byte as usize] as usize
521 }
522 }
523
524 /// Dense full-buffer scan: (pattern_id, end_offset) for every match.
525 fn dense_scan(dfa: &AcDfa, text: &[u8]) -> BTreeSet<(usize, usize)> {
526 let mut hits = BTreeSet::new();
527 let mut state = 0usize;
528 for (i, &b) in text.iter().enumerate() {
529 state = dfa.step(state, b);
530 for &pid in &dfa.out[state] {
531 hits.insert((pid, i + 1)); // end = i+1
532 }
533 }
534 hits
535 }
536
537 /// Segmented scan: exactly what the GPU kernel will do, each window scans
538 /// `[scan_start, emit_end)` from state 0 and emits only matches whose END is
539 /// in `[emit_start, emit_end)`.
540 fn segmented_scan(
541 dfa: &AcDfa,
542 text: &[u8],
543 seg_len: u32,
544 overlap: u32,
545 ) -> BTreeSet<(usize, usize)> {
546 let mut hits = BTreeSet::new();
547 for seg in plan_segments(&[text.len() as u32], seg_len, overlap) {
548 let mut state = 0usize;
549 for i in seg.scan_start..seg.emit_end {
550 state = dfa.step(state, text[i as usize]);
551 let end = i + 1;
552 if end > seg.emit_start && end <= seg.emit_end {
553 for &pid in &dfa.out[state] {
554 hits.insert((pid, end as usize));
555 }
556 }
557 }
558 }
559 hits
560 }
561
562 /// Materialize an [`AcDfa`] into the dense `state * 256 + byte -> next_state`
563 /// transition table the device (and [`dfa_sync_distance`]) consume, mirroring
564 /// `BatchRuleProgram`. Returns `(transitions, state_count)`.
565 fn materialize_dense(dfa: &AcDfa) -> (Vec<u32>, u32) {
566 let n = dfa.goto.len();
567 let mut transitions = vec![0u32; n * 256];
568 for s in 0..n {
569 for b in 0..256usize {
570 transitions[s * 256 + b] = dfa.step(s, b as u8) as u32;
571 }
572 }
573 (transitions, n as u32)
574 }
575
576 /// Brute-force δ*: run the dense table from `start` over `w`.
577 fn run_dense(transitions: &[u32], start: usize, w: &[u8]) -> usize {
578 let mut s = start;
579 for &b in w {
580 s = transitions[s * 256 + b as usize] as usize;
581 }
582 s
583 }
584
585 #[test]
586 fn sync_distance_single_byte_literal_is_one() {
587 // Unanchored "a": after one byte both runs agree (saw 'a' or didn't).
588 let dfa = AcDfa::build(&[b"a"]);
589 let (transitions, n) = materialize_dense(&dfa);
590 assert_eq!(dfa_sync_distance(&transitions, n), Some(1));
591 }
592
593 #[test]
594 fn sync_distance_two_byte_literal_is_two() {
595 // Unanchored "ab": the pair {start, saw-a} only resolves after the 2nd
596 // byte (hand-traced in the dfa_sync_distance doc reasoning).
597 let dfa = AcDfa::build(&[b"ab"]);
598 let (transitions, n) = materialize_dense(&dfa);
599 assert_eq!(dfa_sync_distance(&transitions, n), Some(2));
600 }
601
602 #[test]
603 fn sync_distance_trivial_single_state_is_zero() {
604 // One state, every byte self-loops: all start states already coincide.
605 let transitions = vec![0u32; 256];
606 assert_eq!(dfa_sync_distance(&transitions, 1), Some(0));
607 }
608
609 #[test]
610 fn sync_distance_unbounded_gap_pattern_is_none() {
611 // "a.*b": state 0 (no 'a' yet), 1 (seen 'a', waiting 'b'), 2 (matched).
612 // The pair {0,1} self-loops on any byte that is neither 'a' nor 'b'
613 // (0→0, 1→1), an off-diagonal cycle, so the 'a' and 'b' can be
614 // arbitrarily far apart and the rule has infinite memory.
615 let mut t = vec![0u32; 3 * 256];
616 for b in 0..256usize {
617 t[0 * 256 + b] = 0; // stay at start
618 t[1 * 256 + b] = 1; // stay "seen a"
619 t[2 * 256 + b] = 2; // absorbing accept
620 }
621 t[0 * 256 + b'a' as usize] = 1;
622 t[1 * 256 + b'b' as usize] = 2;
623 assert_eq!(dfa_sync_distance(&t, 3), None);
624 }
625
626 #[test]
627 fn sync_distance_parity_dfa_is_none() {
628 // Even/odd count of 'a': {even,odd} maps to {odd,even} on 'a', an
629 // off-diagonal 2-cycle (so the start state is never forgotten).
630 let mut t = vec![0u32; 2 * 256];
631 for b in 0..256usize {
632 t[0 * 256 + b] = 0;
633 t[1 * 256 + b] = 1;
634 }
635 t[0 * 256 + b'a' as usize] = 1;
636 t[1 * 256 + b'a' as usize] = 0;
637 assert_eq!(dfa_sync_distance(&t, 2), None);
638 }
639
640 #[test]
641 fn sync_class_distinguishes_cycle_from_bounded() {
642 // The diagnostic split the catalog builder relies on: a genuinely
643 // unbounded DFA classifies as `UnboundedCycle` (NEVER segmentable,
644 // regardless of budget), while a bounded literal classifies as
645 // `Bounded(d)` with the exact distance. `BudgetExceeded` is the third,
646 // distinct arm (a larger budget might still prove it bounded), the
647 // builder logs all three separately so budget-capping is never conflated
648 // with true infinite memory.
649
650 // "a.*b" off-diagonal self-loop ⇒ UnboundedCycle, not BudgetExceeded.
651 let mut gap = vec![0u32; 3 * 256];
652 for b in 0..256usize {
653 gap[b] = 0;
654 gap[256 + b] = 1;
655 gap[512 + b] = 2;
656 }
657 gap[b'a' as usize] = 1;
658 gap[256 + b'b' as usize] = 2;
659 assert_eq!(dfa_sync_class(&gap, 3), SyncClass::UnboundedCycle);
660
661 // Two-byte literal "ab" ⇒ Bounded(2) (matches the wrapper's value).
662 let dfa = AcDfa::build(&[b"ab"]);
663 let (transitions, n) = materialize_dense(&dfa);
664 assert_eq!(dfa_sync_class(&transitions, n), SyncClass::Bounded(2));
665 assert_eq!(dfa_sync_class(&transitions, n).bounded(), Some(2));
666
667 // Single-state DFA ⇒ Bounded(0) (already synchronized).
668 assert_eq!(dfa_sync_class(&vec![0u32; 256], 1), SyncClass::Bounded(0));
669 }
670
671 /// Build a `BatchRuleProgram` from a literal-set AC DFA, for catalog tests.
672 fn rule_from_patterns(
673 rule_idx: u32,
674 pats: &[&[u8]],
675 ) -> vyre_runtime::megakernel::BatchRuleProgram {
676 let dfa = AcDfa::build(pats);
677 let (transitions, n) = materialize_dense(&dfa);
678 let mut accept = vec![0u32; n as usize];
679 for s in 0..n as usize {
680 if !dfa.out[s].is_empty() {
681 accept[s] = 1;
682 }
683 }
684 vyre_runtime::megakernel::BatchRuleProgram::new(rule_idx, transitions, accept, n)
685 .expect("materialized AC DFA is a valid rule program")
686 }
687
688 #[test]
689 fn catalog_sync_overlap_is_max_over_rules() {
690 // "ab" needs overlap 2, "abc" needs 3 ⇒ the catalog needs 3.
691 let r_ab = rule_from_patterns(0, &[b"ab"]);
692 let r_abc = rule_from_patterns(1, &[b"abc"]);
693 assert_eq!(catalog_sync_overlap(&[r_ab, r_abc]), Some(3));
694 // Empty catalog: nothing to warm up.
695 assert_eq!(catalog_sync_overlap(&[]), Some(0));
696 }
697
698 #[test]
699 fn catalog_sync_overlap_is_none_if_any_rule_unbounded() {
700 // A bounded rule plus one infinite-memory "a.*b" rule ⇒ the whole catalog
701 // cannot be segmented (the caller must scan whole-file, fail-safe).
702 let r_ok = rule_from_patterns(0, &[b"ab"]);
703 let mut t_bad = vec![0u32; 3 * 256];
704 for b in 0..256usize {
705 t_bad[1 * 256 + b] = 1;
706 t_bad[2 * 256 + b] = 2;
707 }
708 t_bad[0 * 256 + b'a' as usize] = 1;
709 t_bad[1 * 256 + b'b' as usize] = 2;
710 let r_bad = vyre_runtime::megakernel::BatchRuleProgram::new(1, t_bad, vec![0, 0, 1], 3)
711 .expect("valid infinite-memory rule program");
712 assert_eq!(catalog_sync_overlap(&[r_ok, r_bad]), None);
713 }
714
715 proptest! {
716 /// THE soundness link: for a random multi-pattern AC DFA, the computed
717 /// synchronization distance `O` is a SUFFICIENT warm-up, a segmented
718 /// scan with `overlap = O` produces the EXACT dense match set for ANY
719 /// segment width. This is what licenses tuning `seg_len` below the file
720 /// length: `dfa_sync_distance` tells the host the minimum overlap that
721 /// keeps segmentation exact. (AC DFAs over bounded literals always have
722 /// finite memory, so `O` is always `Some`.)
723 #[test]
724 fn sync_distance_overlap_makes_segmentation_exact(
725 patterns in proptest::collection::vec(
726 proptest::collection::vec(b'a'..=b'd', 1..=6), 1..=4),
727 text in proptest::collection::vec(b'a'..=b'd', 0..400),
728 seg_len in 1u32..64,
729 ) {
730 let pat_refs: Vec<&[u8]> = patterns.iter().map(|p| p.as_slice()).collect();
731 let dfa = AcDfa::build(&pat_refs);
732 let (transitions, n) = materialize_dense(&dfa);
733 let sync = dfa_sync_distance(&transitions, n)
734 .expect("a bounded-literal AC DFA has finite memory");
735 // The distance can never exceed the longest pattern (the AC state
736 // depends only on that many trailing bytes).
737 prop_assert!(sync <= dfa.max_len, "sync {} > max_len {}", sync, dfa.max_len);
738 prop_assert_eq!(
739 segmented_scan(&dfa, &text, seg_len, sync),
740 dense_scan(&dfa, &text),
741 "overlap = sync_distance({}) must make seg_len={} exact", sync, seg_len
742 );
743 }
744
745 /// Directly the synchronization property the bound promises: after `O`
746 /// common bytes, the state is independent of the (reachable) start state.
747 #[test]
748 fn sync_distance_converges_from_every_reachable_start(
749 patterns in proptest::collection::vec(
750 proptest::collection::vec(b'a'..=b'c', 1..=5), 1..=3),
751 tail in proptest::collection::vec(b'a'..=b'c', 0..6),
752 prefix in proptest::collection::vec(b'a'..=b'c', 0..8),
753 ) {
754 let pat_refs: Vec<&[u8]> = patterns.iter().map(|p| p.as_slice()).collect();
755 let dfa = AcDfa::build(&pat_refs);
756 let (transitions, n) = materialize_dense(&dfa);
757 let sync = dfa_sync_distance(&transitions, n).expect("finite memory") as usize;
758 // q = any state reachable by reading `prefix` from the start; w is a
759 // string of length >= sync. Reading w from state 0 and from state q
760 // must land in the same state.
761 let q = run_dense(&transitions, 0, &prefix);
762 let mut w = tail.clone();
763 while w.len() < sync {
764 w.push(b'a');
765 }
766 prop_assert_eq!(
767 run_dense(&transitions, 0, &w),
768 run_dense(&transitions, q, &w),
769 "states diverged after sync={} common bytes (|w|={})", sync, w.len()
770 );
771 }
772 }
773
774 #[test]
775 fn segmented_scan_matches_dense_on_a_known_case() {
776 let dfa = AcDfa::build(&[b"aws", b"key", b"secret"]);
777 let text = b"my aws key is secret and the aws secret key follows";
778 // overlap >= max pattern len (6) guarantees parity.
779 assert_eq!(
780 segmented_scan(&dfa, text, 4, 6),
781 dense_scan(&dfa, text),
782 "segmented scan must equal dense scan with adequate warm-up"
783 );
784 }
785
786 #[test]
787 fn tiles_a_single_file_contiguously() {
788 // 1000 bytes, owned width 256, warm-up 64 -> 4 windows tiling [0,1000).
789 let segs = plan_segments(&[1000], 256, 64);
790 assert_eq!(segs.len(), 4);
791 assert_eq!(
792 segs,
793 vec![
794 Segment {
795 file_idx: 0,
796 scan_start: 0,
797 emit_start: 0,
798 emit_end: 256
799 },
800 Segment {
801 file_idx: 0,
802 scan_start: 192,
803 emit_start: 256,
804 emit_end: 512
805 },
806 Segment {
807 file_idx: 0,
808 scan_start: 448,
809 emit_start: 512,
810 emit_end: 768
811 },
812 Segment {
813 file_idx: 0,
814 scan_start: 704,
815 emit_start: 768,
816 emit_end: 1000
817 },
818 ]
819 );
820 // Warm-up never reaches below 0, and every window scans at least its
821 // owned region plus (up to) `overlap` bytes of context.
822 assert_eq!(segs[0].scan_len(), 256); // first window has no room to warm up
823 assert_eq!(segs[1].scan_len(), 256 + 64);
824 }
825
826 #[test]
827 fn short_file_is_one_window_covering_everything() {
828 let segs = plan_segments(&[100], 512, 64);
829 assert_eq!(
830 segs,
831 vec![Segment {
832 file_idx: 0,
833 scan_start: 0,
834 emit_start: 0,
835 emit_end: 100
836 }]
837 );
838 }
839
840 #[test]
841 fn zero_length_file_yields_no_window() {
842 assert!(plan_segments(&[0], 256, 64).is_empty());
843 // ...and is skipped between real files without shifting their indices.
844 let segs = plan_segments(&[10, 0, 10], 256, 0);
845 assert_eq!(
846 segs.iter().map(|s| s.file_idx).collect::<Vec<_>>(),
847 vec![0, 2]
848 );
849 }
850
851 #[test]
852 fn overlap_zero_means_scan_equals_emit() {
853 let segs = plan_segments(&[800], 256, 0);
854 for s in &segs {
855 assert_eq!(s.scan_start, s.emit_start);
856 assert_eq!(s.scan_len(), s.emit_len());
857 }
858 }
859
860 #[test]
861 fn segment_count_matches_planned_len() {
862 let lens = [0u32, 1, 255, 256, 257, 4096, 8 * 1024 * 1024];
863 assert_eq!(
864 segment_count(&lens, 512),
865 plan_segments(&lens, 512, 64).len() as u64
866 );
867 }
868
869 #[test]
870 fn segment_table_flattens_planned_segments_in_order() {
871 let lens = [1000u32, 100];
872 let segs = plan_segments(&lens, 256, 64);
873 let table = segment_table(&lens, 256, 64);
874 // One row of SEGMENT_WORDS per planned segment, same order.
875 assert_eq!(table.len(), segs.len() * SEGMENT_WORDS);
876 for (i, seg) in segs.iter().enumerate() {
877 let row = &table[i * SEGMENT_WORDS..(i + 1) * SEGMENT_WORDS];
878 assert_eq!(row, seg.abi_words(), "segment {i} ABI words mismatch");
879 // Decode order is exactly [file_idx, scan_start, emit_start, emit_end].
880 assert_eq!(row[0], seg.file_idx);
881 assert_eq!(row[1], seg.scan_start);
882 assert_eq!(row[2], seg.emit_start);
883 assert_eq!(row[3], seg.emit_end);
884 }
885 }
886
887 #[test]
888 fn segment_table_first_row_is_file0_offset0() {
889 // The first window of the first file always owns offset 0 with no warm-up.
890 let table = segment_table(&[4096], 512, 64);
891 assert_eq!(&table[..SEGMENT_WORDS], &[0, 0, 0, 512]);
892 }
893
894 proptest! {
895 /// THE core soundness proof of the whole GPU-OOM approach, on REAL
896 /// automata: over a random multi-pattern AC DFA and random text, a
897 /// segmented scan with `overlap >= max_pattern_len` produces the EXACT
898 /// same (pattern, end) match set as a dense full-buffer scan, for ANY
899 /// segment width. A failure here means the kernel's emit-guard / warm-up
900 /// would drop or duplicate a real match.
901 #[test]
902 fn segmented_scan_equals_dense_with_adequate_overlap(
903 // 1-4 patterns of 1-6 bytes over a small alphabet (so matches are dense).
904 patterns in proptest::collection::vec(
905 proptest::collection::vec(b'a'..=b'd', 1..=6), 1..=4),
906 text in proptest::collection::vec(b'a'..=b'd', 0..400),
907 seg_len in 1u32..64,
908 extra_overlap in 0u32..8,
909 ) {
910 let pat_refs: Vec<&[u8]> = patterns.iter().map(|p| p.as_slice()).collect();
911 let dfa = AcDfa::build(&pat_refs);
912 // Adequate warm-up = longest pattern (+ slack); this is the kernel's
913 // contract (overlap is sized from the catalog's max pattern length).
914 let overlap = dfa.max_len + extra_overlap;
915 prop_assert_eq!(
916 segmented_scan(&dfa, &text, seg_len, overlap),
917 dense_scan(&dfa, &text),
918 "segmented (seg_len={}, overlap={}) != dense", seg_len, overlap
919 );
920 }
921
922 /// The flat table is exactly the planned segments' ABI words concatenated,
923 /// and every emit_start/emit_end pair stays within its file length, the
924 /// device decode can never read past the packed haystack for that file.
925 #[test]
926 fn segment_table_rows_are_in_bounds(
927 lens in proptest::collection::vec(0u32..4000, 0..5),
928 seg_len in 1u32..512,
929 overlap in 0u32..128,
930 ) {
931 let table = segment_table(&lens, seg_len, overlap);
932 prop_assert_eq!(table.len() % SEGMENT_WORDS, 0);
933 for row in table.chunks_exact(SEGMENT_WORDS) {
934 let (file_idx, scan_start, emit_start, emit_end) =
935 (row[0], row[1], row[2], row[3]);
936 let file_len = lens[file_idx as usize];
937 prop_assert!(scan_start <= emit_start);
938 prop_assert!(emit_start < emit_end);
939 prop_assert!(emit_end <= file_len, "row reads past file end");
940 }
941 }
942 }
943
944 proptest! {
945 /// THE soundness oracle: per file, the windows' emit ranges tile
946 /// `[0, file_len)` exactly, start at 0, end at len, contiguous, gap-free,
947 /// disjoint, and each window's scan range is its emit range widened by
948 /// exactly `min(overlap, emit_start)` of warm-up. A regression here breaks
949 /// recall (a gap drops matches) or precision (an overlap double-counts).
950 #[test]
951 fn emit_ranges_tile_each_file_exactly(
952 lens in proptest::collection::vec(0u32..5000, 0..6),
953 seg_len in 1u32..1024,
954 overlap in 0u32..256,
955 ) {
956 let segs = plan_segments(&lens, seg_len, overlap);
957 for (file_idx, &len) in lens.iter().enumerate() {
958 let fsegs: Vec<&Segment> =
959 segs.iter().filter(|s| s.file_idx == file_idx as u32).collect();
960
961 if len == 0 {
962 prop_assert!(fsegs.is_empty(), "zero-length file must yield no window");
963 continue;
964 }
965
966 prop_assert_eq!(fsegs.first().unwrap().emit_start, 0, "first window must own offset 0");
967 prop_assert_eq!(fsegs.last().unwrap().emit_end, len, "last window must reach file end");
968
969 let mut cursor = 0u32;
970 for s in &fsegs {
971 // contiguous + gap-free + disjoint emit tiling
972 prop_assert_eq!(s.emit_start, cursor, "gap or overlap between windows");
973 prop_assert!(s.emit_end > s.emit_start, "empty owned range");
974 prop_assert!(s.emit_end <= len, "window owns past file end");
975 // warm-up: scan starts exactly `min(overlap, emit_start)` earlier,
976 // clamped at 0, and the scan range ends at the emit end.
977 prop_assert_eq!(s.scan_start, s.emit_start.saturating_sub(overlap));
978 prop_assert!(s.scan_start <= s.emit_start, "warm-up cannot start after owned region");
979 prop_assert!(s.scan_len() >= s.emit_len(), "scan must cover the owned region");
980 cursor = s.emit_end;
981 }
982 prop_assert_eq!(cursor, len, "emit ranges must cover [0,len) with no remainder");
983
984 // window count == ceil(len / seg_len)
985 prop_assert_eq!(fsegs.len() as u32, len.div_ceil(seg_len));
986 }
987 }
988
989 /// Every byte offset in `[0, file_len)` is owned by exactly one window
990 /// (the dual of gap-free+disjoint, asserted pointwise on small files).
991 #[test]
992 fn every_offset_owned_exactly_once(
993 len in 1u32..600,
994 seg_len in 1u32..200,
995 overlap in 0u32..128,
996 ) {
997 let segs = plan_segments(&[len], seg_len, overlap);
998 for pos in 0..len {
999 let owners = segs
1000 .iter()
1001 .filter(|s| pos >= s.emit_start && pos < s.emit_end)
1002 .count();
1003 prop_assert_eq!(owners, 1, "offset {} owned by {} windows, expected 1", pos, owners);
1004 }
1005 }
1006 }
1007
1008 // ---- Production-path COMBINED multi-pattern AC segmented oracle ----
1009 //
1010 // The oracle above (`segmented_scan` over the local `AcDfa`) proves the
1011 // offset tiling + emit-guard are sound for a model automaton. This second
1012 // oracle proves the SAME tiling is sound for the *production* combined
1013 // automaton the GPU megakernel will actually run: ONE `classic_ac_compile`
1014 // `CompiledDfa` over ALL patterns (dense `state*256+byte` transitions + the
1015 // flat `output_offsets`/`output_records` multi-emit), NOT N per-rule DFAs.
1016 //
1017 // This is the soundness contract the COMBINED-automaton kernel mirrors: a
1018 // single linear pass (`classic_ac_scan`) over the whole buffer must produce
1019 // the EXACT same `(pattern_id, end)` set as the union over `plan_segments`
1020 // windows, each walked from state 0 over `[scan_start, emit_end)` and
1021 // emitting only matches whose end byte index lies in `[emit_start, emit_end)`,
1022 // provided `overlap >= max_pattern_len`. Collapsing the geometry from
1023 // `(seg, rule)` to `(seg)` + this multi-emit is what removes the per-rule
1024 // brute-force multiplier that makes the megakernel lose to Hyperscan on a
1025 // literal-rich catalog (docs/GPU_OOM_SEGMENTATION.md END-TO-END finding).
1026
1027 /// Combined-AC segmented scan over the production `CompiledDfa`. `end` is the
1028 /// 0-based byte index where the match ends, the SAME convention as
1029 /// [`classic_ac_scan`] (NOT the `i+1` convention of the model `segmented_scan`
1030 /// above), so the two are directly comparable.
1031 fn combined_segmented_scan(
1032 ac: &ClassicAcAutomaton,
1033 text: &[u8],
1034 seg_len: u32,
1035 overlap: u32,
1036 ) -> BTreeSet<(u32, u32)> {
1037 let dfa = &ac.dfa;
1038 let mut hits = BTreeSet::new();
1039 for seg in plan_segments(&[text.len() as u32], seg_len, overlap) {
1040 let mut state = 0u32;
1041 for i in seg.scan_start..seg.emit_end {
1042 state = dfa.transitions
1043 [(state as usize) * DFA_BYTE_COLUMNS + text[i as usize] as usize];
1044 // The loop bound already enforces `i < emit_end`; the window owns
1045 // a match ending at byte index `i` iff `i >= emit_start`. Bytes in
1046 // the `[scan_start, emit_start)` warm-up prefix advance state but
1047 // emit nothing, so windows tile the file with no miss / no dup.
1048 if i >= seg.emit_start {
1049 let begin = dfa.output_offsets[state as usize] as usize;
1050 let end = dfa.output_offsets[state as usize + 1] as usize;
1051 for &pattern_id in &dfa.output_records[begin..end] {
1052 hits.insert((pattern_id, i));
1053 }
1054 }
1055 }
1056 }
1057 hits
1058 }
1059
1060 #[test]
1061 fn combined_segmented_equals_linear_on_overlapping_patterns() {
1062 // he/she/his/hers on "ushers", the canonical multi-emit case (one accept
1063 // state emits both "he" and "she" via failure links). A combined segmented
1064 // scan with overlap >= max_pattern_len must reproduce the full linear set.
1065 let ac = classic_ac_compile(&[b"he", b"she", b"his", b"hers"]);
1066 let text = b"ushers";
1067 let overlap = ac.dfa.max_pattern_len; // 4 ("hers"/"she")
1068 let linear: BTreeSet<(u32, u32)> = classic_ac_scan(&ac, text).into_iter().collect();
1069 // Cross-check the linear oracle itself first (guards against a silent
1070 // regression in classic_ac_scan). In "ushers" (u0 s1 h2 e3 r4 s5),
1071 // classic_ac_scan reports the END byte index: "she"(pid1) and "he"(pid0)
1072 // both end at e=index 3; "hers"(pid3) ends at the final s=index 5.
1073 assert!(
1074 linear.contains(&(1, 3)),
1075 "linear must contain she@3: {linear:?}"
1076 );
1077 assert!(
1078 linear.contains(&(0, 3)),
1079 "linear must contain he@3: {linear:?}"
1080 );
1081 assert!(
1082 linear.contains(&(3, 5)),
1083 "linear must contain hers@5: {linear:?}"
1084 );
1085 // Every small segment width must reproduce the linear set exactly.
1086 for seg_len in 1u32..=8 {
1087 let segmented = combined_segmented_scan(&ac, text, seg_len, overlap);
1088 assert_eq!(
1089 segmented, linear,
1090 "combined segmented (seg_len={seg_len}, overlap={overlap}) != linear classic_ac_scan"
1091 );
1092 }
1093 }
1094
1095 proptest! {
1096 /// THE soundness proof for the COMBINED-automaton megakernel path: over a
1097 /// random multi-pattern set and random text, the production combined AC
1098 /// (`classic_ac_compile`) scanned in `plan_segments` windows with
1099 /// `overlap >= max_pattern_len` and the `output_records` multi-emit
1100 /// produces EXACTLY the linear `classic_ac_scan` `(pattern_id, end)` set,
1101 /// for ANY segment width. This is what licenses collapsing the kernel
1102 /// geometry from `(seg, rule)` (N per-rule passes) to `(seg)` (one combined
1103 /// pass) without dropping or duplicating a single match.
1104 #[test]
1105 fn combined_segmented_equals_linear_classic_ac_scan(
1106 patterns in proptest::collection::vec(
1107 proptest::collection::vec(b'a'..=b'd', 1..=6), 1..=5),
1108 text in proptest::collection::vec(b'a'..=b'd', 0..400),
1109 seg_len in 1u32..64,
1110 extra_overlap in 0u32..8,
1111 ) {
1112 let pat_refs: Vec<&[u8]> = patterns.iter().map(|p| p.as_slice()).collect();
1113 let ac = classic_ac_compile(&pat_refs);
1114 // The combined automaton's AC synchronization distance is <= its
1115 // longest pattern, so max_pattern_len (+ slack) is a sufficient warm-up.
1116 let overlap = ac.dfa.max_pattern_len + extra_overlap;
1117 let linear: BTreeSet<(u32, u32)> = classic_ac_scan(&ac, &text).into_iter().collect();
1118 let segmented = combined_segmented_scan(&ac, &text, seg_len, overlap);
1119 prop_assert_eq!(
1120 segmented, linear,
1121 "combined segmented (seg_len={}, overlap={}) != linear", seg_len, overlap
1122 );
1123 }
1124 }
1125}