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 ®ex_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: ®ex_set.transition_table,
478 epsilon_table: ®ex_set.epsilon_table,
479 accept_state_ids: ®ex_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}