Skip to main content

antlr4_runtime/
prediction.rs

1use std::cmp::Ordering;
2use std::collections::{BTreeMap, BTreeSet, HashMap};
3use std::hash::{BuildHasherDefault, Hash, Hasher};
4use std::mem::size_of;
5
6pub const EMPTY_RETURN_STATE: usize = usize::MAX;
7const COMPACT_EMPTY_RETURN_STATE: u32 = u32::MAX;
8
9/// Lightweight `FxHash`-style hasher used on prediction hot paths.
10#[derive(Debug, Default)]
11pub struct PredictionFxHasher {
12    hash: u64,
13}
14
15const FX_ROT: u32 = 5;
16const FX_SEED: u64 = 0x51_7c_c1_b7_27_22_0a_95;
17
18impl Hasher for PredictionFxHasher {
19    #[inline]
20    fn write(&mut self, bytes: &[u8]) {
21        let mut bytes = bytes;
22        while bytes.len() >= 8 {
23            let (head, rest) = bytes.split_at(8);
24            let word = u64::from_le_bytes(head.try_into().expect("8-byte chunk"));
25            self.hash = (self.hash.rotate_left(FX_ROT) ^ word).wrapping_mul(FX_SEED);
26            bytes = rest;
27        }
28        for &byte in bytes {
29            self.hash = (self.hash.rotate_left(FX_ROT) ^ u64::from(byte)).wrapping_mul(FX_SEED);
30        }
31    }
32
33    #[inline]
34    fn write_u8(&mut self, value: u8) {
35        self.hash = (self.hash.rotate_left(FX_ROT) ^ u64::from(value)).wrapping_mul(FX_SEED);
36    }
37
38    #[inline]
39    fn write_u32(&mut self, value: u32) {
40        self.hash = (self.hash.rotate_left(FX_ROT) ^ u64::from(value)).wrapping_mul(FX_SEED);
41    }
42
43    #[inline]
44    fn write_u64(&mut self, value: u64) {
45        self.hash = (self.hash.rotate_left(FX_ROT) ^ value).wrapping_mul(FX_SEED);
46    }
47
48    #[inline]
49    fn write_usize(&mut self, value: usize) {
50        self.hash = (self.hash.rotate_left(FX_ROT) ^ value as u64).wrapping_mul(FX_SEED);
51    }
52
53    #[inline]
54    fn write_i32(&mut self, value: i32) {
55        self.write_u32(i32::cast_unsigned(value));
56    }
57
58    #[inline]
59    fn finish(&self) -> u64 {
60        self.hash
61    }
62}
63
64type FxHashMap<K, V> = HashMap<K, V, BuildHasherDefault<PredictionFxHasher>>;
65
66/// Store-local identity for one canonical prediction-context graph node.
67#[repr(transparent)]
68#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
69pub struct ContextId(u32);
70
71pub const EMPTY_CONTEXT: ContextId = ContextId(0);
72
73#[derive(Clone, Copy, Debug, Eq, PartialEq)]
74enum ContextTag {
75    Empty,
76    Singleton,
77    Array,
78}
79
80#[derive(Clone, Copy, Debug)]
81struct ContextRecord {
82    tag: ContextTag,
83    cached_hash: u64,
84    parent_or_start: u32,
85    return_state_or_len: u32,
86}
87
88/// Allocation and interning totals for one prediction-context arena.
89#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
90pub struct PredictionContextStats {
91    pub contexts_created: usize,
92    pub singleton_contexts: usize,
93    pub array_contexts: usize,
94    pub array_entries: usize,
95    pub interner_hits: usize,
96    pub pooled_bytes: usize,
97    /// Element storage implied by retained capacities, excluding allocator and
98    /// hash-table control metadata.
99    pub retained_bytes: usize,
100    pub context_capacity: usize,
101    pub array_parent_capacity: usize,
102    pub array_return_state_capacity: usize,
103    pub interner_capacity: usize,
104    pub workspace_merge_cache_entries: usize,
105    pub workspace_merge_cache_capacity: usize,
106    pub workspace_entry_capacity: usize,
107    pub outer_context_cache_hits: usize,
108    pub outer_context_cache_misses: usize,
109}
110
111/// Canonical compact storage paired with one learned parser DFA store.
112#[derive(Debug)]
113pub(crate) struct ContextArena {
114    records: Vec<ContextRecord>,
115    array_parents: Vec<ContextId>,
116    array_return_states: Vec<u32>,
117    interner_heads: FxHashMap<u64, ContextId>,
118    interner_next: Vec<Option<ContextId>>,
119    interner_hits: usize,
120    #[cfg(debug_assertions)]
121    generation: u64,
122}
123
124#[cfg(debug_assertions)]
125fn next_context_arena_generation() -> u64 {
126    use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
127
128    static NEXT_GENERATION: AtomicU64 = AtomicU64::new(1);
129    NEXT_GENERATION.fetch_add(1, AtomicOrdering::Relaxed)
130}
131
132impl ContextArena {
133    pub(crate) fn new() -> Self {
134        let empty = ContextRecord {
135            tag: ContextTag::Empty,
136            cached_hash: prediction_context_empty_hash(),
137            parent_or_start: 0,
138            return_state_or_len: 0,
139        };
140        let mut interner_heads = FxHashMap::default();
141        interner_heads.insert(empty.cached_hash, EMPTY_CONTEXT);
142        Self {
143            records: vec![empty],
144            array_parents: Vec::new(),
145            array_return_states: Vec::new(),
146            interner_heads,
147            interner_next: vec![None],
148            interner_hits: 0,
149            #[cfg(debug_assertions)]
150            generation: next_context_arena_generation(),
151        }
152    }
153
154    #[cfg(debug_assertions)]
155    pub(crate) const fn generation(&self) -> u64 {
156        self.generation
157    }
158
159    pub(crate) fn stats(&self) -> PredictionContextStats {
160        let mut singleton_contexts = 0;
161        let mut array_contexts = 0;
162        for record in &self.records {
163            match record.tag {
164                ContextTag::Empty => {}
165                ContextTag::Singleton => singleton_contexts += 1,
166                ContextTag::Array => array_contexts += 1,
167            }
168        }
169        PredictionContextStats {
170            contexts_created: self.records.len(),
171            singleton_contexts,
172            array_contexts,
173            array_entries: self.array_parents.len(),
174            interner_hits: self.interner_hits,
175            pooled_bytes: self.records.len() * size_of::<ContextRecord>()
176                + self.array_parents.len() * size_of::<ContextId>()
177                + self.array_return_states.len() * size_of::<u32>()
178                + self.interner_next.len() * size_of::<Option<ContextId>>(),
179            retained_bytes: self.records.capacity() * size_of::<ContextRecord>()
180                + self.array_parents.capacity() * size_of::<ContextId>()
181                + self.array_return_states.capacity() * size_of::<u32>()
182                + self.interner_heads.capacity() * size_of::<(u64, ContextId)>()
183                + self.interner_next.capacity() * size_of::<Option<ContextId>>(),
184            context_capacity: self.records.capacity(),
185            array_parent_capacity: self.array_parents.capacity(),
186            array_return_state_capacity: self.array_return_states.capacity(),
187            interner_capacity: self.interner_heads.capacity(),
188            workspace_merge_cache_entries: 0,
189            workspace_merge_cache_capacity: 0,
190            workspace_entry_capacity: 0,
191            outer_context_cache_hits: 0,
192            outer_context_cache_misses: 0,
193        }
194    }
195
196    pub(crate) fn singleton(&mut self, parent: ContextId, return_state: usize) -> ContextId {
197        self.assert_valid(parent);
198        if return_state == EMPTY_RETURN_STATE {
199            return EMPTY_CONTEXT;
200        }
201        #[cfg(feature = "perf-counters")]
202        crate::perf::record_context_cache_call();
203        let return_state =
204            u32::try_from(return_state).expect("prediction return state must fit in u32");
205        let cached_hash = prediction_context_singleton_hash(self.cached_hash(parent), return_state);
206        if let Some(existing) = self.find_interned(cached_hash, |record| {
207            record.tag == ContextTag::Singleton
208                && record.parent_or_start == parent.0
209                && record.return_state_or_len == return_state
210        }) {
211            self.interner_hits = self.interner_hits.saturating_add(1);
212            #[cfg(feature = "perf-counters")]
213            crate::perf::record_context_cache_hit();
214            return existing;
215        }
216        #[cfg(feature = "perf-counters")]
217        {
218            crate::perf::record_context_cache_miss();
219            crate::perf::record_context_cache_insert();
220        }
221        self.push_record(ContextRecord {
222            tag: ContextTag::Singleton,
223            cached_hash,
224            parent_or_start: parent.0,
225            return_state_or_len: return_state,
226        })
227    }
228
229    fn intern_entries(&mut self, entries: &[(ContextId, u32)]) -> ContextId {
230        match entries {
231            [] => EMPTY_CONTEXT,
232            [(parent, return_state)] => {
233                if *return_state == COMPACT_EMPTY_RETURN_STATE {
234                    EMPTY_CONTEXT
235                } else {
236                    self.singleton(
237                        *parent,
238                        usize::try_from(*return_state).expect("u32 return state fits in usize"),
239                    )
240                }
241            }
242            _ => {
243                debug_assert!(
244                    entries
245                        .windows(2)
246                        .all(|pair| { compare_entries(pair[0], pair[1]) == Ordering::Less })
247                );
248                #[cfg(feature = "perf-counters")]
249                crate::perf::record_context_cache_call();
250                let cached_hash = prediction_context_array_hash(self, entries);
251                if let Some(existing) = self.find_interned(cached_hash, |record| {
252                    if record.tag != ContextTag::Array
253                        || usize::try_from(record.return_state_or_len).ok() != Some(entries.len())
254                    {
255                        return false;
256                    }
257                    let start =
258                        usize::try_from(record.parent_or_start).expect("u32 pool index fits usize");
259                    let end = start + entries.len();
260                    self.array_parents[start..end]
261                        .iter()
262                        .copied()
263                        .zip(self.array_return_states[start..end].iter().copied())
264                        .eq(entries.iter().copied())
265                }) {
266                    self.interner_hits = self.interner_hits.saturating_add(1);
267                    #[cfg(feature = "perf-counters")]
268                    crate::perf::record_context_cache_hit();
269                    return existing;
270                }
271                #[cfg(feature = "perf-counters")]
272                {
273                    crate::perf::record_context_cache_miss();
274                    crate::perf::record_context_cache_insert();
275                }
276                let start = u32::try_from(self.array_parents.len())
277                    .expect("prediction-context parent pool must fit in u32");
278                let len = u32::try_from(entries.len())
279                    .expect("prediction-context array length must fit in u32");
280                self.array_parents
281                    .extend(entries.iter().map(|(parent, _)| *parent));
282                self.array_return_states
283                    .extend(entries.iter().map(|(_, return_state)| *return_state));
284                self.push_record(ContextRecord {
285                    tag: ContextTag::Array,
286                    cached_hash,
287                    parent_or_start: start,
288                    return_state_or_len: len,
289                })
290            }
291        }
292    }
293
294    fn find_interned(
295        &self,
296        cached_hash: u64,
297        matches: impl Fn(&ContextRecord) -> bool,
298    ) -> Option<ContextId> {
299        let mut candidate = self.interner_heads.get(&cached_hash).copied();
300        while let Some(id) = candidate {
301            let index = usize::try_from(id.0).expect("u32 context ID fits in usize");
302            let record = &self.records[index];
303            if matches(record) {
304                return Some(id);
305            }
306            candidate = self.interner_next[index];
307        }
308        None
309    }
310
311    fn push_record(&mut self, record: ContextRecord) -> ContextId {
312        let id = ContextId(
313            u32::try_from(self.records.len()).expect("prediction-context arena must fit in u32"),
314        );
315        let previous = self.interner_heads.insert(record.cached_hash, id);
316        self.records.push(record);
317        self.interner_next.push(previous);
318        id
319    }
320
321    pub(crate) fn merge(
322        &mut self,
323        left: ContextId,
324        right: ContextId,
325        root_is_wildcard: bool,
326        workspace: &mut PredictionWorkspace,
327    ) -> ContextId {
328        self.assert_valid(left);
329        self.assert_valid(right);
330        #[cfg(feature = "perf-counters")]
331        crate::perf::record_context_merge_call();
332        if left == right {
333            #[cfg(feature = "perf-counters")]
334            crate::perf::record_context_merge_identical();
335            return left;
336        }
337        let key = MergeKey::new(left, right, root_is_wildcard);
338        if let Some(merged) = workspace.merge_cache.get(&key).copied() {
339            #[cfg(feature = "perf-counters")]
340            crate::perf::record_context_merge_cache_hit();
341            return merged;
342        }
343        #[cfg(feature = "perf-counters")]
344        {
345            crate::perf::record_context_merge_cache_miss();
346            crate::perf::record_context_merge_uncached();
347        }
348        let merged = if root_is_wildcard && (left == EMPTY_CONTEXT || right == EMPTY_CONTEXT) {
349            EMPTY_CONTEXT
350        } else {
351            self.merge_uncached(left, right, root_is_wildcard, workspace)
352        };
353        workspace.merge_cache.insert(key, merged);
354        merged
355    }
356
357    fn merge_uncached(
358        &mut self,
359        left: ContextId,
360        right: ContextId,
361        root_is_wildcard: bool,
362        workspace: &mut PredictionWorkspace,
363    ) -> ContextId {
364        match (self.tag(left), self.tag(right)) {
365            (ContextTag::Array, ContextTag::Array) => {
366                self.merge_arrays(left, right, root_is_wildcard, workspace)
367            }
368            (ContextTag::Array, _) => {
369                let entry = self.first_entry(right);
370                self.merge_array_with_entry(left, entry, root_is_wildcard, workspace)
371            }
372            (_, ContextTag::Array) => {
373                let entry = self.first_entry(left);
374                self.merge_array_with_entry(right, entry, root_is_wildcard, workspace)
375            }
376            _ => self.merge_two_entries(
377                self.first_entry(left),
378                self.first_entry(right),
379                root_is_wildcard,
380                workspace,
381            ),
382        }
383    }
384
385    fn merge_two_entries(
386        &mut self,
387        left: (ContextId, u32),
388        right: (ContextId, u32),
389        root_is_wildcard: bool,
390        workspace: &mut PredictionWorkspace,
391    ) -> ContextId {
392        if left.1 == right.1 {
393            let parent = if left.0 == right.0 {
394                left.0
395            } else {
396                self.merge(left.0, right.0, root_is_wildcard, workspace)
397            };
398            return self.intern_entries(&[(parent, left.1)]);
399        }
400
401        let start = workspace.entries.len();
402        if right.1 < left.1 {
403            workspace.entries.extend([right, left]);
404        } else {
405            workspace.entries.extend([left, right]);
406        }
407        self.intern_workspace_entries(workspace, start)
408    }
409
410    fn intern_workspace_entries(
411        &mut self,
412        workspace: &mut PredictionWorkspace,
413        start: usize,
414    ) -> ContextId {
415        let context = self.intern_entries(&workspace.entries[start..]);
416        workspace.entries.truncate(start);
417        context
418    }
419
420    fn merge_array_with_entry(
421        &mut self,
422        array: ContextId,
423        entry: (ContextId, u32),
424        root_is_wildcard: bool,
425        workspace: &mut PredictionWorkspace,
426    ) -> ContextId {
427        let array_len = self.len(array);
428        let mut insert_index = array_len;
429        for index in 0..array_len {
430            let current = self.entry(array, index).expect("array entry in range");
431            match entry.1.cmp(&current.1) {
432                Ordering::Less => {
433                    insert_index = index;
434                    break;
435                }
436                Ordering::Equal => {
437                    let parent = if entry.0 == current.0 {
438                        current.0
439                    } else {
440                        self.merge(entry.0, current.0, root_is_wildcard, workspace)
441                    };
442                    if parent == current.0 {
443                        return array;
444                    }
445
446                    let start = workspace.entries.len();
447                    for entry_index in 0..array_len {
448                        let array_entry = self
449                            .entry(array, entry_index)
450                            .expect("array entry in range");
451                        workspace.entries.push(if entry_index == index {
452                            (parent, current.1)
453                        } else {
454                            array_entry
455                        });
456                    }
457                    return self.intern_workspace_entries(workspace, start);
458                }
459                Ordering::Greater => {}
460            }
461        }
462
463        let start = workspace.entries.len();
464        for index in 0..insert_index {
465            workspace
466                .entries
467                .push(self.entry(array, index).expect("array entry in range"));
468        }
469        workspace.entries.push(entry);
470        for index in insert_index..array_len {
471            workspace
472                .entries
473                .push(self.entry(array, index).expect("array entry in range"));
474        }
475        self.intern_workspace_entries(workspace, start)
476    }
477
478    fn merge_arrays(
479        &mut self,
480        left: ContextId,
481        right: ContextId,
482        root_is_wildcard: bool,
483        workspace: &mut PredictionWorkspace,
484    ) -> ContextId {
485        let start = workspace.entries.len();
486        let left_len = self.len(left);
487        let right_len = self.len(right);
488        let mut left_index = 0;
489        let mut right_index = 0;
490        while left_index < left_len && right_index < right_len {
491            let left_entry = self.entry(left, left_index).expect("array entry in range");
492            let right_entry = self
493                .entry(right, right_index)
494                .expect("array entry in range");
495            match left_entry.1.cmp(&right_entry.1) {
496                Ordering::Less => {
497                    workspace.entries.push(left_entry);
498                    left_index += 1;
499                }
500                Ordering::Greater => {
501                    workspace.entries.push(right_entry);
502                    right_index += 1;
503                }
504                Ordering::Equal => {
505                    let parent = if left_entry.0 == right_entry.0 {
506                        left_entry.0
507                    } else {
508                        self.merge(left_entry.0, right_entry.0, root_is_wildcard, workspace)
509                    };
510                    workspace.entries.push((parent, left_entry.1));
511                    left_index += 1;
512                    right_index += 1;
513                }
514            }
515        }
516        while left_index < left_len {
517            workspace
518                .entries
519                .push(self.entry(left, left_index).expect("array entry in range"));
520            left_index += 1;
521        }
522        while right_index < right_len {
523            workspace.entries.push(
524                self.entry(right, right_index)
525                    .expect("array entry in range"),
526            );
527            right_index += 1;
528        }
529        self.intern_workspace_entries(workspace, start)
530    }
531
532    pub(crate) fn len(&self, context: ContextId) -> usize {
533        let record = self.record(context);
534        match record.tag {
535            ContextTag::Empty | ContextTag::Singleton => 1,
536            ContextTag::Array => usize::try_from(record.return_state_or_len)
537                .expect("u32 context length fits in usize"),
538        }
539    }
540
541    pub(crate) fn is_empty(&self, context: ContextId) -> bool {
542        self.assert_valid(context);
543        context == EMPTY_CONTEXT
544    }
545
546    pub(crate) fn has_empty_path(&self, context: ContextId) -> bool {
547        if context == EMPTY_CONTEXT {
548            return true;
549        }
550        let record = self.record(context);
551        match record.tag {
552            ContextTag::Empty => true,
553            ContextTag::Singleton => false,
554            ContextTag::Array => {
555                let len = usize::try_from(record.return_state_or_len)
556                    .expect("u32 context length fits in usize");
557                let start = usize::try_from(record.parent_or_start)
558                    .expect("u32 context pool index fits in usize");
559                self.array_return_states[start + len - 1] == COMPACT_EMPTY_RETURN_STATE
560            }
561        }
562    }
563
564    pub(crate) fn return_state(&self, context: ContextId, index: usize) -> Option<usize> {
565        let (_, return_state) = self.entry(context, index)?;
566        Some(expand_return_state(return_state))
567    }
568
569    pub(crate) fn parent(&self, context: ContextId, index: usize) -> Option<ContextId> {
570        if context == EMPTY_CONTEXT {
571            self.assert_valid(context);
572            return None;
573        }
574        self.entry(context, index).map(|(parent, _)| parent)
575    }
576
577    fn first_entry(&self, context: ContextId) -> (ContextId, u32) {
578        self.entry(context, 0)
579            .expect("empty and singleton contexts have one logical entry")
580    }
581
582    fn entry(&self, context: ContextId, index: usize) -> Option<(ContextId, u32)> {
583        let record = self.record(context);
584        match record.tag {
585            ContextTag::Empty if index == 0 => Some((EMPTY_CONTEXT, COMPACT_EMPTY_RETURN_STATE)),
586            ContextTag::Singleton if index == 0 => Some((
587                ContextId(record.parent_or_start),
588                record.return_state_or_len,
589            )),
590            ContextTag::Array => {
591                let len = usize::try_from(record.return_state_or_len).ok()?;
592                if index >= len {
593                    return None;
594                }
595                let start = usize::try_from(record.parent_or_start).ok()?;
596                Some((
597                    self.array_parents[start + index],
598                    self.array_return_states[start + index],
599                ))
600            }
601            ContextTag::Empty | ContextTag::Singleton => None,
602        }
603    }
604
605    fn tag(&self, context: ContextId) -> ContextTag {
606        self.record(context).tag
607    }
608
609    fn cached_hash(&self, context: ContextId) -> u64 {
610        self.record(context).cached_hash
611    }
612
613    fn record(&self, context: ContextId) -> &ContextRecord {
614        self.assert_valid(context);
615        &self.records[usize::try_from(context.0).expect("u32 context ID fits in usize")]
616    }
617
618    pub(crate) fn assert_valid(&self, context: ContextId) {
619        assert!(
620            usize::try_from(context.0).is_ok_and(|index| index < self.records.len()),
621            "prediction ContextId does not belong to this store"
622        );
623    }
624
625    pub(crate) fn import_all(
626        &mut self,
627        source: &Self,
628        workspace: &mut PredictionWorkspace,
629    ) -> Vec<ContextId> {
630        workspace.entries.clear();
631        let mut remap = Vec::with_capacity(source.records.len());
632        remap.push(EMPTY_CONTEXT);
633        for source_index in 1..source.records.len() {
634            let source_id = ContextId(
635                u32::try_from(source_index).expect("source prediction-context ID fits in u32"),
636            );
637            let imported = match source.tag(source_id) {
638                ContextTag::Empty => EMPTY_CONTEXT,
639                ContextTag::Singleton => {
640                    let (parent, return_state) = source.first_entry(source_id);
641                    let parent_index =
642                        usize::try_from(parent.0).expect("u32 context ID fits usize");
643                    assert!(
644                        parent_index < remap.len(),
645                        "prediction contexts must reference earlier arena records"
646                    );
647                    self.singleton(remap[parent_index], expand_return_state(return_state))
648                }
649                ContextTag::Array => {
650                    let start = workspace.entries.len();
651                    for entry_index in 0..source.len(source_id) {
652                        let (parent, return_state) = source
653                            .entry(source_id, entry_index)
654                            .expect("source array entry in range");
655                        let parent_index =
656                            usize::try_from(parent.0).expect("u32 context ID fits usize");
657                        assert!(
658                            parent_index < remap.len(),
659                            "prediction contexts must reference earlier arena records"
660                        );
661                        workspace.entries.push((remap[parent_index], return_state));
662                    }
663                    workspace
664                        .entries
665                        .sort_unstable_by(|left, right| compare_entries(*left, *right));
666                    workspace.entries.dedup();
667                    self.intern_workspace_entries(workspace, start)
668                }
669            };
670            remap.push(imported);
671        }
672        remap
673    }
674}
675
676impl Default for ContextArena {
677    fn default() -> Self {
678        Self::new()
679    }
680}
681
682fn compare_entries(left: (ContextId, u32), right: (ContextId, u32)) -> Ordering {
683    left.1.cmp(&right.1)
684}
685
686fn expand_return_state(return_state: u32) -> usize {
687    if return_state == COMPACT_EMPTY_RETURN_STATE {
688        EMPTY_RETURN_STATE
689    } else {
690        usize::try_from(return_state).expect("u32 return state fits in usize")
691    }
692}
693
694fn prediction_context_empty_hash() -> u64 {
695    let mut hasher = PredictionFxHasher::default();
696    hasher.write_u8(0);
697    hasher.finish()
698}
699
700fn prediction_context_singleton_hash(parent_hash: u64, return_state: u32) -> u64 {
701    let mut hasher = PredictionFxHasher::default();
702    hasher.write_u8(1);
703    hasher.write_u64(parent_hash);
704    hasher.write_u32(return_state);
705    hasher.finish()
706}
707
708fn prediction_context_array_hash(arena: &ContextArena, entries: &[(ContextId, u32)]) -> u64 {
709    let mut hasher = PredictionFxHasher::default();
710    hasher.write_u8(2);
711    hasher.write_usize(entries.len());
712    for (parent, _) in entries {
713        hasher.write_u64(arena.cached_hash(*parent));
714    }
715    hasher.write_usize(entries.len());
716    for (_, return_state) in entries {
717        hasher.write_u32(*return_state);
718    }
719    hasher.finish()
720}
721
722#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
723struct MergeKey {
724    left: ContextId,
725    right: ContextId,
726    root_is_wildcard: bool,
727}
728
729impl MergeKey {
730    fn new(left: ContextId, right: ContextId, root_is_wildcard: bool) -> Self {
731        let (left, right) = if right < left {
732            (right, left)
733        } else {
734            (left, right)
735        };
736        Self {
737            left,
738            right,
739            root_is_wildcard,
740        }
741    }
742}
743
744const MAX_RETAINED_MERGE_CACHE_ENTRIES: usize = 65_536;
745const MAX_RETAINED_CONTEXT_ENTRIES: usize = 16_384;
746
747/// Reusable per-prediction merge cache and temporary compact entry storage.
748#[derive(Debug, Default)]
749pub(crate) struct PredictionWorkspace {
750    merge_cache: FxHashMap<MergeKey, ContextId>,
751    entries: Vec<(ContextId, u32)>,
752}
753
754impl PredictionWorkspace {
755    pub(crate) fn reset(&mut self) {
756        if self.merge_cache.capacity() > MAX_RETAINED_MERGE_CACHE_ENTRIES {
757            self.merge_cache = FxHashMap::default();
758        } else {
759            self.merge_cache.clear();
760        }
761        if self.entries.capacity() > MAX_RETAINED_CONTEXT_ENTRIES {
762            self.entries = Vec::new();
763        } else {
764            self.entries.clear();
765        }
766    }
767
768    pub(crate) fn merge_cache_capacity(&self) -> usize {
769        self.merge_cache.capacity()
770    }
771
772    pub(crate) fn merge_cache_len(&self) -> usize {
773        self.merge_cache.len()
774    }
775
776    pub(crate) const fn entry_capacity(&self) -> usize {
777        self.entries.capacity()
778    }
779
780    pub(crate) fn retained_bytes(&self) -> usize {
781        self.merge_cache.capacity() * size_of::<(MergeKey, ContextId)>()
782            + self.entries.capacity() * size_of::<(ContextId, u32)>()
783    }
784}
785
786#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
787pub enum SemanticContext {
788    None,
789    Predicate {
790        rule_index: usize,
791        pred_index: usize,
792        context_dependent: bool,
793    },
794    Precedence {
795        precedence: i32,
796    },
797    And(Vec<Self>),
798    Or(Vec<Self>),
799}
800
801impl SemanticContext {
802    pub const fn none() -> Self {
803        Self::None
804    }
805
806    pub fn and(left: Self, right: Self) -> Self {
807        combine_semantic_context(left, right, true)
808    }
809
810    pub fn or(left: Self, right: Self) -> Self {
811        combine_semantic_context(left, right, false)
812    }
813
814    pub const fn is_none(&self) -> bool {
815        matches!(self, Self::None)
816    }
817}
818
819fn combine_semantic_context(
820    left: SemanticContext,
821    right: SemanticContext,
822    and: bool,
823) -> SemanticContext {
824    if left == right {
825        return left;
826    }
827    if left.is_none() {
828        return right;
829    }
830    if right.is_none() {
831        return left;
832    }
833    let mut entries = Vec::new();
834    for context in [left, right] {
835        match (and, context) {
836            (true, SemanticContext::And(children)) | (false, SemanticContext::Or(children)) => {
837                entries.extend(children);
838            }
839            (_, other) => entries.push(other),
840        }
841    }
842    entries.sort();
843    entries.dedup();
844    if and {
845        SemanticContext::And(entries)
846    } else {
847        SemanticContext::Or(entries)
848    }
849}
850
851#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
852pub(crate) struct AtnConfig {
853    pub(crate) state: usize,
854    pub(crate) alt: usize,
855    pub(crate) context: ContextId,
856    pub(crate) semantic_context: SemanticContext,
857    pub(crate) reaches_into_outer_context: usize,
858    pub(crate) precedence_filter_suppressed: bool,
859    #[cfg(debug_assertions)]
860    context_generation: u64,
861}
862
863impl AtnConfig {
864    pub(crate) fn new(state: usize, alt: usize, context: ContextId, arena: &ContextArena) -> Self {
865        arena.assert_valid(context);
866        Self {
867            state,
868            alt,
869            context,
870            semantic_context: SemanticContext::None,
871            reaches_into_outer_context: 0,
872            precedence_filter_suppressed: false,
873            #[cfg(debug_assertions)]
874            context_generation: arena.generation(),
875        }
876    }
877
878    #[must_use]
879    #[cfg(test)]
880    pub(crate) fn with_semantic_context(mut self, semantic_context: SemanticContext) -> Self {
881        self.semantic_context = semantic_context;
882        self
883    }
884
885    pub(crate) fn set_context(&mut self, context: ContextId, arena: &ContextArena) {
886        arena.assert_valid(context);
887        self.context = context;
888        #[cfg(debug_assertions)]
889        {
890            self.context_generation = arena.generation();
891        }
892    }
893
894    pub(crate) fn moved_to(&self, state: usize, context: ContextId, arena: &ContextArena) -> Self {
895        let mut moved = Self::new(state, self.alt, context, arena);
896        moved.semantic_context = self.semantic_context.clone();
897        moved.reaches_into_outer_context = self.reaches_into_outer_context;
898        moved.precedence_filter_suppressed = self.precedence_filter_suppressed;
899        moved
900    }
901
902    pub(crate) fn assert_store(&self, arena: &ContextArena) {
903        arena.assert_valid(self.context);
904        #[cfg(debug_assertions)]
905        assert_eq!(
906            self.context_generation,
907            arena.generation(),
908            "ATN config carries a ContextId from another prediction store"
909        );
910    }
911}
912
913#[derive(Clone, Debug, Default)]
914pub(crate) struct AtnConfigSet {
915    configs: Vec<AtnConfig>,
916    config_index: FxHashMap<AtnConfigKey, usize>,
917    full_context: bool,
918    unique_alt: Option<usize>,
919    conflicting_alts: BTreeSet<usize>,
920    has_semantic_context: bool,
921    dips_into_outer_context: bool,
922    readonly: bool,
923}
924
925impl AtnConfigSet {
926    pub(crate) fn new() -> Self {
927        Self::default()
928    }
929
930    pub(crate) fn new_full_context(full_context: bool) -> Self {
931        Self {
932            configs: Vec::new(),
933            config_index: FxHashMap::default(),
934            full_context,
935            unique_alt: None,
936            conflicting_alts: BTreeSet::new(),
937            has_semantic_context: false,
938            dips_into_outer_context: false,
939            readonly: false,
940        }
941    }
942
943    /// Adds a configuration, merging contexts for equivalent config keys.
944    pub(crate) fn add(
945        &mut self,
946        config: AtnConfig,
947        arena: &mut ContextArena,
948        workspace: &mut PredictionWorkspace,
949    ) -> bool {
950        assert!(!self.readonly, "cannot mutate readonly ATN config set");
951        config.assert_store(arena);
952        #[cfg(feature = "perf-counters")]
953        crate::perf::record_config_add_call();
954        if !config.semantic_context.is_none() {
955            self.has_semantic_context = true;
956        }
957        if config.reaches_into_outer_context > 0 {
958            self.dips_into_outer_context = true;
959        }
960        let key = AtnConfigKey::from(&config);
961        if let Some(existing_index) = self.config_index.get(&key).copied() {
962            #[cfg(feature = "perf-counters")]
963            crate::perf::record_config_merge();
964            let existing = &mut self.configs[existing_index];
965            existing.assert_store(arena);
966            existing.context = arena.merge(
967                existing.context,
968                config.context,
969                !self.full_context,
970                workspace,
971            );
972            existing.reaches_into_outer_context = existing
973                .reaches_into_outer_context
974                .max(config.reaches_into_outer_context);
975            existing.precedence_filter_suppressed |= config.precedence_filter_suppressed;
976            self.conflicting_alts.clear();
977            false
978        } else {
979            let index = self.configs.len();
980            self.config_index.insert(key, index);
981            self.configs.push(config);
982            #[cfg(feature = "perf-counters")]
983            crate::perf::record_config_insert(self.configs.len());
984            self.unique_alt = None;
985            self.conflicting_alts.clear();
986            true
987        }
988    }
989
990    pub(crate) fn configs(&self) -> &[AtnConfig] {
991        &self.configs
992    }
993
994    pub(crate) fn into_configs(self) -> Vec<AtnConfig> {
995        self.configs
996    }
997
998    pub(crate) const fn is_empty(&self) -> bool {
999        self.configs.is_empty()
1000    }
1001
1002    pub(crate) const fn len(&self) -> usize {
1003        self.configs.len()
1004    }
1005
1006    pub(crate) fn set_readonly(&mut self, readonly: bool) {
1007        self.readonly = readonly;
1008        if readonly {
1009            self.config_index = FxHashMap::default();
1010            self.conflicting_alts.clear();
1011        }
1012    }
1013
1014    pub(crate) const fn full_context(&self) -> bool {
1015        self.full_context
1016    }
1017
1018    pub(crate) const fn has_semantic_context(&self) -> bool {
1019        self.has_semantic_context
1020    }
1021
1022    pub(crate) fn unique_alt(&mut self) -> Option<usize> {
1023        if self.unique_alt.is_none() {
1024            self.unique_alt = unique_alt(self.configs());
1025        }
1026        self.unique_alt
1027    }
1028
1029    pub(crate) fn alts(&self) -> BTreeSet<usize> {
1030        self.configs.iter().map(|config| config.alt).collect()
1031    }
1032
1033    pub(crate) fn conflicting_alt_subsets(&self) -> Vec<BTreeSet<usize>> {
1034        conflicting_alt_subsets(self.configs())
1035    }
1036
1037    pub(crate) fn conflicting_alts(&mut self) -> BTreeSet<usize> {
1038        if self.conflicting_alts.is_empty() {
1039            self.conflicting_alts = self
1040                .conflicting_alt_subsets()
1041                .into_iter()
1042                .filter(|alts| alts.len() > 1)
1043                .flatten()
1044                .collect();
1045        }
1046        self.conflicting_alts.clone()
1047    }
1048
1049    pub(crate) fn remap_contexts(&mut self, remap: &[ContextId], arena: &ContextArena) {
1050        for config in &mut self.configs {
1051            let index = usize::try_from(config.context.0).expect("u32 context ID fits usize");
1052            config.set_context(
1053                *remap
1054                    .get(index)
1055                    .expect("every imported context ID has a remap"),
1056                arena,
1057            );
1058        }
1059        self.config_index.clear();
1060        if !self.readonly {
1061            for (index, config) in self.configs.iter().enumerate() {
1062                self.config_index.insert(AtnConfigKey::from(config), index);
1063            }
1064        }
1065    }
1066
1067    pub(crate) fn fingerprint(&self) -> u64 {
1068        let mut hasher = PredictionFxHasher::default();
1069        self.configs.hash(&mut hasher);
1070        self.full_context.hash(&mut hasher);
1071        self.has_semantic_context.hash(&mut hasher);
1072        self.dips_into_outer_context.hash(&mut hasher);
1073        hasher.finish()
1074    }
1075
1076    pub(crate) fn retained_bytes(&self) -> usize {
1077        self.configs.capacity() * size_of::<AtnConfig>()
1078            + self.config_index.capacity() * size_of::<(AtnConfigKey, usize)>()
1079    }
1080}
1081
1082impl PartialEq for AtnConfigSet {
1083    fn eq(&self, other: &Self) -> bool {
1084        self.configs == other.configs
1085            && self.full_context == other.full_context
1086            && self.has_semantic_context == other.has_semantic_context
1087            && self.dips_into_outer_context == other.dips_into_outer_context
1088    }
1089}
1090
1091impl Eq for AtnConfigSet {}
1092
1093impl Ord for AtnConfigSet {
1094    fn cmp(&self, other: &Self) -> Ordering {
1095        self.configs
1096            .cmp(&other.configs)
1097            .then_with(|| self.full_context.cmp(&other.full_context))
1098            .then_with(|| self.has_semantic_context.cmp(&other.has_semantic_context))
1099            .then_with(|| {
1100                self.dips_into_outer_context
1101                    .cmp(&other.dips_into_outer_context)
1102            })
1103    }
1104}
1105
1106impl PartialOrd for AtnConfigSet {
1107    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
1108        Some(self.cmp(other))
1109    }
1110}
1111
1112#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
1113struct AtnConfigKey {
1114    state: usize,
1115    alt: usize,
1116    semantic_context: SemanticContext,
1117}
1118
1119impl From<&AtnConfig> for AtnConfigKey {
1120    fn from(config: &AtnConfig) -> Self {
1121        Self {
1122            state: config.state,
1123            alt: config.alt,
1124            semantic_context: config.semantic_context.clone(),
1125        }
1126    }
1127}
1128
1129pub(crate) fn unique_alt(configs: &[AtnConfig]) -> Option<usize> {
1130    let mut alt = None;
1131    for config in configs {
1132        match alt {
1133            None => alt = Some(config.alt),
1134            Some(existing) if existing == config.alt => {}
1135            Some(_) => return None,
1136        }
1137    }
1138    alt
1139}
1140
1141pub(crate) fn conflicting_alt_subsets(configs: &[AtnConfig]) -> Vec<BTreeSet<usize>> {
1142    let mut by_state_context = FxHashMap::<(usize, ContextId), BTreeSet<usize>>::default();
1143    for config in configs {
1144        by_state_context
1145            .entry((config.state, config.context))
1146            .or_default()
1147            .insert(config.alt);
1148    }
1149    by_state_context.into_values().collect()
1150}
1151
1152pub(crate) fn all_subsets_conflict(alt_subsets: &[BTreeSet<usize>]) -> bool {
1153    alt_subsets.iter().all(|alts| alts.len() > 1)
1154}
1155
1156pub(crate) fn all_subsets_equal(alt_subsets: &[BTreeSet<usize>]) -> bool {
1157    let mut subsets = alt_subsets.iter();
1158    let Some(first) = subsets.next() else {
1159        return true;
1160    };
1161    subsets.all(|alts| alts == first)
1162}
1163
1164pub(crate) fn single_viable_alt(alt_subsets: &[BTreeSet<usize>]) -> Option<usize> {
1165    let mut result = None;
1166    for alts in alt_subsets {
1167        let min_alt = alts.iter().next().copied()?;
1168        match result {
1169            None => result = Some(min_alt),
1170            Some(existing) if existing == min_alt => {}
1171            Some(_) => return None,
1172        }
1173    }
1174    result
1175}
1176
1177pub(crate) fn has_sll_conflict_terminating_prediction(
1178    configs: &AtnConfigSet,
1179    is_rule_stop_state: impl Fn(usize) -> bool,
1180) -> bool {
1181    if configs
1182        .configs()
1183        .iter()
1184        .all(|config| is_rule_stop_state(config.state))
1185    {
1186        return true;
1187    }
1188    let alt_subsets = configs.conflicting_alt_subsets();
1189    alt_subsets.iter().any(|alts| alts.len() > 1)
1190        && !has_state_associated_with_one_alt(configs.configs())
1191}
1192
1193fn has_state_associated_with_one_alt(configs: &[AtnConfig]) -> bool {
1194    let mut by_state = BTreeMap::<usize, BTreeSet<usize>>::new();
1195    for config in configs {
1196        by_state.entry(config.state).or_default().insert(config.alt);
1197    }
1198    by_state.values().any(|alts| alts.len() == 1)
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203    use super::*;
1204
1205    #[test]
1206    fn arena_interns_singletons_without_per_context_objects() {
1207        let mut arena = ContextArena::new();
1208        let first = arena.singleton(EMPTY_CONTEXT, 7);
1209        let second = arena.singleton(EMPTY_CONTEXT, 7);
1210
1211        assert_eq!(first, second);
1212        assert_eq!(arena.stats().singleton_contexts, 1);
1213        assert_eq!(arena.stats().interner_hits, 1);
1214    }
1215
1216    #[test]
1217    fn array_interner_verifies_payload_after_hash_collision() {
1218        let mut arena = ContextArena::new();
1219        let first_parent = arena.singleton(EMPTY_CONTEXT, 1);
1220        let second_parent = arena.singleton(EMPTY_CONTEXT, 2);
1221        let expected = [(first_parent, 10), (second_parent, 20)];
1222        let colliding = [(second_parent, 10), (first_parent, 20)];
1223        let cached_hash = prediction_context_array_hash(&arena, &expected);
1224        let start = u32::try_from(arena.array_parents.len()).expect("pool index fits u32");
1225        arena
1226            .array_parents
1227            .extend(colliding.iter().map(|(parent, _)| *parent));
1228        arena
1229            .array_return_states
1230            .extend(colliding.iter().map(|(_, return_state)| *return_state));
1231        let collision = arena.push_record(ContextRecord {
1232            tag: ContextTag::Array,
1233            cached_hash,
1234            parent_or_start: start,
1235            return_state_or_len: 2,
1236        });
1237
1238        let interned = arena.intern_entries(&expected);
1239
1240        assert_ne!(interned, collision);
1241        assert_eq!(arena.entry(interned, 0), Some(expected[0]));
1242        assert_eq!(arena.entry(interned, 1), Some(expected[1]));
1243    }
1244
1245    #[test]
1246    fn merge_with_empty_preserves_full_context_empty_path() {
1247        let mut arena = ContextArena::new();
1248        let mut workspace = PredictionWorkspace::default();
1249        let singleton = arena.singleton(EMPTY_CONTEXT, 42);
1250
1251        let merged = arena.merge(singleton, EMPTY_CONTEXT, false, &mut workspace);
1252
1253        assert_eq!(arena.len(merged), 2);
1254        assert_eq!(arena.return_state(merged, 0), Some(42));
1255        assert_eq!(arena.parent(merged, 0), Some(EMPTY_CONTEXT));
1256        assert_eq!(arena.return_state(merged, 1), Some(EMPTY_RETURN_STATE));
1257        assert!(arena.has_empty_path(merged));
1258    }
1259
1260    #[test]
1261    fn wildcard_merge_collapses_to_empty() {
1262        let mut arena = ContextArena::new();
1263        let mut workspace = PredictionWorkspace::default();
1264        let singleton = arena.singleton(EMPTY_CONTEXT, 42);
1265
1266        assert_eq!(
1267            arena.merge(singleton, EMPTY_CONTEXT, true, &mut workspace),
1268            EMPTY_CONTEXT
1269        );
1270    }
1271
1272    #[test]
1273    fn merge_is_order_independent() {
1274        let mut arena = ContextArena::new();
1275        let mut workspace = PredictionWorkspace::default();
1276        let left_parent = arena.singleton(EMPTY_CONTEXT, 100);
1277        let right_parent = arena.singleton(EMPTY_CONTEXT, 200);
1278        let left = arena.singleton(left_parent, 7);
1279        let right = arena.singleton(right_parent, 7);
1280
1281        let left_right = arena.merge(left, right, false, &mut workspace);
1282        workspace.reset();
1283        let right_left = arena.merge(right, left, false, &mut workspace);
1284
1285        assert_eq!(left_right, right_left);
1286        assert_eq!(arena.len(left_right), 1);
1287        let merged_parent = arena.parent(left_right, 0).expect("merged parent");
1288        assert_eq!(arena.len(merged_parent), 2);
1289        assert_eq!(arena.return_state(merged_parent, 0), Some(100));
1290        assert_eq!(arena.return_state(merged_parent, 1), Some(200));
1291    }
1292
1293    #[test]
1294    fn import_remaps_contexts_into_destination_arena() {
1295        let mut source = ContextArena::new();
1296        let parent = source.singleton(EMPTY_CONTEXT, 3);
1297        let child = source.singleton(parent, 9);
1298        let mut destination = ContextArena::new();
1299        let mut workspace = PredictionWorkspace::default();
1300
1301        let remap = destination.import_all(&source, &mut workspace);
1302        let imported = remap[usize::try_from(child.0).expect("context ID fits usize")];
1303
1304        assert_eq!(destination.return_state(imported, 0), Some(9));
1305        let imported_parent = destination.parent(imported, 0).expect("parent");
1306        assert_eq!(destination.return_state(imported_parent, 0), Some(3));
1307    }
1308
1309    #[test]
1310    fn config_set_merges_context_ids() {
1311        let mut arena = ContextArena::new();
1312        let mut workspace = PredictionWorkspace::default();
1313        let left = arena.singleton(EMPTY_CONTEXT, 1);
1314        let right = arena.singleton(EMPTY_CONTEXT, 2);
1315        let mut set = AtnConfigSet::new_full_context(true);
1316
1317        assert!(set.add(
1318            AtnConfig::new(1, 1, left, &arena),
1319            &mut arena,
1320            &mut workspace
1321        ));
1322        assert!(!set.add(
1323            AtnConfig::new(1, 1, right, &arena),
1324            &mut arena,
1325            &mut workspace
1326        ));
1327        assert_eq!(set.len(), 1);
1328        assert_eq!(arena.len(set.configs()[0].context), 2);
1329    }
1330
1331    #[test]
1332    fn workspace_drops_pathological_capacity() {
1333        let mut workspace = PredictionWorkspace::default();
1334        workspace
1335            .merge_cache
1336            .reserve(MAX_RETAINED_MERGE_CACHE_ENTRIES.saturating_mul(2));
1337        workspace
1338            .entries
1339            .reserve(MAX_RETAINED_CONTEXT_ENTRIES.saturating_mul(2));
1340        workspace.reset();
1341
1342        assert!(workspace.merge_cache.capacity() <= MAX_RETAINED_MERGE_CACHE_ENTRIES);
1343        assert!(workspace.entries.capacity() <= MAX_RETAINED_CONTEXT_ENTRIES);
1344    }
1345
1346    mod upstream_graph_nodes {
1347        use super::*;
1348        use std::collections::{BTreeSet, HashMap, VecDeque};
1349        use std::fmt::Write;
1350
1351        const EMPTY_WILDCARD_DOT: &str = concat!(
1352            "digraph G {\n",
1353            "rankdir=LR;\n",
1354            "  s0[label=\"*\"];\n",
1355            "}\n",
1356        );
1357        const EMPTY_FULL_CONTEXT_DOT: &str = concat!(
1358            "digraph G {\n",
1359            "rankdir=LR;\n",
1360            "  s0[label=\"$\"];\n",
1361            "}\n",
1362        );
1363        const X_EMPTY_FULL_CONTEXT_DOT: &str = concat!(
1364            "digraph G {\n",
1365            "rankdir=LR;\n",
1366            "  s0[shape=record, label=\"<p0>|<p1>$\"];\n",
1367            "  s1[label=\"$\"];\n",
1368            "  s0:p0->s1[label=\"9\"];\n",
1369            "}\n",
1370        );
1371        const A_DOT: &str = concat!(
1372            "digraph G {\n",
1373            "rankdir=LR;\n",
1374            "  s0[label=\"0\"];\n",
1375            "  s1[label=\"*\"];\n",
1376            "  s0->s1[label=\"1\"];\n",
1377            "}\n",
1378        );
1379        const A_EMPTY_AX_FULL_CONTEXT_DOT: &str = concat!(
1380            "digraph G {\n",
1381            "rankdir=LR;\n",
1382            "  s0[label=\"0\"];\n",
1383            "  s1[shape=record, label=\"<p0>|<p1>$\"];\n",
1384            "  s2[label=\"$\"];\n",
1385            "  s0->s1[label=\"1\"];\n",
1386            "  s1:p0->s2[label=\"9\"];\n",
1387            "}\n",
1388        );
1389        const NESTED_FULL_CONTEXT_DOT: &str = concat!(
1390            "digraph G {\n",
1391            "rankdir=LR;\n",
1392            "  s0[shape=record, label=\"<p0>|<p1>$\"];\n",
1393            "  s1[shape=record, label=\"<p0>|<p1>$\"];\n",
1394            "  s2[label=\"$\"];\n",
1395            "  s0:p0->s1[label=\"8\"];\n",
1396            "  s1:p0->s2[label=\"8\"];\n",
1397            "}\n",
1398        );
1399        const A_B_DOT: &str = concat!(
1400            "digraph G {\n",
1401            "rankdir=LR;\n",
1402            "  s0[shape=record, label=\"<p0>|<p1>\"];\n",
1403            "  s1[label=\"*\"];\n",
1404            "  s0:p0->s1[label=\"1\"];\n",
1405            "  s0:p1->s1[label=\"2\"];\n",
1406            "}\n",
1407        );
1408        const AX_AX_DOT: &str = concat!(
1409            "digraph G {\n",
1410            "rankdir=LR;\n",
1411            "  s0[label=\"0\"];\n",
1412            "  s1[label=\"1\"];\n",
1413            "  s2[label=\"*\"];\n",
1414            "  s0->s1[label=\"1\"];\n",
1415            "  s1->s2[label=\"9\"];\n",
1416            "}\n",
1417        );
1418        const ABX_ABX_DOT: &str = concat!(
1419            "digraph G {\n",
1420            "rankdir=LR;\n",
1421            "  s0[label=\"0\"];\n",
1422            "  s1[label=\"1\"];\n",
1423            "  s2[label=\"2\"];\n",
1424            "  s3[label=\"*\"];\n",
1425            "  s0->s1[label=\"1\"];\n",
1426            "  s1->s2[label=\"2\"];\n",
1427            "  s2->s3[label=\"9\"];\n",
1428            "}\n",
1429        );
1430        const ABX_ACX_DOT: &str = concat!(
1431            "digraph G {\n",
1432            "rankdir=LR;\n",
1433            "  s0[label=\"0\"];\n",
1434            "  s1[shape=record, label=\"<p0>|<p1>\"];\n",
1435            "  s2[label=\"2\"];\n",
1436            "  s3[label=\"*\"];\n",
1437            "  s0->s1[label=\"1\"];\n",
1438            "  s1:p0->s2[label=\"2\"];\n",
1439            "  s1:p1->s2[label=\"3\"];\n",
1440            "  s2->s3[label=\"9\"];\n",
1441            "}\n",
1442        );
1443        const AX_BX_DOT: &str = concat!(
1444            "digraph G {\n",
1445            "rankdir=LR;\n",
1446            "  s0[shape=record, label=\"<p0>|<p1>\"];\n",
1447            "  s1[label=\"1\"];\n",
1448            "  s2[label=\"*\"];\n",
1449            "  s0:p0->s1[label=\"1\"];\n",
1450            "  s0:p1->s1[label=\"2\"];\n",
1451            "  s1->s2[label=\"9\"];\n",
1452            "}\n",
1453        );
1454        const AX_BY_DOT: &str = concat!(
1455            "digraph G {\n",
1456            "rankdir=LR;\n",
1457            "  s0[shape=record, label=\"<p0>|<p1>\"];\n",
1458            "  s2[label=\"2\"];\n",
1459            "  s3[label=\"*\"];\n",
1460            "  s1[label=\"1\"];\n",
1461            "  s0:p0->s1[label=\"1\"];\n",
1462            "  s0:p1->s2[label=\"2\"];\n",
1463            "  s2->s3[label=\"10\"];\n",
1464            "  s1->s3[label=\"9\"];\n",
1465            "}\n",
1466        );
1467        const A_EMPTY_BX_DOT: &str = concat!(
1468            "digraph G {\n",
1469            "rankdir=LR;\n",
1470            "  s0[shape=record, label=\"<p0>|<p1>\"];\n",
1471            "  s2[label=\"2\"];\n",
1472            "  s1[label=\"*\"];\n",
1473            "  s0:p0->s1[label=\"1\"];\n",
1474            "  s0:p1->s2[label=\"2\"];\n",
1475            "  s2->s1[label=\"9\"];\n",
1476            "}\n",
1477        );
1478        const A_EMPTY_BX_FULL_CONTEXT_DOT: &str = concat!(
1479            "digraph G {\n",
1480            "rankdir=LR;\n",
1481            "  s0[shape=record, label=\"<p0>|<p1>\"];\n",
1482            "  s2[label=\"2\"];\n",
1483            "  s1[label=\"$\"];\n",
1484            "  s0:p0->s1[label=\"1\"];\n",
1485            "  s0:p1->s2[label=\"2\"];\n",
1486            "  s2->s1[label=\"9\"];\n",
1487            "}\n",
1488        );
1489        const AEX_BFX_DOT: &str = concat!(
1490            "digraph G {\n",
1491            "rankdir=LR;\n",
1492            "  s0[shape=record, label=\"<p0>|<p1>\"];\n",
1493            "  s2[label=\"2\"];\n",
1494            "  s3[label=\"3\"];\n",
1495            "  s4[label=\"*\"];\n",
1496            "  s1[label=\"1\"];\n",
1497            "  s0:p0->s1[label=\"1\"];\n",
1498            "  s0:p1->s2[label=\"2\"];\n",
1499            "  s2->s3[label=\"6\"];\n",
1500            "  s3->s4[label=\"9\"];\n",
1501            "  s1->s3[label=\"5\"];\n",
1502            "}\n",
1503        );
1504        const A_B_C_DOT: &str = concat!(
1505            "digraph G {\n",
1506            "rankdir=LR;\n",
1507            "  s0[shape=record, label=\"<p0>|<p1>|<p2>\"];\n",
1508            "  s1[label=\"*\"];\n",
1509            "  s0:p0->s1[label=\"1\"];\n",
1510            "  s0:p1->s1[label=\"2\"];\n",
1511            "  s0:p2->s1[label=\"3\"];\n",
1512            "}\n",
1513        );
1514        const AAX_AAY_DOT: &str = concat!(
1515            "digraph G {\n",
1516            "rankdir=LR;\n",
1517            "  s0[label=\"0\"];\n",
1518            "  s1[shape=record, label=\"<p0>|<p1>\"];\n",
1519            "  s2[label=\"*\"];\n",
1520            "  s0->s1[label=\"1\"];\n",
1521            "  s1:p0->s2[label=\"9\"];\n",
1522            "  s1:p1->s2[label=\"10\"];\n",
1523            "}\n",
1524        );
1525        const AAXC_AAYD_DOT: &str = concat!(
1526            "digraph G {\n",
1527            "rankdir=LR;\n",
1528            "  s0[shape=record, label=\"<p0>|<p1>|<p2>\"];\n",
1529            "  s2[label=\"*\"];\n",
1530            "  s1[shape=record, label=\"<p0>|<p1>\"];\n",
1531            "  s0:p0->s1[label=\"1\"];\n",
1532            "  s0:p1->s2[label=\"3\"];\n",
1533            "  s0:p2->s2[label=\"4\"];\n",
1534            "  s1:p0->s2[label=\"9\"];\n",
1535            "  s1:p1->s2[label=\"10\"];\n",
1536            "}\n",
1537        );
1538        const AAUBV_ACWDX_DOT: &str = concat!(
1539            "digraph G {\n",
1540            "rankdir=LR;\n",
1541            "  s0[shape=record, label=\"<p0>|<p1>|<p2>|<p3>\"];\n",
1542            "  s4[label=\"4\"];\n",
1543            "  s5[label=\"*\"];\n",
1544            "  s3[label=\"3\"];\n",
1545            "  s2[label=\"2\"];\n",
1546            "  s1[label=\"1\"];\n",
1547            "  s0:p0->s1[label=\"1\"];\n",
1548            "  s0:p1->s2[label=\"2\"];\n",
1549            "  s0:p2->s3[label=\"3\"];\n",
1550            "  s0:p3->s4[label=\"4\"];\n",
1551            "  s4->s5[label=\"9\"];\n",
1552            "  s3->s5[label=\"8\"];\n",
1553            "  s2->s5[label=\"7\"];\n",
1554            "  s1->s5[label=\"6\"];\n",
1555            "}\n",
1556        );
1557        const AAUBV_ABVDX_DOT: &str = concat!(
1558            "digraph G {\n",
1559            "rankdir=LR;\n",
1560            "  s0[shape=record, label=\"<p0>|<p1>|<p2>\"];\n",
1561            "  s3[label=\"3\"];\n",
1562            "  s4[label=\"*\"];\n",
1563            "  s2[label=\"2\"];\n",
1564            "  s1[label=\"1\"];\n",
1565            "  s0:p0->s1[label=\"1\"];\n",
1566            "  s0:p1->s2[label=\"2\"];\n",
1567            "  s0:p2->s3[label=\"4\"];\n",
1568            "  s3->s4[label=\"9\"];\n",
1569            "  s2->s4[label=\"7\"];\n",
1570            "  s1->s4[label=\"6\"];\n",
1571            "}\n",
1572        );
1573        const AAUBV_ABWDX_DOT: &str = concat!(
1574            "digraph G {\n",
1575            "rankdir=LR;\n",
1576            "  s0[shape=record, label=\"<p0>|<p1>|<p2>\"];\n",
1577            "  s3[label=\"3\"];\n",
1578            "  s4[label=\"*\"];\n",
1579            "  s2[shape=record, label=\"<p0>|<p1>\"];\n",
1580            "  s1[label=\"1\"];\n",
1581            "  s0:p0->s1[label=\"1\"];\n",
1582            "  s0:p1->s2[label=\"2\"];\n",
1583            "  s0:p2->s3[label=\"4\"];\n",
1584            "  s3->s4[label=\"9\"];\n",
1585            "  s2:p0->s4[label=\"7\"];\n",
1586            "  s2:p1->s4[label=\"8\"];\n",
1587            "  s1->s4[label=\"6\"];\n",
1588            "}\n",
1589        );
1590        const AAUBV_ABVDU_DOT: &str = concat!(
1591            "digraph G {\n",
1592            "rankdir=LR;\n",
1593            "  s0[shape=record, label=\"<p0>|<p1>|<p2>\"];\n",
1594            "  s2[label=\"2\"];\n",
1595            "  s3[label=\"*\"];\n",
1596            "  s1[label=\"1\"];\n",
1597            "  s0:p0->s1[label=\"1\"];\n",
1598            "  s0:p1->s2[label=\"2\"];\n",
1599            "  s0:p2->s1[label=\"4\"];\n",
1600            "  s2->s3[label=\"7\"];\n",
1601            "  s1->s3[label=\"6\"];\n",
1602            "}\n",
1603        );
1604        const AAUBU_ACUDU_DOT: &str = concat!(
1605            "digraph G {\n",
1606            "rankdir=LR;\n",
1607            "  s0[shape=record, label=\"<p0>|<p1>|<p2>|<p3>\"];\n",
1608            "  s1[label=\"1\"];\n",
1609            "  s2[label=\"*\"];\n",
1610            "  s0:p0->s1[label=\"1\"];\n",
1611            "  s0:p1->s1[label=\"2\"];\n",
1612            "  s0:p2->s1[label=\"3\"];\n",
1613            "  s0:p3->s1[label=\"4\"];\n",
1614            "  s1->s2[label=\"6\"];\n",
1615            "}\n",
1616        );
1617
1618        #[derive(Clone, Copy)]
1619        enum ContextSpec {
1620            Empty,
1621            Chain(&'static [usize]),
1622            Array(&'static [&'static [usize]]),
1623        }
1624
1625        #[derive(Clone, Copy)]
1626        enum Scenario {
1627            Merge {
1628                left: ContextSpec,
1629                right: ContextSpec,
1630            },
1631            NestedFullContext,
1632        }
1633
1634        struct GraphCase {
1635            source_test: &'static str,
1636            logical_id: &'static str,
1637            scenario: Scenario,
1638            root_is_wildcard: bool,
1639            expected: &'static str,
1640        }
1641
1642        impl GraphCase {
1643            const fn merge(
1644                source_test: &'static str,
1645                logical_id: &'static str,
1646                left: ContextSpec,
1647                right: ContextSpec,
1648                root_is_wildcard: bool,
1649                expected: &'static str,
1650            ) -> Self {
1651                Self {
1652                    source_test,
1653                    logical_id,
1654                    scenario: Scenario::Merge { left, right },
1655                    root_is_wildcard,
1656                    expected,
1657                }
1658            }
1659
1660            const fn nested_full_context(
1661                source_test: &'static str,
1662                logical_id: &'static str,
1663                expected: &'static str,
1664            ) -> Self {
1665                Self {
1666                    source_test,
1667                    logical_id,
1668                    scenario: Scenario::NestedFullContext,
1669                    root_is_wildcard: false,
1670                    expected,
1671                }
1672            }
1673        }
1674
1675        const CASES: &[GraphCase] = &[
1676            GraphCase::merge(
1677                "test_$_$",
1678                "testgraphnodes-test-9ea85e6b69",
1679                ContextSpec::Empty,
1680                ContextSpec::Empty,
1681                true,
1682                EMPTY_WILDCARD_DOT,
1683            ),
1684            GraphCase::merge(
1685                "test_$_$_fullctx",
1686                "testgraphnodes-test-fullctx-3a6b2d8201",
1687                ContextSpec::Empty,
1688                ContextSpec::Empty,
1689                false,
1690                EMPTY_FULL_CONTEXT_DOT,
1691            ),
1692            GraphCase::merge(
1693                "test_x_$",
1694                "testgraphnodes-test-x-546922b23c",
1695                ContextSpec::Chain(&[9]),
1696                ContextSpec::Empty,
1697                true,
1698                EMPTY_WILDCARD_DOT,
1699            ),
1700            GraphCase::merge(
1701                "test_x_$_fullctx",
1702                "testgraphnodes-test-x-fullctx-7fdaaf473e",
1703                ContextSpec::Chain(&[9]),
1704                ContextSpec::Empty,
1705                false,
1706                X_EMPTY_FULL_CONTEXT_DOT,
1707            ),
1708            GraphCase::merge(
1709                "test_$_x",
1710                "testgraphnodes-test-x-546922b23c",
1711                ContextSpec::Empty,
1712                ContextSpec::Chain(&[9]),
1713                true,
1714                EMPTY_WILDCARD_DOT,
1715            ),
1716            GraphCase::merge(
1717                "test_$_x_fullctx",
1718                "testgraphnodes-test-x-fullctx-7fdaaf473e",
1719                ContextSpec::Empty,
1720                ContextSpec::Chain(&[9]),
1721                false,
1722                X_EMPTY_FULL_CONTEXT_DOT,
1723            ),
1724            GraphCase::merge(
1725                "test_a_a",
1726                "testgraphnodes-test-a-a-429589e373",
1727                ContextSpec::Chain(&[1]),
1728                ContextSpec::Chain(&[1]),
1729                true,
1730                A_DOT,
1731            ),
1732            GraphCase::merge(
1733                "test_a$_ax",
1734                "testgraphnodes-test-a-ax-fd976a340d",
1735                ContextSpec::Chain(&[1]),
1736                ContextSpec::Chain(&[9, 1]),
1737                true,
1738                A_DOT,
1739            ),
1740            GraphCase::merge(
1741                "test_a$_ax_fullctx",
1742                "testgraphnodes-test-a-ax-fullctx-502155fcf9",
1743                ContextSpec::Chain(&[1]),
1744                ContextSpec::Chain(&[9, 1]),
1745                false,
1746                A_EMPTY_AX_FULL_CONTEXT_DOT,
1747            ),
1748            GraphCase::merge(
1749                "test_ax$_a$",
1750                "testgraphnodes-test-ax-a-62a48f251b",
1751                ContextSpec::Chain(&[9, 1]),
1752                ContextSpec::Chain(&[1]),
1753                true,
1754                A_DOT,
1755            ),
1756            GraphCase::nested_full_context(
1757                "test_aa$_a$_$_fullCtx",
1758                "testgraphnodes-test-aa-a-fullctx-8e728ea773",
1759                NESTED_FULL_CONTEXT_DOT,
1760            ),
1761            GraphCase::merge(
1762                "test_ax$_a$_fullctx",
1763                "testgraphnodes-test-ax-a-fullctx-7ef9c1d6b2",
1764                ContextSpec::Chain(&[9, 1]),
1765                ContextSpec::Chain(&[1]),
1766                false,
1767                A_EMPTY_AX_FULL_CONTEXT_DOT,
1768            ),
1769            GraphCase::merge(
1770                "test_a_b",
1771                "testgraphnodes-test-a-b-080058428f",
1772                ContextSpec::Chain(&[1]),
1773                ContextSpec::Chain(&[2]),
1774                true,
1775                A_B_DOT,
1776            ),
1777            GraphCase::merge(
1778                "test_ax_ax_same",
1779                "testgraphnodes-test-ax-ax-same-1504dc3dd3",
1780                ContextSpec::Chain(&[9, 1]),
1781                ContextSpec::Chain(&[9, 1]),
1782                true,
1783                AX_AX_DOT,
1784            ),
1785            GraphCase::merge(
1786                "test_ax_ax",
1787                "testgraphnodes-test-ax-ax-48f57578fa",
1788                ContextSpec::Chain(&[9, 1]),
1789                ContextSpec::Chain(&[9, 1]),
1790                true,
1791                AX_AX_DOT,
1792            ),
1793            GraphCase::merge(
1794                "test_abx_abx",
1795                "testgraphnodes-test-abx-abx-77366e32e9",
1796                ContextSpec::Chain(&[9, 2, 1]),
1797                ContextSpec::Chain(&[9, 2, 1]),
1798                true,
1799                ABX_ABX_DOT,
1800            ),
1801            GraphCase::merge(
1802                "test_abx_acx",
1803                "testgraphnodes-test-abx-acx-a3af7f90fa",
1804                ContextSpec::Chain(&[9, 2, 1]),
1805                ContextSpec::Chain(&[9, 3, 1]),
1806                true,
1807                ABX_ACX_DOT,
1808            ),
1809            GraphCase::merge(
1810                "test_ax_bx_same",
1811                "testgraphnodes-test-ax-bx-same-d0506bf7a9",
1812                ContextSpec::Chain(&[9, 1]),
1813                ContextSpec::Chain(&[9, 2]),
1814                true,
1815                AX_BX_DOT,
1816            ),
1817            GraphCase::merge(
1818                "test_ax_bx",
1819                "testgraphnodes-test-ax-bx-1ea2df9a04",
1820                ContextSpec::Chain(&[9, 1]),
1821                ContextSpec::Chain(&[9, 2]),
1822                true,
1823                AX_BX_DOT,
1824            ),
1825            GraphCase::merge(
1826                "test_ax_by",
1827                "testgraphnodes-test-ax-by-47815d59d2",
1828                ContextSpec::Chain(&[9, 1]),
1829                ContextSpec::Chain(&[10, 2]),
1830                true,
1831                AX_BY_DOT,
1832            ),
1833            GraphCase::merge(
1834                "test_a$_bx",
1835                "testgraphnodes-test-a-bx-b15f7b876f",
1836                ContextSpec::Chain(&[1]),
1837                ContextSpec::Chain(&[9, 2]),
1838                true,
1839                A_EMPTY_BX_DOT,
1840            ),
1841            GraphCase::merge(
1842                "test_a$_bx_fullctx",
1843                "testgraphnodes-test-a-bx-fullctx-a35242b6cf",
1844                ContextSpec::Chain(&[1]),
1845                ContextSpec::Chain(&[9, 2]),
1846                false,
1847                A_EMPTY_BX_FULL_CONTEXT_DOT,
1848            ),
1849            GraphCase::merge(
1850                "test_aex_bfx",
1851                "testgraphnodes-test-aex-bfx-07ad9de126",
1852                ContextSpec::Chain(&[9, 5, 1]),
1853                ContextSpec::Chain(&[9, 6, 2]),
1854                true,
1855                AEX_BFX_DOT,
1856            ),
1857            GraphCase::merge(
1858                "test_A$_A$_fullctx",
1859                "testgraphnodes-test-a-a-fullctx-b023f64b6c",
1860                ContextSpec::Array(&[&[]]),
1861                ContextSpec::Array(&[&[]]),
1862                false,
1863                EMPTY_FULL_CONTEXT_DOT,
1864            ),
1865            GraphCase::merge(
1866                "test_Aab_Ac",
1867                "testgraphnodes-test-aab-ac-139c5b709d",
1868                ContextSpec::Array(&[&[1], &[2]]),
1869                ContextSpec::Array(&[&[3]]),
1870                true,
1871                A_B_C_DOT,
1872            ),
1873            GraphCase::merge(
1874                "test_Aa_Aa",
1875                "testgraphnodes-test-aa-aa-0a175c83db",
1876                ContextSpec::Array(&[&[1]]),
1877                ContextSpec::Array(&[&[1]]),
1878                true,
1879                A_DOT,
1880            ),
1881            GraphCase::merge(
1882                "test_Aa_Abc",
1883                "testgraphnodes-test-aa-abc-db12d99894",
1884                ContextSpec::Array(&[&[1]]),
1885                ContextSpec::Array(&[&[2], &[3]]),
1886                true,
1887                A_B_C_DOT,
1888            ),
1889            GraphCase::merge(
1890                "test_Aac_Ab",
1891                "testgraphnodes-test-aac-ab-ef785e17e7",
1892                ContextSpec::Array(&[&[1], &[3]]),
1893                ContextSpec::Array(&[&[2]]),
1894                true,
1895                A_B_C_DOT,
1896            ),
1897            GraphCase::merge(
1898                "test_Aab_Aa",
1899                "testgraphnodes-test-aab-aa-d90d8d54f0",
1900                ContextSpec::Array(&[&[1], &[2]]),
1901                ContextSpec::Array(&[&[1]]),
1902                true,
1903                A_B_DOT,
1904            ),
1905            GraphCase::merge(
1906                "test_Aab_Ab",
1907                "testgraphnodes-test-aab-ab-e2d46352b4",
1908                ContextSpec::Array(&[&[1], &[2]]),
1909                ContextSpec::Array(&[&[2]]),
1910                true,
1911                A_B_DOT,
1912            ),
1913            GraphCase::merge(
1914                "test_Aax_Aby",
1915                "testgraphnodes-test-aax-aby-cccf935759",
1916                ContextSpec::Array(&[&[9, 1]]),
1917                ContextSpec::Array(&[&[10, 2]]),
1918                true,
1919                AX_BY_DOT,
1920            ),
1921            GraphCase::merge(
1922                "test_Aax_Aay",
1923                "testgraphnodes-test-aax-aay-c0f9b80842",
1924                ContextSpec::Array(&[&[9, 1]]),
1925                ContextSpec::Array(&[&[10, 1]]),
1926                true,
1927                AAX_AAY_DOT,
1928            ),
1929            GraphCase::merge(
1930                "test_Aaxc_Aayd",
1931                "testgraphnodes-test-aaxc-aayd-a73533f64d",
1932                ContextSpec::Array(&[&[9, 1], &[3]]),
1933                ContextSpec::Array(&[&[10, 1], &[4]]),
1934                true,
1935                AAXC_AAYD_DOT,
1936            ),
1937            GraphCase::merge(
1938                "test_Aaubv_Acwdx",
1939                "testgraphnodes-test-aaubv-acwdx-f479c849df",
1940                ContextSpec::Array(&[&[6, 1], &[7, 2]]),
1941                ContextSpec::Array(&[&[8, 3], &[9, 4]]),
1942                true,
1943                AAUBV_ACWDX_DOT,
1944            ),
1945            GraphCase::merge(
1946                "test_Aaubv_Abvdx",
1947                "testgraphnodes-test-aaubv-abvdx-01eb5714fe",
1948                ContextSpec::Array(&[&[6, 1], &[7, 2]]),
1949                ContextSpec::Array(&[&[7, 2], &[9, 4]]),
1950                true,
1951                AAUBV_ABVDX_DOT,
1952            ),
1953            GraphCase::merge(
1954                "test_Aaubv_Abwdx",
1955                "testgraphnodes-test-aaubv-abwdx-7953c9b489",
1956                ContextSpec::Array(&[&[6, 1], &[7, 2]]),
1957                ContextSpec::Array(&[&[8, 2], &[9, 4]]),
1958                true,
1959                AAUBV_ABWDX_DOT,
1960            ),
1961            GraphCase::merge(
1962                "test_Aaubv_Abvdu",
1963                "testgraphnodes-test-aaubv-abvdu-ecc8850384",
1964                ContextSpec::Array(&[&[6, 1], &[7, 2]]),
1965                ContextSpec::Array(&[&[7, 2], &[6, 4]]),
1966                true,
1967                AAUBV_ABVDU_DOT,
1968            ),
1969            GraphCase::merge(
1970                "test_Aaubu_Acudu",
1971                "testgraphnodes-test-aaubu-acudu-7cb798b616",
1972                ContextSpec::Array(&[&[6, 1], &[6, 2]]),
1973                ContextSpec::Array(&[&[6, 3], &[6, 4]]),
1974                true,
1975                AAUBU_ACUDU_DOT,
1976            ),
1977        ];
1978
1979        fn build_chain(arena: &mut ContextArena, return_states: &[usize]) -> ContextId {
1980            let mut context = EMPTY_CONTEXT;
1981            for &return_state in return_states {
1982                context = arena.singleton(context, return_state);
1983            }
1984            context
1985        }
1986
1987        fn build_context(arena: &mut ContextArena, spec: ContextSpec) -> ContextId {
1988            match spec {
1989                ContextSpec::Empty => EMPTY_CONTEXT,
1990                ContextSpec::Chain(return_states) => build_chain(arena, return_states),
1991                ContextSpec::Array(chains) => {
1992                    let mut entries = Vec::with_capacity(chains.len());
1993                    for return_states in chains {
1994                        let context = build_chain(arena, return_states);
1995                        entries.push(arena.first_entry(context));
1996                    }
1997                    arena.intern_entries(&entries)
1998                }
1999            }
2000        }
2001
2002        fn run_case(case: &GraphCase) -> String {
2003            let mut arena = ContextArena::new();
2004            let mut workspace = PredictionWorkspace::default();
2005            let merged = match case.scenario {
2006                Scenario::Merge { left, right } => {
2007                    let left = build_context(&mut arena, left);
2008                    let right = build_context(&mut arena, right);
2009                    arena.merge(left, right, case.root_is_wildcard, &mut workspace)
2010                }
2011                Scenario::NestedFullContext => {
2012                    let child = arena.singleton(EMPTY_CONTEXT, 8);
2013                    let right = arena.merge(EMPTY_CONTEXT, child, false, &mut workspace);
2014                    let left = arena.singleton(right, 8);
2015                    arena.merge(left, right, false, &mut workspace)
2016                }
2017            };
2018            render_dot(&arena, merged, case.root_is_wildcard)
2019        }
2020
2021        fn render_dot(arena: &ContextArena, context: ContextId, root_is_wildcard: bool) -> String {
2022            let mut nodes = String::new();
2023            let mut edges = String::new();
2024            let mut context_ids = HashMap::new();
2025            let mut work_list = VecDeque::new();
2026            context_ids.insert(context, 0);
2027            work_list.push_back(context);
2028
2029            while let Some(current) = work_list.pop_front() {
2030                let current_id = context_ids[&current];
2031                let len = arena.len(current);
2032                write!(&mut nodes, "  s{current_id}[").expect("write to string");
2033                if len > 1 {
2034                    nodes.push_str("shape=record, ");
2035                }
2036                nodes.push_str("label=\"");
2037                if arena.is_empty(current) {
2038                    nodes.push(if root_is_wildcard { '*' } else { '$' });
2039                } else if len > 1 {
2040                    for index in 0..len {
2041                        if index > 0 {
2042                            nodes.push('|');
2043                        }
2044                        write!(&mut nodes, "<p{index}>").expect("write to string");
2045                        if arena.return_state(current, index) == Some(EMPTY_RETURN_STATE) {
2046                            nodes.push(if root_is_wildcard { '*' } else { '$' });
2047                        }
2048                    }
2049                } else {
2050                    write!(&mut nodes, "{current_id}").expect("write to string");
2051                }
2052                nodes.push_str("\"];\n");
2053
2054                if arena.is_empty(current) {
2055                    continue;
2056                }
2057                for index in 0..len {
2058                    let return_state = arena
2059                        .return_state(current, index)
2060                        .expect("context entry in range");
2061                    if return_state == EMPTY_RETURN_STATE {
2062                        continue;
2063                    }
2064                    let parent = arena.parent(current, index).expect("non-empty parent");
2065                    let parent_id = if let Some(&parent_id) = context_ids.get(&parent) {
2066                        parent_id
2067                    } else {
2068                        let parent_id = context_ids.len();
2069                        context_ids.insert(parent, parent_id);
2070                        work_list.push_front(parent);
2071                        parent_id
2072                    };
2073
2074                    write!(&mut edges, "  s{current_id}").expect("write to string");
2075                    if len > 1 {
2076                        write!(&mut edges, ":p{index}").expect("write to string");
2077                    }
2078                    writeln!(&mut edges, "->s{parent_id}[label=\"{return_state}\"];")
2079                        .expect("write to string");
2080                }
2081            }
2082
2083            let mut dot = String::from("digraph G {\nrankdir=LR;\n");
2084            dot.push_str(&nodes);
2085            dot.push_str(&edges);
2086            dot.push_str("}\n");
2087            dot
2088        }
2089
2090        #[test]
2091        fn pinned_upstream_test_graph_nodes_matches_dot() {
2092            assert_eq!(CASES.len(), 38, "pinned Java source case inventory drifted");
2093            let source_tests = CASES
2094                .iter()
2095                .map(|case| case.source_test)
2096                .collect::<BTreeSet<_>>();
2097            assert_eq!(
2098                source_tests.len(),
2099                38,
2100                "pinned Java source test names must be unique"
2101            );
2102            let logical_ids = CASES
2103                .iter()
2104                .map(|case| case.logical_id)
2105                .collect::<BTreeSet<_>>();
2106            assert_eq!(
2107                logical_ids.len(),
2108                36,
2109                "pinned upstream logical row inventory drifted"
2110            );
2111
2112            let selector = std::env::var("ANTLR_GRAPH_NODE_CASE").ok();
2113            let selected = CASES
2114                .iter()
2115                .filter(|case| {
2116                    selector
2117                        .as_deref()
2118                        .is_none_or(|logical_id| case.logical_id == logical_id)
2119                })
2120                .collect::<Vec<_>>();
2121            assert!(
2122                !selected.is_empty(),
2123                "ANTLR_GRAPH_NODE_CASE={:?} matched no logical row",
2124                selector.as_deref().unwrap_or_default()
2125            );
2126
2127            let mut mismatches = Vec::new();
2128            for case in &selected {
2129                let actual = run_case(case);
2130                if actual != case.expected {
2131                    mismatches.push(format!(
2132                        "logical_id={}\nsource_test={}\n--- expected\n{}--- actual\n{}",
2133                        case.logical_id, case.source_test, case.expected, actual
2134                    ));
2135                }
2136            }
2137
2138            assert!(
2139                mismatches.is_empty(),
2140                "TestGraphNodes DOT mismatches ({}/{} source cases):\n\n{}",
2141                mismatches.len(),
2142                selected.len(),
2143                mismatches.join("\n")
2144            );
2145        }
2146    }
2147}