Skip to main content

vyre_libs/scan/
regex_dfa.rs

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