Skip to main content

fnx_classes/
lib.rs

1#![forbid(unsafe_code)]
2
3pub mod digraph;
4
5use fnx_runtime::{
6    CgseValue, CompatibilityMode, DecisionAction, DecisionRecord, EvidenceLedger, EvidenceTerm,
7    RuntimePolicy, bool_evidence, count_evidence,
8};
9use indexmap::IndexMap;
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12
13// br-r37-c1-fxhash: the node (String) and edge ((usize,usize)) key->index maps use
14// a FAST non-cryptographic hasher instead of std's SipHash. IndexMap keeps insertion
15// order in its Vec, so iteration order (nodes/edges/edges_ordered) is UNCHANGED — only
16// lookup/insert hashing gets faster. The (usize,usize) edge keys especially: SipHash on
17// 16 bytes (~20ns) -> FxHash on a usize (~3ns). Speeds up every has_edge/edge_attrs/
18// get_index_of/construction insert across the library.
19pub(crate) type FxIndexMap<K, V> = IndexMap<K, V, rustc_hash::FxBuildHasher>;
20/// br-r37-c1-4c29a: the set half of the same alias. `MultiDiGraph`'s adjacency
21/// rows are `IndexMap<String, IndexSet<usize>>` nested inside an already-Fx
22/// outer map, and the inner pair was missed by the original fxhash pass — they
23/// were still on the default SipHash. Both containers are insertion-ordered, so
24/// the hasher is invisible to iteration order.
25pub(crate) type FxIndexSet<T> = indexmap::IndexSet<T, rustc_hash::FxBuildHasher>;
26use std::collections::HashSet;
27use std::fmt;
28
29pub use fnx_runtime::AttrMap;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum GraphError {
33    FailClosed {
34        operation: &'static str,
35        reason: String,
36    },
37}
38
39impl fmt::Display for GraphError {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        match self {
42            Self::FailClosed { operation, reason } => {
43                write!(f, "operation `{operation}` failed closed: {reason}")
44            }
45        }
46    }
47}
48
49impl std::error::Error for GraphError {}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct EdgeSnapshot {
53    pub left: String,
54    pub right: String,
55    pub attrs: AttrMap,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct GraphSnapshot {
60    pub mode: CompatibilityMode,
61    pub nodes: Vec<String>,
62    /// br-snapnodeattrs: node attributes keyed by node name. Preserved
63    /// across the ``snapshot`` round-trip so a graph with
64    /// ``add_node(n, attrs)`` doesn't lose those attrs when replayed.
65    /// Defaults to empty for snapshots produced by old code paths;
66    /// consumers that don't care can ignore it.
67    #[serde(default)]
68    pub node_attrs: BTreeMap<String, AttrMap>,
69    pub edges: Vec<EdgeSnapshot>,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct MultiEdgeSnapshot {
74    pub left: String,
75    pub right: String,
76    pub key: usize,
77    pub attrs: AttrMap,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct MultiGraphSnapshot {
82    pub mode: CompatibilityMode,
83    pub nodes: Vec<String>,
84    /// See ``GraphSnapshot::node_attrs``.
85    #[serde(default)]
86    pub node_attrs: BTreeMap<String, AttrMap>,
87    pub edges: Vec<MultiEdgeSnapshot>,
88}
89
90/// yr2oc.1: the node table of `Graph` and `DiGraph`: name -> slot, and the
91/// nodes in insertion order.
92///
93/// Slots are handed out in insertion order and the entries are indexed by
94/// slot, so the live entries in slot order ARE the insertion order. Removing a
95/// node tombstones its entry in O(1) instead of shifting every later node. A
96/// node's POSITION (the dense insertion-order index of the public API) is its
97/// rank among the live entries: its slot while nothing has been removed, and
98/// after a removal answered in O(log V) by a Fenwick tree built on first use
99/// and then updated by each further removal.
100#[derive(Debug, Clone, Default)]
101pub(crate) struct NodeOrder {
102    slots: rustc_hash::FxHashMap<String, usize>,
103    /// slot -> name, `None` once removed. Read it freely; change it only
104    /// through the methods, which keep `slots`, `live` and the ranks in step.
105    pub(crate) names: Vec<Option<String>>,
106    live: usize,
107    ranks: std::sync::OnceLock<NodeRanks>,
108    /// sfq4w.3: every node's name is the decimal of its position (int nodes
109    /// added as 0, 1, 2, ...). Computed on first use, then kept current: an
110    /// append checks its one name, a removal of any but the last node clears it.
111    names_are_positions: std::sync::OnceLock<bool>,
112}
113
114/// `name` is exactly the decimal spelling of `n` (no sign, no leading zero).
115fn is_decimal_of(name: &str, mut n: usize) -> bool {
116    let digits = name.as_bytes();
117    let mut i = digits.len();
118    loop {
119        if i == 0 {
120            return false;
121        }
122        i -= 1;
123        #[allow(clippy::cast_possible_truncation)]
124        if digits[i] != b'0' + (n % 10) as u8 {
125            return false;
126        }
127        n /= 10;
128        if n == 0 {
129            return i == 0;
130        }
131    }
132}
133
134/// Rank/select over the live entries of a node table: a Fenwick tree of 0/1
135/// liveness. A removal updates it in O(log V) instead of invalidating it, so a
136/// positional read between two removals never pays an O(V) rebuild.
137#[derive(Debug, Clone)]
138struct NodeRanks {
139    /// 1-based Fenwick tree over entries.
140    tree: Vec<usize>,
141}
142
143impl NodeRanks {
144    /// A tree over `live.len()` entries, entry `i` counting when `live[i]`.
145    fn build(live: impl ExactSizeIterator<Item = bool>) -> Self {
146        let n = live.len();
147        let mut tree = vec![0; n + 1];
148        for (entry, is_live) in live.enumerate() {
149            if is_live {
150                tree[entry + 1] += 1;
151            }
152        }
153        for i in 1..=n {
154            let parent = i + i.isolate_lowest_one();
155            if parent <= n {
156                tree[parent] += tree[i];
157            }
158        }
159        Self { tree }
160    }
161
162    /// The number of live slots before `slot`.
163    fn rank(&self, slot: usize) -> usize {
164        let mut i = slot;
165        let mut live = 0;
166        while i > 0 {
167            live += self.tree[i];
168            i &= i - 1;
169        }
170        live
171    }
172
173    /// The live slot with `position` live slots before it.
174    fn select(&self, position: usize) -> Option<usize> {
175        let n = self.tree.len() - 1;
176        if n == 0 {
177            return None;
178        }
179        let mut remaining = position + 1;
180        let mut index = 0;
181        let mut step = 1 << (usize::BITS - 1 - n.leading_zeros());
182        while step > 0 {
183            let next = index + step;
184            if next <= n && self.tree[next] < remaining {
185                index = next;
186                remaining -= self.tree[next];
187            }
188            step >>= 1;
189        }
190        (index < n).then_some(index)
191    }
192
193    fn remove(&mut self, slot: usize) {
194        let mut i = slot + 1;
195        while i < self.tree.len() {
196            self.tree[i] -= 1;
197            i += i.isolate_lowest_one();
198        }
199    }
200
201    /// Append an entry in O(log V): the new node covers the entries after
202    /// `i - lowbit(i)`, all already counted by the prefix sums.
203    fn push(&mut self, is_live: bool) {
204        let i = self.tree.len();
205        let covered = self.rank(i - 1) - self.rank(i - i.isolate_lowest_one());
206        self.tree.push(covered + usize::from(is_live));
207    }
208
209    /// Drop the last entry. No earlier node covers it.
210    fn pop(&mut self) {
211        debug_assert!(self.tree.len() > 1);
212        self.tree.pop();
213    }
214}
215
216impl NodeOrder {
217    /// A table of distinct names, each at the slot of its index.
218    pub(crate) fn from_names(names: Vec<String>) -> Self {
219        let mut order = Self::default();
220        order.reserve(names.len());
221        for name in names {
222            order.push(name);
223        }
224        order
225    }
226
227    #[inline]
228    pub(crate) fn len(&self) -> usize {
229        self.live
230    }
231
232    #[inline]
233    pub(crate) fn is_empty(&self) -> bool {
234        self.live == 0
235    }
236
237    /// The length of every slot-indexed array: live and removed entries.
238    #[inline]
239    pub(crate) fn slot_count(&self) -> usize {
240        self.names.len()
241    }
242
243    /// Nothing removed since the last compaction: every slot is its position.
244    #[inline]
245    pub(crate) fn is_dense(&self) -> bool {
246        self.live == self.names.len()
247    }
248
249    /// The slot of a live name.
250    #[inline]
251    pub(crate) fn get(&self, name: &str) -> Option<&usize> {
252        self.slots.get(name)
253    }
254
255    #[inline]
256    pub(crate) fn contains_key(&self, name: &str) -> bool {
257        self.slots.contains_key(name)
258    }
259
260    /// The name at a live slot.
261    #[inline]
262    pub(crate) fn name(&self, slot: usize) -> Option<&str> {
263        self.names.get(slot)?.as_deref()
264    }
265
266    pub(crate) fn reserve(&mut self, additional: usize) {
267        self.slots.reserve(additional);
268        self.names.reserve(additional);
269    }
270
271    /// Append a node that is not in the table; returns its slot.
272    pub(crate) fn push(&mut self, name: String) -> usize {
273        debug_assert!(!self.slots.contains_key(&name));
274        let slot = self.names.len();
275        self.slots.insert(name.clone(), slot);
276        if let Some(flag) = self.names_are_positions.get_mut()
277            && *flag
278        {
279            *flag = is_decimal_of(&name, self.live);
280        }
281        self.names.push(Some(name));
282        if let Some(ranks) = self.ranks.get_mut() {
283            ranks.push(true);
284        }
285        self.live += 1;
286        slot
287    }
288
289    /// Remove a node: its slot, and whether its entry was popped rather than
290    /// tombstoned (it was the last entry and nothing else had been removed),
291    /// in which case the caller pops its slot-indexed arrays too. O(1), plus
292    /// O(log V) to keep a built rank tree current.
293    pub(crate) fn remove(&mut self, name: &str) -> Option<(usize, bool)> {
294        let slot = self.slots.remove(name)?;
295        self.live -= 1;
296        let popped = slot + 1 == self.names.len() && self.live == slot;
297        if popped {
298            self.names.pop();
299            self.ranks.take();
300        } else {
301            self.names[slot] = None;
302            if let Some(ranks) = self.ranks.get_mut() {
303                ranks.remove(slot);
304            }
305            // Every later node moved down a position.
306            self.names_are_positions = std::sync::OnceLock::from(false);
307        }
308        Some((slot, popped))
309    }
310
311    /// sfq4w.3: is every node's name the decimal of its position? O(V) on
312    /// first use, O(1) after (see the field).
313    pub(crate) fn names_are_positions(&self) -> bool {
314        *self.names_are_positions.get_or_init(|| {
315            self.keys()
316                .enumerate()
317                .all(|(position, name)| is_decimal_of(name, position))
318        })
319    }
320
321    fn ranks(&self) -> &NodeRanks {
322        self.ranks
323            .get_or_init(|| NodeRanks::build(self.names.iter().map(Option::is_some)))
324    }
325
326    /// The slot at a position.
327    #[inline]
328    pub(crate) fn slot_at(&self, position: usize) -> Option<usize> {
329        if self.is_dense() {
330            (position < self.live).then_some(position)
331        } else if position < self.live {
332            self.ranks().select(position)
333        } else {
334            None
335        }
336    }
337
338    /// The position of a live slot.
339    #[inline]
340    pub(crate) fn position_of(&self, slot: usize) -> Option<usize> {
341        if self.is_dense() {
342            (slot < self.live).then_some(slot)
343        } else {
344            self.names.get(slot)?.as_ref()?;
345            Some(self.ranks().rank(slot))
346        }
347    }
348
349    /// slot -> position for a whole-graph walk (`usize::MAX` for a removed
350    /// slot), or `None` while every slot is its own position. O(slots).
351    pub(crate) fn slot_positions(&self) -> Option<Vec<usize>> {
352        if self.is_dense() {
353            return None;
354        }
355        let mut positions = vec![usize::MAX; self.names.len()];
356        for (position, slot) in self.values().enumerate() {
357            positions[slot] = position;
358        }
359        Some(positions)
360    }
361
362    /// The name and slot at a position.
363    #[inline]
364    pub(crate) fn get_index(&self, position: usize) -> Option<(&String, usize)> {
365        let slot = self.slot_at(position)?;
366        Some((self.names.get(slot)?.as_ref()?, slot))
367    }
368
369    /// The position of a live name.
370    #[inline]
371    pub(crate) fn get_index_of(&self, name: &str) -> Option<usize> {
372        self.position_of(*self.get(name)?)
373    }
374
375    /// `(name, slot)` in insertion order.
376    pub(crate) fn iter(&self) -> impl Iterator<Item = (&String, usize)> + '_ {
377        self.names
378            .iter()
379            .enumerate()
380            .filter_map(|(slot, name)| Some((name.as_ref()?, slot)))
381    }
382
383    /// Names in insertion order.
384    pub(crate) fn keys(&self) -> impl Iterator<Item = &String> + '_ {
385        self.names.iter().filter_map(Option::as_ref)
386    }
387
388    /// Slots in insertion order.
389    pub(crate) fn values(&self) -> impl Iterator<Item = usize> + '_ {
390        self.iter().map(|(_, slot)| slot)
391    }
392
393    /// Renumber the live entries 0..len in order; returns old slot -> new slot
394    /// (`usize::MAX` for a removed one).
395    pub(crate) fn compact(&mut self) -> Vec<usize> {
396        let mut old_to_new = vec![usize::MAX; self.names.len()];
397        let mut names = Vec::with_capacity(self.live);
398        for (old, name) in std::mem::take(&mut self.names).into_iter().enumerate() {
399            if let Some(name) = name {
400                let new = names.len();
401                old_to_new[old] = new;
402                *self.slots.get_mut(&name).expect("live name is indexed") = new;
403                names.push(Some(name));
404            }
405        }
406        self.names = names;
407        self.ranks.take();
408        old_to_new
409    }
410}
411
412/// yr2oc.1: the node table of `MultiGraph`: name -> slot, and the nodes in
413/// insertion order.
414///
415/// Unlike [`NodeOrder`], a freed slot is REUSED by the next new node, because
416/// the multigraph's rows and slot-keyed edge buckets are never renumbered. So
417/// slot order is not insertion order, and the order is a separate list of
418/// entries, each naming a slot. Removing a node tombstones its entry in O(1)
419/// (the last entry is popped); a node's POSITION is its entry's rank among the
420/// live entries, answered by the entry index itself while nothing is
421/// tombstoned and by a Fenwick tree after. Once tombstones outnumber live
422/// entries the list is compacted, which moves entries and never slots.
423#[derive(Debug, Clone, Default)]
424pub(crate) struct SlotOrder {
425    slots: rustc_hash::FxHashMap<String, usize>,
426    /// slot -> name, `None` while the slot is free.
427    names: Vec<Option<String>>,
428    /// Freed slots, reused last-freed first.
429    free: Vec<usize>,
430    /// Insertion order: entry -> slot, `usize::MAX` once removed.
431    entries: Vec<usize>,
432    /// slot -> its entry; meaningful for live slots only.
433    entry_of: Vec<usize>,
434    ranks: std::sync::OnceLock<NodeRanks>,
435    /// br-r37-c1-qaqog: every node's name is the decimal of its position -
436    /// `NodeOrder`'s sfq4w.3 flag for the MultiGraph table. Computed on first
437    /// use, then kept current: an insert checks its one name at the position
438    /// it takes (the end), a removal of any but the last entry clears it.
439    names_are_positions: std::sync::OnceLock<bool>,
440}
441
442impl SlotOrder {
443    #[inline]
444    pub(crate) fn len(&self) -> usize {
445        self.slots.len()
446    }
447
448    /// The length of every slot-indexed array: live and free slots.
449    #[inline]
450    pub(crate) fn slot_count(&self) -> usize {
451        self.names.len()
452    }
453
454    /// No entry tombstoned: every entry index is its position.
455    #[inline]
456    fn is_dense(&self) -> bool {
457        self.entries.len() == self.slots.len()
458    }
459
460    /// The slot of a live name.
461    #[inline]
462    pub(crate) fn get(&self, name: &str) -> Option<&usize> {
463        self.slots.get(name)
464    }
465
466    #[inline]
467    pub(crate) fn contains_key(&self, name: &str) -> bool {
468        self.slots.contains_key(name)
469    }
470
471    /// The name at a live slot.
472    #[inline]
473    pub(crate) fn name(&self, slot: usize) -> Option<&str> {
474        self.names.get(slot)?.as_deref()
475    }
476
477    /// slot -> name for every slot, `None` for a free one.
478    #[inline]
479    pub(crate) fn names(&self) -> &[Option<String>] {
480        &self.names
481    }
482
483    /// The freed slots awaiting reuse.
484    #[cfg(test)]
485    pub(crate) fn free_slots(&self) -> &[usize] {
486        &self.free
487    }
488
489    pub(crate) fn reserve(&mut self, additional: usize) {
490        self.slots.reserve(additional);
491        self.names.reserve(additional);
492        self.entries.reserve(additional);
493        self.entry_of.reserve(additional);
494    }
495
496    /// Append a node that is not in the table. Returns its slot and whether
497    /// the slot is new (the caller grows its slot-indexed arrays) rather than
498    /// a reused free one.
499    pub(crate) fn insert(&mut self, name: String) -> (usize, bool) {
500        debug_assert!(!self.slots.contains_key(&name));
501        if let Some(flag) = self.names_are_positions.get_mut()
502            && *flag
503        {
504            *flag = is_decimal_of(&name, self.slots.len());
505        }
506        let (slot, fresh) = if let Some(slot) = self.free.pop() {
507            debug_assert!(self.names[slot].is_none());
508            self.names[slot] = Some(name.clone());
509            (slot, false)
510        } else {
511            self.names.push(Some(name.clone()));
512            self.entry_of.push(usize::MAX);
513            (self.names.len() - 1, true)
514        };
515        self.slots.insert(name, slot);
516        self.entry_of[slot] = self.entries.len();
517        self.entries.push(slot);
518        if let Some(ranks) = self.ranks.get_mut() {
519            ranks.push(true);
520        }
521        (slot, fresh)
522    }
523
524    /// Remove a node; returns its slot, now free. O(1), plus O(log V) to keep
525    /// a built rank tree current, plus an amortised O(1) share of compaction.
526    pub(crate) fn remove(&mut self, name: &str) -> Option<usize> {
527        let slot = self.slots.remove(name)?;
528        self.names[slot] = None;
529        self.free.push(slot);
530        let entry = self.entry_of[slot];
531        if entry + 1 == self.entries.len() {
532            self.entries.pop();
533            if let Some(ranks) = self.ranks.get_mut() {
534                ranks.pop();
535            }
536        } else {
537            self.entries[entry] = usize::MAX;
538            if let Some(ranks) = self.ranks.get_mut() {
539                ranks.remove(entry);
540            }
541            // Every later node moved down a position.
542            self.names_are_positions = std::sync::OnceLock::from(false);
543        }
544        if self.entries.len() - self.slots.len() > self.slots.len() {
545            self.compact();
546        }
547        Some(slot)
548    }
549
550    /// Remove every node whose slot is in `doomed`, freeing the slots in the
551    /// set's iteration order. O(V).
552    pub(crate) fn remove_slots(&mut self, doomed: &rustc_hash::FxHashSet<usize>) {
553        for &slot in doomed {
554            let name = self.names[slot].take().expect("doomed slot is live");
555            self.slots.remove(&name);
556            self.entries[self.entry_of[slot]] = usize::MAX;
557            self.free.push(slot);
558        }
559        if !doomed.is_empty() {
560            self.names_are_positions = std::sync::OnceLock::from(false);
561        }
562        self.compact();
563    }
564
565    /// br-r37-c1-qaqog: is every node's name the decimal of its position?
566    /// O(V) on first use, O(1) after (see the field).
567    pub(crate) fn names_are_positions(&self) -> bool {
568        *self.names_are_positions.get_or_init(|| {
569            self.keys()
570                .enumerate()
571                .all(|(position, name)| is_decimal_of(name, position))
572        })
573    }
574
575    /// Drop the tombstoned entries; slots do not move.
576    fn compact(&mut self) {
577        self.entries.retain(|&slot| slot != usize::MAX);
578        for (entry, &slot) in self.entries.iter().enumerate() {
579            self.entry_of[slot] = entry;
580        }
581        self.ranks.take();
582    }
583
584    fn ranks(&self) -> &NodeRanks {
585        self.ranks
586            .get_or_init(|| NodeRanks::build(self.entries.iter().map(|&slot| slot != usize::MAX)))
587    }
588
589    /// The slot at a position.
590    #[inline]
591    pub(crate) fn slot_at(&self, position: usize) -> Option<usize> {
592        if self.is_dense() {
593            self.entries.get(position).copied()
594        } else if position < self.slots.len() {
595            Some(self.entries[self.ranks().select(position)?])
596        } else {
597            None
598        }
599    }
600
601    /// The position of a live name.
602    #[inline]
603    pub(crate) fn get_index_of(&self, name: &str) -> Option<usize> {
604        let entry = self.entry_of[*self.slots.get(name)?];
605        Some(if self.is_dense() {
606            entry
607        } else {
608            self.ranks().rank(entry)
609        })
610    }
611
612    /// The name and slot at a position.
613    #[inline]
614    pub(crate) fn get_index(&self, position: usize) -> Option<(&String, usize)> {
615        let slot = self.slot_at(position)?;
616        Some((self.names[slot].as_ref()?, slot))
617    }
618
619    /// `(name, slot)` in insertion order.
620    pub(crate) fn iter(&self) -> impl Iterator<Item = (&String, usize)> + '_ {
621        self.entries
622            .iter()
623            .filter(|&&slot| slot != usize::MAX)
624            .map(|&slot| (self.names[slot].as_ref().expect("live entry"), slot))
625    }
626
627    /// Names in insertion order.
628    pub(crate) fn keys(&self) -> impl Iterator<Item = &String> + '_ {
629        self.iter().map(|(name, _)| name)
630    }
631
632    /// Slots in insertion order.
633    pub(crate) fn values(&self) -> impl Iterator<Item = usize> + '_ {
634        self.iter().map(|(_, slot)| slot)
635    }
636}
637
638/// yr2oc.1: adjacency rows indexed by node SLOT, holding neighbour slots.
639///
640/// Slots stay put when a node is removed, so a removal costs its own degree
641/// instead of renumbering the whole graph. The public `*_indices` API speaks
642/// dense insertion-order POSITIONS, which equal slots only until a removal
643/// separates them. From then until the next compaction,
644/// [`SlotRows::positions`] serves the rows translated to positions, built on
645/// first use. Rows can only be changed through [`SlotRows::rows_mut`], which
646/// drops that translation, so it is never read stale.
647#[derive(Debug, Clone, Default)]
648pub(crate) struct SlotRows {
649    rows: Vec<Vec<usize>>,
650    positions: std::sync::OnceLock<Vec<Vec<usize>>>,
651}
652
653impl std::ops::Deref for SlotRows {
654    type Target = Vec<Vec<usize>>;
655
656    fn deref(&self) -> &Vec<Vec<usize>> {
657        &self.rows
658    }
659}
660
661impl SlotRows {
662    pub(crate) fn new(rows: Vec<Vec<usize>>) -> Self {
663        Self {
664            rows,
665            positions: std::sync::OnceLock::new(),
666        }
667    }
668
669    /// The only mutable access to the rows; it drops the position view.
670    pub(crate) fn rows_mut(&mut self) -> &mut Vec<Vec<usize>> {
671        self.positions.take();
672        &mut self.rows
673    }
674
675    /// The rows by position: position -> neighbour positions, in the slot
676    /// row's order. Built once per change of the rows.
677    pub(crate) fn positions(&self, order: &NodeOrder) -> &[Vec<usize>] {
678        self.positions.get_or_init(|| {
679            let mut slot_positions = vec![usize::MAX; self.rows.len()];
680            for (position, slot) in order.values().enumerate() {
681                slot_positions[slot] = position;
682            }
683            order
684                .values()
685                .map(|slot| {
686                    self.rows[slot]
687                        .iter()
688                        .map(|&neighbour| slot_positions[neighbour])
689                        .collect()
690                })
691                .collect()
692        })
693    }
694}
695
696/// br-r37-c1-36v6r: order every row of `rows` as `wanted`: `wanted[i]` is
697/// the row of the node at position `i` (`slots[i]`), neighbours as
698/// positions. All or nothing: `None` unless every wanted row is a
699/// permutation of the current one; else whether any row moved.
700pub(crate) fn set_slot_row_orders(
701    rows: &mut SlotRows,
702    slots: &[usize],
703    wanted: &[Vec<usize>],
704) -> Option<bool> {
705    if wanted.len() != slots.len() {
706        return None;
707    }
708    // unplaced[w] == slot while w sits in slot's current row, unconsumed.
709    let mut unplaced = vec![usize::MAX; rows.len()];
710    let mut ordered: Vec<Vec<usize>> = Vec::with_capacity(slots.len());
711    for (&slot, row) in slots.iter().zip(wanted) {
712        let current = &rows[slot];
713        if row.len() != current.len() {
714            return None;
715        }
716        for &w in current {
717            unplaced[w] = slot;
718        }
719        let mut new_row = Vec::with_capacity(row.len());
720        for &position in row {
721            let w = *slots.get(position)?;
722            if unplaced[w] != slot {
723                return None;
724            }
725            unplaced[w] = usize::MAX;
726            new_row.push(w);
727        }
728        ordered.push(new_row);
729    }
730    let mut changed = false;
731    for (&slot, new_row) in slots.iter().zip(ordered) {
732        if rows[slot] != new_row {
733            rows.rows_mut()[slot] = new_row;
734            changed = true;
735        }
736    }
737    Some(changed)
738}
739
740/// br-r37-c1-36v6r: put each row of `rows` in the order of the matching row
741/// of `source_rows`, in one pass per row: `slots` are the rows to order,
742/// `to_source` / `from_source` translate slots between the two graphs.
743/// Entries the source row lacks follow the matched ones in their own order.
744/// Returns whether any row moved.
745pub(crate) fn reorder_slot_rows_like(
746    rows: &mut SlotRows,
747    source_rows: &SlotRows,
748    slots: &[usize],
749    to_source: &[usize],
750    from_source: &[usize],
751) -> bool {
752    // in_row[w] == slot while w sits in slot's row and is not yet placed.
753    let mut in_row = vec![usize::MAX; to_source.len()];
754    let mut ordered: Vec<usize> = Vec::new();
755    let mut changed = false;
756    for &slot in slots {
757        let source_slot = to_source[slot];
758        let row = &rows[slot];
759        if source_slot == usize::MAX || row.len() < 2 {
760            continue;
761        }
762        for &w in row {
763            in_row[w] = slot;
764        }
765        ordered.clear();
766        for &source_neighbor in &source_rows[source_slot] {
767            let w = from_source[source_neighbor];
768            if w != usize::MAX && in_row[w] == slot {
769                ordered.push(w);
770                in_row[w] = usize::MAX;
771            }
772        }
773        if ordered.len() < row.len() {
774            ordered.extend(row.iter().copied().filter(|&w| in_row[w] == slot));
775        }
776        if ordered != *row {
777            rows.rows_mut()[slot].clone_from(&ordered);
778            changed = true;
779        }
780    }
781    changed
782}
783
784#[derive(Debug)]
785pub struct Graph {
786    mode: CompatibilityMode,
787    revision: u64,
788    /// name <-> slot and the insertion order (yr2oc.1: removal tombstones).
789    node_order: NodeOrder,
790    /// slot -> node attribute map
791    node_attrs: Vec<AttrMap>,
792    /// slot -> neighbor slots
793    adj_indices: SlotRows,
794    /// br-r37-c1-d58s8: revision-keyed all-int weights memo (see
795    /// DiGraph::all_int_cache).
796    all_int_cache: std::sync::Arc<std::sync::RwLock<Option<(u64, String, bool)>>>,
797    /// Revision-keyed, row-order-faithful mapping from an adjacency position to
798    /// its `edges` storage slot. Weighted readers visit integer adjacency rows
799    /// already; carrying the slot reads attrs without re-hashing the `(u, v)`
800    /// pair. It is built lazily and each structural mutation advances
801    /// `revision`, making stale rows unusable without mutation-side repair.
802    edge_slots_by_neighbor: std::sync::RwLock<Option<(u64, Vec<Vec<usize>>)>>,
803    edge_index_endpoints: Vec<(usize, usize)>,
804    // Slot-canonical (min_slot, max_slot) node pair — slots are stable
805    // so surviving edges NEVER rekey on node removal.
806    edges: FxIndexMap<(usize, usize), AttrMap>,
807    runtime_policy: RuntimePolicy,
808}
809
810impl Clone for Graph {
811    fn clone(&self) -> Self {
812        Self {
813            mode: self.mode,
814            revision: self.revision,
815            node_order: self.node_order.clone(),
816            node_attrs: self.node_attrs.clone(),
817            adj_indices: self.adj_indices.clone(),
818            // Both caches are derived state. A clone must not share a cache:
819            // independently mutated copies can reach the same revision with
820            // different edge storage orders.
821            all_int_cache: std::sync::Arc::default(),
822            edge_slots_by_neighbor: std::sync::RwLock::default(),
823            edge_index_endpoints: self.edge_index_endpoints.clone(),
824            edges: self.edges.clone(),
825            runtime_policy: self.runtime_policy.clone(),
826        }
827    }
828}
829
830impl Graph {
831    #[must_use]
832    pub fn new(mode: CompatibilityMode) -> Self {
833        Self {
834            mode,
835            revision: 0,
836            node_order: NodeOrder::default(),
837            node_attrs: Vec::new(),
838            adj_indices: SlotRows::default(),
839            all_int_cache: std::sync::Arc::default(),
840            edge_slots_by_neighbor: std::sync::RwLock::default(),
841            edge_index_endpoints: Vec::new(),
842            edges: FxIndexMap::default(),
843            runtime_policy: RuntimePolicy::new(mode),
844        }
845    }
846
847    #[must_use]
848    pub fn with_runtime_policy(runtime_policy: RuntimePolicy) -> Self {
849        let mode = runtime_policy.mode();
850        Self {
851            mode,
852            revision: 0,
853            node_order: NodeOrder::default(),
854            node_attrs: Vec::new(),
855            adj_indices: SlotRows::default(),
856            all_int_cache: std::sync::Arc::default(),
857            edge_slots_by_neighbor: std::sync::RwLock::default(),
858            edge_index_endpoints: Vec::new(),
859            edges: FxIndexMap::default(),
860            runtime_policy,
861        }
862    }
863
864    /// br-r37-c1-clearedgesinplace (cc): drop all edges in place, keeping nodes (and
865    /// their index/order/attrs). O(E) teardown + O(V) row clears -- the binding's prior
866    /// path rebuilt a fresh Graph with a per-node add_node loop (ledger record_decision
867    /// tax + node-attr clone + old-inner drop), ~20x slower than nx. Sibling of
868    /// MultiGraph::clear_edges. adj_indices keeps its N rows (one per node), emptied;
869    /// the revision bump invalidates all_int_cache.
870    pub fn clear_edges(&mut self) {
871        if self.edges.is_empty() && self.edge_index_endpoints.is_empty() {
872            return;
873        }
874        self.edges.clear();
875        self.edge_index_endpoints.clear();
876        for row in self.adj_indices.rows_mut() {
877            row.clear();
878        }
879        self.revision = self.revision.saturating_add(1);
880    }
881
882    #[must_use]
883    pub fn strict() -> Self {
884        Self::new(CompatibilityMode::Strict)
885    }
886
887    #[must_use]
888    pub fn complete_graph(mode: CompatibilityMode, n: usize) -> Self {
889        let edge_capacity = n.saturating_mul(n.saturating_sub(1)) / 2;
890        let revision = u64::try_from(n)
891            .unwrap_or(u64::MAX)
892            .saturating_add(u64::try_from(edge_capacity).unwrap_or(u64::MAX));
893        let node_labels = (0..n).map(|node| node.to_string()).collect::<Vec<_>>();
894        let node_order = NodeOrder::from_names(node_labels.clone());
895        let node_attrs = vec![AttrMap::new(); n];
896        let mut graph = Self {
897            mode,
898            revision,
899            node_order,
900            node_attrs,
901            adj_indices: SlotRows::new(vec![Vec::with_capacity(n.saturating_sub(1)); n]),
902            all_int_cache: std::sync::Arc::default(),
903            edge_slots_by_neighbor: std::sync::RwLock::default(),
904            edge_index_endpoints: Vec::with_capacity(edge_capacity),
905            edges: FxIndexMap::with_capacity_and_hasher(edge_capacity, rustc_hash::FxBuildHasher),
906            runtime_policy: RuntimePolicy::new(mode),
907        };
908
909        let rows = graph.adj_indices.rows_mut();
910        for left_index in 0..n {
911            let _left = &node_labels[left_index];
912            for (right_index, _right) in node_labels.iter().enumerate().skip(left_index + 1) {
913                // Maintain integer adjacency
914                rows[left_index].push(right_index);
915                rows[right_index].push(left_index);
916                graph.edge_index_endpoints.push((left_index, right_index));
917                graph
918                    .edges
919                    .insert(Graph::canon_pair(left_index, right_index), AttrMap::new());
920            }
921        }
922
923        graph.record_decision(
924            "complete_graph_bulk",
925            0.0,
926            false,
927            vec![EvidenceTerm {
928                signal: "nodes".into(),
929                observed_value: count_evidence(n),
930                log_likelihood_ratio: 0.0,
931            }],
932        );
933        graph
934    }
935
936    /// Build a non-periodic 2-D grid with NetworkX's node and edge insertion
937    /// sequence. Canonical node keys are row-major all-int tuple strings like
938    /// "(i, j)".
939    #[must_use]
940    pub fn grid_2d(mode: CompatibilityMode, m: usize, n: usize) -> Self {
941        let node_count = m.saturating_mul(n);
942        let edge_capacity = m
943            .saturating_sub(1)
944            .saturating_mul(n)
945            .saturating_add(n.saturating_sub(1).saturating_mul(m));
946        let revision = u64::try_from(node_count)
947            .unwrap_or(u64::MAX)
948            .saturating_add(u64::try_from(edge_capacity).unwrap_or(u64::MAX));
949        let label = |i: usize, j: usize| format!("({i}, {j})");
950        let idx = |i: usize, j: usize| i * n + j;
951        let mut node_order = NodeOrder::default();
952        node_order.reserve(node_count);
953        for i in 0..m {
954            for j in 0..n {
955                node_order.push(label(i, j));
956            }
957        }
958        let mut graph = Self {
959            mode,
960            revision,
961            node_order,
962            node_attrs: vec![AttrMap::new(); node_count],
963            adj_indices: SlotRows::new(vec![Vec::with_capacity(4); node_count]),
964            all_int_cache: std::sync::Arc::default(),
965            edge_slots_by_neighbor: std::sync::RwLock::default(),
966            edge_index_endpoints: Vec::with_capacity(edge_capacity),
967            edges: FxIndexMap::with_capacity_and_hasher(edge_capacity, rustc_hash::FxBuildHasher),
968            runtime_policy: RuntimePolicy::new(mode),
969        };
970        fn add_grid_edge(graph: &mut Graph, _u: String, _v: String, u_idx: usize, v_idx: usize) {
971            // add_edge appends v to row[u] first, then u to row[v].
972            let rows = graph.adj_indices.rows_mut();
973            rows[u_idx].push(v_idx);
974            rows[v_idx].push(u_idx);
975            graph.edge_index_endpoints.push((u_idx, v_idx));
976            graph
977                .edges
978                .insert(Graph::canon_pair(u_idx, v_idx), AttrMap::new());
979        }
980        // Phase 1: for (pi, i) in pairwise(rows), for j in cols.
981        for i in 1..m {
982            for j in 0..n {
983                add_grid_edge(
984                    &mut graph,
985                    label(i, j),
986                    label(i - 1, j),
987                    idx(i, j),
988                    idx(i - 1, j),
989                );
990            }
991        }
992        // Phase 2: for i in rows, for (pj, j) in pairwise(cols).
993        for i in 0..m {
994            for j in 1..n {
995                add_grid_edge(
996                    &mut graph,
997                    label(i, j),
998                    label(i, j - 1),
999                    idx(i, j),
1000                    idx(i, j - 1),
1001                );
1002            }
1003        }
1004        graph.record_decision(
1005            "grid_2d_bulk",
1006            0.0,
1007            false,
1008            vec![EvidenceTerm {
1009                signal: "nodes".into(),
1010                observed_value: count_evidence(node_count),
1011                log_likelihood_ratio: 0.0,
1012            }],
1013        );
1014        graph
1015    }
1016
1017    /// Build NetworkX's n-dimensional integer grid graph in one bulk pass.
1018    ///
1019    /// NetworkX constructs this as repeated cartesian products of path/cycle
1020    /// graphs, then flattens the nested tuple labels. The product order matters:
1021    /// periodic axes use cycle edge-view order, not a simple coordinate loop.
1022    pub fn grid_nd(
1023        mode: CompatibilityMode,
1024        dimensions: &[usize],
1025        periodic: &[bool],
1026    ) -> Result<Self, GraphError> {
1027        if dimensions.len() != periodic.len() {
1028            return Err(GraphError::FailClosed {
1029                operation: "grid_nd",
1030                reason: format!(
1031                    "periodic flag count {} does not match dimension count {}",
1032                    periodic.len(),
1033                    dimensions.len()
1034                ),
1035            });
1036        }
1037        if dimensions.is_empty() {
1038            return Ok(Self::new(mode));
1039        }
1040
1041        #[derive(Clone)]
1042        struct ProductState {
1043            node_count: usize,
1044            adjacency: Vec<Vec<usize>>,
1045            edge_set: HashSet<(usize, usize)>,
1046        }
1047
1048        impl ProductState {
1049            fn empty() -> Self {
1050                Self {
1051                    node_count: 0,
1052                    adjacency: Vec::new(),
1053                    edge_set: HashSet::new(),
1054                }
1055            }
1056
1057            fn axis(size: usize, periodic: bool) -> Self {
1058                let mut state = Self {
1059                    node_count: size,
1060                    adjacency: vec![Vec::new(); size],
1061                    edge_set: HashSet::new(),
1062                };
1063                if size == 0 {
1064                    return state;
1065                }
1066                if periodic {
1067                    if size == 1 {
1068                        state.add_edge(0, 0);
1069                    } else {
1070                        for node in 0..(size - 1) {
1071                            state.add_edge(node, node + 1);
1072                        }
1073                        state.add_edge(size - 1, 0);
1074                    }
1075                } else {
1076                    for node in 0..size.saturating_sub(1) {
1077                        state.add_edge(node, node + 1);
1078                    }
1079                }
1080                state
1081            }
1082
1083            fn product(axis: &Self, old: &Self) -> Result<Self, GraphError> {
1084                let Some(node_count) = axis.node_count.checked_mul(old.node_count) else {
1085                    return Err(GraphError::FailClosed {
1086                        operation: "grid_nd",
1087                        reason: "node count overflow during cartesian product".to_owned(),
1088                    });
1089                };
1090                if node_count == 0 {
1091                    return Ok(Self::empty());
1092                }
1093
1094                let axis_edges = axis.edges_view();
1095                let old_edges = old.edges_view();
1096                let mut state = Self {
1097                    node_count,
1098                    adjacency: vec![Vec::new(); node_count],
1099                    edge_set: HashSet::with_capacity(
1100                        axis_edges
1101                            .len()
1102                            .saturating_mul(old.node_count)
1103                            .saturating_add(old_edges.len().saturating_mul(axis.node_count)),
1104                    ),
1105                };
1106
1107                for (axis_left, axis_right) in axis_edges {
1108                    let left_base = axis_left * old.node_count;
1109                    let right_base = axis_right * old.node_count;
1110                    for old_index in 0..old.node_count {
1111                        state.add_edge(left_base + old_index, right_base + old_index);
1112                    }
1113                }
1114                for axis_index in 0..axis.node_count {
1115                    let base = axis_index * old.node_count;
1116                    for &(old_left, old_right) in &old_edges {
1117                        state.add_edge(base + old_left, base + old_right);
1118                    }
1119                }
1120                Ok(state)
1121            }
1122
1123            fn add_edge(&mut self, left: usize, right: usize) {
1124                let key = if left <= right {
1125                    (left, right)
1126                } else {
1127                    (right, left)
1128                };
1129                if !self.edge_set.insert(key) {
1130                    return;
1131                }
1132                self.adjacency[left].push(right);
1133                if left != right {
1134                    self.adjacency[right].push(left);
1135                }
1136            }
1137
1138            fn edges_view(&self) -> Vec<(usize, usize)> {
1139                let mut edges = Vec::with_capacity(self.edge_set.len());
1140                let mut seen = vec![false; self.node_count];
1141                for left in 0..self.node_count {
1142                    for &right in &self.adjacency[left] {
1143                        if !seen[right] {
1144                            edges.push((left, right));
1145                        }
1146                    }
1147                    seen[left] = true;
1148                }
1149                edges
1150            }
1151        }
1152
1153        fn coords_from_index(mut index: usize, sizes: &[usize]) -> Vec<usize> {
1154            let mut coords = vec![0; sizes.len()];
1155            for axis in (0..sizes.len()).rev() {
1156                let size = sizes[axis];
1157                coords[axis] = index % size;
1158                index /= size;
1159            }
1160            coords
1161        }
1162
1163        fn tuple_canonical(coords: &[usize]) -> String {
1164            if let [value] = coords {
1165                return value.to_string();
1166            }
1167            let mut s = String::with_capacity(coords.len() * 6 + 2);
1168            s.push('(');
1169            for (i, value) in coords.iter().enumerate() {
1170                if i > 0 {
1171                    s.push_str(", ");
1172                }
1173                s.push_str(&value.to_string());
1174            }
1175            s.push(')');
1176            s
1177        }
1178
1179        let mut state = ProductState::axis(dimensions[0], periodic[0]);
1180        for (&size, &periodic_axis) in dimensions.iter().zip(periodic).skip(1) {
1181            let axis = ProductState::axis(size, periodic_axis);
1182            state = ProductState::product(&axis, &state)?;
1183        }
1184
1185        let output_sizes = dimensions.iter().rev().copied().collect::<Vec<_>>();
1186        let labels = (0..state.node_count)
1187            .map(|index| tuple_canonical(&coords_from_index(index, &output_sizes)))
1188            .collect::<Vec<_>>();
1189        let edge_view = state.edges_view();
1190        let mut final_adjacency = vec![Vec::new(); state.node_count];
1191        for &(left, right) in &edge_view {
1192            final_adjacency[left].push(right);
1193            if left != right {
1194                final_adjacency[right].push(left);
1195            }
1196        }
1197        let revision = u64::try_from(state.node_count)
1198            .unwrap_or(u64::MAX)
1199            .saturating_add(u64::try_from(edge_view.len()).unwrap_or(u64::MAX));
1200        let node_order = NodeOrder::from_names(labels);
1201
1202        let mut graph = Self {
1203            mode,
1204            revision,
1205            node_order,
1206            node_attrs: vec![AttrMap::new(); state.node_count],
1207            adj_indices: SlotRows::new(final_adjacency),
1208            all_int_cache: std::sync::Arc::default(),
1209            edge_slots_by_neighbor: std::sync::RwLock::default(),
1210            edge_index_endpoints: Vec::with_capacity(edge_view.len()),
1211            edges: FxIndexMap::with_capacity_and_hasher(edge_view.len(), rustc_hash::FxBuildHasher),
1212            runtime_policy: RuntimePolicy::new(mode),
1213        };
1214
1215        for (left, right) in edge_view {
1216            graph.edge_index_endpoints.push((left, right));
1217            graph
1218                .edges
1219                .insert(Graph::canon_pair(left, right), AttrMap::new());
1220        }
1221
1222        graph.record_decision(
1223            "grid_nd_bulk",
1224            0.0,
1225            false,
1226            vec![EvidenceTerm {
1227                signal: "nodes".into(),
1228                observed_value: count_evidence(state.node_count),
1229                log_likelihood_ratio: 0.0,
1230            }],
1231        );
1232        Ok(graph)
1233    }
1234
1235    /// br-r37-c1-z2eaa: Kneser graph K(n, k) replicating NetworkX's exact
1236    /// construction sequence. Nodes are k-subsets of 0..n as all-int-tuple
1237    /// canonicals "(a, b, ...)" (br-r37-c1-y7m24). nx builds via
1238    /// `add_edges_from((s, t) for s in subsets for t in
1239    /// combinations(universe - set(s), k))` — node order is edge-DISCOVERY
1240    /// order (u then v per new edge) and each unordered edge is offered
1241    /// twice (the second add is a structural no-op). The complement
1242    /// `universe - set(s)` iterates ASCENDING whenever every value fits its
1243    /// exact CPython set slot — the Python wrapper gates this kernel on
1244    /// that condition, so lexicographic combinations over the sorted
1245    /// complement reproduce nx byte-for-byte. For `2k > n` nodes are
1246    /// pre-added in subset order and no edges exist.
1247    #[must_use]
1248    pub fn kneser(mode: CompatibilityMode, n: usize, k: usize) -> Self {
1249        fn tuple_canonical(c: &[usize]) -> String {
1250            let mut s = String::with_capacity(c.len() * 6 + 2);
1251            s.push('(');
1252            for (i, v) in c.iter().enumerate() {
1253                if i > 0 {
1254                    s.push_str(", ");
1255                }
1256                s.push_str(&v.to_string());
1257            }
1258            if c.len() == 1 {
1259                s.push(',');
1260            }
1261            s.push(')');
1262            s
1263        }
1264        fn lex_combinations(m: usize, k: usize) -> Vec<Vec<usize>> {
1265            let mut out = Vec::new();
1266            if k > m {
1267                return out;
1268            }
1269            let mut cur: Vec<usize> = (0..k).collect();
1270            loop {
1271                out.push(cur.clone());
1272                let mut advanced = false;
1273                let mut i = k;
1274                while i > 0 {
1275                    i -= 1;
1276                    if cur[i] != i + m - k {
1277                        cur[i] += 1;
1278                        for j in (i + 1)..k {
1279                            cur[j] = cur[j - 1] + 1;
1280                        }
1281                        advanced = true;
1282                        break;
1283                    }
1284                }
1285                if !advanced {
1286                    break;
1287                }
1288            }
1289            out
1290        }
1291        let subsets = lex_combinations(n, k);
1292        let canonicals: Vec<String> = subsets.iter().map(|c| tuple_canonical(c)).collect();
1293        let total = subsets.len();
1294        let mut node_order = NodeOrder::default();
1295        node_order.reserve(total);
1296        let mut node_attrs = Vec::with_capacity(total);
1297        let mut adj_indices = Vec::new();
1298        if 2 * k > n {
1299            // No disjoint pairs exist — nx pre-adds all subsets as nodes.
1300            return Self {
1301                mode,
1302                revision: u64::try_from(total).unwrap_or(u64::MAX),
1303                node_order: NodeOrder::from_names(canonicals),
1304                node_attrs: vec![AttrMap::new(); total],
1305                adj_indices: SlotRows::new(vec![Vec::new(); total]),
1306                all_int_cache: std::sync::Arc::default(),
1307                edge_slots_by_neighbor: std::sync::RwLock::default(),
1308                edge_index_endpoints: Vec::new(),
1309                edges: FxIndexMap::default(),
1310                runtime_policy: RuntimePolicy::new(mode),
1311            };
1312        }
1313        // Binomial table for lexicographic combination ranking.
1314        let mut binom = vec![vec![0usize; k + 1]; n + 1];
1315        for i in 0..=n {
1316            binom[i][0] = 1;
1317            for j in 1..=k.min(i) {
1318                binom[i][j] = if i == j {
1319                    1
1320                } else {
1321                    binom[i - 1][j - 1].saturating_add(binom[i - 1][j])
1322                };
1323            }
1324        }
1325        let lex_rank = |c: &[usize]| -> usize {
1326            let mut r = 0usize;
1327            let mut prev = 0usize;
1328            for (i, &ci) in c.iter().enumerate() {
1329                for j in prev..ci {
1330                    r = r.saturating_add(binom[n - 1 - j][k - 1 - i]);
1331                }
1332                prev = ci + 1;
1333            }
1334            r
1335        };
1336        let mut seen = HashSet::<(usize, usize)>::with_capacity(total * 2);
1337        // node first-touch insertion mirroring nx add_edge (u then v)
1338        let mut node_idx: Vec<usize> = vec![usize::MAX; total];
1339        let mut in_s = vec![false; n];
1340        let m = n - k;
1341        let picks = lex_combinations(m, k);
1342        let mut edge_index_endpoints = Vec::new();
1343        let mut edges = FxIndexMap::default();
1344        for (s_idx, s) in subsets.iter().enumerate() {
1345            for &v in s {
1346                in_s[v] = true;
1347            }
1348            let complement: Vec<usize> = (0..n).filter(|&v| !in_s[v]).collect();
1349            for pick in &picks {
1350                let t: Vec<usize> = pick.iter().map(|&i| complement[i]).collect();
1351                let t_idx = lex_rank(&t);
1352                let pair = (s_idx.min(t_idx), s_idx.max(t_idx));
1353                if seen.insert(pair) {
1354                    for idx in [s_idx, t_idx] {
1355                        if node_idx[idx] == usize::MAX {
1356                            let slot = node_order.push(canonicals[idx].clone());
1357                            node_attrs.push(AttrMap::new());
1358                            node_idx[idx] = slot;
1359                            adj_indices.push(Vec::new());
1360                        }
1361                    }
1362                    // br-r37-c1-kneserpush (cc): we are inside `if seen.insert(pair)` — a FIRST
1363                    // occurrence of this (s_idx,t_idx) edge — so it is provably not yet in either
1364                    // adjacency row. The old `!adj_indices[..].contains(..)` guards were O(degree)
1365                    // linear rescans always returning true here (redundant); push directly.
1366                    // Byte-identical (Kneser edges have no parallel edges; self-loop s==t handled
1367                    // by the != guard, exactly as before).
1368                    adj_indices[node_idx[s_idx]].push(node_idx[t_idx]);
1369                    if node_idx[s_idx] != node_idx[t_idx] {
1370                        adj_indices[node_idx[t_idx]].push(node_idx[s_idx]);
1371                    }
1372                    edge_index_endpoints.push((node_idx[s_idx], node_idx[t_idx]));
1373                    edges.insert(
1374                        Graph::canon_pair(node_idx[s_idx], node_idx[t_idx]),
1375                        AttrMap::new(),
1376                    );
1377                }
1378            }
1379            for &v in s {
1380                in_s[v] = false;
1381            }
1382        }
1383        let revision = u64::try_from(node_order.len() + edges.len()).unwrap_or(u64::MAX);
1384        Self {
1385            mode,
1386            revision,
1387            node_order,
1388            node_attrs,
1389            adj_indices: SlotRows::new(adj_indices),
1390            all_int_cache: std::sync::Arc::default(),
1391            edge_slots_by_neighbor: std::sync::RwLock::default(),
1392            edge_index_endpoints,
1393            edges,
1394            runtime_policy: RuntimePolicy::new(mode),
1395        }
1396    }
1397
1398    #[must_use]
1399    pub fn hardened() -> Self {
1400        Self::new(CompatibilityMode::Hardened)
1401    }
1402
1403    #[must_use]
1404    pub fn mode(&self) -> CompatibilityMode {
1405        self.mode
1406    }
1407
1408    #[must_use]
1409    pub fn node_count(&self) -> usize {
1410        self.node_order.len()
1411    }
1412
1413    #[must_use]
1414    pub fn edge_count(&self) -> usize {
1415        self.edges.len()
1416    }
1417
1418    #[must_use]
1419    pub fn revision(&self) -> u64 {
1420        self.revision
1421    }
1422
1423    #[must_use]
1424    pub fn has_node(&self, node: &str) -> bool {
1425        self.node_order.contains_key(node)
1426    }
1427
1428    /// br-r37-c1-d58s8 edges-map flip: resolve a String pair to the
1429    /// index-canonical (min, max) key. None if either node is absent.
1430    #[inline]
1431    fn edge_pair_key(&self, left: &str, right: &str) -> Option<(usize, usize)> {
1432        let &l = self.node_order.get(left)?;
1433        let &r = self.node_order.get(right)?;
1434        Some(if l <= r { (l, r) } else { (r, l) })
1435    }
1436
1437    #[inline]
1438    fn canon_pair(l: usize, r: usize) -> (usize, usize) {
1439        if l <= r { (l, r) } else { (r, l) }
1440    }
1441
1442    /// br-r37-c1-indsub: build the induced subgraph over `order` (parent node
1443    /// indices, already in the caller's output order) without hashing a single
1444    /// node name.
1445    ///
1446    /// The per-edge `add_edge_with_attrs` path costs two `String` allocations,
1447    /// four name hashes and a `record_decision` ledger entry (which itself
1448    /// allocates two Strings and a Vec) — ~2.5us/edge, so materializing the
1449    /// 158k-edge k=10 core of ca-AstroPh took 393ms. Everything that loop
1450    /// re-derives is already known here: the surviving rows come straight out
1451    /// of `adj_indices`, and the new index of a kept node is its slot in
1452    /// `order`. All the invariants `add_edge_with_attrs` maintains are
1453    /// reproduced directly:
1454    ///
1455    ///  * `edges` is keyed by the INDEX-canonical pair of the NEW indices;
1456    ///  * `edge_index_endpoints` keeps the STRING-canonical orientation
1457    ///    (`left <= right` on names), matching the per-edge path;
1458    ///  * a row records its neighbor when the pair is first emitted, so
1459    ///    adjacency order is the walk order — which is exactly what nx's
1460    ///    `add_edges_from` over the induced walk produces;
1461    ///  * a self-loop appears once in its row.
1462    ///
1463    /// A pair is emitted from whichever endpoint comes FIRST in `order`, so
1464    /// each undirected edge is visited once. `order` must contain distinct,
1465    /// in-range parent indices.
1466    #[must_use]
1467    pub fn induced_subgraph_ordered(&self, order: &[usize], runtime_policy: RuntimePolicy) -> Self {
1468        let mut parent_slot_to_subgraph_slot = vec![usize::MAX; self.node_order.slot_count()];
1469        for (slot, &parent_idx) in order.iter().enumerate() {
1470            let (_, parent_slot) = self
1471                .node_order
1472                .get_index(parent_idx)
1473                .expect("induced subgraph order must hold valid parent node indices");
1474            parent_slot_to_subgraph_slot[parent_slot] = slot;
1475        }
1476
1477        let mut node_order = NodeOrder::default();
1478        node_order.reserve(order.len());
1479        let mut node_attrs = Vec::with_capacity(order.len());
1480        for &parent_idx in order {
1481            let (name, parent_slot) = self
1482                .node_order
1483                .get_index(parent_idx)
1484                .expect("induced subgraph order must hold valid parent node indices");
1485            node_order.push(name.clone());
1486            node_attrs.push(self.node_attrs[parent_slot].clone());
1487        }
1488
1489        let mut adj_indices = vec![Vec::new(); order.len()];
1490        let mut edges = FxIndexMap::default();
1491        let mut edge_index_endpoints = Vec::new();
1492        for (slot, &parent_idx) in order.iter().enumerate() {
1493            let parent_slot = self.node_order.get_index(parent_idx).unwrap().1;
1494            for &parent_neighbor_slot in &self.adj_indices[parent_slot] {
1495                let other = parent_slot_to_subgraph_slot[parent_neighbor_slot];
1496                if other == usize::MAX || other < slot {
1497                    continue;
1498                }
1499                let attrs = self
1500                    .edges
1501                    .get(&Self::canon_pair(parent_slot, parent_neighbor_slot))
1502                    .cloned()
1503                    .unwrap_or_default();
1504                edges.insert(Self::canon_pair(slot, other), attrs);
1505                let left_name = node_order.get_index(slot).expect("slot inserted above").0;
1506                let right_name = node_order.get_index(other).expect("slot inserted above").0;
1507                if left_name <= right_name {
1508                    edge_index_endpoints.push((slot, other));
1509                } else {
1510                    edge_index_endpoints.push((other, slot));
1511                }
1512                adj_indices[slot].push(other);
1513                if slot != other {
1514                    adj_indices[other].push(slot);
1515                }
1516            }
1517        }
1518
1519        Self {
1520            mode: runtime_policy.mode(),
1521            revision: 0,
1522            node_order,
1523            node_attrs,
1524            adj_indices: SlotRows::new(adj_indices),
1525            all_int_cache: std::sync::Arc::default(),
1526            edge_slots_by_neighbor: std::sync::RwLock::default(),
1527            edge_index_endpoints,
1528            edges,
1529            runtime_policy,
1530        }
1531    }
1532
1533    #[must_use]
1534    pub fn has_edge(&self, left: &str, right: &str) -> bool {
1535        self.edge_pair_key(left, right)
1536            .is_some_and(|k| self.edges.contains_key(&k))
1537    }
1538
1539    /// cc-hasedgeintidx: true iff the node stored at `idx` IS the integer `idx`
1540    /// (its canonical string parses back to `idx`). Lets a caller VERIFY, per call
1541    /// and with no allocation, that an exact-int node lands at its own index before
1542    /// probing an edge by index — so any removal / re-add / remap that shifted
1543    /// indices simply returns false and the caller falls back to the string path.
1544    /// O(1) index access + a no-alloc `str::parse`.
1545    #[must_use]
1546    pub fn node_index_matches_int(&self, idx: usize) -> bool {
1547        self.node_order
1548            .get_index(idx)
1549            .is_some_and(|(k, _)| k.parse::<usize>() == Ok(idx))
1550    }
1551
1552    /// sfq4w.3: every node's name is the decimal of its position, so an int
1553    /// key `k` IS the node at position `k` when `k < node_count()` and is
1554    /// absent otherwise. O(V) on first use, kept current in O(1) after.
1555    #[must_use]
1556    pub fn node_names_are_positions(&self) -> bool {
1557        self.node_order.names_are_positions()
1558    }
1559
1560    /// cc-hasedgeintidx: undirected edge existence by node INDEX (canonical
1561    /// min/max pair). Caller must have validated both indices (e.g. via
1562    /// `node_index_matches_int`).
1563    #[must_use]
1564    pub fn has_edge_by_indices(&self, l: usize, r: usize) -> bool {
1565        let Some(ls) = self.node_order.slot_at(l) else {
1566            return false;
1567        };
1568        let Some(rs) = self.node_order.slot_at(r) else {
1569            return false;
1570        };
1571        self.edges.contains_key(&Self::canon_pair(ls, rs))
1572    }
1573
1574    #[must_use]
1575    pub fn nodes_ordered(&self) -> Vec<&str> {
1576        self.node_order.keys().map(String::as_str).collect()
1577    }
1578
1579    #[must_use]
1580    pub fn get_node_index(&self, node: &str) -> Option<usize> {
1581        self.node_order.get_index_of(node)
1582    }
1583
1584    #[must_use]
1585    pub fn get_node_name(&self, index: usize) -> Option<&str> {
1586        self.node_order.get_index(index).map(|(k, _)| k.as_str())
1587    }
1588
1589    #[must_use]
1590    pub fn neighbors(&self, node: &str) -> Option<Vec<&str>> {
1591        // br-r37-c1-d58s8 P2(a): serve from the integer rows + node-name
1592        // table — adj_indices is order-faithful through every mutator
1593        // (extends push in row order; remove_node repairs in place;
1594        // apply_row_orders and reorder_rows_for_nx_copy_walk both
1595        // resync). Migrating readers off the String rows is the
1596        // prerequisite for dropping their eager maintenance (P2(c)).
1597        let &slot = self.node_order.get(node)?;
1598        Some(
1599            self.adj_indices[slot]
1600                .iter()
1601                .map(|&nbr_slot| {
1602                    self.node_order
1603                        .name(nbr_slot)
1604                        .expect("adj_indices entries are valid node slots")
1605                })
1606                .collect::<Vec<&str>>(),
1607        )
1608    }
1609
1610    #[must_use]
1611    pub fn neighbors_iter(&self, node: &str) -> Option<impl Iterator<Item = &str> + '_> {
1612        // br-r37-c1-d58s8 P2(a): index-backed (see neighbors()).
1613        let &slot = self.node_order.get(node)?;
1614        Some(self.adj_indices[slot].iter().map(move |&nbr_slot| {
1615            self.node_order
1616                .name(nbr_slot)
1617                .expect("adj_indices entries are valid node slots")
1618        }))
1619    }
1620
1621    /// yr2oc.4: the attribute maps of `node`'s incident edges in its
1622    /// neighbour-row order, each flagged when it is the self-loop. Read by
1623    /// slot, so it needs no position translation after a removal.
1624    #[must_use]
1625    pub fn incident_edge_attrs(
1626        &self,
1627        node: &str,
1628    ) -> Option<impl Iterator<Item = (bool, &AttrMap)> + '_> {
1629        let &slot = self.node_order.get(node)?;
1630        Some(self.adj_indices[slot].iter().map(move |&neighbour| {
1631            let attrs = self
1632                .edges
1633                .get(&Self::canon_pair(slot, neighbour))
1634                .expect("adjacency entries have matching edge storage");
1635            (neighbour == slot, attrs)
1636        }))
1637    }
1638
1639    /// Return neighbor indices for O(1) traversal. Avoids string hashing
1640    /// during BFS/DFS. Returns None if node index is out of bounds.
1641    #[must_use]
1642    #[inline]
1643    pub fn neighbors_indices(&self, node_idx: usize) -> Option<&[usize]> {
1644        if self.node_order.is_dense() {
1645            self.adj_indices.get(node_idx).map(Vec::as_slice)
1646        } else {
1647            self.adj_indices
1648                .positions(&self.node_order)
1649                .get(node_idx)
1650                .map(Vec::as_slice)
1651        }
1652    }
1653
1654    /// br-r37-c1-7dpyg: structural clone with a FRESH RuntimePolicy —
1655    /// `Clone` deep-copies the unbounded decision ledger (copy() measured
1656    /// 2.3-2.8x slower on per-edge-ctor sources with identical structure);
1657    /// result graphs carry the MODE, not the history.
1658    #[must_use]
1659    pub fn clone_with_fresh_policy(&self) -> Self {
1660        Self {
1661            mode: self.mode,
1662            revision: self.revision,
1663            node_order: self.node_order.clone(),
1664            node_attrs: self.node_attrs.clone(),
1665            adj_indices: self.adj_indices.clone(),
1666            all_int_cache: std::sync::Arc::default(),
1667            edge_slots_by_neighbor: std::sync::RwLock::default(),
1668            edge_index_endpoints: self.edge_index_endpoints.clone(),
1669            edges: self.edges.clone(),
1670            runtime_policy: RuntimePolicy::new(self.mode),
1671        }
1672    }
1673
1674    /// br-r37-c1-u3qyn: restore explicit adjacency row orders (pickle
1675    /// round-trip). Each entry is (node, neighbor order); rows are
1676    /// rebuilt in the given order with any unlisted survivors appended
1677    /// in their current order (robust to stale state). The integer
1678    /// `adj_indices` mirror is rebuilt to match.
1679    pub fn apply_row_orders(&mut self, orders: &[(String, Vec<String>)]) {
1680        // br-r37-c1-d58s8 P2(c) slice 2: reorder the integer rows directly.
1681        let mut changed = false;
1682        for (node, order) in orders {
1683            let Some(&slot) = self.node_order.get(node.as_str()) else {
1684                continue;
1685            };
1686            let row = &self.adj_indices[slot];
1687            let row_set: std::collections::HashSet<usize> = row.iter().copied().collect();
1688            let mut new_row: Vec<usize> = Vec::with_capacity(row.len());
1689            let mut placed: std::collections::HashSet<usize> = std::collections::HashSet::new();
1690            for v in order {
1691                if let Some(&v_slot) = self.node_order.get(v.as_str())
1692                    && row_set.contains(&v_slot)
1693                    && placed.insert(v_slot)
1694                {
1695                    new_row.push(v_slot);
1696                }
1697            }
1698            for &v_slot in row {
1699                if placed.insert(v_slot) {
1700                    new_row.push(v_slot);
1701                }
1702            }
1703            if self.adj_indices[slot] != new_row {
1704                self.adj_indices.rows_mut()[slot] = new_row;
1705                changed = true;
1706            }
1707        }
1708        if changed {
1709            self.revision = self.revision.saturating_add(1);
1710        }
1711    }
1712
1713    /// br-r37-c1-0ek49: reorder every adjacency row into NetworkX's
1714    /// `Graph.copy()` walk order. nx copy rebuilds via
1715    /// `add_edges_from((u, v, d) for u in _adj for v in _adj[u])`, so an
1716    /// unordered pair enters BOTH endpoint rows at its first touch —
1717    /// during the earlier endpoint's row scan. Row u therefore lists
1718    /// neighbors at a smaller node position first (ordered by that
1719    /// neighbor's scan time: `(pos(v), index of u within row v)`),
1720    /// followed by its remaining neighbors — self-loops included — in
1721    /// row u's original order. Call on a fresh clone inside copy-shaped
1722    /// constructors; graph content is unchanged, only row order moves.
1723    pub fn reorder_rows_for_nx_copy_walk(&mut self) {
1724        // br-r37-c1-predrebuild: rebuild rows in nx's copy-walk order in O(E),
1725        // replacing the per-early-neighbor linear search
1726        // `adj_indices[v].position(|w| w == pu)` (O(E * degree) — quadratic on
1727        // dense graphs; it erodes dense Graph.copy() wins 9.5x->5.9x as density
1728        // climbs). Early neighbors of pu (v < pu) sort by
1729        // (pos(v), index-of-pu-within-adj[v]); walking adj in (v, adj-index)
1730        // order and appending v to early[pu] when pu > v yields exactly that
1731        // order. Late neighbors (v >= pu) keep adj[pu]'s row order. Byte-
1732        // identical row order — no search, no sort.
1733        //
1734        // The walk compares slots as node positions, so slots must be positions.
1735        self.ensure_compact();
1736        let n = self.adj_indices.len();
1737        let mut early: Vec<Vec<usize>> = vec![Vec::new(); n];
1738        for (v, row) in self.adj_indices.iter().enumerate() {
1739            for &pu in row {
1740                if pu > v {
1741                    early[pu].push(v);
1742                }
1743            }
1744        }
1745        let mut new_rows: Vec<Vec<usize>> = Vec::with_capacity(n);
1746        for (pu, row) in self.adj_indices.iter().enumerate() {
1747            let mut new_row = std::mem::take(&mut early[pu]);
1748            new_row.reserve(row.len());
1749            for &v in row {
1750                if v >= pu {
1751                    new_row.push(v);
1752                }
1753            }
1754            new_rows.push(new_row);
1755        }
1756        if *self.adj_indices != new_rows {
1757            *self.adj_indices.rows_mut() = new_rows;
1758            self.revision = self.revision.saturating_add(1);
1759        }
1760    }
1761
1762    /// br-r37-c1-36v6r: order each adjacency row as `source`'s row for the
1763    /// node of the same name. A graph built from a filtered view of `source`
1764    /// by a copy-shaped walk (each row filled in edge-walk order, as
1765    /// networkx's `view.copy()` does) holds the view's rows as sets; the
1766    /// view's ORDER is `source`'s row with entries filtered out, which this
1767    /// restores. Entries `source`'s row lacks (a node or edge `source` does
1768    /// not have) keep their relative order after the matched ones. Graph
1769    /// content is unchanged; `revision` moves if any row does.
1770    pub fn reorder_rows_like(&mut self, source: &Self) {
1771        let mut to_source = vec![usize::MAX; self.node_order.slot_count()];
1772        let mut from_source = vec![usize::MAX; source.node_order.slot_count()];
1773        for (name, slot) in self.node_order.iter() {
1774            if let Some(&source_slot) = source.node_order.get(name) {
1775                to_source[slot] = source_slot;
1776                from_source[source_slot] = slot;
1777            }
1778        }
1779        let slots: Vec<usize> = self.node_order.values().collect();
1780        let changed = reorder_slot_rows_like(
1781            &mut self.adj_indices,
1782            &source.adj_indices,
1783            &slots,
1784            &to_source,
1785            &from_source,
1786        );
1787        if changed {
1788            self.revision = self.revision.saturating_add(1);
1789        }
1790    }
1791
1792    /// br-r37-c1-36v6r: order every adjacency row as given - `rows[i]` is
1793    /// the row of the node at position `i`, neighbours as positions - for a
1794    /// row order no graph holds, such as a `to_undirected(as_view=True)`
1795    /// view's set-ordered union rows. All or nothing: false, and nothing
1796    /// changes, unless every given row is a permutation of the current one.
1797    pub fn set_row_orders(&mut self, rows: &[Vec<usize>]) -> bool {
1798        let slots: Vec<usize> = self.node_order.values().collect();
1799        match set_slot_row_orders(&mut self.adj_indices, &slots, rows) {
1800            None => false,
1801            Some(changed) => {
1802                if changed {
1803                    self.revision = self.revision.saturating_add(1);
1804                }
1805                true
1806            }
1807        }
1808    }
1809
1810    /// Every edge in storage order, its endpoints as node positions.
1811    pub fn edges_storage_order_index_iter(
1812        &self,
1813    ) -> impl Iterator<Item = (usize, usize, &AttrMap)> + '_ {
1814        let slot_positions = self.node_order.slot_positions();
1815        self.edge_index_endpoints
1816            .iter()
1817            .copied()
1818            .zip(self.edges.values())
1819            .map(move |((left, right), attrs)| match &slot_positions {
1820                Some(positions) => (positions[left], positions[right], attrs),
1821                None => (left, right, attrs),
1822            })
1823    }
1824
1825    #[must_use]
1826    pub fn neighbor_count(&self, node: &str) -> usize {
1827        // br-r37-c1-d58s8 P2(a): index-backed.
1828        self.node_order
1829            .get(node)
1830            .map_or(0, |&slot| self.adj_indices[slot].len())
1831    }
1832
1833    #[must_use]
1834    pub fn neighbor_count_by_index(&self, idx: usize) -> usize {
1835        self.node_order
1836            .get_index(idx)
1837            .map_or(0, |(_, slot)| self.adj_indices[slot].len())
1838    }
1839
1840    /// Return the degree of a node.
1841    /// Self-loops contribute 2 to the degree (NetworkX convention).
1842    #[must_use]
1843    pub fn degree(&self, node: &str) -> usize {
1844        let count = self.neighbor_count(node);
1845        // If node has a self-loop, add 1 extra (self-loop contributes 2 total)
1846        if self.has_edge(node, node) {
1847            count + 1
1848        } else {
1849            count
1850        }
1851    }
1852
1853    /// br-r37-c1-degidx: degree by node INDEX — O(1), zero String
1854    /// hashing (the &str path costs 2 hashes/node: neighbor_count's
1855    /// get_index_of + the self-loop has_edge). Used by the DegreeView
1856    /// iterator which already walks nodes in index order.
1857    #[must_use]
1858    pub fn degree_by_index(&self, idx: usize) -> usize {
1859        let mut count = self.neighbor_count_by_index(idx);
1860        if self.has_edge_by_indices(idx, idx) {
1861            count += 1; // self-loop contributes 2 to total degree
1862        }
1863        count
1864    }
1865
1866    #[must_use]
1867    pub fn node_attrs(&self, node: &str) -> Option<&AttrMap> {
1868        let &slot = self.node_order.get(node)?;
1869        Some(&self.node_attrs[slot])
1870    }
1871
1872    #[must_use]
1873    #[inline]
1874    pub fn node_attrs_by_index(&self, idx: usize) -> Option<&AttrMap> {
1875        let slot = self.node_order.slot_at(idx)?;
1876        Some(&self.node_attrs[slot])
1877    }
1878
1879    #[must_use]
1880    pub fn edge_attrs(&self, left: &str, right: &str) -> Option<&AttrMap> {
1881        self.edges.get(&self.edge_pair_key(left, right)?)
1882    }
1883
1884    /// br-r37-c1-d58s8: index-keyed attr access — a DIRECT map get for
1885    /// callers that already hold node indices (kernels, CSR weight
1886    /// builders, compose walks). Zero node-map probes.
1887    #[must_use]
1888    #[inline]
1889    pub fn edge_attrs_by_indices(&self, left_idx: usize, right_idx: usize) -> Option<&AttrMap> {
1890        let l_slot = self.node_order.slot_at(left_idx)?;
1891        let r_slot = self.node_order.slot_at(right_idx)?;
1892        self.edges.get(&Self::canon_pair(l_slot, r_slot))
1893    }
1894
1895    /// Read an edge's attributes by a storage slot supplied by the
1896    /// row-order-preserving edge-slot cache.
1897    #[must_use]
1898    #[inline]
1899    pub fn edge_attrs_by_slot(&self, edge_slot: usize) -> Option<&AttrMap> {
1900        self.edges.get_index(edge_slot).map(|(_, attrs)| attrs)
1901    }
1902
1903    /// Give a reader the edge-storage slot for every entry in every adjacency
1904    /// row, rows by node position and entries in `neighbors_indices` order.
1905    /// The first reader after a structural revision resolves pairs once;
1906    /// steady-state readers avoid pair hashing entirely.
1907    pub fn with_edge_slots_by_neighbor<T>(&self, f: impl FnOnce(&[Vec<usize>]) -> T) -> T {
1908        if let Ok(guard) = self.edge_slots_by_neighbor.read()
1909            && let Some((revision, rows)) = guard.as_ref()
1910            && *revision == self.revision
1911        {
1912            return f(rows);
1913        }
1914
1915        let mut guard = self
1916            .edge_slots_by_neighbor
1917            .write()
1918            .expect("edge slot cache lock poisoned");
1919        if !matches!(guard.as_ref(), Some((revision, _)) if *revision == self.revision) {
1920            let mut rows = Vec::with_capacity(self.node_order.len());
1921            for node in self.node_order.values() {
1922                let neighbors = &self.adj_indices[node];
1923                let mut slots = Vec::with_capacity(neighbors.len());
1924                for &neighbor in neighbors {
1925                    let slot = self
1926                        .edges
1927                        .get_index_of(&Self::canon_pair(node, neighbor))
1928                        .expect("adjacency entries have matching edge storage");
1929                    slots.push(slot);
1930                }
1931                rows.push(slots);
1932            }
1933            *guard = Some((self.revision, rows));
1934        }
1935        let (_, rows) = guard
1936            .as_ref()
1937            .expect("edge slot cache is initialized before use");
1938        f(rows)
1939    }
1940
1941    /// br-r37-c1-hasattrlazyfix: does ANY edge carry the attribute `key` in the
1942    /// authoritative Rust storage? Unlike the `edge_py_attrs` Python mirror (which is
1943    /// LAZILY materialized — a batch-built graph leaves it empty), this scans the inner
1944    /// `edges` AttrMaps directly (no node-name resolution, no allocation), so a
1945    /// freshly-constructed weighted graph reports True. ~O(E) BTreeMap key checks.
1946    #[must_use]
1947    pub fn any_edge_has_attr(&self, key: &str) -> bool {
1948        self.edges.values().any(|attrs| attrs.contains_key(key))
1949    }
1950
1951    /// True if EVERY inner edge attr value is a faithful scalar (Int/Float/Bool)
1952    /// — a String/Map value is a coerced non-scalar (repr/JSON) or genuine str,
1953    /// ambiguous for lossless cloning. Direct no-alloc scan over the edge store
1954    /// values (contrast `edges_ordered_borrowed`, which builds an O(E) dedup
1955    /// HashMap). Used by the compose store-read gate.
1956    #[must_use]
1957    pub fn all_edge_attr_values_scalar(&self) -> bool {
1958        self.edges.values().all(|attrs| {
1959            attrs.values().all(|v| {
1960                matches!(
1961                    v,
1962                    fnx_runtime::CgseValue::Int(_)
1963                        | fnx_runtime::CgseValue::Float(_)
1964                        | fnx_runtime::CgseValue::Bool(_)
1965                )
1966            })
1967        })
1968    }
1969
1970    /// br-r37-c1-hasanyattrlazyfix: does ANY node carry a Python-visible attr, per the
1971    /// authoritative inner storage (not the lazy `node_py_attrs` mirror)?
1972    #[must_use]
1973    pub fn any_node_has_attrs(&self) -> bool {
1974        self.node_order
1975            .values()
1976            .any(|slot| !self.node_attrs[slot].is_empty())
1977    }
1978
1979    /// br-r37-c1-hasanyattrlazyfix: does ANY edge carry a Python-visible attr, per the
1980    /// authoritative inner storage (not the lazy `edge_py_attrs` mirror)?
1981    #[must_use]
1982    pub fn any_edge_has_attrs(&self) -> bool {
1983        self.edges.values().any(|attrs| !attrs.is_empty())
1984    }
1985
1986    #[must_use]
1987    pub fn nodes_are_contiguous_int_prefix(&self) -> bool {
1988        self.node_order
1989            .keys()
1990            .enumerate()
1991            .all(|(index, key)| Self::decimal_key_matches_index(key, index))
1992    }
1993
1994    fn decimal_key_matches_index(key: &str, index: usize) -> bool {
1995        let bytes = key.as_bytes();
1996        if bytes.is_empty() || (bytes.len() > 1 && bytes[0] == b'0') {
1997            return false;
1998        }
1999
2000        let mut parsed = 0usize;
2001        for &byte in bytes {
2002            if !byte.is_ascii_digit() {
2003                return false;
2004            }
2005            let digit = usize::from(byte - b'0');
2006            let Some(next) = parsed
2007                .checked_mul(10)
2008                .and_then(|value| value.checked_add(digit))
2009            else {
2010                return false;
2011            };
2012            parsed = next;
2013        }
2014        parsed == index
2015    }
2016
2017    #[must_use]
2018    pub fn evidence_ledger(&self) -> &EvidenceLedger {
2019        self.runtime_policy.decision_log()
2020    }
2021
2022    pub fn drain_decision_records(&mut self) -> Vec<DecisionRecord> {
2023        self.runtime_policy.drain_decision_records()
2024    }
2025
2026    /// Record a bounded recovery performed OUTSIDE the graph (e.g. a reader
2027    /// that skipped a malformed input line in hardened mode) in this graph's
2028    /// evidence ledger, so `decision_records()` reports it like a native one.
2029    pub fn record_external_recovery(
2030        &mut self,
2031        operation: impl Into<std::borrow::Cow<'static, str>>,
2032        rationale: impl Into<std::borrow::Cow<'static, str>>,
2033    ) {
2034        self.runtime_policy.record(
2035            operation,
2036            DecisionAction::FullValidate,
2037            0.7,
2038            rationale,
2039            Vec::new(),
2040        );
2041    }
2042
2043    #[must_use]
2044    pub fn runtime_policy(&self) -> &RuntimePolicy {
2045        &self.runtime_policy
2046    }
2047
2048    pub fn set_runtime_policy(&mut self, runtime_policy: RuntimePolicy) {
2049        self.mode = runtime_policy.mode();
2050        self.runtime_policy = runtime_policy;
2051    }
2052
2053    /// Type identity: always `false` for undirected Graph.
2054    #[must_use]
2055    pub fn is_directed(&self) -> bool {
2056        false
2057    }
2058
2059    /// Type identity: always `false` for Graph (not a multigraph).
2060    #[must_use]
2061    pub fn is_multigraph(&self) -> bool {
2062        false
2063    }
2064
2065    pub fn add_node(&mut self, node: impl AsRef<str> + Into<String>) -> bool {
2066        self.add_node_with_attrs(node, AttrMap::new())
2067    }
2068
2069    /// br-r37-c1-wa1b9: every storage effect of [`Self::add_node_with_attrs`]
2070    /// and none of its ledger record. Returns `(changed, existed, attrs_count,
2071    /// inserted_index)` so the caller can file the record itself if it is a
2072    /// user-visible `add_node`. `inserted_index` is present only for a newly
2073    /// inserted node, allowing an autocreating caller to reuse the `IndexMap`
2074    /// index it already paid to assign. Extracted rather than reimplemented so
2075    /// the recorded and unrecorded paths cannot drift: node insertion ORDER,
2076    /// the adjacency extension and the revision bump are literally the same
2077    /// code.
2078    /// br-r37-c1-jc9e4: the key is materialised only on the VACANT path.
2079    ///
2080    /// This used to open with `let node = node.into();`, so a `&str` caller
2081    /// allocated an owned `String` on every call — including the calls that
2082    /// insert nothing, where it was built purely to probe the map and then
2083    /// dropped by `entry` on the occupied branch. The allocation census
2084    /// measured that as one allocation per endpoint against a `has_node` read
2085    /// control at zero. Probing borrowed and owning only when the entry is
2086    /// genuinely new keeps the occupied path allocation-free.
2087    ///
2088    /// The raw entry keeps that single borrowed-key hash for a vacant insert,
2089    /// too. That retains the allocation-free occupied branch and turns the
2090    /// fresh branch's former lookup-plus-insert pair into one probe.
2091    fn add_node_with_attrs_unrecorded(
2092        &mut self,
2093        node: impl AsRef<str> + Into<String>,
2094        attrs: AttrMap,
2095    ) -> (bool, bool, usize, Option<usize>) {
2096        let node_ref = node.as_ref();
2097        if let Some(&slot) = self.node_order.get(node_ref) {
2098            let bucket = &mut self.node_attrs[slot];
2099            let changed = !attrs.is_empty()
2100                && attrs
2101                    .iter()
2102                    .any(|(key, value)| bucket.get(key) != Some(value));
2103            bucket.extend(attrs);
2104            if changed {
2105                self.revision = self.revision.saturating_add(1);
2106            }
2107            return (changed, true, bucket.len(), None);
2108        }
2109
2110        let slot = self.node_order.push(node.into());
2111        self.node_attrs.push(attrs);
2112        self.adj_indices.rows_mut().push(Vec::new());
2113        let attr_count = self.node_attrs[slot].len();
2114        self.revision = self.revision.saturating_add(1);
2115        (true, false, attr_count, Some(slot))
2116    }
2117
2118    /// Resolve an endpoint for `add_edge_with_attrs`, creating an empty node
2119    /// bucket only when it is absent.
2120    ///
2121    /// `add_edge_with_attrs` receives borrowed endpoint names.  Sending a miss
2122    /// through `get_index_of` and then `add_node_with_attrs_unrecorded` used to
2123    /// hash that same borrowed name twice: once to discover the miss and once
2124    /// more for the raw-entry insert.  Keep the no-attribute specialization
2125    /// here so the edge path can decide and insert from one raw entry while the
2126    /// general `add_node_with_attrs_unrecorded` path continues to move an owned
2127    /// caller key into its vacant bucket.
2128    ///
2129    /// The storage effects deliberately match an empty-attribute call to
2130    /// `add_node_with_attrs_unrecorded`: an inserted node appends exactly one
2131    /// adjacency slot and advances `revision` once; an existing node is a
2132    /// no-op.  The caller owns the one operation-level ledger record.
2133    fn resolve_empty_node_for_edge_unrecorded(&mut self, node: &str) -> (usize, bool) {
2134        if let Some(&slot) = self.node_order.get(node) {
2135            return (slot, false);
2136        }
2137        let slot = self.node_order.push(node.to_owned());
2138        self.node_attrs.push(AttrMap::new());
2139        self.adj_indices.rows_mut().push(Vec::new());
2140        self.revision = self.revision.saturating_add(1);
2141        (slot, true)
2142    }
2143
2144    pub fn add_node_with_attrs(
2145        &mut self,
2146        node: impl AsRef<str> + Into<String>,
2147        attrs: AttrMap,
2148    ) -> bool {
2149        self.compact_before_growth();
2150        let (changed, existed, attrs_count, _) = self.add_node_with_attrs_unrecorded(node, attrs);
2151        self.record_decision(
2152            "add_node",
2153            0.0,
2154            false,
2155            vec![
2156                EvidenceTerm {
2157                    signal: "node_preexisting".into(),
2158                    observed_value: bool_evidence(existed),
2159                    log_likelihood_ratio: -3.0,
2160                },
2161                EvidenceTerm {
2162                    signal: "attrs_count".into(),
2163                    observed_value: count_evidence(attrs_count),
2164                    log_likelihood_ratio: -1.0,
2165                },
2166            ],
2167        );
2168        changed
2169    }
2170
2171    /// Bulk node insertion for generated/batched construction paths where
2172    /// per-node compatibility evidence would be pure constant-factor overhead.
2173    #[must_use]
2174    pub fn extend_nodes_unrecorded<I, N>(&mut self, nodes: I) -> usize
2175    where
2176        I: IntoIterator<Item = N>,
2177        N: Into<String>,
2178    {
2179        self.compact_before_growth();
2180        let iterator = nodes.into_iter();
2181        let (lower_bound, _) = iterator.size_hint();
2182        self.node_order.reserve(lower_bound);
2183
2184        let mut inserted = 0usize;
2185        for node in iterator {
2186            let node = node.into();
2187            if self.node_order.contains_key(&node) {
2188                continue;
2189            }
2190            self.node_order.push(node);
2191            self.node_attrs.push(AttrMap::new());
2192            self.adj_indices.rows_mut().push(Vec::new());
2193            inserted += 1;
2194        }
2195        if inserted > 0 {
2196            self.revision = self
2197                .revision
2198                .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
2199            self.record_decision(
2200                "extend_nodes_unrecorded",
2201                0.0,
2202                false,
2203                vec![EvidenceTerm {
2204                    signal: "batch_node_count".into(),
2205                    observed_value: count_evidence(inserted),
2206                    log_likelihood_ratio: -1.0,
2207                }],
2208            );
2209        }
2210        inserted
2211    }
2212
2213    /// br-r37-c1-l5ve7: bulk node insert WITH attrs, one ledger record —
2214    /// mirror of DiGraph::extend_nodes_with_attrs_unrecorded. Existing
2215    /// nodes merge attrs (extend), matching add_node_with_attrs.
2216    pub fn extend_nodes_with_attrs_unrecorded<I>(&mut self, nodes: I) -> usize
2217    where
2218        I: IntoIterator<Item = (String, AttrMap)>,
2219    {
2220        self.compact_before_growth();
2221        let iterator = nodes.into_iter();
2222        let (lower_bound, _) = iterator.size_hint();
2223        self.node_order.reserve(lower_bound);
2224
2225        let mut inserted = 0usize;
2226        for (node, attrs) in iterator {
2227            if let Some(&slot) = self.node_order.get(&node) {
2228                self.node_attrs[slot].extend(attrs);
2229                continue;
2230            }
2231            self.node_order.push(node);
2232            self.node_attrs.push(attrs);
2233            self.adj_indices.rows_mut().push(Vec::new());
2234            inserted += 1;
2235        }
2236        if inserted > 0 {
2237            self.revision = self
2238                .revision
2239                .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
2240            self.record_decision(
2241                "extend_nodes_with_attrs_unrecorded",
2242                0.0,
2243                false,
2244                vec![EvidenceTerm {
2245                    signal: "batch_node_count".into(),
2246                    observed_value: count_evidence(inserted),
2247                    log_likelihood_ratio: -1.0,
2248                }],
2249            );
2250        }
2251        inserted
2252    }
2253
2254    pub fn apply_node_defaults(&mut self, defaults: &AttrMap) -> bool {
2255        if defaults.is_empty() {
2256            return false;
2257        }
2258        let mut inserted = 0usize;
2259        for slot in self.node_order.values() {
2260            let attrs = &mut self.node_attrs[slot];
2261            for (key, value) in defaults {
2262                if !attrs.contains_key(key) {
2263                    attrs.insert(key.clone(), value.clone());
2264                    inserted += 1;
2265                }
2266            }
2267        }
2268        if inserted > 0 {
2269            self.revision = self.revision.saturating_add(1);
2270            self.record_decision(
2271                "apply_node_defaults",
2272                0.0,
2273                false,
2274                vec![EvidenceTerm {
2275                    signal: "defaults_applied".into(),
2276                    observed_value: count_evidence(inserted),
2277                    log_likelihood_ratio: 0.0,
2278                }],
2279            );
2280            return true;
2281        }
2282        false
2283    }
2284
2285    pub fn add_edge(
2286        &mut self,
2287        left: impl AsRef<str>,
2288        right: impl AsRef<str>,
2289    ) -> Result<(), GraphError> {
2290        self.add_edge_with_attrs(left, right, AttrMap::new())
2291    }
2292
2293    /// Bulk-add a sequence of attribute-free edges. Bypasses the
2294    /// per-edge `runtime_policy.record_decision` call that
2295    /// [`add_edge_with_attrs`] makes. Instead, emit one summary record
2296    /// for the whole batch.
2297    ///
2298    /// Intended for fnx-internal callers that build a fresh graph
2299    /// from a known-good edge list (e.g., `complement`,
2300    /// `transitive_closure`, etc.) where the per-edge
2301    /// incompatibility-probability accounting would just add
2302    /// constant-factor overhead and is uninteresting for the policy
2303    /// log.
2304    ///
2305    /// Nodes referenced by edges are auto-created if absent
2306    /// (matching `add_edge` semantics). br-r37-c1-4jd8m.
2307    #[must_use]
2308    pub fn extend_edges_unrecorded<I, L, R>(&mut self, edges: I) -> usize
2309    where
2310        I: IntoIterator<Item = (L, R)>,
2311        L: Into<String>,
2312        R: Into<String>,
2313    {
2314        self.compact_before_growth();
2315        let iterator = edges.into_iter();
2316        let (lower_bound, _) = iterator.size_hint();
2317        self.node_order.reserve(lower_bound);
2318        self.edges.reserve(lower_bound);
2319        self.edge_index_endpoints.reserve(lower_bound);
2320
2321        let mut inserted = 0usize;
2322        let mut nodes_added = false;
2323        for (left, right) in iterator {
2324            let left = left.into();
2325            let right = right.into();
2326            let (left_slot, l_added) = match self.node_order.get(&left) {
2327                Some(&slot) => (slot, false),
2328                None => {
2329                    let slot = self.node_order.push(left.clone());
2330                    self.node_attrs.push(AttrMap::new());
2331                    self.adj_indices.rows_mut().push(Vec::new());
2332                    (slot, true)
2333                }
2334            };
2335            nodes_added |= l_added;
2336
2337            let (right_slot, r_added) = if left == right {
2338                (left_slot, false)
2339            } else {
2340                match self.node_order.get(&right) {
2341                    Some(&slot) => (slot, false),
2342                    None => {
2343                        let slot = self.node_order.push(right.clone());
2344                        self.node_attrs.push(AttrMap::new());
2345                        self.adj_indices.rows_mut().push(Vec::new());
2346                        (slot, true)
2347                    }
2348                }
2349            };
2350            nodes_added |= r_added;
2351
2352            let edge_key = Self::canon_pair(left_slot, right_slot);
2353            if self.edges.contains_key(&edge_key) {
2354                continue;
2355            }
2356            if left <= right {
2357                self.edge_index_endpoints.push((left_slot, right_slot));
2358            } else {
2359                self.edge_index_endpoints.push((right_slot, left_slot));
2360            }
2361            self.edges.insert(edge_key, AttrMap::new());
2362
2363            self.adj_indices.rows_mut()[left_slot].push(right_slot);
2364            if left_slot != right_slot {
2365                self.adj_indices.rows_mut()[right_slot].push(left_slot);
2366            }
2367            inserted += 1;
2368        }
2369        self.record_bulk_edge_summary(inserted, nodes_added);
2370        inserted
2371    }
2372
2373    /// Bulk-add attribute-free edges whose endpoints have already been
2374    /// resolved to existing node indices. This is the index-space sibling of
2375    /// [`extend_edges_unrecorded`]: it preserves duplicate handling, adjacency
2376    /// row order, edge storage order, and string-canonical endpoint orientation
2377    /// without per-edge node-name allocation or map lookup.
2378    #[must_use]
2379    pub fn extend_existing_index_edges_unrecorded<I>(&mut self, edges: I) -> usize
2380    where
2381        I: IntoIterator<Item = (usize, usize)>,
2382    {
2383        self.compact_before_growth();
2384        let iterator = edges.into_iter();
2385        let (lower_bound, _) = iterator.size_hint();
2386        self.edges.reserve(lower_bound);
2387        self.edge_index_endpoints.reserve(lower_bound);
2388
2389        let mut inserted = 0usize;
2390        for (left_idx, right_idx) in iterator {
2391            debug_assert!(left_idx < self.node_order.len());
2392            debug_assert!(right_idx < self.node_order.len());
2393            let left_slot = self
2394                .node_order
2395                .slot_at(left_idx)
2396                .expect("validated left node index");
2397            let right_slot = self
2398                .node_order
2399                .slot_at(right_idx)
2400                .expect("validated right node index");
2401
2402            let edge_key = Self::canon_pair(left_slot, right_slot);
2403            if self.edges.contains_key(&edge_key) {
2404                continue;
2405            }
2406
2407            let left_name = self
2408                .node_order
2409                .name(left_slot)
2410                .expect("validated left node index");
2411            let right_name = self
2412                .node_order
2413                .name(right_slot)
2414                .expect("validated right node index");
2415            if left_name <= right_name {
2416                self.edge_index_endpoints.push((left_slot, right_slot));
2417            } else {
2418                self.edge_index_endpoints.push((right_slot, left_slot));
2419            }
2420            self.edges.insert(edge_key, AttrMap::new());
2421
2422            self.adj_indices.rows_mut()[left_slot].push(right_slot);
2423            if left_slot != right_slot {
2424                self.adj_indices.rows_mut()[right_slot].push(left_slot);
2425            }
2426            inserted += 1;
2427        }
2428        self.record_bulk_edge_summary(inserted, false);
2429        inserted
2430    }
2431
2432    /// br-r37-c1-dodattrbatch: bulk-add ATTRIBUTED edges by EXISTING node index
2433    /// — the attributed sibling of [`extend_existing_index_edges_unrecorded`].
2434    /// All endpoints MUST already exist (callers gate on a contiguous-int node
2435    /// prefix). A new edge takes the given AttrMap; a duplicate edge MERGES
2436    /// (last-writer-wins per key) exactly like `add_edge_with_attrs`. Replaces
2437    /// the per-edge ledger with one batch summary; no String hashing for the
2438    /// endpoints (they arrive as integer indices).
2439    pub fn extend_existing_index_edges_with_attrs_unrecorded<I>(&mut self, edges: I) -> usize
2440    where
2441        I: IntoIterator<Item = (usize, usize, AttrMap)>,
2442    {
2443        self.compact_before_growth();
2444        let iterator = edges.into_iter();
2445        let (lower_bound, _) = iterator.size_hint();
2446        self.edges.reserve(lower_bound);
2447        self.edge_index_endpoints.reserve(lower_bound);
2448
2449        let mut inserted = 0usize;
2450        for (left_idx, right_idx, attrs) in iterator {
2451            debug_assert!(left_idx < self.node_order.len());
2452            debug_assert!(right_idx < self.node_order.len());
2453            let left_slot = self
2454                .node_order
2455                .slot_at(left_idx)
2456                .expect("validated left node index");
2457            let right_slot = self
2458                .node_order
2459                .slot_at(right_idx)
2460                .expect("validated right node index");
2461
2462            let edge_key = Self::canon_pair(left_slot, right_slot);
2463            if let Some(existing) = self.edges.get_mut(&edge_key) {
2464                // Duplicate edge within the batch: merge attrs (last wins).
2465                for (key, value) in attrs {
2466                    existing.insert(key, value);
2467                }
2468                continue;
2469            }
2470
2471            let left_name = self
2472                .node_order
2473                .name(left_slot)
2474                .expect("validated left node index");
2475            let right_name = self
2476                .node_order
2477                .name(right_slot)
2478                .expect("validated right node index");
2479            if left_name <= right_name {
2480                self.edge_index_endpoints.push((left_slot, right_slot));
2481            } else {
2482                self.edge_index_endpoints.push((right_slot, left_slot));
2483            }
2484            self.edges.insert(edge_key, attrs);
2485
2486            self.adj_indices.rows_mut()[left_slot].push(right_slot);
2487            if left_slot != right_slot {
2488                self.adj_indices.rows_mut()[right_slot].push(left_slot);
2489            }
2490            inserted += 1;
2491        }
2492        self.record_bulk_edge_summary(inserted, false);
2493        inserted
2494    }
2495
2496    /// br-r37-c1-pr8q6: bulk-add ATTRIBUTED edges without per-edge ledger
2497    /// records — the attributed sibling of [`extend_edges_unrecorded`].
2498    ///
2499    /// Semantics match a sequence of `add_edge_with_attrs` calls exactly:
2500    /// nodes auto-created in first-appearance order, duplicate edges MERGE
2501    /// their attrs into the existing map (`extend`), adjacency /
2502    /// `adj_indices` / `edge_index_endpoints` maintained identically. Only
2503    /// the per-edge `record_decision` push (timestamp + String allocs per
2504    /// edge, the dominant cost of attributed bulk construction) is replaced
2505    /// by one batch summary record.
2506    ///
2507    /// Callers MUST pre-screen attr keys starting with
2508    /// `"__fnx_incompatible"` and route those edges through
2509    /// `add_edge_with_attrs`, which owns the FailClosed contract for them.
2510    pub fn extend_edges_with_attrs_unrecorded<I>(&mut self, edges: I) -> usize
2511    where
2512        I: IntoIterator<Item = (String, String, AttrMap)>,
2513    {
2514        self.compact_before_growth();
2515        let iterator = edges.into_iter();
2516        let (lower_bound, _) = iterator.size_hint();
2517        self.node_order.reserve(lower_bound);
2518        self.edges.reserve(lower_bound);
2519        self.edge_index_endpoints.reserve(lower_bound);
2520
2521        let mut inserted = 0usize;
2522        let mut nodes_added = false;
2523        let mut merged_changed = false;
2524        for (left, right, attrs) in iterator {
2525            let (left_slot, l_added) = match self.node_order.get(&left) {
2526                Some(&slot) => (slot, false),
2527                None => {
2528                    let slot = self.node_order.push(left.clone());
2529                    self.node_attrs.push(AttrMap::new());
2530                    self.adj_indices.rows_mut().push(Vec::new());
2531                    (slot, true)
2532                }
2533            };
2534            nodes_added |= l_added;
2535
2536            let (right_slot, r_added) = if left == right {
2537                (left_slot, false)
2538            } else {
2539                match self.node_order.get(&right) {
2540                    Some(&slot) => (slot, false),
2541                    None => {
2542                        let slot = self.node_order.push(right.clone());
2543                        self.node_attrs.push(AttrMap::new());
2544                        self.adj_indices.rows_mut().push(Vec::new());
2545                        (slot, true)
2546                    }
2547                }
2548            };
2549            nodes_added |= r_added;
2550
2551            let edge_key = Self::canon_pair(left_slot, right_slot);
2552            if let Some(existing) = self.edges.get_mut(&edge_key) {
2553                if attrs.is_empty() {
2554                    continue;
2555                }
2556                if attrs
2557                    .iter()
2558                    .any(|(key, value)| existing.get(key) != Some(value))
2559                {
2560                    merged_changed = true;
2561                }
2562                existing.extend(attrs);
2563                continue;
2564            }
2565            if left <= right {
2566                self.edge_index_endpoints.push((left_slot, right_slot));
2567            } else {
2568                self.edge_index_endpoints.push((right_slot, left_slot));
2569            }
2570            self.edges.insert(edge_key, attrs);
2571
2572            self.adj_indices.rows_mut()[left_slot].push(right_slot);
2573            if left_slot != right_slot {
2574                self.adj_indices.rows_mut()[right_slot].push(left_slot);
2575            }
2576            inserted += 1;
2577        }
2578        if merged_changed {
2579            self.revision = self.revision.saturating_add(1);
2580        }
2581        self.record_bulk_edge_summary(inserted, nodes_added || merged_changed);
2582        inserted
2583    }
2584
2585    /// Bulk-add attributed undirected edges into a fresh graph when the caller
2586    /// has already assigned NetworkX-compatible node indices in first-seen
2587    /// order.
2588    ///
2589    /// This is the fresh/indexed sibling of
2590    /// [`extend_edges_with_attrs_unrecorded`]. It preserves duplicate
2591    /// insert-or-merge semantics, adjacency row order, self-loop handling, and
2592    /// the public `edges()` orientation rule used by the string-key path.
2593    #[must_use]
2594    pub fn extend_fresh_index_edges_with_attrs_unrecorded<I, N>(
2595        &mut self,
2596        nodes: N,
2597        edges: I,
2598    ) -> usize
2599    where
2600        I: IntoIterator<Item = (usize, usize, AttrMap)>,
2601        N: IntoIterator<Item = String>,
2602    {
2603        if !self.node_order.is_empty()
2604            || !self.adj_indices.is_empty()
2605            || !self.edges.is_empty()
2606            || !self.edge_index_endpoints.is_empty()
2607        {
2608            return 0;
2609        }
2610
2611        let node_labels: Vec<String> = nodes.into_iter().collect();
2612        let node_count = node_labels.len();
2613        self.node_order = NodeOrder::from_names(node_labels.clone());
2614        self.node_attrs = vec![AttrMap::new(); node_count];
2615        *self.adj_indices.rows_mut() = vec![Vec::new(); node_count];
2616
2617        let iterator = edges.into_iter();
2618        let (lower_bound, _) = iterator.size_hint();
2619        self.edges.reserve(lower_bound);
2620        self.edge_index_endpoints.reserve(lower_bound);
2621
2622        let mut inserted = 0usize;
2623        let mut merged_changed = false;
2624        for (left_idx, right_idx, attrs) in iterator {
2625            if left_idx >= node_count || right_idx >= node_count {
2626                continue;
2627            }
2628
2629            let edge_key = Self::canon_pair(left_idx, right_idx);
2630            if let Some(existing) = self.edges.get_mut(&edge_key) {
2631                if attrs.is_empty() {
2632                    continue;
2633                }
2634                if attrs
2635                    .iter()
2636                    .any(|(key, value)| existing.get(key) != Some(value))
2637                {
2638                    merged_changed = true;
2639                }
2640                existing.extend(attrs);
2641                continue;
2642            }
2643
2644            if node_labels[left_idx] <= node_labels[right_idx] {
2645                self.edge_index_endpoints.push((left_idx, right_idx));
2646            } else {
2647                self.edge_index_endpoints.push((right_idx, left_idx));
2648            }
2649            self.edges.insert(edge_key, attrs);
2650            let rows = self.adj_indices.rows_mut();
2651            rows[left_idx].push(right_idx);
2652            if left_idx != right_idx {
2653                rows[right_idx].push(left_idx);
2654            }
2655            inserted += 1;
2656        }
2657
2658        self.record_bulk_edge_summary(inserted, node_count > 0 || merged_changed);
2659        inserted
2660    }
2661
2662    fn record_bulk_edge_summary(&mut self, inserted: usize, nodes_added: bool) {
2663        if inserted == 0 && !nodes_added {
2664            return;
2665        }
2666        self.revision = self
2667            .revision
2668            .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
2669        // One summary record covers the whole batch and keeps the
2670        // policy ledger non-empty so existing diagnostics still
2671        // see the operation.
2672        self.record_decision(
2673            "extend_edges_unrecorded",
2674            0.0,
2675            false,
2676            vec![EvidenceTerm {
2677                signal: "batch_edge_count".into(),
2678                observed_value: count_evidence(inserted),
2679                log_likelihood_ratio: -1.0,
2680            }],
2681        );
2682    }
2683
2684    /// br-r37-c1-jc9e4: the endpoint keys are never owned by this function.
2685    ///
2686    /// This used to open with `let left = left.into(); let right =
2687    /// right.into();`, then hand `left.clone()` / `right.clone()` to
2688    /// autocreation — up to four owned `String`s per call, none of which this
2689    /// function keeps. Every use below is a comparison, a map probe, or a
2690    /// forward to `add_node_with_attrs_unrecorded`, and that callee now owns
2691    /// the key only on the vacant path. So the keys stay borrowed here and the
2692    /// single allocation an insert genuinely needs happens once, inside the
2693    /// callee, exactly when a node is created.
2694    ///
2695    /// Comparison semantics are unchanged: `left == right` and `left <= right`
2696    /// on `&str` are the same byte-wise comparisons `String` performed, so the
2697    /// self-loop test and the `edge_index_endpoints` push order are identical.
2698    pub fn add_edge_with_attrs(
2699        &mut self,
2700        left: impl AsRef<str>,
2701        right: impl AsRef<str>,
2702        attrs: AttrMap,
2703    ) -> Result<(), GraphError> {
2704        self.compact_before_growth();
2705        let left = left.as_ref();
2706        let right = right.as_ref();
2707
2708        let unknown_feature = attrs
2709            .keys()
2710            .any(|key| key.starts_with("__fnx_incompatible"));
2711        let incompatibility_probability = if unknown_feature {
2712            1.0
2713        } else if left == right {
2714            0.22
2715        } else {
2716            0.08
2717        };
2718
2719        // br-r37-c1-eo88t: the gate decision earns a ledger record only when it
2720        // REFUSES. A refusal returns from here, so the outcome record at the end
2721        // of this function never runs and the gate record is the whole audit
2722        // trail for the rejected mutation. On the allow path the outcome record
2723        // carries the same operation/mode/action/incompatibility_probability
2724        // plus strictly richer evidence, so recording the gate as well wrote two
2725        // near-identical records for every edge on the hottest mutation path.
2726        let action = self
2727            .runtime_policy
2728            .action_for(incompatibility_probability, unknown_feature);
2729
2730        if action == DecisionAction::FailClosed || action == DecisionAction::FullValidate {
2731            self.record_decision(
2732                "add_edge",
2733                incompatibility_probability,
2734                unknown_feature,
2735                vec![EvidenceTerm {
2736                    signal: "unknown_incompatible_feature".into(),
2737                    observed_value: bool_evidence(unknown_feature),
2738                    log_likelihood_ratio: 12.0,
2739                }],
2740            );
2741            return Err(GraphError::FailClosed {
2742                operation: "add_edge",
2743                reason: "incompatible edge metadata".to_owned(),
2744            });
2745        }
2746
2747        // br-r37-c1-wa1b9: autocreating an endpoint is an internal STEP of this
2748        // add_edge, not a user-visible add_node, so it does not file its own
2749        // ledger record. The outcome record below already reports the event via
2750        // `left_autocreated` / `right_autocreated`, so no audit information is
2751        // lost. This matches the ledger's per-OPERATION contract, which the
2752        // bulk paths already assert (`extend_nodes_unrecorded` files ONE record
2753        // for N nodes), and follows br-r37-c1-eo88t, which removed the other
2754        // redundant record from this same function. A caller-visible
2755        // `add_node` still records — only this internal path is unrecorded.
2756        // br-r37-c1-jc9e4: resolve each endpoint with ONE raw-entry probe.
2757        // A missing endpoint formerly did `get_index_of` followed by
2758        // `add_node_with_attrs_unrecorded`, which probed the same borrowed key
2759        // a second time before it could insert.  The specialized resolver
2760        // returns the existing index or inserts the empty bucket from that
2761        // single raw entry. A self-loop is resolved without probing the second
2762        // endpoint at all, since `left == right` makes the indices equal by
2763        // construction.
2764        //
2765        // Order is untouched: the left endpoint is still resolved and
2766        // autocreated before the right one, and `IndexMap` appends, so
2767        // autocreating the right endpoint cannot move the left one's index.
2768        let (left_key_idx, left_autocreated) = self.resolve_empty_node_for_edge_unrecorded(left);
2769        let (right_key_idx, right_autocreated) = if left == right {
2770            (left_key_idx, left_autocreated)
2771        } else {
2772            self.resolve_empty_node_for_edge_unrecorded(right)
2773        };
2774        let edge_key = Self::canon_pair(left_key_idx, right_key_idx);
2775        let self_loop = left == right;
2776        // One `entry` probe decides both whether this is a fresh edge and
2777        // which attribute bucket receives the update.  The former
2778        // `contains_key` + `entry` sequence hashed the same index pair twice
2779        // on every add_edge call, including the dominant fresh-edge build arm.
2780        let (new_edge, changed, edge_attr_count) = match self.edges.entry(edge_key) {
2781            indexmap::map::Entry::Vacant(entry) => {
2782                let edge_attr_count = attrs.len();
2783                entry.insert(attrs);
2784                (true, true, edge_attr_count)
2785            }
2786            indexmap::map::Entry::Occupied(mut entry) => {
2787                let edge_attrs = entry.get_mut();
2788                let changed = !attrs.is_empty()
2789                    && attrs
2790                        .iter()
2791                        .any(|(key, value)| edge_attrs.get(key) != Some(value));
2792                edge_attrs.extend(attrs);
2793                (false, changed, edge_attrs.len())
2794            }
2795        };
2796
2797        // Update integer adjacency (only for new edges, avoid duplicates)
2798        if changed {
2799            // br-r37-c1-jc9e4 (cc): reuse the indices resolved above instead of
2800            // re-running two String-keyed IndexMap lookups. `left_key_idx` /
2801            // `right_key_idx` came from the same `self.nodes` a few lines up and
2802            // nothing between them touches the node map — the intervening work
2803            // is the `self.edges` entry and the attr extend — so the old
2804            // `if let (Some(..), Some(..))` could never fail and never differed.
2805            // Byte-identical: same indices, same push order, same self-loop
2806            // single-push behaviour.
2807            if new_edge {
2808                if left <= right {
2809                    self.edge_index_endpoints
2810                        .push((left_key_idx, right_key_idx));
2811                } else {
2812                    self.edge_index_endpoints
2813                        .push((right_key_idx, left_key_idx));
2814                }
2815                // br-r37-c1-addedgenewedge (cc): adj_indices membership is EXACTLY edge
2816                // existence, which `new_edge` (= !self.edges.contains_key(edge_key)) already
2817                // decided in O(1). The old `!adj_indices[left_idx].contains(&right_idx)` guards
2818                // were an O(degree) linear rescan of the adjacency row computing the SAME
2819                // answer — O(n²) to build a star/hub via add_edge. Push only when new_edge
2820                // (byte-identical: existing edge ⇒ new_edge false ⇒ both endpoints already in
2821                // adj_indices ⇒ old guards were false too; self-loop ⇒ left_idx==right_idx ⇒
2822                // single push in both).
2823                let rows = self.adj_indices.rows_mut();
2824                rows[left_key_idx].push(right_key_idx);
2825                if left_key_idx != right_key_idx {
2826                    rows[right_key_idx].push(left_key_idx);
2827                }
2828            }
2829            self.revision = self.revision.saturating_add(1);
2830        }
2831
2832        self.record_decision(
2833            "add_edge",
2834            incompatibility_probability,
2835            unknown_feature,
2836            vec![
2837                EvidenceTerm {
2838                    signal: "self_loop".into(),
2839                    observed_value: bool_evidence(self_loop),
2840                    log_likelihood_ratio: -0.5,
2841                },
2842                EvidenceTerm {
2843                    signal: "edge_attr_count".into(),
2844                    observed_value: count_evidence(edge_attr_count),
2845                    log_likelihood_ratio: -2.0,
2846                },
2847                EvidenceTerm {
2848                    signal: "left_autocreated".into(),
2849                    observed_value: bool_evidence(left_autocreated),
2850                    log_likelihood_ratio: -1.25,
2851                },
2852                EvidenceTerm {
2853                    signal: "right_autocreated".into(),
2854                    observed_value: bool_evidence(right_autocreated),
2855                    log_likelihood_ratio: -1.25,
2856                },
2857            ],
2858        );
2859
2860        Ok(())
2861    }
2862
2863    pub fn apply_edge_defaults(&mut self, defaults: &AttrMap) -> bool {
2864        if defaults.is_empty() {
2865            return false;
2866        }
2867        let mut inserted = 0usize;
2868        for attrs in self.edges.values_mut() {
2869            for (key, value) in defaults {
2870                if !attrs.contains_key(key) {
2871                    attrs.insert(key.clone(), value.clone());
2872                    inserted += 1;
2873                }
2874            }
2875        }
2876        if inserted > 0 {
2877            self.revision = self.revision.saturating_add(1);
2878            self.record_decision(
2879                "apply_edge_defaults",
2880                0.0,
2881                false,
2882                vec![EvidenceTerm {
2883                    signal: "defaults_applied".into(),
2884                    observed_value: count_evidence(inserted),
2885                    log_likelihood_ratio: 0.0,
2886                }],
2887            );
2888            return true;
2889        }
2890        false
2891    }
2892
2893    /// br-r37-c1-sjf4t: overwrite the attribute map for an existing edge,
2894    /// matching post-creation Python-side mutations. Unlike
2895    /// `add_edge_with_attrs` which extends, this replaces. Returns
2896    /// `true` if the edge existed and was updated, `false` otherwise.
2897    pub fn replace_edge_attrs(&mut self, left: &str, right: &str, attrs: AttrMap) -> bool {
2898        let Some(edge_key) = self.edge_pair_key(left, right) else {
2899            return false;
2900        };
2901        if let Some(slot) = self.edges.get_mut(&edge_key) {
2902            if *slot != attrs {
2903                *slot = attrs;
2904                self.revision = self.revision.saturating_add(1);
2905            }
2906            true
2907        } else {
2908            false
2909        }
2910    }
2911
2912    /// br-r37-c1-sjf4t: overwrite the attribute map for an existing node.
2913    pub fn replace_node_attrs(&mut self, node: &str, attrs: AttrMap) -> bool {
2914        if let Some(&slot) = self.node_order.get(node) {
2915            if self.node_attrs[slot] != attrs {
2916                self.node_attrs[slot] = attrs;
2917                self.revision = self.revision.saturating_add(1);
2918            }
2919            true
2920        } else {
2921            false
2922        }
2923    }
2924
2925    pub fn remove_edge(&mut self, left: &str, right: &str) -> bool {
2926        let (Some(&left_slot), Some(&right_slot)) =
2927            (self.node_order.get(left), self.node_order.get(right))
2928        else {
2929            return false;
2930        };
2931        let pair = Self::canon_pair(left_slot, right_slot);
2932        let removed = self.edges.swap_remove_full(&pair);
2933        if let Some((edge_pos, _, _)) = removed {
2934            let rows = self.adj_indices.rows_mut();
2935            rows[left_slot].retain(|&i| i != right_slot);
2936            if left_slot != right_slot {
2937                rows[right_slot].retain(|&i| i != left_slot);
2938            }
2939            self.edge_index_endpoints.swap_remove(edge_pos);
2940            self.revision = self.revision.saturating_add(1);
2941            true
2942        } else {
2943            false
2944        }
2945    }
2946
2947    pub fn remove_node(&mut self, node: &str) -> bool {
2948        let removed = self.remove_node_deferred_compaction(node);
2949        if removed {
2950            self.compact_when_sparse();
2951        }
2952        removed
2953    }
2954
2955    fn remove_node_deferred_compaction(&mut self, node: &str) -> bool {
2956        // O(1): the node's entry is tombstoned (or popped, for the last entry
2957        // of a table with nothing else removed), never shifted.
2958        let Some((slot, popped)) = self.node_order.remove(node) else {
2959            return false;
2960        };
2961
2962        // Drop incident edges and remove this slot from neighbors' adjacency lists.
2963        let rows = self.adj_indices.rows_mut();
2964        let nbrs = std::mem::take(&mut rows[slot]);
2965        for &nbr in &nbrs {
2966            let pair = Self::canon_pair(slot, nbr);
2967            if let Some((edge_pos, _, _)) = self.edges.swap_remove_full(&pair) {
2968                self.edge_index_endpoints.swap_remove(edge_pos);
2969            }
2970            if nbr != slot {
2971                rows[nbr].retain(|&i| i != slot);
2972            }
2973        }
2974
2975        if popped {
2976            self.node_attrs.pop();
2977            rows.pop();
2978        } else {
2979            self.node_attrs[slot].clear();
2980        }
2981
2982        self.revision = self.revision.saturating_add(1);
2983        true
2984    }
2985
2986    pub fn remove_nodes_from<'a, I>(&mut self, nodes: I) -> (usize, usize)
2987    where
2988        I: IntoIterator<Item = &'a str>,
2989    {
2990        let mut removed_nodes = 0usize;
2991        let old_edge_count = self.edges.len();
2992        for node in nodes {
2993            if self.remove_node_deferred_compaction(node) {
2994                removed_nodes += 1;
2995            }
2996        }
2997        let removed_edges = old_edge_count.saturating_sub(self.edges.len());
2998        if removed_nodes > 0 {
2999            self.compact_when_sparse();
3000        }
3001        (removed_nodes, removed_edges)
3002    }
3003
3004    /// yr2oc.1: compact once the freed slots outnumber the live nodes. A run
3005    /// of removals then pays the O(V+E) renumbering once per O(V) removals
3006    /// (amortised O(degree) each) rather than on every removal, and the slot
3007    /// arrays stay within twice the live node count. Until then, readers of
3008    /// positions go through the position view.
3009    fn compact_when_sparse(&mut self) {
3010        let removed = self.node_order.slot_count() - self.node_order.len();
3011        if removed > self.node_order.len() {
3012            self.compact_internal();
3013        }
3014    }
3015
3016    /// yr2oc.1: freed slots last only through a run of removals. The first
3017    /// insertion after one compacts, once, so a graph that grows again is back
3018    /// on dense slots: an insertion would otherwise drop the position view and
3019    /// every index read after it would rebuild the view in O(V+E).
3020    fn compact_before_growth(&mut self) {
3021        if !self.node_order.is_dense() {
3022            self.compact_internal();
3023        }
3024    }
3025
3026    /// Rebuild integer adjacency from string adjacency. Called after node
3027    /// removal since indices shift.
3028    #[allow(dead_code)]
3029    /// br-r37-c1-d58s8: revision-keyed memo of "every edge's `attr`
3030    /// value is an integer (missing = default 1 = int)".
3031    #[must_use]
3032    pub fn edge_weights_all_int(&self, attr: &str) -> bool {
3033        if let Ok(guard) = self.all_int_cache.read()
3034            && let Some((rev, a, v)) = guard.as_ref()
3035            && *rev == self.revision
3036            && a == attr
3037        {
3038            return *v;
3039        }
3040        let v = self
3041            .edges
3042            .values()
3043            .all(|attrs| attrs.get(attr).is_none_or(fnx_runtime::CgseValue::is_int));
3044        if let Ok(mut guard) = self.all_int_cache.write() {
3045            *guard = Some((self.revision, attr.to_owned(), v));
3046        }
3047        v
3048    }
3049
3050    pub fn edges_ordered(&self) -> Vec<EdgeSnapshot> {
3051        let mut ordered = Vec::with_capacity(self.edges.len());
3052        let mut seen = vec![false; self.node_order.slot_count()];
3053        for (u_name, u) in self.node_order.iter() {
3054            for &v in &self.adj_indices[u] {
3055                if !seen[v] {
3056                    let pair = Self::canon_pair(u, v);
3057                    if let Some(attrs) = self.edges.get(&pair) {
3058                        let v_name = self.node_order.name(v).expect("valid node slot");
3059                        ordered.push(EdgeSnapshot {
3060                            left: u_name.clone(),
3061                            right: v_name.to_owned(),
3062                            attrs: attrs.clone(),
3063                        });
3064                    }
3065                }
3066            }
3067            seen[u] = true;
3068        }
3069
3070        ordered
3071    }
3072
3073    #[must_use]
3074    pub fn edges_ordered_indices_borrowed(&self) -> Vec<(usize, usize, &AttrMap)> {
3075        let mut ordered = Vec::with_capacity(self.edges.len());
3076        let mut seen = vec![false; self.node_order.slot_count()];
3077        let mut slot_to_pos = vec![0; self.node_order.slot_count()];
3078        for (pos, (_, slot)) in self.node_order.iter().enumerate() {
3079            slot_to_pos[slot] = pos;
3080        }
3081
3082        for (u_pos, (_, u)) in self.node_order.iter().enumerate() {
3083            for &v in &self.adj_indices[u] {
3084                if !seen[v] {
3085                    let pair = Self::canon_pair(u, v);
3086                    if let Some(attrs) = self.edges.get(&pair) {
3087                        let v_pos = slot_to_pos[v];
3088                        ordered.push((u_pos, v_pos, attrs));
3089                    }
3090                }
3091            }
3092            seen[u] = true;
3093        }
3094
3095        ordered
3096    }
3097
3098    #[must_use]
3099    pub fn edges_ordered_borrowed(&self) -> Vec<(&str, &str, &AttrMap)> {
3100        let mut ordered = Vec::with_capacity(self.edges.len());
3101        let mut seen = vec![false; self.node_order.slot_count()];
3102        for (u_name, u) in self.node_order.iter() {
3103            for &v in &self.adj_indices[u] {
3104                if !seen[v] {
3105                    let pair = Self::canon_pair(u, v);
3106                    if let Some(attrs) = self.edges.get(&pair) {
3107                        let v_name = self.node_order.name(v).expect("valid node slot");
3108                        ordered.push((u_name.as_str(), v_name, attrs));
3109                    }
3110                }
3111            }
3112            seen[u] = true;
3113        }
3114
3115        ordered
3116    }
3117
3118    /// br-r37-c1-wsize (cc): integer `size(weight)` straight from the CgseValue
3119    /// store. `size(weight)` is `sum(degree(weight))/2`; the `/2` exactly cancels
3120    /// the double-endpoint counting, so the weighted size equals each stored
3121    /// edge's weight summed ONCE (a self-loop is one stored edge → counted once,
3122    /// matching nx's degree-counts-it-twice-then-halves). Each edge appears once
3123    /// in `self.edges`, so a single store walk is the whole answer — no per-node
3124    /// PyObject degree materialization. Returns `None` (caller falls back to the
3125    /// exact float/PyObject degree path) on ANY non-integer weight value; a
3126    /// missing weight defaults to nx's int `1`. Integer addition is associative,
3127    /// so iteration order is irrelevant.
3128    #[must_use]
3129    pub fn weighted_size_int(&self, weight: &str) -> Option<i128> {
3130        let mut total: i128 = 0;
3131        for attrs in self.edges.values() {
3132            let value = match attrs.get(weight) {
3133                Some(CgseValue::Int(v)) => i128::from(*v),
3134                Some(_) => return None,
3135                None => 1,
3136            };
3137            total = total.checked_add(value)?;
3138        }
3139        Some(total)
3140    }
3141
3142    /// br-r37-c1-2a00r: index-space twin of `edges_ordered_borrowed` — yields
3143    /// `(u, v)` node indices in the SAME node-major traversed order/orientation,
3144    /// without resolving endpoints to `&str`. Lets the EdgeView iterate edges and
3145    /// look up the per-index Python node-key object directly (O(1) incref) instead
3146    /// of hashing the canonical String per endpoint via `py_node_key`.
3147    #[must_use]
3148    pub fn edges_ordered_indices(&self) -> Vec<(usize, usize)> {
3149        let mut ordered = Vec::with_capacity(self.edges.len());
3150        let mut seen_source = vec![false; self.node_order.slot_count()];
3151        let mut slot_to_pos = vec![0; self.node_order.slot_count()];
3152        for (pos, (_, slot)) in self.node_order.iter().enumerate() {
3153            slot_to_pos[slot] = pos;
3154        }
3155
3156        for (u_pos, (_, u)) in self.node_order.iter().enumerate() {
3157            for &v in &self.adj_indices[u] {
3158                if !seen_source[v] {
3159                    let v_pos = slot_to_pos[v];
3160                    ordered.push((u_pos, v_pos));
3161                }
3162            }
3163            seen_source[u] = true;
3164        }
3165        if ordered.len() == self.edges.len() {
3166            return ordered;
3167        }
3168        // Fallback (degenerate adjacency):
3169        let mut present: std::collections::HashSet<(usize, usize)> =
3170            std::collections::HashSet::with_capacity(self.edges.len());
3171        for &(l, r) in &self.edge_index_endpoints {
3172            present.insert(Self::canon_pair(l, r));
3173        }
3174        let mut ordered = Vec::with_capacity(self.edges.len());
3175        let mut seen_pairs =
3176            std::collections::HashSet::<(usize, usize)>::with_capacity(self.edges.len());
3177        for (u_pos, (_, u)) in self.node_order.iter().enumerate() {
3178            for &v in &self.adj_indices[u] {
3179                let pair = Self::canon_pair(u, v);
3180                if !seen_pairs.insert(pair) {
3181                    continue;
3182                }
3183                if present.contains(&pair) {
3184                    let v_pos = slot_to_pos[v];
3185                    ordered.push((u_pos, v_pos));
3186                }
3187            }
3188        }
3189        if ordered.len() < self.edges.len() {
3190            for &(l, r) in self.edges.keys() {
3191                if seen_pairs.insert((l, r)) {
3192                    let l_pos = slot_to_pos[l];
3193                    let r_pos = slot_to_pos[r];
3194                    ordered.push((l_pos, r_pos));
3195                }
3196            }
3197        }
3198        ordered
3199    }
3200
3201    #[must_use]
3202    pub fn edges_storage_order_borrowed(&self) -> Vec<(&str, &str, &AttrMap)> {
3203        self.edge_index_endpoints
3204            .iter()
3205            .zip(self.edges.values())
3206            .map(|(&(l, r), attrs)| {
3207                (
3208                    self.node_order.name(l).expect("valid index"),
3209                    self.node_order.name(r).expect("valid index"),
3210                    attrs,
3211                )
3212            })
3213            .collect()
3214    }
3215
3216    pub fn edges_storage_order_iter(&self) -> impl Iterator<Item = (&str, &str, &AttrMap)> + '_ {
3217        self.edge_index_endpoints
3218            .iter()
3219            .zip(self.edges.values())
3220            .map(|(&(l, r), attrs)| {
3221                (
3222                    self.node_order.name(l).expect("valid index"),
3223                    self.node_order.name(r).expect("valid index"),
3224                    attrs,
3225                )
3226            })
3227    }
3228
3229    /// Nothing removed since the last compaction: every node's slot is its
3230    /// position (O(1)).
3231    #[inline]
3232    #[must_use]
3233    pub fn is_compact(&self) -> bool {
3234        self.node_order.is_dense()
3235    }
3236
3237    pub fn ensure_compact(&mut self) {
3238        if self.is_compact() {
3239            return;
3240        }
3241        self.compact_internal();
3242    }
3243
3244    fn compact_internal(&mut self) {
3245        let n = self.node_order.len();
3246        let old_slots: Vec<usize> = self.node_order.values().collect();
3247        let old_to_new = self.node_order.compact();
3248
3249        let mut new_node_attrs = Vec::with_capacity(n);
3250        let mut new_adj_indices = Vec::with_capacity(n);
3251        let rows = self.adj_indices.rows_mut();
3252        for old_slot in old_slots {
3253            new_node_attrs.push(std::mem::take(&mut self.node_attrs[old_slot]));
3254            new_adj_indices.push(std::mem::take(&mut rows[old_slot]));
3255        }
3256
3257        // Remap neighbor slots in adjacency rows
3258        for row in &mut new_adj_indices {
3259            for neighbor in row {
3260                *neighbor = old_to_new[*neighbor];
3261            }
3262        }
3263
3264        // Remap edge keys and edge_index_endpoints
3265        let old_edges = std::mem::take(&mut self.edges);
3266        let old_endpoints = std::mem::take(&mut self.edge_index_endpoints);
3267        let mut new_edges =
3268            FxIndexMap::with_capacity_and_hasher(old_edges.len(), rustc_hash::FxBuildHasher);
3269        let mut new_endpoints = Vec::with_capacity(old_endpoints.len());
3270
3271        for (((old_l, old_r), attrs), (ep_l, ep_r)) in old_edges.into_iter().zip(old_endpoints) {
3272            let new_l = old_to_new[old_l];
3273            let new_r = old_to_new[old_r];
3274            new_edges.insert(Self::canon_pair(new_l, new_r), attrs);
3275            new_endpoints.push((old_to_new[ep_l], old_to_new[ep_r]));
3276        }
3277
3278        self.node_attrs = new_node_attrs;
3279        *self.adj_indices.rows_mut() = new_adj_indices;
3280        self.edges = new_edges;
3281        self.edge_index_endpoints = new_endpoints;
3282        self.revision = self.revision.saturating_add(1);
3283    }
3284
3285    #[must_use]
3286    pub fn snapshot(&self) -> GraphSnapshot {
3287        let mut node_attrs: BTreeMap<String, AttrMap> = BTreeMap::new();
3288        for (name, slot) in self.node_order.iter() {
3289            if !self.node_attrs[slot].is_empty() {
3290                node_attrs.insert(name.clone(), self.node_attrs[slot].clone());
3291            }
3292        }
3293        GraphSnapshot {
3294            mode: self.mode,
3295            nodes: self.node_order.keys().cloned().collect(),
3296            node_attrs,
3297            edges: self.edges_ordered(),
3298        }
3299    }
3300
3301    fn record_decision(
3302        &mut self,
3303        operation: &'static str,
3304        incompatibility_probability: f64,
3305        unknown_incompatible_feature: bool,
3306        evidence: Vec<EvidenceTerm>,
3307    ) -> DecisionAction {
3308        let action = self
3309            .runtime_policy
3310            .action_for(incompatibility_probability, unknown_incompatible_feature);
3311        self.runtime_policy.record(
3312            operation,
3313            action,
3314            incompatibility_probability,
3315            "argmin expected loss over {allow,full_validate,fail_closed}",
3316            evidence,
3317        );
3318        action
3319    }
3320}
3321
3322/// br-r37-c1-thp6w slice 1: revision-keyed integer-adjacency memo for MultiGraph. Holds the
3323/// integer neighbor rows (`get_node_index` of each adjacency-row neighbor, in row order),
3324/// lazily built from the authoritative stable-slot rows and reused until the graph mutates.
3325/// Interior mutability (RwLock) so it can populate on a `&self` read while staying `Sync`,
3326/// matching `Graph::all_int_cache`. Clone yields a FRESH (empty) memo — never shared — so a
3327/// clone that mutates independently can never serve another graph's rows.
3328#[derive(Debug, Default)]
3329struct IntAdjCache(std::sync::RwLock<Option<(u64, Vec<Vec<usize>>)>>);
3330
3331impl Clone for IntAdjCache {
3332    fn clone(&self) -> Self {
3333        Self::default()
3334    }
3335}
3336
3337/// br-r37-c1-thp6w S4: the structural row delta of one single-edge MultiGraph
3338/// mutation, used by `advance_int_adj_memo` to keep a warm integer-adjacency
3339/// memo live across the mutation instead of invalidating it.
3340enum IntAdjEdgeDelta<'a> {
3341    Add {
3342        left: &'a str,
3343        right: &'a str,
3344        left_new: bool,
3345        right_new: bool,
3346        pair_new: bool,
3347    },
3348    Remove {
3349        left: &'a str,
3350        right: &'a str,
3351        pair_gone: bool,
3352    },
3353}
3354
3355/// Stable-slot storage for `MultiGraph`. Test-only positional prototypes stay
3356/// alongside it as parity and benchmark comparators for the storage cutover.
3357mod multigraph_storage {
3358    use super::{AttrMap, CompatibilityMode, MultiEdgeSnapshot, MultiGraphSnapshot, SlotOrder};
3359    use indexmap::IndexMap;
3360    #[cfg(test)]
3361    use indexmap::IndexSet;
3362
3363    /// Index-keyed analog of the MultiGraph storage core. Internal pair
3364    /// canonicalization is (min_idx, max_idx) — deliberately DIFFERENT from
3365    /// the String-lex `EdgeKey` canonical ("10" < "2" lex, 2 < 10 numeric) —
3366    /// because pair-key orientation is never observable: every public
3367    /// ordering derives from the rows walk + per-cell key sets, which the
3368    /// parity gates pin byte-for-byte.
3369    #[cfg(test)]
3370    #[derive(Debug, Default)]
3371    pub struct MgIntStorageProto {
3372        /// Node names in insertion order (the surviving String table).
3373        pub node_names: Vec<String>,
3374        /// name -> index resolution (the `nodes` map analog).
3375        pub node_index: IndexMap<String, usize, rustc_hash::FxBuildHasher>,
3376        /// node idx -> distinct-neighbor idx -> parallel-edge key set,
3377        /// insertion-ordered exactly like the String rows.
3378        pub rows: Vec<IndexMap<usize, IndexSet<usize>>>,
3379        /// (min_idx, max_idx) -> key -> attrs.
3380        pub edges: IndexMap<(usize, usize), IndexMap<usize, AttrMap>, rustc_hash::FxBuildHasher>,
3381        pub edge_count: usize,
3382    }
3383
3384    #[cfg(test)]
3385    impl MgIntStorageProto {
3386        #[must_use]
3387        pub fn new() -> Self {
3388            Self::default()
3389        }
3390
3391        fn ensure_node(&mut self, name: &str) -> usize {
3392            if let Some(&idx) = self.node_index.get(name) {
3393                return idx;
3394            }
3395            let idx = self.node_names.len();
3396            self.node_names.push(name.to_owned());
3397            self.node_index.insert(name.to_owned(), idx);
3398            self.rows.push(IndexMap::new());
3399            idx
3400        }
3401
3402        /// Mirror of `MultiGraph::extend_keyed_edges_with_attrs_unrecorded`'s
3403        /// per-edge storage ops: endpoint nodes created on demand, an existing
3404        /// (pair, key) cell merges attrs, new cells count toward `edge_count`.
3405        pub fn add_keyed_edge(&mut self, left: &str, right: &str, key: usize, attrs: AttrMap) {
3406            let l = self.ensure_node(left);
3407            let r = if left == right {
3408                l
3409            } else {
3410                self.ensure_node(right)
3411            };
3412            let pair = (l.min(r), l.max(r));
3413            let bucket = self.edges.entry(pair).or_default();
3414            if !bucket.contains_key(&key) {
3415                self.edge_count += 1;
3416            }
3417            bucket.entry(key).or_default().extend(attrs);
3418            self.rows[l].entry(r).or_default().insert(key);
3419            if l != r {
3420                self.rows[r].entry(l).or_default().insert(key);
3421            }
3422        }
3423
3424        /// Mirror of `MultiGraph::extend_fresh_int_prefix_keyed_edges_unrecorded`
3425        /// for the A/B: same node-name String table build (fairness — the name
3426        /// table survives the flip), then pure index-keyed edge inserts with no
3427        /// String clone/hash per edge.
3428        #[must_use]
3429        pub fn bulk_load_int_prefix<I>(node_count: usize, edges: I) -> Self
3430        where
3431            I: IntoIterator<Item = (usize, usize, usize)>,
3432        {
3433            let iterator = edges.into_iter();
3434            let (lower_bound, _) = iterator.size_hint();
3435            let mut proto = Self::new();
3436            proto.node_names.reserve(node_count);
3437            proto.node_index.reserve(node_count);
3438            proto.rows.reserve(node_count);
3439            proto.edges.reserve(lower_bound);
3440            for node in 0..node_count {
3441                let name = node.to_string();
3442                proto.node_names.push(name.clone());
3443                proto.node_index.insert(name, node);
3444                proto.rows.push(IndexMap::new());
3445            }
3446            for (left_idx, right_idx, key) in iterator {
3447                debug_assert!(left_idx < node_count);
3448                debug_assert!(right_idx < node_count);
3449                let pair = (left_idx.min(right_idx), left_idx.max(right_idx));
3450                proto
3451                    .edges
3452                    .entry(pair)
3453                    .or_default()
3454                    .insert(key, AttrMap::new());
3455                proto.rows[left_idx]
3456                    .entry(right_idx)
3457                    .or_default()
3458                    .insert(key);
3459                if left_idx != right_idx {
3460                    proto.rows[right_idx]
3461                        .entry(left_idx)
3462                        .or_default()
3463                        .insert(key);
3464                }
3465                proto.edge_count += 1;
3466            }
3467            proto
3468        }
3469
3470        /// Distinct neighbors of node `i` as names, in row order.
3471        #[must_use]
3472        pub fn neighbor_names(&self, i: usize) -> Vec<&str> {
3473            self.rows[i]
3474                .keys()
3475                .map(|&j| self.node_names[j].as_str())
3476                .collect()
3477        }
3478
3479        /// Parallel-edge key order for the (left, right) pair, `edge_keys_vec` analog.
3480        #[must_use]
3481        pub fn key_order(&self, left: &str, right: &str) -> Vec<usize> {
3482            let (Some(&l), Some(&r)) = (self.node_index.get(left), self.node_index.get(right))
3483            else {
3484                return Vec::new();
3485            };
3486            self.edges
3487                .get(&(l.min(r), l.max(r)))
3488                .map(|bucket| bucket.keys().copied().collect())
3489                .unwrap_or_default()
3490        }
3491
3492        /// br-r37-c1-thp6w S7: mirror of `MultiGraph::remove_edge` on the
3493        /// index layout — `key=None` picks the LAST bucket key (`next_back`),
3494        /// the surviving bucket keeps its key order (shift semantics), an
3495        /// emptied pair drops from the outer edges map (order unobservable)
3496        /// and shift-removes the neighbor cell from both rows.
3497        pub fn remove_edge(&mut self, left: &str, right: &str, key: Option<usize>) -> bool {
3498            let (Some(&l), Some(&r)) = (self.node_index.get(left), self.node_index.get(right))
3499            else {
3500                return false;
3501            };
3502            let pair = (l.min(r), l.max(r));
3503            let Some(bucket) = self.edges.get_mut(&pair) else {
3504                return false;
3505            };
3506            let Some(removal_key) = key.or_else(|| bucket.keys().next_back().copied()) else {
3507                return false;
3508            };
3509            if bucket.shift_remove(&removal_key).is_none() {
3510                return false;
3511            }
3512            self.edge_count -= 1;
3513            let pair_gone = bucket.is_empty();
3514            if pair_gone {
3515                self.edges.swap_remove(&pair);
3516            }
3517            let mut drop_cell = |u: usize, v: usize| {
3518                let mut cell_empty = false;
3519                if let Some(keys) = self.rows[u].get_mut(&v) {
3520                    keys.shift_remove(&removal_key);
3521                    cell_empty = keys.is_empty();
3522                }
3523                if cell_empty {
3524                    self.rows[u].shift_remove(&v);
3525                }
3526            };
3527            drop_cell(l, r);
3528            if l != r {
3529                drop_cell(r, l);
3530            }
3531            true
3532        }
3533
3534        /// br-r37-c1-thp6w S7: mirror of `MultiGraph::remove_node` under the
3535        /// index layout — the d58s8-pattern fused renumber (Graph::remove_node
3536        /// template): drop the node's row, decrement every surviving index
3537        /// above it in one order-preserving pass per row, rebuild the edges
3538        /// map order-preserving with incident pairs skipped and both pair
3539        /// members decremented (order within the pair is preserved by a
3540        /// uniform decrement, so the index-canonical invariant survives).
3541        pub fn remove_node(&mut self, name: &str) -> bool {
3542            let Some(&idx) = self.node_index.get(name) else {
3543                return false;
3544            };
3545            self.node_names.remove(idx);
3546            self.node_index.shift_remove(name);
3547            for stored in self.node_index.values_mut() {
3548                if *stored > idx {
3549                    *stored -= 1;
3550                }
3551            }
3552            self.rows.remove(idx);
3553            for row in &mut self.rows {
3554                let old = std::mem::take(row);
3555                for (j, keys) in old {
3556                    if j == idx {
3557                        continue;
3558                    }
3559                    row.insert(if j > idx { j - 1 } else { j }, keys);
3560                }
3561            }
3562            let old_edges = std::mem::take(&mut self.edges);
3563            let mut removed_cells = 0usize;
3564            for ((a, b), bucket) in old_edges {
3565                if a == idx || b == idx {
3566                    removed_cells += bucket.len();
3567                    continue;
3568                }
3569                self.edges.insert(
3570                    (
3571                        if a > idx { a - 1 } else { a },
3572                        if b > idx { b - 1 } else { b },
3573                    ),
3574                    bucket,
3575                );
3576            }
3577            self.edge_count -= removed_cells;
3578            true
3579        }
3580    }
3581
3582    /// br-r37-c1-thp6w S6: singleton-optimized parallel-key cell. The
3583    /// insertion-ordered set semantics of `IndexSet<usize>` are preserved
3584    /// exactly (dedup on insert, One -> Many promotion appends), but the
3585    /// singleton case — the overwhelmingly common one in real multigraphs —
3586    /// allocates NOTHING.
3587    /// br-r37-c1-19ngg: Fx-hashed like the rest of the storage - the default
3588    /// SipHash on these usize keys was 18.7% of a multigraph attr batch; the
3589    /// set is insertion-ordered, so the hasher cannot change any order.
3590    #[derive(Debug, Clone)]
3591    pub enum CompactKeys {
3592        One(usize),
3593        Many(crate::FxIndexSet<usize>),
3594    }
3595
3596    impl CompactKeys {
3597        /// Set-insert preserving insertion order; returns true if newly added.
3598        pub fn insert(&mut self, key: usize) -> bool {
3599            match self {
3600                Self::One(existing) => {
3601                    if *existing == key {
3602                        return false;
3603                    }
3604                    let mut set =
3605                        crate::FxIndexSet::with_capacity_and_hasher(2, rustc_hash::FxBuildHasher);
3606                    set.insert(*existing);
3607                    set.insert(key);
3608                    *self = Self::Many(set);
3609                    true
3610                }
3611                Self::Many(set) => set.insert(key),
3612            }
3613        }
3614
3615        #[cfg(test)]
3616        #[must_use]
3617        pub fn order(&self) -> Vec<usize> {
3618            match self {
3619                Self::One(key) => vec![*key],
3620                Self::Many(set) => set.iter().copied().collect(),
3621            }
3622        }
3623
3624        /// br-r37-c1-thp6w S27: number of parallel keys (never zero — a cell is
3625        /// dropped wholesale when its last key is removed).
3626        #[must_use]
3627        pub fn len(&self) -> usize {
3628            match self {
3629                Self::One(_) => 1,
3630                Self::Many(set) => set.len(),
3631            }
3632        }
3633
3634        /// br-r37-c1-thp6w S9: order-preserving key removal; returns
3635        /// (removed, cell_now_empty). A `Many` that shrinks to one entry stays
3636        /// `Many` — only iteration order is observable, not the variant.
3637        pub fn shift_remove(&mut self, key: usize) -> (bool, bool) {
3638            match self {
3639                Self::One(existing) => {
3640                    let removed = *existing == key;
3641                    (removed, removed)
3642                }
3643                Self::Many(set) => {
3644                    let removed = set.shift_remove(&key);
3645                    (removed, set.is_empty())
3646                }
3647            }
3648        }
3649    }
3650
3651    /// br-r37-c1-thp6w S6: singleton-optimized (pair -> key -> attrs) bucket,
3652    /// insertion-key-ordered like `IndexMap<usize, AttrMap>`; the singleton
3653    /// case stores the key + attrs inline with no map allocation (an empty
3654    /// `AttrMap`/BTreeMap does not allocate).
3655    /// br-r37-c1-19ngg: Fx-hashed, as `CompactKeys`.
3656    #[derive(Debug, Clone)]
3657    pub enum CompactBucket {
3658        One(usize, AttrMap),
3659        Many(crate::FxIndexMap<usize, AttrMap>),
3660    }
3661
3662    impl CompactBucket {
3663        /// `entry(key).or_default().extend(attrs)` analog; returns true if the
3664        /// (key) cell is new.
3665        pub fn merge(&mut self, key: usize, attrs: AttrMap) -> bool {
3666            match self {
3667                Self::One(existing, existing_attrs) => {
3668                    if *existing == key {
3669                        existing_attrs.extend(attrs);
3670                        return false;
3671                    }
3672                    let mut map =
3673                        crate::FxIndexMap::with_capacity_and_hasher(2, rustc_hash::FxBuildHasher);
3674                    map.insert(*existing, std::mem::take(existing_attrs));
3675                    map.insert(key, attrs);
3676                    *self = Self::Many(map);
3677                    true
3678                }
3679                // One hash: the entry decides both the merge and the answer.
3680                Self::Many(map) => match map.entry(key) {
3681                    indexmap::map::Entry::Occupied(mut cell) => {
3682                        cell.get_mut().extend(attrs);
3683                        false
3684                    }
3685                    indexmap::map::Entry::Vacant(cell) => {
3686                        cell.insert(attrs);
3687                        true
3688                    }
3689                },
3690            }
3691        }
3692
3693        #[must_use]
3694        pub fn key_order(&self) -> Vec<usize> {
3695            match self {
3696                Self::One(key, _) => vec![*key],
3697                Self::Many(map) => map.keys().copied().collect(),
3698            }
3699        }
3700
3701        #[must_use]
3702        pub fn attrs_for(&self, key: usize) -> Option<&AttrMap> {
3703            match self {
3704                Self::One(existing, attrs) => (*existing == key).then_some(attrs),
3705                Self::Many(map) => map.get(&key),
3706            }
3707        }
3708
3709        #[must_use]
3710        pub fn contains_key(&self, key: usize) -> bool {
3711            self.attrs_for(key).is_some()
3712        }
3713
3714        /// br-r37-c1-thp6w S22: OVERWRITE the attr map for an existing key
3715        /// (`merge` extends; this replaces). Returns true if the key was
3716        /// present. Mirror of one cell of `MultiGraph::replace_edge_attrs`.
3717        pub fn replace_attrs(&mut self, key: usize, attrs: AttrMap) -> bool {
3718            match self {
3719                Self::One(existing, existing_attrs) => {
3720                    if *existing == key {
3721                        *existing_attrs = attrs;
3722                        true
3723                    } else {
3724                        false
3725                    }
3726                }
3727                Self::Many(map) => {
3728                    if let Some(slot) = map.get_mut(&key) {
3729                        *slot = attrs;
3730                        true
3731                    } else {
3732                        false
3733                    }
3734                }
3735            }
3736        }
3737
3738        /// br-r37-c1-thp6w S9: number of parallel keys in the bucket.
3739        #[must_use]
3740        pub fn len(&self) -> usize {
3741            match self {
3742                Self::One(..) => 1,
3743                Self::Many(map) => map.len(),
3744            }
3745        }
3746
3747        #[must_use]
3748        pub fn is_empty(&self) -> bool {
3749            self.len() == 0
3750        }
3751
3752        /// br-r37-c1-thp6w S9: the LAST key in insertion order (the real
3753        /// `remove_edge(key=None)` default via `next_back`).
3754        #[must_use]
3755        pub fn last_key(&self) -> Option<usize> {
3756            match self {
3757                Self::One(key, _) => Some(*key),
3758                Self::Many(map) => map.keys().next_back().copied(),
3759            }
3760        }
3761
3762        /// br-r37-c1-thp6w S9: order-preserving key removal; returns
3763        /// (removed, bucket_now_empty).
3764        pub fn shift_remove(&mut self, key: usize) -> (bool, bool) {
3765            match self {
3766                Self::One(existing, _) => {
3767                    let removed = *existing == key;
3768                    (removed, removed)
3769                }
3770                Self::Many(map) => {
3771                    let removed = map.shift_remove(&key).is_some();
3772                    (removed, map.is_empty())
3773                }
3774            }
3775        }
3776
3777        /// br-r37-c1-thp6w S12: key-ordered (key, attrs) iteration without a
3778        /// per-bucket allocation (read-path hot loop).
3779        pub fn iter(&self) -> CompactBucketIter<'_> {
3780            match self {
3781                Self::One(key, attrs) => CompactBucketIter::One(std::iter::once((*key, attrs))),
3782                Self::Many(map) => CompactBucketIter::Many(map.iter()),
3783            }
3784        }
3785
3786        /// br-r37-c1-thp6w S33: zero-alloc parallel-key iterator yielding
3787        /// `&usize`, matching the String adjacency `IndexSet<usize>::iter` that
3788        /// backs `edge_keys_iter`.
3789        pub fn keys(&self) -> CompactBucketKeys<'_> {
3790            match self {
3791                Self::One(key, _) => CompactBucketKeys::One(Some(key)),
3792                Self::Many(map) => CompactBucketKeys::Many(map.keys()),
3793            }
3794        }
3795
3796        pub fn values(&self) -> CompactBucketValues<'_> {
3797            match self {
3798                Self::One(_, attrs) => CompactBucketValues::One(Some(attrs)),
3799                Self::Many(map) => CompactBucketValues::Many(map.values()),
3800            }
3801        }
3802    }
3803
3804    /// br-r37-c1-thp6w S12: enum iterator for `CompactBucket::iter` (no Box,
3805    /// no per-bucket allocation).
3806    pub enum CompactBucketIter<'a> {
3807        One(std::iter::Once<(usize, &'a AttrMap)>),
3808        Many(indexmap::map::Iter<'a, usize, AttrMap>),
3809    }
3810
3811    impl<'a> Iterator for CompactBucketIter<'a> {
3812        type Item = (usize, &'a AttrMap);
3813
3814        fn next(&mut self) -> Option<Self::Item> {
3815            match self {
3816                Self::One(it) => it.next(),
3817                Self::Many(it) => it.next().map(|(key, attrs)| (*key, attrs)),
3818            }
3819        }
3820    }
3821
3822    /// br-r37-c1-thp6w S33: zero-alloc `&usize` parallel-key iterator over a
3823    /// `CompactBucket` (One = the single key once, Many = the map's keys),
3824    /// matching `IndexSet<usize>::Iter` so `edge_keys_iter` keeps `Item=&usize`.
3825    pub enum CompactBucketKeys<'a> {
3826        One(Option<&'a usize>),
3827        Many(indexmap::map::Keys<'a, usize, AttrMap>),
3828    }
3829
3830    impl<'a> Iterator for CompactBucketKeys<'a> {
3831        type Item = &'a usize;
3832
3833        fn next(&mut self) -> Option<Self::Item> {
3834            match self {
3835                Self::One(opt) => opt.take(),
3836                Self::Many(keys) => keys.next(),
3837            }
3838        }
3839    }
3840
3841    pub enum CompactBucketValues<'a> {
3842        One(Option<&'a AttrMap>),
3843        Many(indexmap::map::Values<'a, usize, AttrMap>),
3844    }
3845
3846    impl<'a> Iterator for CompactBucketValues<'a> {
3847        type Item = &'a AttrMap;
3848
3849        fn next(&mut self) -> Option<Self::Item> {
3850            match self {
3851                Self::One(opt) => opt.take(),
3852                Self::Many(values) => values.next(),
3853            }
3854        }
3855    }
3856
3857    /// br-r37-c1-thp6w S6: the compact-bucket variant of the index-keyed
3858    /// layout — identical structure to `MgIntStorageProto` except both nested
3859    /// bucket levels use the singleton-optimized enums, so a simple-graph-like
3860    /// multigraph (no parallel edges) performs ZERO per-pair bucket
3861    /// allocations.
3862    #[cfg(test)]
3863    #[derive(Debug, Default)]
3864    pub struct MgIntStorageProtoCompact {
3865        pub node_names: Vec<String>,
3866        pub node_index: IndexMap<String, usize, rustc_hash::FxBuildHasher>,
3867        pub rows: Vec<IndexMap<usize, CompactKeys>>,
3868        pub edges: IndexMap<(usize, usize), CompactBucket, rustc_hash::FxBuildHasher>,
3869        pub edge_count: usize,
3870    }
3871
3872    #[cfg(test)]
3873    impl MgIntStorageProtoCompact {
3874        #[must_use]
3875        pub fn new() -> Self {
3876            Self::default()
3877        }
3878
3879        fn ensure_node(&mut self, name: &str) -> usize {
3880            if let Some(&idx) = self.node_index.get(name) {
3881                return idx;
3882            }
3883            let idx = self.node_names.len();
3884            self.node_names.push(name.to_owned());
3885            self.node_index.insert(name.to_owned(), idx);
3886            self.rows.push(IndexMap::new());
3887            idx
3888        }
3889
3890        /// Mirror of `MgIntStorageProto::add_keyed_edge` on compact buckets.
3891        pub fn add_keyed_edge(
3892            &mut self,
3893            left: &str,
3894            right: &str,
3895            key: usize,
3896            attrs: AttrMap,
3897        ) -> bool {
3898            let l = self.ensure_node(left);
3899            let r = if left == right {
3900                l
3901            } else {
3902                self.ensure_node(right)
3903            };
3904            let pair = (l.min(r), l.max(r));
3905            let is_new = match self.edges.entry(pair) {
3906                indexmap::map::Entry::Occupied(mut bucket) => bucket.get_mut().merge(key, attrs),
3907                indexmap::map::Entry::Vacant(slot) => {
3908                    slot.insert(CompactBucket::One(key, attrs));
3909                    true
3910                }
3911            };
3912            if is_new {
3913                self.edge_count += 1;
3914            }
3915            match self.rows[l].entry(r) {
3916                indexmap::map::Entry::Occupied(mut cell) => {
3917                    let _ = cell.get_mut().insert(key);
3918                }
3919                indexmap::map::Entry::Vacant(slot) => {
3920                    slot.insert(CompactKeys::One(key));
3921                }
3922            }
3923            if l != r {
3924                match self.rows[r].entry(l) {
3925                    indexmap::map::Entry::Occupied(mut cell) => {
3926                        let _ = cell.get_mut().insert(key);
3927                    }
3928                    indexmap::map::Entry::Vacant(slot) => {
3929                        slot.insert(CompactKeys::One(key));
3930                    }
3931                }
3932            }
3933            is_new
3934        }
3935
3936        /// Mirror of `MgIntStorageProto::bulk_load_int_prefix` on compact buckets.
3937        #[cfg(test)]
3938        #[must_use]
3939        pub fn bulk_load_int_prefix<I>(node_count: usize, edges: I) -> Self
3940        where
3941            I: IntoIterator<Item = (usize, usize, usize)>,
3942        {
3943            let iterator = edges.into_iter();
3944            let (lower_bound, _) = iterator.size_hint();
3945            let mut proto = Self::new();
3946            proto.node_names.reserve(node_count);
3947            proto.node_index.reserve(node_count);
3948            proto.rows.reserve(node_count);
3949            proto.edges.reserve(lower_bound);
3950            for node in 0..node_count {
3951                let name = node.to_string();
3952                proto.node_names.push(name.clone());
3953                proto.node_index.insert(name, node);
3954                proto.rows.push(IndexMap::new());
3955            }
3956            for (left_idx, right_idx, key) in iterator {
3957                debug_assert!(left_idx < node_count);
3958                debug_assert!(right_idx < node_count);
3959                let pair = (left_idx.min(right_idx), left_idx.max(right_idx));
3960                match proto.edges.entry(pair) {
3961                    indexmap::map::Entry::Occupied(mut bucket) => {
3962                        let _ = bucket.get_mut().merge(key, AttrMap::new());
3963                    }
3964                    indexmap::map::Entry::Vacant(slot) => {
3965                        slot.insert(CompactBucket::One(key, AttrMap::new()));
3966                    }
3967                }
3968                match proto.rows[left_idx].entry(right_idx) {
3969                    indexmap::map::Entry::Occupied(mut cell) => {
3970                        let _ = cell.get_mut().insert(key);
3971                    }
3972                    indexmap::map::Entry::Vacant(slot) => {
3973                        slot.insert(CompactKeys::One(key));
3974                    }
3975                }
3976                if left_idx != right_idx {
3977                    match proto.rows[right_idx].entry(left_idx) {
3978                        indexmap::map::Entry::Occupied(mut cell) => {
3979                            let _ = cell.get_mut().insert(key);
3980                        }
3981                        indexmap::map::Entry::Vacant(slot) => {
3982                            slot.insert(CompactKeys::One(key));
3983                        }
3984                    }
3985                }
3986                proto.edge_count += 1;
3987            }
3988            proto
3989        }
3990
3991        /// Distinct neighbors of node `i` as names, in row order.
3992        #[must_use]
3993        pub fn neighbor_names(&self, i: usize) -> Vec<&str> {
3994            self.rows[i]
3995                .keys()
3996                .map(|&j| self.node_names[j].as_str())
3997                .collect()
3998        }
3999
4000        /// Parallel-edge key order for the (left, right) pair.
4001        #[must_use]
4002        pub fn key_order(&self, left: &str, right: &str) -> Vec<usize> {
4003            let (Some(&l), Some(&r)) = (self.node_index.get(left), self.node_index.get(right))
4004            else {
4005                return Vec::new();
4006            };
4007            self.edges
4008                .get(&(l.min(r), l.max(r)))
4009                .map(CompactBucket::key_order)
4010                .unwrap_or_default()
4011        }
4012    }
4013
4014    /// br-r37-c1-thp6w S9: the slab/stable-slot layout MANDATED by the S8
4015    /// removal A/B (positional renumber measured 190-203x slower than the
4016    /// String store). Node identity = a STABLE SLOT that never renumbers;
4017    /// removal frees the slot for reuse and tombstones only the order entry
4018    /// (yr2oc.1: O(1), see [`SlotOrder`]), so rows and edge pair-keys stay
4019    /// valid with ZERO rekeying. nx-observable insertion order lives entirely
4020    /// in `node_order`. Buckets are the S6 compact enums (the winning
4021    /// construction layout).
4022    #[derive(Debug, Clone, Default)]
4023    pub struct MgSlabStorage {
4024        /// name <-> stable slot, and the nx node insertion order.
4025        pub(crate) node_order: SlotOrder,
4026        /// Slot-parallel node attributes. Empty for edge-created nodes and
4027        /// cleared when a slot is freed.
4028        pub node_attrs: Vec<AttrMap>,
4029        /// slot -> neighbor slot -> parallel-key cell (row insertion order).
4030        /// br-r37-c1-19ngg: Fx-hashed; order is the IndexMap's insertion order.
4031        pub rows: Vec<crate::FxIndexMap<usize, CompactKeys>>,
4032        /// slot-canonical (min, max) pair -> compact bucket. Slots are stable,
4033        /// so pair keys survive any removal without rekeying.
4034        pub edges: IndexMap<(usize, usize), CompactBucket, rustc_hash::FxBuildHasher>,
4035        pub edge_count: usize,
4036    }
4037
4038    impl MgSlabStorage {
4039        #[must_use]
4040        pub fn new() -> Self {
4041            Self::default()
4042        }
4043
4044        fn ensure_node(&mut self, name: &str) -> usize {
4045            if let Some(&slot) = self.node_order.get(name) {
4046                return slot;
4047            }
4048            let (slot, fresh) = self.node_order.insert(name.to_owned());
4049            if fresh {
4050                self.rows.push(crate::FxIndexMap::default());
4051                self.node_attrs.push(AttrMap::new());
4052            } else {
4053                debug_assert!(self.rows[slot].is_empty());
4054                self.node_attrs[slot] = AttrMap::new();
4055            }
4056            slot
4057        }
4058
4059        /// Same insert semantics as the S5/S6 variants, slot-keyed.
4060        pub fn add_keyed_edge(
4061            &mut self,
4062            left: &str,
4063            right: &str,
4064            key: usize,
4065            attrs: AttrMap,
4066        ) -> bool {
4067            let l = self.ensure_node(left);
4068            let r = if left == right {
4069                l
4070            } else {
4071                self.ensure_node(right)
4072            };
4073            self.add_keyed_edge_by_pair(l, r, key, attrs)
4074        }
4075
4076        /// br-r37-c1-4c29a: slot-keyed half of [`Self::add_keyed_edge`], for a
4077        /// caller that has already ensured both endpoints exist and knows their
4078        /// slots. Identical insert semantics — the only thing skipped is the
4079        /// `ensure_node` pair, which for such a caller is two redundant
4080        /// `String` hashes.
4081        pub fn add_keyed_edge_by_pair(
4082            &mut self,
4083            l: usize,
4084            r: usize,
4085            key: usize,
4086            attrs: AttrMap,
4087        ) -> bool {
4088            let pair = (l.min(r), l.max(r));
4089            let is_new = match self.edges.entry(pair) {
4090                indexmap::map::Entry::Occupied(mut bucket) => bucket.get_mut().merge(key, attrs),
4091                indexmap::map::Entry::Vacant(slot) => {
4092                    slot.insert(CompactBucket::One(key, attrs));
4093                    true
4094                }
4095            };
4096            if is_new {
4097                self.edge_count += 1;
4098            }
4099            match self.rows[l].entry(r) {
4100                indexmap::map::Entry::Occupied(mut cell) => {
4101                    let _ = cell.get_mut().insert(key);
4102                }
4103                indexmap::map::Entry::Vacant(slot) => {
4104                    slot.insert(CompactKeys::One(key));
4105                }
4106            }
4107            if l != r {
4108                match self.rows[r].entry(l) {
4109                    indexmap::map::Entry::Occupied(mut cell) => {
4110                        let _ = cell.get_mut().insert(key);
4111                    }
4112                    indexmap::map::Entry::Vacant(slot) => {
4113                        slot.insert(CompactKeys::One(key));
4114                    }
4115                }
4116            }
4117            is_new
4118        }
4119
4120        /// Bulk loader mirroring the S5/S6 int-prefix arms (fresh graph, slots
4121        /// == 0..n, no tombstones).
4122        #[cfg(test)]
4123        #[must_use]
4124        pub fn bulk_load_int_prefix<I>(node_count: usize, edges: I) -> Self
4125        where
4126            I: IntoIterator<Item = (usize, usize, usize)>,
4127        {
4128            let iterator = edges.into_iter();
4129            let (lower_bound, _) = iterator.size_hint();
4130            let mut proto = Self::new();
4131            proto.node_order.reserve(node_count);
4132            proto.rows.reserve(node_count);
4133            proto.edges.reserve(lower_bound);
4134            proto.node_attrs.reserve(node_count);
4135            for node in 0..node_count {
4136                proto.node_order.insert(node.to_string());
4137                proto.rows.push(crate::FxIndexMap::default());
4138                proto.node_attrs.push(AttrMap::new());
4139            }
4140            for (left_slot, right_slot, key) in iterator {
4141                debug_assert!(left_slot < node_count);
4142                debug_assert!(right_slot < node_count);
4143                let pair = (left_slot.min(right_slot), left_slot.max(right_slot));
4144                match proto.edges.entry(pair) {
4145                    indexmap::map::Entry::Occupied(mut bucket) => {
4146                        let _ = bucket.get_mut().merge(key, AttrMap::new());
4147                    }
4148                    indexmap::map::Entry::Vacant(slot) => {
4149                        slot.insert(CompactBucket::One(key, AttrMap::new()));
4150                    }
4151                }
4152                match proto.rows[left_slot].entry(right_slot) {
4153                    indexmap::map::Entry::Occupied(mut cell) => {
4154                        let _ = cell.get_mut().insert(key);
4155                    }
4156                    indexmap::map::Entry::Vacant(slot) => {
4157                        slot.insert(CompactKeys::One(key));
4158                    }
4159                }
4160                if left_slot != right_slot {
4161                    match proto.rows[right_slot].entry(left_slot) {
4162                        indexmap::map::Entry::Occupied(mut cell) => {
4163                            let _ = cell.get_mut().insert(key);
4164                        }
4165                        indexmap::map::Entry::Vacant(slot) => {
4166                            slot.insert(CompactKeys::One(key));
4167                        }
4168                    }
4169                }
4170                proto.edge_count += 1;
4171            }
4172            proto
4173        }
4174
4175        /// Mirror of `MultiGraph::remove_edge` (key=None -> LAST bucket key).
4176        pub fn remove_edge(&mut self, left: &str, right: &str, key: Option<usize>) -> bool {
4177            let (Some(&l), Some(&r)) = (self.node_order.get(left), self.node_order.get(right))
4178            else {
4179                return false;
4180            };
4181            let pair = (l.min(r), l.max(r));
4182            let Some(bucket) = self.edges.get_mut(&pair) else {
4183                return false;
4184            };
4185            let Some(removal_key) = key.or_else(|| bucket.last_key()) else {
4186                return false;
4187            };
4188            let (removed, bucket_empty) = bucket.shift_remove(removal_key);
4189            if !removed {
4190                return false;
4191            }
4192            self.edge_count -= 1;
4193            if bucket_empty {
4194                self.edges.swap_remove(&pair);
4195            }
4196            let mut drop_cell = |u: usize, v: usize| {
4197                let mut cell_empty = false;
4198                if let Some(keys) = self.rows[u].get_mut(&v) {
4199                    let (cell_removed, now_empty) = keys.shift_remove(removal_key);
4200                    debug_assert!(cell_removed);
4201                    cell_empty = now_empty;
4202                }
4203                if cell_empty {
4204                    self.rows[u].shift_remove(&v);
4205                }
4206            };
4207            drop_cell(l, r);
4208            if l != r {
4209                drop_cell(r, l);
4210            }
4211            true
4212        }
4213
4214        /// Slab removal: O(degree) row/bucket drops and an O(1) order-entry
4215        /// tombstone. NO renumber, NO edges rekey — the S8-mandated shape.
4216        pub fn remove_node(&mut self, name: &str) -> bool {
4217            let Some(&slot) = self.node_order.get(name) else {
4218                return false;
4219            };
4220            let row = std::mem::take(&mut self.rows[slot]);
4221            for &nbr in row.keys() {
4222                if nbr != slot {
4223                    self.rows[nbr].shift_remove(&slot);
4224                }
4225                if let Some(bucket) = self.edges.swap_remove(&(slot.min(nbr), slot.max(nbr))) {
4226                    self.edge_count -= bucket.len();
4227                }
4228            }
4229            self.node_order.remove(name);
4230            self.node_attrs[slot] = AttrMap::new();
4231            true
4232        }
4233
4234        /// `MultiGraph::add_node_with_attrs` semantics: ensure the node exists,
4235        /// then extend its attribute map.
4236        pub fn set_node_attrs(&mut self, name: &str, attrs: AttrMap) -> (bool, bool, usize) {
4237            let existed = self.node_order.contains_key(name);
4238            let changed = !existed
4239                || attrs.iter().any(|(key, value)| {
4240                    self.node_attrs_for(name).and_then(|a| a.get(key)) != Some(value)
4241                });
4242            let slot = self.ensure_node(name);
4243            self.node_attrs[slot].extend(attrs);
4244            (existed, changed, self.node_attrs[slot].len())
4245        }
4246
4247        /// Overwrite an existing node's attribute map. No-op if absent.
4248        pub fn replace_node_attrs(&mut self, name: &str, attrs: AttrMap) {
4249            if let Some(&slot) = self.node_order.get(name) {
4250                self.node_attrs[slot] = attrs;
4251            }
4252        }
4253
4254        /// Distinct neighbors (names, row order) of the node at insertion-order
4255        /// position `pos`.
4256        #[cfg(test)]
4257        #[must_use]
4258        pub fn neighbor_names(&self, pos: usize) -> Vec<&str> {
4259            let Some((_, slot)) = self.node_order.get_index(pos) else {
4260                return Vec::new();
4261            };
4262            self.rows[slot]
4263                .keys()
4264                .map(|&s| self.node_order.name(s).expect("row references a live slot"))
4265                .collect()
4266        }
4267
4268        /// br-r37-c1-thp6w S26: name-keyed distinct neighbors in row order
4269        /// (mirror of `MultiGraph::neighbors`); `None` if the node is absent.
4270        #[must_use]
4271        pub fn neighbors_by_name(&self, name: &str) -> Option<Vec<&str>> {
4272            let &slot = self.node_order.get(name)?;
4273            Some(
4274                self.rows[slot]
4275                    .keys()
4276                    .map(|&s| self.node_order.name(s).expect("row references a live slot"))
4277                    .collect(),
4278            )
4279        }
4280
4281        /// br-r37-c1-thp6w S27: node degree (mirror of `MultiGraph::degree`) —
4282        /// sum of per-neighbor parallel-edge counts, self-loops counted twice
4283        /// (nx convention). 0 if the node is absent.
4284        #[must_use]
4285        pub fn degree(&self, name: &str) -> usize {
4286            let Some(&slot) = self.node_order.get(name) else {
4287                return 0;
4288            };
4289            let mut deg = 0;
4290            for (&nbr_slot, keys) in &self.rows[slot] {
4291                let count = keys.len();
4292                if nbr_slot == slot {
4293                    deg += count * 2;
4294                } else {
4295                    deg += count;
4296                }
4297            }
4298            deg
4299        }
4300
4301        #[must_use]
4302        pub fn number_of_selfloops(&self) -> usize {
4303            self.edges
4304                .iter()
4305                .filter(|((left, right), _)| left == right)
4306                .map(|(_, bucket)| bucket.len())
4307                .sum()
4308        }
4309
4310        /// br-r37-c1-thp6w S28: node membership (mirror of `MultiGraph::has_node`).
4311        #[must_use]
4312        pub fn has_node(&self, name: &str) -> bool {
4313            self.node_order.contains_key(name)
4314        }
4315
4316        /// br-r37-c1-thp6w S28: edge membership (mirror of `MultiGraph::has_edge`)
4317        /// — true iff a non-empty (left, right) bucket exists.
4318        #[must_use]
4319        pub fn has_edge(&self, left: &str, right: &str) -> bool {
4320            let (Some(&l), Some(&r)) = (self.node_order.get(left), self.node_order.get(right))
4321            else {
4322                return false;
4323            };
4324            self.has_edge_by_pair(l, r)
4325        }
4326
4327        /// br-r37-c1-4c29a: the slot-keyed half of [`Self::has_edge`].
4328        ///
4329        /// The callgrind profile of the MultiGraph build put
4330        /// `IndexMap<String, usize>::get_index_of` at 17.39% — the largest
4331        /// entry, ahead of the allocator — because `add_edge_impl` re-resolved
4332        /// both endpoint strings inside every one of six storage calls. These
4333        /// `_by_pair` variants let a caller that has already resolved its
4334        /// endpoints skip the lookup, and the `&str` methods keep working by
4335        /// resolving once and delegating here, so there is a single
4336        /// implementation of the logic.
4337        #[must_use]
4338        pub fn has_edge_by_pair(&self, l: usize, r: usize) -> bool {
4339            self.edges
4340                .get(&(l.min(r), l.max(r)))
4341                .is_some_and(|bucket| !bucket.is_empty())
4342        }
4343
4344        /// Slot for a node name, or `None` when absent.
4345        #[must_use]
4346        pub fn node_slot(&self, name: &str) -> Option<usize> {
4347            self.node_order.get(name).copied()
4348        }
4349
4350        /// br-r37-c1-2ndmw: the stable SLOT of the node at an insertion-order
4351        /// POSITION, without hashing a node key: O(1) until a removal, O(log V)
4352        /// after (yr2oc.1).
4353        ///
4354        /// This is the missing conversion between the two index spaces. Callers
4355        /// hold POSITIONS (`get_node_index`, and the cached exact-string index
4356        /// on the Python side); the edge map is keyed by SLOTS. Feeding one to
4357        /// the other reports a real edge as ABSENT once a node has been removed
4358        /// -- see `multigraph_node_slot_and_position_are_distinct_index_spaces`.
4359        #[must_use]
4360        pub fn slot_at_position(&self, position: usize) -> Option<usize> {
4361            self.node_order.slot_at(position)
4362        }
4363
4364        /// br-r37-c1-thp6w S30: node is isolated (mirror of
4365        /// `MultiGraph::is_isolate`) — present with an empty adjacency row.
4366        #[must_use]
4367        pub fn is_isolate(&self, name: &str) -> bool {
4368            self.node_order
4369                .get(name)
4370                .is_some_and(|&slot| self.rows[slot].is_empty())
4371        }
4372
4373        #[must_use]
4374        pub fn isolates(&self) -> Vec<String> {
4375            self.node_order
4376                .iter()
4377                .filter(|&(_, slot)| self.rows[slot].is_empty())
4378                .map(|(name, _)| name.clone())
4379                .collect()
4380        }
4381
4382        #[must_use]
4383        pub fn number_of_isolates(&self) -> usize {
4384            self.node_order
4385                .values()
4386                .filter(|&slot| self.rows[slot].is_empty())
4387                .count()
4388        }
4389
4390        /// br-r37-c1-thp6w S31: integer weighted size (mirror of
4391        /// `MultiGraph::weighted_size_int`) — sum of the `weight` attr over
4392        /// every edge cell (int -> value, missing -> 1); `None` on any non-int
4393        /// weight. Order-independent (commutative sum; `None` iff any non-int).
4394        #[must_use]
4395        pub fn weighted_size_int(&self, weight: &str) -> Option<i128> {
4396            let mut total: i128 = 0;
4397            for bucket in self.edges.values() {
4398                for (_key, attrs) in bucket.iter() {
4399                    let value = match attrs.get(weight) {
4400                        Some(fnx_runtime::CgseValue::Int(v)) => i128::from(*v),
4401                        Some(_) => return None,
4402                        None => 1,
4403                    };
4404                    total = total.checked_add(value)?;
4405                }
4406            }
4407            Some(total)
4408        }
4409
4410        /// Parallel-edge key order for the (left, right) pair.
4411        #[must_use]
4412        pub fn key_order(&self, left: &str, right: &str) -> Vec<usize> {
4413            let (Some(&l), Some(&r)) = (self.node_order.get(left), self.node_order.get(right))
4414            else {
4415                return Vec::new();
4416            };
4417            self.edges
4418                .get(&(l.min(r), l.max(r)))
4419                .map(CompactBucket::key_order)
4420                .unwrap_or_default()
4421        }
4422
4423        #[must_use]
4424        pub fn edge_keys(&self, left: &str, right: &str) -> Option<Vec<usize>> {
4425            let (Some(&l), Some(&r)) = (self.node_order.get(left), self.node_order.get(right))
4426            else {
4427                return None;
4428            };
4429            self.edges
4430                .get(&(l.min(r), l.max(r)))
4431                .map(CompactBucket::key_order)
4432        }
4433
4434        pub fn edge_keys_iter(&self, left: &str, right: &str) -> Option<CompactBucketKeys<'_>> {
4435            let (Some(&l), Some(&r)) = (self.node_order.get(left), self.node_order.get(right))
4436            else {
4437                return None;
4438            };
4439            self.edges
4440                .get(&(l.min(r), l.max(r)))
4441                .map(CompactBucket::keys)
4442        }
4443
4444        pub fn edge_attr_values(&self, left: &str, right: &str) -> Option<CompactBucketValues<'_>> {
4445            let (Some(&l), Some(&r)) = (self.node_order.get(left), self.node_order.get(right))
4446            else {
4447                return None;
4448            };
4449            self.edges
4450                .get(&(l.min(r), l.max(r)))
4451                .map(CompactBucket::values)
4452        }
4453
4454        /// br-r37-c1-4c29a: slot-keyed edge-cell count. The `&str` wrapper this
4455        /// replaced had exactly one caller — `add_edge_impl`, which now holds
4456        /// the slots — so it was removed rather than left dead.
4457        #[must_use]
4458        pub fn edge_attr_count_by_pair(&self, l: usize, r: usize) -> usize {
4459            self.edges
4460                .get(&(l.min(r), l.max(r)))
4461                .map_or(0, CompactBucket::len)
4462        }
4463
4464        /// br-r37-c1-4c29a: slot-keyed auto-key selection. As with
4465        /// `edge_attr_count_by_pair`, the `&str` wrapper had a single caller
4466        /// that now holds the slots, so it was removed rather than left dead.
4467        #[must_use]
4468        pub fn next_edge_key_by_pair(&self, l: usize, r: usize) -> usize {
4469            let Some(bucket) = self.edges.get(&(l.min(r), l.max(r))) else {
4470                return 0;
4471            };
4472            let mut key = bucket.len();
4473            while bucket.contains_key(key) {
4474                key += 1;
4475            }
4476            key
4477        }
4478
4479        /// Overwrite the attribute map for one edge cell.
4480        pub fn replace_edge_attrs(
4481            &mut self,
4482            left: &str,
4483            right: &str,
4484            key: usize,
4485            attrs: AttrMap,
4486        ) -> bool {
4487            let (Some(&l), Some(&r)) = (self.node_order.get(left), self.node_order.get(right))
4488            else {
4489                return false;
4490            };
4491            self.edges
4492                .get_mut(&(l.min(r), l.max(r)))
4493                .is_some_and(|bucket| bucket.replace_attrs(key, attrs))
4494        }
4495
4496        /// Attributes of one `(left, right, key)` edge cell.
4497        #[must_use]
4498        pub fn edge_attrs(&self, left: &str, right: &str, key: usize) -> Option<&AttrMap> {
4499            let (Some(&l), Some(&r)) = (self.node_order.get(left), self.node_order.get(right))
4500            else {
4501                return None;
4502            };
4503            self.edge_attrs_by_pair(l, r, key)
4504        }
4505
4506        /// br-r37-c1-4c29a: slot-keyed half of [`Self::edge_attrs`].
4507        #[must_use]
4508        pub fn edge_attrs_by_pair(&self, l: usize, r: usize, key: usize) -> Option<&AttrMap> {
4509            self.edges
4510                .get(&(l.min(r), l.max(r)))
4511                .and_then(|bucket| bucket.attrs_for(key))
4512        }
4513
4514        /// The node's attribute map, or `None` when the node is absent.
4515        #[must_use]
4516        pub fn node_attrs_for(&self, name: &str) -> Option<&AttrMap> {
4517            let &slot = self.node_order.get(name)?;
4518            Some(&self.node_attrs[slot])
4519        }
4520
4521        #[must_use]
4522        pub fn get_node_index(&self, name: &str) -> Option<usize> {
4523            self.node_order.get_index_of(name)
4524        }
4525
4526        #[must_use]
4527        pub fn get_node_name(&self, index: usize) -> Option<&str> {
4528            self.node_order
4529                .get_index(index)
4530                .map(|(name, _)| name.as_str())
4531        }
4532
4533        #[must_use]
4534        pub fn node_index_matches_int(&self, index: usize) -> bool {
4535            self.node_order
4536                .get_index(index)
4537                .is_some_and(|(name, _)| name.parse::<usize>() == Ok(index))
4538        }
4539
4540        #[must_use]
4541        pub fn build_int_adjacency(&self) -> Vec<Vec<usize>> {
4542            let slot_to_position = self.slot_positions();
4543            self.node_order
4544                .values()
4545                .map(|slot| {
4546                    self.rows[slot]
4547                        .keys()
4548                        .map(|&neighbor_slot| slot_to_position[neighbor_slot])
4549                        .collect()
4550                })
4551                .collect()
4552        }
4553
4554        /// br-r37-c1-thp6w S10: slot -> insertion-order position (usize::MAX
4555        /// for tombstones), rebuilt on demand in O(V).
4556        fn slot_positions(&self) -> Vec<usize> {
4557            let mut pos = vec![usize::MAX; self.node_order.slot_count()];
4558            for (p, slot) in self.node_order.values().enumerate() {
4559                pos[slot] = p;
4560            }
4561            pos
4562        }
4563
4564        /// br-r37-c1-thp6w S12: full `edges_ordered_borrowed` analog with
4565        /// attrs — the production read candidate. Same walk contract as
4566        /// `edges_ordered_names` (node order x row order x bucket key order,
4567        /// first-touch dedup, String-lex canonical emission orientation) but
4568        /// hashing only usizes where the String store hashes `EdgeKeyRef`
4569        /// String pairs per cell and per seen-set probe.
4570        #[must_use]
4571        pub fn edges_ordered_borrowed(&self) -> Vec<(&str, &str, usize, &AttrMap)> {
4572            let mut ordered = Vec::with_capacity(self.edge_count);
4573            let mut seen: std::collections::HashSet<(usize, usize, usize)> =
4574                std::collections::HashSet::with_capacity(self.edge_count);
4575            for (u_name, u_slot) in self.node_order.iter() {
4576                for &v_slot in self.rows[u_slot].keys() {
4577                    let pair = (u_slot.min(v_slot), u_slot.max(v_slot));
4578                    let Some(bucket) = self.edges.get(&pair) else {
4579                        continue;
4580                    };
4581                    for (key, attrs) in bucket.iter() {
4582                        if !seen.insert((pair.0, pair.1, key)) {
4583                            continue;
4584                        }
4585                        let v_name = self
4586                            .node_order
4587                            .name(v_slot)
4588                            .expect("row references a live slot");
4589                        let (left, right) = if u_name.as_str() <= v_name {
4590                            (u_name.as_str(), v_name)
4591                        } else {
4592                            (v_name, u_name.as_str())
4593                        };
4594                        ordered.push((left, right, key, attrs));
4595                    }
4596                }
4597            }
4598            ordered
4599        }
4600
4601        #[must_use]
4602        pub fn snapshot(&self, mode: CompatibilityMode) -> MultiGraphSnapshot {
4603            let node_attrs = self
4604                .node_order
4605                .iter()
4606                .filter_map(|(name, slot)| {
4607                    let attrs = &self.node_attrs[slot];
4608                    (!attrs.is_empty()).then(|| (name.clone(), attrs.clone()))
4609                })
4610                .collect();
4611            let edges = self
4612                .edges_ordered_borrowed()
4613                .into_iter()
4614                .map(|(left, right, key, attrs)| MultiEdgeSnapshot {
4615                    left: left.to_owned(),
4616                    right: right.to_owned(),
4617                    key,
4618                    attrs: attrs.clone(),
4619                })
4620                .collect();
4621            MultiGraphSnapshot {
4622                mode,
4623                nodes: self.node_order.keys().cloned().collect(),
4624                node_attrs,
4625                edges,
4626            }
4627        }
4628
4629        /// br-r37-c1-thp6w S13: index-space analog of `edges_ordered_borrowed`
4630        /// — the same walk/order/orientation, but each endpoint emitted as its
4631        /// POSITION (node_order iteration index = nx node position), not its
4632        /// name. A `slot -> position` array is built once up front (O(n),
4633        /// hash-free — positions come straight from `node_order` iteration, so
4634        /// this is correct under any slot recycling, where slot != position),
4635        /// then the walk resolves both endpoints by O(1) array index. The only
4636        /// per-edge string op is the lex compare for the canonical orientation
4637        /// (production `edges_ordered_indices_borrowed` pays a `get_index_of`
4638        /// String hash per endpoint plus an `EdgeKey` pair hash instead).
4639        #[must_use]
4640        pub fn edges_ordered_indices_borrowed(&self) -> Vec<(usize, usize, usize, &AttrMap)> {
4641            let slot_to_pos = self.slot_positions();
4642            let mut ordered = Vec::with_capacity(self.edge_count);
4643            let mut seen: std::collections::HashSet<(usize, usize, usize)> =
4644                std::collections::HashSet::with_capacity(self.edge_count);
4645            for (u_name, u_slot) in self.node_order.iter() {
4646                for &v_slot in self.rows[u_slot].keys() {
4647                    let pair = (u_slot.min(v_slot), u_slot.max(v_slot));
4648                    let Some(bucket) = self.edges.get(&pair) else {
4649                        continue;
4650                    };
4651                    for (key, attrs) in bucket.iter() {
4652                        if !seen.insert((pair.0, pair.1, key)) {
4653                            continue;
4654                        }
4655                        let v_name = self
4656                            .node_order
4657                            .name(v_slot)
4658                            .expect("row references a live slot");
4659                        let (left_pos, right_pos) = if u_name.as_str() <= v_name {
4660                            (slot_to_pos[u_slot], slot_to_pos[v_slot])
4661                        } else {
4662                            (slot_to_pos[v_slot], slot_to_pos[u_slot])
4663                        };
4664                        ordered.push((left_pos, right_pos, key, attrs));
4665                    }
4666                }
4667            }
4668            ordered
4669        }
4670
4671        /// br-r37-c1-thp6w S10: `edges_ordered_borrowed` analog — the ONLY
4672        /// observed edge order. Walk = node insertion order, row order, bucket
4673        /// key order; each (pair, key) emitted once (first touch); emitted
4674        /// orientation = STRING-LEX canonical (`EdgeKey` semantics), derived
4675        /// from the names at emission since the internal pair key is
4676        /// slot-canonical. This is the snapshot/pickle orientation gate.
4677        #[cfg(test)]
4678        #[must_use]
4679        pub fn edges_ordered_names(&self) -> Vec<(&str, &str, usize)> {
4680            let mut ordered = Vec::with_capacity(self.edge_count);
4681            let mut seen: std::collections::HashSet<(usize, usize, usize)> =
4682                std::collections::HashSet::with_capacity(self.edge_count);
4683            for (u_name, u_slot) in self.node_order.iter() {
4684                for &v_slot in self.rows[u_slot].keys() {
4685                    let pair = (u_slot.min(v_slot), u_slot.max(v_slot));
4686                    let Some(bucket) = self.edges.get(&pair) else {
4687                        continue;
4688                    };
4689                    for key in bucket.key_order() {
4690                        if !seen.insert((pair.0, pair.1, key)) {
4691                            continue;
4692                        }
4693                        let v_name = self
4694                            .node_order
4695                            .name(v_slot)
4696                            .expect("row references a live slot");
4697                        let (left, right) = if u_name.as_str() <= v_name {
4698                            (u_name.as_str(), v_name)
4699                        } else {
4700                            (v_name, u_name.as_str())
4701                        };
4702                        ordered.push((left, right, key));
4703                    }
4704                }
4705            }
4706            ordered
4707        }
4708
4709        pub fn clear_edges(&mut self) {
4710            self.edges.clear();
4711            for row in &mut self.rows {
4712                row.clear();
4713            }
4714            self.edge_count = 0;
4715        }
4716
4717        pub fn remove_nodes_from<'a, I>(&mut self, nodes: I) -> (usize, usize)
4718        where
4719            I: IntoIterator<Item = &'a str>,
4720        {
4721            let remove_slots: rustc_hash::FxHashSet<usize> = nodes
4722                .into_iter()
4723                .filter_map(|name| self.node_order.get(name).copied())
4724                .collect();
4725            if remove_slots.is_empty() {
4726                return (0, 0);
4727            }
4728
4729            let old_edge_count = self.edge_count;
4730            self.edges.retain(|(left, right), _| {
4731                !remove_slots.contains(left) && !remove_slots.contains(right)
4732            });
4733            self.edge_count = self.edges.values().map(CompactBucket::len).sum();
4734
4735            for (slot, row) in self.rows.iter_mut().enumerate() {
4736                if remove_slots.contains(&slot) {
4737                    row.clear();
4738                } else {
4739                    row.retain(|neighbor, _| !remove_slots.contains(neighbor));
4740                }
4741            }
4742            self.node_order.remove_slots(&remove_slots);
4743            for &slot in &remove_slots {
4744                self.node_attrs[slot].clear();
4745            }
4746            (
4747                remove_slots.len(),
4748                old_edge_count.saturating_sub(self.edge_count),
4749            )
4750        }
4751
4752        /// br-r37-c1-thp6w S10: `MultiGraph::reorder_rows_for_nx_copy_walk`
4753        /// under slot storage. Two-phase exactly like the real one: ALL new
4754        /// row orders are computed against the PRE-reorder rows (early =
4755        /// earlier-position neighbors sorted by (pos(v), index of u within
4756        /// row v), then late in original order), then applied. Sort key
4757        /// (pos, idx, slot) is equivalent to the real (pos, idx, name)
4758        /// because pos is unique per neighbor.
4759        pub fn reorder_rows_for_nx_copy_walk(&mut self) {
4760            let pos = self.slot_positions();
4761            let order_slots: Vec<usize> = self.node_order.values().collect();
4762            let mut new_orders: Vec<(usize, Vec<usize>)> = Vec::with_capacity(order_slots.len());
4763            for &u_slot in &order_slots {
4764                let pu = pos[u_slot];
4765                let mut early: Vec<(usize, usize, usize)> = Vec::new();
4766                let mut late: Vec<usize> = Vec::new();
4767                for &v_slot in self.rows[u_slot].keys() {
4768                    let pv = pos[v_slot];
4769                    if pv < pu {
4770                        let idx = self.rows[v_slot]
4771                            .get_index_of(&u_slot)
4772                            .unwrap_or(usize::MAX);
4773                        early.push((pv, idx, v_slot));
4774                    } else {
4775                        late.push(v_slot);
4776                    }
4777                }
4778                early.sort_unstable();
4779                let mut order: Vec<usize> = early.into_iter().map(|(_, _, v)| v).collect();
4780                order.extend(late);
4781                new_orders.push((u_slot, order));
4782            }
4783            for (u_slot, order) in new_orders {
4784                let mut old = std::mem::take(&mut self.rows[u_slot]);
4785                let mut rebuilt = crate::FxIndexMap::with_capacity_and_hasher(
4786                    old.len(),
4787                    rustc_hash::FxBuildHasher,
4788                );
4789                for v_slot in order {
4790                    if let Some(cell) = old.shift_remove(&v_slot) {
4791                        rebuilt.insert(v_slot, cell);
4792                    }
4793                }
4794                debug_assert!(old.is_empty(), "copy-walk reorder dropped a cell");
4795                self.rows[u_slot] = rebuilt;
4796            }
4797        }
4798
4799        /// Apply arbitrary name-keyed row orders to the slot-keyed rows, with
4800        /// requested neighbors first and untouched neighbors retaining order.
4801        pub fn apply_row_orders(&mut self, orders: &[(String, Vec<String>)]) {
4802            for (node, order) in orders {
4803                let Some(&slot) = self.node_order.get(node.as_str()) else {
4804                    continue;
4805                };
4806                let mut old = std::mem::take(&mut self.rows[slot]);
4807                let mut new_row = crate::FxIndexMap::with_capacity_and_hasher(
4808                    old.len(),
4809                    rustc_hash::FxBuildHasher,
4810                );
4811                for v in order {
4812                    if let Some(&v_slot) = self.node_order.get(v.as_str())
4813                        && let Some((k, val)) = old.shift_remove_entry(&v_slot)
4814                    {
4815                        new_row.insert(k, val);
4816                    }
4817                }
4818                for (k, val) in old {
4819                    new_row.insert(k, val);
4820                }
4821                self.rows[slot] = new_row;
4822            }
4823        }
4824
4825        /// br-r37-c1-36v6r: order each row as `source`'s row for the node of
4826        /// the same name - see Graph::reorder_rows_like. A stable sort on the
4827        /// neighbour's position in the source row, so entries the source row
4828        /// lacks keep their relative order after the matched ones; keyed
4829        /// cells move with their neighbour. Whether any row moved.
4830        pub fn reorder_rows_like(&mut self, source: &Self) -> bool {
4831            let mut to_source = vec![usize::MAX; self.node_order.slot_count()];
4832            for (name, slot) in self.node_order.iter() {
4833                if let Some(&source_slot) = source.node_order.get(name) {
4834                    to_source[slot] = source_slot;
4835                }
4836            }
4837            let mut changed = false;
4838            for slot in self.node_order.values() {
4839                let row = &mut self.rows[slot];
4840                let Some(source_row) = source.rows.get(to_source[slot]) else {
4841                    continue;
4842                };
4843                if row.len() < 2 {
4844                    continue;
4845                }
4846                let rank = |w: &usize| {
4847                    source_row
4848                        .get_index_of(&to_source[*w])
4849                        .unwrap_or(usize::MAX)
4850                };
4851                if !row.keys().map(rank).is_sorted() {
4852                    row.sort_by_cached_key(|w, _| rank(w));
4853                    changed = true;
4854                }
4855            }
4856            changed
4857        }
4858    }
4859}
4860
4861#[derive(Debug, Clone)]
4862pub struct MultiGraph {
4863    mode: CompatibilityMode,
4864    revision: u64,
4865    storage: multigraph_storage::MgSlabStorage,
4866    runtime_policy: RuntimePolicy,
4867    edge_count: usize,
4868    /// br-r37-c1-thp6w slice 1: lazy revision-keyed integer adjacency (see `IntAdjCache`).
4869    int_adj_cache: IntAdjCache,
4870}
4871
4872/// Neighbor-name iterator over a stable-slot integer row.
4873pub struct MgNeighborNames<'a> {
4874    rows: indexmap::map::Keys<'a, usize, multigraph_storage::CompactKeys>,
4875    slot_names: &'a [Option<String>],
4876}
4877
4878impl<'a> Iterator for MgNeighborNames<'a> {
4879    type Item = &'a str;
4880
4881    fn next(&mut self) -> Option<Self::Item> {
4882        self.rows.next().map(|&slot| {
4883            self.slot_names[slot]
4884                .as_deref()
4885                .expect("row references a live slot")
4886        })
4887    }
4888}
4889
4890/// Zero-allocation parallel-key iterator over a stable-slot edge bucket.
4891pub struct MgEdgeKeys<'a>(multigraph_storage::CompactBucketKeys<'a>);
4892
4893impl<'a> Iterator for MgEdgeKeys<'a> {
4894    type Item = &'a usize;
4895
4896    fn next(&mut self) -> Option<Self::Item> {
4897        self.0.next()
4898    }
4899}
4900
4901impl MultiGraph {
4902    /// br-r37-c1-7dpyg: structural clone with a FRESH RuntimePolicy —
4903    /// see Graph::clone_with_fresh_policy.
4904    #[must_use]
4905    pub fn clone_with_fresh_policy(&self) -> Self {
4906        Self {
4907            mode: self.mode,
4908            revision: self.revision,
4909            storage: self.storage.clone(),
4910            runtime_policy: RuntimePolicy::new(self.mode),
4911            edge_count: self.edge_count,
4912            int_adj_cache: IntAdjCache::default(),
4913        }
4914    }
4915
4916    /// br-r37-c1-s0d4x: reorder every adjacency row into NetworkX's
4917    /// `MultiGraph.copy()` walk order — the multigraph counterpart of
4918    /// Graph::reorder_rows_for_nx_copy_walk (cells move wholesale). A
4919    /// pair's cells enter both rows at the first u-major touch: row u =
4920    /// earlier-position neighbors sorted by (pos(v), index of u within
4921    /// row v), then the rest (self-loops included) in original order.
4922    pub fn reorder_rows_for_nx_copy_walk(&mut self) {
4923        self.storage.reorder_rows_for_nx_copy_walk();
4924        *self
4925            .int_adj_cache
4926            .0
4927            .get_mut()
4928            .expect("int_adj_cache poisoned") = None;
4929    }
4930
4931    /// br-r37-c1-u3qyn: restore explicit adjacency row orders (pickle
4932    /// round-trip) — see Graph::apply_row_orders. Multigraph rows are
4933    /// keyed cells (neighbor -> key set); the cells move wholesale.
4934    pub fn apply_row_orders(&mut self, orders: &[(String, Vec<String>)]) {
4935        self.storage.apply_row_orders(orders);
4936        // br-r37-c1-thp6w slice 1: a pure ROW-ORDER change (MultiGraph.copy walk order,
4937        // pickle round-trip) does NOT bump `revision`, so the revision-keyed int-adjacency
4938        // memo would otherwise serve stale row order. Drop it explicitly here — this is the
4939        // only adjacency mutator that changes order without a content (revision) bump.
4940        *self
4941            .int_adj_cache
4942            .0
4943            .get_mut()
4944            .expect("int_adj_cache poisoned") = None;
4945    }
4946
4947    /// br-r37-c1-36v6r: order each row as `source`'s row for the node of the
4948    /// same name - a graph built from a filtered view of `source` by a
4949    /// copy-shaped walk, whose rows the view shows as `source`'s rows
4950    /// filtered (see Graph::reorder_rows_like). Cells move wholesale; like
4951    /// `apply_row_orders`, drops the int-adjacency memo rather than moving
4952    /// `revision`.
4953    pub fn reorder_rows_like(&mut self, source: &Self) {
4954        if self.storage.reorder_rows_like(&source.storage) {
4955            *self
4956                .int_adj_cache
4957                .0
4958                .get_mut()
4959                .expect("int_adj_cache poisoned") = None;
4960        }
4961    }
4962
4963    #[must_use]
4964    pub fn new(mode: CompatibilityMode) -> Self {
4965        Self {
4966            mode,
4967            revision: 0,
4968            storage: multigraph_storage::MgSlabStorage::new(),
4969            runtime_policy: RuntimePolicy::new(mode),
4970            edge_count: 0,
4971            int_adj_cache: IntAdjCache::default(),
4972        }
4973    }
4974
4975    #[must_use]
4976    pub fn with_runtime_policy(runtime_policy: RuntimePolicy) -> Self {
4977        let mode = runtime_policy.mode();
4978        Self {
4979            mode,
4980            revision: 0,
4981            storage: multigraph_storage::MgSlabStorage::new(),
4982            runtime_policy,
4983            edge_count: 0,
4984            int_adj_cache: IntAdjCache::default(),
4985        }
4986    }
4987
4988    fn slab_store(&self) -> &multigraph_storage::MgSlabStorage {
4989        &self.storage
4990    }
4991
4992    fn slab_store_mut(&mut self) -> &mut multigraph_storage::MgSlabStorage {
4993        &mut self.storage
4994    }
4995
4996    #[must_use]
4997    pub fn strict() -> Self {
4998        Self::new(CompatibilityMode::Strict)
4999    }
5000
5001    #[must_use]
5002    pub fn hardened() -> Self {
5003        Self::new(CompatibilityMode::Hardened)
5004    }
5005
5006    #[must_use]
5007    pub fn mode(&self) -> CompatibilityMode {
5008        self.mode
5009    }
5010
5011    #[must_use]
5012    pub fn node_count(&self) -> usize {
5013        self.slab_store().node_order.len()
5014    }
5015
5016    #[must_use]
5017    pub fn edge_count(&self) -> usize {
5018        self.edge_count
5019    }
5020
5021    #[must_use]
5022    pub fn number_of_selfloops(&self) -> usize {
5023        self.slab_store().number_of_selfloops()
5024    }
5025
5026    /// Return edge keys as Vec (needed by Python bindings).
5027    #[must_use]
5028    pub fn edge_keys(&self, left: &str, right: &str) -> Option<Vec<usize>> {
5029        self.slab_store().edge_keys(left, right)
5030    }
5031
5032    /// Return an iterator over keys for edges between left and right.
5033    pub fn edge_keys_iter(&self, left: &str, right: &str) -> Option<MgEdgeKeys<'_>> {
5034        self.slab_store()
5035            .edge_keys_iter(left, right)
5036            .map(MgEdgeKeys)
5037    }
5038
5039    #[must_use]
5040    pub fn revision(&self) -> u64 {
5041        self.revision
5042    }
5043
5044    #[must_use]
5045    pub fn has_node(&self, node: &str) -> bool {
5046        self.slab_store().has_node(node)
5047    }
5048
5049    #[must_use]
5050    pub fn has_edge(&self, left: &str, right: &str) -> bool {
5051        self.slab_store().has_edge(left, right)
5052    }
5053
5054    /// br-r37-c1-04z53 (cc): resolve `has_edge` straight from insertion-order
5055    /// indices, skipping the `i.to_string()` heap alloc the Python binding pays
5056    /// for int nodes. The names come from the node table by index (borrowed, no
5057    /// alloc); the caller (`PyMultiGraph::has_edge`) guards each index with
5058    /// `node_index_matches_int` so the identity `index == int-value` holds
5059    /// (any removal / remap that broke it fails the guard and falls through to
5060    /// the String path). Mirror of `Graph::has_edge_by_indices`.
5061    ///
5062    /// br-r37-c1-2ndmw: THIS TAKES A POSITION AND THE EDGE MAP IS KEYED BY SLOT,
5063    /// which is the whole subtlety. It used to bridge the two spaces by
5064    /// resolving both positions back to NAMES and calling the string
5065    /// `has_edge`, which re-hashes both node keys -- so this was O(node key
5066    /// length) while `Graph::has_edge_by_indices` was a genuine O(1)
5067    /// `contains_key`, and every caller taking the "index fast path"
5068    /// (`PyMultiGraph::has_edge`, the edge-view probe, the algorithms bulk
5069    /// paths) still paid for the key length it was trying to avoid.
5070    ///
5071    /// `slot_at_position` bridges the same gap in O(1) with no hashing, and
5072    /// substitutes an EQUAL value rather than changing behaviour:
5073    /// `MgSlabStorage::has_edge(l, r)` is itself defined as `node_order.get(l)`
5074    /// and `node_order.get(r)` followed by `has_edge_by_pair`, and the slot
5075    /// `get(name)` returns for an entry is the slot `get_index(position)`
5076    /// returns for that same entry.
5077    ///
5078    /// What must NEVER happen is passing the position straight to
5079    /// `has_edge_by_pair`: the spaces coincide only until a node is removed,
5080    /// after which that reports a real edge as absent. Pinned by
5081    /// `multigraph_node_slot_and_position_are_distinct_index_spaces`, and the
5082    /// equivalence pinned by
5083    /// `multigraph_has_edge_by_indices_matches_the_string_path_across_removals`.
5084    #[must_use]
5085    pub fn has_edge_by_indices(&self, li: usize, ri: usize) -> bool {
5086        let store = self.slab_store();
5087        match (store.slot_at_position(li), store.slot_at_position(ri)) {
5088            (Some(left), Some(right)) => store.has_edge_by_pair(left, right),
5089            _ => false,
5090        }
5091    }
5092
5093    /// br-r37-c1-s8dj1: attributes of a KEYED edge, addressed by node POSITION.
5094    ///
5095    /// The keyed sibling of [`Self::has_edge_by_indices`], and it exists for the
5096    /// same reason: `PyMultiGraph::has_edge` had an O(1) path only while `key`
5097    /// was `None`, so `(u, v) in G.edges` -- which passes key 0 -- paid two
5098    /// canonicalisations and several node-key hashes and grew 3.6x from 2- to
5099    /// 2000-character keys while the keyless form stayed flat.
5100    ///
5101    /// Takes POSITIONS and converts them to SLOTS, exactly as the keyless
5102    /// sibling does. Passing a position straight to the slot-keyed store
5103    /// reports a real edge as absent after any node removal -- see
5104    /// `multigraph_node_slot_and_position_are_distinct_index_spaces`.
5105    #[must_use]
5106    pub fn edge_attrs_by_indices(&self, li: usize, ri: usize, key: usize) -> Option<&AttrMap> {
5107        let store = self.slab_store();
5108        match (store.slot_at_position(li), store.slot_at_position(ri)) {
5109            (Some(left), Some(right)) => store.edge_attrs_by_pair(left, right, key),
5110            _ => None,
5111        }
5112    }
5113
5114    #[must_use]
5115    pub fn nodes_ordered(&self) -> Vec<&str> {
5116        self.slab_store()
5117            .node_order
5118            .keys()
5119            .map(String::as_str)
5120            .collect()
5121    }
5122
5123    /// Resolve a node through the insertion-ordered node table without
5124    /// rebuilding a query-local name-to-index map.
5125    #[must_use]
5126    #[inline]
5127    pub fn get_node_index(&self, node: &str) -> Option<usize> {
5128        self.slab_store().get_node_index(node)
5129    }
5130
5131    /// Resolve the current node name for an insertion-order index.
5132    #[must_use]
5133    #[inline]
5134    pub fn get_node_name(&self, index: usize) -> Option<&str> {
5135        self.slab_store().get_node_name(index)
5136    }
5137
5138    /// br-cc-nbunchbulk: int membership fast path for `_nbunch_present` — see the
5139    /// simple-Graph accessor.
5140    #[must_use]
5141    pub fn node_index_matches_int(&self, idx: usize) -> bool {
5142        self.slab_store().node_index_matches_int(idx)
5143    }
5144
5145    /// br-r37-c1-qaqog: every node's name is the decimal of its position, so
5146    /// an int key `k` IS the node at position `k` when `k < node_count()` and
5147    /// is absent otherwise - `Graph::node_names_are_positions`. O(V) on first
5148    /// use, kept current in O(1) after.
5149    #[must_use]
5150    pub fn node_names_are_positions(&self) -> bool {
5151        self.slab_store().node_order.names_are_positions()
5152    }
5153
5154    #[must_use]
5155    pub fn neighbors(&self, node: &str) -> Option<Vec<&str>> {
5156        self.slab_store().neighbors_by_name(node)
5157    }
5158
5159    pub fn neighbors_iter(&self, node: &str) -> Option<MgNeighborNames<'_>> {
5160        let slab = self.slab_store();
5161        let &slot = slab.node_order.get(node)?;
5162        Some(MgNeighborNames {
5163            rows: slab.rows[slot].keys(),
5164            slot_names: slab.node_order.names(),
5165        })
5166    }
5167
5168    /// br-r37-c1-thp6w slice 1: build integer adjacency from the authoritative stable-slot
5169    /// rows. Row `i` (node at insertion-index `i`) lists the node indices of that node's
5170    /// neighbors in adjacency-row order — i.e. exactly
5171    /// `[get_node_index(v) for v in neighbors_iter(node_i)]`. Distinct neighbors only
5172    /// (parallel-edge multiplicity lives in `edges`).
5173    fn build_int_adjacency(&self) -> Vec<Vec<usize>> {
5174        self.slab_store().build_int_adjacency()
5175    }
5176
5177    /// br-r37-c1-thp6w slice 1: run `f` with the integer adjacency (hash-free neighbor rows),
5178    /// built lazily from the stable-slot rows and memoized until the next mutation. The
5179    /// memo is keyed on `revision` (bumped by every content mutation) and cleared by
5180    /// `apply_row_orders` (the one order-only mutator), so the rows always match the current
5181    /// storage byte-for-byte. The closure form lets a whole traversal borrow the rows
5182    /// under one read lock without a per-node allocation. Foundation for the epoch's
5183    /// `neighbors_indices` traversal; no caller yet (infrastructure).
5184    pub fn with_int_adjacency<R>(&self, f: impl FnOnce(&[Vec<usize>]) -> R) -> R {
5185        {
5186            let guard = self.int_adj_cache.0.read().expect("int_adj_cache poisoned");
5187            if let Some((rev, adj)) = guard.as_ref()
5188                && *rev == self.revision
5189            {
5190                return f(adj);
5191            }
5192        }
5193        let adj = self.build_int_adjacency();
5194        let result = f(&adj);
5195        *self
5196            .int_adj_cache
5197            .0
5198            .write()
5199            .expect("int_adj_cache poisoned") = Some((self.revision, adj));
5200        result
5201    }
5202
5203    /// br-r37-c1-thp6w S4: advance a warm integer-adjacency memo across one
5204    /// single-edge mutation instead of invalidating it. The memo must still be
5205    /// keyed at `prev_revision` (the revision on entry to the mutating call);
5206    /// the delta mirrors the String-row edit exactly (new distinct neighbors
5207    /// append, emptied pairs shift-remove, new nodes push fresh rows), then the
5208    /// memo re-keys to the post-mutation revision. Every mutation path that
5209    /// does NOT call this leaves the memo keyed at a stale revision, so
5210    /// `with_int_adjacency` lazily rebuilds — unhandled sites stay exactly as
5211    /// safe as the invalidate-only scheme.
5212    fn advance_int_adj_memo(&mut self, prev_revision: u64, delta: IntAdjEdgeDelta<'_>) {
5213        let needs_indices = matches!(
5214            delta,
5215            IntAdjEdgeDelta::Add { pair_new: true, .. }
5216                | IntAdjEdgeDelta::Remove {
5217                    pair_gone: true,
5218                    ..
5219                }
5220        );
5221        let indices = if needs_indices {
5222            let (left, right) = match &delta {
5223                IntAdjEdgeDelta::Add { left, right, .. }
5224                | IntAdjEdgeDelta::Remove { left, right, .. } => (*left, *right),
5225            };
5226            match (
5227                self.storage.get_node_index(left),
5228                self.storage.get_node_index(right),
5229            ) {
5230                (Some(u), Some(v)) => Some((u, v)),
5231                _ => None,
5232            }
5233        } else {
5234            None
5235        };
5236        let Ok(slot) = self.int_adj_cache.0.get_mut() else {
5237            return;
5238        };
5239        if needs_indices && indices.is_none() {
5240            // Endpoint missing from the node table (should be unreachable for a
5241            // live edge): the delta cannot be mirrored, so drop the memo.
5242            *slot = None;
5243            return;
5244        }
5245        let Some((rev, rows)) = slot.as_mut() else {
5246            return;
5247        };
5248        if *rev != prev_revision {
5249            return;
5250        }
5251        let mut advanced = true;
5252        match delta {
5253            IntAdjEdgeDelta::Add {
5254                left,
5255                right,
5256                left_new,
5257                right_new,
5258                pair_new,
5259            } => {
5260                if left_new {
5261                    rows.push(Vec::new());
5262                }
5263                if right_new && left != right {
5264                    rows.push(Vec::new());
5265                }
5266                if pair_new && let Some((u, v)) = indices {
5267                    rows[u].push(v);
5268                    if u != v {
5269                        rows[v].push(u);
5270                    }
5271                }
5272            }
5273            IntAdjEdgeDelta::Remove { pair_gone, .. } => {
5274                if pair_gone && let Some((u, v)) = indices {
5275                    // Mirror `IndexMap::shift_remove` on the String row:
5276                    // order-preserving removal of the emptied neighbor cell.
5277                    if let Some(pu) = rows[u].iter().position(|&x| x == v) {
5278                        rows[u].remove(pu);
5279                        if u != v {
5280                            if let Some(pv) = rows[v].iter().position(|&x| x == u) {
5281                                rows[v].remove(pv);
5282                            } else {
5283                                advanced = false;
5284                            }
5285                        }
5286                    } else {
5287                        advanced = false;
5288                    }
5289                }
5290            }
5291        }
5292        if advanced {
5293            *rev = self.revision;
5294        } else {
5295            *slot = None;
5296        }
5297    }
5298
5299    #[must_use]
5300    pub fn edge_keys_vec(&self, left: &str, right: &str) -> Vec<usize> {
5301        self.slab_store().key_order(left, right)
5302    }
5303
5304    #[must_use]
5305    pub fn node_attrs(&self, node: &str) -> Option<&AttrMap> {
5306        self.slab_store().node_attrs_for(node)
5307    }
5308
5309    #[must_use]
5310    pub fn edge_attrs(&self, left: &str, right: &str, key: usize) -> Option<&AttrMap> {
5311        self.slab_store().edge_attrs(left, right, key)
5312    }
5313
5314    /// Iterator over the `AttrMap`s of every parallel edge between `left` and
5315    /// `right` (undirected: the single canonical bucket), yielded in the SAME
5316    /// order as `edge_keys` / `edge_keys_iter`. The adjacency `IndexSet<usize>`
5317    /// and this `edges` `IndexMap<usize, AttrMap>` are appended together on add
5318    /// and both `shift_remove`d on per-key remove (whole bucket dropped on
5319    /// node/endpoint removal), so their key order stays identical — callers that
5320    /// need adjacency-order iteration (e.g. the order-sensitive Neumaier weighted
5321    /// degree) may consume this directly instead of re-looking-up each key.
5322    /// Undirected sibling of `MultiDiGraph::edge_attr_values`.
5323    pub fn edge_attr_values(
5324        &self,
5325        left: &str,
5326        right: &str,
5327    ) -> Option<impl Iterator<Item = &AttrMap> + '_> {
5328        self.slab_store().edge_attr_values(left, right)
5329    }
5330
5331    #[must_use]
5332    pub fn evidence_ledger(&self) -> &EvidenceLedger {
5333        self.runtime_policy.decision_log()
5334    }
5335
5336    pub fn drain_decision_records(&mut self) -> Vec<DecisionRecord> {
5337        self.runtime_policy.drain_decision_records()
5338    }
5339
5340    /// Record a bounded recovery performed OUTSIDE the graph (e.g. a reader
5341    /// that skipped a malformed input line in hardened mode) in this graph's
5342    /// evidence ledger, so `decision_records()` reports it like a native one.
5343    pub fn record_external_recovery(
5344        &mut self,
5345        operation: impl Into<std::borrow::Cow<'static, str>>,
5346        rationale: impl Into<std::borrow::Cow<'static, str>>,
5347    ) {
5348        self.runtime_policy.record(
5349            operation,
5350            DecisionAction::FullValidate,
5351            0.7,
5352            rationale,
5353            Vec::new(),
5354        );
5355    }
5356
5357    #[must_use]
5358    pub fn runtime_policy(&self) -> &RuntimePolicy {
5359        &self.runtime_policy
5360    }
5361
5362    pub fn set_runtime_policy(&mut self, runtime_policy: RuntimePolicy) {
5363        self.mode = runtime_policy.mode();
5364        self.runtime_policy = runtime_policy;
5365    }
5366
5367    #[must_use]
5368    pub fn is_directed(&self) -> bool {
5369        false
5370    }
5371
5372    #[must_use]
5373    pub fn is_multigraph(&self) -> bool {
5374        true
5375    }
5376
5377    /// Return the degree of a node (total number of parallel edges incident).
5378    /// Self-loops contribute 2 to the degree each (NetworkX convention).
5379    #[must_use]
5380    pub fn degree(&self, node: &str) -> usize {
5381        self.slab_store().degree(node)
5382    }
5383
5384    /// br-r37-c1-mgisol (cc): native isolate detection for MultiGraph. A node
5385    /// is isolated iff it has no incident edges (empty/absent adjacency row);
5386    /// a self-loop keeps a node non-isolated (the loop records the node in its
5387    /// own row, matching nx's degree-2 self-loop convention). Yields nodes in
5388    /// insertion order — identical to the `G.degree()`-driven nx generator and
5389    /// to the old `multigraph_to_simple_graph` projection path, but WITHOUT the
5390    /// per-call O(V+E) simple-graph rebuild that dominated the binding.
5391    #[must_use]
5392    pub fn isolates(&self) -> Vec<String> {
5393        self.slab_store().isolates()
5394    }
5395
5396    /// br-r37-c1-mgisol (cc): isolate count without the simple-graph projection.
5397    #[must_use]
5398    pub fn number_of_isolates(&self) -> usize {
5399        self.slab_store().number_of_isolates()
5400    }
5401
5402    /// br-r37-c1-mgisol (cc): O(1) isolate predicate. Absent node -> false
5403    /// (mirrors `is_isolate(&Graph)`); the binding validates presence first.
5404    #[must_use]
5405    pub fn is_isolate(&self, node: &str) -> bool {
5406        self.slab_store().is_isolate(node)
5407    }
5408
5409    pub fn add_node(&mut self, node: impl AsRef<str>) -> bool {
5410        self.add_node_with_attrs(node, AttrMap::new())
5411    }
5412
5413    /// br-r37-c1-wa1b9: the storage half of [`Self::add_node_with_attrs`],
5414    /// without its ledger record. See the undirected `Graph` sibling.
5415    /// br-r37-c1-jc9e4: no owned key at all on this path. `set_node_attrs`
5416    /// already takes `&str` and owns internally only when it inserts, so the
5417    /// `let node = node.into();` this used to open with was allocating a
5418    /// `String` purely to hand out a borrow of it — waste on every call, not
5419    /// just the occupied ones.
5420    fn add_node_with_attrs_unrecorded(
5421        &mut self,
5422        node: impl AsRef<str>,
5423        attrs: AttrMap,
5424    ) -> (bool, bool, usize) {
5425        let (existed, changed, attrs_count) = self.storage.set_node_attrs(node.as_ref(), attrs);
5426        if changed {
5427            self.revision = self.revision.saturating_add(1);
5428        }
5429        (changed, existed, attrs_count)
5430    }
5431
5432    pub fn add_node_with_attrs(&mut self, node: impl AsRef<str>, attrs: AttrMap) -> bool {
5433        let (changed, existed, attrs_count) = self.add_node_with_attrs_unrecorded(node, attrs);
5434        self.record_decision(
5435            "add_node",
5436            0.0,
5437            false,
5438            vec![
5439                EvidenceTerm {
5440                    signal: "node_preexisting".into(),
5441                    observed_value: bool_evidence(existed),
5442                    log_likelihood_ratio: -3.0,
5443                },
5444                EvidenceTerm {
5445                    signal: "attrs_count".into(),
5446                    observed_value: count_evidence(attrs_count),
5447                    log_likelihood_ratio: -1.0,
5448                },
5449            ],
5450        );
5451        changed
5452    }
5453
5454    pub fn add_edge(
5455        &mut self,
5456        left: impl Into<String>,
5457        right: impl Into<String>,
5458    ) -> Result<usize, GraphError> {
5459        self.add_edge_impl(left, right, None, AttrMap::new())
5460    }
5461
5462    pub fn add_edge_with_attrs(
5463        &mut self,
5464        left: impl Into<String>,
5465        right: impl Into<String>,
5466        attrs: AttrMap,
5467    ) -> Result<usize, GraphError> {
5468        self.add_edge_impl(left, right, None, attrs)
5469    }
5470
5471    pub fn add_edge_with_key_and_attrs(
5472        &mut self,
5473        left: impl Into<String>,
5474        right: impl Into<String>,
5475        key: usize,
5476        attrs: AttrMap,
5477    ) -> Result<usize, GraphError> {
5478        self.add_edge_impl(left, right, Some(key), attrs)
5479    }
5480
5481    /// Insert one attribute-free edge for a pair known to have no existing
5482    /// parallel edge bucket. Returns `None` if the pair already exists.
5483    #[must_use]
5484    pub fn add_fresh_edge_unrecorded(
5485        &mut self,
5486        left: impl Into<String>,
5487        right: impl Into<String>,
5488    ) -> Option<usize> {
5489        self.add_fresh_edge_with_key_unrecorded(left, right, 0)
5490    }
5491
5492    /// Insert one attribute-free edge with an explicit key for a pair known to
5493    /// have no existing parallel edge bucket. Returns `None` if the pair
5494    /// already exists.
5495    #[must_use]
5496    pub fn add_fresh_edge_with_key_unrecorded(
5497        &mut self,
5498        left: impl Into<String>,
5499        right: impl Into<String>,
5500        key: usize,
5501    ) -> Option<usize> {
5502        let left = left.into();
5503        let right = right.into();
5504        self.storage.set_node_attrs(&left, AttrMap::new());
5505        if left != right {
5506            self.storage.set_node_attrs(&right, AttrMap::new());
5507        }
5508
5509        if self.storage.has_edge(&left, &right) {
5510            return None;
5511        }
5512        let inserted = self
5513            .storage
5514            .add_keyed_edge(&left, &right, key, AttrMap::new());
5515        debug_assert!(inserted, "fresh edge unexpectedly merged");
5516        self.edge_count += 1;
5517        self.revision = self.revision.saturating_add(1);
5518        Some(key)
5519    }
5520
5521    /// br-r37-c1-l5ve7: bulk node insert WITH attrs, one ledger record —
5522    /// MultiGraph mirror of MultiDiGraph::extend_nodes_with_attrs_unrecorded.
5523    /// Existing nodes merge attrs (extend).
5524    pub fn extend_nodes_with_attrs_unrecorded<I>(&mut self, nodes: I) -> usize
5525    where
5526        I: IntoIterator<Item = (String, AttrMap)>,
5527    {
5528        let mut inserted = 0usize;
5529        for (node, attrs) in nodes {
5530            let (existed, _, _) = self.storage.set_node_attrs(&node, attrs);
5531            if !existed {
5532                inserted += 1;
5533            }
5534        }
5535        if inserted > 0 {
5536            self.revision = self
5537                .revision
5538                .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
5539            self.record_decision(
5540                "extend_nodes_with_attrs_unrecorded",
5541                0.0,
5542                false,
5543                vec![EvidenceTerm {
5544                    signal: "batch_node_count".into(),
5545                    observed_value: count_evidence(inserted),
5546                    log_likelihood_ratio: -1.0,
5547                }],
5548            );
5549        }
5550        inserted
5551    }
5552
5553    /// Bulk node insert without attrs, one summary ledger record.
5554    pub fn extend_nodes_unrecorded<I, N>(&mut self, nodes: I) -> usize
5555    where
5556        I: IntoIterator<Item = N>,
5557        N: AsRef<str>,
5558    {
5559        let mut inserted = 0usize;
5560        for node in nodes {
5561            let (existed, _, _) = self.storage.set_node_attrs(node.as_ref(), AttrMap::new());
5562            if !existed {
5563                inserted += 1;
5564            }
5565        }
5566        if inserted > 0 {
5567            self.revision = self
5568                .revision
5569                .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
5570            self.record_decision(
5571                "extend_nodes_unrecorded",
5572                0.0,
5573                false,
5574                vec![EvidenceTerm {
5575                    signal: "batch_node_count".into(),
5576                    observed_value: count_evidence(inserted),
5577                    log_likelihood_ratio: -1.0,
5578                }],
5579            );
5580        }
5581        inserted
5582    }
5583
5584    /// br-r37-c1-l5ve7: bulk KEYED edge insert WITH attrs, one ledger
5585    /// record — MultiGraph mirror of
5586    /// MultiDiGraph::extend_keyed_edges_with_attrs_unrecorded
5587    /// (add_edge_impl pays TWO record_decision calls per edge). Endpoint
5588    /// nodes are created on demand; an existing (u, v, key) cell merges
5589    /// attrs (extend), matching add_edge_with_key_and_attrs.
5590    pub fn extend_keyed_edges_with_attrs_unrecorded<I>(&mut self, edges: I) -> usize
5591    where
5592        I: IntoIterator<Item = (String, String, usize, AttrMap)>,
5593    {
5594        let mut inserted = 0usize;
5595        for (left, right, key, attrs) in edges {
5596            if self.storage.add_keyed_edge(&left, &right, key, attrs) {
5597                self.edge_count += 1;
5598                inserted += 1;
5599            }
5600        }
5601        if inserted > 0 {
5602            self.revision = self
5603                .revision
5604                .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
5605            self.record_decision(
5606                "extend_keyed_edges_with_attrs_unrecorded",
5607                0.0,
5608                false,
5609                vec![EvidenceTerm {
5610                    signal: "batch_edge_count".into(),
5611                    observed_value: count_evidence(inserted),
5612                    log_likelihood_ratio: -1.0,
5613                }],
5614            );
5615        }
5616        inserted
5617    }
5618
5619    /// Bulk-load attribute-free keyed edges for a freshly-created integer
5620    /// prefix graph. The caller has already validated that node first-seen
5621    /// order is exactly `0..node_count` and that internal keys are sequential
5622    /// per undirected pair, so this builds the string-keyed MultiGraph state
5623    /// directly from indices instead of canonicalizing both endpoints for every
5624    /// edge in the Python boundary layer.
5625    #[must_use]
5626    pub fn extend_fresh_int_prefix_keyed_edges_unrecorded<I>(
5627        &mut self,
5628        node_count: usize,
5629        edges: I,
5630    ) -> usize
5631    where
5632        I: IntoIterator<Item = (usize, usize, usize)>,
5633    {
5634        debug_assert_eq!(self.node_count(), 0);
5635        debug_assert_eq!(self.edge_count(), 0);
5636
5637        let iterator = edges.into_iter();
5638        let (lower_bound, _) = iterator.size_hint();
5639
5640        let node_names = (0..node_count)
5641            .map(|node| node.to_string())
5642            .collect::<Vec<_>>();
5643        let mut fresh_slab = {
5644            use multigraph_storage::MgSlabStorage;
5645            let mut slab = MgSlabStorage::new();
5646            slab.node_order.reserve(node_count);
5647            slab.node_attrs.reserve(node_count);
5648            slab.rows.reserve(node_count);
5649            slab.edges.reserve(lower_bound);
5650            for name in &node_names {
5651                slab.node_order.insert(name.clone());
5652                slab.node_attrs.push(AttrMap::new());
5653                slab.rows.push(crate::FxIndexMap::default());
5654            }
5655            slab
5656        };
5657
5658        let mut inserted = 0usize;
5659        for (left_idx, right_idx, key) in iterator {
5660            debug_assert!(left_idx < node_names.len());
5661            debug_assert!(right_idx < node_names.len());
5662            {
5663                use multigraph_storage::{CompactBucket, CompactKeys};
5664                let pair = (left_idx.min(right_idx), left_idx.max(right_idx));
5665                match fresh_slab.edges.entry(pair) {
5666                    indexmap::map::Entry::Occupied(mut bucket) => {
5667                        let _ = bucket.get_mut().merge(key, AttrMap::new());
5668                    }
5669                    indexmap::map::Entry::Vacant(slot) => {
5670                        slot.insert(CompactBucket::One(key, AttrMap::new()));
5671                    }
5672                }
5673                match fresh_slab.rows[left_idx].entry(right_idx) {
5674                    indexmap::map::Entry::Occupied(mut cell) => {
5675                        let _ = cell.get_mut().insert(key);
5676                    }
5677                    indexmap::map::Entry::Vacant(slot) => {
5678                        slot.insert(CompactKeys::One(key));
5679                    }
5680                }
5681                if left_idx != right_idx {
5682                    match fresh_slab.rows[right_idx].entry(left_idx) {
5683                        indexmap::map::Entry::Occupied(mut cell) => {
5684                            let _ = cell.get_mut().insert(key);
5685                        }
5686                        indexmap::map::Entry::Vacant(slot) => {
5687                            slot.insert(CompactKeys::One(key));
5688                        }
5689                    }
5690                }
5691                fresh_slab.edge_count += 1;
5692            }
5693
5694            inserted += 1;
5695        }
5696        self.edge_count = fresh_slab.edge_count;
5697        if inserted > 0 || node_count > 0 {
5698            self.revision = self.revision.saturating_add(
5699                u64::try_from(inserted.saturating_add(node_count)).unwrap_or(u64::MAX),
5700            );
5701            self.record_decision(
5702                "extend_fresh_int_prefix_keyed_edges_unrecorded",
5703                0.0,
5704                false,
5705                vec![EvidenceTerm {
5706                    signal: "batch_edge_count".into(),
5707                    observed_value: count_evidence(inserted),
5708                    log_likelihood_ratio: -1.0,
5709                }],
5710            );
5711        }
5712        debug_assert_eq!(fresh_slab.edge_count, self.edge_count);
5713        self.storage = fresh_slab;
5714        inserted
5715    }
5716
5717    /// Bulk-load attributed keyed edges for a freshly-created integer-indexed
5718    /// graph. The caller has already assigned NetworkX-compatible node labels
5719    /// and per-undirected-pair keys, so this avoids endpoint string hashing in
5720    /// the Python boundary layer while preserving the normal MultiGraph storage
5721    /// layout.
5722    #[must_use]
5723    pub fn extend_fresh_index_keyed_edges_with_attrs_unrecorded<I, N>(
5724        &mut self,
5725        nodes: N,
5726        edges: I,
5727    ) -> usize
5728    where
5729        I: IntoIterator<Item = (usize, usize, usize, AttrMap)>,
5730        N: IntoIterator<Item = String>,
5731    {
5732        if self.node_count() != 0 || self.edge_count() != 0 {
5733            return 0;
5734        }
5735
5736        let node_labels: Vec<String> = nodes.into_iter().collect();
5737        let mut fresh_slab = {
5738            use multigraph_storage::MgSlabStorage;
5739            let mut slab = MgSlabStorage::new();
5740            slab.node_order.reserve(node_labels.len());
5741            slab.node_attrs.reserve(node_labels.len());
5742            slab.rows.reserve(node_labels.len());
5743            for name in &node_labels {
5744                slab.node_order.insert(name.clone());
5745                slab.node_attrs.push(AttrMap::new());
5746                slab.rows.push(crate::FxIndexMap::default());
5747            }
5748            slab
5749        };
5750
5751        let node_count = node_labels.len();
5752        let mut inserted = 0usize;
5753        for (left_idx, right_idx, key, attrs) in edges {
5754            if node_labels.get(left_idx).is_none() {
5755                continue;
5756            }
5757            if node_labels.get(right_idx).is_none() {
5758                continue;
5759            }
5760            {
5761                use multigraph_storage::{CompactBucket, CompactKeys};
5762                let pair = (left_idx.min(right_idx), left_idx.max(right_idx));
5763                let is_new = match fresh_slab.edges.entry(pair) {
5764                    indexmap::map::Entry::Occupied(mut bucket) => {
5765                        bucket.get_mut().merge(key, attrs)
5766                    }
5767                    indexmap::map::Entry::Vacant(slot) => {
5768                        slot.insert(CompactBucket::One(key, attrs));
5769                        true
5770                    }
5771                };
5772                if is_new {
5773                    fresh_slab.edge_count += 1;
5774                    inserted += 1;
5775                }
5776                match fresh_slab.rows[left_idx].entry(right_idx) {
5777                    indexmap::map::Entry::Occupied(mut cell) => {
5778                        let _ = cell.get_mut().insert(key);
5779                    }
5780                    indexmap::map::Entry::Vacant(slot) => {
5781                        slot.insert(CompactKeys::One(key));
5782                    }
5783                }
5784                if left_idx != right_idx {
5785                    match fresh_slab.rows[right_idx].entry(left_idx) {
5786                        indexmap::map::Entry::Occupied(mut cell) => {
5787                            let _ = cell.get_mut().insert(key);
5788                        }
5789                        indexmap::map::Entry::Vacant(slot) => {
5790                            slot.insert(CompactKeys::One(key));
5791                        }
5792                    }
5793                }
5794            }
5795        }
5796
5797        self.edge_count = fresh_slab.edge_count;
5798        if node_count > 0 || inserted > 0 {
5799            self.revision = self.revision.saturating_add(
5800                u64::try_from(node_count.saturating_add(inserted)).unwrap_or(u64::MAX),
5801            );
5802            self.record_decision(
5803                "extend_fresh_index_keyed_edges_with_attrs_unrecorded",
5804                0.0,
5805                false,
5806                vec![
5807                    EvidenceTerm {
5808                        signal: "batch_node_count".into(),
5809                        observed_value: count_evidence(node_count),
5810                        log_likelihood_ratio: -1.0,
5811                    },
5812                    EvidenceTerm {
5813                        signal: "batch_edge_count".into(),
5814                        observed_value: count_evidence(inserted),
5815                        log_likelihood_ratio: -1.0,
5816                    },
5817                ],
5818            );
5819        }
5820        debug_assert_eq!(fresh_slab.edge_count, self.edge_count);
5821        self.storage = fresh_slab;
5822        inserted
5823    }
5824
5825    fn add_edge_impl(
5826        &mut self,
5827        left: impl Into<String>,
5828        right: impl Into<String>,
5829        explicit_key: Option<usize>,
5830        attrs: AttrMap,
5831    ) -> Result<usize, GraphError> {
5832        let left = left.into();
5833        let right = right.into();
5834        // br-r37-c1-thp6w S4: revision on entry — a warm int-adjacency memo
5835        // keyed here is advanced across this mutation instead of invalidated.
5836        let prev_revision = self.revision;
5837
5838        let unknown_feature = attrs
5839            .keys()
5840            .any(|key| key.starts_with("__fnx_incompatible"));
5841        let incompatibility_probability = if unknown_feature {
5842            1.0
5843        } else if left == right {
5844            0.22
5845        } else {
5846            0.08
5847        };
5848
5849        // br-r37-c1-eo88t: the gate decision earns a ledger record only when it
5850        // REFUSES. A refusal returns from here, so the outcome record at the end
5851        // of this function never runs and the gate record is the whole audit
5852        // trail for the rejected mutation. On the allow path the outcome record
5853        // carries the same operation/mode/action/incompatibility_probability
5854        // plus strictly richer evidence, so recording the gate as well wrote two
5855        // near-identical records for every edge on the hottest mutation path.
5856        let action = self
5857            .runtime_policy
5858            .action_for(incompatibility_probability, unknown_feature);
5859
5860        if action == DecisionAction::FailClosed || action == DecisionAction::FullValidate {
5861            self.record_decision(
5862                "add_edge",
5863                incompatibility_probability,
5864                unknown_feature,
5865                vec![EvidenceTerm {
5866                    signal: "unknown_incompatible_feature".into(),
5867                    observed_value: bool_evidence(unknown_feature),
5868                    log_likelihood_ratio: 12.0,
5869                }],
5870            );
5871            return Err(GraphError::FailClosed {
5872                operation: "add_edge",
5873                reason: "incompatible edge metadata".to_owned(),
5874            });
5875        }
5876
5877        // br-r37-c1-wa1b9: internal autocreation files no record of its own —
5878        // see the undirected `Graph` sibling for the ruling.
5879        let mut left_autocreated = false;
5880        if !self.has_node(&left) {
5881            let _ = self.add_node_with_attrs_unrecorded(left.clone(), AttrMap::new());
5882            left_autocreated = true;
5883        }
5884        let mut right_autocreated = false;
5885        if left == right {
5886            right_autocreated = left_autocreated;
5887        } else if !self.has_node(&right) {
5888            let _ = self.add_node_with_attrs_unrecorded(right.clone(), AttrMap::new());
5889            right_autocreated = true;
5890        }
5891
5892        // br-r37-c1-4c29a: resolve each endpoint to its slot ONCE. Both nodes
5893        // are guaranteed present by the autocreation above, and the six storage
5894        // calls below used to re-resolve BOTH endpoint strings independently —
5895        // `IndexMap<String, usize>::get_index_of` was 17.39% of the MultiGraph
5896        // build profile, the largest entry, ahead of the allocator. The
5897        // `_by_pair` variants take the slots and are otherwise identical.
5898        let left_slot = self
5899            .slab_store()
5900            .node_slot(&left)
5901            .expect("autocreated above");
5902        let right_slot = if left == right {
5903            left_slot
5904        } else {
5905            self.slab_store()
5906                .node_slot(&right)
5907                .expect("autocreated above")
5908        };
5909
5910        let key = explicit_key.unwrap_or_else(|| {
5911            self.slab_store()
5912                .next_edge_key_by_pair(left_slot, right_slot)
5913        });
5914        let pair_was_present = self.slab_store().has_edge_by_pair(left_slot, right_slot);
5915        let changed = self
5916            .slab_store()
5917            .edge_attrs_by_pair(left_slot, right_slot, key)
5918            .is_none_or(|edge_attrs| {
5919                attrs
5920                    .iter()
5921                    .any(|(attr_key, value)| edge_attrs.get(attr_key) != Some(value))
5922            });
5923        let inserted = self
5924            .slab_store_mut()
5925            .add_keyed_edge_by_pair(left_slot, right_slot, key, attrs);
5926        if inserted {
5927            self.edge_count += 1;
5928        }
5929        let edge_attr_count = self
5930            .slab_store()
5931            .edge_attr_count_by_pair(left_slot, right_slot);
5932        if changed {
5933            self.revision = self.revision.saturating_add(1);
5934        }
5935        if self.revision != prev_revision {
5936            self.advance_int_adj_memo(
5937                prev_revision,
5938                IntAdjEdgeDelta::Add {
5939                    left: &left,
5940                    right: &right,
5941                    left_new: left_autocreated,
5942                    right_new: right_autocreated,
5943                    pair_new: !pair_was_present,
5944                },
5945            );
5946        }
5947
5948        self.record_decision(
5949            "add_edge",
5950            incompatibility_probability,
5951            unknown_feature,
5952            vec![
5953                EvidenceTerm {
5954                    signal: "edge_key".into(),
5955                    observed_value: key.to_string().into(),
5956                    log_likelihood_ratio: -2.0,
5957                },
5958                EvidenceTerm {
5959                    signal: "edge_attr_count".into(),
5960                    observed_value: count_evidence(edge_attr_count),
5961                    log_likelihood_ratio: -2.0,
5962                },
5963                EvidenceTerm {
5964                    signal: "left_autocreated".into(),
5965                    observed_value: bool_evidence(left_autocreated),
5966                    log_likelihood_ratio: -1.25,
5967                },
5968                EvidenceTerm {
5969                    signal: "right_autocreated".into(),
5970                    observed_value: bool_evidence(right_autocreated),
5971                    log_likelihood_ratio: -1.25,
5972                },
5973            ],
5974        );
5975
5976        Ok(key)
5977    }
5978
5979    /// br-r37-c1-sjf4t: overwrite the attribute map for an existing
5980    /// (u, v, key) edge. Returns whether the edge existed.
5981    pub fn replace_edge_attrs(
5982        &mut self,
5983        left: &str,
5984        right: &str,
5985        key: usize,
5986        attrs: AttrMap,
5987    ) -> bool {
5988        let Some(existing) = self.slab_store().edge_attrs(left, right, key) else {
5989            return false;
5990        };
5991        let changed = *existing != attrs;
5992        if changed {
5993            let replaced = self
5994                .slab_store_mut()
5995                .replace_edge_attrs(left, right, key, attrs);
5996            debug_assert!(replaced, "slab edge disappeared during replacement");
5997            self.revision = self.revision.saturating_add(1);
5998        }
5999        true
6000    }
6001
6002    /// br-r37-c1-sjf4t: overwrite the attribute map for an existing node.
6003    pub fn replace_node_attrs(&mut self, node: &str, attrs: AttrMap) -> bool {
6004        let Some(existing) = self.slab_store().node_attrs_for(node) else {
6005            return false;
6006        };
6007        let changed = *existing != attrs;
6008        if changed {
6009            self.slab_store_mut().replace_node_attrs(node, attrs);
6010            self.revision = self.revision.saturating_add(1);
6011        }
6012        true
6013    }
6014
6015    pub fn remove_edge(&mut self, left: &str, right: &str, key: Option<usize>) -> bool {
6016        // br-r37-c1-thp6w S4: revision on entry — a warm int-adjacency memo
6017        // keyed here is advanced across this mutation instead of invalidated.
6018        let prev_revision = self.revision;
6019        let removal_key = key.or_else(|| {
6020            self.slab_store()
6021                .edge_keys_iter(left, right)
6022                .and_then(|keys| keys.last().copied())
6023        });
6024
6025        let Some(removal_key) = removal_key else {
6026            return false;
6027        };
6028
6029        if !self
6030            .slab_store_mut()
6031            .remove_edge(left, right, Some(removal_key))
6032        {
6033            return false;
6034        }
6035        self.edge_count -= 1;
6036        let pair_gone = !self.slab_store().has_edge(left, right);
6037        self.revision = self.revision.saturating_add(1);
6038        self.advance_int_adj_memo(
6039            prev_revision,
6040            IntAdjEdgeDelta::Remove {
6041                left,
6042                right,
6043                pair_gone,
6044            },
6045        );
6046        true
6047    }
6048
6049    pub fn clear_edges(&mut self) {
6050        if self.edge_count == 0 {
6051            return;
6052        }
6053
6054        self.slab_store_mut().clear_edges();
6055        self.edge_count = 0;
6056        self.revision = self.revision.saturating_add(1);
6057    }
6058
6059    pub fn remove_node(&mut self, node: &str) -> bool {
6060        if !self.has_node(node) {
6061            return false;
6062        }
6063
6064        let removed = self.slab_store_mut().remove_node(node);
6065        debug_assert!(removed, "node disappeared during remove_node");
6066        self.edge_count = self.slab_store().edge_count;
6067        self.revision = self.revision.saturating_add(1);
6068        true
6069    }
6070
6071    /// br-r37-c1-mgrnf: batch node removal — one O(|V|+|E|) `retain` pass,
6072    /// matching the simple-`Graph` batch (`Graph::remove_nodes_from`) that the
6073    /// directed types already carry. (`remove_node` itself is O(degree) since
6074    /// yr2oc.1, so this pays off only for a large share of the graph.)
6075    /// Returns `(removed_node_count, removed_edge_instance_count)`.
6076    pub fn remove_nodes_from<'a, I>(&mut self, nodes: I) -> (usize, usize)
6077    where
6078        I: IntoIterator<Item = &'a str>,
6079    {
6080        let (removed_nodes, removed_edges) = self.slab_store_mut().remove_nodes_from(nodes);
6081        if removed_nodes == 0 {
6082            return (0, 0);
6083        }
6084        self.edge_count = self.slab_store().edge_count;
6085        self.revision = self
6086            .revision
6087            .saturating_add(u64::try_from(removed_nodes).unwrap_or(u64::MAX));
6088        (removed_nodes, removed_edges)
6089    }
6090
6091    #[must_use]
6092    pub fn edges_ordered(&self) -> Vec<MultiEdgeSnapshot> {
6093        self.slab_store()
6094            .edges_ordered_borrowed()
6095            .into_iter()
6096            .map(|(left, right, key, attrs)| MultiEdgeSnapshot {
6097                left: left.to_owned(),
6098                right: right.to_owned(),
6099                key,
6100                attrs: attrs.clone(),
6101            })
6102            .collect()
6103    }
6104
6105    #[must_use]
6106    pub fn edges_ordered_borrowed(&self) -> Vec<(&str, &str, usize, &AttrMap)> {
6107        self.slab_store().edges_ordered_borrowed()
6108    }
6109
6110    /// br-r37-c1-wsize (cc): integer `size(weight)` from the store — the
6111    /// multigraph twin of `Graph::weighted_size_int`. Each parallel edge is one
6112    /// bucket entry in `self.edges` and contributes its weight once (size halves
6113    /// degree's double count); a self-loop key is one stored edge counted once.
6114    /// Returns `None` on any non-integer weight (caller uses the exact
6115    /// float/PyObject degree path); missing weight defaults to nx's int `1`.
6116    #[must_use]
6117    pub fn weighted_size_int(&self, weight: &str) -> Option<i128> {
6118        self.slab_store().weighted_size_int(weight)
6119    }
6120
6121    #[must_use]
6122    pub fn edges_ordered_indices_borrowed(&self) -> Vec<(usize, usize, usize, &AttrMap)> {
6123        self.slab_store().edges_ordered_indices_borrowed()
6124    }
6125
6126    #[must_use]
6127    pub fn snapshot(&self) -> MultiGraphSnapshot {
6128        self.slab_store().snapshot(self.mode)
6129    }
6130
6131    fn record_decision(
6132        &mut self,
6133        operation: &'static str,
6134        incompatibility_probability: f64,
6135        unknown_incompatible_feature: bool,
6136        evidence: Vec<EvidenceTerm>,
6137    ) -> DecisionAction {
6138        let action = self
6139            .runtime_policy
6140            .action_for(incompatibility_probability, unknown_incompatible_feature);
6141        self.runtime_policy.record(
6142            operation,
6143            action,
6144            incompatibility_probability,
6145            "argmin expected loss over {allow,full_validate,fail_closed}",
6146            evidence,
6147        );
6148        action
6149    }
6150}
6151
6152#[cfg(test)]
6153mod tests {
6154    /// br-r37-c1-19ngg: the compact key cells are Fx-hashed; their order must
6155    /// stay insertion order (a pair's key order is what edges(keys=True) and
6156    /// every keydict iterate) through the One -> Many promotion, a repeated
6157    /// key, removals and re-insertion - never numeric or hash order.
6158    #[test]
6159    fn compact_key_cells_keep_insertion_order() {
6160        use super::multigraph_storage::{CompactBucket, CompactKeys};
6161
6162        let inserted = [7_usize, 2, 9, 0, 5, 11, 3];
6163        let mut keys = CompactKeys::One(inserted[0]);
6164        let mut bucket = CompactBucket::One(inserted[0], AttrMap::new());
6165        for &key in &inserted[1..] {
6166            let mut attrs = AttrMap::new();
6167            attrs.insert(
6168                "k".to_owned(),
6169                CgseValue::Int(i64::try_from(key).expect("small key")),
6170            );
6171            assert!(keys.insert(key));
6172            assert!(bucket.merge(key, attrs));
6173        }
6174        assert_eq!(keys.order(), inserted);
6175        assert_eq!(bucket.key_order(), inserted);
6176
6177        // A repeated key is not new and moves nothing; its attrs merge in place.
6178        let mut more = AttrMap::new();
6179        more.insert("extra".to_owned(), CgseValue::Bool(true));
6180        assert!(!keys.insert(9));
6181        assert!(!bucket.merge(9, more));
6182        assert_eq!(keys.order(), inserted);
6183        assert_eq!(bucket.key_order(), inserted);
6184        let merged = bucket.attrs_for(9).expect("key 9 is held");
6185        assert_eq!(merged.get("k"), Some(&CgseValue::Int(9)));
6186        assert_eq!(merged.get("extra"), Some(&CgseValue::Bool(true)));
6187
6188        // Removal keeps the rest in order; a key re-inserted goes to the end.
6189        assert_eq!(keys.shift_remove(0), (true, false));
6190        assert_eq!(bucket.shift_remove(0), (true, false));
6191        assert!(keys.insert(0));
6192        assert!(bucket.merge(0, AttrMap::new()));
6193        assert_eq!(keys.order(), [7, 2, 9, 5, 11, 3, 0]);
6194        assert_eq!(bucket.key_order(), [7, 2, 9, 5, 11, 3, 0]);
6195    }
6196
6197    /// br-r37-c1-2ndmw: `has_edge_by_indices` must agree with the string path
6198    /// for EVERY pair, including after removals move the positions away from the
6199    /// slots.
6200    ///
6201    /// The implementation no longer resolves positions back to names; it
6202    /// converts position to slot and probes the slot-keyed edge map directly.
6203    /// That is only correct if the slot at a position is the slot the name at
6204    /// that position maps to, which is an IndexMap invariant rather than
6205    /// something this crate enforces — so it is asserted here exhaustively
6206    /// rather than assumed, against the string lookup as the oracle.
6207    #[test]
6208    fn multigraph_has_edge_by_indices_matches_the_string_path_across_removals() {
6209        let mut g = MultiGraph::new(CompatibilityMode::Strict);
6210        let names: Vec<String> = (0..8).map(|i| format!("n{i}")).collect();
6211        for name in &names {
6212            g.add_node(name.as_str());
6213        }
6214        for (a, b) in [(0, 1), (2, 3), (3, 5), (5, 7), (0, 7), (4, 4)] {
6215            g.add_edge(names[a].as_str(), names[b].as_str()).unwrap();
6216        }
6217
6218        // Removals chosen to shift positions out from under the slots, plus a
6219        // re-add so a reused slot is exercised too.
6220        for step in 0..4 {
6221            if step == 1 {
6222                g.remove_node("n0");
6223            }
6224            if step == 2 {
6225                g.remove_node("n4");
6226            }
6227            if step == 3 {
6228                g.add_node("n8");
6229                g.add_edge("n8", "n3").unwrap();
6230            }
6231            let live: Vec<String> = g.nodes_ordered().iter().map(|s| (*s).to_string()).collect();
6232            for (li, left) in live.iter().enumerate() {
6233                for (ri, right) in live.iter().enumerate() {
6234                    let by_index = g.has_edge_by_indices(li, ri);
6235                    let by_name = g.has_edge(left.as_str(), right.as_str());
6236                    assert_eq!(
6237                        by_index, by_name,
6238                        "step {step}: has_edge_by_indices({li}, {ri}) disagreed with \
6239                         has_edge({left:?}, {right:?})"
6240                    );
6241                }
6242            }
6243            // Out-of-range positions must answer false, not panic.
6244            let n = live.len();
6245            assert!(!g.has_edge_by_indices(n, 0));
6246            assert!(!g.has_edge_by_indices(0, n + 50));
6247        }
6248    }
6249
6250    /// br-r37-c1-2ndmw: THE SLOT AND THE POSITION ARE DIFFERENT INDEX SPACES,
6251    /// and confusing them is a silent wrong-answer bug rather than a type error.
6252    ///
6253    /// `MultiGraph::get_node_index` returns `node_order.get_index_of(name)` — a
6254    /// POSITION in the IndexMap. `MgSlabStorage::has_edge_by_pair` is keyed on
6255    /// `node_order.get(name)` — the stored SLOT. On a graph that has never had a
6256    /// node removed these are equal, which is precisely what makes the confusion
6257    /// dangerous: every fresh-graph test passes and the divergence only appears
6258    /// after a removal, as a `false` from an edge that exists.
6259    ///
6260    /// WHY THIS TEST EXISTS. `MultiGraph::has_edge_by_indices` reads like an
6261    /// O(1) index fast path — it is named like one and documented as a "Mirror
6262    /// of `Graph::has_edge_by_indices`", which genuinely is one
6263    /// (`edges.contains_key(&canon_pair)`). It is not. It resolves both indices
6264    /// back to NAMES and calls the string `has_edge`, so it is O(node key
6265    /// length). Anyone optimising the multigraph row probe will find the
6266    /// slot-keyed `has_edge_by_pair`, reach for a cached `get_node_index`, and
6267    /// ship a read that returns the wrong answer after any node removal. That
6268    /// was attempted on this pane and stopped by this test.
6269    ///
6270    /// The assertions below pin all three facts: the positions move, the
6271    /// position-keyed lookup is WRONG, and the slot-keyed lookup is RIGHT.
6272    #[test]
6273    fn multigraph_node_slot_and_position_are_distinct_index_spaces() {
6274        let mut g = MultiGraph::new(CompatibilityMode::Strict);
6275        for name in ["n0", "n1", "n2", "n3"] {
6276            g.add_node(name);
6277        }
6278        g.add_edge("n2", "n3").unwrap();
6279        assert!(g.has_edge("n2", "n3"));
6280
6281        // Before any removal the two spaces agree, which is the trap.
6282        let pos_before = (g.get_node_index("n2"), g.get_node_index("n3"));
6283        let slot_before = (
6284            g.slab_store().node_slot("n2"),
6285            g.slab_store().node_slot("n3"),
6286        );
6287        assert_eq!(
6288            pos_before, slot_before,
6289            "on a graph with no removals the position and the slot coincide — \
6290             this is why confusing them survives fresh-graph testing"
6291        );
6292
6293        g.remove_node("n0");
6294
6295        let (Some(p2), Some(p3)) = (g.get_node_index("n2"), g.get_node_index("n3")) else {
6296            panic!("nodes vanished after removing an unrelated node");
6297        };
6298        let (Some(s2), Some(s3)) = (
6299            g.slab_store().node_slot("n2"),
6300            g.slab_store().node_slot("n3"),
6301        ) else {
6302            panic!("slots vanished after removing an unrelated node");
6303        };
6304
6305        // The edge is untouched: only an unrelated node was removed.
6306        assert!(
6307            g.has_edge("n2", "n3"),
6308            "removing an unrelated node must not remove this edge"
6309        );
6310
6311        // The positions MOVED and the slots did not follow them.
6312        assert_ne!(
6313            (p2, p3),
6314            (s2, s3),
6315            "after a removal the position and slot spaces must diverge; if this \
6316             ever holds, re-derive whether the fast path below is now safe"
6317        );
6318
6319        // THE TRAP: position indices through the slot-keyed edge map are WRONG.
6320        assert!(
6321            !g.slab_store().has_edge_by_pair(p2, p3),
6322            "position indices happened to answer correctly here — that is luck, \
6323             not a contract; do not take it as licence to feed get_node_index() \
6324             into has_edge_by_pair()"
6325        );
6326
6327        // THE CORRECT SPACE: slots answer correctly.
6328        assert!(
6329            g.slab_store().has_edge_by_pair(s2, s3),
6330            "slot indices must agree with the string lookup"
6331        );
6332    }
6333
6334    /// br-r37-c1-f3i50: two helpers on the SAME struct use DIFFERENT index
6335    /// spaces, so you cannot infer the space from where a function lives.
6336    ///
6337    /// The test above pins that `MgSlabStorage::has_edge_by_pair` is SLOT-keyed.
6338    /// `MgSlabStorage::node_index_matches_int` sits in the same `impl`, is named
6339    /// in the same style, and is POSITION-keyed: it calls
6340    /// `node_order.get_index(index)`, which is the IndexMap POSITION, not the
6341    /// stored slot. So it composes correctly with `MultiGraph::get_node_index`
6342    /// and would be WRONG to pair with `has_edge_by_pair`.
6343    ///
6344    /// I got this backwards first: the helper delegates to `slab_store()`, which
6345    /// reads like slot territory, and I wrote the opposite assertion and watched
6346    /// it fail. Reading the body is the only way to know. That is exactly why
6347    /// this is pinned rather than left to the next reader's inference.
6348    ///
6349    /// SECOND FACT, and the one that bites: once a node is removed, name and
6350    /// position stop agreeing for EVERY surviving node, so this predicate goes
6351    /// uniformly false on a graph whose int nodes are all still present. A
6352    /// caller treating it as "is int N in the graph" would answer no for nodes
6353    /// that plainly exist.
6354    #[test]
6355    fn multigraph_node_index_matches_int_is_position_keyed_not_slot_keyed() {
6356        let mut g = MultiGraph::new(CompatibilityMode::Strict);
6357        for name in ["0", "1", "2", "3"] {
6358            g.add_node(name);
6359        }
6360
6361        // Fresh graph: name, position and slot all coincide — the trap state.
6362        for idx in 0..4 {
6363            assert!(
6364                g.node_index_matches_int(idx),
6365                "on a fresh graph int {idx} sits at position {idx}"
6366            );
6367            assert_eq!(g.get_node_index(&idx.to_string()), Some(idx));
6368            assert_eq!(g.slab_store().node_slot(&idx.to_string()), Some(idx));
6369        }
6370
6371        g.remove_node("0");
6372
6373        // Node "3" survives; its SLOT is unchanged and its POSITION has moved.
6374        let slot_3 = g.slab_store().node_slot("3").expect("node 3 survives");
6375        let pos_3 = g.get_node_index("3").expect("node 3 survives");
6376        assert_eq!(slot_3, 3, "slots are stable across removals");
6377        assert_eq!(pos_3, 2, "positions compact after a removal");
6378        assert_ne!(slot_3, pos_3, "the two spaces have diverged");
6379
6380        // POSITION-KEYED: asking with the out-of-range slot is simply absent,
6381        // which is what proves the argument is a position and not a slot — a
6382        // slot-keyed predicate would have answered for the live node 3.
6383        assert!(
6384            !g.node_index_matches_int(slot_3),
6385            "slot {slot_3} is past the end of a 3-node order, so a position-keyed              predicate reports absent; if this ever answers true the argument has              become slot-keyed and every caller must be re-derived"
6386        );
6387
6388        // And the predicate is now uniformly false, because no surviving name
6389        // equals its position — even though all three nodes are present.
6390        for idx in 0..3 {
6391            assert!(
6392                !g.node_index_matches_int(idx),
6393                "after the removal no name equals its position, so int {idx} must                  not report as sitting at position {idx}"
6394            );
6395            assert!(
6396                g.has_node(&(idx + 1).to_string()),
6397                "...while the node itself is still very much present"
6398            );
6399        }
6400    }
6401
6402    use super::{AttrMap, Graph, GraphError, MultiGraph};
6403    use fnx_runtime::{CgseValue, CompatibilityMode, DecisionAction, DecisionRecord};
6404    use proptest::prelude::*;
6405    use std::collections::BTreeSet;
6406
6407    /// br-r37-c1-addedgenewedge parity: the new_edge-gated adjacency push must never create a
6408    /// duplicate adjacency entry, even under duplicate edges / self-loops / edges added in both
6409    /// orientations. (The old `!adj_indices[..].contains(..)` guard prevented dups; the O(1)
6410    /// `new_edge` flag must too, since adj-membership ⟺ edge existence.)
6411    #[test]
6412    fn add_edge_newedge_no_duplicate_adjacency() {
6413        let mut g = Graph::strict();
6414        let _ = g.add_edge("h", "a");
6415        let _ = g.add_edge("h", "b");
6416        let _ = g.add_edge("h", "a"); // exact duplicate
6417        let _ = g.add_edge("a", "h"); // reversed duplicate
6418        let _ = g.add_edge("s", "s"); // self-loop
6419        let _ = g.add_edge("s", "s"); // duplicate self-loop
6420        let n = g.node_count();
6421        for i in 0..n {
6422            if let Some(row) = g.neighbors_indices(i) {
6423                let uniq: BTreeSet<usize> = row.iter().copied().collect();
6424                assert_eq!(
6425                    uniq.len(),
6426                    row.len(),
6427                    "node {i} has duplicate adjacency entries"
6428                );
6429            }
6430        }
6431        // Unique edges: h-a, h-b, s-s.
6432        assert_eq!(
6433            g.edge_count(),
6434            3,
6435            "duplicate/reversed adds must not create new edges"
6436        );
6437    }
6438
6439    /// br-r37-c1-addedgenewedge: paired-interleaved median A/B for the exact change — building a
6440    /// high-degree hub's adjacency row. The OLD code guarded each push with
6441    /// `adj_indices[hub].contains(&x)` (an O(row.len) linear rescan → O(n²) to build a star via
6442    /// add_edge); the NEW code pushes on the O(1) `new_edge` flag → O(n). This isolates the two
6443    /// changed lines; the rest of add_edge is O(1)/edge in both arms (so the full-function win is
6444    /// this, diluted by that per-edge overhead). `#[ignore]`; run with
6445    /// `cargo test --release -p fnx-classes --lib add_edge_newedge_ab -- --ignored --nocapture`.
6446    #[test]
6447    #[ignore = "measurement; run with --release --ignored --nocapture"]
6448    fn add_edge_newedge_ab() {
6449        use std::hint::black_box;
6450        use std::time::Instant;
6451
6452        let n = 40000usize;
6453        let time = |use_new: bool| -> f64 {
6454            let t0 = Instant::now();
6455            let mut row: Vec<usize> = Vec::with_capacity(n);
6456            for i in 0..n {
6457                if use_new || !row.contains(&i) {
6458                    row.push(i);
6459                }
6460            }
6461            black_box(&row);
6462            t0.elapsed().as_secs_f64()
6463        };
6464        for _ in 0..2 {
6465            black_box(time(true));
6466            black_box(time(false));
6467        }
6468        let median = |v: &[f64]| {
6469            let mut s = v.to_vec();
6470            s.sort_by(|a, b| a.partial_cmp(b).unwrap());
6471            s[s.len() / 2]
6472        };
6473        let rounds = 41usize;
6474        let paired = |cand: bool, base: bool| -> Vec<f64> {
6475            let mut v = Vec::with_capacity(rounds);
6476            for r in 0..rounds {
6477                let (tb, tc) = if r % 2 == 0 {
6478                    let b = time(base);
6479                    let c = time(cand);
6480                    (b, c)
6481                } else {
6482                    let c = time(cand);
6483                    let b = time(base);
6484                    (b, c)
6485                };
6486                v.push(tb / tc);
6487            }
6488            v
6489        };
6490        let report = |name: &str, ratios: &[f64]| {
6491            let wins = ratios.iter().filter(|&&r| r > 1.0).count();
6492            let mut sorted = ratios.to_vec();
6493            sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
6494            println!(
6495                "ADDEDGE_AB {name}: median={:.4}x win_rate={wins}/{rounds} p5_p95=[{:.4},{:.4}]",
6496                median(ratios),
6497                sorted[rounds * 5 / 100],
6498                sorted[rounds * 95 / 100],
6499            );
6500        };
6501        println!("ADDEDGE_AB adj-row push n={n} rounds={rounds} (>1 = new_edge-flag faster)");
6502        report("NEWEDGE_vs_contains", &paired(true, false));
6503        report("NULL_new_vs_new", &paired(true, true));
6504    }
6505
6506    /// br-r37-c1-degselfloopidx: parity + paired-interleaved A/B for `degree_by_index` — the O(1)
6507    /// `has_edge_by_indices(idx,idx)` self-loop check vs the pre-lever O(degree)
6508    /// `adj_indices[idx].contains(&idx)` rescan, over a degree histogram of a dense graph. Byte-exact
6509    /// parity (also vs the `&str` `degree`). `#[ignore]`; run with
6510    /// `cargo test --release -p fnx-classes --lib degree_by_index_selfloop_ab -- --ignored --nocapture`.
6511    #[test]
6512    #[ignore = "measurement; run with --release --ignored --nocapture"]
6513    fn degree_by_index_selfloop_ab() {
6514        use std::hint::black_box;
6515        use std::time::Instant;
6516
6517        let n = 20000usize;
6518        let deg = 20usize;
6519        let mut g = Graph::strict();
6520        for i in 0..n {
6521            let _ = g.add_node(format!("n{i}"));
6522        }
6523        for i in 0..n {
6524            for step in 1..=deg {
6525                let _ = g.add_edge(format!("n{i}"), format!("n{}", (i + step) % n));
6526            }
6527        }
6528        for i in (0..n).step_by(1000) {
6529            let _ = g.add_edge(format!("n{i}"), format!("n{i}"));
6530        }
6531
6532        // Byte-exact parity: new == old (row.contains) == the &str degree, incl. self-loop nodes.
6533        for idx in 0..g.node_count() {
6534            let new = g.degree_by_index(idx);
6535            let old = g.adj_indices[idx].len() + usize::from(g.adj_indices[idx].contains(&idx));
6536            assert_eq!(new, old, "degree_by_index parity vs row.contains at {idx}");
6537            assert_eq!(
6538                new,
6539                g.degree(&format!("n{idx}")),
6540                "degree_by_index parity vs &str degree at {idx}"
6541            );
6542        }
6543
6544        let time = |use_new: bool| -> f64 {
6545            let t0 = Instant::now();
6546            let mut sum = 0usize;
6547            for idx in 0..g.node_count() {
6548                sum += if use_new {
6549                    g.degree_by_index(idx)
6550                } else {
6551                    g.adj_indices[idx].len() + usize::from(g.adj_indices[idx].contains(&idx))
6552                };
6553            }
6554            black_box(sum);
6555            t0.elapsed().as_secs_f64()
6556        };
6557        for _ in 0..3 {
6558            black_box(time(true));
6559            black_box(time(false));
6560        }
6561        let median = |v: &[f64]| {
6562            let mut s = v.to_vec();
6563            s.sort_by(|a, b| a.partial_cmp(b).unwrap());
6564            s[s.len() / 2]
6565        };
6566        let rounds = 41usize;
6567        let paired = |cand: bool, base: bool| -> Vec<f64> {
6568            let mut v = Vec::with_capacity(rounds);
6569            for r in 0..rounds {
6570                let (tb, tc) = if r % 2 == 0 {
6571                    let b = time(base);
6572                    let c = time(cand);
6573                    (b, c)
6574                } else {
6575                    let c = time(cand);
6576                    let b = time(base);
6577                    (b, c)
6578                };
6579                v.push(tb / tc);
6580            }
6581            v
6582        };
6583        let report = |name: &str, ratios: &[f64]| {
6584            let wins = ratios.iter().filter(|&&r| r > 1.0).count();
6585            let mut sorted = ratios.to_vec();
6586            sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
6587            println!(
6588                "DEGIDX_AB {name}: median={:.4}x win_rate={wins}/{rounds} p5_p95=[{:.4},{:.4}]",
6589                median(ratios),
6590                sorted[rounds * 5 / 100],
6591                sorted[rounds * 95 / 100],
6592            );
6593        };
6594        println!(
6595            "DEGIDX_AB degree histogram n={n} deg={deg} rounds={rounds} (>1 = O(1) has_edge faster)"
6596        );
6597        report("HASEDGE_vs_contains", &paired(true, false));
6598        report("NULL_new_vs_new", &paired(true, true));
6599    }
6600
6601    /// br-r37-c1-isoedgecount: paired-interleaved A/B for the graph-isomorphism early-out edge
6602    /// count check. `undirected_isomorphism_mappings` / `directed_isomorphism_mappings` (fnx-python,
6603    /// reached by public `is_isomorphic`) compared edge COUNTS via `edges_ordered().len()`, which
6604    /// materialises a full `Vec<EdgeSnapshot>` (two owned Strings per edge) only to read its length;
6605    /// the O(1) `edge_count()` (== `self.edges.len()`) is byte-identical. This times the exact
6606    /// early-out compare on the non-isomorphic same-node / different-edge case (where the check is
6607    /// the dominant cost). Byte-exact parity asserted (`edge_count() == edges_ordered().len()`).
6608    /// `#[ignore]`; run with
6609    /// `cargo test --release -p fnx-classes --lib iso_edge_count_ab -- --ignored --nocapture`.
6610    #[test]
6611    #[ignore = "measurement; run with --release --ignored --nocapture"]
6612    fn iso_edge_count_ab() {
6613        use std::hint::black_box;
6614        use std::time::Instant;
6615
6616        let n = 4000usize;
6617        let deg = 25usize;
6618        let build = |steps: usize| -> Graph {
6619            let mut g = Graph::strict();
6620            for i in 0..n {
6621                let _ = g.add_node(format!("n{i}"));
6622            }
6623            for i in 0..n {
6624                for step in 1..=steps {
6625                    let _ = g.add_edge(format!("n{i}"), format!("n{}", (i + step) % n));
6626                }
6627            }
6628            g
6629        };
6630        // Same node count, DIFFERENT edge count → the early-out returns at the edge-count compare.
6631        let g1 = build(deg);
6632        let g2 = build(deg - 1);
6633
6634        // Byte-exact parity: edge_count() must equal edges_ordered().len() for both graphs.
6635        assert_eq!(
6636            g1.edge_count(),
6637            g1.edges_ordered().len(),
6638            "edge_count parity g1"
6639        );
6640        assert_eq!(
6641            g2.edge_count(),
6642            g2.edges_ordered().len(),
6643            "edge_count parity g2"
6644        );
6645        assert_ne!(
6646            g1.edge_count(),
6647            g2.edge_count(),
6648            "workload must exercise the differing-count early-out"
6649        );
6650
6651        let time = |use_new: bool| -> f64 {
6652            let t0 = Instant::now();
6653            let mut acc = 0usize;
6654            for _ in 0..8 {
6655                let differ = if use_new {
6656                    g1.edge_count() != g2.edge_count()
6657                } else {
6658                    g1.edges_ordered().len() != g2.edges_ordered().len()
6659                };
6660                acc += usize::from(differ);
6661            }
6662            black_box(acc);
6663            t0.elapsed().as_secs_f64()
6664        };
6665        for _ in 0..3 {
6666            black_box(time(true));
6667            black_box(time(false));
6668        }
6669        let median = |v: &[f64]| {
6670            let mut s = v.to_vec();
6671            s.sort_by(|a, b| a.partial_cmp(b).unwrap());
6672            s[s.len() / 2]
6673        };
6674        let rounds = 41usize;
6675        let paired = |cand: bool, base: bool| -> Vec<f64> {
6676            let mut v = Vec::with_capacity(rounds);
6677            for r in 0..rounds {
6678                let (tb, tc) = if r % 2 == 0 {
6679                    let b = time(base);
6680                    let c = time(cand);
6681                    (b, c)
6682                } else {
6683                    let c = time(cand);
6684                    let b = time(base);
6685                    (b, c)
6686                };
6687                v.push(tb / tc);
6688            }
6689            v
6690        };
6691        let report = |name: &str, ratios: &[f64]| {
6692            let wins = ratios.iter().filter(|&&r| r > 1.0).count();
6693            let mut sorted = ratios.to_vec();
6694            sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
6695            println!(
6696                "ISOEDGE_AB {name}: median={:.4}x win_rate={wins}/{rounds} p5_p95=[{:.4},{:.4}]",
6697                median(ratios),
6698                sorted[rounds * 5 / 100],
6699                sorted[rounds * 95 / 100],
6700            );
6701        };
6702        println!(
6703            "ISOEDGE_AB same-node/different-edge early-out n={n} deg={deg} rounds={rounds} (>1 = edge_count faster)"
6704        );
6705        report("EDGECOUNT_vs_edgesordered", &paired(true, false));
6706        report("NULL_new_vs_new", &paired(true, true));
6707    }
6708
6709    /// br-r37-c1-selfloopidx: paired-interleaved A/B for the `nodes_with_selfloops` per-node self-loop
6710    /// probe (feeds `number_of_selfloops` / `selfloop_edges` / `nodes_with_selfloops`, many callers).
6711    /// The kernel checked `has_edge(node, node)` — resolving BOTH `&str` endpoints via
6712    /// `edge_pair_key` (two String-hash lookups per node) — vs the index probe
6713    /// `has_edge_by_indices(i, i)` (a direct integer `canon_pair` + `contains_key`, no String
6714    /// resolution). Byte-exact parity asserted (same self-loop node set). `#[ignore]`; run with
6715    /// `cargo test --release -p fnx-classes --lib nodes_selfloop_idx_ab -- --ignored --nocapture`.
6716    #[test]
6717    #[ignore = "measurement; run with --release --ignored --nocapture"]
6718    fn nodes_selfloop_idx_ab() {
6719        use std::hint::black_box;
6720        use std::time::Instant;
6721
6722        // 40k nodes, ring-of-chords, NO self-loops (the common case: every node is probed and misses).
6723        let n = 40_000usize;
6724        let deg = 10usize;
6725        let mut g = Graph::strict();
6726        for i in 0..n {
6727            let _ = g.add_node(i.to_string());
6728        }
6729        for i in 0..n {
6730            for step in 1..=deg {
6731                let _ = g.add_edge(i.to_string(), ((i + step) % n).to_string());
6732            }
6733        }
6734
6735        let names = g.nodes_ordered();
6736        let old_scan =
6737            |g: &Graph| -> usize { names.iter().filter(|&&node| g.has_edge(node, node)).count() };
6738        let new_scan = |g: &Graph| -> usize {
6739            (0..names.len())
6740                .filter(|&i| g.has_edge_by_indices(i, i))
6741                .count()
6742        };
6743        assert_eq!(
6744            old_scan(&g),
6745            new_scan(&g),
6746            "nodes_with_selfloops parity (self-loop count)"
6747        );
6748
6749        let time = |cand: bool| -> f64 {
6750            let t0 = Instant::now();
6751            let c = if cand { new_scan(&g) } else { old_scan(&g) };
6752            black_box(c);
6753            t0.elapsed().as_secs_f64()
6754        };
6755        for _ in 0..3 {
6756            black_box(time(true));
6757            black_box(time(false));
6758        }
6759        let median = |v: &[f64]| {
6760            let mut s = v.to_vec();
6761            s.sort_by(|a, b| a.partial_cmp(b).unwrap());
6762            s[s.len() / 2]
6763        };
6764        let rounds = 61usize;
6765        let paired = |cand: bool, base: bool| -> Vec<f64> {
6766            let mut v = Vec::with_capacity(rounds);
6767            for r in 0..rounds {
6768                let (tb, tc) = if r % 2 == 0 {
6769                    let b = time(base);
6770                    let c = time(cand);
6771                    (b, c)
6772                } else {
6773                    let c = time(cand);
6774                    let b = time(base);
6775                    (b, c)
6776                };
6777                v.push(tb / tc);
6778            }
6779            v
6780        };
6781        let report = |name: &str, ratios: &[f64]| {
6782            let wins = ratios.iter().filter(|&&r| r > 1.0).count();
6783            let mut sorted = ratios.to_vec();
6784            sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
6785            println!(
6786                "SELFLOOPIDX_AB {name}: median={:.4}x win_rate={wins}/{rounds} p5_p95=[{:.4},{:.4}]",
6787                median(ratios),
6788                sorted[rounds * 5 / 100],
6789                sorted[rounds * 95 / 100],
6790            );
6791        };
6792        println!(
6793            "SELFLOOPIDX_AB nodes_with_selfloops n={n} deg={deg} rounds={rounds} (>1 = index probe faster)"
6794        );
6795        report("HASEDGEIDX_vs_hasedge", &paired(true, false));
6796        report("NULL_new_vs_new", &paired(true, true));
6797    }
6798
6799    /// br-r37-c1-numselfidx: paired-interleaved A/B for `number_of_selfloops` (feeds the is_planar
6800    /// dense-graph `planarity_euler_reject` fast path). The kernel counted self-loops with
6801    /// `edges_ordered().filter(|e| e.left == e.right).count()` — materialising a full
6802    /// `Vec<EdgeSnapshot>` (two owned Strings + an attr clone PER edge) — vs the integer per-node
6803    /// probe `(0..n).filter(|i| has_edge_by_indices(i, i)).count()` (no allocation). Byte-exact parity
6804    /// asserted (same self-loop count). `#[ignore]`; run with
6805    /// `cargo test --release -p fnx-classes --lib number_of_selfloops_idx_ab -- --ignored --nocapture`.
6806    #[test]
6807    #[ignore = "measurement; run with --release --ignored --nocapture"]
6808    fn number_of_selfloops_idx_ab() {
6809        use std::hint::black_box;
6810        use std::time::Instant;
6811
6812        // Dense-ish ring-of-chords, ~20k nodes * 20 = ~200k edges, no self-loops (the count is 0 and
6813        // the old arm materialises the whole edge Vec anyway).
6814        let n = 20_000usize;
6815        let deg = 20usize;
6816        let mut g = Graph::strict();
6817        for i in 0..n {
6818            let _ = g.add_node(i.to_string());
6819        }
6820        for i in 0..n {
6821            for step in 1..=deg {
6822                let _ = g.add_edge(i.to_string(), ((i + step) % n).to_string());
6823            }
6824        }
6825
6826        let old_count = |g: &Graph| -> usize {
6827            g.edges_ordered()
6828                .iter()
6829                .filter(|e| e.left == e.right)
6830                .count()
6831        };
6832        let new_count = |g: &Graph| -> usize {
6833            (0..g.node_count())
6834                .filter(|&i| g.has_edge_by_indices(i, i))
6835                .count()
6836        };
6837        assert_eq!(old_count(&g), new_count(&g), "number_of_selfloops parity");
6838
6839        let time = |cand: bool| -> f64 {
6840            let t0 = Instant::now();
6841            let c = if cand { new_count(&g) } else { old_count(&g) };
6842            black_box(c);
6843            t0.elapsed().as_secs_f64()
6844        };
6845        for _ in 0..3 {
6846            black_box(time(true));
6847            black_box(time(false));
6848        }
6849        let median = |v: &[f64]| {
6850            let mut s = v.to_vec();
6851            s.sort_by(|a, b| a.partial_cmp(b).unwrap());
6852            s[s.len() / 2]
6853        };
6854        let rounds = 61usize;
6855        let paired = |cand: bool, base: bool| -> Vec<f64> {
6856            let mut v = Vec::with_capacity(rounds);
6857            for r in 0..rounds {
6858                let (tb, tc) = if r % 2 == 0 {
6859                    let b = time(base);
6860                    let c = time(cand);
6861                    (b, c)
6862                } else {
6863                    let c = time(cand);
6864                    let b = time(base);
6865                    (b, c)
6866                };
6867                v.push(tb / tc);
6868            }
6869            v
6870        };
6871        let report = |name: &str, ratios: &[f64]| {
6872            let wins = ratios.iter().filter(|&&r| r > 1.0).count();
6873            let mut sorted = ratios.to_vec();
6874            sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
6875            println!(
6876                "NUMSELFIDX_AB {name}: median={:.4}x win_rate={wins}/{rounds} p5_p95=[{:.4},{:.4}]",
6877                median(ratios),
6878                sorted[rounds * 5 / 100],
6879                sorted[rounds * 95 / 100],
6880            );
6881        };
6882        println!(
6883            "NUMSELFIDX_AB number_of_selfloops n={n} deg={deg} rounds={rounds} (>1 = index count faster)"
6884        );
6885        report("INDEXCOUNT_vs_edgesordered", &paired(true, false));
6886        report("NULL_new_vs_new", &paired(true, true));
6887    }
6888
6889    /// br-r37-c1-kneserpush parity: removing the redundant `adj_indices.contains` guards under the
6890    /// `seen.insert(pair)` new-pair guard must keep the Kneser build byte-identical — correct
6891    /// node/edge counts and NO duplicate adjacency entries. (Same lever + adj_indices.push operation
6892    /// as br-r37-c1-addedgenewedge, whose A/B measured the O(degree)→O(1) win.)
6893    #[test]
6894    fn kneser_push_no_duplicate_adjacency() {
6895        // Kneser(10,3): C(10,3)=120 nodes, degree C(7,3)=35, edges = 120*35/2 = 2100.
6896        let g = Graph::kneser(CompatibilityMode::Strict, 10, 3);
6897        assert_eq!(g.node_count(), 120, "C(10,3) nodes");
6898        assert_eq!(g.edge_count(), 2100, "120*35/2 Kneser edges");
6899        let mut total_deg = 0usize;
6900        for (i, row) in g.adj_indices.iter().enumerate() {
6901            let uniq: BTreeSet<usize> = row.iter().copied().collect();
6902            assert_eq!(
6903                uniq.len(),
6904                row.len(),
6905                "node {i} has duplicate adjacency entries"
6906            );
6907            assert_eq!(row.len(), 35, "Kneser(10,3) is 35-regular");
6908            total_deg += row.len();
6909        }
6910        assert_eq!(total_deg, 2 * 2100, "handshake: sum of degrees == 2|E|");
6911    }
6912
6913    fn node_name(id: u8) -> String {
6914        format!("n{}", id % 8)
6915    }
6916
6917    fn canonical_edge(left: &str, right: &str) -> (String, String) {
6918        if left <= right {
6919            (left.to_owned(), right.to_owned())
6920        } else {
6921            (right.to_owned(), left.to_owned())
6922        }
6923    }
6924
6925    /// br-r37-c1-thp6w slice 1 INVARIANT: the cached integer adjacency
6926    /// (`with_int_adjacency`) must equal, byte-for-byte, a fresh derivation from the
6927    /// authoritative stable-slot rows — row `i` == `[get_node_index(v) for v in
6928    /// neighbors_iter(node_i)]` — for the CURRENT graph state. Because the memo is keyed on
6929    /// `revision` + explicitly cleared by `apply_row_orders`, calling this after a mutation
6930    /// (with a prior populating read) catches any mutation path that fails to invalidate.
6931    fn assert_int_adjacency_matches(g: &MultiGraph) {
6932        let expected: Vec<Vec<usize>> = g
6933            .nodes_ordered()
6934            .iter()
6935            .map(|name| {
6936                g.neighbors_iter(name).map_or_else(Vec::new, |it| {
6937                    it.map(|v| g.get_node_index(v).unwrap()).collect()
6938                })
6939            })
6940            .collect();
6941        g.with_int_adjacency(|adj| {
6942            assert_eq!(
6943                adj,
6944                expected.as_slice(),
6945                "int adjacency must mirror the stable-slot rows exactly"
6946            );
6947        });
6948    }
6949
6950    #[test]
6951    fn thp6w_int_adjacency_invariant_across_mutations() {
6952        let mut g = MultiGraph::strict();
6953        for i in 0..8 {
6954            g.add_node(format!("n{i}"));
6955        }
6956        // Edges incl. parallels and a self-loop.
6957        let _ = g.add_edge("n0", "n1");
6958        let _ = g.add_edge("n0", "n1"); // parallel
6959        let _ = g.add_edge("n1", "n2");
6960        let _ = g.add_edge("n2", "n3");
6961        let _ = g.add_edge("n3", "n0");
6962        let _ = g.add_edge("n4", "n4"); // self-loop
6963        let _ = g.add_edge("n5", "n6");
6964        // Populate the memo, then verify.
6965        assert_int_adjacency_matches(&g);
6966
6967        // read-mutate-read for EACH mutation kind: a stale memo would fail the re-check.
6968        // add_node
6969        assert_int_adjacency_matches(&g);
6970        g.add_node("n9");
6971        assert_int_adjacency_matches(&g);
6972        // add_edge (new neighbor -> row grows)
6973        assert_int_adjacency_matches(&g);
6974        let _ = g.add_edge("n9", "n0");
6975        assert_int_adjacency_matches(&g);
6976        // parallel add_edge (row unchanged, content bumps revision)
6977        assert_int_adjacency_matches(&g);
6978        let _ = g.add_edge("n9", "n0");
6979        assert_int_adjacency_matches(&g);
6980        // remove one parallel edge (row must stay; neighbor still present)
6981        assert_int_adjacency_matches(&g);
6982        assert!(g.remove_edge("n0", "n1", None));
6983        assert_int_adjacency_matches(&g);
6984        // remove last edge of a pair (neighbor drops from both rows)
6985        assert_int_adjacency_matches(&g);
6986        assert!(g.remove_edge("n5", "n6", None));
6987        assert_int_adjacency_matches(&g);
6988        // remove_node (renumber: indices shift down)
6989        assert_int_adjacency_matches(&g);
6990        assert!(g.remove_node("n2"));
6991        assert_int_adjacency_matches(&g);
6992        // ORDER-ONLY change via the copy walk reorder (does NOT bump revision — the
6993        // apply_row_orders cache-clear is what keeps this correct).
6994        assert_int_adjacency_matches(&g);
6995        g.reorder_rows_for_nx_copy_walk();
6996        assert_int_adjacency_matches(&g);
6997        // clear_edges
6998        assert_int_adjacency_matches(&g);
6999        g.clear_edges();
7000        assert_int_adjacency_matches(&g);
7001    }
7002
7003    #[test]
7004    fn reorder_rows_like_restores_the_source_rows() {
7005        // source rows: x [c, a, b], c [x, b], a [x, b], b [x, a, c]
7006        let mut source = Graph::strict();
7007        for (u, v) in [("x", "c"), ("x", "a"), ("x", "b"), ("a", "b"), ("c", "b")] {
7008            let _ = source.add_edge(u, v);
7009        }
7010        // A copy-shaped rebuild over the node order [a, b, c, x] fills each
7011        // row in edge-walk order: a [x, b], b [a, x, c], c [b, x],
7012        // x [a, b, c]; plus an edge the source lacks.
7013        let mut copy = Graph::strict();
7014        for node in ["a", "b", "c", "x", "y"] {
7015            let _ = copy.add_node(node);
7016        }
7017        for (u, v) in [
7018            ("a", "x"),
7019            ("a", "b"),
7020            ("b", "x"),
7021            ("b", "c"),
7022            ("c", "x"),
7023            ("x", "y"),
7024        ] {
7025            let _ = copy.add_edge(u, v);
7026        }
7027        assert_eq!(copy.neighbors("b"), Some(vec!["a", "x", "c"]));
7028        let before = copy.revision;
7029        copy.reorder_rows_like(&source);
7030        assert_eq!(copy.neighbors("a"), Some(vec!["x", "b"]));
7031        assert_eq!(copy.neighbors("b"), Some(vec!["x", "a", "c"]));
7032        assert_eq!(copy.neighbors("c"), Some(vec!["x", "b"]));
7033        // The unmatched neighbour keeps its place after the matched ones.
7034        assert_eq!(copy.neighbors("x"), Some(vec!["c", "a", "b", "y"]));
7035        assert_eq!(copy.neighbors("y"), Some(vec!["x"]));
7036        assert!(copy.revision > before, "a moved row must bump the revision");
7037        let after = copy.revision;
7038        copy.reorder_rows_like(&source);
7039        assert_eq!(copy.revision, after, "an ordered graph is left alone");
7040        assert_eq!(copy.edge_count(), 6);
7041    }
7042
7043    #[test]
7044    fn set_row_orders_takes_permutations_only_and_all_or_nothing() {
7045        let mut g = Graph::strict();
7046        for (u, v) in [("a", "b"), ("a", "c"), ("b", "c")] {
7047            let _ = g.add_edge(u, v);
7048        }
7049        // positions: a 0, b 1, c 2
7050        assert!(g.set_row_orders(&[vec![2, 1], vec![2, 0], vec![1, 0]]));
7051        assert_eq!(g.neighbors("a"), Some(vec!["c", "b"]));
7052        assert_eq!(g.neighbors("b"), Some(vec!["c", "a"]));
7053        assert_eq!(g.neighbors("c"), Some(vec!["b", "a"]));
7054        let before = g.revision;
7055        // Row b names a node twice (not a permutation): nothing moves, not
7056        // even row a, which alone would have been valid.
7057        assert!(!g.set_row_orders(&[vec![1, 2], vec![2, 2], vec![0, 1]]));
7058        // A missing row, a short row, a position past the end.
7059        assert!(!g.set_row_orders(&[vec![1, 2], vec![0, 2]]));
7060        assert!(!g.set_row_orders(&[vec![1], vec![0, 2], vec![0, 1]]));
7061        assert!(!g.set_row_orders(&[vec![1, 7], vec![0, 2], vec![0, 1]]));
7062        assert_eq!(g.neighbors("a"), Some(vec!["c", "b"]));
7063        assert_eq!(g.revision, before);
7064    }
7065
7066    #[test]
7067    fn multigraph_reorder_rows_like_restores_the_source_rows() {
7068        // br-r37-c1-36v6r: source row x [c, a, b]; the pair x-a has two keys.
7069        let mut source = MultiGraph::strict();
7070        for (u, v) in [("x", "c"), ("x", "a"), ("x", "b"), ("x", "a")] {
7071            let _ = source.add_edge(u, v);
7072        }
7073        // A copy-shaped rebuild over [a, b, c, x, y] fills row x in walk
7074        // order, plus a neighbour the source lacks.
7075        let mut copy = MultiGraph::strict();
7076        for node in ["a", "b", "c", "x", "y"] {
7077            let _ = copy.add_node(node);
7078        }
7079        for (u, v) in [("a", "x"), ("a", "x"), ("b", "x"), ("c", "x"), ("x", "y")] {
7080            let _ = copy.add_edge(u, v);
7081        }
7082        assert_eq!(copy.neighbors("x"), Some(vec!["a", "b", "c", "y"]));
7083        assert_int_adjacency_matches(&copy); // warms the int-adjacency memo
7084        copy.reorder_rows_like(&source);
7085        // The unmatched neighbour keeps its place after the matched ones;
7086        // the keyed cell moves with its neighbour.
7087        assert_eq!(copy.neighbors("x"), Some(vec!["c", "a", "b", "y"]));
7088        assert_eq!(copy.neighbors("a"), Some(vec!["x"]));
7089        assert_eq!(copy.edge_keys("x", "a"), Some(vec![0, 1]));
7090        assert_eq!(copy.edge_count(), 5);
7091        // The memo is rebuilt from the reordered rows, not served stale.
7092        assert_int_adjacency_matches(&copy);
7093    }
7094
7095    /// br-r37-c1-thp6w S4: the memo is keyed at the CURRENT revision without any
7096    /// intervening read — i.e. the mutation advanced it in place rather than
7097    /// leaving it stale for the next `with_int_adjacency` rebuild.
7098    fn int_adj_memo_is_warm(g: &MultiGraph) -> bool {
7099        g.int_adj_cache
7100            .0
7101            .read()
7102            .expect("int_adj_cache poisoned")
7103            .as_ref()
7104            .is_some_and(|(rev, _)| *rev == g.revision)
7105    }
7106
7107    /// br-r37-c1-thp6w S4 INVARIANT: single-edge `add_edge`/`remove_edge` must
7108    /// ADVANCE a warm integer-adjacency memo across the mutation (no rebuild),
7109    /// and the advanced rows must equal a fresh derivation from the String
7110    /// adjacency after every step. Unhandled mutation kinds (`remove_node`)
7111    /// must still leave the memo stale so the lazy rebuild path takes over.
7112    #[test]
7113    fn thp6w_s4_single_edge_mutations_advance_warm_memo() {
7114        let mut g = MultiGraph::strict();
7115        let _ = g.add_edge("a", "b");
7116        assert_int_adjacency_matches(&g); // populate the memo
7117
7118        // new neighbor with an auto-created right endpoint
7119        let _ = g.add_edge("b", "c");
7120        assert!(
7121            int_adj_memo_is_warm(&g),
7122            "add_edge must advance, not invalidate"
7123        );
7124        assert_int_adjacency_matches(&g);
7125        // both endpoints auto-created
7126        let _ = g.add_edge("d", "e");
7127        assert!(int_adj_memo_is_warm(&g));
7128        assert_int_adjacency_matches(&g);
7129        // self-loop on an auto-created node
7130        let _ = g.add_edge("f", "f");
7131        assert!(int_adj_memo_is_warm(&g));
7132        assert_int_adjacency_matches(&g);
7133        // parallel edge: rows unchanged, revision bumps -> re-key only
7134        let _ = g.add_edge("a", "b");
7135        assert!(int_adj_memo_is_warm(&g));
7136        assert_int_adjacency_matches(&g);
7137        // attr-only change on an existing key: rows unchanged, revision bumps
7138        let mut attrs = AttrMap::new();
7139        attrs.insert("w".to_owned(), fnx_runtime::CgseValue::Int(2));
7140        let _ = g.add_edge_with_key_and_attrs("a", "b", 0, attrs);
7141        assert!(int_adj_memo_is_warm(&g));
7142        assert_int_adjacency_matches(&g);
7143        // remove one parallel key (pair survives -> rows unchanged)
7144        assert!(g.remove_edge("a", "b", None));
7145        assert!(int_adj_memo_is_warm(&g));
7146        assert_int_adjacency_matches(&g);
7147        // remove the last key (pair gone -> shift-remove mirrored in the rows)
7148        assert!(g.remove_edge("a", "b", None));
7149        assert!(int_adj_memo_is_warm(&g));
7150        assert_int_adjacency_matches(&g);
7151        // order-sensitive middle-of-row removal: row b = [a-gone..., c, ...]; build
7152        // b->x, b->y then drop (b,c) so the survivors must keep String-row order.
7153        let _ = g.add_edge("b", "x");
7154        let _ = g.add_edge("b", "y");
7155        assert!(g.remove_edge("b", "c", None));
7156        assert!(int_adj_memo_is_warm(&g));
7157        assert_int_adjacency_matches(&g);
7158        // self-loop removal
7159        assert!(g.remove_edge("f", "f", None));
7160        assert!(int_adj_memo_is_warm(&g));
7161        assert_int_adjacency_matches(&g);
7162        // unhandled mutation kind: remove_node renumbers -> memo must go stale
7163        assert!(g.remove_node("x"));
7164        assert!(
7165            !int_adj_memo_is_warm(&g),
7166            "remove_node stays invalidate-only; a warm memo here would serve stale indices"
7167        );
7168        assert_int_adjacency_matches(&g);
7169    }
7170
7171    /// br-r37-c1-thp6w S5 PARITY GATES: the index-keyed prototype must derive
7172    /// byte-identical observable orderings (node order, per-row distinct-
7173    /// neighbor order, per-pair parallel-key order) and identical edge count +
7174    /// merged attrs from the same insert stream as the real String-keyed
7175    /// MultiGraph batch path. The stream deliberately includes names where
7176    /// String-lex and index canonicalization DIVERGE ("10" < "2" lex, 2 < 10
7177    /// numeric), reversed re-adds, self-loops, sparse explicit keys, dup
7178    /// (pair, key) attr merges, and late out-of-order node introduction.
7179    #[test]
7180    fn thp6w_s5_int_storage_proto_parity_gates() {
7181        use super::multigraph_storage::MgIntStorageProto;
7182
7183        let stream: Vec<(&str, &str, usize)> = vec![
7184            ("2", "10", 0),
7185            ("10", "2", 1), // reversed orientation, same pair, next key
7186            ("1", "1", 0),  // self-loop on a fresh node
7187            ("2", "3", 0),
7188            ("3", "2", 0), // dup (pair, key) from the reverse orientation -> attr merge
7189            ("10", "1", 5), // sparse explicit key
7190            ("0", "2", 0), // node "0" introduced late (insertion order != numeric)
7191            ("2", "10", 1), // dup (pair, key) again -> attr merge
7192            ("4", "5", 2),
7193            ("5", "4", 0), // parallel keys, both orientations
7194            ("1", "1", 1), // parallel self-loop key
7195        ];
7196        let attrs_for = |i: usize| {
7197            let mut attrs = AttrMap::new();
7198            attrs.insert(
7199                format!("a{}", i % 3),
7200                fnx_runtime::CgseValue::Int(i64::try_from(i).unwrap()),
7201            );
7202            attrs
7203        };
7204
7205        let mut mg = MultiGraph::strict();
7206        let _ = mg.extend_keyed_edges_with_attrs_unrecorded(
7207            stream
7208                .iter()
7209                .enumerate()
7210                .map(|(i, (l, r, k))| ((*l).to_owned(), (*r).to_owned(), *k, attrs_for(i))),
7211        );
7212        let mut proto = MgIntStorageProto::new();
7213        for (i, (l, r, k)) in stream.iter().enumerate() {
7214            proto.add_keyed_edge(l, r, *k, attrs_for(i));
7215        }
7216        // br-r37-c1-thp6w S6: the compact-bucket variant must pass the same gates.
7217        let mut compact = super::multigraph_storage::MgIntStorageProtoCompact::new();
7218        for (i, (l, r, k)) in stream.iter().enumerate() {
7219            compact.add_keyed_edge(l, r, *k, attrs_for(i));
7220        }
7221
7222        // Gate 1: node insertion order.
7223        assert_eq!(
7224            proto
7225                .node_names
7226                .iter()
7227                .map(String::as_str)
7228                .collect::<Vec<_>>(),
7229            mg.nodes_ordered(),
7230            "node order must match"
7231        );
7232        assert_eq!(
7233            compact
7234                .node_names
7235                .iter()
7236                .map(String::as_str)
7237                .collect::<Vec<_>>(),
7238            mg.nodes_ordered(),
7239            "compact node order must match"
7240        );
7241        // Gate 2 + 3: per-row distinct-neighbor order and per-pair key order.
7242        for (i, name) in mg.nodes_ordered().iter().enumerate() {
7243            let mg_row: Vec<&str> = mg
7244                .neighbors_iter(name)
7245                .map_or_else(Vec::new, Iterator::collect);
7246            assert_eq!(proto.neighbor_names(i), mg_row, "row order for node {name}");
7247            assert_eq!(
7248                compact.neighbor_names(i),
7249                mg_row,
7250                "compact row order for node {name}"
7251            );
7252            for v in &mg_row {
7253                assert_eq!(
7254                    proto.key_order(name, v),
7255                    mg.edge_keys_vec(name, v),
7256                    "key order for pair ({name}, {v})"
7257                );
7258                assert_eq!(
7259                    compact.key_order(name, v),
7260                    mg.edge_keys_vec(name, v),
7261                    "compact key order for pair ({name}, {v})"
7262                );
7263            }
7264        }
7265        // Gate 4: edge count.
7266        assert_eq!(proto.edge_count, mg.edge_count(), "edge_count must match");
7267        assert_eq!(
7268            compact.edge_count,
7269            mg.edge_count(),
7270            "compact edge_count must match"
7271        );
7272        // Gate 5: merged attrs on a dup-inserted cell ((2,10) key 1 saw inserts
7273        // at stream positions 1 and 7 -> extend semantics on both sides).
7274        let l = *proto.node_index.get("2").expect("proto must retain node 2");
7275        let r = *proto
7276            .node_index
7277            .get("10")
7278            .expect("proto must retain node 10");
7279        let proto_attrs = proto.edges[&(l.min(r), l.max(r))]
7280            .get(&1)
7281            .expect("proto (2,10,1) cell");
7282        assert_eq!(
7283            Some(proto_attrs),
7284            mg.edge_attrs("2", "10", 1),
7285            "merged attrs on the dup (pair, key) cell must match"
7286        );
7287        assert_eq!(
7288            compact.edges[&(l.min(r), l.max(r))].attrs_for(1),
7289            mg.edge_attrs("2", "10", 1),
7290            "compact merged attrs on the dup (pair, key) cell must match"
7291        );
7292    }
7293
7294    /// br-r37-c1-thp6w S7 helper: full observable-order parity between the
7295    /// real MultiGraph and the index-keyed prototype, plus the prototype's own
7296    /// name->index consistency (the renumber invariant).
7297    fn assert_proto_matches_mg(
7298        mg: &MultiGraph,
7299        proto: &super::multigraph_storage::MgIntStorageProto,
7300    ) {
7301        assert_eq!(
7302            proto
7303                .node_names
7304                .iter()
7305                .map(String::as_str)
7306                .collect::<Vec<_>>(),
7307            mg.nodes_ordered(),
7308            "node order"
7309        );
7310        for (i, name) in proto.node_names.iter().enumerate() {
7311            assert_eq!(
7312                proto.node_index.get(name),
7313                Some(&i),
7314                "node_index desync for {name}"
7315            );
7316        }
7317        for (i, name) in mg.nodes_ordered().iter().enumerate() {
7318            let mg_row: Vec<&str> = mg
7319                .neighbors_iter(name)
7320                .map_or_else(Vec::new, Iterator::collect);
7321            assert_eq!(proto.neighbor_names(i), mg_row, "row order for {name}");
7322            for v in &mg_row {
7323                assert_eq!(
7324                    proto.key_order(name, v),
7325                    mg.edge_keys_vec(name, v),
7326                    "key order for ({name}, {v})"
7327                );
7328            }
7329        }
7330        assert_eq!(proto.edge_count, mg.edge_count(), "edge_count");
7331    }
7332
7333    /// br-r37-c1-thp6w S7 PARITY GAUNTLET (removal + renumber): the epoch's
7334    /// riskiest semantics — `remove_edge` shift/`next_back` behavior and the
7335    /// `remove_node` index renumber — must keep every observable ordering
7336    /// byte-identical to the real String-keyed MultiGraph after EVERY step,
7337    /// including re-adds AFTER a renumber (stale-index detection).
7338    #[test]
7339    fn thp6w_s7_removal_renumber_parity_gauntlet() {
7340        use super::multigraph_storage::MgIntStorageProto;
7341
7342        let stream: Vec<(&str, &str, usize)> = vec![
7343            ("2", "10", 0),
7344            ("10", "2", 1),
7345            ("1", "1", 0),
7346            ("2", "3", 0),
7347            ("3", "2", 0),
7348            ("10", "1", 5),
7349            ("0", "2", 0),
7350            ("2", "10", 1),
7351            ("4", "5", 2),
7352            ("5", "4", 0),
7353            ("1", "1", 1),
7354        ];
7355        let mut mg = MultiGraph::strict();
7356        let _ = mg.extend_keyed_edges_with_attrs_unrecorded(
7357            stream
7358                .iter()
7359                .map(|(l, r, k)| ((*l).to_owned(), (*r).to_owned(), *k, AttrMap::new())),
7360        );
7361        let mut proto = MgIntStorageProto::new();
7362        for (l, r, k) in &stream {
7363            proto.add_keyed_edge(l, r, *k, AttrMap::new());
7364        }
7365        assert_proto_matches_mg(&mg, &proto);
7366
7367        // key=None must pick the LAST bucket key (next_back): (4,5) keys are
7368        // [2, 0] in insertion order -> removes 0, survivor [2].
7369        assert_eq!(
7370            mg.remove_edge("4", "5", None),
7371            proto.remove_edge("4", "5", None)
7372        );
7373        assert_proto_matches_mg(&mg, &proto);
7374        // Partial removal of a parallel bundle: survivor key order preserved.
7375        assert_eq!(
7376            mg.remove_edge("2", "10", Some(0)),
7377            proto.remove_edge("2", "10", Some(0))
7378        );
7379        assert_proto_matches_mg(&mg, &proto);
7380        // Last key of the pair -> neighbor cell drops from both rows.
7381        assert_eq!(
7382            mg.remove_edge("2", "10", None),
7383            proto.remove_edge("2", "10", None)
7384        );
7385        assert_proto_matches_mg(&mg, &proto);
7386        // Self-loop partial removal.
7387        assert_eq!(
7388            mg.remove_edge("1", "1", Some(0)),
7389            proto.remove_edge("1", "1", Some(0))
7390        );
7391        assert_proto_matches_mg(&mg, &proto);
7392        // Missing pair / missing key: both sides must refuse identically.
7393        assert_eq!(
7394            mg.remove_edge("0", "10", None),
7395            proto.remove_edge("0", "10", None)
7396        );
7397        assert_eq!(
7398            mg.remove_edge("2", "3", Some(9)),
7399            proto.remove_edge("2", "3", Some(9))
7400        );
7401        assert_proto_matches_mg(&mg, &proto);
7402
7403        // THE renumber: remove a mid-order node with live edges.
7404        assert_eq!(mg.remove_node("2"), proto.remove_node("2"));
7405        assert_proto_matches_mg(&mg, &proto);
7406        // Re-adds AFTER the renumber: fresh node + edges into shifted indices.
7407        mg.extend_keyed_edges_with_attrs_unrecorded(vec![
7408            ("3".to_owned(), "99".to_owned(), 0, AttrMap::new()),
7409            ("0".to_owned(), "3".to_owned(), 0, AttrMap::new()),
7410        ]);
7411        proto.add_keyed_edge("3", "99", 0, AttrMap::new());
7412        proto.add_keyed_edge("0", "3", 0, AttrMap::new());
7413        assert_proto_matches_mg(&mg, &proto);
7414        // Renumber again from the other end, then the self-loop node.
7415        assert_eq!(mg.remove_node("10"), proto.remove_node("10"));
7416        assert_proto_matches_mg(&mg, &proto);
7417        assert_eq!(mg.remove_node("1"), proto.remove_node("1"));
7418        assert_proto_matches_mg(&mg, &proto);
7419        // Removing a missing node refuses identically.
7420        assert_eq!(mg.remove_node("2"), proto.remove_node("2"));
7421        assert_proto_matches_mg(&mg, &proto);
7422    }
7423
7424    /// br-r37-c1-thp6w S8 A/B: the flip's COST side — `remove_node` under the
7425    /// index layout pays the d58s8 positional renumber (O(V+E) per removal:
7426    /// row drop+decrement passes + full edges-map rebuild) where the current
7427    /// String store pays only O(V + degree) (shift_remove memmoves, no edge
7428    /// rekey). Same build stream, same 200-name removal sequence, removal
7429    /// phase timed alone, paired-interleaved with a null arm. This number
7430    /// decides whether the real flip needs the slab/stable-slot + tombstone
7431    /// node store (see the bead's S8 design note) instead of positional
7432    /// renumber.
7433    /// `cargo test --release -p fnx-classes --lib thp6w_s8_removal_cost_ab -- --ignored --nocapture`
7434    #[test]
7435    #[ignore = "paired A/B benchmark; run --release with --ignored --nocapture"]
7436    fn thp6w_s8_removal_cost_ab() {
7437        use super::multigraph_storage::MgIntStorageProto;
7438        use std::collections::HashMap;
7439        use std::hint::black_box;
7440        use std::time::Instant;
7441
7442        fn push_edge(
7443            stream: &mut Vec<(usize, usize, usize)>,
7444            key_counter: &mut HashMap<(usize, usize), usize>,
7445            u: usize,
7446            v: usize,
7447        ) {
7448            let entry = key_counter.entry((u.min(v), u.max(v))).or_insert(0);
7449            stream.push((u, v, *entry));
7450            *entry += 1;
7451        }
7452
7453        let n = 20000usize;
7454        let mut stream = Vec::new();
7455        let mut key_counter = HashMap::new();
7456        for i in 0..n {
7457            push_edge(&mut stream, &mut key_counter, i, (i + 1) % n);
7458        }
7459        let mut state = 0x2545_F491_4F6C_DD1D_u64;
7460        let draw = |state: &mut u64| -> usize {
7461            *state = state
7462                .wrapping_mul(6_364_136_223_846_793_005)
7463                .wrapping_add(1_442_695_040_888_963_407);
7464            usize::try_from(*state >> 33).unwrap() % n
7465        };
7466        for _ in 0..3 * n {
7467            let u = draw(&mut state);
7468            let v = draw(&mut state);
7469            push_edge(&mut stream, &mut key_counter, u, v);
7470        }
7471        let victims: Vec<String> = (0..n).step_by(100).map(|i| i.to_string()).collect();
7472
7473        let time_current_removals = |stream: &[(usize, usize, usize)]| -> f64 {
7474            let mut g = MultiGraph::strict();
7475            let _ = g.extend_fresh_int_prefix_keyed_edges_unrecorded(n, stream.iter().copied());
7476            let t0 = Instant::now();
7477            for name in &victims {
7478                assert!(g.remove_node(name));
7479            }
7480            let dt = t0.elapsed().as_secs_f64();
7481            black_box(g.edge_count());
7482            dt
7483        };
7484        let time_proto_removals = |stream: &[(usize, usize, usize)]| -> f64 {
7485            let mut proto = MgIntStorageProto::bulk_load_int_prefix(n, stream.iter().copied());
7486            let t0 = Instant::now();
7487            for name in &victims {
7488                assert!(proto.remove_node(name));
7489            }
7490            let dt = t0.elapsed().as_secs_f64();
7491            black_box(proto.edge_count);
7492            dt
7493        };
7494
7495        // Post-removal structural agreement (once, outside timing).
7496        let mut g = MultiGraph::strict();
7497        let _ = g.extend_fresh_int_prefix_keyed_edges_unrecorded(n, stream.iter().copied());
7498        let mut proto = MgIntStorageProto::bulk_load_int_prefix(n, stream.iter().copied());
7499        for name in &victims {
7500            assert!(g.remove_node(name));
7501            assert!(proto.remove_node(name));
7502        }
7503        assert_eq!(
7504            proto.edge_count,
7505            g.edge_count(),
7506            "removal arms diverged in edge_count"
7507        );
7508        assert_eq!(
7509            proto
7510                .node_names
7511                .iter()
7512                .map(String::as_str)
7513                .collect::<Vec<_>>(),
7514            g.nodes_ordered(),
7515            "removal arms diverged in node order"
7516        );
7517
7518        let median = |samples: &mut Vec<f64>| -> f64 {
7519            samples.sort_by(f64::total_cmp);
7520            samples[samples.len() / 2]
7521        };
7522        let (mut cur, mut pro, mut nul) = (Vec::new(), Vec::new(), Vec::new());
7523        for _ in 0..7 {
7524            cur.push(time_current_removals(&stream));
7525            pro.push(time_proto_removals(&stream));
7526            nul.push(time_current_removals(&stream));
7527        }
7528        // br-r37-c1-thp6w S9: slab arm — must be ~parity with the String store.
7529        let time_slab_removals = |stream: &[(usize, usize, usize)]| -> f64 {
7530            let mut slab = super::multigraph_storage::MgSlabStorage::bulk_load_int_prefix(
7531                n,
7532                stream.iter().copied(),
7533            );
7534            let t0 = Instant::now();
7535            for name in &victims {
7536                assert!(slab.remove_node(name));
7537            }
7538            let dt = t0.elapsed().as_secs_f64();
7539            black_box(slab.edge_count);
7540            dt
7541        };
7542        let mut g2 = MultiGraph::strict();
7543        let _ = g2.extend_fresh_int_prefix_keyed_edges_unrecorded(n, stream.iter().copied());
7544        let mut slab = super::multigraph_storage::MgSlabStorage::bulk_load_int_prefix(
7545            n,
7546            stream.iter().copied(),
7547        );
7548        for name in &victims {
7549            assert!(g2.remove_node(name));
7550            assert!(slab.remove_node(name));
7551        }
7552        assert_eq!(
7553            slab.edge_count,
7554            g2.edge_count(),
7555            "slab removal arm diverged in edge_count"
7556        );
7557        assert_eq!(
7558            slab.node_order
7559                .keys()
7560                .map(String::as_str)
7561                .collect::<Vec<_>>(),
7562            g2.nodes_ordered(),
7563            "slab removal arm diverged in node order"
7564        );
7565        let mut sla = Vec::new();
7566        for _ in 0..7 {
7567            sla.push(time_slab_removals(&stream));
7568        }
7569        let msl = median(&mut sla);
7570
7571        let (mc, mp, mn) = (median(&mut cur), median(&mut pro), median(&mut nul));
7572        println!(
7573            "THP6W_S8_REMOVAL_AB n={n} m={} removals={} current={mc:.6}s proto_renumber={mp:.6}s slab={msl:.6}s ratio_proto_over_current={:.3} ratio_slab_over_current={:.3} null={:.3}",
7574            stream.len(),
7575            victims.len(),
7576            mp / mc,
7577            msl / mc,
7578            mc / mn,
7579        );
7580    }
7581
7582    /// br-r37-c1-thp6w S9 helper: full observable-order parity between the
7583    /// real MultiGraph and the slab prototype (order from `node_order`, rows
7584    /// resolved slot -> name), plus slab self-consistency (live slots named,
7585    /// free slots tombstoned).
7586    fn assert_slab_matches_mg(mg: &MultiGraph, slab: &super::multigraph_storage::MgSlabStorage) {
7587        let mg_node_order = mg.nodes_ordered();
7588        assert_eq!(
7589            slab.node_order
7590                .keys()
7591                .map(String::as_str)
7592                .collect::<Vec<_>>(),
7593            mg_node_order,
7594            "slab node order"
7595        );
7596        for (name, slot) in slab.node_order.iter() {
7597            assert_eq!(
7598                slab.node_order.name(slot),
7599                Some(name.as_str()),
7600                "slot names desync for {name}"
7601            );
7602        }
7603        for &free in slab.node_order.free_slots() {
7604            assert!(
7605                slab.node_order.name(free).is_none(),
7606                "free slot {free} still named"
7607            );
7608            assert!(slab.rows[free].is_empty(), "free slot {free} has row cells");
7609            assert!(
7610                slab.node_attrs[free].is_empty(),
7611                "free slot {free} still has node attrs"
7612            );
7613        }
7614        for &name in &mg_node_order {
7615            assert_eq!(
7616                slab.node_attrs_for(name),
7617                mg.node_attrs(name),
7618                "slab node attrs for {name}"
7619            );
7620        }
7621        for (pos, &name) in mg_node_order.iter().enumerate() {
7622            let mg_row = mg.neighbors(name).unwrap_or_default();
7623            assert_eq!(
7624                slab.neighbor_names(pos),
7625                mg_row,
7626                "slab row order for {name}"
7627            );
7628            for &v in &mg_row {
7629                let mg_keys = mg.edge_keys_vec(name, v);
7630                assert_eq!(
7631                    slab.key_order(name, v),
7632                    mg_keys,
7633                    "slab key order for ({name}, {v})"
7634                );
7635            }
7636        }
7637        assert_eq!(slab.edge_count, mg.edge_count, "slab edge_count");
7638    }
7639
7640    /// br-r37-c1-thp6w S9 PARITY GAUNTLET (slab + slot recycling): the
7641    /// S8-mandated layout must stay byte-identical to the real MultiGraph
7642    /// through the S7 removal shapes AND the slab-specific hazards — slot
7643    /// reuse after removal (fresh name AND the removed name re-added must
7644    /// append at the END of node order with no phantom edges from the slot's
7645    /// previous occupant).
7646    #[test]
7647    fn thp6w_s9_slab_recycling_parity_gauntlet() {
7648        use super::multigraph_storage::MgSlabStorage;
7649
7650        let stream: Vec<(&str, &str, usize)> = vec![
7651            ("2", "10", 0),
7652            ("10", "2", 1),
7653            ("1", "1", 0),
7654            ("2", "3", 0),
7655            ("3", "2", 0),
7656            ("10", "1", 5),
7657            ("0", "2", 0),
7658            ("2", "10", 1),
7659            ("4", "5", 2),
7660            ("5", "4", 0),
7661            ("1", "1", 1),
7662        ];
7663        let mut mg = MultiGraph::strict();
7664        let _ = mg.extend_keyed_edges_with_attrs_unrecorded(
7665            stream
7666                .iter()
7667                .map(|(l, r, k)| ((*l).to_owned(), (*r).to_owned(), *k, AttrMap::new())),
7668        );
7669        let mut slab = MgSlabStorage::new();
7670        for (l, r, k) in &stream {
7671            slab.add_keyed_edge(l, r, *k, AttrMap::new());
7672        }
7673        assert_slab_matches_mg(&mg, &slab);
7674
7675        // S7 removal shapes.
7676        assert_eq!(
7677            mg.remove_edge("4", "5", None),
7678            slab.remove_edge("4", "5", None)
7679        );
7680        assert_slab_matches_mg(&mg, &slab);
7681        assert_eq!(
7682            mg.remove_edge("2", "10", Some(0)),
7683            slab.remove_edge("2", "10", Some(0))
7684        );
7685        assert_slab_matches_mg(&mg, &slab);
7686        assert_eq!(
7687            mg.remove_edge("2", "10", None),
7688            slab.remove_edge("2", "10", None)
7689        );
7690        assert_slab_matches_mg(&mg, &slab);
7691        assert_eq!(
7692            mg.remove_edge("1", "1", Some(0)),
7693            slab.remove_edge("1", "1", Some(0))
7694        );
7695        assert_slab_matches_mg(&mg, &slab);
7696        assert_eq!(
7697            mg.remove_edge("0", "10", None),
7698            slab.remove_edge("0", "10", None)
7699        );
7700        assert_eq!(
7701            mg.remove_edge("2", "3", Some(9)),
7702            slab.remove_edge("2", "3", Some(9))
7703        );
7704        assert_slab_matches_mg(&mg, &slab);
7705
7706        // Tombstone a mid-order node with live parallel edges...
7707        assert_eq!(mg.remove_node("2"), slab.remove_node("2"));
7708        assert_slab_matches_mg(&mg, &slab);
7709        // ...then RECYCLE its slot with a FRESH name: must append at the END
7710        // of node order and carry no phantom adjacency from the old occupant.
7711        mg.extend_keyed_edges_with_attrs_unrecorded(vec![(
7712            "fresh".to_owned(),
7713            "3".to_owned(),
7714            0,
7715            AttrMap::new(),
7716        )]);
7717        slab.add_keyed_edge("fresh", "3", 0, AttrMap::new());
7718        assert_slab_matches_mg(&mg, &slab);
7719        // ...and re-add the REMOVED name too (recycles another slot or a fresh
7720        // one; order must be append-at-end either way).
7721        mg.extend_keyed_edges_with_attrs_unrecorded(vec![(
7722            "2".to_owned(),
7723            "fresh".to_owned(),
7724            7,
7725            AttrMap::new(),
7726        )]);
7727        slab.add_keyed_edge("2", "fresh", 7, AttrMap::new());
7728        assert_slab_matches_mg(&mg, &slab);
7729        // Remove the recycled node again (double-recycle path).
7730        assert_eq!(mg.remove_node("fresh"), slab.remove_node("fresh"));
7731        assert_slab_matches_mg(&mg, &slab);
7732        // Self-loop node and end nodes.
7733        assert_eq!(mg.remove_node("1"), slab.remove_node("1"));
7734        assert_slab_matches_mg(&mg, &slab);
7735        assert_eq!(mg.remove_node("10"), slab.remove_node("10"));
7736        assert_slab_matches_mg(&mg, &slab);
7737        // Missing-node refusal parity.
7738        assert_eq!(mg.remove_node("gone"), slab.remove_node("gone"));
7739        assert_slab_matches_mg(&mg, &slab);
7740    }
7741
7742    /// br-r37-c1-thp6w S10 helper: the ONLY observed edge order — walk-order
7743    /// emission with String-lex canonical orientation — must be byte-identical
7744    /// between the real MultiGraph and the slab layout.
7745    fn assert_slab_edges_ordered_matches_mg(
7746        mg: &MultiGraph,
7747        slab: &super::multigraph_storage::MgSlabStorage,
7748    ) {
7749        let mg_ordered: Vec<(&str, &str, usize)> = mg
7750            .edges_ordered_borrowed()
7751            .into_iter()
7752            .map(|(l, r, k, _)| (l, r, k))
7753            .collect();
7754        assert_eq!(
7755            slab.edges_ordered_names(),
7756            mg_ordered,
7757            "edges_ordered walk emission must match"
7758        );
7759        // br-r37-c1-thp6w S12: the attrs-carrying walk must match in FULL,
7760        // including per-cell attr payloads.
7761        assert_eq!(
7762            slab.edges_ordered_borrowed(),
7763            mg.edges_ordered_borrowed(),
7764            "attrs-carrying edges_ordered walk must match"
7765        );
7766    }
7767
7768    /// br-r37-c1-thp6w S10 PARITY GAUNTLET (copy-walk reorder + snapshot
7769    /// orientation): the two remaining tie-break axes for the real flip.
7770    /// `edges_ordered` (walk emission with lex-canonical orientation) and
7771    /// `reorder_rows_for_nx_copy_walk` (the MultiGraph.copy row-order
7772    /// contract) must stay byte-identical through reorders, post-reorder
7773    /// mutations, removals, and slot recycling.
7774    #[test]
7775    fn thp6w_s10_copy_walk_and_snapshot_orientation_gauntlet() {
7776        use super::multigraph_storage::MgSlabStorage;
7777
7778        let stream: Vec<(&str, &str, usize)> = vec![
7779            ("2", "10", 0),
7780            ("10", "2", 1),
7781            ("1", "1", 0),
7782            ("2", "3", 0),
7783            ("10", "1", 5),
7784            ("0", "2", 0),
7785            ("4", "5", 2),
7786            ("5", "4", 0),
7787            ("3", "0", 0),
7788            ("1", "1", 1),
7789            ("5", "0", 3),
7790        ];
7791        let mut mg = MultiGraph::strict();
7792        let _ = mg.extend_keyed_edges_with_attrs_unrecorded(
7793            stream
7794                .iter()
7795                .map(|(l, r, k)| ((*l).to_owned(), (*r).to_owned(), *k, AttrMap::new())),
7796        );
7797        let mut slab = MgSlabStorage::new();
7798        for (l, r, k) in &stream {
7799            slab.add_keyed_edge(l, r, *k, AttrMap::new());
7800        }
7801        assert_slab_matches_mg(&mg, &slab);
7802        assert_slab_edges_ordered_matches_mg(&mg, &slab);
7803
7804        // THE copy-walk reorder (MultiGraph.copy row-order contract).
7805        mg.reorder_rows_for_nx_copy_walk();
7806        slab.reorder_rows_for_nx_copy_walk();
7807        assert_slab_matches_mg(&mg, &slab);
7808        assert_slab_edges_ordered_matches_mg(&mg, &slab);
7809
7810        // Reorder must be idempotent on both sides.
7811        mg.reorder_rows_for_nx_copy_walk();
7812        slab.reorder_rows_for_nx_copy_walk();
7813        assert_slab_matches_mg(&mg, &slab);
7814        assert_slab_edges_ordered_matches_mg(&mg, &slab);
7815
7816        // Post-reorder mutations: new edges append after the reordered cells.
7817        mg.extend_keyed_edges_with_attrs_unrecorded(vec![
7818            ("0".to_owned(), "9".to_owned(), 0, AttrMap::new()),
7819            ("9".to_owned(), "2".to_owned(), 0, AttrMap::new()),
7820        ]);
7821        slab.add_keyed_edge("0", "9", 0, AttrMap::new());
7822        slab.add_keyed_edge("9", "2", 0, AttrMap::new());
7823        assert_slab_matches_mg(&mg, &slab);
7824        assert_slab_edges_ordered_matches_mg(&mg, &slab);
7825
7826        // Removal + slot recycling, then reorder AGAIN on the recycled state.
7827        assert_eq!(mg.remove_node("2"), slab.remove_node("2"));
7828        mg.extend_keyed_edges_with_attrs_unrecorded(vec![(
7829            "fresh".to_owned(),
7830            "9".to_owned(),
7831            0,
7832            AttrMap::new(),
7833        )]);
7834        slab.add_keyed_edge("fresh", "9", 0, AttrMap::new());
7835        assert_slab_matches_mg(&mg, &slab);
7836        assert_slab_edges_ordered_matches_mg(&mg, &slab);
7837        mg.reorder_rows_for_nx_copy_walk();
7838        slab.reorder_rows_for_nx_copy_walk();
7839        assert_slab_matches_mg(&mg, &slab);
7840        assert_slab_edges_ordered_matches_mg(&mg, &slab);
7841    }
7842
7843    /// br-r37-c1-thp6w S12 A/B: the `edges_ordered_borrowed` read on the
7844    /// String store (per-cell `EdgeKeyRef` String-pair hashing + String-pair
7845    /// seen-set probes) vs the slab walk (usize hashing throughout). Same
7846    /// graph state on both sides (slab via `from_string_state`), full-output
7847    /// equality asserted outside timing, paired-interleaved with a null arm.
7848    /// `cargo test --release -p fnx-classes --lib thp6w_s12_edges_ordered_read_ab -- --ignored --nocapture`
7849    #[test]
7850    #[ignore = "paired A/B benchmark; run --release with --ignored --nocapture"]
7851    fn thp6w_s12_edges_ordered_read_ab() {
7852        use std::collections::HashMap;
7853        use std::hint::black_box;
7854        use std::time::Instant;
7855
7856        fn push_edge(
7857            stream: &mut Vec<(usize, usize, usize)>,
7858            key_counter: &mut HashMap<(usize, usize), usize>,
7859            u: usize,
7860            v: usize,
7861        ) {
7862            let entry = key_counter.entry((u.min(v), u.max(v))).or_insert(0);
7863            stream.push((u, v, *entry));
7864            *entry += 1;
7865        }
7866
7867        let n = 20000usize;
7868        let mut stream = Vec::new();
7869        let mut key_counter = HashMap::new();
7870        for i in 0..n {
7871            push_edge(&mut stream, &mut key_counter, i, (i + 1) % n);
7872        }
7873        let mut state = 0x2545_F491_4F6C_DD1D_u64;
7874        let draw = |state: &mut u64| -> usize {
7875            *state = state
7876                .wrapping_mul(6_364_136_223_846_793_005)
7877                .wrapping_add(1_442_695_040_888_963_407);
7878            usize::try_from(*state >> 33).unwrap() % n
7879        };
7880        for _ in 0..3 * n {
7881            let u = draw(&mut state);
7882            let v = draw(&mut state);
7883            push_edge(&mut stream, &mut key_counter, u, v);
7884        }
7885
7886        let mut g = MultiGraph::strict();
7887        let _ = g.extend_fresh_int_prefix_keyed_edges_unrecorded(n, stream.iter().copied());
7888        let slab = g.storage.clone();
7889        assert_eq!(
7890            slab.edges_ordered_borrowed(),
7891            g.edges_ordered_borrowed(),
7892            "arms must emit identical sequences"
7893        );
7894
7895        let time_string = || -> f64 {
7896            let t0 = Instant::now();
7897            let out = g.edges_ordered_borrowed();
7898            let dt = t0.elapsed().as_secs_f64();
7899            black_box(out.len());
7900            dt
7901        };
7902        let time_slab = || -> f64 {
7903            let t0 = Instant::now();
7904            let out = slab.edges_ordered_borrowed();
7905            let dt = t0.elapsed().as_secs_f64();
7906            black_box(out.len());
7907            dt
7908        };
7909        let median = |samples: &mut Vec<f64>| -> f64 {
7910            samples.sort_by(f64::total_cmp);
7911            samples[samples.len() / 2]
7912        };
7913        let (mut cur, mut sla, mut nul) = (Vec::new(), Vec::new(), Vec::new());
7914        for _ in 0..9 {
7915            cur.push(time_string());
7916            sla.push(time_slab());
7917            nul.push(time_string());
7918        }
7919        let (mc, ms, mn) = (median(&mut cur), median(&mut sla), median(&mut nul));
7920        println!(
7921            "THP6W_S12_EDGES_ORDERED_AB n={n} m={} string={mc:.6}s slab={ms:.6}s ratio_string_over_slab={:.3} null={:.3}",
7922            stream.len(),
7923            mc / ms,
7924            mc / mn,
7925        );
7926    }
7927
7928    /// br-r37-c1-thp6w S13 A/B: the INDEX-space `edges_ordered_indices_borrowed`
7929    /// read — String store (per-edge `nodes.get_index_of` String hash + `EdgeKey`
7930    /// pair hash) vs the slab (hash-free `slot->position` array + usize walk).
7931    /// Same graph state, output equality asserted outside timing, paired-
7932    /// interleaved with a null arm.
7933    /// `cargo test --release -p fnx-classes --lib thp6w_s13_edges_ordered_indices_ab -- --ignored --nocapture`
7934    #[test]
7935    #[ignore = "paired A/B benchmark; run --release with --ignored --nocapture"]
7936    fn thp6w_s13_edges_ordered_indices_ab() {
7937        use std::collections::HashMap;
7938        use std::hint::black_box;
7939        use std::time::Instant;
7940
7941        fn push_edge(
7942            stream: &mut Vec<(usize, usize, usize)>,
7943            key_counter: &mut HashMap<(usize, usize), usize>,
7944            u: usize,
7945            v: usize,
7946        ) {
7947            let entry = key_counter.entry((u.min(v), u.max(v))).or_insert(0);
7948            stream.push((u, v, *entry));
7949            *entry += 1;
7950        }
7951
7952        let n = 20000usize;
7953        let mut stream = Vec::new();
7954        let mut key_counter = HashMap::new();
7955        for i in 0..n {
7956            push_edge(&mut stream, &mut key_counter, i, (i + 1) % n);
7957        }
7958        let mut state = 0x2545_F491_4F6C_DD1D_u64;
7959        let draw = |state: &mut u64| -> usize {
7960            *state = state
7961                .wrapping_mul(6_364_136_223_846_793_005)
7962                .wrapping_add(1_442_695_040_888_963_407);
7963            usize::try_from(*state >> 33).unwrap() % n
7964        };
7965        for _ in 0..3 * n {
7966            let u = draw(&mut state);
7967            let v = draw(&mut state);
7968            push_edge(&mut stream, &mut key_counter, u, v);
7969        }
7970
7971        let mut g = MultiGraph::strict();
7972        let _ = g.extend_fresh_int_prefix_keyed_edges_unrecorded(n, stream.iter().copied());
7973        let slab = g.storage.clone();
7974        assert_eq!(
7975            slab.edges_ordered_indices_borrowed(),
7976            g.edges_ordered_indices_borrowed(),
7977            "arms must emit identical index sequences"
7978        );
7979
7980        let time_string = || -> f64 {
7981            let t0 = Instant::now();
7982            let out = g.edges_ordered_indices_borrowed();
7983            let dt = t0.elapsed().as_secs_f64();
7984            black_box(out.len());
7985            dt
7986        };
7987        let time_slab = || -> f64 {
7988            let t0 = Instant::now();
7989            let out = slab.edges_ordered_indices_borrowed();
7990            let dt = t0.elapsed().as_secs_f64();
7991            black_box(out.len());
7992            dt
7993        };
7994        let median = |samples: &mut Vec<f64>| -> f64 {
7995            samples.sort_by(f64::total_cmp);
7996            samples[samples.len() / 2]
7997        };
7998        let (mut cur, mut sla, mut nul) = (Vec::new(), Vec::new(), Vec::new());
7999        for _ in 0..9 {
8000            cur.push(time_string());
8001            sla.push(time_slab());
8002            nul.push(time_string());
8003        }
8004        let (mc, ms, mn) = (median(&mut cur), median(&mut sla), median(&mut nul));
8005        println!(
8006            "THP6W_S13_EDGES_ORDERED_INDICES_AB n={n} m={} string={mc:.6}s slab={ms:.6}s ratio_string_over_slab={:.3} null={:.3}",
8007            stream.len(),
8008            mc / ms,
8009            mc / mn,
8010        );
8011    }
8012
8013    fn assert_multigraph_storage_invariants(graph: &MultiGraph) {
8014        let storage = &graph.storage;
8015        assert_eq!(storage.node_order.slot_count(), storage.node_attrs.len());
8016        assert_eq!(storage.node_order.slot_count(), storage.rows.len());
8017        assert_eq!(graph.edge_count, storage.edge_count);
8018        assert_eq!(
8019            storage.edge_count,
8020            storage
8021                .edges
8022                .values()
8023                .map(super::multigraph_storage::CompactBucket::len)
8024                .sum::<usize>()
8025        );
8026
8027        let mut live_slots = vec![false; storage.node_order.slot_count()];
8028        for (position, (name, slot)) in storage.node_order.iter().enumerate() {
8029            assert!(!live_slots[slot], "slot {slot} is assigned twice");
8030            live_slots[slot] = true;
8031            assert_eq!(storage.node_order.name(slot), Some(name.as_str()));
8032            // yr2oc.1: every positional reader agrees with the order walk.
8033            assert_eq!(graph.get_node_index(name), Some(position));
8034            assert_eq!(graph.get_node_name(position), Some(name.as_str()));
8035            assert_eq!(storage.slot_at_position(position), Some(slot));
8036        }
8037        assert_eq!(
8038            live_slots.iter().filter(|&&live| live).count(),
8039            graph.node_count()
8040        );
8041        assert_eq!(graph.get_node_name(graph.node_count()), None);
8042        assert_eq!(storage.slot_at_position(graph.node_count()), None);
8043
8044        let mut free_slots = BTreeSet::new();
8045        for &slot in storage.node_order.free_slots() {
8046            assert!(free_slots.insert(slot), "free slot {slot} listed twice");
8047            assert!(!live_slots[slot], "live slot {slot} is also free");
8048            assert!(storage.node_order.name(slot).is_none());
8049            assert!(storage.node_attrs[slot].is_empty());
8050            assert!(storage.rows[slot].is_empty());
8051        }
8052        assert_eq!(
8053            free_slots.len() + graph.node_count(),
8054            storage.node_order.slot_count(),
8055            "a slot is neither live nor free"
8056        );
8057
8058        for (left, row) in storage.rows.iter().enumerate() {
8059            if !live_slots[left] {
8060                assert!(row.is_empty(), "tombstoned slot {left} has neighbors");
8061                continue;
8062            }
8063            for (&right, keys) in row {
8064                assert!(live_slots[right], "row points to tombstoned slot {right}");
8065                let pair = (left.min(right), left.max(right));
8066                let bucket = storage
8067                    .edges
8068                    .get(&pair)
8069                    .expect("every row cell must have an edge bucket");
8070                assert_eq!(keys.order(), bucket.key_order());
8071                if left != right {
8072                    assert_eq!(
8073                        storage.rows[right]
8074                            .get(&left)
8075                            .expect("undirected reverse row is missing")
8076                            .order(),
8077                        keys.order()
8078                    );
8079                }
8080            }
8081        }
8082        for (&(left, right), bucket) in &storage.edges {
8083            assert!(live_slots[left] && live_slots[right]);
8084            assert_eq!(
8085                storage.rows[left]
8086                    .get(&right)
8087                    .expect("edge bucket missing left row")
8088                    .order(),
8089                bucket.key_order()
8090            );
8091            if left != right {
8092                assert_eq!(
8093                    storage.rows[right]
8094                        .get(&left)
8095                        .expect("edge bucket missing right row")
8096                        .order(),
8097                    bucket.key_order()
8098                );
8099            }
8100        }
8101    }
8102
8103    #[test]
8104    fn thp6w_s35_slab_is_sole_store_mutation_gauntlet() {
8105        let attr = |key: &str, value: i64| {
8106            let mut attrs = AttrMap::new();
8107            attrs.insert(key.to_owned(), fnx_runtime::CgseValue::Int(value));
8108            attrs
8109        };
8110
8111        let mut graph = MultiGraph::strict();
8112        assert!(graph.add_node_with_attrs("iso", attr("kind", 1)));
8113        assert_eq!(
8114            graph.add_edge_with_key_and_attrs("b", "a", 3, attr("weight", 2)),
8115            Ok(3)
8116        );
8117        assert_eq!(
8118            graph.add_edge_with_key_and_attrs("a", "b", 3, attr("color", 7)),
8119            Ok(3)
8120        );
8121        assert_eq!(
8122            graph.add_edge_with_key_and_attrs("a", "b", 0, AttrMap::new()),
8123            Ok(0)
8124        );
8125        assert_eq!(
8126            graph.add_edge_with_key_and_attrs("c", "c", 5, attr("weight", 11)),
8127            Ok(5)
8128        );
8129        assert_eq!(graph.nodes_ordered(), vec!["iso", "b", "a", "c"]);
8130        assert_eq!(graph.edge_keys_vec("a", "b"), vec![3, 0]);
8131        assert_eq!(
8132            graph.edge_attrs("a", "b", 3),
8133            Some(&{
8134                let mut attrs = attr("weight", 2);
8135                attrs.extend(attr("color", 7));
8136                attrs
8137            })
8138        );
8139        assert_eq!(graph.number_of_selfloops(), 1);
8140        assert_eq!(graph.degree("c"), 2);
8141        assert_eq!(graph.isolates(), vec!["iso"]);
8142        assert_multigraph_storage_invariants(&graph);
8143        assert_int_adjacency_matches(&graph);
8144
8145        let baseline = graph.snapshot();
8146        let cloned = graph.clone_with_fresh_policy();
8147        assert_eq!(cloned.snapshot(), baseline);
8148        assert_multigraph_storage_invariants(&cloned);
8149
8150        let revision = graph.revision();
8151        assert!(graph.replace_edge_attrs("a", "b", 3, attr("replacement", 9)));
8152        assert_eq!(graph.revision(), revision + 1);
8153        let revision = graph.revision();
8154        assert!(graph.replace_edge_attrs("a", "b", 3, attr("replacement", 9)));
8155        assert_eq!(
8156            graph.revision(),
8157            revision,
8158            "no-op edge replace bumped revision"
8159        );
8160        assert!(graph.replace_node_attrs("iso", attr("kind", 4)));
8161        assert!(graph.remove_edge("b", "a", Some(3)));
8162        assert_eq!(graph.edge_keys_vec("a", "b"), vec![0]);
8163        assert!(graph.remove_node("b"));
8164        assert!(!graph.has_edge("a", "b"));
8165        assert_eq!(graph.nodes_ordered(), vec!["iso", "a", "c"]);
8166        assert_eq!(graph.add_edge("d", "a"), Ok(0));
8167        assert_eq!(graph.nodes_ordered(), vec!["iso", "a", "c", "d"]);
8168        assert_multigraph_storage_invariants(&graph);
8169
8170        let (removed_nodes, removed_edges) = graph.remove_nodes_from(["iso", "c", "missing"]);
8171        assert_eq!((removed_nodes, removed_edges), (2, 1));
8172        assert_eq!(graph.nodes_ordered(), vec!["a", "d"]);
8173        assert_eq!(graph.edge_count(), 1);
8174        assert_multigraph_storage_invariants(&graph);
8175        graph.clear_edges();
8176        assert_eq!(graph.edge_count(), 0);
8177        assert_eq!(graph.isolates(), vec!["a", "d"]);
8178        assert_multigraph_storage_invariants(&graph);
8179    }
8180
8181    #[test]
8182    fn thp6w_s35_fresh_loaders_populate_sole_store() {
8183        let mut integer = MultiGraph::strict();
8184        assert_eq!(
8185            integer.extend_fresh_int_prefix_keyed_edges_unrecorded(
8186                4,
8187                [(0, 1, 0), (1, 0, 1), (2, 2, 0), (3, 0, 0)]
8188            ),
8189            4
8190        );
8191        assert_eq!(integer.nodes_ordered(), vec!["0", "1", "2", "3"]);
8192        assert_eq!(integer.edge_keys_vec("0", "1"), vec![0, 1]);
8193        assert_eq!(integer.number_of_selfloops(), 1);
8194        assert_multigraph_storage_invariants(&integer);
8195        assert_int_adjacency_matches(&integer);
8196
8197        let mut weight = AttrMap::new();
8198        weight.insert("weight".to_owned(), fnx_runtime::CgseValue::Int(2));
8199        let mut color = AttrMap::new();
8200        color.insert("color".to_owned(), fnx_runtime::CgseValue::Int(7));
8201        let mut attributed = MultiGraph::strict();
8202        assert_eq!(
8203            attributed.extend_fresh_index_keyed_edges_with_attrs_unrecorded(
8204                ["b", "a", "c"].into_iter().map(str::to_owned),
8205                [
8206                    (0, 1, 4, weight),
8207                    (1, 0, 4, color),
8208                    (2, 2, 0, AttrMap::new()),
8209                    (0, 9, 0, AttrMap::new()),
8210                ]
8211            ),
8212            2
8213        );
8214        assert_eq!(attributed.nodes_ordered(), vec!["b", "a", "c"]);
8215        assert_eq!(attributed.edge_count(), 2);
8216        assert_eq!(attributed.edge_keys_vec("a", "b"), vec![4]);
8217        let attrs = attributed
8218            .edge_attrs("a", "b", 4)
8219            .expect("merged attributed edge");
8220        assert_eq!(attrs.len(), 2);
8221        assert!(attrs.contains_key("weight"));
8222        assert!(attrs.contains_key("color"));
8223        assert_multigraph_storage_invariants(&attributed);
8224    }
8225
8226    /// yr2oc.1: runs of O(1) multigraph removals, interleaved with insertions
8227    /// that reuse the freed slots and with batch removals, keep every
8228    /// positional reader and the stored edges in step with a name-level model.
8229    #[test]
8230    fn multigraph_removal_runs_keep_positional_readers_in_step_with_names() {
8231        use std::collections::BTreeMap;
8232        let mut state = 0x9e37_79b9_7f4a_7c15_u64;
8233        let mut next = |bound: usize| {
8234            state ^= state << 13;
8235            state ^= state >> 7;
8236            state ^= state << 17;
8237            usize::try_from(state % bound as u64).unwrap()
8238        };
8239        let pair = |u: &str, v: &str| {
8240            if u <= v {
8241                (u.to_owned(), v.to_owned())
8242            } else {
8243                (v.to_owned(), u.to_owned())
8244            }
8245        };
8246        for round in 0..30 {
8247            let n = 10 + round % 17;
8248            let mut g = MultiGraph::strict();
8249            let mut order: Vec<String> = Vec::new();
8250            let mut edges: BTreeMap<(String, String), usize> = BTreeMap::new();
8251            let mut fresh = 0;
8252            let add_edge = |g: &mut MultiGraph,
8253                            order: &mut Vec<String>,
8254                            edges: &mut BTreeMap<(String, String), usize>,
8255                            u: String,
8256                            v: String| {
8257                for node in [&u, &v] {
8258                    if !order.contains(node) {
8259                        order.push(node.clone());
8260                    }
8261                }
8262                *edges.entry(pair(&u, &v)).or_default() += 1;
8263                g.add_edge(u, v).expect("edge");
8264            };
8265            for _ in 0..3 * n {
8266                let (u, v) = (next(n), next(n));
8267                add_edge(
8268                    &mut g,
8269                    &mut order,
8270                    &mut edges,
8271                    format!("n{u}"),
8272                    format!("n{v}"),
8273                );
8274            }
8275            for _ in 0..4 * n {
8276                match next(6) {
8277                    0..=2 if !order.is_empty() => {
8278                        let victim = order.remove(next(order.len()));
8279                        edges.retain(|(u, v), _| *u != victim && *v != victim);
8280                        assert!(g.remove_node(&victim));
8281                        assert!(!g.remove_node(&victim));
8282                    }
8283                    3 | 4 => {
8284                        let u = format!("f{fresh}");
8285                        fresh += 1;
8286                        let v = if order.is_empty() || next(4) == 0 {
8287                            u.clone()
8288                        } else {
8289                            order[next(order.len())].clone()
8290                        };
8291                        add_edge(&mut g, &mut order, &mut edges, u, v);
8292                    }
8293                    5 if order.len() > 2 => {
8294                        let doomed = [order[next(order.len())].clone(), "absent".to_owned()];
8295                        let removed = g.remove_nodes_from(doomed.iter().map(String::as_str));
8296                        assert_eq!(removed.0, 1);
8297                        order.retain(|name| *name != doomed[0]);
8298                        edges.retain(|(u, v), _| *u != doomed[0] && *v != doomed[0]);
8299                    }
8300                    _ => {}
8301                }
8302                assert_eq!(g.nodes_ordered(), order, "round {round}");
8303                assert_eq!(g.edge_count(), edges.values().sum::<usize>());
8304                for ((u, v), &count) in &edges {
8305                    assert_eq!(g.edge_keys_vec(u, v).len(), count, "({u}, {v})");
8306                }
8307                assert_multigraph_storage_invariants(&g);
8308                assert_int_adjacency_matches(&g);
8309            }
8310        }
8311    }
8312
8313    /// br-r37-c1-thp6w S14: a fresh bulk-built graph must be WARM from birth
8314    /// (the slab co-built during construction), byte-identical, and stay warm
8315    /// through instrumented mutations. NOTE: `edges_ordered_borrowed` is a
8316    /// ROUTED read (S13) — comparing it against the shadow would be circular
8317    /// with a warm shadow, so this gauntlet uses only unrouted ground-truth
8318    /// accessors (`nodes_ordered`, `neighbors_iter`, `edge_keys_vec`); the
8319    /// walk itself is gated non-circularly by the S13 route gate.
8320    #[cfg(any())]
8321    #[test]
8322    fn thp6w_s14_fresh_bulk_graph_is_warm_from_birth() {
8323        let mut g = MultiGraph::strict();
8324        let stream = vec![
8325            (0usize, 1usize, 0usize),
8326            (1, 2, 0),
8327            (0, 1, 1), // parallel
8328            (2, 2, 0), // self-loop
8329            (3, 0, 0),
8330        ];
8331        let _ = g.extend_fresh_int_prefix_keyed_edges_unrecorded(5, stream.into_iter());
8332        assert!(
8333            g.slab_shadow_is_warm(),
8334            "fresh bulk graph must be born warm"
8335        );
8336        {
8337            let shadow = g.slab_shadow.as_deref().expect("shadow present");
8338            assert_slab_matches_mg(&g, &shadow.1);
8339        }
8340        // Instrumented mutations keep the born-warm shadow warm.
8341        let _ = g.add_edge("2", "4");
8342        assert!(g.slab_shadow_is_warm());
8343        assert!(g.remove_edge("0", "1", None));
8344        assert!(g.slab_shadow_is_warm());
8345        assert!(g.remove_node("1"));
8346        assert!(g.slab_shadow_is_warm());
8347        let shadow = g.slab_shadow.as_deref().expect("shadow present");
8348        assert_slab_matches_mg(&g, &shadow.1);
8349
8350        // br-r37-c1-thp6w S17: a node-attr set now ADVANCES the warm shadow in
8351        // place (was stale->rebuild in S16). Existing node "2":
8352        let mut nattrs = AttrMap::new();
8353        nattrs.insert("color".to_owned(), fnx_runtime::CgseValue::Int(7));
8354        let _ = g.add_node_with_attrs("2", nattrs);
8355        assert!(
8356            g.slab_shadow_is_warm(),
8357            "node-attr set must advance (not stale) the warm shadow"
8358        );
8359        {
8360            let shadow = g.slab_shadow.as_deref().expect("shadow present");
8361            assert_slab_matches_mg(&g, &shadow.1);
8362            assert_eq!(
8363                shadow.1.node_attrs_for("2"),
8364                g.nodes.get("2"),
8365                "advanced shadow must carry the set node attr for node 2"
8366            );
8367        }
8368        // A node-attr set that CREATES a new node advances the shadow too.
8369        let mut nattrs2 = AttrMap::new();
8370        nattrs2.insert("w".to_owned(), fnx_runtime::CgseValue::Int(9));
8371        let _ = g.add_node_with_attrs("attr_only", nattrs2);
8372        assert!(g.slab_shadow_is_warm());
8373        {
8374            let shadow = g.slab_shadow.as_deref().expect("shadow present");
8375            assert_slab_matches_mg(&g, &shadow.1);
8376            assert_eq!(
8377                shadow.1.node_attrs_for("attr_only"),
8378                g.nodes.get("attr_only"),
8379                "advanced shadow must carry a new attr-only node"
8380            );
8381        }
8382        // A full rebuild still reproduces the same node attrs (from_string_state).
8383        g.sync_slab_shadow();
8384        {
8385            let shadow = g.slab_shadow.as_deref().expect("shadow present");
8386            assert_slab_matches_mg(&g, &shadow.1);
8387        }
8388
8389        // br-r37-c1-thp6w S15: the ATTRIBUTED fresh path is born warm too —
8390        // arbitrary labels, dup-(pair,key) attr merge, out-of-bounds skip.
8391        let mut h = MultiGraph::strict();
8392        let mut w1 = AttrMap::new();
8393        w1.insert("w".to_owned(), fnx_runtime::CgseValue::Int(1));
8394        let mut w2 = AttrMap::new();
8395        w2.insert("x".to_owned(), fnx_runtime::CgseValue::Int(2));
8396        let _ = h.extend_fresh_index_keyed_edges_with_attrs_unrecorded(
8397            vec!["b".to_owned(), "a".to_owned(), "c".to_owned()],
8398            vec![
8399                (0, 1, 0, w1.clone()),
8400                (1, 0, 0, w2),             // dup (pair, key) reversed -> attr merge
8401                (2, 2, 0, w1),             // self-loop
8402                (0, 9, 0, AttrMap::new()), // out-of-bounds -> skipped by BOTH sides
8403                (1, 2, 3, AttrMap::new()), // sparse key
8404            ],
8405        );
8406        assert!(h.slab_shadow_is_warm(), "attributed fresh graph born warm");
8407        let shadow = h.slab_shadow.as_deref().expect("shadow present");
8408        assert_slab_matches_mg(&h, &shadow.1);
8409        assert_eq!(
8410            shadow.1.edges[&(0, 1)].attrs_for(0),
8411            h.edges
8412                .get(&super::EdgeKeyRef::new("b", "a"))
8413                .and_then(|kb| kb.get(&0)),
8414            "merged attrs on the dup cell must match"
8415        );
8416    }
8417
8418    /// br-r37-c1-thp6w S19: batch `remove_nodes_from` advances the warm shadow
8419    /// (per-node slab remove, stable slots) on BOTH its exit paths — the
8420    /// small-fraction incident fast path and the large-fraction whole-graph
8421    /// retain — and never spuriously warms a stale shadow.
8422    #[cfg(any())]
8423    #[test]
8424    fn thp6w_s19_remove_nodes_from_advances_shadow() {
8425        let mut g = MultiGraph::strict();
8426        for i in 0..12u32 {
8427            let _ = g.add_edge(&i.to_string(), &((i + 1) % 12).to_string());
8428        }
8429        let _ = g.add_edge("0", "6"); // a chord
8430        let _ = g.add_edge("3", "3"); // a self-loop
8431        g.sync_slab_shadow();
8432        assert!(g.slab_shadow_is_warm());
8433
8434        // Fast path: small fraction (2*4 <= 12 nodes).
8435        let (rn, _) = g.remove_nodes_from(["3", "9"].into_iter());
8436        assert_eq!(rn, 2);
8437        assert!(
8438            g.slab_shadow_is_warm(),
8439            "remove_nodes_from fast path must advance the shadow"
8440        );
8441        {
8442            let shadow = g.slab_shadow.as_deref().expect("shadow present");
8443            assert_slab_matches_mg(&g, &shadow.1);
8444        }
8445
8446        // Fall-through path: large fraction (5*4 > 10 remaining nodes).
8447        let (rn2, _) = g.remove_nodes_from(["0", "1", "2", "4", "5"].into_iter());
8448        assert_eq!(rn2, 5);
8449        assert!(
8450            g.slab_shadow_is_warm(),
8451            "remove_nodes_from fall-through must advance the shadow"
8452        );
8453        {
8454            let shadow = g.slab_shadow.as_deref().expect("shadow present");
8455            assert_slab_matches_mg(&g, &shadow.1);
8456        }
8457
8458        // A STALE shadow must not be spuriously warmed by the removal.
8459        g.add_node("island"); // structural (empty attrs) -> stales
8460        assert!(!g.slab_shadow_is_warm());
8461        let _ = g.remove_nodes_from(["island"].into_iter());
8462        assert!(
8463            !g.slab_shadow_is_warm(),
8464            "remove_nodes_from must not warm a stale shadow"
8465        );
8466        g.sync_slab_shadow();
8467        let shadow = g.slab_shadow.as_deref().expect("shadow present");
8468        assert_slab_matches_mg(&g, &shadow.1);
8469    }
8470
8471    /// br-r37-c1-thp6w S20: batch `extend_nodes_with_attrs_unrecorded` advances
8472    /// the warm shadow — including the latent attr-only case (no NEW nodes, so
8473    /// no revision bump) where a warm shadow would otherwise carry stale attrs.
8474    #[cfg(any())]
8475    #[test]
8476    fn thp6w_s20_extend_nodes_with_attrs_advances_shadow() {
8477        let mut g = MultiGraph::strict();
8478        for i in 0..6u32 {
8479            let _ = g.add_edge(&i.to_string(), &((i + 1) % 6).to_string());
8480        }
8481        g.sync_slab_shadow();
8482        assert!(g.slab_shadow_is_warm());
8483
8484        let attr = |k: &str, v: i64| {
8485            let mut a = AttrMap::new();
8486            a.insert(k.to_owned(), fnx_runtime::CgseValue::Int(v));
8487            a
8488        };
8489        // Batch: 2 new attr-only nodes + an attr update on existing node "0".
8490        let ins = g.extend_nodes_with_attrs_unrecorded(vec![
8491            ("new_a".to_owned(), attr("c", 1)),
8492            ("new_b".to_owned(), attr("c", 2)),
8493            ("0".to_owned(), attr("k", 3)),
8494        ]);
8495        assert_eq!(ins, 2, "two new nodes");
8496        assert!(
8497            g.slab_shadow_is_warm(),
8498            "node-attr batch must advance the shadow"
8499        );
8500        {
8501            let shadow = g.slab_shadow.as_deref().expect("shadow present");
8502            assert_slab_matches_mg(&g, &shadow.1);
8503            assert_eq!(shadow.1.node_attrs_for("new_a"), g.nodes.get("new_a"));
8504            assert_eq!(shadow.1.node_attrs_for("0"), g.nodes.get("0"));
8505        }
8506
8507        // ATTR-ONLY batch (no new nodes -> no revision bump): the shadow must
8508        // still carry the updated attr, not stay warm-but-stale.
8509        let ins2 = g.extend_nodes_with_attrs_unrecorded(vec![("new_a".to_owned(), attr("c", 99))]);
8510        assert_eq!(ins2, 0, "no new nodes");
8511        assert!(
8512            g.slab_shadow_is_warm(),
8513            "attr-only batch keeps the shadow warm"
8514        );
8515        {
8516            let shadow = g.slab_shadow.as_deref().expect("shadow present");
8517            assert_eq!(
8518                shadow.1.node_attrs_for("new_a"),
8519                g.nodes.get("new_a"),
8520                "attr-only update must be reflected in the (still-warm) shadow"
8521            );
8522        }
8523    }
8524
8525    /// br-r37-c1-thp6w S21: `replace_node_attrs` (node attr OVERWRITE) advances
8526    /// the warm shadow; a no-op replace (no revision bump) and a missing-node
8527    /// replace leave it warm and uncorrupted.
8528    #[cfg(any())]
8529    #[test]
8530    fn thp6w_s21_replace_node_attrs_advances_shadow() {
8531        let attr = |k: &str, v: i64| {
8532            let mut a = AttrMap::new();
8533            a.insert(k.to_owned(), fnx_runtime::CgseValue::Int(v));
8534            a
8535        };
8536        let mut g = MultiGraph::strict();
8537        let _ = g.add_node_with_attrs("x", attr("c", 1));
8538        let _ = g.add_edge("x", "y");
8539        g.sync_slab_shadow();
8540        assert!(g.slab_shadow_is_warm());
8541
8542        // Overwrite x's attrs -> advances the warm shadow.
8543        assert!(g.replace_node_attrs("x", attr("d", 2)));
8544        assert!(
8545            g.slab_shadow_is_warm(),
8546            "replace_node_attrs must advance the shadow"
8547        );
8548        {
8549            let shadow = g.slab_shadow.as_deref().expect("shadow present");
8550            assert_slab_matches_mg(&g, &shadow.1);
8551            assert_eq!(
8552                shadow.1.node_attrs_for("x"),
8553                g.nodes.get("x"),
8554                "shadow must reflect the overwrite"
8555            );
8556        }
8557
8558        // No-op replace (same attrs) does not bump revision; shadow stays warm.
8559        let same = g.nodes.get("x").cloned().unwrap_or_default();
8560        assert!(g.replace_node_attrs("x", same));
8561        assert!(g.slab_shadow_is_warm());
8562
8563        // Replace on a missing node returns false and leaves the shadow warm.
8564        assert!(!g.replace_node_attrs("missing", attr("z", 9)));
8565        assert!(g.slab_shadow_is_warm());
8566        let shadow = g.slab_shadow.as_deref().expect("shadow present");
8567        assert_slab_matches_mg(&g, &shadow.1);
8568    }
8569
8570    /// br-r37-c1-thp6w S22: `replace_edge_attrs` (edge-cell attr OVERWRITE)
8571    /// advances the warm shadow, leaves parallel cells untouched, and a no-op
8572    /// or missing-edge replace leaves the shadow warm/uncorrupted.
8573    #[cfg(any())]
8574    #[test]
8575    fn thp6w_s22_replace_edge_attrs_advances_shadow() {
8576        let attr = |k: &str, v: i64| {
8577            let mut a = AttrMap::new();
8578            a.insert(k.to_owned(), fnx_runtime::CgseValue::Int(v));
8579            a
8580        };
8581        let mut g = MultiGraph::strict();
8582        let _ = g.add_edge_with_key_and_attrs("a", "b", 0, attr("w", 1));
8583        let _ = g.add_edge_with_key_and_attrs("a", "b", 1, attr("w", 2)); // parallel
8584        let _ = g.add_edge("b", "c");
8585        g.sync_slab_shadow();
8586        assert!(g.slab_shadow_is_warm());
8587
8588        // Overwrite the (a,b,0) cell attrs -> advances the warm shadow.
8589        assert!(g.replace_edge_attrs("a", "b", 0, attr("x", 5)));
8590        assert!(
8591            g.slab_shadow_is_warm(),
8592            "replace_edge_attrs must advance the shadow"
8593        );
8594        {
8595            let shadow = g.slab_shadow.as_deref().expect("shadow present");
8596            assert_slab_matches_mg(&g, &shadow.1);
8597            // a=slot0, b=slot1 (fresh graph) -> the a,b bucket is (0,1).
8598            assert_eq!(
8599                shadow.1.edges[&(0, 1)].attrs_for(0),
8600                g.edges
8601                    .get(&super::EdgeKeyRef::new("a", "b"))
8602                    .and_then(|kb| kb.get(&0)),
8603                "advanced shadow must carry the overwritten cell attrs"
8604            );
8605            assert_eq!(
8606                shadow.1.edges[&(0, 1)].attrs_for(1),
8607                g.edges
8608                    .get(&super::EdgeKeyRef::new("a", "b"))
8609                    .and_then(|kb| kb.get(&1)),
8610                "the parallel cell (key 1) must be untouched"
8611            );
8612        }
8613
8614        // No-op replace (same attrs): no revision bump, shadow stays warm.
8615        let same = g.edge_attrs("a", "b", 0).cloned().unwrap_or_default();
8616        assert!(g.replace_edge_attrs("a", "b", 0, same));
8617        assert!(g.slab_shadow_is_warm());
8618
8619        // Missing edge -> false, shadow left warm/uncorrupted.
8620        assert!(!g.replace_edge_attrs("a", "z", 0, attr("q", 1)));
8621        assert!(g.slab_shadow_is_warm());
8622        let shadow = g.slab_shadow.as_deref().expect("shadow present");
8623        assert_slab_matches_mg(&g, &shadow.1);
8624    }
8625
8626    /// br-r37-c1-thp6w S23: feature-on `node_attrs` READS route through the
8627    /// always-warm slab. Verified against the unrouted String ground truth
8628    /// (`g.nodes` direct field access) — non-circular — across present (with
8629    /// and without attrs) and missing nodes, and after a mutation keeps the
8630    /// shadow warm so the routed read stays correct.
8631    #[cfg(any())]
8632    #[test]
8633    fn thp6w_s23_node_attrs_reads_route_to_slab() {
8634        let attr = |k: &str, v: i64| {
8635            let mut a = AttrMap::new();
8636            a.insert(k.to_owned(), fnx_runtime::CgseValue::Int(v));
8637            a
8638        };
8639        let mut g = MultiGraph::strict();
8640        let _ = g.add_node_with_attrs("x", attr("c", 1));
8641        let _ = g.add_edge("x", "y"); // y born attr-less
8642        // A fresh graph built by individual adds has no shadow yet; sync it warm
8643        // (individual adds only ADVANCE an existing warm shadow, never create one).
8644        g.sync_slab_shadow();
8645        assert!(g.slab_shadow_is_warm());
8646        // Routed read (-> slab) == String field ground truth.
8647        assert_eq!(
8648            g.node_attrs("x"),
8649            g.nodes.get("x"),
8650            "attr node routes to slab"
8651        );
8652        assert_eq!(g.node_attrs("y"), g.nodes.get("y"), "attr-less node routes");
8653        assert_eq!(g.node_attrs("missing"), g.nodes.get("missing"), "both None");
8654        assert!(g.node_attrs("x").is_some_and(|a| a.get("c").is_some()));
8655        // After an overwrite the shadow stays warm; the routed read reflects it.
8656        assert!(g.replace_node_attrs("x", attr("d", 9)));
8657        assert!(g.slab_shadow_is_warm());
8658        assert_eq!(
8659            g.node_attrs("x"),
8660            g.nodes.get("x"),
8661            "routed read tracks overwrite"
8662        );
8663        // With a STALE/absent shadow the read falls through to the String store.
8664        let mut h = MultiGraph::strict();
8665        let _ = h.add_node_with_attrs("p", attr("c", 3));
8666        assert!(
8667            !h.slab_shadow_is_warm(),
8668            "fresh individual-add graph has no shadow"
8669        );
8670        assert_eq!(
8671            h.node_attrs("p"),
8672            h.nodes.get("p"),
8673            "unrouted fallback still correct"
8674        );
8675    }
8676
8677    /// br-r37-c1-thp6w S24: feature-on `edge_attrs` READS route through the
8678    /// always-warm slab. Verified against the unrouted String field ground
8679    /// truth (`g.edges` direct access) across present cells (incl. a parallel
8680    /// key), a missing key, and a missing edge, plus the no-shadow fallback.
8681    #[cfg(any())]
8682    #[test]
8683    fn thp6w_s24_edge_attrs_reads_route_to_slab() {
8684        let attr = |k: &str, v: i64| {
8685            let mut a = AttrMap::new();
8686            a.insert(k.to_owned(), fnx_runtime::CgseValue::Int(v));
8687            a
8688        };
8689        let gt = |g: &MultiGraph, l: &str, r: &str, k: usize| {
8690            g.edges
8691                .get(&super::EdgeKeyRef::new(l, r))
8692                .and_then(|kb| kb.get(&k))
8693                .cloned()
8694        };
8695        let mut g = MultiGraph::strict();
8696        let _ = g.add_edge_with_key_and_attrs("a", "b", 0, attr("w", 1));
8697        let _ = g.add_edge_with_key_and_attrs("a", "b", 1, attr("w", 2)); // parallel
8698        let _ = g.add_edge("b", "c");
8699        g.sync_slab_shadow();
8700        assert!(g.slab_shadow_is_warm());
8701        // Routed read (-> slab) == String field ground truth.
8702        assert_eq!(g.edge_attrs("a", "b", 0).cloned(), gt(&g, "a", "b", 0));
8703        assert_eq!(g.edge_attrs("a", "b", 1).cloned(), gt(&g, "a", "b", 1));
8704        assert_eq!(g.edge_attrs("a", "b", 9), None, "missing key");
8705        assert_eq!(g.edge_attrs("a", "z", 0), None, "missing edge");
8706        // Overwrite advances the shadow; the routed read reflects it.
8707        assert!(g.replace_edge_attrs("a", "b", 0, attr("x", 8)));
8708        assert!(g.slab_shadow_is_warm());
8709        assert_eq!(g.edge_attrs("a", "b", 0).cloned(), gt(&g, "a", "b", 0));
8710        // No-shadow graph falls through to the String store.
8711        let mut h = MultiGraph::strict();
8712        let _ = h.add_edge_with_key_and_attrs("p", "q", 0, attr("z", 5));
8713        assert!(!h.slab_shadow_is_warm());
8714        assert_eq!(h.edge_attrs("p", "q", 0).cloned(), gt(&h, "p", "q", 0));
8715    }
8716
8717    /// br-r37-c1-thp6w S25: feature-on `edge_keys_vec` READS route through the
8718    /// always-warm slab (`key_order`). Verified against the unrouted String
8719    /// field ground truth (`g.edges` bucket keys), incl. parallel keys, a
8720    /// removal advance, missing edge, and the no-shadow fallback.
8721    #[cfg(any())]
8722    #[test]
8723    fn thp6w_s25_edge_keys_vec_reads_route_to_slab() {
8724        let gt = |g: &MultiGraph, l: &str, r: &str| -> Vec<usize> {
8725            g.edges
8726                .get(&super::EdgeKeyRef::new(l, r))
8727                .map(|b| b.keys().copied().collect())
8728                .unwrap_or_default()
8729        };
8730        let mut g = MultiGraph::strict();
8731        let _ = g.add_edge("a", "b"); // auto key 0
8732        let _ = g.add_edge("a", "b"); // auto key 1
8733        let _ = g.add_edge("a", "b"); // auto key 2
8734        let _ = g.add_edge("b", "c");
8735        g.sync_slab_shadow();
8736        assert!(g.slab_shadow_is_warm());
8737        assert_eq!(
8738            g.edge_keys_vec("a", "b"),
8739            gt(&g, "a", "b"),
8740            "routed key order"
8741        );
8742        assert_eq!(g.edge_keys_vec("b", "c"), gt(&g, "b", "c"));
8743        assert_eq!(
8744            g.edge_keys_vec("a", "z"),
8745            Vec::<usize>::new(),
8746            "missing edge"
8747        );
8748        // A per-key removal advances the shadow; the routed read reflects it.
8749        assert!(g.remove_edge("a", "b", Some(1)));
8750        assert!(g.slab_shadow_is_warm());
8751        assert_eq!(g.edge_keys_vec("a", "b"), gt(&g, "a", "b"), "after removal");
8752        // No-shadow graph falls through to the String store.
8753        let mut h = MultiGraph::strict();
8754        let _ = h.add_edge("p", "q");
8755        assert!(!h.slab_shadow_is_warm());
8756        assert_eq!(h.edge_keys_vec("p", "q"), gt(&h, "p", "q"));
8757    }
8758
8759    /// br-r37-c1-thp6w S26: feature-on `neighbors` READS route through the
8760    /// always-warm slab. Verified against the unrouted String field ground
8761    /// truth (`g.adjacency` row keys) incl. row order, a removal advance, a
8762    /// missing node (None), and the no-shadow fallback.
8763    #[cfg(any())]
8764    #[test]
8765    fn thp6w_s26_neighbors_reads_route_to_slab() {
8766        fn gt<'a>(g: &'a MultiGraph, n: &str) -> Option<Vec<&'a str>> {
8767            g.adjacency
8768                .get(n)
8769                .map(|row| row.keys().map(String::as_str).collect())
8770        }
8771        let mut g = MultiGraph::strict();
8772        let _ = g.add_edge("a", "b");
8773        let _ = g.add_edge("a", "c");
8774        let _ = g.add_edge("a", "b"); // parallel — distinct neighbor unchanged
8775        let _ = g.add_edge("c", "d");
8776        g.sync_slab_shadow();
8777        assert!(g.slab_shadow_is_warm());
8778        assert_eq!(
8779            g.neighbors("a"),
8780            gt(&g, "a"),
8781            "routed distinct neighbors, row order"
8782        );
8783        assert_eq!(g.neighbors("c"), gt(&g, "c"));
8784        assert_eq!(g.neighbors("missing"), None, "absent node -> None");
8785        assert_eq!(g.neighbors("missing"), gt(&g, "missing"));
8786        // A removal advances the shadow; the routed read reflects it.
8787        assert!(g.remove_edge("a", "c", None));
8788        assert!(g.slab_shadow_is_warm());
8789        assert_eq!(g.neighbors("a"), gt(&g, "a"), "after removal");
8790        // No-shadow graph falls through to the String store.
8791        let mut h = MultiGraph::strict();
8792        let _ = h.add_edge("p", "q");
8793        assert!(!h.slab_shadow_is_warm());
8794        assert_eq!(h.neighbors("p"), gt(&h, "p"));
8795    }
8796
8797    /// br-r37-c1-thp6w S27: feature-on `degree` READS route through the
8798    /// always-warm slab. Verified against the unrouted String field ground
8799    /// truth (same self-loop-double + parallel-count formula over
8800    /// `g.adjacency`) incl. a removal advance, a missing node (0), and the
8801    /// no-shadow fallback.
8802    #[cfg(any())]
8803    #[test]
8804    fn thp6w_s27_degree_reads_route_to_slab() {
8805        let gt = |g: &MultiGraph, n: &str| -> usize {
8806            g.adjacency.get(n).map_or(0, |row| {
8807                let mut d = 0;
8808                for (nbr, keys) in row {
8809                    let c = keys.len();
8810                    if nbr.as_str() == n {
8811                        d += c * 2;
8812                    } else {
8813                        d += c;
8814                    }
8815                }
8816                d
8817            })
8818        };
8819        let mut g = MultiGraph::strict();
8820        let _ = g.add_edge("a", "b");
8821        let _ = g.add_edge("a", "b"); // parallel
8822        let _ = g.add_edge("a", "c");
8823        let _ = g.add_edge("a", "a"); // self-loop -> +2 to a
8824        g.sync_slab_shadow();
8825        assert!(g.slab_shadow_is_warm());
8826        assert_eq!(g.degree("a"), gt(&g, "a"), "self-loop double + parallel");
8827        assert_eq!(g.degree("b"), gt(&g, "b"));
8828        assert_eq!(g.degree("missing"), 0, "absent node -> 0");
8829        assert_eq!(g.degree("missing"), gt(&g, "missing"));
8830        // A removal advances the shadow; the routed read reflects it.
8831        assert!(g.remove_edge("a", "b", None));
8832        assert!(g.slab_shadow_is_warm());
8833        assert_eq!(g.degree("a"), gt(&g, "a"), "after removal");
8834        // No-shadow graph falls through to the String store.
8835        let mut h = MultiGraph::strict();
8836        let _ = h.add_edge("p", "q");
8837        assert!(!h.slab_shadow_is_warm());
8838        assert_eq!(h.degree("p"), gt(&h, "p"));
8839    }
8840
8841    /// br-r37-c1-thp6w S28: feature-on `has_node` / `has_edge` membership READS
8842    /// route through the always-warm slab. Verified against the unrouted String
8843    /// field ground truth incl. isolated node, undirected symmetry, a removal
8844    /// advance, missing node/edge, and the no-shadow fallback.
8845    #[cfg(any())]
8846    #[test]
8847    fn thp6w_s28_membership_reads_route_to_slab() {
8848        let he = |g: &MultiGraph, l: &str, r: &str| {
8849            g.edges
8850                .get(&super::EdgeKeyRef::new(l, r))
8851                .is_some_and(|b| !b.is_empty())
8852        };
8853        let mut g = MultiGraph::strict();
8854        let _ = g.add_edge("a", "b");
8855        let _ = g.add_edge("b", "c");
8856        let _ = g.add_node("iso"); // isolated node
8857        g.sync_slab_shadow();
8858        assert!(g.slab_shadow_is_warm());
8859        // has_node
8860        assert_eq!(g.has_node("a"), g.nodes.contains_key("a"));
8861        assert_eq!(g.has_node("iso"), g.nodes.contains_key("iso"));
8862        assert_eq!(g.has_node("missing"), g.nodes.contains_key("missing"));
8863        assert!(g.has_node("iso") && !g.has_node("missing"));
8864        // has_edge (undirected: symmetric; missing nodes -> false)
8865        assert_eq!(g.has_edge("a", "b"), he(&g, "a", "b"));
8866        assert_eq!(g.has_edge("b", "a"), he(&g, "b", "a"));
8867        assert_eq!(g.has_edge("a", "c"), he(&g, "a", "c"));
8868        assert_eq!(g.has_edge("x", "y"), he(&g, "x", "y"));
8869        assert!(g.has_edge("a", "b") && g.has_edge("b", "a") && !g.has_edge("a", "c"));
8870        // A removal advances the shadow; membership reflects it.
8871        assert!(g.remove_edge("a", "b", None));
8872        assert!(g.slab_shadow_is_warm());
8873        assert_eq!(g.has_edge("a", "b"), he(&g, "a", "b"));
8874        assert!(!g.has_edge("a", "b"));
8875        // No-shadow graph falls through to the String store.
8876        let mut h = MultiGraph::strict();
8877        let _ = h.add_edge("p", "q");
8878        assert!(!h.slab_shadow_is_warm());
8879        assert_eq!(h.has_edge("p", "q"), he(&h, "p", "q"));
8880        assert_eq!(h.has_node("p"), h.nodes.contains_key("p"));
8881    }
8882
8883    /// br-r37-c1-thp6w S29: feature-on `nodes_ordered` READS route through the
8884    /// always-warm slab. Verified against the unrouted String field ground
8885    /// truth (`g.nodes.keys()`) + exact insertion order, a node-removal advance
8886    /// (order compaction), and the no-shadow fallback.
8887    #[cfg(any())]
8888    #[test]
8889    fn thp6w_s29_nodes_ordered_reads_route_to_slab() {
8890        fn gt<'a>(g: &'a MultiGraph) -> Vec<&'a str> {
8891            g.nodes.keys().map(String::as_str).collect()
8892        }
8893        let mut g = MultiGraph::strict();
8894        let _ = g.add_edge("c", "a"); // insertion: c, a
8895        let _ = g.add_edge("a", "b"); // b
8896        let _ = g.add_node("z"); // z
8897        g.sync_slab_shadow();
8898        assert!(g.slab_shadow_is_warm());
8899        assert_eq!(g.nodes_ordered(), gt(&g), "routed node order == String");
8900        assert_eq!(
8901            g.nodes_ordered(),
8902            vec!["c", "a", "b", "z"],
8903            "insertion order"
8904        );
8905        // A node removal advances the shadow; order compacts.
8906        assert!(g.remove_node("a"));
8907        assert!(g.slab_shadow_is_warm());
8908        assert_eq!(g.nodes_ordered(), gt(&g), "after removal");
8909        assert_eq!(g.nodes_ordered(), vec!["c", "b", "z"]);
8910        // No-shadow graph falls through to the String store.
8911        let mut h = MultiGraph::strict();
8912        let _ = h.add_edge("p", "q");
8913        assert!(!h.slab_shadow_is_warm());
8914        assert_eq!(h.nodes_ordered(), gt(&h));
8915    }
8916
8917    /// br-r37-c1-thp6w S30: feature-on `is_isolate` READS route through the
8918    /// always-warm slab. Verified against the unrouted String field ground
8919    /// truth incl. an isolated node, a self-loop node (NOT isolated), a node
8920    /// with edges, a missing node, a removal that isolates, and the no-shadow
8921    /// fallback.
8922    #[cfg(any())]
8923    #[test]
8924    fn thp6w_s30_is_isolate_reads_route_to_slab() {
8925        let gt = |g: &MultiGraph, n: &str| -> bool {
8926            g.nodes.contains_key(n) && g.adjacency.get(n).is_none_or(|row| row.is_empty())
8927        };
8928        let mut g = MultiGraph::strict();
8929        let _ = g.add_edge("a", "b");
8930        let _ = g.add_node("iso"); // isolated
8931        let _ = g.add_edge("s", "s"); // self-loop -> NOT isolated
8932        g.sync_slab_shadow();
8933        assert!(g.slab_shadow_is_warm());
8934        assert_eq!(g.is_isolate("iso"), gt(&g, "iso"));
8935        assert!(g.is_isolate("iso"));
8936        assert_eq!(g.is_isolate("a"), gt(&g, "a"));
8937        assert!(!g.is_isolate("a"));
8938        assert_eq!(g.is_isolate("s"), gt(&g, "s"));
8939        assert!(!g.is_isolate("s"), "self-loop node is not isolated");
8940        assert_eq!(g.is_isolate("missing"), gt(&g, "missing"));
8941        assert!(!g.is_isolate("missing"));
8942        // A removal isolates node "a"; the routed read reflects it.
8943        assert!(g.remove_edge("a", "b", None));
8944        assert!(g.slab_shadow_is_warm());
8945        assert_eq!(g.is_isolate("a"), gt(&g, "a"));
8946        assert!(g.is_isolate("a"));
8947        // No-shadow graph falls through to the String store.
8948        let mut h = MultiGraph::strict();
8949        let _ = h.add_node("lonely");
8950        assert!(!h.slab_shadow_is_warm());
8951        assert_eq!(h.is_isolate("lonely"), gt(&h, "lonely"));
8952    }
8953
8954    /// br-r37-c1-thp6w S31: feature-on `weighted_size_int` READS route through
8955    /// the always-warm slab. Verified against the unrouted String field ground
8956    /// truth (same int-sum/missing=1/non-int=None formula over `g.edges`) incl.
8957    /// parallel + self-loop weights, a removal advance, a non-int (Float) weight
8958    /// -> None, and the no-shadow fallback.
8959    #[cfg(any())]
8960    #[test]
8961    fn thp6w_s31_weighted_size_int_routes_to_slab() {
8962        fn gt(g: &MultiGraph, w: &str) -> Option<i128> {
8963            let mut total = 0i128;
8964            for bucket in g.edges.values() {
8965                for attrs in bucket.values() {
8966                    let v = match attrs.get(w) {
8967                        Some(fnx_runtime::CgseValue::Int(x)) => i128::from(*x),
8968                        Some(_) => return None,
8969                        None => 1,
8970                    };
8971                    total = total.checked_add(v)?;
8972                }
8973            }
8974            Some(total)
8975        }
8976        let w = |v: i64| -> AttrMap {
8977            let mut a = AttrMap::new();
8978            a.insert("weight".to_owned(), fnx_runtime::CgseValue::Int(v));
8979            a
8980        };
8981        let mut g = MultiGraph::strict();
8982        let _ = g.add_edge_with_key_and_attrs("a", "b", 0, w(5));
8983        let _ = g.add_edge_with_key_and_attrs("a", "b", 1, w(3)); // parallel
8984        let _ = g.add_edge("b", "c"); // no weight -> counts as 1
8985        let _ = g.add_edge_with_key_and_attrs("c", "c", 0, w(2)); // self-loop
8986        g.sync_slab_shadow();
8987        assert!(g.slab_shadow_is_warm());
8988        assert_eq!(
8989            g.weighted_size_int("weight"),
8990            gt(&g, "weight"),
8991            "routed == String"
8992        );
8993        assert_eq!(g.weighted_size_int("weight"), Some(11), "5+3+1+2");
8994        // A removal advances the shadow; the routed sum reflects it.
8995        assert!(g.remove_edge("a", "b", Some(0)));
8996        assert!(g.slab_shadow_is_warm());
8997        assert_eq!(g.weighted_size_int("weight"), gt(&g, "weight"));
8998        assert_eq!(g.weighted_size_int("weight"), Some(6), "3+1+2");
8999        // A non-int (Float) weight makes it None on both arms.
9000        let mut fattr = AttrMap::new();
9001        fattr.insert("weight".to_owned(), fnx_runtime::CgseValue::Float(1.5));
9002        let _ = g.add_edge_with_key_and_attrs("d", "e", 0, fattr);
9003        assert!(g.slab_shadow_is_warm());
9004        assert_eq!(g.weighted_size_int("weight"), gt(&g, "weight"));
9005        assert!(
9006            g.weighted_size_int("weight").is_none(),
9007            "non-int weight -> None"
9008        );
9009        // No-shadow graph falls through to the String store.
9010        let mut h = MultiGraph::strict();
9011        let _ = h.add_edge_with_key_and_attrs("p", "q", 0, w(7));
9012        assert!(!h.slab_shadow_is_warm());
9013        assert_eq!(h.weighted_size_int("weight"), gt(&h, "weight"));
9014        assert_eq!(h.weighted_size_int("weight"), Some(7));
9015    }
9016
9017    /// br-r37-c1-thp6w S32: feature-on `neighbors_iter` READS route through the
9018    /// always-warm slab via the `MgNeighborNames` enum iterator. Collected
9019    /// output verified against the unrouted String field ground truth
9020    /// (`g.adjacency` row keys) incl. row order, a removal advance, a missing
9021    /// node (None), and the no-shadow fallback.
9022    #[cfg(any())]
9023    #[test]
9024    fn thp6w_s32_neighbors_iter_reads_route_to_slab() {
9025        fn gt<'a>(g: &'a MultiGraph, n: &str) -> Option<Vec<&'a str>> {
9026            g.adjacency
9027                .get(n)
9028                .map(|row| row.keys().map(String::as_str).collect())
9029        }
9030        let collect = |g: &MultiGraph, n: &str| -> Option<Vec<String>> {
9031            g.neighbors_iter(n)
9032                .map(|it| it.map(str::to_owned).collect())
9033        };
9034        let want = |g: &MultiGraph, n: &str| -> Option<Vec<String>> {
9035            gt(g, n).map(|v| v.into_iter().map(str::to_owned).collect())
9036        };
9037        let mut g = MultiGraph::strict();
9038        let _ = g.add_edge("a", "b");
9039        let _ = g.add_edge("a", "c");
9040        let _ = g.add_edge("a", "b"); // parallel — distinct neighbor unchanged
9041        let _ = g.add_edge("c", "d");
9042        g.sync_slab_shadow();
9043        assert!(g.slab_shadow_is_warm());
9044        assert_eq!(
9045            collect(&g, "a"),
9046            want(&g, "a"),
9047            "routed iter == String, row order"
9048        );
9049        assert_eq!(collect(&g, "c"), want(&g, "c"));
9050        assert!(g.neighbors_iter("missing").is_none(), "absent node -> None");
9051        // A removal advances the shadow; the routed iteration reflects it.
9052        assert!(g.remove_edge("a", "c", None));
9053        assert!(g.slab_shadow_is_warm());
9054        assert_eq!(collect(&g, "a"), want(&g, "a"), "after removal");
9055        // No-shadow graph falls through to the String store.
9056        let mut h = MultiGraph::strict();
9057        let _ = h.add_edge("p", "q");
9058        assert!(!h.slab_shadow_is_warm());
9059        assert_eq!(collect(&h, "p"), want(&h, "p"));
9060    }
9061
9062    /// br-r37-c1-thp6w S33: feature-on `edge_keys_iter` READS route through the
9063    /// always-warm slab via the `MgEdgeKeys` enum iterator. Collected output
9064    /// verified against the unrouted String field ground truth (`g.adjacency`
9065    /// key set) incl. the Many bucket (parallel keys), the One bucket (single
9066    /// key), a per-key removal advance, a missing edge (None), and the no-shadow
9067    /// fallback.
9068    #[cfg(any())]
9069    #[test]
9070    fn thp6w_s33_edge_keys_iter_reads_route_to_slab() {
9071        fn gt(g: &MultiGraph, l: &str, r: &str) -> Option<Vec<usize>> {
9072            g.adjacency
9073                .get(l)?
9074                .get(r)
9075                .map(|keys| keys.iter().copied().collect())
9076        }
9077        let collect = |g: &MultiGraph, l: &str, r: &str| -> Option<Vec<usize>> {
9078            g.edge_keys_iter(l, r).map(|it| it.copied().collect())
9079        };
9080        let mut g = MultiGraph::strict();
9081        let _ = g.add_edge("a", "b"); // key 0
9082        let _ = g.add_edge("a", "b"); // key 1
9083        let _ = g.add_edge("a", "b"); // key 2 (Many bucket)
9084        let _ = g.add_edge("b", "c"); // single key (One bucket)
9085        g.sync_slab_shadow();
9086        assert!(g.slab_shadow_is_warm());
9087        assert_eq!(collect(&g, "a", "b"), gt(&g, "a", "b"), "Many == String");
9088        assert_eq!(collect(&g, "a", "b"), Some(vec![0, 1, 2]));
9089        assert_eq!(collect(&g, "b", "c"), gt(&g, "b", "c"), "One == String");
9090        assert_eq!(collect(&g, "b", "c"), Some(vec![0]));
9091        assert!(g.edge_keys_iter("a", "z").is_none(), "missing edge -> None");
9092        // A per-key removal advances the shadow; iteration reflects it.
9093        assert!(g.remove_edge("a", "b", Some(1)));
9094        assert!(g.slab_shadow_is_warm());
9095        assert_eq!(collect(&g, "a", "b"), gt(&g, "a", "b"), "after removal");
9096        assert_eq!(collect(&g, "a", "b"), Some(vec![0, 2]));
9097        // No-shadow graph falls through to the String store.
9098        let mut h = MultiGraph::strict();
9099        let _ = h.add_edge("p", "q");
9100        assert!(!h.slab_shadow_is_warm());
9101        assert_eq!(collect(&h, "p", "q"), gt(&h, "p", "q"));
9102    }
9103
9104    /// br-r37-c1-thp6w S11 GAUNTLET (production dual-write shadow): with the
9105    /// `mg-int-storage` feature on, every instrumented mutation (single-edge
9106    /// add incl. autocreate/parallel/self-loop/attr-merge, keyed batch,
9107    /// edge/node removal with slot recycling, node-attr set (S17), copy-walk
9108    /// row reorder (S18)) must ADVANCE the shadow in place (warm +
9109    /// byte-identical), and every UNINSTRUMENTED mutation must leave it stale
9110    /// (revision key) — never silently wrong.
9111    #[cfg(any())]
9112    #[test]
9113    fn thp6w_s11_slab_shadow_dual_write_gauntlet() {
9114        let shadow_parity = |g: &MultiGraph| {
9115            let shadow = g.slab_shadow.as_deref().expect("shadow must be present");
9116            assert_eq!(shadow.0, g.revision, "shadow must be warm (advanced)");
9117            assert_slab_matches_mg(g, &shadow.1);
9118        };
9119
9120        let mut g = MultiGraph::strict();
9121        let _ = g.add_edge("a", "b");
9122        g.sync_slab_shadow();
9123        assert!(g.slab_shadow_is_warm());
9124        shadow_parity(&g);
9125
9126        // Instrumented single-edge adds.
9127        let _ = g.add_edge("b", "c"); // autocreated endpoint
9128        shadow_parity(&g);
9129        let _ = g.add_edge("a", "b"); // parallel key
9130        shadow_parity(&g);
9131        let _ = g.add_edge("d", "d"); // self-loop, fresh node
9132        shadow_parity(&g);
9133        let mut attrs = AttrMap::new();
9134        attrs.insert("w".to_owned(), fnx_runtime::CgseValue::Int(3));
9135        let _ = g.add_edge_with_key_and_attrs("a", "b", 0, attrs); // attr merge
9136        shadow_parity(&g);
9137
9138        // Instrumented keyed batch (write-through).
9139        let _ = g.extend_keyed_edges_with_attrs_unrecorded(vec![
9140            ("x".to_owned(), "y".to_owned(), 0, AttrMap::new()),
9141            ("y".to_owned(), "x".to_owned(), 1, AttrMap::new()),
9142            ("a".to_owned(), "x".to_owned(), 0, AttrMap::new()),
9143        ]);
9144        shadow_parity(&g);
9145
9146        // Instrumented removals: parallel key (next_back), last key, node
9147        // removal, then slot recycling via a fresh name.
9148        assert!(g.remove_edge("a", "b", None));
9149        shadow_parity(&g);
9150        assert!(g.remove_edge("a", "b", None));
9151        shadow_parity(&g);
9152        assert!(g.remove_node("b"));
9153        shadow_parity(&g);
9154        let _ = g.add_edge("recycled", "c");
9155        shadow_parity(&g);
9156
9157        // br-r37-c1-thp6w S18: an order-only mutation (copy-walk reorder) now
9158        // ADVANCES the warm shadow in place — rows reordered on the slab,
9159        // revision unchanged so it stays warm and byte-identical.
9160        g.reorder_rows_for_nx_copy_walk();
9161        assert!(
9162            g.slab_shadow_is_warm(),
9163            "apply_row_orders must advance (not drop) the warm shadow"
9164        );
9165        shadow_parity(&g);
9166        // A full rebuild still reproduces the same post-reorder row orders.
9167        g.sync_slab_shadow();
9168        shadow_parity(&g);
9169
9170        // Uninstrumented content mutations leave the shadow STALE, never wrong.
9171        g.add_node("island");
9172        assert!(
9173            !g.slab_shadow_is_warm(),
9174            "uninstrumented mutation must stale the shadow"
9175        );
9176        g.sync_slab_shadow();
9177        shadow_parity(&g);
9178        g.clear_edges();
9179        assert!(!g.slab_shadow_is_warm());
9180        g.sync_slab_shadow();
9181        shadow_parity(&g);
9182
9183        // br-r37-c1-thp6w S13 ROUTE GATE: with a warm shadow the production
9184        // `edges_ordered_borrowed` serves from the slab; the sequence must be
9185        // byte-identical to what the String walk produced while the shadow
9186        // was STALE (owned snapshot so it survives the sync).
9187        let _ = g.add_edge("r1", "r2"); // stales nothing — shadow advances...
9188        g.add_node("stale_maker"); // ...so force staleness via an uninstrumented op
9189        assert!(!g.slab_shadow_is_warm());
9190        let _ = g.add_edge("r2", "r1"); // parallel via reverse orientation (shadow stays stale)
9191        let _ = g.add_edge("r3", "r3");
9192        let expected: Vec<(String, String, usize, AttrMap)> = g
9193            .edges_ordered_borrowed() // stale shadow -> String walk
9194            .into_iter()
9195            .map(|(l, r, k, a)| (l.to_owned(), r.to_owned(), k, a.clone()))
9196            .collect();
9197        // Same gate for the OWNED `edges_ordered` route (captured stale first).
9198        let expected_owned: Vec<(String, String, usize, AttrMap)> = g
9199            .edges_ordered() // stale shadow -> String walk (owned)
9200            .into_iter()
9201            .map(|e| (e.left, e.right, e.key, e.attrs))
9202            .collect();
9203        g.sync_slab_shadow();
9204        assert!(g.slab_shadow_is_warm());
9205        let served: Vec<(String, String, usize, AttrMap)> = g
9206            .edges_ordered_borrowed() // warm shadow -> slab walk
9207            .into_iter()
9208            .map(|(l, r, k, a)| (l.to_owned(), r.to_owned(), k, a.clone()))
9209            .collect();
9210        assert_eq!(
9211            served, expected,
9212            "shadow-served edges_ordered must equal the String walk"
9213        );
9214        let served_owned: Vec<(String, String, usize, AttrMap)> = g
9215            .edges_ordered() // warm shadow -> slab walk (owned)
9216            .into_iter()
9217            .map(|e| (e.left, e.right, e.key, e.attrs))
9218            .collect();
9219        assert_eq!(
9220            served_owned, expected_owned,
9221            "shadow-served owned edges_ordered must equal the String walk"
9222        );
9223    }
9224
9225    /// br-r37-c1-thp6w S5 A/B: pure storage-representation tax of the String-
9226    /// keyed MultiGraph core vs the index-keyed prototype layout, on the SAME
9227    /// pre-resolved (u_idx, v_idx, key) stream via the SAME-shape bulk loaders
9228    /// (`extend_fresh_int_prefix_keyed_edges_unrecorded` vs
9229    /// `bulk_load_int_prefix`) — no canonicalization, no Python boundary, no
9230    /// per-edge ledger in either arm. Paired-interleaved (current, proto,
9231    /// current-null per round), medians over 9 rounds.
9232    /// `cargo test --release -p fnx-classes --lib thp6w_s5_int_storage_construction_ab -- --ignored --nocapture`
9233    #[test]
9234    #[ignore = "paired A/B benchmark; run --release with --ignored --nocapture"]
9235    fn thp6w_s5_int_storage_construction_ab() {
9236        use super::multigraph_storage::MgIntStorageProto;
9237        use std::collections::HashMap;
9238        use std::hint::black_box;
9239        use std::time::Instant;
9240
9241        fn push_edge(
9242            stream: &mut Vec<(usize, usize, usize)>,
9243            key_counter: &mut HashMap<(usize, usize), usize>,
9244            u: usize,
9245            v: usize,
9246        ) {
9247            let entry = key_counter.entry((u.min(v), u.max(v))).or_insert(0);
9248            stream.push((u, v, *entry));
9249            *entry += 1;
9250        }
9251
9252        let n = 20000usize;
9253        let mut stream = Vec::new();
9254        let mut key_counter = HashMap::new();
9255        for i in 0..n {
9256            push_edge(&mut stream, &mut key_counter, i, (i + 1) % n);
9257        }
9258        // Deterministic LCG chords (incl. incidental self-loops) + explicit parallels.
9259        let mut state = 0x2545_F491_4F6C_DD1D_u64;
9260        let draw = |state: &mut u64| -> usize {
9261            *state = state
9262                .wrapping_mul(6_364_136_223_846_793_005)
9263                .wrapping_add(1_442_695_040_888_963_407);
9264            usize::try_from(*state >> 33).unwrap() % n
9265        };
9266        for _ in 0..3 * n {
9267            let u = draw(&mut state);
9268            let v = draw(&mut state);
9269            push_edge(&mut stream, &mut key_counter, u, v);
9270        }
9271        for i in (0..2000).step_by(2) {
9272            push_edge(&mut stream, &mut key_counter, i, i + 1);
9273        }
9274
9275        let time_current = |stream: &[(usize, usize, usize)]| -> f64 {
9276            let t0 = Instant::now();
9277            let mut g = MultiGraph::strict();
9278            let _ = g.extend_fresh_int_prefix_keyed_edges_unrecorded(n, stream.iter().copied());
9279            let dt = t0.elapsed().as_secs_f64();
9280            black_box(&g);
9281            dt
9282        };
9283        let time_proto = |stream: &[(usize, usize, usize)]| -> f64 {
9284            let t0 = Instant::now();
9285            let proto = MgIntStorageProto::bulk_load_int_prefix(n, stream.iter().copied());
9286            let dt = t0.elapsed().as_secs_f64();
9287            black_box(&proto);
9288            dt
9289        };
9290        // br-r37-c1-thp6w S6: compact One/Many bucket arm.
9291        let time_compact = |stream: &[(usize, usize, usize)]| -> f64 {
9292            let t0 = Instant::now();
9293            let compact = super::multigraph_storage::MgIntStorageProtoCompact::bulk_load_int_prefix(
9294                n,
9295                stream.iter().copied(),
9296            );
9297            let dt = t0.elapsed().as_secs_f64();
9298            black_box(&compact);
9299            dt
9300        };
9301
9302        // Structural agreement between the arms (once, outside timing):
9303        // the real store's derived integer rows must equal both proto rows.
9304        let mut g = MultiGraph::strict();
9305        let _ = g.extend_fresh_int_prefix_keyed_edges_unrecorded(n, stream.iter().copied());
9306        let proto = MgIntStorageProto::bulk_load_int_prefix(n, stream.iter().copied());
9307        let compact = super::multigraph_storage::MgIntStorageProtoCompact::bulk_load_int_prefix(
9308            n,
9309            stream.iter().copied(),
9310        );
9311        g.with_int_adjacency(|adj| {
9312            for (i, row) in adj.iter().enumerate() {
9313                let proto_row: Vec<usize> = proto.rows[i].keys().copied().collect();
9314                assert_eq!(row, &proto_row, "derived integer row {i} must agree");
9315                let compact_row: Vec<usize> = compact.rows[i].keys().copied().collect();
9316                assert_eq!(row, &compact_row, "compact integer row {i} must agree");
9317            }
9318        });
9319        assert_eq!(
9320            proto.edge_count,
9321            g.edge_count(),
9322            "A/B arms built different graphs"
9323        );
9324        assert_eq!(
9325            compact.edge_count,
9326            g.edge_count(),
9327            "compact arm built a different graph"
9328        );
9329        // br-r37-c1-thp6w S9: slab arm — must hold the compact win.
9330        let time_slab = |stream: &[(usize, usize, usize)]| -> f64 {
9331            let t0 = Instant::now();
9332            let slab = super::multigraph_storage::MgSlabStorage::bulk_load_int_prefix(
9333                n,
9334                stream.iter().copied(),
9335            );
9336            let dt = t0.elapsed().as_secs_f64();
9337            black_box(&slab);
9338            dt
9339        };
9340        let slab = super::multigraph_storage::MgSlabStorage::bulk_load_int_prefix(
9341            n,
9342            stream.iter().copied(),
9343        );
9344        g.with_int_adjacency(|adj| {
9345            for (i, row) in adj.iter().enumerate() {
9346                let slab_row: Vec<usize> = slab.rows[i].keys().copied().collect();
9347                assert_eq!(row, &slab_row, "slab integer row {i} must agree");
9348            }
9349        });
9350        assert_eq!(
9351            slab.edge_count,
9352            g.edge_count(),
9353            "slab arm built a different graph"
9354        );
9355
9356        let median = |samples: &mut Vec<f64>| -> f64 {
9357            samples.sort_by(f64::total_cmp);
9358            samples[samples.len() / 2]
9359        };
9360        let (mut cur, mut pro, mut com, mut sla, mut nul) =
9361            (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new());
9362        for _ in 0..9 {
9363            cur.push(time_current(&stream));
9364            pro.push(time_proto(&stream));
9365            com.push(time_compact(&stream));
9366            sla.push(time_slab(&stream));
9367            nul.push(time_current(&stream));
9368        }
9369        let (mc, mp, mk, ms, mn) = (
9370            median(&mut cur),
9371            median(&mut pro),
9372            median(&mut com),
9373            median(&mut sla),
9374            median(&mut nul),
9375        );
9376        println!(
9377            "THP6W_S5_AB n={n} m={} current={mc:.6}s proto={mp:.6}s compact={mk:.6}s slab={ms:.6}s ratio_current_over_proto={:.3} ratio_current_over_compact={:.3} ratio_current_over_slab={:.3} ratio_compact_over_slab={:.3} null_current_over_current={:.3}",
9378            stream.len(),
9379            mc / mp,
9380            mc / mk,
9381            mc / ms,
9382            mk / ms,
9383            mc / mn,
9384        );
9385    }
9386
9387    #[test]
9388    fn thp6w_int_adjacency_cache_is_fresh_per_clone() {
9389        let mut g = MultiGraph::strict();
9390        for i in 0..4 {
9391            g.add_node(format!("n{i}"));
9392        }
9393        let _ = g.add_edge("n0", "n1");
9394        // Populate g's memo.
9395        assert_int_adjacency_matches(&g);
9396        // Clone, then mutate the CLONE differently. The clone must NOT serve g's rows
9397        // (fresh-per-clone memo), and g must keep its own.
9398        let mut clone = g.clone();
9399        let _ = clone.add_edge("n2", "n3");
9400        assert_int_adjacency_matches(&clone);
9401        assert_int_adjacency_matches(&g);
9402        // And mutate g after the clone; both stay self-consistent.
9403        let _ = g.add_edge("n0", "n2");
9404        assert_int_adjacency_matches(&g);
9405        assert_int_adjacency_matches(&clone);
9406    }
9407
9408    fn single_attr(key: &str, value: &str) -> AttrMap {
9409        let mut attrs = AttrMap::new();
9410        attrs.insert(key.to_owned(), CgseValue::from(value));
9411        attrs
9412    }
9413
9414    #[test]
9415    fn edge_slot_rows_follow_adj_order_and_refresh_after_mutation() {
9416        let mut graph = Graph::strict();
9417        let weighted = |value| {
9418            let mut attrs = AttrMap::new();
9419            attrs.insert("weight".to_owned(), CgseValue::Int(value));
9420            attrs
9421        };
9422        graph
9423            .add_edge_with_attrs("a", "b", weighted(1))
9424            .expect("edge add should succeed");
9425        graph
9426            .add_edge_with_attrs("a", "c", weighted(2))
9427            .expect("edge add should succeed");
9428        graph
9429            .add_edge_with_attrs("b", "c", weighted(3))
9430            .expect("edge add should succeed");
9431
9432        let a = graph.get_node_index("a").expect("a exists");
9433        graph.with_edge_slots_by_neighbor(|rows| {
9434            let weights = rows[a]
9435                .iter()
9436                .map(|&slot| {
9437                    graph
9438                        .edge_attrs_by_slot(slot)
9439                        .and_then(|attrs| attrs.get("weight"))
9440                })
9441                .collect::<Vec<_>>();
9442            assert_eq!(
9443                weights,
9444                vec![Some(&CgseValue::Int(1)), Some(&CgseValue::Int(2))]
9445            );
9446        });
9447
9448        graph.apply_row_orders(&[("a".to_owned(), vec!["c".to_owned(), "b".to_owned()])]);
9449        graph.with_edge_slots_by_neighbor(|rows| {
9450            let weights = rows[a]
9451                .iter()
9452                .map(|&slot| {
9453                    graph
9454                        .edge_attrs_by_slot(slot)
9455                        .and_then(|attrs| attrs.get("weight"))
9456                })
9457                .collect::<Vec<_>>();
9458            assert_eq!(
9459                weights,
9460                vec![Some(&CgseValue::Int(2)), Some(&CgseValue::Int(1))]
9461            );
9462        });
9463
9464        assert!(graph.remove_edge("a", "c"));
9465        graph.with_edge_slots_by_neighbor(|rows| {
9466            assert_eq!(rows[a].len(), 1);
9467            assert_eq!(
9468                graph
9469                    .edge_attrs_by_slot(rows[a][0])
9470                    .and_then(|attrs| attrs.get("weight")),
9471                Some(&CgseValue::Int(1))
9472            );
9473        });
9474    }
9475
9476    #[test]
9477    fn edges_ordered_tracks_node_and_neighbor_iteration_order() {
9478        let mut graph = Graph::strict();
9479        graph
9480            .add_edge_with_attrs("a", "b", AttrMap::new())
9481            .expect("edge add should succeed");
9482        graph
9483            .add_edge_with_attrs("b", "c", AttrMap::new())
9484            .expect("edge add should succeed");
9485        graph
9486            .add_edge_with_attrs("a", "c", AttrMap::new())
9487            .expect("edge add should succeed");
9488
9489        let pairs = graph
9490            .edges_ordered()
9491            .into_iter()
9492            .map(|edge| (edge.left, edge.right))
9493            .collect::<Vec<(String, String)>>();
9494        assert_eq!(
9495            pairs,
9496            vec![
9497                ("a".to_owned(), "b".to_owned()),
9498                ("a".to_owned(), "c".to_owned()),
9499                ("b".to_owned(), "c".to_owned()),
9500            ]
9501        );
9502    }
9503
9504    #[test]
9505    fn edges_ordered_preserves_traversed_endpoint_orientation() {
9506        let mut graph = Graph::strict();
9507        graph
9508            .add_edge_with_attrs("2", "1", AttrMap::new())
9509            .expect("edge add should succeed");
9510
9511        let pairs = graph
9512            .edges_ordered()
9513            .into_iter()
9514            .map(|edge| (edge.left, edge.right))
9515            .collect::<Vec<(String, String)>>();
9516        assert_eq!(pairs, vec![("2".to_owned(), "1".to_owned())]);
9517
9518        let borrowed_pairs = graph
9519            .edges_ordered_borrowed()
9520            .into_iter()
9521            .map(|(left, right, _)| (left.to_owned(), right.to_owned()))
9522            .collect::<Vec<(String, String)>>();
9523        assert_eq!(borrowed_pairs, vec![("2".to_owned(), "1".to_owned())]);
9524    }
9525
9526    #[test]
9527    fn edges_ordered_uses_node_order_for_preexisting_nodes() {
9528        let mut graph = Graph::strict();
9529        graph.add_node("1".to_owned());
9530        graph.add_node("2".to_owned());
9531        graph
9532            .add_edge_with_attrs("2", "1", AttrMap::new())
9533            .expect("edge add should succeed");
9534
9535        let pairs = graph
9536            .edges_ordered()
9537            .into_iter()
9538            .map(|edge| (edge.left, edge.right))
9539            .collect::<Vec<(String, String)>>();
9540        assert_eq!(pairs, vec![("1".to_owned(), "2".to_owned())]);
9541    }
9542
9543    #[test]
9544    fn edge_storage_order_index_iter_tracks_mutations() {
9545        let mut graph = Graph::strict();
9546        graph.add_node("c");
9547        graph.add_node("a");
9548        graph.add_node("b");
9549        graph
9550            .add_edge_with_attrs("c", "a", single_attr("weight", "1"))
9551            .expect("edge add should succeed");
9552        graph
9553            .add_edge_with_attrs("b", "c", single_attr("weight", "2"))
9554            .expect("edge add should succeed");
9555        graph
9556            .add_edge_with_attrs("a", "c", single_attr("color", "red"))
9557            .expect("duplicate edge update should succeed");
9558
9559        let indexed_edges = |graph: &Graph| {
9560            graph
9561                .edges_storage_order_index_iter()
9562                .map(|(left, right, attrs)| {
9563                    (
9564                        graph
9565                            .get_node_name(left)
9566                            .expect("left endpoint index should resolve")
9567                            .to_owned(),
9568                        graph
9569                            .get_node_name(right)
9570                            .expect("right endpoint index should resolve")
9571                            .to_owned(),
9572                        attrs.get("weight").cloned(),
9573                    )
9574                })
9575                .collect::<Vec<_>>()
9576        };
9577
9578        assert_eq!(
9579            indexed_edges(&graph),
9580            vec![
9581                (
9582                    "a".to_owned(),
9583                    "c".to_owned(),
9584                    Some(CgseValue::String("1".to_owned())),
9585                ),
9586                (
9587                    "b".to_owned(),
9588                    "c".to_owned(),
9589                    Some(CgseValue::String("2".to_owned())),
9590                ),
9591            ]
9592        );
9593
9594        assert!(graph.remove_edge("a", "c"));
9595        assert_eq!(
9596            indexed_edges(&graph),
9597            vec![(
9598                "b".to_owned(),
9599                "c".to_owned(),
9600                Some(CgseValue::String("2".to_owned())),
9601            )]
9602        );
9603
9604        assert!(graph.remove_node("b"));
9605        assert!(indexed_edges(&graph).is_empty());
9606    }
9607
9608    fn assert_graph_core_invariants(graph: &Graph) {
9609        let mut unique_edges = BTreeSet::new();
9610        for node in graph.nodes_ordered() {
9611            let neighbors = graph
9612                .neighbors(node)
9613                .expect("graph nodes should always have adjacency buckets");
9614            for neighbor in neighbors {
9615                assert!(graph.has_node(neighbor));
9616                assert!(graph.has_edge(node, neighbor));
9617                let reverse_neighbors = graph
9618                    .neighbors(neighbor)
9619                    .expect("neighbor should always have adjacency bucket");
9620                assert!(reverse_neighbors.contains(&node));
9621                unique_edges.insert(canonical_edge(node, neighbor));
9622            }
9623        }
9624        assert_eq!(graph.edge_count(), unique_edges.len());
9625    }
9626
9627    fn assert_multigraph_core_invariants(graph: &MultiGraph) {
9628        let mut edge_instances = BTreeSet::new();
9629        for node in graph.nodes_ordered() {
9630            let neighbors = graph
9631                .neighbors(node)
9632                .expect("multigraph nodes should always have adjacency buckets");
9633            for neighbor in neighbors {
9634                assert!(graph.has_node(neighbor));
9635                assert!(graph.has_edge(node, neighbor));
9636                let reverse_neighbors = graph
9637                    .neighbors(neighbor)
9638                    .expect("neighbor should always have adjacency bucket");
9639                assert!(reverse_neighbors.contains(&node));
9640                let keys = graph
9641                    .edge_keys(node, neighbor)
9642                    .expect("parallel edge bucket should exist");
9643                for key in keys {
9644                    let canonical = canonical_edge(node, neighbor);
9645                    edge_instances.insert((canonical.0, canonical.1, key));
9646                }
9647            }
9648        }
9649        assert_eq!(graph.edge_count(), edge_instances.len());
9650    }
9651
9652    fn assert_decision_record_schema(record: &DecisionRecord, expected_mode: CompatibilityMode) {
9653        assert!(record.ts_unix_ms > 0);
9654        assert!(!record.operation.trim().is_empty());
9655        assert_eq!(record.mode, expected_mode);
9656        assert!((0.0..=1.0).contains(&record.incompatibility_probability));
9657        assert!(!record.rationale.trim().is_empty());
9658        assert!(!record.evidence.is_empty());
9659        for term in &record.evidence {
9660            assert!(!term.signal.trim().is_empty());
9661            assert!(!term.observed_value.trim().is_empty());
9662        }
9663    }
9664
9665    #[test]
9666    fn add_edge_autocreates_nodes_and_preserves_order() {
9667        let mut graph = Graph::strict();
9668        graph
9669            .add_edge_with_attrs("a", "b", AttrMap::new())
9670            .expect("edge insert should succeed");
9671        graph
9672            .add_edge_with_attrs("a", "c", AttrMap::new())
9673            .expect("edge insert should succeed");
9674
9675        assert_eq!(graph.node_count(), 3);
9676        assert_eq!(graph.edge_count(), 2);
9677        assert_eq!(graph.nodes_ordered(), vec!["a", "b", "c"]);
9678        assert_eq!(graph.neighbors("a"), Some(vec!["b", "c"]));
9679    }
9680
9681    #[test]
9682    fn add_edge_empty_endpoint_resolution_preserves_autocreation_effects() {
9683        let mut graph = Graph::strict();
9684        let before = graph.revision();
9685
9686        graph
9687            .add_edge("c", "a")
9688            .expect("fresh endpoints should be autocreated");
9689
9690        // The single-probe endpoint resolver keeps the public storage effects
9691        // of two empty add_node calls plus the new edge mutation: first-seen
9692        // node order, one adjacency bucket per node, and three revision bumps.
9693        assert_eq!(graph.nodes_ordered(), vec!["c", "a"]);
9694        assert_eq!(graph.neighbors("c"), Some(vec!["a"]));
9695        assert_eq!(graph.neighbors("a"), Some(vec!["c"]));
9696        assert_eq!(graph.revision(), before + 3);
9697
9698        graph
9699            .add_edge("a", "c")
9700            .expect("an existing empty edge is a no-op");
9701        assert_eq!(graph.nodes_ordered(), vec!["c", "a"]);
9702        assert_eq!(graph.revision(), before + 3);
9703    }
9704
9705    #[test]
9706    fn extend_edges_unrecorded_preserves_adjacency_and_records_once() {
9707        let mut graph = Graph::strict();
9708        graph.add_node("a");
9709        graph.add_node("b");
9710        graph.add_node("c");
9711        let before = graph.evidence_ledger().records().len();
9712
9713        let inserted = graph.extend_edges_unrecorded([("a", "b"), ("a", "c"), ("b", "a")]);
9714
9715        assert_eq!(inserted, 2);
9716        assert_eq!(graph.edge_count(), 2);
9717        assert_eq!(graph.neighbors("a"), Some(vec!["b", "c"]));
9718        let records = graph.evidence_ledger().records();
9719        assert_eq!(records.len(), before + 1);
9720        assert_eq!(
9721            records.last().map(|record| record.operation.as_ref()),
9722            Some("extend_edges_unrecorded")
9723        );
9724    }
9725
9726    #[test]
9727    fn extend_existing_index_edges_unrecorded_matches_string_batch_on_int_prefix() {
9728        let nodes = (0..12).map(|node| node.to_string()).collect::<Vec<_>>();
9729        let edge_indices = vec![(10usize, 2usize), (2, 10), (3, 3), (1, 11), (11, 1)];
9730        let edge_strings = edge_indices
9731            .iter()
9732            .map(|(left, right)| (left.to_string(), right.to_string()))
9733            .collect::<Vec<_>>();
9734
9735        let mut by_string = Graph::strict();
9736        let mut by_index = Graph::strict();
9737        let _ = by_string.extend_nodes_unrecorded(nodes.clone());
9738        let _ = by_index.extend_nodes_unrecorded(nodes);
9739
9740        assert!(by_index.nodes_are_contiguous_int_prefix());
9741        assert_eq!(by_string.extend_edges_unrecorded(edge_strings), 3);
9742        assert_eq!(
9743            by_index.extend_existing_index_edges_unrecorded(edge_indices),
9744            3
9745        );
9746
9747        assert_eq!(by_index.snapshot(), by_string.snapshot());
9748        assert_graph_core_invariants(&by_index);
9749    }
9750
9751    #[test]
9752    fn extend_edges_with_attrs_unrecorded_matches_add_edge_with_attrs() {
9753        // br-r37-c1-pr8q6: bulk attributed insertion must be observationally
9754        // identical to a sequence of add_edge_with_attrs calls (node order,
9755        // adjacency order, attr merge on duplicates) minus the per-edge
9756        // ledger records.
9757        let mut attrs1 = AttrMap::new();
9758        attrs1.insert("w".to_owned(), CgseValue::Int(1));
9759        let mut attrs2 = AttrMap::new();
9760        attrs2.insert("w".to_owned(), CgseValue::Int(7));
9761        attrs2.insert("c".to_owned(), CgseValue::String("x".to_owned()));
9762
9763        let mut reference = Graph::strict();
9764        reference
9765            .add_edge_with_attrs("a", "b", attrs1.clone())
9766            .unwrap();
9767        reference
9768            .add_edge_with_attrs("b", "c", AttrMap::new())
9769            .unwrap();
9770        // duplicate edge: attrs merge
9771        reference
9772            .add_edge_with_attrs("b", "a", attrs2.clone())
9773            .unwrap();
9774        // self-loop
9775        reference
9776            .add_edge_with_attrs("d", "d", attrs1.clone())
9777            .unwrap();
9778
9779        let mut bulk = Graph::strict();
9780        let before = bulk.evidence_ledger().records().len();
9781        let inserted = bulk.extend_edges_with_attrs_unrecorded([
9782            ("a".to_owned(), "b".to_owned(), attrs1.clone()),
9783            ("b".to_owned(), "c".to_owned(), AttrMap::new()),
9784            ("b".to_owned(), "a".to_owned(), attrs2.clone()),
9785            ("d".to_owned(), "d".to_owned(), attrs1.clone()),
9786        ]);
9787
9788        assert_eq!(inserted, 3);
9789        assert_eq!(bulk.edge_count(), reference.edge_count());
9790        assert_eq!(bulk.nodes_ordered(), reference.nodes_ordered());
9791        for node in bulk.nodes_ordered() {
9792            assert_eq!(bulk.neighbors(node), reference.neighbors(node));
9793        }
9794        assert_eq!(bulk.edge_attrs("a", "b"), reference.edge_attrs("a", "b"));
9795        assert_eq!(bulk.edge_attrs("d", "d"), reference.edge_attrs("d", "d"));
9796        // one summary record, not one per edge
9797        assert_eq!(bulk.evidence_ledger().records().len(), before + 1);
9798    }
9799
9800    #[test]
9801    fn multigraph_int_prefix_keyed_edges_match_string_batch() {
9802        let indexed_edges = vec![
9803            (0usize, 1usize, 0usize),
9804            (1, 2, 0),
9805            (2, 0, 0),
9806            (0, 1, 1),
9807            (2, 2, 0),
9808        ];
9809        let string_edges = indexed_edges
9810            .iter()
9811            .map(|(left, right, key)| (left.to_string(), right.to_string(), *key, AttrMap::new()))
9812            .collect::<Vec<_>>();
9813
9814        let mut by_string = MultiGraph::strict();
9815        let mut by_index = MultiGraph::strict();
9816
9817        assert_eq!(
9818            by_string.extend_keyed_edges_with_attrs_unrecorded(string_edges),
9819            indexed_edges.len()
9820        );
9821        assert_eq!(
9822            by_index.extend_fresh_int_prefix_keyed_edges_unrecorded(3, indexed_edges),
9823            5
9824        );
9825
9826        assert_eq!(by_index.snapshot(), by_string.snapshot());
9827        assert_multigraph_core_invariants(&by_index);
9828    }
9829
9830    #[test]
9831    fn extend_nodes_unrecorded_preserves_order_and_records_once() {
9832        let mut graph = Graph::strict();
9833        graph.add_node("1");
9834        let before = graph.evidence_ledger().records().len();
9835
9836        let inserted =
9837            graph.extend_nodes_unrecorded(["0".to_owned(), "1".to_owned(), "2".to_owned()]);
9838
9839        assert_eq!(inserted, 2);
9840        assert_eq!(graph.node_count(), 3);
9841        assert_eq!(graph.nodes_ordered(), vec!["1", "0", "2"]);
9842        assert_eq!(graph.neighbor_count("0"), 0);
9843        assert_eq!(graph.neighbor_count("2"), 0);
9844        let records = graph.evidence_ledger().records();
9845        assert_eq!(records.len(), before + 1);
9846        assert_eq!(
9847            records.last().map(|record| record.operation.as_ref()),
9848            Some("extend_nodes_unrecorded")
9849        );
9850    }
9851
9852    #[test]
9853    fn neighbors_iter_preserves_deterministic_order() {
9854        let mut graph = Graph::strict();
9855        graph.add_edge("a", "b").expect("edge add should succeed");
9856        graph.add_edge("a", "c").expect("edge add should succeed");
9857        graph.add_edge("a", "d").expect("edge add should succeed");
9858
9859        let neighbors = graph
9860            .neighbors_iter("a")
9861            .expect("neighbors should exist")
9862            .collect::<Vec<&str>>();
9863        assert_eq!(neighbors, vec!["b", "c", "d"]);
9864    }
9865
9866    #[test]
9867    fn neighbor_count_matches_neighbors_len() {
9868        let mut graph = Graph::strict();
9869        graph.add_edge("a", "b").expect("edge add should succeed");
9870        graph.add_edge("a", "c").expect("edge add should succeed");
9871        assert_eq!(graph.neighbor_count("a"), 2);
9872        assert_eq!(graph.neighbor_count("missing"), 0);
9873    }
9874
9875    #[test]
9876    fn repeated_edge_updates_attrs_in_place() {
9877        let mut graph = Graph::strict();
9878        let mut first = AttrMap::new();
9879        first.insert("weight".to_owned(), "1".into());
9880        graph
9881            .add_edge_with_attrs("a", "b", first)
9882            .expect("edge insert should succeed");
9883
9884        let mut second = AttrMap::new();
9885        second.insert("color".to_owned(), "blue".into());
9886        graph
9887            .add_edge_with_attrs("b", "a", second)
9888            .expect("edge update should succeed");
9889
9890        let attrs = graph
9891            .edge_attrs("a", "b")
9892            .expect("edge attrs should be present");
9893        assert_eq!(
9894            attrs.get("weight"),
9895            Some(&CgseValue::String("1".to_owned()))
9896        );
9897        assert_eq!(
9898            attrs.get("color"),
9899            Some(&CgseValue::String("blue".to_owned()))
9900        );
9901        assert_eq!(graph.edge_count(), 1);
9902    }
9903
9904    #[test]
9905    fn complete_graph_bulk_constructor_preserves_ordered_contract() {
9906        let graph = Graph::complete_graph(CompatibilityMode::Strict, 5);
9907
9908        assert_eq!(graph.node_count(), 5);
9909        assert_eq!(graph.edge_count(), 10);
9910        assert_eq!(graph.nodes_ordered(), vec!["0", "1", "2", "3", "4"]);
9911        assert_eq!(
9912            graph.neighbors("0").expect("node 0 should exist"),
9913            vec!["1", "2", "3", "4"]
9914        );
9915        assert_eq!(
9916            graph.neighbors("4").expect("node 4 should exist"),
9917            vec!["0", "1", "2", "3"]
9918        );
9919        assert_eq!(
9920            graph
9921                .edges_ordered()
9922                .into_iter()
9923                .map(|edge| (edge.left, edge.right))
9924                .collect::<Vec<_>>(),
9925            vec![
9926                ("0".to_owned(), "1".to_owned()),
9927                ("0".to_owned(), "2".to_owned()),
9928                ("0".to_owned(), "3".to_owned()),
9929                ("0".to_owned(), "4".to_owned()),
9930                ("1".to_owned(), "2".to_owned()),
9931                ("1".to_owned(), "3".to_owned()),
9932                ("1".to_owned(), "4".to_owned()),
9933                ("2".to_owned(), "3".to_owned()),
9934                ("2".to_owned(), "4".to_owned()),
9935                ("3".to_owned(), "4".to_owned()),
9936            ]
9937        );
9938
9939        let operations = graph
9940            .evidence_ledger()
9941            .records()
9942            .iter()
9943            .map(|record| record.operation.as_ref())
9944            .collect::<Vec<_>>();
9945        assert_eq!(operations, vec!["complete_graph_bulk"]);
9946    }
9947
9948    #[test]
9949    fn remove_node_removes_incident_edges() {
9950        let mut graph = Graph::strict();
9951        graph.add_edge("a", "b").expect("edge add should succeed");
9952        graph.add_edge("b", "c").expect("edge add should succeed");
9953        assert!(graph.remove_node("b"));
9954        assert_eq!(graph.node_count(), 2);
9955        assert_eq!(graph.edge_count(), 0);
9956    }
9957
9958    /// br-r37-c1-8n3j3: attribution bench for the per-mutation CGSE decision
9959    /// record. `Graph::add_edge_with_attrs` calls `record_decision` twice per
9960    /// edge (5 `EvidenceTerm`s total), and every field of a `DecisionRecord`
9961    /// that comes from a `&'static str` is materialised as an owned `String`.
9962    /// This measures, in one invocation and interleaved, how much of a single
9963    /// `add_edge_with_attrs` is the ledger: arm A records the exact terms
9964    /// `add_edge_with_attrs` builds, arm B builds the identical terms and
9965    /// discards them. `add_edge_full` is the denominator the ratio is against.
9966    /// `#[ignore]`; run with
9967    /// `cargo test --release -p fnx-classes --lib ledger_record_cost_ab -- --ignored --nocapture`.
9968    #[test]
9969    #[ignore = "measurement; run with --release --ignored --nocapture"]
9970    fn ledger_record_cost_ab() {
9971        use fnx_runtime::{EvidenceTerm, RuntimePolicy, bool_evidence, count_evidence};
9972        use std::hint::black_box;
9973        use std::time::Instant;
9974
9975        const EDGES: usize = 20_000;
9976        const ROUNDS: usize = 21;
9977        const RATIONALE: &str = "argmin expected loss over {allow,full_validate,fail_closed}";
9978
9979        // The exact evidence `add_edge_with_attrs` builds for a fresh
9980        // non-self-loop edge between two autocreated nodes.
9981        fn gate_terms() -> Vec<EvidenceTerm> {
9982            vec![EvidenceTerm {
9983                signal: "unknown_incompatible_feature".into(),
9984                observed_value: bool_evidence(false),
9985                log_likelihood_ratio: 12.0,
9986            }]
9987        }
9988        fn outcome_terms(edge_attr_count: usize) -> Vec<EvidenceTerm> {
9989            vec![
9990                EvidenceTerm {
9991                    signal: "self_loop".into(),
9992                    observed_value: bool_evidence(false),
9993                    log_likelihood_ratio: -0.5,
9994                },
9995                EvidenceTerm {
9996                    signal: "edge_attr_count".into(),
9997                    observed_value: count_evidence(edge_attr_count),
9998                    log_likelihood_ratio: -2.0,
9999                },
10000                EvidenceTerm {
10001                    signal: "left_autocreated".into(),
10002                    observed_value: bool_evidence(true),
10003                    log_likelihood_ratio: -1.25,
10004                },
10005                EvidenceTerm {
10006                    signal: "right_autocreated".into(),
10007                    observed_value: bool_evidence(true),
10008                    log_likelihood_ratio: -1.25,
10009                },
10010            ]
10011        }
10012
10013        // br-r37-c1-jc9e4: the record the AUTOCREATED ENDPOINT files.
10014        // `add_edge_with_attrs` creates a missing endpoint via `add_node` ->
10015        // `add_node_with_attrs`, which calls `record_decision` UNCONDITIONALLY,
10016        // so an edge-BUILDING workload files this record in addition to the
10017        // outcome record. Two evidence terms, against the outcome record's four.
10018        fn node_terms() -> Vec<EvidenceTerm> {
10019            vec![
10020                EvidenceTerm {
10021                    signal: "node_preexisting".into(),
10022                    observed_value: bool_evidence(false),
10023                    log_likelihood_ratio: -3.0,
10024                },
10025                EvidenceTerm {
10026                    signal: "attrs_count".into(),
10027                    observed_value: count_evidence(0),
10028                    log_likelihood_ratio: -1.0,
10029                },
10030            ]
10031        }
10032
10033        // Arm A: build the terms AND record them (the shipped behaviour).
10034        let recorded = || {
10035            let mut policy = RuntimePolicy::strict();
10036            let start = Instant::now();
10037            for index in 0..EDGES {
10038                let action = policy.action_for(0.08, false);
10039                policy.record("add_edge", action, 0.08, RATIONALE, gate_terms());
10040                policy.record(
10041                    "add_edge",
10042                    action,
10043                    0.08,
10044                    RATIONALE,
10045                    outcome_terms(index & 3),
10046                );
10047            }
10048            let elapsed = start.elapsed();
10049            assert_eq!(policy.decision_log().total_recorded(), (EDGES * 2) as u64);
10050            elapsed
10051        };
10052
10053        // Arm A1: the shipped path after br-r37-c1-eo88t — the gate decision is
10054        // taken without a record, so an allowed edge writes only the outcome
10055        // record. Same bounded ledger as arm A, half the traffic.
10056        let recorded_single = || {
10057            let mut policy = RuntimePolicy::strict();
10058            let start = Instant::now();
10059            for index in 0..EDGES {
10060                let action = policy.action_for(0.08, false);
10061                policy.record(
10062                    "add_edge",
10063                    action,
10064                    0.08,
10065                    RATIONALE,
10066                    outcome_terms(index & 3),
10067                );
10068            }
10069            let elapsed = start.elapsed();
10070            assert_eq!(policy.decision_log().total_recorded(), EDGES as u64);
10071            elapsed
10072        };
10073
10074        // Arm A2: two records per edge — the autocreated endpoint's `add_node`
10075        // record PLUS the `add_edge` outcome record. This was the shipped
10076        // behaviour until br-r37-c1-wa1b9 stopped the internal autocreation
10077        // filing its own record; it is now the COUNTERFACTUAL that prices what
10078        // that change removed, and A1 is the shipped path. Keep it: it is the
10079        // only arm that shows what a per-call (rather than per-operation)
10080        // ledger contract would cost on the hottest mutation path.
10081        let recorded_edge_build = || {
10082            let mut policy = RuntimePolicy::strict();
10083            let start = Instant::now();
10084            for index in 0..EDGES {
10085                let node_action = policy.action_for(0.0, false);
10086                policy.record("add_node", node_action, 0.0, RATIONALE, node_terms());
10087                let action = policy.action_for(0.08, false);
10088                policy.record(
10089                    "add_edge",
10090                    action,
10091                    0.08,
10092                    RATIONALE,
10093                    outcome_terms(index & 3),
10094                );
10095            }
10096            let elapsed = start.elapsed();
10097            assert_eq!(policy.decision_log().total_recorded(), (EDGES * 2) as u64);
10098            elapsed
10099        };
10100
10101        // Arm A0: the pre-br-r37-c1-8n3j3 ledger — the identical records pushed
10102        // into an unbounded `Vec` that is never retired. This is the incumbent
10103        // arm for the bound, run in the SAME invocation as arm A.
10104        let unbounded = || {
10105            let policy = RuntimePolicy::strict();
10106            let mode = policy.mode();
10107            let mut records: Vec<DecisionRecord> = Vec::new();
10108            let start = Instant::now();
10109            for index in 0..EDGES {
10110                let action = policy.action_for(0.08, false);
10111                records.push(DecisionRecord {
10112                    ts_unix_ms: fnx_runtime::unix_time_ms(),
10113                    operation: "add_edge".into(),
10114                    mode,
10115                    action,
10116                    incompatibility_probability: 0.08,
10117                    rationale: RATIONALE.into(),
10118                    evidence: gate_terms(),
10119                });
10120                records.push(DecisionRecord {
10121                    ts_unix_ms: fnx_runtime::unix_time_ms(),
10122                    operation: "add_edge".into(),
10123                    mode,
10124                    action,
10125                    incompatibility_probability: 0.08,
10126                    rationale: RATIONALE.into(),
10127                    evidence: outcome_terms(index & 3),
10128                });
10129            }
10130            let elapsed = start.elapsed();
10131            assert_eq!(records.len(), EDGES * 2);
10132            elapsed
10133        };
10134
10135        // Arm B: build the identical terms, skip only the recording.
10136        let discarded = || {
10137            let policy = RuntimePolicy::strict();
10138            let start = Instant::now();
10139            for index in 0..EDGES {
10140                let action = policy.action_for(0.08, false);
10141                black_box((action, "add_edge", 0.08, RATIONALE, gate_terms()));
10142                black_box((
10143                    action,
10144                    "add_edge",
10145                    0.08,
10146                    RATIONALE,
10147                    outcome_terms(index & 3),
10148                ));
10149            }
10150            let elapsed = start.elapsed();
10151            assert!(policy.decision_log().records().is_empty());
10152            elapsed
10153        };
10154
10155        // Arm C: terms + the clock read, discarded. Isolates `unix_time_ms`.
10156        let clocked = || {
10157            let policy = RuntimePolicy::strict();
10158            let start = Instant::now();
10159            for index in 0..EDGES {
10160                let action = policy.action_for(0.08, false);
10161                black_box((action, fnx_runtime::unix_time_ms(), gate_terms()));
10162                black_box((
10163                    action,
10164                    fnx_runtime::unix_time_ms(),
10165                    outcome_terms(index & 3),
10166                ));
10167            }
10168            start.elapsed()
10169        };
10170
10171        // Arm D: terms + clock + the two owned `&'static str` clones + the whole
10172        // `DecisionRecord`, discarded instead of pushed. Isolates the ledger Vec
10173        // push and its growth memcpy from the per-record materialisation cost.
10174        let materialized = || {
10175            let policy = RuntimePolicy::strict();
10176            let mode = policy.mode();
10177            let start = Instant::now();
10178            for index in 0..EDGES {
10179                let action = policy.action_for(0.08, false);
10180                black_box(DecisionRecord {
10181                    ts_unix_ms: fnx_runtime::unix_time_ms(),
10182                    operation: "add_edge".into(),
10183                    mode,
10184                    action,
10185                    incompatibility_probability: 0.08,
10186                    rationale: RATIONALE.into(),
10187                    evidence: gate_terms(),
10188                });
10189                black_box(DecisionRecord {
10190                    ts_unix_ms: fnx_runtime::unix_time_ms(),
10191                    operation: "add_edge".into(),
10192                    mode,
10193                    action,
10194                    incompatibility_probability: 0.08,
10195                    rationale: RATIONALE.into(),
10196                    evidence: outcome_terms(index & 3),
10197                });
10198            }
10199            start.elapsed()
10200        };
10201
10202        // Denominator: the whole shipped add_edge_with_attrs path.
10203        let add_edge_full = || {
10204            let mut graph = Graph::strict();
10205            let keys = (0..=EDGES).map(|n| n.to_string()).collect::<Vec<_>>();
10206            let start = Instant::now();
10207            for index in 0..EDGES {
10208                graph
10209                    .add_edge_with_attrs(
10210                        keys[index].clone(),
10211                        keys[index + 1].clone(),
10212                        AttrMap::new(),
10213                    )
10214                    .expect("edge add should succeed");
10215            }
10216            let elapsed = start.elapsed();
10217            assert_eq!(graph.edge_count(), EDGES);
10218            elapsed
10219        };
10220
10221        let mut record_ns = Vec::with_capacity(ROUNDS);
10222        let mut single_ns = Vec::with_capacity(ROUNDS);
10223        let mut edge_build_ns = Vec::with_capacity(ROUNDS);
10224        let mut unbounded_ns = Vec::with_capacity(ROUNDS);
10225        let mut discard_ns = Vec::with_capacity(ROUNDS);
10226        let mut clock_ns = Vec::with_capacity(ROUNDS);
10227        let mut material_ns = Vec::with_capacity(ROUNDS);
10228        let mut full_ns = Vec::with_capacity(ROUNDS);
10229        for _ in 0..ROUNDS {
10230            record_ns.push(recorded().as_nanos() as f64 / EDGES as f64);
10231            single_ns.push(recorded_single().as_nanos() as f64 / EDGES as f64);
10232            edge_build_ns.push(recorded_edge_build().as_nanos() as f64 / EDGES as f64);
10233            unbounded_ns.push(unbounded().as_nanos() as f64 / EDGES as f64);
10234            discard_ns.push(discarded().as_nanos() as f64 / EDGES as f64);
10235            clock_ns.push(clocked().as_nanos() as f64 / EDGES as f64);
10236            material_ns.push(materialized().as_nanos() as f64 / EDGES as f64);
10237            full_ns.push(add_edge_full().as_nanos() as f64 / EDGES as f64);
10238        }
10239        let median = |samples: &mut Vec<f64>| {
10240            samples.sort_by(f64::total_cmp);
10241            samples[samples.len() / 2]
10242        };
10243        let recorded_ns = median(&mut record_ns);
10244        let single_median_ns = median(&mut single_ns);
10245        let edge_build_median_ns = median(&mut edge_build_ns);
10246        let unbounded_median_ns = median(&mut unbounded_ns);
10247        let discarded_ns = median(&mut discard_ns);
10248        let clocked_ns = median(&mut clock_ns);
10249        let materialized_ns = median(&mut material_ns);
10250        let full = median(&mut full_ns);
10251        println!("ledger_record_cost_ab n={EDGES} rounds={ROUNDS} (2 records/edge)");
10252        println!("  A  bounded ledger, 2 records: {recorded_ns:8.1} ns/edge");
10253        println!("  A1 bounded ledger, 1 record : {single_median_ns:8.1} ns/edge  (SHIPPED)");
10254        println!(
10255            "  A2 two records per edge      : {edge_build_median_ns:8.1} ns/edge  (pre-wa1b9 counterfactual)"
10256        );
10257        println!("  A0 unbounded Vec (incumbent): {unbounded_median_ns:8.1} ns/edge");
10258        println!("  B  build terms only         : {discarded_ns:8.1} ns/edge");
10259        println!("  C  terms + clock            : {clocked_ns:8.1} ns/edge");
10260        println!("  D  terms + clock + record   : {materialized_ns:8.1} ns/edge");
10261        println!("  add_edge_with_attrs (total) : {full:8.1} ns/edge");
10262        println!(
10263            "  -> clock (C-B)              : {:8.1} ns/edge",
10264            clocked_ns - discarded_ns
10265        );
10266        println!(
10267            "  -> static str clones (D-C)  : {:8.1} ns/edge",
10268            materialized_ns - clocked_ns
10269        );
10270        println!(
10271            "  -> unbounded retention (A0-D): {:8.1} ns/edge",
10272            unbounded_median_ns - materialized_ns
10273        );
10274        println!(
10275            "  -> bounded retention (A-D)  : {:8.1} ns/edge",
10276            recorded_ns - materialized_ns
10277        );
10278        println!(
10279            "  -> bound speedup (A0/A)     : {:8.4}x",
10280            unbounded_median_ns / recorded_ns
10281        );
10282        println!(
10283            "  -> single-record (A/A1)     : {:8.4}x",
10284            recorded_ns / single_median_ns
10285        );
10286        println!(
10287            "  -> both levers (A0/A1)      : {:8.4}x",
10288            unbounded_median_ns / single_median_ns
10289        );
10290        println!(
10291            "  -> ledger share, SHIPPED    : {:6.1}%  (A1/full — one record per add_edge)",
10292            single_median_ns / full * 100.0
10293        );
10294        println!(
10295            "  -> ledger share, pre-wa1b9  : {:6.1}%  (A2/full — when autocreation also recorded)",
10296            edge_build_median_ns / full * 100.0
10297        );
10298    }
10299
10300    /// br-r37-c1-g2nev: is the `Cow<'static, str>` field migration worth 110
10301    /// hand-edited construction sites?
10302    ///
10303    /// The bead proposes changing `DecisionRecord.operation`/`.rationale` and
10304    /// `EvidenceTerm.signal`/`.observed_value` from `String` to
10305    /// `Cow<'static, str>`, because 13 of the 14 owned strings a shipped
10306    /// `add_edge` materialises are compile-time constants. It has been REJECTed
10307    /// twice — not on the merits, but because it is an all-surface mechanical
10308    /// conversion across seven crates that `AGENTS.md` forbids scripting, and
10309    /// nobody had a number saying whether the payoff justifies it.
10310    ///
10311    /// This produces that number WITHOUT doing the migration first. Both arms
10312    /// run in ONE invocation and differ in exactly one thing: the field type.
10313    ///
10314    /// WHAT IS SIMULATED, AND WHY THE DELTA STILL TRANSFERS. The real ledger
10315    /// retires selectively (oldest `Allow` only) inside `RuntimePolicy`, which
10316    /// a local type cannot reuse. Both arms here therefore use the same plain
10317    /// capped `Vec` instead. That shifts the ABSOLUTE ns/edge away from the
10318    /// shipped path — so these are not ledger-share numbers and must not be
10319    /// quoted as such — but it shifts BOTH arms identically, and the quantity
10320    /// this bead turns on is the DIFFERENCE between them. `ledger_record_cost_ab`
10321    /// above remains the authority on the shipped path's absolute cost.
10322    ///
10323    /// A `Cow` record is also WIDER than a `String` one (a `Cow<str>` carries a
10324    /// discriminant), so a migration trades N allocations for a larger memcpy on
10325    /// every push and retire. That trade is exactly what is being measured, and
10326    /// it is why the answer was not obvious enough to skip measuring.
10327    ///
10328    /// `#[ignore]`; run with
10329    /// `cargo test --release -p fnx-classes --lib ledger_cow_field_cost_ab -- --ignored --nocapture`.
10330    #[test]
10331    #[ignore = "measurement; run with --release --ignored --nocapture"]
10332    fn ledger_cow_field_cost_ab() {
10333        use fnx_runtime::{
10334            DecisionAction, EvidenceTerm, RuntimePolicy, bool_evidence, count_evidence,
10335        };
10336        use std::borrow::Cow;
10337        use std::hint::black_box;
10338        use std::time::{Duration, Instant};
10339
10340        const EDGES: usize = 20_000;
10341        const ROUNDS: usize = 21;
10342        const CAP: usize = fnx_runtime::DECISION_LEDGER_RETAINED_RECORDS;
10343        const RATIONALE: &str = "argmin expected loss over {allow,full_validate,fail_closed}";
10344
10345        /// Shape-for-shape mirror of `DecisionRecord` with borrowed fields.
10346        struct CowRecord {
10347            ts_unix_ms: u128,
10348            operation: Cow<'static, str>,
10349            action: DecisionAction,
10350            incompatibility_probability: f64,
10351            rationale: Cow<'static, str>,
10352            evidence: Vec<CowTerm>,
10353        }
10354        struct CowTerm {
10355            signal: Cow<'static, str>,
10356            observed_value: Cow<'static, str>,
10357            log_likelihood_ratio: f64,
10358        }
10359
10360        // The exact four terms a shipped allowed edge records, in both forms.
10361        // Only `edge_attr_count` is genuinely dynamic; the other nine strings
10362        // are constants, which is the whole premise of the bead.
10363        fn owned_terms(edge_attr_count: usize) -> Vec<EvidenceTerm> {
10364            vec![
10365                EvidenceTerm {
10366                    signal: "self_loop".into(),
10367                    observed_value: bool_evidence(false),
10368                    log_likelihood_ratio: -0.5,
10369                },
10370                EvidenceTerm {
10371                    signal: "edge_attr_count".into(),
10372                    observed_value: count_evidence(edge_attr_count),
10373                    log_likelihood_ratio: -2.0,
10374                },
10375                EvidenceTerm {
10376                    signal: "left_autocreated".into(),
10377                    observed_value: bool_evidence(true),
10378                    log_likelihood_ratio: -1.25,
10379                },
10380                EvidenceTerm {
10381                    signal: "right_autocreated".into(),
10382                    observed_value: bool_evidence(true),
10383                    log_likelihood_ratio: -1.25,
10384                },
10385            ]
10386        }
10387        fn borrowed_terms(edge_attr_count: usize) -> Vec<CowTerm> {
10388            vec![
10389                CowTerm {
10390                    signal: Cow::Borrowed("self_loop"),
10391                    observed_value: Cow::Borrowed("false"),
10392                    log_likelihood_ratio: -0.5,
10393                },
10394                CowTerm {
10395                    signal: Cow::Borrowed("edge_attr_count"),
10396                    // The one field a migration cannot borrow.
10397                    observed_value: Cow::Owned(edge_attr_count.to_string()),
10398                    log_likelihood_ratio: -2.0,
10399                },
10400                CowTerm {
10401                    signal: Cow::Borrowed("left_autocreated"),
10402                    observed_value: Cow::Borrowed("true"),
10403                    log_likelihood_ratio: -1.25,
10404                },
10405                CowTerm {
10406                    signal: Cow::Borrowed("right_autocreated"),
10407                    observed_value: Cow::Borrowed("true"),
10408                    log_likelihood_ratio: -1.25,
10409                },
10410            ]
10411        }
10412
10413        let owned_arm = || -> Duration {
10414            let policy = RuntimePolicy::strict();
10415            let mode = policy.mode();
10416            let mut log: Vec<DecisionRecord> = Vec::new();
10417            let start = Instant::now();
10418            for index in 0..EDGES {
10419                let action = policy.action_for(0.08, false);
10420                if log.len() == CAP {
10421                    log.clear();
10422                }
10423                log.push(DecisionRecord {
10424                    ts_unix_ms: fnx_runtime::unix_time_ms(),
10425                    operation: "add_edge".into(),
10426                    mode,
10427                    action,
10428                    incompatibility_probability: 0.08,
10429                    rationale: RATIONALE.into(),
10430                    evidence: owned_terms(index & 3),
10431                });
10432            }
10433            let elapsed = start.elapsed();
10434            // Consume every field AFTER timing. Both arms do this, symmetrically:
10435            // it keeps the record contents observable so neither arm can have
10436            // its construction elided as dead, which would compare a real build
10437            // against an optimised-away one.
10438            let mut sink = 0usize;
10439            for record in &log {
10440                sink ^= record.operation.len()
10441                    ^ record.rationale.len()
10442                    ^ (record.ts_unix_ms as usize)
10443                    ^ (record.incompatibility_probability.to_bits() as usize)
10444                    ^ usize::from(matches!(record.action, DecisionAction::Allow));
10445                for term in &record.evidence {
10446                    sink ^= term.signal.len()
10447                        ^ term.observed_value.len()
10448                        ^ (term.log_likelihood_ratio.to_bits() as usize);
10449                }
10450            }
10451            black_box(sink);
10452            elapsed
10453        };
10454
10455        let borrowed_arm = || -> Duration {
10456            let policy = RuntimePolicy::strict();
10457            let mut log: Vec<CowRecord> = Vec::new();
10458            let start = Instant::now();
10459            for index in 0..EDGES {
10460                let action = policy.action_for(0.08, false);
10461                if log.len() == CAP {
10462                    log.clear();
10463                }
10464                log.push(CowRecord {
10465                    ts_unix_ms: fnx_runtime::unix_time_ms(),
10466                    operation: Cow::Borrowed("add_edge"),
10467                    action,
10468                    incompatibility_probability: 0.08,
10469                    rationale: Cow::Borrowed(RATIONALE),
10470                    evidence: borrowed_terms(index & 3),
10471                });
10472            }
10473            let elapsed = start.elapsed();
10474            let mut sink = 0usize;
10475            for record in &log {
10476                sink ^= record.operation.len()
10477                    ^ record.rationale.len()
10478                    ^ (record.ts_unix_ms as usize)
10479                    ^ (record.incompatibility_probability.to_bits() as usize)
10480                    ^ usize::from(matches!(record.action, DecisionAction::Allow));
10481                for term in &record.evidence {
10482                    sink ^= term.signal.len()
10483                        ^ term.observed_value.len()
10484                        ^ (term.log_likelihood_ratio.to_bits() as usize);
10485                }
10486            }
10487            black_box(sink);
10488            elapsed
10489        };
10490
10491        // Order-balanced ABBA, and an A/A null measured through the identical
10492        // scheme. br-r37-c1-vz4v9 found a fixed-order sibling bench reporting a
10493        // 12% "effect" that was purely which arm ran first; the null is what
10494        // makes this readable.
10495        let balanced =
10496            |first: &dyn Fn() -> Duration, second: &dyn Fn() -> Duration| -> (f64, f64) {
10497                let a1 = first();
10498                let b1 = second();
10499                let b2 = second();
10500                let a2 = first();
10501                let scale = (2 * EDGES) as f64;
10502                (
10503                    (a1 + a2).as_nanos() as f64 / scale,
10504                    (b1 + b2).as_nanos() as f64 / scale,
10505                )
10506            };
10507
10508        owned_arm();
10509        borrowed_arm();
10510
10511        let mut owned_ns = Vec::with_capacity(ROUNDS);
10512        let mut cow_ns = Vec::with_capacity(ROUNDS);
10513        let mut null_a_ns = Vec::with_capacity(ROUNDS);
10514        let mut null_b_ns = Vec::with_capacity(ROUNDS);
10515        for _ in 0..ROUNDS {
10516            let (na, nb) = balanced(&owned_arm, &owned_arm);
10517            let (owned, cow) = balanced(&owned_arm, &borrowed_arm);
10518            null_a_ns.push(na);
10519            null_b_ns.push(nb);
10520            owned_ns.push(owned);
10521            cow_ns.push(cow);
10522        }
10523
10524        let median = |samples: &mut Vec<f64>| {
10525            samples.sort_by(f64::total_cmp);
10526            samples[samples.len() / 2]
10527        };
10528        let owned = median(&mut owned_ns);
10529        let cow = median(&mut cow_ns);
10530        let null_a = median(&mut null_a_ns);
10531        let null_b = median(&mut null_b_ns);
10532
10533        println!("ledger_cow_field_cost_ab n={EDGES} rounds={ROUNDS} cap={CAP}");
10534        println!("  S  String fields   : {owned:8.1} ns/edge  (incumbent)");
10535        println!("  C  Cow fields      : {cow:8.1} ns/edge  (proposed)");
10536        println!("  N1 String, null A  : {null_a:8.1} ns/edge");
10537        println!("  N2 String, null B  : {null_b:8.1} ns/edge");
10538        println!("  -> A/A null (N2/N1): {:8.4}x", null_b / null_a);
10539        println!("  -> Cow gain (S/C)  : {:8.4}x", owned / cow);
10540        println!("  -> saving          : {:8.1} ns/edge", owned - cow);
10541    }
10542
10543    // br-r37-c1-p6bxu: A/B substrate bench for MultiGraph::remove_node
10544    // (O(degree) swap_remove vs the old O(|E|) retain). Ignored by default;
10545    // run with `cargo test -p fnx-classes --release ab_bench_multigraph_remove_node
10546    // -- --ignored --nocapture`. Also asserts byte-exact parity (node_count,
10547    // edge_count, edges_ordered) between the two paths.
10548    #[test]
10549    #[ignore]
10550    fn ab_bench_multigraph_remove_node() {
10551        use std::time::Instant;
10552        const N: usize = 1000;
10553        const M: usize = 8000;
10554        const ITERS: usize = 500;
10555        let build = || {
10556            let mut g = MultiGraph::new(CompatibilityMode::Strict);
10557            for i in 0..N {
10558                let _ = g.add_node(i.to_string());
10559            }
10560            let mut s: u64 = 0x9E3779B97F4A7C15;
10561            let mut next = || {
10562                s = s
10563                    .wrapping_mul(6364136223846793005)
10564                    .wrapping_add(1442695040888963407);
10565                (s >> 33) as usize % N
10566            };
10567            for _ in 0..M {
10568                let a = next();
10569                let b = next();
10570                let _ = g.add_edge(a.to_string(), b.to_string());
10571            }
10572            g
10573        };
10574
10575        let mut gnew = build();
10576        let victims: Vec<String> = gnew
10577            .nodes_ordered()
10578            .into_iter()
10579            .take(ITERS)
10580            .map(str::to_owned)
10581            .collect();
10582        let t = Instant::now();
10583        for node in &victims {
10584            gnew.remove_node(node);
10585        }
10586        let new_t = t.elapsed();
10587
10588        // A/A null path: the sole store must produce the same state under the
10589        // same removal sequence.
10590        let mut gold = build();
10591        let t = Instant::now();
10592        for node in &victims {
10593            gold.remove_node(node);
10594        }
10595        let null_t = t.elapsed();
10596
10597        assert_eq!(gnew.node_count(), gold.node_count());
10598        assert_eq!(gnew.edge_count(), gold.edge_count());
10599        assert_eq!(gnew.edges_ordered(), gold.edges_ordered());
10600        eprintln!(
10601            "MultiGraph remove_node A/A x{ITERS}: candidate {new_t:?}, null {null_t:?}, ratio={:.3}",
10602            null_t.as_secs_f64() / new_t.as_secs_f64()
10603        );
10604    }
10605
10606    #[test]
10607    fn remove_nodes_from_matches_repeated_removal_and_rebuilds_indices() {
10608        let edges = [
10609            ("0", "1"),
10610            ("1", "2"),
10611            ("2", "3"),
10612            ("3", "4"),
10613            ("4", "5"),
10614            ("0", "5"),
10615            ("1", "4"),
10616            ("2", "5"),
10617        ];
10618        let mut batch = Graph::strict();
10619        let mut repeated = Graph::strict();
10620        for (left, right) in edges {
10621            batch.add_edge(left, right).expect("batch edge add");
10622            repeated.add_edge(left, right).expect("repeated edge add");
10623        }
10624
10625        let victims = ["1", "3", "99", "1"];
10626        let removed = batch.remove_nodes_from(victims);
10627        for victim in victims {
10628            let _ = repeated.remove_node(victim);
10629        }
10630
10631        assert_eq!(removed, (2, 5));
10632        assert_eq!(batch.snapshot(), repeated.snapshot());
10633        let ordered_nodes = batch
10634            .nodes_ordered()
10635            .into_iter()
10636            .map(str::to_owned)
10637            .collect::<Vec<_>>();
10638        for (node_index, node) in ordered_nodes.iter().enumerate() {
10639            let names_from_strings = batch.neighbors(node).expect("node should exist");
10640            let names_from_indices = batch
10641                .neighbors_indices(node_index)
10642                .expect("index should exist")
10643                .iter()
10644                .map(|index| batch.get_node_name(*index).expect("index should resolve"))
10645                .collect::<Vec<_>>();
10646            assert_eq!(names_from_indices, names_from_strings);
10647        }
10648        let names_from_edge_indices = batch
10649            .edges_storage_order_index_iter()
10650            .map(|(left, right, _)| {
10651                (
10652                    batch
10653                        .get_node_name(left)
10654                        .expect("left endpoint should resolve")
10655                        .to_owned(),
10656                    batch
10657                        .get_node_name(right)
10658                        .expect("right endpoint should resolve")
10659                        .to_owned(),
10660                )
10661            })
10662            .collect::<Vec<_>>();
10663        let names_from_edge_storage = batch
10664            .edges_storage_order_iter()
10665            .map(|(left, right, _)| (left.to_owned(), right.to_owned()))
10666            .collect::<Vec<_>>();
10667        assert_eq!(names_from_edge_indices, names_from_edge_storage);
10668    }
10669
10670    /// yr2oc.1: every reader that speaks positions agrees with the name-based
10671    /// readers (the slot store) — the as50i invariant.
10672    fn assert_graph_positions_agree_with_names(g: &Graph) {
10673        let names: Vec<String> = g.nodes_ordered().into_iter().map(str::to_owned).collect();
10674        for (position, name) in names.iter().enumerate() {
10675            assert_eq!(g.get_node_index(name), Some(position));
10676            assert_eq!(g.get_node_name(position), Some(name.as_str()));
10677            let by_name = g.neighbors(name).expect("live node");
10678            let by_position: Vec<&str> = g
10679                .neighbors_indices(position)
10680                .expect("live position")
10681                .iter()
10682                .map(|&p| g.get_node_name(p).expect("neighbour position"))
10683                .collect();
10684            assert_eq!(by_position, by_name, "row of {name}");
10685            // yr2oc.4: the slot-read incident edges follow the same row.
10686            let incident: Vec<(bool, &AttrMap)> =
10687                g.incident_edge_attrs(name).expect("live node").collect();
10688            let expected: Vec<(bool, &AttrMap)> = by_name
10689                .iter()
10690                .map(|&nbr| (nbr == name, g.edge_attrs(name, nbr).expect("edge")))
10691                .collect();
10692            assert_eq!(incident, expected, "incident edges of {name}");
10693        }
10694        assert!(g.neighbors_indices(names.len()).is_none());
10695        let by_positions: Vec<(&str, &str)> = g
10696            .edges_storage_order_index_iter()
10697            .map(|(l, r, _)| (g.get_node_name(l).unwrap(), g.get_node_name(r).unwrap()))
10698            .collect();
10699        let by_storage: Vec<(&str, &str)> = g
10700            .edges_storage_order_iter()
10701            .map(|(l, r, _)| (l, r))
10702            .collect();
10703        assert_eq!(by_positions, by_storage);
10704        g.with_edge_slots_by_neighbor(|rows| {
10705            assert_eq!(rows.len(), names.len());
10706            for (position, row) in rows.iter().enumerate() {
10707                let neighbours = g.neighbors_indices(position).unwrap();
10708                assert_eq!(row.len(), neighbours.len());
10709                for (&edge_slot, &neighbour) in row.iter().zip(neighbours) {
10710                    assert_eq!(
10711                        g.edge_attrs_by_slot(edge_slot),
10712                        g.edge_attrs(&names[position], &names[neighbour])
10713                    );
10714                }
10715            }
10716        });
10717    }
10718
10719    /// yr2oc.1: a run of removals leaves slots apart from positions (no
10720    /// compaction per removal), and the positional readers must still agree
10721    /// with the names. Reading the slot rows by position, which is what
10722    /// dropping the per-removal compaction alone would do, fails the row check
10723    /// on the first non-tail removal.
10724    #[test]
10725    fn removal_runs_keep_positional_readers_in_step_with_names() {
10726        let mut state = 0x9e37_79b9_7f4a_7c15_u64;
10727        let mut next = |bound: usize| {
10728            state ^= state << 13;
10729            state ^= state >> 7;
10730            state ^= state << 17;
10731            usize::try_from(state % bound as u64).unwrap()
10732        };
10733        for round in 0..40 {
10734            let n = 12 + round % 20;
10735            let mut g = Graph::strict();
10736            for i in 0..n {
10737                let _ = g.add_node(format!("n{i}"));
10738            }
10739            for _ in 0..3 * n {
10740                let (u, v) = (next(n), next(n));
10741                let mut attrs = AttrMap::new();
10742                attrs.insert(
10743                    "w".into(),
10744                    CgseValue::Int(i64::try_from(u * 100 + v).unwrap()),
10745                );
10746                g.add_edge_with_attrs(format!("n{u}"), format!("n{v}"), attrs)
10747                    .expect("edge");
10748            }
10749            assert!(g.is_compact());
10750            // Fewer than half the nodes, never the last position: no compaction.
10751            for _ in 0..n / 3 {
10752                let names: Vec<String> = g.nodes_ordered().into_iter().map(str::to_owned).collect();
10753                let victim = names[next(names.len() - 1)].clone();
10754                assert!(g.remove_node(&victim));
10755                assert!(!g.is_compact(), "round {round}: slots should stay apart");
10756                assert_graph_positions_agree_with_names(&g);
10757            }
10758            // The first insertion compacts, once.
10759            g.add_edge("fresh", "n0").expect("edge");
10760            assert!(g.is_compact());
10761            assert_graph_positions_agree_with_names(&g);
10762        }
10763    }
10764
10765    /// yr2oc.1: removals past half the live nodes compact, so the slot arrays
10766    /// stay within twice the live nodes.
10767    #[test]
10768    fn removal_runs_compact_once_freed_slots_outnumber_live_nodes() {
10769        let mut g = Graph::strict();
10770        for i in 0..10 {
10771            g.add_edge(format!("n{i}"), format!("n{}", (i + 1) % 10))
10772                .expect("edge");
10773        }
10774        for i in 0..5 {
10775            assert!(g.remove_node(&format!("n{i}")));
10776            assert!(!g.is_compact());
10777        }
10778        assert!(g.remove_node("n5"));
10779        assert!(g.is_compact(), "6 freed slots against 4 live nodes");
10780        assert_graph_positions_agree_with_names(&g);
10781    }
10782
10783    /// yr2oc.1: the node table's rank/select after O(1) removals, against the
10784    /// order a plain insertion-ordered list keeps.
10785    #[test]
10786    fn node_order_positions_follow_removals_and_compaction() {
10787        use super::NodeOrder;
10788        let names: Vec<String> = (0..12).map(|i| format!("v{i}")).collect();
10789        let mut order = NodeOrder::from_names(names.clone());
10790        let mut model = names.clone();
10791        let check = |order: &NodeOrder, model: &[String]| {
10792            assert_eq!(order.len(), model.len());
10793            assert_eq!(order.keys().cloned().collect::<Vec<_>>(), model);
10794            for (position, name) in model.iter().enumerate() {
10795                assert_eq!(order.get_index_of(name), Some(position));
10796                let (at, slot) = order.get_index(position).expect("live position");
10797                assert_eq!(at, name);
10798                assert_eq!(order.slot_at(position), Some(slot));
10799                assert_eq!(order.position_of(slot), Some(position));
10800                assert_eq!(order.get(name), Some(&slot));
10801            }
10802            assert_eq!(order.get_index(model.len()), None);
10803        };
10804        for victim in ["v3", "v0", "v7", "v11", "v10"] {
10805            let (slot, popped) = order.remove(victim).expect("live name");
10806            model.retain(|name| name != victim);
10807            assert!(
10808                !popped,
10809                "{victim} is not the last live entry of a dense table"
10810            );
10811            assert_eq!(order.position_of(slot), None);
10812            assert!(order.get(victim).is_none());
10813            assert!(!order.is_dense());
10814            check(&order, &model);
10815        }
10816        assert!(order.remove("v3").is_none());
10817        // A push after removals appends at the end of the order.
10818        order.push("late".to_owned());
10819        model.push("late".to_owned());
10820        check(&order, &model);
10821        // Compaction renumbers the live entries in order and keeps every name.
10822        let old_to_new = order.compact();
10823        assert!(order.is_dense());
10824        assert_eq!(order.slot_count(), model.len());
10825        assert_eq!(old_to_new[3], usize::MAX);
10826        assert_eq!(old_to_new[1], 0);
10827        check(&order, &model);
10828        // On a dense table the last entry is popped, not tombstoned.
10829        let last = model.pop().expect("non-empty");
10830        assert_eq!(order.remove(&last).map(|(_, popped)| popped), Some(true));
10831        assert!(order.is_dense());
10832        check(&order, &model);
10833    }
10834
10835    /// sfq4w.3: `names_are_positions` is never true when some name is not the
10836    /// decimal of its position, through random appends and removals, and a
10837    /// fresh table computes it exactly. `is_decimal_of` accepts only the
10838    /// canonical spelling.
10839    #[test]
10840    fn names_are_positions_is_never_true_when_a_name_is_off_position() {
10841        use super::{NodeOrder, is_decimal_of};
10842        assert!(is_decimal_of("0", 0));
10843        assert!(is_decimal_of("10", 10));
10844        assert!(is_decimal_of(&usize::MAX.to_string(), usize::MAX));
10845        for (name, n) in [
10846            ("", 0),
10847            ("00", 0),
10848            ("01", 1),
10849            ("+1", 1),
10850            ("-1", 1),
10851            ("1 ", 1),
10852            ("11", 1),
10853            ("1", 11),
10854            ("str:1:1", 1),
10855        ] {
10856            assert!(!is_decimal_of(name, n), "{name:?} spelled {n}");
10857        }
10858        let truth = |names: &[String]| {
10859            names
10860                .iter()
10861                .enumerate()
10862                .all(|(position, name)| *name == position.to_string())
10863        };
10864        let mut state = 0x2545_f491_4f6c_dd1d_u64;
10865        let mut next = |bound: usize| {
10866            state ^= state << 13;
10867            state ^= state >> 7;
10868            state ^= state << 17;
10869            usize::try_from(state % bound as u64).unwrap()
10870        };
10871        for round in 0..200 {
10872            let mut order = NodeOrder::default();
10873            let mut model: Vec<String> = Vec::new();
10874            for step in 0..40 {
10875                match next(5) {
10876                    // Mostly the next position, sometimes any other name.
10877                    0..=2 => {
10878                        let name = if next(4) == 0 {
10879                            format!("{}", next(50))
10880                        } else {
10881                            model.len().to_string()
10882                        };
10883                        if !order.contains_key(&name) {
10884                            order.push(name.clone());
10885                            model.push(name);
10886                        }
10887                    }
10888                    3 if !model.is_empty() => {
10889                        let victim = model.pop().expect("non-empty");
10890                        order.remove(&victim);
10891                    }
10892                    4 if !model.is_empty() => {
10893                        let victim = model.remove(next(model.len()));
10894                        order.remove(&victim);
10895                    }
10896                    _ => {}
10897                }
10898                if step % 3 == 0 {
10899                    let flag = order.names_are_positions();
10900                    assert!(
10901                        !flag || truth(&model),
10902                        "round {round} step {step}: {model:?}"
10903                    );
10904                    assert_eq!(
10905                        NodeOrder::from_names(model.clone()).names_are_positions(),
10906                        truth(&model)
10907                    );
10908                }
10909            }
10910        }
10911    }
10912
10913    /// br-r37-c1-qaqog: the MultiGraph table's copy of the flag - never true
10914    /// while some live name is off its position, through inserts (reusing
10915    /// freed slots), removals of the last and of other entries, and batch
10916    /// removals; and exactly the truth on a table built fresh.
10917    #[test]
10918    fn slot_order_names_are_positions_is_never_true_when_a_name_is_off_position() {
10919        use super::SlotOrder;
10920        let truth = |names: &[String]| {
10921            names
10922                .iter()
10923                .enumerate()
10924                .all(|(position, name)| *name == position.to_string())
10925        };
10926        let mut state = 0x9e37_79b9_7f4a_7c15_u64;
10927        let mut next = |bound: usize| {
10928            state ^= state << 13;
10929            state ^= state >> 7;
10930            state ^= state << 17;
10931            usize::try_from(state % bound as u64).unwrap()
10932        };
10933        for round in 0..200 {
10934            let mut order = SlotOrder::default();
10935            let mut model: Vec<String> = Vec::new();
10936            for step in 0..40 {
10937                match next(6) {
10938                    0..=2 => {
10939                        let name = if next(4) == 0 {
10940                            format!("{}", next(50))
10941                        } else {
10942                            model.len().to_string()
10943                        };
10944                        if !order.contains_key(&name) {
10945                            order.insert(name.clone());
10946                            model.push(name);
10947                        }
10948                    }
10949                    3 if !model.is_empty() => {
10950                        let victim = model.pop().expect("non-empty");
10951                        order.remove(&victim);
10952                    }
10953                    4 if !model.is_empty() => {
10954                        let victim = model.remove(next(model.len()));
10955                        order.remove(&victim);
10956                    }
10957                    5 if model.len() > 1 => {
10958                        let victim = model.remove(next(model.len()));
10959                        let slot = *order.get(&victim).expect("live");
10960                        order.remove_slots(&std::iter::once(slot).collect());
10961                    }
10962                    _ => {}
10963                }
10964                if step % 3 == 0 {
10965                    let flag = order.names_are_positions();
10966                    assert!(
10967                        !flag || truth(&model),
10968                        "round {round} step {step}: {model:?}"
10969                    );
10970                    let mut fresh = SlotOrder::default();
10971                    for name in &model {
10972                        fresh.insert(name.clone());
10973                    }
10974                    assert_eq!(fresh.names_are_positions(), truth(&model));
10975                }
10976            }
10977        }
10978    }
10979
10980    /// yr2oc.1: the multigraph node table against a plain insertion-ordered
10981    /// list, through random insertions (reusing freed slots), removals and
10982    /// batch removals, with positional reads interleaved so the rank tree is
10983    /// both rebuilt and carried through later appends and removals.
10984    #[test]
10985    fn slot_order_positions_follow_reuse_removals_and_compaction() {
10986        use super::SlotOrder;
10987        let mut state = 0x2545_f491_4f6c_dd1d_u64;
10988        let mut next = |bound: usize| {
10989            state ^= state << 13;
10990            state ^= state >> 7;
10991            state ^= state << 17;
10992            usize::try_from(state % bound as u64).unwrap()
10993        };
10994        let check = |order: &SlotOrder, model: &[String]| {
10995            assert_eq!(order.len(), model.len());
10996            assert_eq!(order.keys().cloned().collect::<Vec<_>>(), model);
10997            for (position, name) in model.iter().enumerate() {
10998                assert_eq!(order.get_index_of(name), Some(position));
10999                let (at, slot) = order.get_index(position).expect("live position");
11000                assert_eq!(at, name);
11001                assert_eq!(order.slot_at(position), Some(slot));
11002                assert_eq!(order.get(name), Some(&slot));
11003                assert_eq!(order.name(slot), Some(name.as_str()));
11004            }
11005            assert_eq!(order.slot_at(model.len()), None);
11006            assert_eq!(
11007                order.slot_count(),
11008                order.len() + order.free_slots().len(),
11009                "a slot is neither live nor free"
11010            );
11011            // Compaction keeps the entry list within twice the live nodes.
11012            assert!(order.entries.len() <= 2 * order.len());
11013        };
11014        let mut order = SlotOrder::default();
11015        let mut model: Vec<String> = Vec::new();
11016        let mut fresh = 0;
11017        let mut saw_tombstones = false;
11018        for step in 0..3000 {
11019            match next(10) {
11020                0..=4 => {
11021                    let name = format!("v{fresh}");
11022                    fresh += 1;
11023                    let free_before = order.free_slots().last().copied();
11024                    let (slot, grew) = order.insert(name.clone());
11025                    // A freed slot is reused, most recently freed first.
11026                    assert_eq!(grew, free_before.is_none());
11027                    if let Some(reused) = free_before {
11028                        assert_eq!(slot, reused);
11029                    }
11030                    model.push(name);
11031                }
11032                5..=8 if !model.is_empty() => {
11033                    let victim = model.remove(next(model.len()));
11034                    let slot = order.remove(&victim).expect("live name");
11035                    assert_eq!(order.free_slots().last(), Some(&slot));
11036                    assert_eq!(order.remove(&victim), None);
11037                }
11038                9 if model.len() > 3 => {
11039                    let doomed: rustc_hash::FxHashSet<usize> = (0..3)
11040                        .map(|_| *order.get(&model[next(model.len())]).expect("live"))
11041                        .collect();
11042                    model.retain(|name| !doomed.contains(order.get(name).expect("live")));
11043                    order.remove_slots(&doomed);
11044                }
11045                _ => {}
11046            }
11047            saw_tombstones |= order.entries.len() > order.len();
11048            // Read positions on most steps, so later steps meet a built tree.
11049            if step % 4 != 3 {
11050                check(&order, &model);
11051            }
11052        }
11053        assert!(
11054            saw_tombstones,
11055            "the run never left a tombstone to rank past"
11056        );
11057    }
11058
11059    #[test]
11060    fn multigraph_ordered_indices_match_borrowed_edges() {
11061        let mut graph = MultiGraph::strict();
11062        graph
11063            .add_edge_with_key_and_attrs("b", "a", 2, single_attr("w", "left"))
11064            .expect("first keyed edge should be added");
11065        graph
11066            .add_edge_with_key_and_attrs("a", "b", 0, single_attr("w", "right"))
11067            .expect("parallel keyed edge should be added");
11068        graph
11069            .add_edge_with_key_and_attrs("a", "c", 3, AttrMap::new())
11070            .expect("third edge should be added");
11071        graph
11072            .add_edge_with_key_and_attrs("c", "c", 1, single_attr("w", "loop"))
11073            .expect("self-loop should be added");
11074
11075        let nodes = graph.nodes_ordered();
11076        let from_indices = graph
11077            .edges_ordered_indices_borrowed()
11078            .into_iter()
11079            .map(|(left, right, key, attrs)| {
11080                (
11081                    nodes[left].to_owned(),
11082                    nodes[right].to_owned(),
11083                    key,
11084                    attrs.clone(),
11085                )
11086            })
11087            .collect::<Vec<_>>();
11088        let from_borrowed = graph
11089            .edges_ordered_borrowed()
11090            .into_iter()
11091            .map(|(left, right, key, attrs)| {
11092                (left.to_owned(), right.to_owned(), key, attrs.clone())
11093            })
11094            .collect::<Vec<_>>();
11095
11096        assert_eq!(from_indices, from_borrowed);
11097    }
11098
11099    #[test]
11100    fn multigraph_node_index_accessors_follow_remove_readd() {
11101        let mut graph = MultiGraph::strict();
11102        for node in ["prefix", "s", "a", "b", "t"] {
11103            let _ = graph.add_node(node);
11104        }
11105
11106        for (expected_index, node) in ["prefix", "s", "a", "b", "t"].into_iter().enumerate() {
11107            assert_eq!(graph.get_node_index(node), Some(expected_index));
11108            assert_eq!(graph.get_node_name(expected_index), Some(node));
11109        }
11110
11111        assert!(graph.remove_node("prefix"));
11112        assert!(graph.remove_node("a"));
11113        let _ = graph.add_node("a");
11114
11115        for (expected_index, node) in ["s", "b", "t", "a"].into_iter().enumerate() {
11116            assert_eq!(graph.get_node_index(node), Some(expected_index));
11117            assert_eq!(graph.get_node_name(expected_index), Some(node));
11118        }
11119        assert_eq!(graph.get_node_index("prefix"), None);
11120        assert_eq!(graph.get_node_name(graph.node_count()), None);
11121    }
11122
11123    #[test]
11124    fn strict_mode_fails_closed_for_unknown_incompatible_feature() {
11125        let mut graph = Graph::strict();
11126        let mut attrs = AttrMap::new();
11127        attrs.insert("__fnx_incompatible_decoder".to_owned(), "v2".into());
11128        let err = graph
11129            .add_edge_with_attrs("a", "b", attrs)
11130            .expect_err("strict mode should fail closed");
11131
11132        assert_eq!(
11133            err,
11134            GraphError::FailClosed {
11135                operation: "add_edge",
11136                reason: "incompatible edge metadata".to_owned(),
11137            }
11138        );
11139
11140        let last_record = graph
11141            .evidence_ledger()
11142            .records()
11143            .last()
11144            .expect("strict fail-closed path should emit a ledger row");
11145        assert_decision_record_schema(last_record, CompatibilityMode::Strict);
11146        assert_eq!(last_record.operation, "add_edge");
11147        assert_eq!(last_record.action, DecisionAction::FailClosed);
11148        assert!(
11149            last_record
11150                .evidence
11151                .iter()
11152                .any(|term| term.signal == "unknown_incompatible_feature")
11153        );
11154    }
11155
11156    /// br-r37-c1-eo88t: an ALLOWED edge add writes exactly one `add_edge`
11157    /// ledger record, and it is the outcome record. Asserting the surviving
11158    /// record's evidence is the point: an implementation that kept the gate
11159    /// record instead of the outcome one would still pass a bare count check
11160    /// but would have thrown away the richer decision.
11161    fn add_edge_records(graph_records: &[DecisionRecord]) -> Vec<Vec<&str>> {
11162        graph_records
11163            .iter()
11164            .filter(|record| record.operation == "add_edge")
11165            .map(|record| {
11166                record
11167                    .evidence
11168                    .iter()
11169                    .map(|term| term.signal.as_ref())
11170                    .collect()
11171            })
11172            .collect()
11173    }
11174
11175    #[test]
11176    fn allowed_add_edge_records_only_the_outcome_decision() {
11177        let mut graph = Graph::strict();
11178        graph
11179            .add_edge_with_attrs("a", "b", AttrMap::new())
11180            .expect("edge add should succeed");
11181
11182        assert_eq!(
11183            add_edge_records(graph.evidence_ledger().records()),
11184            vec![vec![
11185                "self_loop",
11186                "edge_attr_count",
11187                "left_autocreated",
11188                "right_autocreated",
11189            ]]
11190        );
11191        assert_eq!(
11192            graph
11193                .evidence_ledger()
11194                .records()
11195                .iter()
11196                .filter(|record| record.operation == "add_edge")
11197                .map(|record| record.action)
11198                .collect::<Vec<_>>(),
11199            vec![DecisionAction::Allow]
11200        );
11201    }
11202
11203    #[test]
11204    fn add_edge_autocreation_files_no_add_node_record() {
11205        // br-r37-c1-wa1b9: autocreating an endpoint is an internal STEP of
11206        // add_edge, so the ledger's per-OPERATION contract means add_edge files
11207        // exactly ONE record however many endpoints it had to create. The
11208        // information is not lost — it is in that record's autocreated terms.
11209        // A caller-visible add_node still records, which the second half pins.
11210        let mut graph = Graph::strict();
11211        graph
11212            .add_edge_with_attrs("a", "b", AttrMap::new())
11213            .expect("edge add should succeed");
11214
11215        let records = graph.evidence_ledger().records();
11216        assert_eq!(
11217            records.len(),
11218            1,
11219            "add_edge creating two nodes must file one record, got {:?}",
11220            records
11221                .iter()
11222                .map(|record| record.operation.as_ref())
11223                .collect::<Vec<_>>()
11224        );
11225        let record = &records[0];
11226        assert_eq!(record.operation, "add_edge");
11227        assert_eq!(
11228            record
11229                .evidence
11230                .iter()
11231                .filter(
11232                    |term| term.signal == "left_autocreated" || term.signal == "right_autocreated"
11233                )
11234                .map(|term| term.observed_value.as_ref())
11235                .collect::<Vec<_>>(),
11236            vec!["true", "true"],
11237            "the autocreation event must still be auditable from the outcome record"
11238        );
11239
11240        // A user-visible add_node keeps its own record.
11241        let before = graph.evidence_ledger().records().len();
11242        graph.add_node("c");
11243        let records = graph.evidence_ledger().records();
11244        assert_eq!(records.len(), before + 1);
11245        assert_eq!(
11246            records.last().map(|record| record.operation.as_ref()),
11247            Some("add_node")
11248        );
11249    }
11250
11251    /// br-r37-c1-4c29a: `MultiGraph::add_edge_impl` now resolves each endpoint
11252    /// to a storage slot ONCE and calls the `_by_pair` variants, instead of
11253    /// re-resolving both endpoint strings inside six separate storage calls.
11254    /// The behaviour that could break is key assignment and the self-loop
11255    /// shortcut (`left == right` reuses the source slot rather than probing),
11256    /// so both are pinned here against the observable key sequence.
11257    #[test]
11258    fn multigraph_slot_resolution_preserves_key_assignment() {
11259        let mut graph = MultiGraph::strict();
11260
11261        // Parallel edges take successive keys, and the pair is order-insensitive.
11262        assert_eq!(
11263            graph
11264                .add_edge_with_attrs("a", "b", AttrMap::new())
11265                .expect("edge is allowed"),
11266            0
11267        );
11268        assert_eq!(
11269            graph
11270                .add_edge_with_attrs("a", "b", AttrMap::new())
11271                .expect("edge is allowed"),
11272            1
11273        );
11274        assert_eq!(
11275            graph
11276                .add_edge_with_attrs("b", "a", AttrMap::new())
11277                .expect("reversed endpoints are the same undirected pair"),
11278            2
11279        );
11280
11281        // Self-loop takes the shortcut branch and must key independently.
11282        assert_eq!(
11283            graph
11284                .add_edge_with_attrs("a", "a", AttrMap::new())
11285                .expect("self-loop is allowed"),
11286            0
11287        );
11288        assert_eq!(
11289            graph
11290                .add_edge_with_attrs("a", "a", AttrMap::new())
11291                .expect("parallel self-loop is allowed"),
11292            1
11293        );
11294
11295        // An explicit key still lands where asked, on both branches.
11296        assert_eq!(
11297            graph
11298                .add_edge_with_key_and_attrs("a", "b", 9, AttrMap::new())
11299                .expect("explicit key is allowed"),
11300            9
11301        );
11302        assert_eq!(
11303            graph
11304                .add_edge_with_key_and_attrs("a", "a", 9, AttrMap::new())
11305                .expect("explicit key is allowed"),
11306            9
11307        );
11308
11309        assert_eq!(graph.node_count(), 2);
11310        assert_eq!(graph.edge_count(), 7);
11311    }
11312
11313    /// br-r37-c1-jc9e4: `add_edge_with_attrs` now TAKES the caller's attribute
11314    /// map when the edge bucket is empty instead of extending entry by entry.
11315    /// That is a new branch on a CGSE-critical path, so this pins the four
11316    /// bucket-state / incoming-attrs combinations it splits, including the
11317    /// non-obvious one: an edge that already exists but carries NO attributes
11318    /// still takes the empty-bucket branch on a later attributed add.
11319    #[test]
11320    fn add_edge_fresh_bucket_take_matches_merge_semantics() {
11321        fn weight(value: i64) -> AttrMap {
11322            let mut attrs = AttrMap::new();
11323            attrs.insert("w".to_owned(), CgseValue::Int(value));
11324            attrs
11325        }
11326        fn stored(graph: &Graph, left: &str, right: &str) -> Vec<(String, CgseValue)> {
11327            graph
11328                .edge_attrs(left, right)
11329                .expect("edge exists")
11330                .iter()
11331                .map(|(key, value)| (key.clone(), value.clone()))
11332                .collect()
11333        }
11334
11335        // New edge WITH attrs: the taken map is the stored map.
11336        let mut graph = Graph::strict();
11337        graph
11338            .add_edge_with_attrs("a", "b", weight(1))
11339            .expect("edge is allowed");
11340        assert_eq!(
11341            stored(&graph, "a", "b"),
11342            vec![("w".to_owned(), CgseValue::Int(1))]
11343        );
11344
11345        // New edge with EMPTY attrs: bucket stays empty, edge still exists.
11346        graph
11347            .add_edge_with_attrs("c", "d", AttrMap::new())
11348            .expect("edge is allowed");
11349        assert!(stored(&graph, "c", "d").is_empty());
11350
11351        // EXISTING edge whose bucket is still empty — takes the same branch a
11352        // brand-new edge does, and must land the attrs just the same.
11353        graph
11354            .add_edge_with_attrs("c", "d", weight(7))
11355            .expect("edge is allowed");
11356        assert_eq!(
11357            stored(&graph, "c", "d"),
11358            vec![("w".to_owned(), CgseValue::Int(7))]
11359        );
11360
11361        // EXISTING edge with a NON-empty bucket still merges, and a differing
11362        // value overwrites rather than being dropped by the take.
11363        graph
11364            .add_edge_with_attrs("a", "b", weight(9))
11365            .expect("edge is allowed");
11366        let mut extra = AttrMap::new();
11367        extra.insert("k".to_owned(), CgseValue::Int(2));
11368        graph
11369            .add_edge_with_attrs("a", "b", extra)
11370            .expect("edge is allowed");
11371        // A merged key goes after the ones already there, as dict.update puts
11372        // it (br-r37-c1-6hyf1: the store keeps insertion order).
11373        assert_eq!(
11374            stored(&graph, "a", "b"),
11375            vec![
11376                ("w".to_owned(), CgseValue::Int(9)),
11377                ("k".to_owned(), CgseValue::Int(2)),
11378            ]
11379        );
11380    }
11381
11382    #[test]
11383    fn add_edge_entry_probe_keeps_existing_empty_edge_structurally_unchanged() {
11384        let mut graph = Graph::strict();
11385        graph
11386            .add_edge_with_attrs("a", "b", AttrMap::new())
11387            .expect("initial edge is allowed");
11388        let revision_before = graph.revision;
11389
11390        graph
11391            .add_edge_with_attrs("b", "a", AttrMap::new())
11392            .expect("duplicate edge is allowed");
11393
11394        assert_eq!(graph.edge_count(), 1);
11395        assert_eq!(graph.neighbors("a"), Some(vec!["b"]));
11396        assert_eq!(graph.neighbors("b"), Some(vec!["a"]));
11397        assert_eq!(graph.revision, revision_before);
11398    }
11399
11400    /// br-r37-c1-jc9e4: locks the endpoint-resolution cases that collapsing the
11401    /// per-endpoint `contains_key` + `get_index_of` pair restructured.
11402    ///
11403    /// The self-loop branch is the delicate one: it now returns the left index
11404    /// directly instead of probing the map for the right endpoint, and it
11405    /// still has to report `right_autocreated` as a COPY of `left_autocreated`
11406    /// — true when the loop created its node, false when the node was already
11407    /// there. Node insertion order after autocreation is asserted alongside,
11408    /// because the left endpoint is resolved before the right one and an
11409    /// `IndexMap` append is what keeps the left index valid across the right
11410    /// endpoint's insertion.
11411    #[test]
11412    fn add_edge_endpoint_resolution_preserves_autocreation_and_order() {
11413        fn autocreated(graph: &Graph) -> (String, String) {
11414            let record = graph
11415                .evidence_ledger()
11416                .records()
11417                .last()
11418                .expect("add_edge files an outcome record");
11419            let read = |signal: &str| {
11420                record
11421                    .evidence
11422                    .iter()
11423                    .find(|term| term.signal == signal)
11424                    .map(|term| term.observed_value.to_string())
11425                    .unwrap_or_else(|| panic!("{signal} must be present"))
11426            };
11427            (read("left_autocreated"), read("right_autocreated"))
11428        }
11429
11430        // Self-loop that CREATES its node: both flags true, one node added.
11431        let mut graph = Graph::strict();
11432        graph
11433            .add_edge_with_attrs("s", "s", AttrMap::new())
11434            .expect("self-loop is allowed");
11435        assert_eq!(autocreated(&graph), ("true".to_owned(), "true".to_owned()));
11436        assert_eq!(graph.node_count(), 1);
11437
11438        // Self-loop on an EXISTING node: both flags false, nothing added.
11439        graph
11440            .add_edge_with_attrs("s", "s", AttrMap::new())
11441            .expect("re-adding a self-loop is allowed");
11442        assert_eq!(
11443            autocreated(&graph),
11444            ("false".to_owned(), "false".to_owned())
11445        );
11446        assert_eq!(graph.node_count(), 1);
11447
11448        // Left exists, right is new — the asymmetric case.
11449        graph
11450            .add_edge_with_attrs("s", "t", AttrMap::new())
11451            .expect("edge is allowed");
11452        assert_eq!(autocreated(&graph), ("false".to_owned(), "true".to_owned()));
11453
11454        // Both new: left is resolved and inserted BEFORE right, so insertion
11455        // order is left-then-right and the earlier indices are unmoved.
11456        graph
11457            .add_edge_with_attrs("z", "a", AttrMap::new())
11458            .expect("edge is allowed");
11459        assert_eq!(autocreated(&graph), ("true".to_owned(), "true".to_owned()));
11460        assert_eq!(graph.get_node_index("s"), Some(0));
11461        assert_eq!(graph.get_node_index("t"), Some(1));
11462        assert_eq!(graph.get_node_index("z"), Some(2));
11463        assert_eq!(graph.get_node_index("a"), Some(3));
11464    }
11465
11466    /// br-r37-c1-g2nev: the ledger's compile-time-constant strings must reach
11467    /// the record as BORROWED `Cow`s. That is the whole lever — every one of
11468    /// them used to be a per-mutation heap allocation — so this pins it on the
11469    /// shipped `add_edge`/`add_node` path rather than on a bench mirror. The
11470    /// value assertions pin the other half: the bytes are unchanged, so the
11471    /// CGSE audit trail is untouched.
11472    #[test]
11473    fn mutation_ledger_constants_are_borrowed_not_allocated() {
11474        use std::borrow::Cow;
11475        use std::collections::BTreeMap;
11476
11477        let mut graph = Graph::strict();
11478        let mut attrs = AttrMap::new();
11479        attrs.insert("weight".to_owned(), CgseValue::Int(3));
11480        graph
11481            .add_edge_with_attrs("a", "b", attrs)
11482            .expect("strict add_edge with an int attribute is allowed");
11483        graph.add_node("c");
11484
11485        let records = graph.evidence_ledger().records();
11486        assert_eq!(
11487            records
11488                .iter()
11489                .map(|record| record.operation.as_ref())
11490                .collect::<Vec<_>>(),
11491            vec!["add_edge", "add_node"],
11492            "ledger shape must be unchanged by the Cow migration"
11493        );
11494
11495        // Constant `observed_value`s, by signal, exactly as `bool::to_string()`
11496        // rendered them before the migration.
11497        let expected_borrowed: BTreeMap<&str, &str> = [
11498            ("self_loop", "false"),
11499            ("left_autocreated", "true"),
11500            ("right_autocreated", "true"),
11501            ("node_preexisting", "false"),
11502        ]
11503        .into_iter()
11504        .collect();
11505
11506        for record in records {
11507            assert!(
11508                matches!(record.operation, Cow::Borrowed(_)),
11509                "operation `{}` must be borrowed",
11510                record.operation
11511            );
11512            assert!(
11513                matches!(record.rationale, Cow::Borrowed(_)),
11514                "rationale `{}` must be borrowed",
11515                record.rationale
11516            );
11517            for term in &record.evidence {
11518                assert!(
11519                    matches!(term.signal, Cow::Borrowed(_)),
11520                    "signal `{}` must be borrowed",
11521                    term.signal
11522                );
11523                if let Some(expected) = expected_borrowed.get(term.signal.as_ref()) {
11524                    assert_eq!(&term.observed_value, expected);
11525                    assert!(
11526                        matches!(term.observed_value, Cow::Borrowed(_)),
11527                        "boolean observed_value for `{}` must be borrowed",
11528                        term.signal
11529                    );
11530                }
11531            }
11532        }
11533
11534        // The genuinely dynamic counts still allocate, and still read the same.
11535        let edge_record = &records[0];
11536        assert_eq!(
11537            edge_record
11538                .evidence
11539                .iter()
11540                .find(|term| term.signal == "edge_attr_count")
11541                .map(|term| term.observed_value.as_ref()),
11542            Some("1")
11543        );
11544    }
11545
11546    #[test]
11547    fn allowed_multigraph_add_edge_records_only_the_outcome_decision() {
11548        let mut graph = MultiGraph::strict();
11549        let _ = graph
11550            .add_edge_with_attrs("a", "b", AttrMap::new())
11551            .expect("edge add should succeed");
11552
11553        assert_eq!(
11554            add_edge_records(graph.evidence_ledger().records()),
11555            vec![vec![
11556                "edge_key",
11557                "edge_attr_count",
11558                "left_autocreated",
11559                "right_autocreated",
11560            ]]
11561        );
11562    }
11563
11564    #[test]
11565    fn refused_multigraph_add_edge_still_records_its_gate_decision() {
11566        // The negative case for the pairing above: a refusal returns before the
11567        // outcome record runs, so dropping the gate record unconditionally would
11568        // leave a rejected mutation with no audit trail at all.
11569        let mut graph = MultiGraph::strict();
11570        let mut attrs = AttrMap::new();
11571        attrs.insert("__fnx_incompatible_decoder".to_owned(), "v2".into());
11572        let err = graph
11573            .add_edge_with_attrs("a", "b", attrs)
11574            .expect_err("strict mode should fail closed");
11575        assert_eq!(
11576            err,
11577            GraphError::FailClosed {
11578                operation: "add_edge",
11579                reason: "incompatible edge metadata".to_owned(),
11580            }
11581        );
11582
11583        assert_eq!(
11584            add_edge_records(graph.evidence_ledger().records()),
11585            vec![vec!["unknown_incompatible_feature"]]
11586        );
11587        assert_eq!(
11588            graph
11589                .evidence_ledger()
11590                .records()
11591                .last()
11592                .map(|record| record.action),
11593            Some(DecisionAction::FailClosed)
11594        );
11595    }
11596
11597    #[test]
11598    fn revision_increments_on_mutating_operations() {
11599        let mut graph = Graph::strict();
11600        let r0 = graph.revision();
11601        let _ = graph.add_node("a");
11602        let r1 = graph.revision();
11603        assert!(r1 > r0);
11604
11605        graph.add_edge("a", "b").expect("edge add should succeed");
11606        let r2 = graph.revision();
11607        assert!(r2 > r1);
11608
11609        let _ = graph.remove_edge("a", "b");
11610        let r3 = graph.revision();
11611        assert!(r3 > r2);
11612    }
11613
11614    #[test]
11615    fn add_node_with_attrs_reports_change_on_existing_node() {
11616        let mut graph = Graph::strict();
11617        assert!(graph.add_node_with_attrs("a", single_attr("color", "red")));
11618        let r1 = graph.revision();
11619        assert!(graph.add_node_with_attrs("a", single_attr("color", "blue")));
11620        let r2 = graph.revision();
11621        assert!(r2 > r1);
11622        let expected = CgseValue::from("blue");
11623        assert_eq!(graph.node_attrs("a").unwrap().get("color"), Some(&expected));
11624    }
11625
11626    #[test]
11627    fn add_node_with_identical_attrs_keeps_order_and_revision() {
11628        let mut graph = Graph::strict();
11629        assert!(graph.add_node_with_attrs("a", single_attr("color", "red")));
11630        assert!(graph.add_node("b"));
11631        let revision_before = graph.revision();
11632
11633        assert!(!graph.add_node_with_attrs("a", single_attr("color", "red")));
11634
11635        assert_eq!(graph.nodes_ordered(), vec!["a", "b"]);
11636        assert_eq!(graph.revision(), revision_before);
11637        assert_eq!(
11638            graph.node_attrs("a").and_then(|attrs| attrs.get("color")),
11639            Some(&CgseValue::from("red"))
11640        );
11641    }
11642
11643    #[test]
11644    fn add_node_with_attrs_keeps_fresh_node_order_and_attrs() {
11645        let mut graph = Graph::strict();
11646        assert!(graph.add_node("before"));
11647        assert!(graph.add_node_with_attrs("fresh", single_attr("color", "red")));
11648        assert!(graph.add_node("after"));
11649
11650        assert_eq!(graph.nodes_ordered(), vec!["before", "fresh", "after"]);
11651        assert_eq!(
11652            graph
11653                .node_attrs("fresh")
11654                .and_then(|attrs| attrs.get("color")),
11655            Some(&CgseValue::from("red"))
11656        );
11657    }
11658
11659    #[test]
11660    fn multigraph_add_node_with_attrs_reports_change_on_existing_node() {
11661        let mut graph = MultiGraph::strict();
11662        assert!(graph.add_node_with_attrs("a", single_attr("color", "red")));
11663        let r1 = graph.revision();
11664        assert!(graph.add_node_with_attrs("a", single_attr("color", "blue")));
11665        let r2 = graph.revision();
11666        assert!(r2 > r1);
11667        let expected = CgseValue::from("blue");
11668        assert_eq!(graph.node_attrs("a").unwrap().get("color"), Some(&expected));
11669    }
11670
11671    #[test]
11672    fn hardened_self_loop_records_allow_decision() {
11673        let mut graph = Graph::hardened();
11674        graph
11675            .add_edge("loop", "loop")
11676            .expect("hardened self-loop edge should be accepted");
11677
11678        let add_edge_record = graph
11679            .evidence_ledger()
11680            .records()
11681            .iter()
11682            .rev()
11683            .find(|record| record.operation == "add_edge")
11684            .expect("add_edge operation should emit ledger row");
11685        assert_decision_record_schema(add_edge_record, CompatibilityMode::Hardened);
11686        assert_eq!(add_edge_record.action, DecisionAction::Allow);
11687        assert!(
11688            add_edge_record
11689                .evidence
11690                .iter()
11691                .any(|term| term.signal == "self_loop" && term.observed_value == "true")
11692        );
11693    }
11694
11695    #[test]
11696    fn runtime_policy_tracks_hardened_graph_recovery() {
11697        let mut graph = Graph::hardened();
11698        graph
11699            .add_edge("a", "b")
11700            .expect("hardened graph edge add should succeed");
11701
11702        assert_eq!(graph.runtime_policy().mode(), CompatibilityMode::Hardened);
11703        assert!(!graph.runtime_policy().decision_log().records().is_empty());
11704        assert!(graph.runtime_policy().posterior().observation_count >= 1);
11705    }
11706
11707    #[test]
11708    fn snapshot_roundtrip_replays_to_identical_state() {
11709        let mut graph = Graph::strict();
11710
11711        let mut first_attrs = AttrMap::new();
11712        first_attrs.insert("weight".to_owned(), "7".into());
11713        graph
11714            .add_edge_with_attrs("a", "b", first_attrs)
11715            .expect("edge insert should succeed");
11716
11717        let mut second_attrs = AttrMap::new();
11718        second_attrs.insert("color".to_owned(), "green".into());
11719        graph
11720            .add_edge_with_attrs("b", "c", second_attrs)
11721            .expect("edge insert should succeed");
11722
11723        let snapshot = graph.snapshot();
11724        let mut replayed = Graph::new(snapshot.mode);
11725        for node in &snapshot.nodes {
11726            let _ = replayed.add_node(node.clone());
11727        }
11728        for edge in &snapshot.edges {
11729            replayed
11730                .add_edge_with_attrs(edge.left.clone(), edge.right.clone(), edge.attrs.clone())
11731                .expect("snapshot replay should be valid");
11732        }
11733
11734        assert_eq!(replayed.snapshot(), snapshot);
11735        assert_graph_core_invariants(&replayed);
11736    }
11737
11738    #[test]
11739    fn multigraph_tracks_parallel_edges_with_distinct_keys() {
11740        let mut graph = MultiGraph::strict();
11741        let first = graph.add_edge("a", "b").expect("edge add should succeed");
11742        let second = graph.add_edge("a", "b").expect("edge add should succeed");
11743
11744        assert_ne!(first, second);
11745        assert_eq!(graph.edge_count(), 2);
11746        assert_eq!(graph.edge_keys("a", "b"), Some(vec![0, 1]));
11747        assert_multigraph_core_invariants(&graph);
11748    }
11749
11750    #[test]
11751    fn multigraph_counts_parallel_selfloops_only() {
11752        let mut graph = MultiGraph::strict();
11753        let _ = graph
11754            .add_edge("a", "a")
11755            .expect("self-loop add should succeed");
11756        let _ = graph
11757            .add_edge("a", "a")
11758            .expect("self-loop add should succeed");
11759        let _ = graph
11760            .add_edge("b", "b")
11761            .expect("self-loop add should succeed");
11762        let _ = graph.add_edge("a", "b").expect("edge add should succeed");
11763        let _ = graph.add_edge("a", "b").expect("edge add should succeed");
11764
11765        assert_eq!(graph.number_of_selfloops(), 3);
11766        assert_multigraph_core_invariants(&graph);
11767    }
11768
11769    #[test]
11770    fn multigraph_add_fresh_edge_unrecorded_rejects_occupied_bucket() {
11771        let mut graph = MultiGraph::strict();
11772        assert_eq!(graph.add_fresh_edge_unrecorded("a", "b"), Some(0));
11773        let revision = graph.revision();
11774        let edge_count = graph.edge_count();
11775
11776        assert_eq!(graph.add_fresh_edge_unrecorded("b", "a"), None);
11777
11778        assert_eq!(graph.revision(), revision);
11779        assert_eq!(graph.edge_count(), edge_count);
11780        assert_eq!(graph.edge_keys("a", "b"), Some(vec![0]));
11781        assert_eq!(graph.nodes_ordered(), vec!["a", "b"]);
11782        assert_multigraph_core_invariants(&graph);
11783    }
11784
11785    #[test]
11786    fn multigraph_add_fresh_edge_with_key_unrecorded_preserves_key() {
11787        let mut graph = MultiGraph::strict();
11788
11789        assert_eq!(
11790            graph.add_fresh_edge_with_key_unrecorded("a", "b", 42),
11791            Some(42)
11792        );
11793
11794        assert_eq!(graph.edge_count(), 1);
11795        assert_eq!(graph.edge_keys("a", "b"), Some(vec![42]));
11796        assert_eq!(graph.nodes_ordered(), vec!["a", "b"]);
11797        assert_multigraph_core_invariants(&graph);
11798    }
11799
11800    #[test]
11801    fn multigraph_remove_edge_without_key_removes_latest_instance() {
11802        let mut graph = MultiGraph::strict();
11803        let first = graph.add_edge("a", "b").expect("edge add should succeed");
11804        let second = graph.add_edge("a", "b").expect("edge add should succeed");
11805
11806        assert!(graph.remove_edge("a", "b", None));
11807        assert_eq!(graph.edge_count(), 1);
11808        assert_eq!(graph.edge_keys("a", "b"), Some(vec![first]));
11809        assert_ne!(first, second);
11810        assert_multigraph_core_invariants(&graph);
11811    }
11812
11813    #[test]
11814    fn multigraph_remove_node_clears_parallel_incident_edges() {
11815        let mut graph = MultiGraph::strict();
11816        let _ = graph.add_edge("a", "b").expect("edge add should succeed");
11817        let _ = graph.add_edge("a", "b").expect("edge add should succeed");
11818        let _ = graph.add_edge("b", "c").expect("edge add should succeed");
11819
11820        assert!(graph.remove_node("b"));
11821        assert_eq!(graph.edge_count(), 0);
11822        assert!(!graph.has_node("b"));
11823        assert_eq!(graph.neighbors("a"), Some(vec![]));
11824        assert_eq!(graph.neighbors("c"), Some(vec![]));
11825        assert_multigraph_core_invariants(&graph);
11826    }
11827
11828    #[test]
11829    fn multigraph_clear_edges_preserves_nodes_attrs_and_rows() {
11830        let mut graph = MultiGraph::strict();
11831        assert!(graph.add_node_with_attrs("a", single_attr("color", "red")));
11832        assert!(graph.add_node_with_attrs("b", single_attr("color", "blue")));
11833        let _ = graph
11834            .add_edge_with_key_and_attrs("a", "b", 7, single_attr("weight", "3"))
11835            .expect("edge add should succeed");
11836        let _ = graph
11837            .add_edge_with_key_and_attrs("b", "a", 2, single_attr("weight", "5"))
11838            .expect("edge add should succeed");
11839        let before_revision = graph.revision();
11840
11841        graph.clear_edges();
11842
11843        assert_eq!(graph.node_count(), 2);
11844        assert_eq!(graph.nodes_ordered(), vec!["a", "b"]);
11845        assert_eq!(graph.edge_count(), 0);
11846        assert!(graph.edges_ordered().is_empty());
11847        assert_eq!(graph.neighbors("a"), Some(vec![]));
11848        assert_eq!(graph.neighbors("b"), Some(vec![]));
11849        assert_eq!(
11850            graph.node_attrs("a").unwrap().get("color"),
11851            Some(&CgseValue::from("red"))
11852        );
11853        assert!(graph.revision() > before_revision);
11854        assert_multigraph_core_invariants(&graph);
11855    }
11856
11857    #[test]
11858    fn runtime_policy_tracks_hardened_multigraph_recovery() {
11859        let mut graph = MultiGraph::hardened();
11860        graph
11861            .add_edge("a", "b")
11862            .expect("hardened multigraph edge add should succeed");
11863
11864        assert_eq!(graph.runtime_policy().mode(), CompatibilityMode::Hardened);
11865        assert!(!graph.runtime_policy().decision_log().records().is_empty());
11866        assert!(graph.runtime_policy().posterior().observation_count >= 1);
11867    }
11868
11869    #[test]
11870    fn multigraph_roundtrips_sparse_snapshot_keys() {
11871        let mut graph = MultiGraph::strict();
11872        assert_eq!(
11873            graph.add_edge("a", "b").expect("edge add should succeed"),
11874            0
11875        );
11876        assert_eq!(
11877            graph.add_edge("a", "b").expect("edge add should succeed"),
11878            1
11879        );
11880        assert_eq!(
11881            graph.add_edge("a", "b").expect("edge add should succeed"),
11882            2
11883        );
11884        assert!(graph.remove_edge("a", "b", Some(1)));
11885        assert_eq!(
11886            graph.add_edge("a", "b").expect("edge add should succeed"),
11887            3
11888        );
11889
11890        let snapshot = graph.snapshot();
11891        assert_eq!(
11892            snapshot
11893                .edges
11894                .iter()
11895                .map(|edge| edge.key)
11896                .collect::<Vec<_>>(),
11897            vec![0, 2, 3]
11898        );
11899
11900        let mut replayed = MultiGraph::new(snapshot.mode);
11901        for node in &snapshot.nodes {
11902            let _ = replayed.add_node(node.clone());
11903        }
11904        for edge in &snapshot.edges {
11905            replayed
11906                .add_edge_with_key_and_attrs(
11907                    edge.left.clone(),
11908                    edge.right.clone(),
11909                    edge.key,
11910                    edge.attrs.clone(),
11911                )
11912                .expect("snapshot replay should preserve explicit keys");
11913        }
11914
11915        assert_eq!(replayed.snapshot(), snapshot);
11916        assert_multigraph_core_invariants(&replayed);
11917    }
11918
11919    proptest! {
11920        #[test]
11921        fn prop_core_invariants_hold_for_mixed_edge_mutations(
11922            ops in prop::collection::vec((0_u8..8, 0_u8..8, any::<bool>()), 1..80),
11923        ) {
11924            let mut graph = Graph::strict();
11925            let mut last_revision = graph.revision();
11926
11927            for (left_id, right_id, is_add) in ops {
11928                let left = node_name(left_id);
11929                let right = node_name(right_id);
11930                if is_add {
11931                    prop_assert!(graph.add_edge(left.clone(), right.clone()).is_ok());
11932                } else {
11933                    let _ = graph.remove_edge(&left, &right);
11934                }
11935                let revision = graph.revision();
11936                prop_assert!(revision >= last_revision);
11937                last_revision = revision;
11938                assert_graph_core_invariants(&graph);
11939            }
11940        }
11941
11942        #[test]
11943        fn prop_snapshot_is_deterministic_for_same_operation_stream(
11944            ops in prop::collection::vec((0_u8..8, 0_u8..8, 0_u8..3), 0..64),
11945        ) {
11946            let mut graph_left = Graph::hardened();
11947            let mut graph_right = Graph::hardened();
11948
11949            for (left_id, right_id, attrs_variant) in ops {
11950                let left = node_name(left_id);
11951                let right = node_name(right_id);
11952                let mut attrs = AttrMap::new();
11953                if attrs_variant == 1 {
11954                    attrs.insert("weight".to_owned(), (left_id % 5).to_string().into());
11955                } else if attrs_variant == 2 {
11956                    attrs.insert("tag".to_owned(), format!("k{}", right_id % 4).into());
11957                }
11958                prop_assert!(
11959                    graph_left
11960                        .add_edge_with_attrs(left.clone(), right.clone(), attrs.clone())
11961                        .is_ok()
11962                );
11963                prop_assert!(
11964                    graph_right
11965                        .add_edge_with_attrs(left, right, attrs)
11966                        .is_ok()
11967                );
11968            }
11969
11970            prop_assert_eq!(graph_left.snapshot(), graph_right.snapshot());
11971            prop_assert_eq!(graph_left.snapshot(), graph_left.snapshot());
11972        }
11973
11974        #[test]
11975        fn prop_reapplying_identical_edge_attrs_is_revision_stable(
11976            left_id in 0_u8..8,
11977            right_id in 0_u8..8,
11978            weight in 0_u16..5000,
11979        ) {
11980            let mut graph = Graph::strict();
11981            let left = node_name(left_id);
11982            let right = node_name(right_id);
11983            let mut attrs = AttrMap::new();
11984            attrs.insert("weight".to_owned(), weight.to_string().into());
11985
11986            prop_assert!(
11987                graph
11988                    .add_edge_with_attrs(left.clone(), right.clone(), attrs.clone())
11989                    .is_ok()
11990            );
11991            let revision_after_first = graph.revision();
11992            prop_assert!(
11993                graph
11994                    .add_edge_with_attrs(left, right, attrs)
11995                    .is_ok()
11996            );
11997            prop_assert_eq!(graph.revision(), revision_after_first);
11998        }
11999
12000        #[test]
12001        fn prop_remove_node_clears_incident_edges(
12002            ops in prop::collection::vec((0_u8..8, 0_u8..8), 1..64),
12003            target_id in 0_u8..8,
12004        ) {
12005            let mut graph = Graph::strict();
12006            for (left_id, right_id) in ops {
12007                let left = node_name(left_id);
12008                let right = node_name(right_id);
12009                prop_assert!(graph.add_edge(left, right).is_ok());
12010            }
12011
12012            let target = node_name(target_id);
12013            let removed = graph.remove_node(&target);
12014            if removed {
12015                prop_assert!(!graph.has_node(&target));
12016                for node in graph.nodes_ordered() {
12017                    let neighbors = graph
12018                        .neighbors(node)
12019                        .expect("graph nodes should always have adjacency buckets");
12020                    prop_assert!(!neighbors.contains(&target.as_str()));
12021                    prop_assert!(!graph.has_edge(node, &target));
12022                }
12023            }
12024            assert_graph_core_invariants(&graph);
12025        }
12026
12027        #[test]
12028        fn prop_decision_ledger_records_follow_schema(
12029            ops in prop::collection::vec((0_u8..8, 0_u8..8, 0_u8..4), 1..72),
12030        ) {
12031            let mut graph = Graph::strict();
12032            for (left_id, right_id, attrs_kind) in ops {
12033                let left = node_name(left_id);
12034                let right = node_name(right_id);
12035                let mut attrs = AttrMap::new();
12036                match attrs_kind {
12037                    0 => {}
12038                    1 => {
12039                        attrs.insert("weight".to_owned(), (left_id % 9).to_string().into());
12040                    }
12041                    2 => {
12042                        attrs.insert("color".to_owned(), format!("c{}", right_id % 6).into());
12043                    }
12044                    _ => {
12045                        attrs.insert("__fnx_incompatible_decoder".to_owned(), "v2".into());
12046                    }
12047                }
12048                let _ = graph.add_edge_with_attrs(left, right, attrs);
12049            }
12050
12051            let records = graph.evidence_ledger().records();
12052            prop_assert!(!records.is_empty());
12053            for record in records {
12054                assert_decision_record_schema(record, CompatibilityMode::Strict);
12055                if record.operation == "add_node" {
12056                    prop_assert_eq!(record.action, DecisionAction::Allow);
12057                    prop_assert!(record.evidence.iter().any(|term| term.signal == "node_preexisting"));
12058                    prop_assert!(record.evidence.iter().any(|term| term.signal == "attrs_count"));
12059                } else {
12060                    prop_assert_eq!(&record.operation, "add_edge");
12061                    if record.action == DecisionAction::FailClosed {
12062                        prop_assert!(
12063                            record
12064                                .evidence
12065                                .iter()
12066                                .any(|term| term.signal == "unknown_incompatible_feature")
12067                        );
12068                    } else {
12069                        prop_assert_eq!(record.action, DecisionAction::Allow);
12070                        // add_edge records two decisions: a pre-check (only
12071                        // unknown_incompatible_feature) and a post-check (with
12072                        // self_loop, edge_attr_count, etc.). Both are valid.
12073                        let is_precheck = record
12074                            .evidence
12075                            .iter()
12076                            .any(|term| term.signal == "unknown_incompatible_feature")
12077                            && !record
12078                                .evidence
12079                                .iter()
12080                                .any(|term| term.signal == "edge_attr_count");
12081                        if !is_precheck {
12082                            prop_assert!(record.evidence.iter().any(|term| term.signal == "edge_attr_count"));
12083                            prop_assert!(record.evidence.iter().any(|term| term.signal == "self_loop"));
12084                        }
12085                    }
12086                }
12087            }
12088        }
12089    }
12090}
12091
12092/// br-r37-c1-jc9e4: how much of a no-op `add_node` is the DECISION LEDGER?
12093///
12094/// The closed self-time ladder in `fnx-python` puts `Graph::add_node_with_attrs` at
12095/// `+128` ns of a `~243` ns no-op call — the single largest term inside the body. That
12096/// call is exactly two things: `add_node_with_attrs_unrecorded`, which does the store
12097/// work, and `record_decision`, which builds a `Vec<EvidenceTerm>`, reads the clock via
12098/// `unix_time_ms`, updates the posterior and pushes into the decision log. Which of the
12099/// two carries the 128 ns has never been measured, and the answer decides whether there
12100/// is anything worth trading against the audit doctrine at all.
12101///
12102/// This lives in `fnx-classes` because `add_node_with_attrs_unrecorded` is private to
12103/// this crate; measuring it from the binding would have meant widening its visibility
12104/// for a benchmark, which is a worse trade than putting the probe where the code is.
12105///
12106/// It changes NO shipped behaviour — both arms are existing functions, and the node is
12107/// already present so neither mutates anything.
12108///
12109/// CONTROLS: `recorded/recorded` is an A/A null through the identical ABBA scheme; if it
12110/// is not ~1.0 the instrument is measuring slot position, not the arms.
12111///
12112/// `#[ignore]`; run with
12113/// `rch exec -- cargo test --release -j 2 -p fnx-classes --lib
12114///  node_decision_ledger_self_time_ab -- --ignored --nocapture`.
12115#[test]
12116#[ignore = "measurement; run with --release --ignored --nocapture"]
12117fn node_decision_ledger_self_time_ab() {
12118    use std::hint::black_box;
12119    use std::time::{Duration, Instant};
12120
12121    const NODES: usize = 2000;
12122    const REPS: usize = 200;
12123    const ROUNDS: usize = 41;
12124
12125    let keys: Vec<String> = (0..NODES).map(|n| format!("n{n}")).collect();
12126    let mut graph = Graph::strict();
12127    for key in &keys {
12128        let _ = graph.add_node_with_attrs(key.clone(), AttrMap::new());
12129    }
12130    let before = graph.node_count();
12131
12132    // Both arms re-add nodes that are ALREADY PRESENT, so neither changes the store and
12133    // repeating them is idempotent — the property that makes a no-op control timeable.
12134    let recorded = |g: &mut Graph| -> Duration {
12135        let start = Instant::now();
12136        for _ in 0..REPS {
12137            for key in &keys {
12138                black_box(g.add_node_with_attrs(key.as_str(), AttrMap::new()));
12139            }
12140        }
12141        start.elapsed()
12142    };
12143    let unrecorded = |g: &mut Graph| -> Duration {
12144        let start = Instant::now();
12145        for _ in 0..REPS {
12146            for key in &keys {
12147                black_box(g.add_node_with_attrs_unrecorded(key.as_str(), AttrMap::new()));
12148            }
12149        }
12150        start.elapsed()
12151    };
12152
12153    // ABBA: each arm runs at both ends of a four-slot block, so any cost that depends on
12154    // slot rather than work cancels.
12155    let balanced = |first: &dyn Fn(&mut Graph) -> Duration,
12156                    second: &dyn Fn(&mut Graph) -> Duration,
12157                    g: &mut Graph|
12158     -> (f64, f64) {
12159        let a1 = first(g);
12160        let b1 = second(g);
12161        let b2 = second(g);
12162        let a2 = first(g);
12163        let scale = (2 * NODES * REPS) as f64;
12164        (
12165            (a1 + a2).as_nanos() as f64 / scale,
12166            (b1 + b2).as_nanos() as f64 / scale,
12167        )
12168    };
12169
12170    for _ in 0..3 {
12171        recorded(&mut graph);
12172        unrecorded(&mut graph);
12173    }
12174
12175    let mut rec_ns = Vec::with_capacity(ROUNDS);
12176    let mut unrec_ns = Vec::with_capacity(ROUNDS);
12177    let mut null_a = Vec::with_capacity(ROUNDS);
12178    let mut null_b = Vec::with_capacity(ROUNDS);
12179    for _ in 0..ROUNDS {
12180        let (na, nb) = balanced(&recorded, &recorded, &mut graph);
12181        null_a.push(na);
12182        null_b.push(nb);
12183        let (r, u) = balanced(&recorded, &unrecorded, &mut graph);
12184        rec_ns.push(r);
12185        unrec_ns.push(u);
12186    }
12187
12188    assert_eq!(
12189        graph.node_count(),
12190        before,
12191        "a no-op control that changed the node count measured a real insert"
12192    );
12193
12194    let median = |v: &mut Vec<f64>| -> f64 {
12195        v.sort_by(f64::total_cmp);
12196        v[(v.len() - 1) / 2]
12197    };
12198    let rec = median(&mut rec_ns);
12199    let unrec = median(&mut unrec_ns);
12200    let na = median(&mut null_a);
12201    let nb = median(&mut null_b);
12202
12203    println!("node_decision_ledger_self_time_ab nodes={NODES} reps={REPS} rounds={ROUNDS}");
12204    println!("  A/A null (recorded/recorded) : {:8.4}x", nb / na);
12205    println!("  A  add_node_with_attrs       : {rec:8.1} ns/call  (store + ledger)");
12206    println!("  B  ..._unrecorded            : {unrec:8.1} ns/call  (store only)");
12207    println!(
12208        "  -> decision ledger costs     : {:8.1} ns/call",
12209        rec - unrec
12210    );
12211    println!(
12212        "  -> ledger share of the pair  : {:8.1}%",
12213        100.0 * (rec - unrec) / rec
12214    );
12215}