1use super::staging_reserve::{
4 reserve_hash_map_capacity as reserve_catalog_map, reserve_vec_capacity as reserve_catalog_vec,
5};
6use crate::PipelineError;
7use rustc_hash::FxHashMap;
8
9pub const ALPHABET_SIZE: u32 = 256;
15const ALPHABET_SIZE_USIZE: usize = 256;
16
17pub const RULE_META_WORDS: usize = 5;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct BatchRuleProgram {
26 pub rule_idx: u32,
28 pub transitions: Vec<u32>,
30 pub accept: Vec<u32>,
32 pub state_count: u32,
34}
35
36impl BatchRuleProgram {
37 pub fn new(
44 rule_idx: u32,
45 transitions: Vec<u32>,
46 accept: Vec<u32>,
47 state_count: u32,
48 ) -> Result<Self, PipelineError> {
49 validate_rule_shape(rule_idx, &transitions, &accept, state_count)?;
50 Ok(Self {
51 rule_idx,
52 transitions,
53 accept,
54 state_count,
55 })
56 }
57}
58
59#[repr(C)]
69#[derive(Debug, Clone, Copy, PartialEq, Eq, bytemuck::Pod, bytemuck::Zeroable)]
70pub struct RuleMeta {
71 pub transition_base: u32,
73 pub accept_base: u32,
75 pub state_count: u32,
77 pub class_map_base: u32,
79 pub num_classes: u32,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct BatchRuleRejection {
86 pub rule_idx: Option<u32>,
88 pub reason: String,
90}
91
92pub struct PackedRuleCatalog {
94 pub rule_meta: Vec<RuleMeta>,
96 pub transitions: Vec<u32>,
100 pub accept: Vec<u32>,
102 pub class_maps: Vec<u32>,
105 pub rejected_rules: Vec<BatchRuleRejection>,
107}
108
109#[derive(Default)]
112pub struct RuleCatalogPackingScratch {
113 pub rule_meta: Vec<RuleMeta>,
115 pub transitions: Vec<u32>,
117 pub accept: Vec<u32>,
119 pub class_maps: Vec<u32>,
121 pub rejected_rules: Vec<BatchRuleRejection>,
123 unique_storage: FxHashMap<[u8; 32], UniqueStorageLayout>,
126 occupied: Vec<bool>,
127 addressed: Vec<bool>,
128 class_scratch: Vec<u32>,
130}
131
132#[derive(Clone, Copy)]
134struct UniqueStorageLayout {
135 transition_base: u32,
136 accept_base: u32,
137 state_count: u32,
138 class_map_base: u32,
139 num_classes: u32,
140}
141
142#[must_use]
144pub fn accepted_rule_fingerprints(
145 rules: &[BatchRuleProgram],
146) -> (Vec<[u8; 32]>, Vec<BatchRuleRejection>) {
147 let mut fingerprints = Vec::new();
148 let mut occupied = Vec::new();
149 let mut addressed = Vec::new();
150 let rejections =
151 accepted_rule_fingerprints_into(rules, &mut fingerprints, &mut occupied, &mut addressed);
152 (fingerprints, rejections)
153}
154
155pub fn accepted_rule_fingerprints_into(
162 rules: &[BatchRuleProgram],
163 fingerprints: &mut Vec<[u8; 32]>,
164 occupied: &mut Vec<bool>,
165 addressed: &mut Vec<bool>,
166) -> Vec<BatchRuleRejection> {
167 let mut rejections = Vec::new();
168 accepted_rule_fingerprints_and_rejections_into(
169 rules,
170 fingerprints,
171 occupied,
172 addressed,
173 &mut rejections,
174 );
175 rejections
176}
177
178pub fn accepted_rule_fingerprints_and_rejections_into(
185 rules: &[BatchRuleProgram],
186 fingerprints: &mut Vec<[u8; 32]>,
187 occupied: &mut Vec<bool>,
188 addressed: &mut Vec<bool>,
189 rejections: &mut Vec<BatchRuleRejection>,
190) {
191 fingerprints.clear();
192 fingerprints.resize(rules.len(), [0; 32]);
193 occupied.clear();
194 occupied.resize(rules.len(), false);
195 addressed.clear();
196 addressed.resize(rules.len(), false);
197 rejections.clear();
198
199 for rule in rules {
200 mark_addressed(addressed, rule.rule_idx);
201 match validate_rule_shape(
202 rule.rule_idx,
203 &rule.transitions,
204 &rule.accept,
205 rule.state_count,
206 ) {
207 Ok(()) => match claim_dense_index(occupied, rule.rule_idx, rules.len()) {
208 Ok(index) => fingerprints[index] = rule_fingerprint(rule),
209 Err(rejection) => rejections.push(rejection),
210 },
211 Err(error) => rejections.push(BatchRuleRejection {
212 rule_idx: Some(rule.rule_idx),
213 reason: error.to_string(),
214 }),
215 }
216 }
217
218 extend_missing_rejections(occupied, addressed, rejections);
219 let mut write = 0;
220 for read in 0..occupied.len() {
221 if occupied[read] {
222 fingerprints[write] = fingerprints[read];
223 write += 1;
224 }
225 }
226 fingerprints.truncate(write);
227}
228
229pub fn pack_rule_catalog(rules: &[BatchRuleProgram]) -> Result<PackedRuleCatalog, PipelineError> {
234 let mut scratch = RuleCatalogPackingScratch::default();
235 pack_rule_catalog_into(rules, &mut scratch)?;
236 Ok(PackedRuleCatalog {
237 rule_meta: scratch.rule_meta,
238 transitions: scratch.transitions,
239 accept: scratch.accept,
240 class_maps: scratch.class_maps,
241 rejected_rules: scratch.rejected_rules,
242 })
243}
244
245pub fn pack_rule_catalog_into(
251 rules: &[BatchRuleProgram],
252 scratch: &mut RuleCatalogPackingScratch,
253) -> Result<(), PipelineError> {
254 scratch.unique_storage.clear();
255 reserve_catalog_map(
256 &mut scratch.unique_storage,
257 rules.len(),
258 "unique DFA storage",
259 )?;
260 scratch.transitions.clear();
266 reserve_catalog_vec(&mut scratch.transitions, 1, "inert transition row")?;
267 scratch.transitions.push(0);
268 scratch.accept.clear();
269 reserve_catalog_vec(&mut scratch.accept, 1, "inert accept row")?;
270 scratch.accept.push(0);
271 scratch.class_maps.clear();
272 reserve_catalog_vec(
273 &mut scratch.class_maps,
274 ALPHABET_SIZE_USIZE,
275 "inert byte-class map",
276 )?;
277 scratch.class_maps.resize(ALPHABET_SIZE_USIZE, 0);
278 scratch.rule_meta.clear();
279 reserve_catalog_vec(&mut scratch.rule_meta, rules.len(), "rule metadata")?;
280 scratch.rule_meta.resize(
281 rules.len(),
282 RuleMeta {
283 transition_base: 0,
284 accept_base: 0,
285 state_count: 1,
286 class_map_base: 0,
287 num_classes: 1,
288 },
289 );
290 scratch.rejected_rules.clear();
291 reserve_catalog_vec(
292 &mut scratch.rejected_rules,
293 rules.len(),
294 "rule rejection rows",
295 )?;
296 scratch.occupied.clear();
297 reserve_catalog_vec(&mut scratch.occupied, rules.len(), "dense occupancy bitmap")?;
298 scratch.occupied.resize(rules.len(), false);
299 scratch.addressed.clear();
300 reserve_catalog_vec(
301 &mut scratch.addressed,
302 rules.len(),
303 "dense addressed bitmap",
304 )?;
305 scratch.addressed.resize(rules.len(), false);
306
307 for rule in rules {
308 mark_addressed(&mut scratch.addressed, rule.rule_idx);
309 if let Err(error) = validate_rule_shape(
310 rule.rule_idx,
311 &rule.transitions,
312 &rule.accept,
313 rule.state_count,
314 ) {
315 scratch.rejected_rules.push(BatchRuleRejection {
316 rule_idx: Some(rule.rule_idx),
317 reason: error.to_string(),
318 });
319 continue;
320 }
321
322 let meta_index = match claim_dense_index(
323 &mut scratch.occupied,
324 rule.rule_idx,
325 scratch.rule_meta.len(),
326 ) {
327 Ok(index) => index,
328 Err(rejection) => {
329 scratch.rejected_rules.push(rejection);
330 continue;
331 }
332 };
333
334 let storage_fingerprint = dfa_storage_fingerprint(rule);
335 let layout = if let Some(layout) = scratch.unique_storage.get(&storage_fingerprint) {
336 *layout
337 } else {
338 let num_classes = build_byte_class_map_for_table(
344 &rule.transitions,
345 rule.state_count as usize,
346 &mut scratch.class_scratch,
347 );
348
349 let class_map_base =
350 u32::try_from(scratch.class_maps.len()).map_err(|_| PipelineError::QueueFull {
351 queue: "submission",
352 fix: "flattened byte-class map table exceeds u32::MAX words; split the rule catalog into smaller groups",
353 })?;
354 let class_map_target = scratch
355 .class_maps
356 .len()
357 .checked_add(ALPHABET_SIZE_USIZE)
358 .ok_or(PipelineError::QueueFull {
359 queue: "submission",
360 fix: "flattened byte-class map length overflows usize; split the rule catalog into smaller groups",
361 })?;
362 reserve_catalog_vec(
363 &mut scratch.class_maps,
364 class_map_target,
365 "flattened byte-class map storage",
366 )?;
367 scratch.class_maps.extend_from_slice(&scratch.class_scratch);
368
369 let transition_base =
370 u32::try_from(scratch.transitions.len()).map_err(|_| PipelineError::QueueFull {
371 queue: "submission",
372 fix: "flattened transition table exceeds u32::MAX words; split the rule catalog into smaller groups",
373 })?;
374 let accept_base = u32::try_from(scratch.accept.len()).map_err(|_| PipelineError::QueueFull {
375 queue: "submission",
376 fix: "flattened accept table exceeds u32::MAX words; split the rule catalog into smaller groups",
377 })?;
378 let compressed_words = (rule.state_count as usize)
383 .checked_mul(num_classes as usize)
384 .ok_or(PipelineError::QueueFull {
385 queue: "submission",
386 fix: "compressed transition block size overflows usize; split the rule catalog into smaller groups",
387 })?;
388 let transition_target = scratch
389 .transitions
390 .len()
391 .checked_add(compressed_words)
392 .ok_or(PipelineError::QueueFull {
393 queue: "submission",
394 fix: "flattened transition table length overflows usize; split the rule catalog into smaller groups",
395 })?;
396 reserve_catalog_vec(
397 &mut scratch.transitions,
398 transition_target,
399 "flattened transition storage",
400 )?;
401 let accept_target = scratch
402 .accept
403 .len()
404 .checked_add(rule.accept.len())
405 .ok_or(PipelineError::QueueFull {
406 queue: "submission",
407 fix: "flattened accept table length overflows usize; split the rule catalog into smaller groups",
408 })?;
409 reserve_catalog_vec(
410 &mut scratch.accept,
411 accept_target,
412 "flattened accept storage",
413 )?;
414 compress_dense_transitions_into(
418 &rule.transitions,
419 rule.state_count as usize,
420 &scratch.class_scratch,
421 num_classes,
422 &mut scratch.transitions,
423 );
424 scratch.accept.extend_from_slice(&rule.accept);
425
426 let layout = UniqueStorageLayout {
427 transition_base,
428 accept_base,
429 state_count: rule.state_count,
430 class_map_base,
431 num_classes,
432 };
433 scratch.unique_storage.insert(storage_fingerprint, layout);
434 layout
435 };
436 scratch.rule_meta[meta_index] = RuleMeta {
437 transition_base: layout.transition_base,
438 accept_base: layout.accept_base,
439 state_count: layout.state_count,
440 class_map_base: layout.class_map_base,
441 num_classes: layout.num_classes,
442 };
443 }
444
445 extend_missing_rejections(
446 &scratch.occupied,
447 &scratch.addressed,
448 &mut scratch.rejected_rules,
449 );
450 Ok(())
451}
452
453#[must_use]
479pub fn build_byte_class_map_for_table(
480 transitions: &[u32],
481 state_count: usize,
482 out: &mut Vec<u32>,
483) -> u32 {
484 out.clear();
485 out.resize(ALPHABET_SIZE_USIZE, 0);
486 let mut signature_to_class: FxHashMap<Vec<u32>, u32> = FxHashMap::default();
490 let mut next_class: u32 = 0;
491 let mut signature = Vec::with_capacity(state_count);
492 for byte in 0..ALPHABET_SIZE_USIZE {
493 signature.clear();
494 for state in 0..state_count {
495 signature.push(transitions[state * ALPHABET_SIZE_USIZE + byte]);
496 }
497 let class = match signature_to_class.get(&signature) {
498 Some(&class) => class,
499 None => {
500 let class = next_class;
501 next_class += 1;
502 signature_to_class.insert(signature.clone(), class);
503 class
504 }
505 };
506 out[byte] = class;
507 }
508 next_class
509}
510
511pub fn compress_dense_transitions_into(
520 dense: &[u32],
521 state_count: usize,
522 class_map: &[u32],
523 num_classes: u32,
524 out: &mut Vec<u32>,
525) {
526 let num_classes = num_classes as usize;
527 let mut class_representative = vec![0usize; num_classes];
528 let mut seen = vec![false; num_classes];
529 for (byte, &class) in class_map.iter().enumerate() {
530 let class = class as usize;
531 if !seen[class] {
532 seen[class] = true;
533 class_representative[class] = byte;
534 }
535 }
536 for state in 0..state_count {
537 let dense_row = state * ALPHABET_SIZE_USIZE;
538 for &rep_byte in &class_representative {
539 out.push(dense[dense_row + rep_byte]);
540 }
541 }
542}
543
544pub fn try_pack_u16_transitions_into(
564 compressed: &[u32],
565 out: &mut Vec<u32>,
566) -> Result<(), PipelineError> {
567 for (idx, &target) in compressed.iter().enumerate() {
568 if target > u32::from(u16::MAX) {
569 return Err(PipelineError::Backend(format!(
570 "transition target {target} at index {idx} exceeds u16::MAX ({}); u16 packing \
571 would silently truncate it and corrupt the automaton. Fix: keep this catalog on \
572 the u32 transition path (state_count must be <= 65536 for u16 packing).",
573 u16::MAX
574 )));
575 }
576 }
577 out.clear();
578 out.reserve(compressed.len().div_ceil(2));
579 let mut chunks = compressed.chunks_exact(2);
580 for pair in chunks.by_ref() {
581 out.push(pair[0] | (pair[1] << 16));
583 }
584 if let [last] = chunks.remainder() {
585 out.push(*last);
586 }
587 Ok(())
588}
589
590#[must_use]
595pub fn unpack_u16_transition(packed: &[u32], flat_index: usize) -> u32 {
596 let word = packed[flat_index / 2];
597 (word >> ((flat_index & 1) * 16)) & 0xFFFF
598}
599
600fn validate_rule_shape(
601 rule_idx: u32,
602 transitions: &[u32],
603 accept: &[u32],
604 state_count: u32,
605) -> Result<(), PipelineError> {
606 let expected_transitions = usize::try_from(state_count)
607 .ok()
608 .and_then(|count| count.checked_mul(ALPHABET_SIZE_USIZE))
609 .ok_or_else(|| {
610 PipelineError::Backend("rule transition table size overflowed usize".to_string())
611 })?;
612 if transitions.len() != expected_transitions {
613 return Err(PipelineError::Backend(format!(
614 "rule {rule_idx} transition table has {} words, expected {expected_transitions}. Fix: compile a dense state_count * 256 DFA table before batch dispatch.",
615 transitions.len()
616 )));
617 }
618 let state_count_usize = usize::try_from(state_count).map_err(|source| {
619 PipelineError::Backend(format!(
620 "rule {rule_idx} state_count {state_count} cannot fit usize: {source}. Fix: shard the DFA state space before batch dispatch."
621 ))
622 })?;
623 if accept.len() != state_count_usize {
624 return Err(PipelineError::Backend(format!(
625 "rule {rule_idx} accept table has {} words, expected {state_count}. Fix: emit one accept entry per DFA state before batch dispatch.",
626 accept.len()
627 )));
628 }
629 Ok(())
630}
631
632fn rule_fingerprint(rule: &BatchRuleProgram) -> [u8; 32] {
633 let mut hasher = blake3::Hasher::new();
634 hasher.update(&rule.rule_idx.to_le_bytes());
635 hasher.update(bytemuck::cast_slice(&rule.transitions));
636 hasher.update(bytemuck::cast_slice(&rule.accept));
637 hasher.update(&rule.state_count.to_le_bytes());
638 *hasher.finalize().as_bytes()
639}
640
641fn dfa_storage_fingerprint(rule: &BatchRuleProgram) -> [u8; 32] {
642 let mut hasher = blake3::Hasher::new();
643 hasher.update(bytemuck::cast_slice(&rule.transitions));
644 hasher.update(bytemuck::cast_slice(&rule.accept));
645 hasher.update(&rule.state_count.to_le_bytes());
646 *hasher.finalize().as_bytes()
647}
648
649fn mark_addressed(addressed: &mut [bool], rule_idx: u32) {
650 if let Some(index) = usize::try_from(rule_idx)
651 .ok()
652 .filter(|index| *index < addressed.len())
653 {
654 addressed[index] = true;
655 }
656}
657
658fn claim_dense_index(
659 occupied: &mut [bool],
660 rule_idx: u32,
661 slot_count: usize,
662) -> Result<usize, BatchRuleRejection> {
663 let Some(meta_index) = usize::try_from(rule_idx).ok() else {
664 return Err(BatchRuleRejection {
665 rule_idx: Some(rule_idx),
666 reason: "rule_idx exceeds usize. Fix: rebuild the batch with a smaller rule catalog"
667 .to_string(),
668 });
669 };
670 if meta_index >= slot_count {
671 return Err(BatchRuleRejection {
672 rule_idx: Some(rule_idx),
673 reason: format!(
674 "rule_idx {rule_idx} falls outside 0..{slot_count}. Fix: keep the rule catalog dense so the batch work queue can address every rule"
675 ),
676 });
677 }
678 if occupied[meta_index] {
679 return Err(BatchRuleRejection {
680 rule_idx: Some(rule_idx),
681 reason: format!(
682 "duplicate rule_idx {rule_idx}. Fix: keep exactly one rule per dense rule-table slot"
683 ),
684 });
685 }
686 occupied[meta_index] = true;
687 Ok(meta_index)
688}
689
690fn extend_missing_rejections(
691 occupied: &[bool],
692 addressed: &[bool],
693 out: &mut Vec<BatchRuleRejection>,
694) {
695 for (rule_idx, (occupied, addressed)) in occupied
696 .iter()
697 .copied()
698 .zip(addressed.iter().copied())
699 .enumerate()
700 {
701 if !occupied && !addressed {
702 let Ok(rule_idx_u32) = u32::try_from(rule_idx) else {
703 continue;
704 };
705 out.push(BatchRuleRejection {
706 rule_idx: Some(rule_idx_u32),
707 reason: format!(
708 "rule_idx {rule_idx} has no valid catalog entry. Fix: provide a well-formed DFA for every dense rule slot before batch dispatch"
709 ),
710 });
711 }
712 }
713}
714
715#[cfg(test)]
716
717mod tests {
718 use super::*;
719
720 fn packed_next_state(
724 packed: &PackedRuleCatalog,
725 meta_index: usize,
726 state: u32,
727 byte: u8,
728 ) -> u32 {
729 let meta = packed.rule_meta[meta_index];
730 let class = packed.class_maps[meta.class_map_base as usize + byte as usize];
731 let idx = meta.transition_base as usize
732 + state as usize * meta.num_classes as usize
733 + class as usize;
734 packed.transitions[idx]
735 }
736
737 #[test]
738 fn duplicate_dfas_share_catalog_storage() {
739 let first = BatchRuleProgram::new(0, vec![0; 256], vec![0], 1).unwrap();
740 let second = BatchRuleProgram::new(1, vec![0; 256], vec![0], 1).unwrap();
741 let packed = pack_rule_catalog(&[first, second]).unwrap();
742 assert_eq!(
744 packed.rule_meta[0].transition_base,
745 packed.rule_meta[1].transition_base
746 );
747 assert_eq!(
748 packed.rule_meta[0].accept_base,
749 packed.rule_meta[1].accept_base
750 );
751 assert_eq!(
752 packed.rule_meta[0].class_map_base,
753 packed.rule_meta[1].class_map_base
754 );
755 assert_eq!(
756 packed.rule_meta[0].num_classes,
757 packed.rule_meta[1].num_classes
758 );
759 assert_eq!(packed.rule_meta[0].num_classes, 1);
763 assert_eq!(packed.rule_meta[0].transition_base, 1);
764 assert_eq!(
765 packed.transitions.len(),
766 packed.rule_meta[0].transition_base as usize + 1
767 );
768 assert_eq!(
769 packed.accept.len(),
770 packed.rule_meta[0].accept_base as usize + 1
771 );
772 assert!(packed.rejected_rules.is_empty());
773 }
774
775 #[test]
776 fn u16_pack_round_trips_losslessly_including_odd_tail() {
777 let compressed: Vec<u32> = vec![0, 1, 2, 65_535, 100, 0, 42];
780 let mut packed = Vec::new();
781 try_pack_u16_transitions_into(&compressed, &mut packed).expect("all targets fit u16");
782 assert_eq!(packed.len(), compressed.len().div_ceil(2));
784 for (idx, &original) in compressed.iter().enumerate() {
787 assert_eq!(
788 unpack_u16_transition(&packed, idx),
789 original,
790 "u16 round-trip diverged at flat index {idx}",
791 );
792 }
793 }
794
795 #[test]
796 fn u16_pack_fails_closed_on_target_exceeding_u16() {
797 let compressed: Vec<u32> = vec![0, 1, 70_000, 3];
800 let mut out = Vec::new();
801 let err = try_pack_u16_transitions_into(&compressed, &mut out)
802 .expect_err("a target above u16::MAX must be refused");
803 let msg = err.to_string();
804 assert!(
805 msg.contains("70000") && msg.contains("index 2") && msg.contains("u16"),
806 "error must name the offending target/index and the u16 cause: {msg}",
807 );
808 }
809
810 #[test]
818 fn duplicate_dfas_shared_storage_both_rules_fire_correctly() {
819 let states = 3usize;
821 let mut dense = vec![0u32; states * 256];
822 dense[0 * 256 + 0x41] = 1; dense[1 * 256 + 0x41] = 2; dense[1 * 256 + 0x42] = 2; dense[2 * 256 + 0x41] = 2; let accept = vec![0u32, 0, 1];
827
828 let rule0 = BatchRuleProgram::new(0, dense.clone(), accept.clone(), states as u32).unwrap();
829 let rule1 = BatchRuleProgram::new(1, dense.clone(), accept.clone(), states as u32).unwrap();
830 let packed = pack_rule_catalog(&[rule0, rule1]).unwrap();
831
832 assert!(packed.rejected_rules.is_empty());
833 assert_eq!(
835 packed.rule_meta[0].transition_base, packed.rule_meta[1].transition_base,
836 "Fix: duplicate DFAs must share transition storage"
837 );
838 assert_eq!(
839 packed.rule_meta[0].accept_base, packed.rule_meta[1].accept_base,
840 "Fix: duplicate DFAs must share accept storage"
841 );
842
843 for state in 0..states as u32 {
847 for byte in 0u16..256 {
848 let byte = byte as u8;
849 let expected = dense[state as usize * 256 + byte as usize];
850 let got0 = packed_next_state(&packed, 0, state, byte);
851 let got1 = packed_next_state(&packed, 1, state, byte);
852 assert_eq!(
853 got0, expected,
854 "Fix: rule0 compressed transition mismatch at state {state} byte {byte:#x}: expected {expected} got {got0}"
855 );
856 assert_eq!(
857 got1, expected,
858 "Fix: rule1 compressed transition mismatch at state {state} byte {byte:#x}: expected {expected} got {got1}"
859 );
860 }
861 }
862 }
863
864 #[test]
865 fn duplicate_dfas_do_not_reserve_raw_duplicate_storage() {
866 let rules = (0..32)
867 .map(|rule_idx| BatchRuleProgram::new(rule_idx, vec![0; 256], vec![0], 1).unwrap())
868 .collect::<Vec<_>>();
869
870 let packed = pack_rule_catalog(&rules).unwrap();
871
872 assert_eq!(packed.transitions.len(), 2);
874 assert!(
875 packed.transitions.capacity() < ALPHABET_SIZE as usize * rules.len(),
876 "Fix: duplicate DFA catalogs must not reserve memory as if every rule had unique transition storage."
877 );
878 assert_eq!(packed.accept.len(), 2);
879 assert!(
880 packed.accept.capacity() < rules.len(),
881 "Fix: duplicate DFA catalogs must not reserve accept storage for every duplicate rule."
882 );
883 assert_eq!(packed.class_maps.len(), ALPHABET_SIZE as usize * 2);
885 }
886
887 #[test]
891 fn byte_class_compression_is_lossless() {
892 let states = 3usize;
897 let mut dense = vec![0u32; states * 256];
898 dense[0 * 256 + 0x41] = 1;
900 dense[1 * 256 + 0x41] = 2;
902 dense[1 * 256 + 0x42] = 2;
903 dense[2 * 256 + 0x41] = 2;
905 let accept = vec![0u32, 0, 1];
906 let rule = BatchRuleProgram::new(0, dense.clone(), accept, states as u32).unwrap();
907 let packed = pack_rule_catalog(&[rule]).unwrap();
908
909 assert_eq!(packed.rejected_rules.len(), 0);
910 assert_eq!(packed.rule_meta[0].num_classes, 3);
912 assert!(
913 packed.transitions.len() < 1 + states * 256,
914 "compressed transitions must be smaller than the dense table"
915 );
916
917 for state in 0..states as u32 {
918 for byte in 0u16..256 {
919 let byte = byte as u8;
920 let expected = dense[state as usize * 256 + byte as usize];
921 let got = packed_next_state(&packed, 0, state, byte);
922 assert_eq!(
923 got, expected,
924 "compressed transition mismatch at state {state} byte {byte:#x}: dense={expected} packed={got}"
925 );
926 }
927 }
928 }
929
930 #[test]
933 fn full_alphabet_dfa_keeps_all_classes_and_is_lossless() {
934 let states = 256usize;
940 let mut dense = vec![0u32; states * 256];
941 for s in 0..states {
942 for b in 0..256 {
943 dense[s * 256 + b] = b as u32; }
945 }
946 let accept = vec![0u32; states];
949 let rule = BatchRuleProgram::new(0, dense.clone(), accept, states as u32).unwrap();
950 let packed = pack_rule_catalog(&[rule]).unwrap();
951 assert_eq!(packed.rule_meta[0].num_classes, 256);
952 for state in 0..states as u32 {
953 for byte in 0u16..256 {
954 let byte = byte as u8;
955 let expected = dense[state as usize * 256 + byte as usize];
956 assert_eq!(packed_next_state(&packed, 0, state, byte), expected);
957 }
958 }
959 }
960
961 #[test]
962 fn accepted_rule_fingerprints_into_reuses_caller_storage() {
963 let rules = (0..8)
964 .map(|rule_idx| BatchRuleProgram::new(rule_idx, vec![0; 256], vec![0], 1).unwrap())
965 .collect::<Vec<_>>();
966 let mut fingerprints = Vec::with_capacity(16);
967 let mut occupied = Vec::with_capacity(16);
968 let mut addressed = Vec::with_capacity(16);
969 let fingerprint_ptr = fingerprints.as_ptr();
970 let occupied_ptr = occupied.as_ptr();
971 let addressed_ptr = addressed.as_ptr();
972
973 let rejections = accepted_rule_fingerprints_into(
974 &rules,
975 &mut fingerprints,
976 &mut occupied,
977 &mut addressed,
978 );
979
980 assert!(rejections.is_empty());
981 assert_eq!(fingerprints.len(), rules.len());
982 assert_eq!(fingerprints.as_ptr(), fingerprint_ptr);
983 assert_eq!(occupied.as_ptr(), occupied_ptr);
984 assert_eq!(addressed.as_ptr(), addressed_ptr);
985 }
986
987 #[test]
988 fn invalid_rules_are_isolated_to_inert_catalog_entries() {
989 let valid = BatchRuleProgram::new(0, vec![0; 256], vec![1], 1).unwrap();
990 let invalid = BatchRuleProgram {
991 rule_idx: 1,
992 transitions: vec![0; 8],
993 accept: vec![0],
994 state_count: 1,
995 };
996
997 let packed = pack_rule_catalog(&[valid, invalid]).unwrap();
998 assert_eq!(packed.rejected_rules.len(), 1);
999 assert_eq!(packed.rejected_rules[0].rule_idx, Some(1));
1000 assert_eq!(packed.rule_meta[0].state_count, 1);
1003 assert!(packed.rule_meta[0].transition_base >= 1);
1004 assert_eq!(packed.rule_meta[1].transition_base, 0);
1005 assert_eq!(packed.rule_meta[1].accept_base, 0);
1006 assert_eq!(packed.rule_meta[1].state_count, 1);
1007 assert_eq!(packed.rule_meta[1].class_map_base, 0);
1008 assert_eq!(packed.rule_meta[1].num_classes, 1);
1009 assert_eq!(packed.transitions[0], 0);
1012 assert_eq!(packed.accept[0], 0);
1013 assert_eq!(
1014 &packed.class_maps[..ALPHABET_SIZE as usize],
1015 &vec![0; ALPHABET_SIZE as usize]
1016 );
1017 for byte in 0u16..256 {
1023 let byte = byte as u8;
1024 assert_eq!(
1025 packed_next_state(&packed, 1, 0, byte),
1026 0,
1027 "Fix: inert slot must self-loop to state 0 for every byte, failed at byte {byte:#x}"
1028 );
1029 }
1030 assert_eq!(
1032 packed.accept[packed.rule_meta[1].accept_base as usize],
1033 0,
1034 "Fix: inert slot accept entry at state 0 must be 0, the inert DFA must never produce a match"
1035 );
1036 }
1037}