Skip to main content

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}