Skip to main content

vyre_primitives/matching/
dfa_compile.rs

1//! CPU-side DFA compiler for multi-pattern scanning.
2//!
3//! `dfa_compile` produces a transition table for Aho-Corasick-style
4//! byte scanning. The table is pure data (`Vec<u32>`) so downstream
5//! crates can upload it to a GPU buffer or consume it from CPU tests
6//! without depending on the higher-level matching dialect.
7//!
8//! The table layout is deliberately simple so kernels can step the
9//! DFA in one load per byte:
10//!
11//! ```text
12//! transitions[state * 256 + byte] = next_state
13//! accept   [state]                  = nonzero if `state` matches a pattern
14//! ```
15//!
16//! Patterns are compiled with failure links collapsed, so scanners
17//! never have to walk failure pointers while processing input.
18
19use std::{error::Error, fmt};
20
21/// Compiled DFA ready to be uploaded to a GPU buffer.
22#[derive(Debug, Clone)]
23pub struct CompiledDfa {
24    /// `transitions[state * 256 + byte] = next_state`. Length =
25    /// `state_count * 256`.
26    pub transitions: Vec<u32>,
27    /// `accept[state] = pattern_id + 1` when `state` accepts, else 0.
28    /// Length = `state_count`.
29    pub accept: Vec<u32>,
30    /// Number of states in the automaton (>= 1; state 0 is root).
31    pub state_count: u32,
32    /// Longest pattern length in bytes. Scanners can limit each
33    /// per-position replay to this suffix window without changing
34    /// Aho-Corasick semantics.
35    pub max_pattern_len: u32,
36    /// `output_offsets[state]` = start index in `output_records` for
37    /// `state`. Length = `state_count + 1`. The last element is the
38    /// total length of `output_records`.
39    pub output_offsets: Vec<u32>,
40    /// Flat array of pattern ids. Each state `s` owns the slice
41    /// `output_records[output_offsets[s]..output_offsets[s+1]]`.
42    /// These are all patterns that match at `s` (including via
43    /// failure links), not just the single `accept[state]` id.
44    pub output_records: Vec<u32>,
45}
46
47/// Structured failure from [`dfa_compile_with_budget`].
48#[derive(Debug, Clone)]
49#[non_exhaustive]
50pub enum DfaCompileError {
51    /// Built DFA would exceed the caller's transition-table budget.
52    TooLarge {
53        /// Number of bytes the naive table would require.
54        requested_bytes: usize,
55        /// Caller-supplied budget.
56        budget_bytes: usize,
57        /// State count at the point of budget exhaustion.
58        state_count: u32,
59    },
60    /// Trie grew past the permitted state cap during construction.
61    TrieStateCapExceeded {
62        /// State cap derived from the caller-supplied budget.
63        state_cap: usize,
64    },
65}
66
67impl fmt::Display for DfaCompileError {
68    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
69        match self {
70            Self::TooLarge {
71                requested_bytes,
72                budget_bytes,
73                ..
74            } => write!(
75                formatter,
76                "DFA transition table is too large: {requested_bytes} bytes (cap = {budget_bytes}). Fix: reduce the pattern set, raise the budget, or shard patterns into multiple DFAs."
77            ),
78            Self::TrieStateCapExceeded { state_cap } => write!(
79                formatter,
80                "DFA trie exceeded state cap during construction: requested > {state_cap} states. Fix: reduce the pattern set or raise the budget (cap derived from budget_bytes / 1024)."
81            ),
82        }
83    }
84}
85
86impl Error for DfaCompileError {}
87
88/// Magic + version header for `CompiledDfa::to_bytes` / `from_bytes`.
89/// Keep this stable; bump `DFA_WIRE_VERSION` for any breaking layout change.
90///
91/// The actual framing (magic + version header, length-prefixed sections,
92/// truncation / shape error variants) is delegated to
93/// `vyre_foundation::serial::envelope`. This file owns only the
94/// payload schema (which fields go in what order) so future serializable
95/// types in vyre-primitives reuse the same envelope.
96const DFA_WIRE_MAGIC: [u8; 4] = *b"VDFA";
97const DFA_WIRE_VERSION: u32 = 2;
98
99/// Returned from [`CompiledDfa::from_bytes`] when the on-wire payload
100/// cannot be decoded into a valid DFA. The variant carries enough
101/// context for the caller to discriminate "stale cache, recompile" from
102/// "actual bug, refuse".
103#[derive(Debug, Clone)]
104#[non_exhaustive]
105pub enum DfaWireError {
106    /// Payload is shorter than the fixed header / a declared section.
107    Truncated {
108        /// Total bytes the decoder needed to read this section.
109        needed: usize,
110        /// Bytes actually provided in the input slice.
111        got: usize,
112    },
113    /// First four bytes were not the `VDFA` magic  -  caller likely passed
114    /// an unrelated blob.
115    BadMagic,
116    /// Wire version did not match the build's `DFA_WIRE_VERSION`. The
117    /// caller's cache is from an older scanner consumer/vyre and must be rebuilt.
118    VersionMismatch {
119        /// Wire version this build of vyre-primitives understands.
120        expected: u32,
121        /// Wire version recorded in the blob's header.
122        found: u32,
123    },
124    /// One of the array length fields disagreed with the declared
125    /// `state_count`  -  corrupt or hand-crafted blob.
126    ShapeMismatch {
127        /// Static description of which length cross-check failed.
128        reason: &'static str,
129    },
130    /// A payload section exceeded the wire envelope's `u32` length prefix.
131    SectionTooLarge {
132        /// Word count the caller attempted to encode.
133        len: usize,
134        /// Maximum word count representable by the wire format.
135        max: usize,
136    },
137    /// The shared wire envelope returned an error variant this crate
138    /// reports through the generic envelope branch.
139    Envelope(String),
140}
141
142impl fmt::Display for DfaWireError {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        match self {
145            Self::Truncated { needed, got } => write!(
146                f,
147                "DFA wire blob truncated: needed {needed} bytes, got {got}. \
148                 Fix: regenerate the cache."
149            ),
150            Self::BadMagic => write!(
151                f,
152                "DFA wire blob does not start with `VDFA` magic. Fix: this \
153                 is not a CompiledDfa::to_bytes payload."
154            ),
155            Self::VersionMismatch { expected, found } => write!(
156                f,
157                "DFA wire blob version {found} does not match the runtime \
158                 version {expected}. Fix: discard the cache and recompile \
159                 the DFA."
160            ),
161            Self::ShapeMismatch { reason } => write!(
162                f,
163                "DFA wire blob shape mismatch: {reason}. Fix: this blob is \
164                 corrupt  -  discard and recompile."
165            ),
166            Self::SectionTooLarge { len, max } => write!(
167                f,
168                "DFA wire section length {len} exceeds maximum {max}. \
169                 Fix: shard the DFA into smaller pattern groups."
170            ),
171            Self::Envelope(message) => write!(f, "DFA wire envelope error: {message}"),
172        }
173    }
174}
175
176impl Error for DfaWireError {}
177
178impl CompiledDfa {
179    /// Empty DFA with a single rejecting root state.
180    #[must_use]
181    pub fn empty() -> Self {
182        Self {
183            transitions: vec![0; 256],
184            accept: vec![0],
185            state_count: 1,
186            max_pattern_len: 0,
187            output_offsets: vec![0, 0],
188            output_records: Vec::new(),
189        }
190    }
191
192    /// Serialize this DFA into a self-describing little-endian binary
193    /// blob suitable for on-disk caching. Stable layout under
194    /// `DFA_WIRE_VERSION`. Pure data, no allocator-dependent state.
195    ///
196    /// Layout:
197    ///   - 4 bytes: magic `b"VDFA"`
198    ///   - 4 bytes: version (LE u32)
199    ///   - 4 bytes: state_count (LE u32)
200    ///   - 4 bytes: max_pattern_len (LE u32)
201    ///   - 4 bytes: transitions length in u32 words (LE u32)
202    ///   - 4 bytes: accept length in u32 words (LE u32)
203    ///   - 4 bytes: output_offsets length in u32 words (LE u32)
204    ///   - 4 bytes: output_records length in u32 words (LE u32)
205    ///   - transitions data    (state_count * 256 * 4 bytes)
206    ///   - accept data         (state_count * 4 bytes)
207    ///   - output_offsets data ((state_count + 1) * 4 bytes)
208    ///   - output_records data (variable * 4 bytes)
209    ///
210    /// Total size is `O(state_count)` bytes; ~1 MiB per 1k states.
211    pub fn to_bytes(&self) -> Result<Vec<u8>, DfaWireError> {
212        let mut out = vyre_foundation::serial::WireWriter::new(&DFA_WIRE_MAGIC, DFA_WIRE_VERSION);
213        out.write_u32(self.state_count);
214        out.write_u32(self.max_pattern_len);
215        out.write_words(&self.transitions)
216            .map_err(map_envelope_error)?;
217        out.write_words(&self.accept).map_err(map_envelope_error)?;
218        out.write_words(&self.output_offsets)
219            .map_err(map_envelope_error)?;
220        out.write_words(&self.output_records)
221            .map_err(map_envelope_error)?;
222        Ok(out.into_bytes())
223    }
224
225    /// Decode a `CompiledDfa` from a blob produced by [`Self::to_bytes`].
226    ///
227    /// # Errors
228    /// Returns [`DfaWireError`] for truncation, magic mismatch, version
229    /// drift, or shape inconsistencies. A `VersionMismatch` is the
230    /// expected signal to invalidate an on-disk cache and recompile.
231    pub fn from_bytes(bytes: &[u8]) -> Result<Self, DfaWireError> {
232        let mut reader =
233            vyre_foundation::serial::WireReader::new(bytes, &DFA_WIRE_MAGIC, DFA_WIRE_VERSION)
234                .map_err(map_envelope_error)?;
235        let state_count = reader.read_u32().map_err(map_envelope_error)?;
236        let max_pattern_len = reader.read_u32().map_err(map_envelope_error)?;
237        let transitions = reader.read_words().map_err(map_envelope_error)?;
238        let accept = reader.read_words().map_err(map_envelope_error)?;
239        let output_offsets = reader.read_words().map_err(map_envelope_error)?;
240        let output_records = reader.read_words().map_err(map_envelope_error)?;
241
242        // Cross-check the declared shape before returning the payload to
243        // callers. Length fields are validated by the envelope reader; these
244        // checks validate DFA-specific invariants so corrupt cache blobs do not
245        // become internally inconsistent automata.
246        if transitions.len() != (state_count as usize) * 256 {
247            return Err(DfaWireError::ShapeMismatch {
248                reason: "transitions length != state_count * 256",
249            });
250        }
251        // Every transition is consumed as the next state index
252        // (`transitions[state * 256 + byte]`), so a target >= state_count would
253        // index out of bounds on the following step. A corrupt/stale cache blob
254        // must fail closed here rather than OOB-panic (or read a garbage state)
255        // mid-scan (the same invariant the length checks enforce for the tables).
256        if transitions
257            .iter()
258            .any(|&target| target as usize >= state_count as usize)
259        {
260            return Err(DfaWireError::ShapeMismatch {
261                reason: "transition target out of range for state_count",
262            });
263        }
264        if accept.len() != state_count as usize {
265            return Err(DfaWireError::ShapeMismatch {
266                reason: "accept length != state_count",
267            });
268        }
269        if output_offsets.len() != (state_count as usize) + 1 {
270            return Err(DfaWireError::ShapeMismatch {
271                reason: "output_offsets length != state_count + 1",
272            });
273        }
274        if output_offsets.first().copied() != Some(0) {
275            return Err(DfaWireError::ShapeMismatch {
276                reason: "output_offsets must start at zero",
277            });
278        }
279        if output_offsets.last().copied() != Some(output_records.len() as u32) {
280            return Err(DfaWireError::ShapeMismatch {
281                reason: "output_offsets last entry must equal output_records length",
282            });
283        }
284        if output_offsets
285            .windows(2)
286            .any(|window| window[0] > window[1])
287        {
288            return Err(DfaWireError::ShapeMismatch {
289                reason: "output_offsets must be monotonic",
290            });
291        }
292        if output_offsets
293            .iter()
294            .any(|&offset| offset as usize > output_records.len())
295        {
296            return Err(DfaWireError::ShapeMismatch {
297                reason: "output_offsets entries must be within output_records",
298            });
299        }
300        // max_pattern_len == 0 is consistent ONLY when the sole accepting state is the
301        // root (state 0). The empty pattern matches at the root having consumed no
302        // bytes, so its length is 0 and `dfa_compile(&[b""])` legitimately carries
303        // max_pattern_len == 0 with accept[0] != 0. A *non-root* accept state, however,
304        // is reachable only by consuming >= 1 byte along some pattern, so it spells a
305        // pattern of length == depth(state) >= 1 and forces max_pattern_len >= 1. A blob
306        // that pairs max_pattern_len == 0 with a deeper accept is therefore internally
307        // inconsistent (the classic symptom of a corrupted cache whose length scalar was
308        // zeroed). Reject it: max_pattern_len bounds the per-position replay / segmentation
309        // warm-up window (see the field doc), so handing back an under-sized 0 would
310        // silently drop every match that straddles a segment boundary, an invisible
311        // recall loss. This is the precise form of the former guard, which was over-broad
312        // (it also rejected the genuine empty-pattern round-trip, accept only at the root).
313        if max_pattern_len == 0 && accept.iter().skip(1).any(|&state| state != 0) {
314            return Err(DfaWireError::ShapeMismatch {
315                reason: "max_pattern_len == 0 but a non-root state accepts",
316            });
317        }
318
319        Ok(Self {
320            transitions,
321            accept,
322            state_count,
323            max_pattern_len,
324            output_offsets,
325            output_records,
326        })
327    }
328}
329
330fn map_envelope_error(error: vyre_foundation::serial::EnvelopeError) -> DfaWireError {
331    match error {
332        vyre_foundation::serial::EnvelopeError::Truncated { needed, got } => {
333            DfaWireError::Truncated { needed, got }
334        }
335        vyre_foundation::serial::EnvelopeError::BadMagic { .. } => DfaWireError::BadMagic,
336        vyre_foundation::serial::EnvelopeError::VersionMismatch { expected, found } => {
337            DfaWireError::VersionMismatch { expected, found }
338        }
339        vyre_foundation::serial::EnvelopeError::SectionTooLarge { len, max } => {
340            DfaWireError::SectionTooLarge { len, max }
341        }
342        error => DfaWireError::Envelope(error.to_string()),
343    }
344}
345
346/// Default transition-table budget: 16 MiB.
347///
348/// Covers roughly 16k states x 256 transitions x 4 bytes/word. Most
349/// real pattern sets stay well under this; callers that need more can
350/// use [`dfa_compile_with_budget`].
351pub const DEFAULT_DFA_BUDGET_BYTES: usize = 16 * 1024 * 1024;
352
353/// Compile a list of byte patterns into a CPU-built DFA under the
354/// default [`DEFAULT_DFA_BUDGET_BYTES`] budget.
355///
356/// # Panics
357///
358/// Panics when the transition table would exceed the default budget. Returning
359/// an empty DFA in that case would silently drop EVERY match (the empty
360/// automaton rejects all input), an invisible recall loss in any scanner built
361/// on it. The pattern set is operator-supplied (a rule catalog, never attacker
362/// haystack), so an oversized set is a configuration error that must fail
363/// closed and loud. Callers that need to handle oversized sets programmatically
364/// must use [`dfa_compile_with_budget`] and shard oversized pattern sets,
365/// capturing the structured [`DfaCompileError`] instead of panicking.
366#[must_use]
367pub fn dfa_compile(patterns: &[&[u8]]) -> CompiledDfa {
368    match dfa_compile_with_budget(patterns, DEFAULT_DFA_BUDGET_BYTES) {
369        Ok(dfa) => dfa,
370        Err(error) => panic!(
371            "dfa_compile: compiling {} pattern(s) exceeded the default {DEFAULT_DFA_BUDGET_BYTES}-byte DFA budget ({error}). \
372             Returning the empty rejecting automaton would silently drop every match; \
373             use dfa_compile_with_budget and shard oversized pattern sets to handle this as a structured error.",
374            patterns.len()
375        ),
376    }
377}
378
379/// Compile a list of byte patterns with an explicit transition-table
380/// budget. Use this when the caller wants to handle oversized DFAs
381/// programmatically instead of panicking.
382///
383/// # Errors
384///
385/// Returns [`DfaCompileError::TooLarge`] when the DFA would exceed
386/// `budget_bytes`. The error carries the requested size and the
387/// budget for diagnostic messages.
388pub fn dfa_compile_with_budget(
389    patterns: &[&[u8]],
390    budget_bytes: usize,
391) -> Result<CompiledDfa, DfaCompileError> {
392    dfa_compile_with_budget_ci(patterns, budget_bytes, false)
393}
394
395/// ASCII-CASE-INSENSITIVE counterpart of [`dfa_compile`]: `A`/`a` … `Z`/`z` are
396/// matched interchangeably. The case fold is baked into the TRANSITION TABLE, not
397/// the haystack, patterns are canonicalized to lowercase at trie construction,
398/// and the flattened transition for a raw byte `b` resolves through
399/// `fold(b)`, so `transitions[state][b'A'] == transitions[state][b'a']`. A scanner
400/// therefore matches mixed-case input with ZERO per-byte folding work and no
401/// second resident haystack copy (it kills the consumer-side `to_ascii_lowercase`
402/// pass entirely). Non-ASCII and non-letter bytes are unchanged.
403///
404/// NOTE for downstream matchers: a case-insensitive DFA also needs its candidate
405/// PREFILTER masks (end-byte / suffix2 / suffix3) folded to admit both cases of
406/// each pattern byte, the masks are checked against the RAW haystack byte, which
407/// this DFA does not fold. Build those masks with the case-insensitive variant.
408///
409/// # Panics
410/// See [`dfa_compile`].
411#[must_use]
412pub fn dfa_compile_case_insensitive(patterns: &[&[u8]]) -> CompiledDfa {
413    match dfa_compile_case_insensitive_with_budget(patterns, DEFAULT_DFA_BUDGET_BYTES) {
414        Ok(dfa) => dfa,
415        Err(error) => panic!(
416            "dfa_compile_case_insensitive: compiling {} pattern(s) exceeded the default {DEFAULT_DFA_BUDGET_BYTES}-byte DFA budget ({error}). \
417             Returning the empty rejecting automaton would silently drop every match; \
418             use dfa_compile_case_insensitive_with_budget and shard oversized pattern sets to handle this as a structured error.",
419            patterns.len()
420        ),
421    }
422}
423
424/// ASCII-case-insensitive counterpart of [`dfa_compile_with_budget`].
425///
426/// # Errors
427/// See [`dfa_compile_with_budget`].
428pub fn dfa_compile_case_insensitive_with_budget(
429    patterns: &[&[u8]],
430    budget_bytes: usize,
431) -> Result<CompiledDfa, DfaCompileError> {
432    dfa_compile_with_budget_ci(patterns, budget_bytes, true)
433}
434
435fn dfa_compile_with_budget_ci(
436    patterns: &[&[u8]],
437    budget_bytes: usize,
438    case_insensitive: bool,
439) -> Result<CompiledDfa, DfaCompileError> {
440    let state_cap = budget_bytes / (256 * core::mem::size_of::<u32>());
441    let inner = dfa_compile_inner_capped(patterns, state_cap, case_insensitive)?;
442    let requested_bytes = (inner.state_count as usize)
443        .saturating_mul(256)
444        .saturating_mul(core::mem::size_of::<u32>());
445    if requested_bytes > budget_bytes {
446        return Err(DfaCompileError::TooLarge {
447            requested_bytes,
448            budget_bytes,
449            state_count: inner.state_count,
450        });
451    }
452    Ok(inner)
453}
454
455/// Canonicalize an ASCII byte for a case-insensitive DFA: `A`..=`Z` map to their
456/// lowercase; every other byte (including non-ASCII) is unchanged. Identity when
457/// `case_insensitive` is false. One owner for the fold so the insert path and the
458/// transition-flatten path cannot disagree on the byte class.
459#[inline]
460fn fold_ascii_byte(b: usize, case_insensitive: bool) -> usize {
461    if case_insensitive && (0x41..=0x5A).contains(&b) {
462        b | 0x20
463    } else {
464        b
465    }
466}
467
468/// Compile a DFA with an explicit state cap.
469///
470/// # Panics
471/// Panics when `pattern_idx` exceeds `u32::MAX - 1`, which the `pid + 1` wire encoding
472/// cannot represent. The caller bounds the pattern count first.
473fn dfa_compile_inner_capped(
474    patterns: &[&[u8]],
475    state_cap: usize,
476    case_insensitive: bool,
477) -> Result<CompiledDfa, DfaCompileError> {
478    const NO_TRANSITION: u32 = u32::MAX;
479
480    let upper_bound = patterns
481        .iter()
482        .fold(0usize, |acc, p| acc.saturating_add(p.len()))
483        .saturating_add(1);
484    let max_pattern_len = patterns
485        .iter()
486        .map(|pattern| pattern.len())
487        .max()
488        .unwrap_or(0)
489        .min(u32::MAX as usize) as u32;
490    let trie_capacity = state_cap.min(upper_bound).max(1);
491
492    let mut trie: Vec<[u32; 256]> = Vec::with_capacity(trie_capacity);
493    let mut accept: Vec<u32> = Vec::with_capacity(trie_capacity);
494    let mut local_accepts: Vec<Vec<u32>> = Vec::with_capacity(trie_capacity);
495
496    trie.push([NO_TRANSITION; 256]);
497    accept.push(0);
498    local_accepts.push(Vec::new());
499
500    for (pattern_idx, pat) in patterns.iter().enumerate() {
501        let mut cur = 0usize;
502        for &b in *pat {
503            // Case-insensitive: fold pattern bytes to lowercase so the trie is
504            // built over the canonical alphabet; uppercase input is redirected
505            // onto the same path in the transition-flatten step below.
506            let b = fold_ascii_byte(b as usize, case_insensitive);
507            let next = trie[cur][b];
508            if next != NO_TRANSITION {
509                cur = next as usize;
510            } else {
511                if trie.len() >= state_cap {
512                    return Err(DfaCompileError::TrieStateCapExceeded { state_cap });
513                }
514                let new_id = trie.len() as u32;
515                trie.push([NO_TRANSITION; 256]);
516                accept.push(0);
517                local_accepts.push(Vec::new());
518                trie[cur][b] = new_id;
519                cur = new_id as usize;
520            }
521        }
522        local_accepts[cur].push(pattern_idx as u32);
523        // The accept fast-path field stores the FIRST (lowest) pattern id that reaches
524        // a given trie node, encoded as pid+1. Using the first-inserted pattern preserves
525        // the stable, predictable semantics documented at CompiledDfa.accept: the
526        // lowest pattern id is canonical. If we overwrote on each iteration, the last
527        // pattern would win, silently misreporting earlier patterns on the fast path
528        // (output_records is unaffected and always carries all pids).
529        if accept[cur] == 0 {
530            accept[cur] = (pattern_idx as u32)
531                .checked_add(1)
532                .expect("pattern_idx must be <= u32::MAX - 1 to fit the pid+1 encoding");
533        }
534    }
535
536    let state_count = trie.len();
537    let mut fail = vec![0u32; state_count];
538    let mut queue = Vec::new();
539    for b in 0..256usize {
540        let child = trie[0][b];
541        if child != NO_TRANSITION {
542            fail[child as usize] = 0;
543            queue.push(child as usize);
544        }
545    }
546    let mut head = 0usize;
547    while head < queue.len() {
548        let state = queue[head];
549        head += 1;
550        for b in 0..256usize {
551            let child = trie[state][b];
552            if child != NO_TRANSITION {
553                let mut f = fail[state] as usize;
554                while f != 0 && trie[f][b] == NO_TRANSITION {
555                    f = fail[f] as usize;
556                }
557                let f_child = trie[f][b];
558                if f_child != NO_TRANSITION && f_child != child {
559                    fail[child as usize] = f_child;
560                }
561                if accept[child as usize] == 0 {
562                    let f_accept = accept[fail[child as usize] as usize];
563                    if f_accept != 0 {
564                        accept[child as usize] = f_accept;
565                    }
566                }
567                queue.push(child as usize);
568            }
569        }
570    }
571
572    let mut bfs_order = Vec::with_capacity(state_count);
573    let mut bfs_queue = Vec::with_capacity(state_count);
574    bfs_queue.push(0usize);
575    let mut bfs_head = 0usize;
576    while bfs_head < bfs_queue.len() {
577        let state = bfs_queue[bfs_head];
578        bfs_head += 1;
579        bfs_order.push(state);
580
581        for b in 0..256usize {
582            let child = trie[state][b];
583            if child != NO_TRANSITION {
584                bfs_queue.push(child as usize);
585            }
586        }
587    }
588
589    let mut output_counts = vec![0usize; state_count];
590    for &state in &bfs_order {
591        let f = fail[state] as usize;
592        let inherited = if f != 0 && f != state {
593            output_counts[f]
594        } else {
595            0
596        };
597        let adds_local = local_accepts[state]
598            .iter()
599            .filter(|&&pattern| !fail_chain_accepts_pattern(state, pattern, &fail, &local_accepts))
600            .count();
601        output_counts[state] = inherited + adds_local;
602    }
603
604    let mut output_offsets = vec![0u32; state_count + 1];
605    for state in 0..state_count {
606        output_offsets[state + 1] =
607            output_offsets[state].saturating_add(output_counts[state] as u32);
608    }
609    let mut output_records = vec![0u32; output_offsets[state_count] as usize];
610    for &state in &bfs_order {
611        let mut write = output_offsets[state] as usize;
612        let f = fail[state] as usize;
613        if f != 0 && f != state {
614            let start = output_offsets[f] as usize;
615            let end = output_offsets[f + 1] as usize;
616            let len = end - start;
617            output_records.copy_within(start..end, write);
618            write += len;
619        }
620        for &pattern in &local_accepts[state] {
621            let start = output_offsets[state] as usize;
622            if !output_records[start..write].contains(&pattern) {
623                output_records[write] = pattern;
624                write += 1;
625            }
626        }
627        debug_assert_eq!(write, output_offsets[state + 1] as usize);
628    }
629
630    let mut transitions = vec![0u32; state_count * 256];
631    let mut accept_out = vec![0u32; state_count];
632    for state in 0..state_count {
633        accept_out[state] = accept[state];
634        for b in 0..256usize {
635            // Resolve the goto for the FOLDED byte class and store it under the
636            // raw byte column `b`, so a case-insensitive DFA answers `b'A'` with
637            // the same next state as `b'a'` (identity when case-sensitive). The
638            // trie only carries folded edges, so the fail-chain walk uses the
639            // folded index throughout.
640            let fb = fold_ascii_byte(b, case_insensitive);
641            let mut s = state;
642            loop {
643                let child = trie[s][fb];
644                if child != NO_TRANSITION {
645                    transitions[state * 256 + b] = child;
646                    break;
647                }
648                if s == 0 {
649                    transitions[state * 256 + b] = 0;
650                    break;
651                }
652                s = fail[s] as usize;
653            }
654        }
655    }
656
657    Ok(CompiledDfa {
658        transitions,
659        accept: accept_out,
660        state_count: state_count as u32,
661        max_pattern_len,
662        output_offsets,
663        output_records,
664    })
665}
666
667fn fail_chain_accepts_pattern(
668    state: usize,
669    pattern: u32,
670    fail: &[u32],
671    local_accepts: &[Vec<u32>],
672) -> bool {
673    let mut f = fail[state] as usize;
674    while f != 0 && f != state {
675        if local_accepts[f].contains(&pattern) {
676            return true;
677        }
678        let next = fail[f] as usize;
679        if next == f {
680            return false;
681        }
682        f = next;
683    }
684    false
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690
691    #[test]
692    fn single_string_matches_only_its_suffix() {
693        let dfa = dfa_compile(&[b"abc"]);
694        let input = b"xxabcxx";
695
696        // Walk to the state immediately after scanning "xxabc" (before the trailing xx).
697        // We can't stop mid-scan in a loop; trace the exact 5-byte prefix instead.
698        let mut s = 0usize;
699        for &b in b"xxabc" {
700            s = dfa.transitions[s * 256 + b as usize] as usize;
701        }
702        // Pattern 0 encodes as accept = pid+1 = 0+1 = 1. Asserting == 1 catches both
703        // "no match" (accept=0) and wrong pid (accept != 1), including the pid+1 wrap
704        // bug where pid=u32::MAX would encode as 0 and silence the match.
705        assert_eq!(
706            dfa.accept[s], 1,
707            "after 'xxabc' the DFA must be in a state that accepts pattern 0 (encoded as 1); \
708             got accept[{s}] = {}",
709            dfa.accept[s]
710        );
711        // Verify output_records carries the correct pid for the full-match path.
712        let rec_start = dfa.output_offsets[s] as usize;
713        let rec_end = dfa.output_offsets[s + 1] as usize;
714        assert_eq!(
715            &dfa.output_records[rec_start..rec_end],
716            &[0u32],
717            "output_records for the accept state must contain exactly [0] (pid=0)"
718        );
719
720        // Negative: after trailing 'x' the DFA must have left the accept state.
721        let s_after_x = dfa.transitions[s * 256 + b'x' as usize] as usize;
722        assert_eq!(
723            dfa.accept[s_after_x], 0,
724            "after trailing 'x' the DFA must not accept; pattern 'abc' ends before it"
725        );
726    }
727
728    /// Walk `dfa` over `haystack` and return every `(pattern_id, end_pos)` match,
729    /// the plain-Rust oracle used to prove case-insensitive folding.
730    fn scan_ends(dfa: &CompiledDfa, haystack: &[u8]) -> std::collections::BTreeSet<(u32, u32)> {
731        let mut state = 0usize;
732        let mut out = std::collections::BTreeSet::new();
733        for (pos, &b) in haystack.iter().enumerate() {
734            state = dfa.transitions[state * 256 + b as usize] as usize;
735            let begin = dfa.output_offsets[state] as usize;
736            let end = dfa.output_offsets[state + 1] as usize;
737            for &pid in &dfa.output_records[begin..end] {
738                out.insert((pid, pos as u32));
739            }
740        }
741        out
742    }
743
744    #[test]
745    fn case_insensitive_matches_every_case_variant() {
746        let dfa = dfa_compile_case_insensitive(&[b"key"]);
747        // Every case variant of "key" ends at position 2 in its 3-byte window.
748        for variant in [b"KEY", b"Key", b"kEy", b"keY", b"kEY", b"key"] {
749            let hits = scan_ends(&dfa, variant);
750            assert!(
751                hits.contains(&(0, 2)),
752                "case-insensitive DFA must match {:?} as pattern 0 ending at 2, got {hits:?}",
753                std::str::from_utf8(variant).unwrap()
754            );
755        }
756        // A genuinely different string must NOT match.
757        assert!(
758            scan_ends(&dfa, b"kez").is_empty(),
759            "case-insensitive folding must not match a non-variant string"
760        );
761    }
762
763    #[test]
764    fn case_insensitive_is_identical_to_host_folded_case_sensitive() {
765        // The correctness contract the plan names: a case-insensitive scan of the
766        // RAW mixed-case haystack must equal a case-SENSITIVE scan of the
767        // host-lowercased haystack with lowercased patterns. Randomized differential.
768        let alphabet = b"aAbBkK_9/";
769        let mut seed = 0x9E37_79B1u64;
770        let mut next = || {
771            seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
772            (seed >> 33) as u32
773        };
774        for _ in 0..500 {
775            // 1..=4 patterns, each 1..=5 bytes over the mixed-case alphabet.
776            let pat_count = 1 + (next() % 4) as usize;
777            let patterns_owned: Vec<Vec<u8>> = (0..pat_count)
778                .map(|_| {
779                    let len = 1 + (next() % 5) as usize;
780                    (0..len)
781                        .map(|_| alphabet[(next() as usize) % alphabet.len()])
782                        .collect()
783                })
784                .collect();
785            let patterns: Vec<&[u8]> = patterns_owned.iter().map(Vec::as_slice).collect();
786
787            let hay_len = 4 + (next() % 40) as usize;
788            let haystack: Vec<u8> = (0..hay_len)
789                .map(|_| alphabet[(next() as usize) % alphabet.len()])
790                .collect();
791
792            // Case-insensitive DFA over the raw haystack.
793            let ci = dfa_compile_case_insensitive(&patterns);
794            let ci_hits = scan_ends(&ci, &haystack);
795
796            // Host-folded reference: lowercase patterns + lowercase haystack,
797            // case-SENSITIVE DFA. This is exactly the pass W2-1 replaces.
798            let lowered_pat: Vec<Vec<u8>> = patterns_owned
799                .iter()
800                .map(|p| p.iter().map(|b| b.to_ascii_lowercase()).collect())
801                .collect();
802            let lowered_refs: Vec<&[u8]> = lowered_pat.iter().map(Vec::as_slice).collect();
803            let lowered_hay: Vec<u8> = haystack.iter().map(|b| b.to_ascii_lowercase()).collect();
804            let reference = dfa_compile(&lowered_refs);
805            let ref_hits = scan_ends(&reference, &lowered_hay);
806
807            assert_eq!(
808                ci_hits, ref_hits,
809                "case-insensitive DFA over raw haystack must equal host-folded case-sensitive scan\n\
810                 patterns={patterns_owned:?}\n\
811                 haystack={:?}",
812                String::from_utf8_lossy(&haystack)
813            );
814        }
815    }
816
817    #[test]
818    fn overlapping_patterns_both_accept() {
819        let patterns: [&[u8]; 4] = [b"he", b"she", b"his", b"hers"];
820        let dfa = dfa_compile(&patterns);
821        let mut state = 0u32;
822        let mut matches = Vec::new();
823        for &b in b"ushers" {
824            state = dfa.transitions[(state as usize) * 256 + (b as usize)];
825            let accept = dfa.accept[state as usize];
826            if accept != 0 {
827                matches.push(accept - 1);
828            }
829        }
830        assert!(matches.contains(&1), "must accept `she`");
831        assert!(
832            matches.contains(&0) || matches.contains(&3),
833            "must accept `he` or `hers`"
834        );
835    }
836
837    #[test]
838    fn duplicate_literals_preserve_distinct_output_records() {
839        let dfa = dfa_compile(&[b"B".as_slice(), b"B".as_slice(), b"AB".as_slice()]);
840        let state_b = dfa.transitions[b'B' as usize] as usize;
841        let state_ab = {
842            let state_a = dfa.transitions[b'A' as usize] as usize;
843            dfa.transitions[state_a * 256 + b'B' as usize] as usize
844        };
845
846        let b_start = dfa.output_offsets[state_b] as usize;
847        let b_end = dfa.output_offsets[state_b + 1] as usize;
848        assert_eq!(
849            &dfa.output_records[b_start..b_end],
850            &[0, 1],
851            "Fix: exact duplicate literals must keep both consumer pattern ids in output_records."
852        );
853
854        let ab_start = dfa.output_offsets[state_ab] as usize;
855        let ab_end = dfa.output_offsets[state_ab + 1] as usize;
856        assert_eq!(
857            &dfa.output_records[ab_start..ab_end],
858            &[0, 1, 2],
859            "Fix: suffix inheritance must preserve duplicate suffix pattern ids plus the local longer pattern."
860        );
861    }
862
863    #[test]
864    fn empty_pattern_list_yields_trivial_dfa() {
865        let dfa = dfa_compile(&[]);
866        assert_eq!(dfa.state_count, 1);
867        assert_eq!(dfa.transitions.len(), 256);
868        assert!(dfa.transitions.iter().all(|&t| t == 0));
869        assert_eq!(dfa.accept, vec![0]);
870    }
871
872    #[test]
873    fn budget_exhaustion_returns_structured_error() {
874        let err = dfa_compile_with_budget(&[b"ab", b"cd"], 1024).unwrap_err();
875        match err {
876            DfaCompileError::TooLarge {
877                requested_bytes,
878                budget_bytes,
879                state_count,
880            } => {
881                assert!(
882                    requested_bytes > budget_bytes,
883                    "TooLarge must carry requested > budget"
884                );
885                assert_eq!(budget_bytes, 1024);
886                assert!(state_count >= 1);
887            }
888            DfaCompileError::TrieStateCapExceeded { state_cap } => {
889                assert!(state_cap <= 1024);
890            }
891        }
892    }
893
894    #[test]
895    fn generous_budget_succeeds() {
896        let dfa = dfa_compile_with_budget(&[b"abc"], DEFAULT_DFA_BUDGET_BYTES)
897            .expect("Fix: generous budget must succeed; restore this invariant before continuing.");
898        assert!(dfa.state_count >= 1);
899    }
900
901    #[test]
902    fn zero_budget_rejects_every_nonempty_dfa() {
903        let err = dfa_compile_with_budget(&[b"a"], 0).unwrap_err();
904        assert!(matches!(
905            err,
906            DfaCompileError::TooLarge { .. } | DfaCompileError::TrieStateCapExceeded { .. }
907        ));
908    }
909
910    /// Finding #13 (P2): accept field last-writer-wins bug.
911    /// When two patterns share a final trie node (duplicate literals or suffix patterns),
912    /// the accept fast-path field must store the FIRST (lowest) pattern id, not the last.
913    /// Before the fix, accept[state_B] = 2 (pid=1, last writer) instead of 1 (pid=0, first).
914    /// Finding #14 (P2): from_bytes incorrectly rejected DFAs compiled from
915    /// zero-length patterns because max_pattern_len==0 with accept states was
916    /// treated as "corrupt sentinel" rather than "empty-pattern accept".
917    #[test]
918    fn empty_pattern_dfa_round_trips() {
919        let dfa = dfa_compile(&[b"".as_slice()]);
920        // The root state must accept (empty string matches everywhere).
921        assert_eq!(
922            dfa.accept[0], 1,
923            "dfa_compile(&[b\"\"]) root state must accept pattern 0 (accept=1)"
924        );
925        assert_eq!(
926            dfa.max_pattern_len, 0,
927            "empty pattern must produce max_pattern_len=0"
928        );
929        let bytes = dfa
930            .to_bytes()
931            .expect("Fix: serialization must succeed for empty-pattern DFA");
932        let dfa2 = CompiledDfa::from_bytes(&bytes)
933            .expect("Fix: round-trip must succeed for empty-pattern DFA");
934        assert_eq!(
935            dfa2.accept[0], 1,
936            "deserialized DFA must preserve accept[0]=1 for empty-pattern compile"
937        );
938        assert_eq!(
939            dfa2.max_pattern_len, 0,
940            "deserialized DFA must preserve max_pattern_len=0"
941        );
942    }
943
944    #[test]
945    fn from_bytes_rejects_zero_max_pattern_len_with_non_root_accept() {
946        // dfa_compile(&[b"AKIA"]) produces a non-root accept state (the state reached
947        // after consuming A-K-I-A) and max_pattern_len == 4. A blob that claims
948        // max_pattern_len == 0 while still carrying that deeper accept is internally
949        // inconsistent, the canonical symptom of a corrupted cache whose length scalar
950        // was zeroed. Decoding it would yield a DFA whose under-sized replay/segmentation
951        // window silently drops cross-boundary matches, so from_bytes must fail closed.
952        let mut dfa = dfa_compile(&[b"AKIA".as_slice()]);
953        assert!(
954            dfa.max_pattern_len >= 1,
955            "precondition: AKIA must compile to max_pattern_len >= 1, got {}",
956            dfa.max_pattern_len
957        );
958        assert!(
959            dfa.accept.iter().skip(1).any(|&state| state != 0),
960            "precondition: AKIA must have a non-root accept state"
961        );
962        // Forge the corruption by zeroing only the max_pattern_len scalar; every other
963        // table stays consistent, so the rejection is attributable solely to the new check.
964        dfa.max_pattern_len = 0;
965        let bytes = dfa.to_bytes().expect("encode forged DFA wire blob");
966        let err = CompiledDfa::from_bytes(&bytes).unwrap_err();
967        assert!(
968            matches!(
969                err,
970                DfaWireError::ShapeMismatch {
971                    reason: "max_pattern_len == 0 but a non-root state accepts"
972                }
973            ),
974            "expected ShapeMismatch with the non-root-accept reason, got {err:?}"
975        );
976    }
977
978    #[test]
979    fn from_bytes_rejects_out_of_range_transition_target() {
980        // Every transition value is consumed as the next state index
981        // (`transitions[state * 256 + byte]`), so a target >= state_count would
982        // OOB-index on the following step. A corrupt/stale cache blob must fail
983        // closed at decode, not panic (or read a garbage state) mid-scan.
984        let mut dfa = dfa_compile(&[b"abc".as_slice()]);
985        assert!(
986            dfa.state_count >= 2,
987            "precondition: fixture must have real states"
988        );
989        assert!(
990            dfa.transitions
991                .iter()
992                .all(|&t| (t as usize) < dfa.state_count as usize),
993            "precondition: an honest compile keeps every transition target in range"
994        );
995        // state_count itself is the first out-of-range state id (valid ids are 0..state_count).
996        // Forge only this one target; every length/offset table stays consistent, so the
997        // rejection is attributable solely to the new bounds check.
998        dfa.transitions[0] = dfa.state_count;
999        let bytes = dfa.to_bytes().expect("encode forged DFA wire blob");
1000        let err = CompiledDfa::from_bytes(&bytes).unwrap_err();
1001        assert!(
1002            matches!(
1003                err,
1004                DfaWireError::ShapeMismatch {
1005                    reason: "transition target out of range for state_count"
1006                }
1007            ),
1008            "expected the transition-target range violation, got {err:?}"
1009        );
1010    }
1011
1012    #[test]
1013    fn duplicate_literal_accept_field_contains_first_pattern() {
1014        // dfa_compile(&[b"B", b"B"]): both patterns share trie state 1 (after b'B').
1015        // pid=0 is inserted first → accept[state_B] must be 1 (0+1).
1016        // pid=1 is inserted second → must not overwrite → accept[state_B] stays 1.
1017        let dfa = dfa_compile(&[b"B".as_slice(), b"B".as_slice()]);
1018        let state_b = dfa.transitions[b'B' as usize] as usize;
1019        assert_eq!(
1020            dfa.accept[state_b],
1021            1,
1022            "first duplicate literal (pid=0) must win the accept fast-path field (encoded as pid+1=1); \
1023             last-writer-wins would give 2 (pid=1)"
1024        );
1025        // The output_records must still carry both pids for the full-match path.
1026        let start = dfa.output_offsets[state_b] as usize;
1027        let end = dfa.output_offsets[state_b + 1] as usize;
1028        assert_eq!(
1029            &dfa.output_records[start..end],
1030            &[0u32, 1u32],
1031            "duplicate literals must both appear in output_records"
1032        );
1033    }
1034
1035    #[test]
1036    fn infallible_compile_does_not_silently_return_empty_on_error() {
1037        let src = std::fs::read_to_string(concat!(
1038            env!("CARGO_MANIFEST_DIR"),
1039            "/src/matching/dfa_compile.rs"
1040        ))
1041        .expect("Fix: DFA compiler source must be readable");
1042        let production = src
1043            .split("#[cfg(test)]")
1044            .next()
1045            .expect("Fix: meta-test scans production sources; update fixture path if module moved - production section must exist");
1046        assert!(
1047            !production.contains("unwrap_or_else(|_| CompiledDfa::empty())"),
1048            "dfa_compile must never hide a failed compile by returning the empty rejecting automaton"
1049        );
1050        assert!(
1051            production.contains("use dfa_compile_with_budget and shard oversized pattern sets"),
1052            "dfa_compile panic must explain the structured recovery path"
1053        );
1054    }
1055}