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