Skip to main content

vyre_libs/scan/
regex_dfa.rs

1//! Regex set → dense DFA GPU pipeline.
2//!
3//! Composes three existing primitives end-to-end:
4//!
5//! 1. `compile_regex_set` (this crate) - regex sources → bit-vector NFA.
6//! 2. `vyre_primitives::matching::nfa_to_dfa` - NFA → dense
7//!    `CompiledDfa` via subset construction.
8//! 3. `build_ac_bounded_ranges_program` (this crate) - `CompiledDfa`
9//!    → GPU `Program` with O(1)-per-byte AC scan semantics.
10//!
11//! The output [`RegexDfaPipeline`] is the regex-aware counterpart of
12//! the literal-pattern `ClassicAcAutomaton`: same `Program` shape, same
13//! per-byte transition cost, same `(pid, start, end)` hit triple
14//! output. Consumers that already dispatch
15//! `classic_ac_bounded_ranges_program` can swap their literal pattern
16//! source for a regex set and pay no extra per-byte cost on the GPU.
17//!
18//! # When this beats `RulePipeline`
19//!
20//! `RulePipeline` (the bit-vector NFA scan kernel) is O(LANES²) per
21//! byte regardless of pattern set size - fine for short-buffer per-
22//! anchor confirmation, catastrophic for whole-buffer scans at >1 MiB.
23//! `RegexDfaPipeline` is O(1) per byte but pays a state-explosion risk
24//! in the subset construction. Use it when:
25//!
26//! * The pattern set's subset-constructed DFA stays under
27//!   `max_dfa_states` (typical for high-volume detector regexes:
28//!   bounded character classes + bounded repetitions).
29//! * The haystack is large enough that per-byte cost dominates per-
30//!   dispatch overhead.
31//!
32//! Fall back to `RulePipeline` when the subset construction returns
33//! [`vyre_primitives::matching::NfaToDfaError::StateExplosion`].
34
35use std::error::Error;
36use std::fmt;
37
38use vyre_foundation::ir::Program;
39
40use vyre_primitives::matching::{nfa_to_dfa, CompiledDfa, NfaTables, NfaToDfaError};
41
42use crate::scan::classic_ac::try_build_ac_bounded_ranges_program_ext;
43use crate::scan::regex_compile::{compile_regex_set, CompiledRegexSet, RegexCompileError};
44
45/// Ready-to-dispatch regex DFA pipeline.
46///
47/// `program` is built by `build_ac_bounded_ranges_program` and has the
48/// same buffer contract as a literal-AC dispatch: `haystack`,
49/// `transitions`, `output_offsets`, `output_records`, `pattern_lengths`,
50/// `haystack_len`, `match_count`, `matches`. Upload the corresponding
51/// fields of [`RegexDfaPipeline::dfa`] and [`RegexDfaPipeline::pattern_lengths`]
52/// to those buffers and dispatch.
53#[derive(Debug, Clone)]
54pub struct RegexDfaPipeline {
55    /// Dispatchable GPU program. Same shape as
56    /// `classic_ac_bounded_ranges_program` output - caller wires the
57    /// existing AC kernel host-side, no new dispatch path needed.
58    pub program: Program,
59    /// Dense DFA produced by NFA → DFA subset construction. Owns the
60    /// transition / accept / output_offsets / output_records buffers
61    /// the GPU program reads from.
62    pub dfa: CompiledDfa,
63    /// One entry per input regex; `pattern_lengths[pid]` is the longest
64    /// possible match length for that pattern. Uploaded to the
65    /// `pattern_lengths` buffer the AC kernel uses to derive each
66    /// match's `start` offset from the accepting `end` offset.
67    pub pattern_lengths: Vec<u32>,
68}
69
70/// Failures from [`build_regex_dfa_pipeline`].
71#[derive(Debug)]
72#[non_exhaustive]
73pub enum RegexDfaError {
74    /// Regex parsing or NFA construction rejected a pattern.
75    Compile(RegexCompileError),
76    /// Subset construction couldn't lower the NFA - typically state
77    /// explosion. The caller should either raise `max_dfa_states`,
78    /// shard the pattern set, or fall back to `RulePipeline`.
79    Lower(NfaToDfaError),
80    /// Regex/DFA metadata exceeded the GPU program's u32 ABI or host-side
81    /// staging allocation budget.
82    Size {
83        /// Actionable sizing diagnostic.
84        message: String,
85    },
86}
87
88impl fmt::Display for RegexDfaError {
89    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
90        match self {
91            Self::Compile(error) => write!(formatter, "regex NFA compile failed: {error}"),
92            Self::Lower(error) => {
93                write!(formatter, "NFA → DFA subset construction failed: {error}")
94            }
95            Self::Size { message } => write!(formatter, "regex DFA sizing failed: {message}"),
96        }
97    }
98}
99
100impl RegexDfaError {
101    /// The canonical `REGEX_UNSUPPORTED_DIAGNOSTICS.toml` diagnostic code for
102    /// this pipeline error, forwarded from the inner [`RegexCompileError`] when
103    /// the failure is an unsupported construct, else `None`. Lets a consumer of
104    /// the higher-level pipeline builder route on the same registry code as the
105    /// low-level `compile_regex_set` path (one owner for the mapping).
106    #[must_use]
107    pub fn diagnostic_code(&self) -> Option<&'static str> {
108        match self {
109            Self::Compile(error) => error.diagnostic_code(),
110            Self::Lower(_) | Self::Size { .. } => None,
111        }
112    }
113}
114
115impl Error for RegexDfaError {}
116
117impl From<RegexCompileError> for RegexDfaError {
118    fn from(error: RegexCompileError) -> Self {
119        Self::Compile(error)
120    }
121}
122
123impl From<NfaToDfaError> for RegexDfaError {
124    fn from(error: NfaToDfaError) -> Self {
125        Self::Lower(error)
126    }
127}
128
129/// Build a [`RegexDfaPipeline`] from a list of regex sources.
130///
131/// `max_matches` is the per-dispatch hit-buffer cap (passed through to
132/// `build_ac_bounded_ranges_program`). `max_dfa_states` is the subset-
133/// construction state cap (see
134/// [`vyre_primitives::matching::nfa_to_dfa`]). The default of 16k
135/// states matches `DEFAULT_DFA_BUDGET_BYTES = 16 MiB` (16k × 256 × 4 B).
136///
137/// The match-append strategy is the default `append_match_subgroup`
138/// (I.17 - one atomic per subgroup leader). On backends that can't
139/// lower `subgroup_ballot` / `subgroup_shuffle` yet (currently
140/// `vyre-driver-cuda`) use [`build_regex_dfa_pipeline_ext`] with
141/// `use_subgroup_coalesce = false`.
142///
143/// # Errors
144/// See [`RegexDfaError`].
145pub fn build_regex_dfa_pipeline(
146    patterns: &[&str],
147    max_matches: u32,
148    max_dfa_states: usize,
149) -> Result<RegexDfaPipeline, RegexDfaError> {
150    build_regex_dfa_pipeline_ext(patterns, max_matches, max_dfa_states, true)
151}
152
153/// [`build_regex_dfa_pipeline`] with explicit `use_subgroup_coalesce`
154/// control. Pass `false` on backends whose IR lowering cannot yet emit
155/// `subgroup_ballot` + `subgroup_shuffle` - currently `vyre-driver-cuda`
156/// rejects the subgroup form during canonical pre-emit lowering. Either
157/// flag produces bit-identical match output; the difference is purely
158/// the atomic-coalescing strategy at hit-buffer append time.
159///
160/// # Errors
161/// See [`RegexDfaError`].
162pub fn build_regex_dfa_pipeline_ext(
163    patterns: &[&str],
164    max_matches: u32,
165    max_dfa_states: usize,
166    use_subgroup_coalesce: bool,
167) -> Result<RegexDfaPipeline, RegexDfaError> {
168    let regex_set = compile_regex_set(patterns)?;
169    finish_regex_dfa_pipeline(
170        regex_set,
171        patterns,
172        max_matches,
173        max_dfa_states,
174        use_subgroup_coalesce,
175    )
176}
177
178/// **Unanchored (find-anywhere)** counterpart of [`build_regex_dfa_pipeline`].
179///
180/// [`build_regex_dfa_pipeline`] compiles an *anchored* DFA: it only matches a
181/// pattern starting at the scan origin (a secret at byte 9 of a file is missed).
182/// This variant adds the implicit `.*` prefix at the **NFA-table level**: it
183/// self-loops the NFA start state on every byte so the automaton stays live at
184/// every position (Aho-Corasick semantics), then runs the same subset
185/// construction. Match offsets are reported at the match END, exactly as the
186/// literal AC path.
187///
188/// This is done on the bit-table, NOT by prepending `(?s).*?` to the regex
189/// source: the regex-text approach explodes NFA/DFA construction for complex
190/// patterns (measured OOM across a 1.7k-pattern set), while the start self-loop
191/// is O(256) and leaves the rest of the automaton untouched.
192///
193/// # Errors
194/// See [`RegexDfaError`].
195pub fn build_regex_dfa_unanchored(
196    patterns: &[&str],
197    max_matches: u32,
198    max_dfa_states: usize,
199) -> Result<RegexDfaPipeline, RegexDfaError> {
200    let mut regex_set = compile_regex_set(patterns)?;
201    add_implicit_dotstar_prefix(
202        &mut regex_set.transition_table,
203        regex_set.plan.num_states as usize,
204    )?;
205    finish_regex_dfa_pipeline(regex_set, patterns, max_matches, max_dfa_states, true)
206}
207
208/// One shard of a state-cap-sharded regex DFA set: a self-contained,
209/// independently dispatchable [`RegexDfaPipeline`] plus the map from its
210/// local pattern ids back to the caller's global pattern indices.
211///
212/// A shard's DFA reports matches with LOCAL pattern ids `0..global_pattern_ids.len()`;
213/// the consumer rewrites each hit's pid to `global_pattern_ids[local_pid]` before
214/// merging shard results, so the union is expressed in the caller's original
215/// pattern numbering.
216#[derive(Debug, Clone)]
217pub struct RegexDfaShard {
218    /// Dispatchable pipeline for this shard's pattern subset.
219    pub pipeline: RegexDfaPipeline,
220    /// `global_pattern_ids[local_pid]` = index of this shard's pattern in the
221    /// original `patterns` slice passed to the shard builder.
222    pub global_pattern_ids: Vec<u32>,
223}
224
225/// True when `error` is a *capacity* failure that splitting the pattern group
226/// can resolve (the DFA/table was too big), as opposed to a *per-pattern*
227/// failure (bad syntax, unsupported construct) that no amount of sharding fixes.
228fn regex_dfa_error_is_capacity(error: &RegexDfaError) -> bool {
229    match error {
230        // Subset construction blew its state budget, or the metadata exceeded
231        // the GPU program's ABI/staging budget: fewer patterns per shard fixes both.
232        RegexDfaError::Lower(_) | RegexDfaError::Size { .. } => true,
233        // The NFA itself needed more states than the per-pipeline cap.
234        RegexDfaError::Compile(RegexCompileError::TooManyStates { .. }) => true,
235        // Parse / Unsupported / ABI-count overflow are per-pattern: return them.
236        RegexDfaError::Compile(_) => false,
237    }
238}
239
240/// Recursively compile `indexed` into fitting shards, bisecting on any capacity
241/// overflow. Each emitted shard is a proven-fitting DFA (its build returned Ok).
242fn compile_or_split(
243    indexed: &[(u32, &str)],
244    max_matches: u32,
245    max_dfa_states: usize,
246    compile: fn(&[&str], u32, usize) -> Result<RegexDfaPipeline, RegexDfaError>,
247    out: &mut Vec<RegexDfaShard>,
248) -> Result<(), RegexDfaError> {
249    if indexed.is_empty() {
250        return Ok(());
251    }
252    let pats: Vec<&str> = indexed.iter().map(|(_, p)| *p).collect();
253    match compile(&pats, max_matches, max_dfa_states) {
254        Ok(pipeline) => {
255            out.push(RegexDfaShard {
256                pipeline,
257                global_pattern_ids: indexed.iter().map(|(g, _)| *g).collect(),
258            });
259            Ok(())
260        }
261        // A single pattern that still overflows cannot be split further: surface
262        // its error so the caller raises the cap or drops that pattern, never a
263        // silent omission (Law 10).
264        Err(error) if indexed.len() > 1 && regex_dfa_error_is_capacity(&error) => {
265            let mid = indexed.len() / 2;
266            compile_or_split(&indexed[..mid], max_matches, max_dfa_states, compile, out)?;
267            compile_or_split(&indexed[mid..], max_matches, max_dfa_states, compile, out)
268        }
269        Err(error) => Err(error),
270    }
271}
272
273/// Compile a pattern set into one-or-more [`RegexDfaShard`]s, each of whose DFA
274/// fits within `max_dfa_states`: eliminating the single-DFA state cap as a hard
275/// limit on how many patterns a consumer can admit in one scan phase.
276///
277/// Why not just size-account the NFA (`plan_shards`)? Subset construction can
278/// explode the DFA far past the NFA state count, so NFA accounting cannot
279/// *guarantee* a fitting DFA. This builder instead COMPILES each candidate group
280/// and, on a capacity overflow, bisects and recompiles, so every emitted shard is
281/// a proven-fitting DFA. A single pattern that cannot fit on its own surfaces its
282/// compile error rather than being silently dropped.
283///
284/// The default builds **anchored** shards (mirrors [`build_regex_dfa_pipeline`]);
285/// use [`build_regex_dfa_shards_unanchored`] for the find-anywhere consumer path.
286///
287/// # Errors
288/// The first per-pattern compile error (bad syntax / unsupported construct), or a
289/// capacity error for a lone pattern that cannot fit `max_dfa_states`.
290pub fn build_regex_dfa_shards(
291    patterns: &[&str],
292    max_matches: u32,
293    max_dfa_states: usize,
294) -> Result<Vec<RegexDfaShard>, RegexDfaError> {
295    build_regex_dfa_shards_with(
296        patterns,
297        max_matches,
298        max_dfa_states,
299        build_regex_dfa_pipeline,
300    )
301}
302
303/// Unanchored (find-anywhere) counterpart of [`build_regex_dfa_shards`], shards
304/// the `.*`-prefixed DFA the megakernel batch path uses.
305///
306/// # Errors
307/// See [`build_regex_dfa_shards`].
308pub fn build_regex_dfa_shards_unanchored(
309    patterns: &[&str],
310    max_matches: u32,
311    max_dfa_states: usize,
312) -> Result<Vec<RegexDfaShard>, RegexDfaError> {
313    build_regex_dfa_shards_with(
314        patterns,
315        max_matches,
316        max_dfa_states,
317        build_regex_dfa_unanchored,
318    )
319}
320
321fn build_regex_dfa_shards_with(
322    patterns: &[&str],
323    max_matches: u32,
324    max_dfa_states: usize,
325    compile: fn(&[&str], u32, usize) -> Result<RegexDfaPipeline, RegexDfaError>,
326) -> Result<Vec<RegexDfaShard>, RegexDfaError> {
327    let mut indexed: Vec<(u32, &str)> = Vec::with_capacity(patterns.len());
328    for (index, pattern) in patterns.iter().enumerate() {
329        let global = u32::try_from(index).map_err(|_| {
330            RegexDfaError::Compile(RegexCompileError::PatternCountOverflow {
331                count: patterns.len(),
332            })
333        })?;
334        indexed.push((global, *pattern));
335    }
336    let mut shards = Vec::new();
337    compile_or_split(&indexed, max_matches, max_dfa_states, compile, &mut shards)?;
338    Ok(shards)
339}
340
341/// Add an implicit `.*` prefix to a subgroup-NFA transition table: self-loop the
342/// start state (state 0, lane 0, bit 0) on every byte so it remains active at
343/// each input position. This is the standard unanchored/Aho-Corasick transform,
344/// applied to the lane-major `[num_states × 256 × LANES]` table where entry
345/// `trans[src*256*LANES + byte*LANES + lane]` holds the destination-state bits
346/// lane `lane` owns. For `src = 0, lane = 0` over every byte we OR in bit 0.
347/// Returns `Err(RegexDfaError::Size)` when `transition_table.len()` is not
348/// divisible by `num_states * 256`, which would produce a silently-anchored DFA
349/// (the self-loop cannot be applied, so the caller's `build_regex_dfa_unanchored`
350/// would succeed but return an anchored DFA (every match at offset > 0 dropped)).
351fn add_implicit_dotstar_prefix(
352    transition_table: &mut [u32],
353    num_states: usize,
354) -> Result<(), RegexDfaError> {
355    if num_states == 0 {
356        return Ok(());
357    }
358    // LANES = table_len / (num_states * 256); derive it so this stays correct if
359    // LANES_PER_SUBGROUP ever changes, with no extra feature import.
360    let denom = num_states.saturating_mul(256);
361    if denom == 0 || transition_table.len() % denom != 0 {
362        // A malformed table means the self-loop cannot be applied. Returning
363        // Ok(()) here would leave the table anchored, causing build_regex_dfa_unanchored
364        // to return an anchored DFA (silently dropping every match at offset > 0).
365        return Err(RegexDfaError::Size {
366            message: format!(
367                "add_implicit_dotstar_prefix: transition_table length {} is not divisible \
368                 by num_states({num_states}) * 256 = {denom}; cannot apply unanchored \
369                 start-state self-loop. Fix: ensure the NFA table is well-formed before \
370                 calling build_regex_dfa_unanchored.",
371                transition_table.len()
372            ),
373        });
374    }
375    let lanes = transition_table.len() / denom;
376    for byte in 0..256usize {
377        // src = 0, lane = 0  →  index = 0*256*lanes + byte*lanes + 0
378        let idx = byte * lanes;
379        if idx < transition_table.len() {
380            transition_table[idx] |= 1; // bit 0 = state 0 (start) self-loop
381        }
382    }
383    Ok(())
384}
385
386/// Shared tail of the regex→DFA build: turn a compiled NFA regex set into a
387/// dispatchable [`RegexDfaPipeline`] (subset construction + AC program). Called
388/// by both the anchored and unanchored entry points.
389fn finish_regex_dfa_pipeline(
390    regex_set: CompiledRegexSet,
391    patterns: &[&str],
392    max_matches: u32,
393    max_dfa_states: usize,
394    use_subgroup_coalesce: bool,
395) -> Result<RegexDfaPipeline, RegexDfaError> {
396    // The NFA `plan` carries accept_states as `(pattern_id, match_len)`
397    // tuples. nfa_to_dfa wants the pattern ids and the max len
398    // separately; max_pattern_len doubles as the AC kernel's per-
399    // position replay window cap.
400    let mut accept_pattern_ids: Vec<u32> = Vec::new();
401    reserve_regex_vec(
402        &mut accept_pattern_ids,
403        regex_set.plan.accept_states.len(),
404        "accept pattern id table",
405    )?;
406    accept_pattern_ids.extend(regex_set.plan.accept_states.iter().map(|(pid, _)| *pid));
407    let max_pattern_len = regex_set
408        .plan
409        .accept_states
410        .iter()
411        .map(|(_, len)| *len)
412        .max()
413        .unwrap_or(0);
414    // pattern_lengths is per-pattern indexed; build it from the accept
415    // table. A pattern with multiple accept states (alternation) takes
416    // the longest match length - same convention `dfa_compile` uses.
417    let pattern_count = u32::try_from(patterns.len()).map_err(|source| RegexDfaError::Size {
418        message: format!(
419            "pattern count {} exceeds u32 GPU buffer metadata: {source}. Fix: shard the regex set before building a DFA dispatch.",
420            patterns.len()
421        ),
422    })?;
423    let mut pattern_lengths = Vec::new();
424    reserve_regex_vec(&mut pattern_lengths, patterns.len(), "pattern length table")?;
425    pattern_lengths.resize(patterns.len(), 0);
426    for (pid, len) in &regex_set.plan.accept_states {
427        let idx = usize::try_from(*pid).map_err(|source| RegexDfaError::Size {
428            message: format!(
429                "accept pattern id {pid} cannot fit usize for pattern-length indexing: {source}. Fix: shard the regex set before building a DFA dispatch."
430            ),
431        })?;
432        if idx < pattern_lengths.len() && *len > pattern_lengths[idx] {
433            pattern_lengths[idx] = *len;
434        }
435    }
436
437    let tables = NfaTables {
438        num_states: regex_set.plan.num_states,
439        transition_table: &regex_set.transition_table,
440        epsilon_table: &regex_set.epsilon_table,
441        accept_state_ids: &regex_set.plan.accept_state_ids,
442        accept_pattern_ids: &accept_pattern_ids,
443        max_pattern_len,
444    };
445    let dfa = nfa_to_dfa(&tables, max_dfa_states)?;
446
447    let program = try_build_ac_bounded_ranges_program_ext(
448        &dfa,
449        pattern_count,
450        max_matches,
451        use_subgroup_coalesce,
452    )
453    .map_err(|message| RegexDfaError::Size { message })?;
454
455    Ok(RegexDfaPipeline {
456        program,
457        dfa,
458        pattern_lengths,
459    })
460}
461
462fn reserve_regex_vec<T>(
463    vec: &mut Vec<T>,
464    requested: usize,
465    label: &'static str,
466) -> Result<(), RegexDfaError> {
467    vyre_foundation::allocation::try_reserve_vec_to_capacity(vec, requested).map_err(|source| {
468        RegexDfaError::Size {
469            message: format!(
470                "regex DFA {label} reservation failed for {requested} item(s): {source}. Fix: shard the regex set or lower the DFA budget before dispatch."
471            ),
472        }
473    })
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    /// Single-pass DFA replay from the start state, the exact semantics the
481    /// megakernel batch dispatcher uses (one pass per file, no per-position
482    /// restart). Returns the end offsets where the DFA accepts.
483    fn single_pass_accept_ends(dfa: &CompiledDfa, haystack: &[u8]) -> Vec<usize> {
484        let mut state = 0u32;
485        let mut ends = Vec::new();
486        for (i, &b) in haystack.iter().enumerate() {
487            state = dfa.transitions[state as usize * 256 + b as usize];
488            if dfa.accept[state as usize] != 0 {
489                ends.push(i + 1);
490            }
491        }
492        ends
493    }
494
495    /// Leftmost-longest ("maximal munch") accept ends over the unanchored dense
496    /// DFA. A token that accepts at several consecutive lengths, a variable
497    /// `{n,m}` / `+` / `*` body, collapses to the SINGLE longest end (the end of
498    /// its accepting run) instead of one hit per admissible length. Emits end `p`
499    /// iff the DFA accepts at `p` and does NOT accept at `p + 1` (the match cannot
500    /// be extended), which for a `<prefix><class>{n,m}` token terminated by a
501    /// non-class byte is exactly its maximal end. Fixed-length patterns (one
502    /// accept length per occurrence) yield the same result as
503    /// [`single_pass_accept_ends`]. This is the semantics a scanner wants: one
504    /// finding covering the whole token, not `m - n + 1` overlapping partials.
505    fn single_pass_leftmost_longest_ends(dfa: &CompiledDfa, haystack: &[u8]) -> Vec<usize> {
506        let mut state = 0u32;
507        let mut ends = Vec::new();
508        let mut prev_end = 0usize;
509        let mut prev_accept = false;
510        for (i, &b) in haystack.iter().enumerate() {
511            state = dfa.transitions[state as usize * 256 + b as usize];
512            let accept = dfa.accept[state as usize] != 0;
513            if prev_accept && !accept {
514                // The accepting run ended: `prev_end` was its maximal end.
515                ends.push(prev_end);
516            }
517            prev_end = i + 1;
518            prev_accept = accept;
519        }
520        if prev_accept {
521            // The accepting run reaches end-of-input.
522            ends.push(prev_end);
523        }
524        ends
525    }
526
527    /// The unanchored build must match a pattern at ANY offset under a single
528    /// forward pass (find-anywhere), while the anchored build dies on a
529    /// non-matching prefix. This is the property the megakernel fallback
530    /// port depends on (a secret is rarely at byte 0).
531    #[test]
532    fn unanchored_dfa_matches_at_any_offset_single_pass() {
533        let anchored = build_regex_dfa_pipeline(&["abc"], 1024, 1024).expect("anchored compiles");
534        let unanchored =
535            build_regex_dfa_unanchored(&["abc"], 1024, 1024).expect("unanchored compiles");
536
537        // Unanchored: one pass over "xxabc" accepts at end=5 (abc at bytes 2..4).
538        assert_eq!(
539            single_pass_accept_ends(&unanchored.dfa, b"xxabc"),
540            vec![5],
541            "unanchored DFA must match `abc` after a non-matching prefix"
542        );
543        // Anchored: the leading 'x' drives state 0 to a dead state → no accept.
544        assert!(
545            single_pass_accept_ends(&anchored.dfa, b"xxabc").is_empty(),
546            "anchored DFA must NOT match `abc` after a non-matching prefix"
547        );
548        // Both match at the start.
549        assert_eq!(single_pass_accept_ends(&unanchored.dfa, b"abc"), vec![3]);
550        assert_eq!(single_pass_accept_ends(&anchored.dfa, b"abc"), vec![3]);
551        // Unanchored finds every occurrence in one pass.
552        assert_eq!(
553            single_pass_accept_ends(&unanchored.dfa, b"abcxabc"),
554            vec![3, 7],
555            "unanchored DFA must find all occurrences"
556        );
557    }
558
559    /// Regression: a downstream GPU parity gate missed a real `ghp_` token whose
560    /// 36-char body contains g/h/p (the prefix chars), a prefix/body overlap
561    /// under the `.*` self-loop. This CPU single-pass DFA check isolates whether
562    /// the miss is in THIS primitive's construction or downstream on the GPU.
563    #[test]
564    fn unanchored_dfa_finds_overlap_body_token_single_pass() {
565        let dfa = build_regex_dfa_unanchored(&["ghp_[A-Za-z0-9]{36}"], 1024, 16384)
566            .expect("compiles")
567            .dfa;
568        // Exact missed content from a downstream cpu_parity gate (file 120).
569        let hay = b"at = \"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\"";
570        let ends = single_pass_accept_ends(&dfa, hay);
571        assert_eq!(
572            ends,
573            vec![hay.len() - 1],
574            "unanchored DFA must accept the ghp_ token exactly before the closing quote"
575        );
576    }
577
578    /// Isolation for the 6 GPU parity-gate misses: run the EXACT missed contexts
579    /// through the dense `CompiledDfa` on the CPU with the kernel's single-pass
580    /// semantics. If these all accept here but the GPU drops them, the bug is in
581    /// the megakernel dispatch, not this primitive's DFA construction.
582    #[test]
583    fn unanchored_dfa_finds_all_parity_gate_misses_single_pass() {
584        // (pattern, exact missed match content from the cpu_parity gate run)
585        let cases: &[(&str, &[u8])] = &[
586            (
587                "ghp_[A-Za-z0-9]{36}",
588                b"at = \"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\"",
589            ),
590            (
591                "gho_[A-Za-z0-9]{36}",
592                b"ken: \"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\"",
593            ),
594            (
595                "ghu_[A-Za-z0-9]{36}",
596                b"Key: \"ghu_m7BOv2Uj0AZZK088M7RQJkZX3EgBVV1Xt7i2\"",
597            ),
598            (
599                "ghu_[A-Za-z0-9]{36}",
600                b"OKEN: ghu_4u5ef0rIhtKpPV1F0dPwwhXNMpEXkB0tWWQv",
601            ),
602            (
603                "xox[baprs]-[A-Za-z0-9-]{10,48}",
604                b"Key: \"xoxb-1234567890-1234567890-EXAMPLE-TOKEN\"",
605            ),
606            (
607                "xox[baprs]-[A-Za-z0-9-]{10,48}",
608                b"_KEY=\"xoxb-32790994721-16118213278-q5KLPWcLboh0tchHpJPgWhuC\"",
609            ),
610        ];
611        for (pat, hay) in cases {
612            let dfa = build_regex_dfa_unanchored(&[pat], 1024, 16384)
613                .unwrap_or_else(|e| panic!("pattern {pat:?} must compile: {e:?}"))
614                .dfa;
615            // Leftmost-longest ("maximal munch") extraction: each case holds ONE
616            // complete token, so the scanner-correct result is its single maximal
617            // end. The raw all-ends walk (`single_pass_accept_ends`) is only
618            // single-valued for FIXED-length patterns, a variable `{10,48}` body
619            // genuinely accepts at every admissible length (26 ends for the `xox`
620            // cases), so asserting a single end there requires the leftmost-longest
621            // walk, which collapses the run to its longest end. Asserting the exact
622            // set (not containment) catches both a missed hit and a spurious/
623            // duplicated earlier hit from body overlap under the dotstar self-loop.
624            let ends = single_pass_leftmost_longest_ends(&dfa, hay);
625            let expected_end = if hay.ends_with(b"\"") {
626                hay.len() - 1
627            } else {
628                hay.len()
629            };
630            assert_eq!(
631                ends,
632                vec![expected_end],
633                "dense CompiledDfa for {pat:?} must report exactly one leftmost-longest \
634                 end offset ({expected_end}) in {:?}; got {ends:?}. state_count={}",
635                String::from_utf8_lossy(hay),
636                dfa.state_count,
637            );
638        }
639    }
640
641    /// End-to-end: a literal regex set should produce a Program whose
642    /// CompiledDfa accepts the literal at the expected end offset. The
643    /// CompiledDfa accept table is the load-bearing assertion - if it's
644    /// empty, the composition didn't propagate accept metadata through
645    /// the subset construction.
646    #[test]
647    fn literal_pattern_set_lowers_through_to_dfa_program() {
648        let pipeline =
649            build_regex_dfa_pipeline(&["abc"], 1024, 1024).expect("Fix: literal must compile");
650        assert!(
651            pipeline.dfa.state_count >= 4,
652            "literal 'abc' DFA must have at least 4 states (entry + 3 progress); got {}",
653            pipeline.dfa.state_count
654        );
655        assert_eq!(
656            pipeline.pattern_lengths,
657            vec![3],
658            "single literal 'abc' must have pattern_lengths = [3]"
659        );
660        assert!(
661            pipeline
662                .dfa
663                .accept
664                .iter()
665                .any(|&pid_plus_one| pid_plus_one == 1),
666            "at least one DFA state must accept pattern 0 (encoded as accept = 1)"
667        );
668        // Program buffer surface matches the AC kernel's contract:
669        // haystack, transitions, output_offsets, output_records,
670        // pattern_lengths, haystack_len, match_count, matches.
671        let names: Vec<&str> = pipeline.program.buffers.iter().map(|b| b.name()).collect();
672        for expected in [
673            "haystack",
674            "transitions",
675            "output_offsets",
676            "output_records",
677            "pattern_lengths",
678            "haystack_len",
679            "match_count",
680            "matches",
681        ] {
682            assert!(
683                names.contains(&expected),
684                "RegexDfaPipeline program must declare buffer `{expected}` for AC dispatch; got {names:?}"
685            );
686        }
687    }
688
689    /// Multi-pattern union: two literals must end up in two distinct
690    /// accept states (each tied to its own pattern id), not collapsed
691    /// into one.
692    #[test]
693    fn multi_literal_set_emits_distinct_accept_pids() {
694        let pipeline = build_regex_dfa_pipeline(&["abc", "xyz"], 1024, 1024)
695            .expect("Fix: two literals must compile");
696        assert_eq!(pipeline.pattern_lengths, vec![3, 3]);
697        // accept[s] = pid + 1, so a multi-pattern set should produce
698        // both `1` (pid 0) and `2` (pid 1) somewhere in the accept
699        // table. If either is missing, the subset construction lost
700        // an accept's pattern_id.
701        let has_pid0 = pipeline.dfa.accept.iter().any(|&value| value == 1);
702        let has_pid1 = pipeline.dfa.accept.iter().any(|&value| value == 2);
703        assert!(has_pid0, "no DFA state accepts pid 0 - 'abc' lost in lower");
704        assert!(has_pid1, "no DFA state accepts pid 1 - 'xyz' lost in lower");
705    }
706
707    /// State-explosion path: setting `max_dfa_states` to 1 must surface
708    /// as a structured error, not a panic.
709    #[test]
710    fn state_explosion_surfaces_as_error_not_panic() {
711        let err = build_regex_dfa_pipeline(&["abc"], 1024, 1)
712            .expect_err("max_dfa_states=1 must trip state explosion");
713        match err {
714            RegexDfaError::Lower(NfaToDfaError::StateExplosion { .. }) => {}
715            other => panic!("expected Lower(StateExplosion), got {other:?}"),
716        }
717    }
718
719    /// A regex with a character class should also lower - this is the
720    /// case `RulePipeline` would scan via NFA bit-vector. The DFA path
721    /// must produce an accept somewhere so the consumer gets a hit.
722    #[test]
723    fn character_class_pattern_lowers_to_acceptor_dfa() {
724        let pipeline = build_regex_dfa_pipeline(&["[ab]c"], 1024, 1024)
725            .expect("Fix: character class must compile");
726        assert!(
727            pipeline.dfa.accept.iter().any(|&value| value != 0),
728            "DFA for '[ab]c' must accept at least one state"
729        );
730    }
731
732    #[test]
733    fn regex_dfa_pipeline_uses_checked_size_conversions() {
734        let production = include_str!("regex_dfa.rs")
735            .split("\n#[cfg(test)]\nmod tests")
736            .next()
737            .expect("Fix: regex DFA production section should precede tests");
738
739        assert!(
740            production.contains("RegexDfaError::Size"),
741            "Fix: regex DFA sizing failures must be structured errors, not panics or unchecked casts."
742        );
743        assert!(
744            production.contains("u32::try_from(patterns.len())"),
745            "Fix: regex DFA pattern count must use checked conversion for GPU ABI metadata."
746        );
747        assert!(
748            production.contains("usize::try_from(*pid)"),
749            "Fix: regex DFA accept pattern ids must use checked host indexing conversion."
750        );
751        assert!(
752            production.contains("try_build_ac_bounded_ranges_program_ext"),
753            "Fix: regex DFA must call the fallible AC program builder."
754        );
755        assert!(
756            !production.contains("patterns.len() as u32"),
757            "Fix: regex DFA must not narrow pattern counts with unchecked casts."
758        );
759    }
760
761    /// Behavioral complement to regex_dfa_pipeline_uses_checked_size_conversions:
762    /// verify that the RegexDfaError::Size variant actually carries an actionable
763    /// message when triggered. We trigger it via nfa_to_dfa's max_dfa_states guard
764    /// (maps to RegexDfaError::Lower), and separately verify the Size variant's
765    /// Display output is actionable when constructed directly.
766    #[test]
767    fn regex_dfa_size_error_has_actionable_message() {
768        // Construct a Size error directly (the behavioral path that exercises the
769        // variant formatting, pattern-count overflow requires > u32::MAX allocations
770        // which is not feasible in a unit test, but we can verify the error is
771        // coherent and carries the expected guidance text).
772        let err = RegexDfaError::Size {
773            message: "pattern count 4294967296 exceeds u32 GPU buffer metadata: out of range integral type conversion attempted. Fix: shard the regex set before building a DFA dispatch.".to_string(),
774        };
775        let displayed = format!("{err}");
776        assert!(
777            displayed.contains("Fix:"),
778            "RegexDfaError::Size display must carry an actionable Fix directive; got: {displayed:?}"
779        );
780        assert!(
781            displayed.contains("shard"),
782            "RegexDfaError::Size display must mention sharding as the recovery path; got: {displayed:?}"
783        );
784    }
785
786    /// Regression guard: build_regex_dfa_unanchored must propagate the error from
787    /// add_implicit_dotstar_prefix rather than silently producing an anchored DFA.
788    /// This test verifies the success path still works; the error path cannot be
789    /// triggered for well-formed patterns (compile_regex_set always produces
790    /// internally-consistent tables), so the fix is covered by a source-scan guard below.
791    #[test]
792    fn unanchored_build_succeeds_and_is_actually_unanchored() {
793        let pipeline =
794            build_regex_dfa_unanchored(&["abc"], 1024, 1024).expect("unanchored must compile");
795        // An anchored DFA would fail to match "abc" after a non-matching prefix in
796        // a single forward pass. The unanchored DFA must succeed.
797        let mut state = 0u32;
798        let mut accepted = false;
799        for &b in b"xxabc" {
800            state = pipeline.dfa.transitions[state as usize * 256 + b as usize];
801            if pipeline.dfa.accept[state as usize] != 0 {
802                accepted = true;
803            }
804        }
805        assert!(
806            accepted,
807            "unanchored DFA must match 'abc' after non-matching prefix 'xx' in a single pass; \
808             if this fails the add_implicit_dotstar_prefix self-loop was not applied"
809        );
810    }
811
812    /// Pid-aware single-pass replay: at each accepting state, emit EVERY pattern
813    /// id in `output_records` (not just the single `accept` id), exactly as the
814    /// real dispatch does (so overlapping patterns at one position all surface).
815    fn walk_unanchored_local_hits(dfa: &CompiledDfa, hay: &[u8]) -> Vec<(u32, usize)> {
816        let mut state = 0u32;
817        let mut hits = Vec::new();
818        for (i, &b) in hay.iter().enumerate() {
819            state = dfa.transitions[state as usize * 256 + b as usize];
820            let s = state as usize;
821            let lo = dfa.output_offsets[s] as usize;
822            let hi = dfa.output_offsets[s + 1] as usize;
823            for &pid in &dfa.output_records[lo..hi] {
824                hits.push((pid, i + 1));
825            }
826        }
827        hits
828    }
829
830    /// State-cap elimination: a pattern set that OVERFLOWS a small single-DFA cap
831    /// must still scan losslessly once split into shards, and the union of shard
832    /// hits, rewritten to global pattern ids, must equal an independent
833    /// naive-substring oracle over the same haystack. Proves both the fitting
834    /// guarantee and that pid remapping loses/duplicates nothing (Law 10).
835    #[test]
836    fn dfa_shards_cover_overflowing_set_losslessly_with_global_pids() {
837        let patterns = ["alpha", "bravo", "charlie", "delta", "epsilon", "gamma"];
838        let refs: Vec<&str> = patterns.to_vec();
839        // A cap that fits a couple of these literals' unanchored DFA but not all
840        // six at once (forces multiple shards).
841        let cap = 18usize;
842
843        // Precondition: the whole set genuinely overflows the small cap.
844        assert!(
845            build_regex_dfa_unanchored(&refs, 4096, cap).is_err(),
846            "precondition: the whole 6-pattern set must overflow a {cap}-state cap"
847        );
848
849        let shards = build_regex_dfa_shards_unanchored(&refs, 4096, cap)
850            .expect("sharding must fit every pattern within the cap");
851        assert!(
852            shards.len() >= 2,
853            "an overflowing set must split into >=2 shards"
854        );
855
856        // Every global pid 0..6 is covered exactly once across shards, and each
857        // shard's DFA actually fits the cap (the fitting guarantee).
858        let mut covered: Vec<u32> = shards
859            .iter()
860            .flat_map(|s| s.global_pattern_ids.iter().copied())
861            .collect();
862        covered.sort_unstable();
863        assert_eq!(
864            covered,
865            (0..patterns.len() as u32).collect::<Vec<_>>(),
866            "shards must partition the global pattern ids with no gap or overlap"
867        );
868        for shard in &shards {
869            assert!(
870                shard.pipeline.dfa.state_count as usize <= cap,
871                "every emitted shard must fit the {cap}-state cap; got {}",
872                shard.pipeline.dfa.state_count
873            );
874            assert_eq!(
875                shard.global_pattern_ids.len(),
876                shard.pipeline.pattern_lengths.len(),
877                "one global id per shard-local pattern"
878            );
879        }
880
881        // Differential over a haystack that embeds several patterns at offsets.
882        let hay = b"__alpha xx charlie--epsilon..bravo gamma zz delta__epsilonalpha";
883        // Independent oracle: every occurrence of each pattern -> (global_pid, end).
884        let mut oracle: Vec<(u32, usize)> = Vec::new();
885        for (gid, pat) in patterns.iter().enumerate() {
886            let pb = pat.as_bytes();
887            if pb.len() <= hay.len() {
888                for start in 0..=hay.len() - pb.len() {
889                    if &hay[start..start + pb.len()] == pb {
890                        oracle.push((gid as u32, start + pb.len()));
891                    }
892                }
893            }
894        }
895        oracle.sort_unstable();
896
897        // Sharded union: walk each shard, rewrite local pid -> global pid.
898        let mut got: Vec<(u32, usize)> = Vec::new();
899        for shard in &shards {
900            for (local_pid, end) in walk_unanchored_local_hits(&shard.pipeline.dfa, hay) {
901                let global = shard.global_pattern_ids[local_pid as usize];
902                got.push((global, end));
903            }
904        }
905        got.sort_unstable();
906
907        assert_eq!(
908            got, oracle,
909            "sharded scan (global-remapped) must equal the naive-substring oracle; \
910             a mismatch means the cap-sharding dropped, duplicated, or mis-attributed a match"
911        );
912        // Sanity: the oracle actually found the embedded patterns (guards a vacuous pass).
913        assert!(
914            oracle.len() >= patterns.len(),
915            "oracle must contain at least one hit per pattern for a meaningful differential"
916        );
917    }
918
919    /// A single pattern that cannot fit the cap on its own must SURFACE its
920    /// capacity error, never be silently omitted from the shard set (Law 10).
921    #[test]
922    fn dfa_shards_surface_error_for_unshardable_single_pattern() {
923        // One pattern whose own DFA needs more than a 1-state cap.
924        let result = build_regex_dfa_shards_unanchored(&["abcdef"], 4096, 1);
925        assert!(
926            result.is_err(),
927            "a lone pattern that overflows the cap must error, not drop silently"
928        );
929    }
930
931    /// The pipeline builder must forward the inner compile error's registry
932    /// diagnostic code, so a consumer routing on `build_regex_dfa_pipeline`'s
933    /// error gets the same code as the low-level `compile_regex_set` path.
934    #[test]
935    fn pipeline_error_forwards_diagnostic_code() {
936        let err = build_regex_dfa_pipeline(&[r"a\bc"], 1024, 1024)
937            .expect_err("a non-edge lookaround pattern must not compile");
938        assert_eq!(
939            err.diagnostic_code(),
940            Some("VYRE_SCAN_APPROXIMATED_LOOKAROUND_REQUIRES_VERIFIER"),
941            "pipeline error must forward the inner lookaround diagnostic code; error was: {err}"
942        );
943        // A sizing/lowering failure is not a registry construct -> no code.
944        let size_err =
945            build_regex_dfa_pipeline(&["abc"], 1024, 1).expect_err("a 1-state cap must overflow");
946        assert_eq!(
947            size_err.diagnostic_code(),
948            None,
949            "a state-budget overflow is not a registry unsupported-construct"
950        );
951    }
952}