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