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::{
10    IndexMap,
11    map::{RawEntryApiV1, raw_entry_v1::RawEntryMut},
12};
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15use std::hash::BuildHasher;
16
17// br-r37-c1-fxhash: the node (String) and edge ((usize,usize)) key->index maps use
18// a FAST non-cryptographic hasher instead of std's SipHash. IndexMap keeps insertion
19// order in its Vec, so iteration order (nodes/edges/edges_ordered) is UNCHANGED — only
20// lookup/insert hashing gets faster. The (usize,usize) edge keys especially: SipHash on
21// 16 bytes (~20ns) -> FxHash on a usize (~3ns). Speeds up every has_edge/edge_attrs/
22// get_index_of/construction insert across the library.
23pub(crate) type FxIndexMap<K, V> = IndexMap<K, V, rustc_hash::FxBuildHasher>;
24/// br-r37-c1-4c29a: the set half of the same alias. `MultiDiGraph`'s adjacency
25/// rows are `IndexMap<String, IndexSet<usize>>` nested inside an already-Fx
26/// outer map, and the inner pair was missed by the original fxhash pass — they
27/// were still on the default SipHash. Both containers are insertion-ordered, so
28/// the hasher is invisible to iteration order.
29pub(crate) type FxIndexSet<T> = indexmap::IndexSet<T, rustc_hash::FxBuildHasher>;
30use std::collections::HashSet;
31use std::fmt;
32
33pub type AttrMap = BTreeMap<String, CgseValue>;
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum GraphError {
37    FailClosed {
38        operation: &'static str,
39        reason: String,
40    },
41}
42
43impl fmt::Display for GraphError {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        match self {
46            Self::FailClosed { operation, reason } => {
47                write!(f, "operation `{operation}` failed closed: {reason}")
48            }
49        }
50    }
51}
52
53impl std::error::Error for GraphError {}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct EdgeSnapshot {
57    pub left: String,
58    pub right: String,
59    pub attrs: AttrMap,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct GraphSnapshot {
64    pub mode: CompatibilityMode,
65    pub nodes: Vec<String>,
66    /// br-snapnodeattrs: node attributes keyed by node name. Preserved
67    /// across the ``snapshot`` round-trip so a graph with
68    /// ``add_node(n, attrs)`` doesn't lose those attrs when replayed.
69    /// Defaults to empty for snapshots produced by old code paths;
70    /// consumers that don't care can ignore it.
71    #[serde(default)]
72    pub node_attrs: BTreeMap<String, AttrMap>,
73    pub edges: Vec<EdgeSnapshot>,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct MultiEdgeSnapshot {
78    pub left: String,
79    pub right: String,
80    pub key: usize,
81    pub attrs: AttrMap,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct MultiGraphSnapshot {
86    pub mode: CompatibilityMode,
87    pub nodes: Vec<String>,
88    /// See ``GraphSnapshot::node_attrs``.
89    #[serde(default)]
90    pub node_attrs: BTreeMap<String, AttrMap>,
91    pub edges: Vec<MultiEdgeSnapshot>,
92}
93
94#[derive(Debug)]
95pub struct Graph {
96    mode: CompatibilityMode,
97    revision: u64,
98    nodes: FxIndexMap<String, AttrMap>,
99    // br-r37-c1-d58s8 P2(c) slice 2: the String adjacency rows are GONE —
100    // adj_indices (order-faithful integer rows) is the single row store;
101    // String views derive through the nodes name table.
102    /// Integer-indexed adjacency list for O(1) traversal. Each entry
103    /// `adj_indices[i]` contains the node indices of neighbors of node i.
104    /// This mirrors `adjacency` but avoids string hashing during BFS/CC.
105    adj_indices: Vec<Vec<usize>>,
106    /// br-r37-c1-d58s8: revision-keyed all-int weights memo (see
107    /// DiGraph::all_int_cache).
108    all_int_cache: std::sync::Arc<std::sync::RwLock<Option<(u64, String, bool)>>>,
109    /// Revision-keyed, row-order-faithful mapping from an adjacency position to
110    /// its `edges` storage slot. Weighted readers visit integer adjacency rows
111    /// already; carrying the slot reads attrs without re-hashing the `(u, v)`
112    /// pair. It is built lazily and each structural mutation advances
113    /// `revision`, making stale rows unusable without mutation-side repair.
114    edge_slots_by_neighbor: std::sync::RwLock<Option<(u64, Vec<Vec<usize>>)>>,
115    edge_index_endpoints: Vec<(usize, usize)>,
116    // br-r37-c1-d58s8 edges-map flip: keyed by the INDEX-CANONICAL
117    // (min_idx, max_idx) node pair — zero String allocs/hashes per
118    // insert (the profiled remaining store tax). Node removal REKEYS
119    // via the same remap as the row repair. edge_index_endpoints keeps
120    // the string-canonical orientation for snapshot derivation.
121    edges: FxIndexMap<(usize, usize), AttrMap>,
122    runtime_policy: RuntimePolicy,
123}
124
125impl Clone for Graph {
126    fn clone(&self) -> Self {
127        Self {
128            mode: self.mode,
129            revision: self.revision,
130            nodes: self.nodes.clone(),
131            adj_indices: self.adj_indices.clone(),
132            // Both caches are derived state. A clone must not share a cache:
133            // independently mutated copies can reach the same revision with
134            // different edge storage orders.
135            all_int_cache: std::sync::Arc::default(),
136            edge_slots_by_neighbor: std::sync::RwLock::default(),
137            edge_index_endpoints: self.edge_index_endpoints.clone(),
138            edges: self.edges.clone(),
139            runtime_policy: self.runtime_policy.clone(),
140        }
141    }
142}
143
144impl Graph {
145    #[must_use]
146    pub fn new(mode: CompatibilityMode) -> Self {
147        Self {
148            mode,
149            revision: 0,
150            nodes: FxIndexMap::default(),
151            adj_indices: Vec::new(),
152            all_int_cache: std::sync::Arc::default(),
153            edge_slots_by_neighbor: std::sync::RwLock::default(),
154            edge_index_endpoints: Vec::new(),
155            edges: FxIndexMap::default(),
156            runtime_policy: RuntimePolicy::new(mode),
157        }
158    }
159
160    #[must_use]
161    pub fn with_runtime_policy(runtime_policy: RuntimePolicy) -> Self {
162        let mode = runtime_policy.mode();
163        Self {
164            mode,
165            revision: 0,
166            nodes: FxIndexMap::default(),
167            adj_indices: Vec::new(),
168            all_int_cache: std::sync::Arc::default(),
169            edge_slots_by_neighbor: std::sync::RwLock::default(),
170            edge_index_endpoints: Vec::new(),
171            edges: FxIndexMap::default(),
172            runtime_policy,
173        }
174    }
175
176    /// br-r37-c1-clearedgesinplace (cc): drop all edges in place, keeping nodes (and
177    /// their index/order/attrs). O(E) teardown + O(V) row clears -- the binding's prior
178    /// path rebuilt a fresh Graph with a per-node add_node loop (ledger record_decision
179    /// tax + node-attr clone + old-inner drop), ~20x slower than nx. Sibling of
180    /// MultiGraph::clear_edges. adj_indices keeps its N rows (one per node), emptied;
181    /// the revision bump invalidates all_int_cache.
182    pub fn clear_edges(&mut self) {
183        if self.edges.is_empty() && self.edge_index_endpoints.is_empty() {
184            return;
185        }
186        self.edges.clear();
187        self.edge_index_endpoints.clear();
188        for row in &mut self.adj_indices {
189            row.clear();
190        }
191        self.revision = self.revision.saturating_add(1);
192    }
193
194    #[must_use]
195    pub fn strict() -> Self {
196        Self::new(CompatibilityMode::Strict)
197    }
198
199    #[must_use]
200    pub fn complete_graph(mode: CompatibilityMode, n: usize) -> Self {
201        let edge_capacity = n.saturating_mul(n.saturating_sub(1)) / 2;
202        let revision = u64::try_from(n)
203            .unwrap_or(u64::MAX)
204            .saturating_add(u64::try_from(edge_capacity).unwrap_or(u64::MAX));
205        let node_labels = (0..n).map(|node| node.to_string()).collect::<Vec<_>>();
206        let mut graph = Self {
207            mode,
208            revision,
209            nodes: FxIndexMap::with_capacity_and_hasher(n, rustc_hash::FxBuildHasher),
210            adj_indices: vec![Vec::with_capacity(n.saturating_sub(1)); n],
211            all_int_cache: std::sync::Arc::default(),
212            edge_slots_by_neighbor: std::sync::RwLock::default(),
213            edge_index_endpoints: Vec::with_capacity(edge_capacity),
214            edges: FxIndexMap::with_capacity_and_hasher(edge_capacity, rustc_hash::FxBuildHasher),
215            runtime_policy: RuntimePolicy::new(mode),
216        };
217
218        for node in &node_labels {
219            graph.nodes.insert(node.clone(), AttrMap::new());
220        }
221
222        for left_index in 0..n {
223            let _left = &node_labels[left_index];
224            for (right_index, _right) in node_labels.iter().enumerate().skip(left_index + 1) {
225                // Maintain integer adjacency
226                graph.adj_indices[left_index].push(right_index);
227                graph.adj_indices[right_index].push(left_index);
228                graph.edge_index_endpoints.push((left_index, right_index));
229                graph
230                    .edges
231                    .insert(Graph::canon_pair(left_index, right_index), AttrMap::new());
232            }
233        }
234
235        graph.record_decision(
236            "complete_graph_bulk",
237            0.0,
238            false,
239            vec![EvidenceTerm {
240                signal: "nodes".into(),
241                observed_value: count_evidence(n),
242                log_likelihood_ratio: 0.0,
243            }],
244        );
245        graph
246    }
247
248    /// Build a non-periodic 2-D grid with NetworkX's node and edge insertion
249    /// sequence. Canonical node keys are row-major all-int tuple strings like
250    /// "(i, j)".
251    #[must_use]
252    pub fn grid_2d(mode: CompatibilityMode, m: usize, n: usize) -> Self {
253        let node_count = m.saturating_mul(n);
254        let edge_capacity = m
255            .saturating_sub(1)
256            .saturating_mul(n)
257            .saturating_add(n.saturating_sub(1).saturating_mul(m));
258        let revision = u64::try_from(node_count)
259            .unwrap_or(u64::MAX)
260            .saturating_add(u64::try_from(edge_capacity).unwrap_or(u64::MAX));
261        let label = |i: usize, j: usize| format!("({i}, {j})");
262        let idx = |i: usize, j: usize| i * n + j;
263        let mut graph = Self {
264            mode,
265            revision,
266            nodes: FxIndexMap::with_capacity_and_hasher(node_count, rustc_hash::FxBuildHasher),
267            adj_indices: vec![Vec::with_capacity(4); node_count],
268            all_int_cache: std::sync::Arc::default(),
269            edge_slots_by_neighbor: std::sync::RwLock::default(),
270            edge_index_endpoints: Vec::with_capacity(edge_capacity),
271            edges: FxIndexMap::with_capacity_and_hasher(edge_capacity, rustc_hash::FxBuildHasher),
272            runtime_policy: RuntimePolicy::new(mode),
273        };
274        for i in 0..m {
275            for j in 0..n {
276                let key = label(i, j);
277                graph.nodes.insert(key.clone(), AttrMap::new());
278            }
279        }
280        fn add_grid_edge(graph: &mut Graph, _u: String, _v: String, u_idx: usize, v_idx: usize) {
281            // add_edge appends v to row[u] first, then u to row[v].
282            graph.adj_indices[u_idx].push(v_idx);
283            graph.adj_indices[v_idx].push(u_idx);
284            graph.edge_index_endpoints.push((u_idx, v_idx));
285            graph
286                .edges
287                .insert(Graph::canon_pair(u_idx, v_idx), AttrMap::new());
288        }
289        // Phase 1: for (pi, i) in pairwise(rows), for j in cols.
290        for i in 1..m {
291            for j in 0..n {
292                add_grid_edge(
293                    &mut graph,
294                    label(i, j),
295                    label(i - 1, j),
296                    idx(i, j),
297                    idx(i - 1, j),
298                );
299            }
300        }
301        // Phase 2: for i in rows, for (pj, j) in pairwise(cols).
302        for i in 0..m {
303            for j in 1..n {
304                add_grid_edge(
305                    &mut graph,
306                    label(i, j),
307                    label(i, j - 1),
308                    idx(i, j),
309                    idx(i, j - 1),
310                );
311            }
312        }
313        graph.record_decision(
314            "grid_2d_bulk",
315            0.0,
316            false,
317            vec![EvidenceTerm {
318                signal: "nodes".into(),
319                observed_value: count_evidence(node_count),
320                log_likelihood_ratio: 0.0,
321            }],
322        );
323        graph
324    }
325
326    /// Build NetworkX's n-dimensional integer grid graph in one bulk pass.
327    ///
328    /// NetworkX constructs this as repeated cartesian products of path/cycle
329    /// graphs, then flattens the nested tuple labels. The product order matters:
330    /// periodic axes use cycle edge-view order, not a simple coordinate loop.
331    pub fn grid_nd(
332        mode: CompatibilityMode,
333        dimensions: &[usize],
334        periodic: &[bool],
335    ) -> Result<Self, GraphError> {
336        if dimensions.len() != periodic.len() {
337            return Err(GraphError::FailClosed {
338                operation: "grid_nd",
339                reason: format!(
340                    "periodic flag count {} does not match dimension count {}",
341                    periodic.len(),
342                    dimensions.len()
343                ),
344            });
345        }
346        if dimensions.is_empty() {
347            return Ok(Self::new(mode));
348        }
349
350        #[derive(Clone)]
351        struct ProductState {
352            node_count: usize,
353            adjacency: Vec<Vec<usize>>,
354            edge_set: HashSet<(usize, usize)>,
355        }
356
357        impl ProductState {
358            fn empty() -> Self {
359                Self {
360                    node_count: 0,
361                    adjacency: Vec::new(),
362                    edge_set: HashSet::new(),
363                }
364            }
365
366            fn axis(size: usize, periodic: bool) -> Self {
367                let mut state = Self {
368                    node_count: size,
369                    adjacency: vec![Vec::new(); size],
370                    edge_set: HashSet::new(),
371                };
372                if size == 0 {
373                    return state;
374                }
375                if periodic {
376                    if size == 1 {
377                        state.add_edge(0, 0);
378                    } else {
379                        for node in 0..(size - 1) {
380                            state.add_edge(node, node + 1);
381                        }
382                        state.add_edge(size - 1, 0);
383                    }
384                } else {
385                    for node in 0..size.saturating_sub(1) {
386                        state.add_edge(node, node + 1);
387                    }
388                }
389                state
390            }
391
392            fn product(axis: &Self, old: &Self) -> Result<Self, GraphError> {
393                let Some(node_count) = axis.node_count.checked_mul(old.node_count) else {
394                    return Err(GraphError::FailClosed {
395                        operation: "grid_nd",
396                        reason: "node count overflow during cartesian product".to_owned(),
397                    });
398                };
399                if node_count == 0 {
400                    return Ok(Self::empty());
401                }
402
403                let axis_edges = axis.edges_view();
404                let old_edges = old.edges_view();
405                let mut state = Self {
406                    node_count,
407                    adjacency: vec![Vec::new(); node_count],
408                    edge_set: HashSet::with_capacity(
409                        axis_edges
410                            .len()
411                            .saturating_mul(old.node_count)
412                            .saturating_add(old_edges.len().saturating_mul(axis.node_count)),
413                    ),
414                };
415
416                for (axis_left, axis_right) in axis_edges {
417                    let left_base = axis_left * old.node_count;
418                    let right_base = axis_right * old.node_count;
419                    for old_index in 0..old.node_count {
420                        state.add_edge(left_base + old_index, right_base + old_index);
421                    }
422                }
423                for axis_index in 0..axis.node_count {
424                    let base = axis_index * old.node_count;
425                    for &(old_left, old_right) in &old_edges {
426                        state.add_edge(base + old_left, base + old_right);
427                    }
428                }
429                Ok(state)
430            }
431
432            fn add_edge(&mut self, left: usize, right: usize) {
433                let key = if left <= right {
434                    (left, right)
435                } else {
436                    (right, left)
437                };
438                if !self.edge_set.insert(key) {
439                    return;
440                }
441                self.adjacency[left].push(right);
442                if left != right {
443                    self.adjacency[right].push(left);
444                }
445            }
446
447            fn edges_view(&self) -> Vec<(usize, usize)> {
448                let mut edges = Vec::with_capacity(self.edge_set.len());
449                let mut seen = vec![false; self.node_count];
450                for left in 0..self.node_count {
451                    for &right in &self.adjacency[left] {
452                        if !seen[right] {
453                            edges.push((left, right));
454                        }
455                    }
456                    seen[left] = true;
457                }
458                edges
459            }
460        }
461
462        fn coords_from_index(mut index: usize, sizes: &[usize]) -> Vec<usize> {
463            let mut coords = vec![0; sizes.len()];
464            for axis in (0..sizes.len()).rev() {
465                let size = sizes[axis];
466                coords[axis] = index % size;
467                index /= size;
468            }
469            coords
470        }
471
472        fn tuple_canonical(coords: &[usize]) -> String {
473            if let [value] = coords {
474                return value.to_string();
475            }
476            let mut s = String::with_capacity(coords.len() * 6 + 2);
477            s.push('(');
478            for (i, value) in coords.iter().enumerate() {
479                if i > 0 {
480                    s.push_str(", ");
481                }
482                s.push_str(&value.to_string());
483            }
484            s.push(')');
485            s
486        }
487
488        let mut state = ProductState::axis(dimensions[0], periodic[0]);
489        for (&size, &periodic_axis) in dimensions.iter().zip(periodic).skip(1) {
490            let axis = ProductState::axis(size, periodic_axis);
491            state = ProductState::product(&axis, &state)?;
492        }
493
494        let output_sizes = dimensions.iter().rev().copied().collect::<Vec<_>>();
495        let labels = (0..state.node_count)
496            .map(|index| tuple_canonical(&coords_from_index(index, &output_sizes)))
497            .collect::<Vec<_>>();
498        let edge_view = state.edges_view();
499        let mut final_adjacency = vec![Vec::new(); state.node_count];
500        for &(left, right) in &edge_view {
501            final_adjacency[left].push(right);
502            if left != right {
503                final_adjacency[right].push(left);
504            }
505        }
506        let revision = u64::try_from(state.node_count)
507            .unwrap_or(u64::MAX)
508            .saturating_add(u64::try_from(edge_view.len()).unwrap_or(u64::MAX));
509        let mut graph = Self {
510            mode,
511            revision,
512            nodes: FxIndexMap::with_capacity_and_hasher(
513                state.node_count,
514                rustc_hash::FxBuildHasher,
515            ),
516            adj_indices: Vec::with_capacity(state.node_count),
517            all_int_cache: std::sync::Arc::default(),
518            edge_slots_by_neighbor: std::sync::RwLock::default(),
519            edge_index_endpoints: Vec::with_capacity(edge_view.len()),
520            edges: FxIndexMap::with_capacity_and_hasher(edge_view.len(), rustc_hash::FxBuildHasher),
521            runtime_policy: RuntimePolicy::new(mode),
522        };
523
524        for (index, label) in labels.iter().enumerate() {
525            graph.nodes.insert(label.clone(), AttrMap::new());
526            graph.adj_indices.push(final_adjacency[index].clone());
527        }
528
529        for (left, right) in edge_view {
530            graph.edge_index_endpoints.push((left, right));
531            graph
532                .edges
533                .insert(Graph::canon_pair(left, right), AttrMap::new());
534        }
535
536        graph.record_decision(
537            "grid_nd_bulk",
538            0.0,
539            false,
540            vec![EvidenceTerm {
541                signal: "nodes".into(),
542                observed_value: count_evidence(state.node_count),
543                log_likelihood_ratio: 0.0,
544            }],
545        );
546        Ok(graph)
547    }
548
549    /// br-r37-c1-z2eaa: Kneser graph K(n, k) replicating NetworkX's exact
550    /// construction sequence. Nodes are k-subsets of 0..n as all-int-tuple
551    /// canonicals "(a, b, ...)" (br-r37-c1-y7m24). nx builds via
552    /// `add_edges_from((s, t) for s in subsets for t in
553    /// combinations(universe - set(s), k))` — node order is edge-DISCOVERY
554    /// order (u then v per new edge) and each unordered edge is offered
555    /// twice (the second add is a structural no-op). The complement
556    /// `universe - set(s)` iterates ASCENDING whenever every value fits its
557    /// exact CPython set slot — the Python wrapper gates this kernel on
558    /// that condition, so lexicographic combinations over the sorted
559    /// complement reproduce nx byte-for-byte. For `2k > n` nodes are
560    /// pre-added in subset order and no edges exist.
561    #[must_use]
562    pub fn kneser(mode: CompatibilityMode, n: usize, k: usize) -> Self {
563        fn tuple_canonical(c: &[usize]) -> String {
564            let mut s = String::with_capacity(c.len() * 6 + 2);
565            s.push('(');
566            for (i, v) in c.iter().enumerate() {
567                if i > 0 {
568                    s.push_str(", ");
569                }
570                s.push_str(&v.to_string());
571            }
572            if c.len() == 1 {
573                s.push(',');
574            }
575            s.push(')');
576            s
577        }
578        fn lex_combinations(m: usize, k: usize) -> Vec<Vec<usize>> {
579            let mut out = Vec::new();
580            if k > m {
581                return out;
582            }
583            let mut cur: Vec<usize> = (0..k).collect();
584            loop {
585                out.push(cur.clone());
586                let mut advanced = false;
587                let mut i = k;
588                while i > 0 {
589                    i -= 1;
590                    if cur[i] != i + m - k {
591                        cur[i] += 1;
592                        for j in (i + 1)..k {
593                            cur[j] = cur[j - 1] + 1;
594                        }
595                        advanced = true;
596                        break;
597                    }
598                }
599                if !advanced {
600                    break;
601                }
602            }
603            out
604        }
605        let subsets = lex_combinations(n, k);
606        let canonicals: Vec<String> = subsets.iter().map(|c| tuple_canonical(c)).collect();
607        let total = subsets.len();
608        let mut graph = Self {
609            mode,
610            revision: u64::try_from(total).unwrap_or(u64::MAX),
611            nodes: FxIndexMap::with_capacity_and_hasher(total, rustc_hash::FxBuildHasher),
612            adj_indices: Vec::new(),
613            all_int_cache: std::sync::Arc::default(),
614            edge_slots_by_neighbor: std::sync::RwLock::default(),
615            edge_index_endpoints: Vec::new(),
616            edges: FxIndexMap::default(),
617            runtime_policy: RuntimePolicy::new(mode),
618        };
619        if 2 * k > n {
620            // No disjoint pairs exist — nx pre-adds all subsets as nodes.
621            for canonical in &canonicals {
622                graph.nodes.insert(canonical.clone(), AttrMap::new());
623            }
624            graph.adj_indices = vec![Vec::new(); total];
625            return graph;
626        }
627        // Binomial table for lexicographic combination ranking.
628        let mut binom = vec![vec![0usize; k + 1]; n + 1];
629        for i in 0..=n {
630            binom[i][0] = 1;
631            for j in 1..=k.min(i) {
632                binom[i][j] = if i == j {
633                    1
634                } else {
635                    binom[i - 1][j - 1].saturating_add(binom[i - 1][j])
636                };
637            }
638        }
639        let lex_rank = |c: &[usize]| -> usize {
640            let mut r = 0usize;
641            let mut prev = 0usize;
642            for (i, &ci) in c.iter().enumerate() {
643                for j in prev..ci {
644                    r = r.saturating_add(binom[n - 1 - j][k - 1 - i]);
645                }
646                prev = ci + 1;
647            }
648            r
649        };
650        let mut seen = HashSet::<(usize, usize)>::with_capacity(total * 2);
651        // node first-touch insertion mirroring nx add_edge (u then v)
652        let mut node_idx: Vec<usize> = vec![usize::MAX; total];
653        let mut in_s = vec![false; n];
654        let m = n - k;
655        let picks = lex_combinations(m, k);
656        for (s_idx, s) in subsets.iter().enumerate() {
657            for &v in s {
658                in_s[v] = true;
659            }
660            let complement: Vec<usize> = (0..n).filter(|&v| !in_s[v]).collect();
661            for pick in &picks {
662                let t: Vec<usize> = pick.iter().map(|&i| complement[i]).collect();
663                let t_idx = lex_rank(&t);
664                let pair = (s_idx.min(t_idx), s_idx.max(t_idx));
665                if seen.insert(pair) {
666                    for idx in [s_idx, t_idx] {
667                        if node_idx[idx] == usize::MAX {
668                            let entry = graph
669                                .nodes
670                                .insert_full(canonicals[idx].clone(), AttrMap::new())
671                                .0;
672                            node_idx[idx] = entry;
673                            graph.adj_indices.push(Vec::new());
674                        }
675                    }
676                    // br-r37-c1-kneserpush (cc): we are inside `if seen.insert(pair)` — a FIRST
677                    // occurrence of this (s_idx,t_idx) edge — so it is provably not yet in either
678                    // adjacency row. The old `!adj_indices[..].contains(..)` guards were O(degree)
679                    // linear rescans always returning true here (redundant); push directly.
680                    // Byte-identical (Kneser edges have no parallel edges; self-loop s==t handled
681                    // by the != guard, exactly as before).
682                    graph.adj_indices[node_idx[s_idx]].push(node_idx[t_idx]);
683                    if node_idx[s_idx] != node_idx[t_idx] {
684                        graph.adj_indices[node_idx[t_idx]].push(node_idx[s_idx]);
685                    }
686                    graph
687                        .edge_index_endpoints
688                        .push((node_idx[s_idx], node_idx[t_idx]));
689                    graph.edges.insert(
690                        Graph::canon_pair(node_idx[s_idx], node_idx[t_idx]),
691                        AttrMap::new(),
692                    );
693                }
694            }
695            for &v in s {
696                in_s[v] = false;
697            }
698        }
699        // adj_indices maintained inline above (P2(c) slice 2).
700        graph.revision = u64::try_from(graph.nodes.len() + graph.edges.len()).unwrap_or(u64::MAX);
701        graph
702    }
703
704    #[must_use]
705    pub fn hardened() -> Self {
706        Self::new(CompatibilityMode::Hardened)
707    }
708
709    #[must_use]
710    pub fn mode(&self) -> CompatibilityMode {
711        self.mode
712    }
713
714    #[must_use]
715    pub fn node_count(&self) -> usize {
716        self.nodes.len()
717    }
718
719    #[must_use]
720    pub fn edge_count(&self) -> usize {
721        self.edges.len()
722    }
723
724    #[must_use]
725    pub fn revision(&self) -> u64 {
726        self.revision
727    }
728
729    #[must_use]
730    pub fn has_node(&self, node: &str) -> bool {
731        self.nodes.contains_key(node)
732    }
733
734    /// br-r37-c1-d58s8 edges-map flip: resolve a String pair to the
735    /// index-canonical (min, max) key. None if either node is absent.
736    #[inline]
737    fn edge_pair_key(&self, left: &str, right: &str) -> Option<(usize, usize)> {
738        let l = self.nodes.get_index_of(left)?;
739        let r = self.nodes.get_index_of(right)?;
740        Some(if l <= r { (l, r) } else { (r, l) })
741    }
742
743    #[inline]
744    fn canon_pair(l: usize, r: usize) -> (usize, usize) {
745        if l <= r { (l, r) } else { (r, l) }
746    }
747
748    /// br-r37-c1-indsub: build the induced subgraph over `order` (parent node
749    /// indices, already in the caller's output order) without hashing a single
750    /// node name.
751    ///
752    /// The per-edge `add_edge_with_attrs` path costs two `String` allocations,
753    /// four name hashes and a `record_decision` ledger entry (which itself
754    /// allocates two Strings and a Vec) — ~2.5us/edge, so materializing the
755    /// 158k-edge k=10 core of ca-AstroPh took 393ms. Everything that loop
756    /// re-derives is already known here: the surviving rows come straight out
757    /// of `adj_indices`, and the new index of a kept node is its slot in
758    /// `order`. All the invariants `add_edge_with_attrs` maintains are
759    /// reproduced directly:
760    ///
761    ///  * `edges` is keyed by the INDEX-canonical pair of the NEW indices;
762    ///  * `edge_index_endpoints` keeps the STRING-canonical orientation
763    ///    (`left <= right` on names), matching the per-edge path;
764    ///  * a row records its neighbor when the pair is first emitted, so
765    ///    adjacency order is the walk order — which is exactly what nx's
766    ///    `add_edges_from` over the induced walk produces;
767    ///  * a self-loop appears once in its row.
768    ///
769    /// A pair is emitted from whichever endpoint comes FIRST in `order`, so
770    /// each undirected edge is visited once. `order` must contain distinct,
771    /// in-range parent indices.
772    #[must_use]
773    pub fn induced_subgraph_ordered(&self, order: &[usize], runtime_policy: RuntimePolicy) -> Self {
774        let mut position = vec![usize::MAX; self.nodes.len()];
775        for (slot, &parent_idx) in order.iter().enumerate() {
776            position[parent_idx] = slot;
777        }
778
779        let mut nodes = FxIndexMap::default();
780        nodes.reserve(order.len());
781        for &parent_idx in order {
782            let (name, attrs) = self
783                .nodes
784                .get_index(parent_idx)
785                .expect("induced subgraph order must hold valid parent node indices");
786            nodes.insert(name.clone(), attrs.clone());
787        }
788
789        let mut adj_indices = vec![Vec::new(); order.len()];
790        let mut edges = FxIndexMap::default();
791        let mut edge_index_endpoints = Vec::new();
792        for (slot, &parent_idx) in order.iter().enumerate() {
793            for &parent_neighbor in &self.adj_indices[parent_idx] {
794                let other = position[parent_neighbor];
795                if other == usize::MAX || other < slot {
796                    continue;
797                }
798                let attrs = self
799                    .edges
800                    .get(&Self::canon_pair(parent_idx, parent_neighbor))
801                    .cloned()
802                    .unwrap_or_default();
803                edges.insert(Self::canon_pair(slot, other), attrs);
804                let left_name = nodes.get_index(slot).expect("slot inserted above").0;
805                let right_name = nodes.get_index(other).expect("slot inserted above").0;
806                if left_name <= right_name {
807                    edge_index_endpoints.push((slot, other));
808                } else {
809                    edge_index_endpoints.push((other, slot));
810                }
811                adj_indices[slot].push(other);
812                if slot != other {
813                    adj_indices[other].push(slot);
814                }
815            }
816        }
817
818        Self {
819            mode: runtime_policy.mode(),
820            revision: 0,
821            nodes,
822            adj_indices,
823            all_int_cache: std::sync::Arc::default(),
824            edge_slots_by_neighbor: std::sync::RwLock::default(),
825            edge_index_endpoints,
826            edges,
827            runtime_policy,
828        }
829    }
830
831    #[must_use]
832    pub fn has_edge(&self, left: &str, right: &str) -> bool {
833        self.edge_pair_key(left, right)
834            .is_some_and(|k| self.edges.contains_key(&k))
835    }
836
837    /// cc-hasedgeintidx: true iff the node stored at `idx` IS the integer `idx`
838    /// (its canonical string parses back to `idx`). Lets a caller VERIFY, per call
839    /// and with no allocation, that an exact-int node lands at its own index before
840    /// probing an edge by index — so any removal / re-add / remap that shifted
841    /// indices simply returns false and the caller falls back to the string path.
842    /// O(1) index access + a no-alloc `str::parse`.
843    #[must_use]
844    pub fn node_index_matches_int(&self, idx: usize) -> bool {
845        self.nodes
846            .get_index(idx)
847            .is_some_and(|(k, _)| k.parse::<usize>() == Ok(idx))
848    }
849
850    /// cc-hasedgeintidx: undirected edge existence by node INDEX (canonical
851    /// min/max pair). Caller must have validated both indices (e.g. via
852    /// `node_index_matches_int`).
853    #[must_use]
854    pub fn has_edge_by_indices(&self, l: usize, r: usize) -> bool {
855        self.edges.contains_key(&Self::canon_pair(l, r))
856    }
857
858    #[must_use]
859    pub fn nodes_ordered(&self) -> Vec<&str> {
860        self.nodes.keys().map(String::as_str).collect()
861    }
862
863    #[must_use]
864    pub fn get_node_index(&self, node: &str) -> Option<usize> {
865        self.nodes.get_index_of(node)
866    }
867
868    #[must_use]
869    pub fn get_node_name(&self, index: usize) -> Option<&str> {
870        self.nodes.get_index(index).map(|(k, _)| k.as_str())
871    }
872
873    #[must_use]
874    pub fn neighbors(&self, node: &str) -> Option<Vec<&str>> {
875        // br-r37-c1-d58s8 P2(a): serve from the integer rows + node-name
876        // table — adj_indices is order-faithful through every mutator
877        // (extends push in row order; remove_node repairs in place;
878        // apply_row_orders and reorder_rows_for_nx_copy_walk both
879        // resync). Migrating readers off the String rows is the
880        // prerequisite for dropping their eager maintenance (P2(c)).
881        let idx = self.nodes.get_index_of(node)?;
882        Some(
883            self.adj_indices[idx]
884                .iter()
885                .map(|&i| {
886                    self.nodes
887                        .get_index(i)
888                        .expect("adj_indices entries are valid node indices")
889                        .0
890                        .as_str()
891                })
892                .collect::<Vec<&str>>(),
893        )
894    }
895
896    #[must_use]
897    pub fn neighbors_iter(&self, node: &str) -> Option<impl Iterator<Item = &str> + '_> {
898        // br-r37-c1-d58s8 P2(a): index-backed (see neighbors()).
899        let idx = self.nodes.get_index_of(node)?;
900        Some(self.adj_indices[idx].iter().map(move |&i| {
901            self.nodes
902                .get_index(i)
903                .expect("adj_indices entries are valid node indices")
904                .0
905                .as_str()
906        }))
907    }
908
909    /// Return neighbor indices for O(1) traversal. Avoids string hashing
910    /// during BFS/DFS. Returns None if node index is out of bounds.
911    #[must_use]
912    #[inline]
913    pub fn neighbors_indices(&self, node_idx: usize) -> Option<&[usize]> {
914        self.adj_indices.get(node_idx).map(Vec::as_slice)
915    }
916
917    /// br-r37-c1-7dpyg: structural clone with a FRESH RuntimePolicy —
918    /// `Clone` deep-copies the unbounded decision ledger (copy() measured
919    /// 2.3-2.8x slower on per-edge-ctor sources with identical structure);
920    /// result graphs carry the MODE, not the history.
921    #[must_use]
922    pub fn clone_with_fresh_policy(&self) -> Self {
923        Self {
924            mode: self.mode,
925            revision: self.revision,
926            nodes: self.nodes.clone(),
927            adj_indices: self.adj_indices.clone(),
928            all_int_cache: std::sync::Arc::default(),
929            edge_slots_by_neighbor: std::sync::RwLock::default(),
930            edge_index_endpoints: self.edge_index_endpoints.clone(),
931            edges: self.edges.clone(),
932            runtime_policy: RuntimePolicy::new(self.mode),
933        }
934    }
935
936    /// br-r37-c1-u3qyn: restore explicit adjacency row orders (pickle
937    /// round-trip). Each entry is (node, neighbor order); rows are
938    /// rebuilt in the given order with any unlisted survivors appended
939    /// in their current order (robust to stale state). The integer
940    /// `adj_indices` mirror is rebuilt to match.
941    pub fn apply_row_orders(&mut self, orders: &[(String, Vec<String>)]) {
942        // br-r37-c1-d58s8 P2(c) slice 2: reorder the integer rows directly.
943        let mut changed = false;
944        for (node, order) in orders {
945            let Some(idx) = self.nodes.get_index_of(node.as_str()) else {
946                continue;
947            };
948            let row = &self.adj_indices[idx];
949            let row_set: std::collections::HashSet<usize> = row.iter().copied().collect();
950            let mut new_row: Vec<usize> = Vec::with_capacity(row.len());
951            let mut placed: std::collections::HashSet<usize> = std::collections::HashSet::new();
952            for v in order {
953                if let Some(v_idx) = self.nodes.get_index_of(v.as_str())
954                    && row_set.contains(&v_idx)
955                    && placed.insert(v_idx)
956                {
957                    new_row.push(v_idx);
958                }
959            }
960            for &v_idx in row {
961                if placed.insert(v_idx) {
962                    new_row.push(v_idx);
963                }
964            }
965            if self.adj_indices[idx] != new_row {
966                self.adj_indices[idx] = new_row;
967                changed = true;
968            }
969        }
970        if changed {
971            self.revision = self.revision.saturating_add(1);
972        }
973    }
974
975    /// br-r37-c1-0ek49: reorder every adjacency row into NetworkX's
976    /// `Graph.copy()` walk order. nx copy rebuilds via
977    /// `add_edges_from((u, v, d) for u in _adj for v in _adj[u])`, so an
978    /// unordered pair enters BOTH endpoint rows at its first touch —
979    /// during the earlier endpoint's row scan. Row u therefore lists
980    /// neighbors at a smaller node position first (ordered by that
981    /// neighbor's scan time: `(pos(v), index of u within row v)`),
982    /// followed by its remaining neighbors — self-loops included — in
983    /// row u's original order. Call on a fresh clone inside copy-shaped
984    /// constructors; graph content is unchanged, only row order moves.
985    pub fn reorder_rows_for_nx_copy_walk(&mut self) {
986        // br-r37-c1-predrebuild: rebuild rows in nx's copy-walk order in O(E),
987        // replacing the per-early-neighbor linear search
988        // `adj_indices[v].position(|w| w == pu)` (O(E * degree) — quadratic on
989        // dense graphs; it erodes dense Graph.copy() wins 9.5x->5.9x as density
990        // climbs). Early neighbors of pu (v < pu) sort by
991        // (pos(v), index-of-pu-within-adj[v]); walking adj in (v, adj-index)
992        // order and appending v to early[pu] when pu > v yields exactly that
993        // order. Late neighbors (v >= pu) keep adj[pu]'s row order. Byte-
994        // identical row order — no search, no sort.
995        let n = self.adj_indices.len();
996        let mut early: Vec<Vec<usize>> = vec![Vec::new(); n];
997        for (v, row) in self.adj_indices.iter().enumerate() {
998            for &pu in row {
999                if pu > v {
1000                    early[pu].push(v);
1001                }
1002            }
1003        }
1004        let mut new_rows: Vec<Vec<usize>> = Vec::with_capacity(n);
1005        for (pu, row) in self.adj_indices.iter().enumerate() {
1006            let mut new_row = std::mem::take(&mut early[pu]);
1007            new_row.reserve(row.len());
1008            for &v in row {
1009                if v >= pu {
1010                    new_row.push(v);
1011                }
1012            }
1013            new_rows.push(new_row);
1014        }
1015        if self.adj_indices != new_rows {
1016            self.adj_indices = new_rows;
1017            self.revision = self.revision.saturating_add(1);
1018        }
1019    }
1020
1021    pub fn edges_storage_order_index_iter(
1022        &self,
1023    ) -> impl Iterator<Item = (usize, usize, &AttrMap)> + '_ {
1024        self.edge_index_endpoints
1025            .iter()
1026            .copied()
1027            .zip(self.edges.values())
1028            .map(|((left, right), attrs)| (left, right, attrs))
1029    }
1030
1031    #[must_use]
1032    pub fn neighbor_count(&self, node: &str) -> usize {
1033        // br-r37-c1-d58s8 P2(a): index-backed.
1034        self.nodes
1035            .get_index_of(node)
1036            .map_or(0, |idx| self.adj_indices[idx].len())
1037    }
1038
1039    /// Return the degree of a node.
1040    /// Self-loops contribute 2 to the degree (NetworkX convention).
1041    #[must_use]
1042    pub fn degree(&self, node: &str) -> usize {
1043        let count = self.neighbor_count(node);
1044        // If node has a self-loop, add 1 extra (self-loop contributes 2 total)
1045        if self.has_edge(node, node) {
1046            count + 1
1047        } else {
1048            count
1049        }
1050    }
1051
1052    /// br-r37-c1-degidx: degree by node INDEX — O(1), zero String
1053    /// hashing (the &str path costs 2 hashes/node: neighbor_count's
1054    /// get_index_of + the self-loop has_edge). Used by the DegreeView
1055    /// iterator which already walks nodes in index order.
1056    #[must_use]
1057    pub fn degree_by_index(&self, idx: usize) -> usize {
1058        // br-r37-c1-degselfloopidx (cc): the self-loop check was `adj_indices[idx].contains(&idx)`
1059        // — an O(degree) linear rescan of the adjacency row (which for the common no-self-loop node
1060        // scans the WHOLE row to return false), making this hot per-node accessor O(degree) despite
1061        // the "O(1)" claim. `has_edge_by_indices(idx, idx)` = `self.edges.contains_key(canon(idx,idx))`
1062        // is an O(1) HashMap probe deciding the SAME fact (a self-loop edge exists IFF idx is in its
1063        // own adjacency row — the maintained adj_indices<=>self.edges invariant). Byte-identical.
1064        let mut count = self.adj_indices[idx].len();
1065        if self.has_edge_by_indices(idx, idx) {
1066            count += 1; // self-loop contributes 2 to total degree
1067        }
1068        count
1069    }
1070
1071    #[must_use]
1072    pub fn node_attrs(&self, node: &str) -> Option<&AttrMap> {
1073        self.nodes.get(node)
1074    }
1075
1076    #[must_use]
1077    pub fn edge_attrs(&self, left: &str, right: &str) -> Option<&AttrMap> {
1078        self.edges.get(&self.edge_pair_key(left, right)?)
1079    }
1080
1081    /// br-r37-c1-d58s8: index-keyed attr access — a DIRECT map get for
1082    /// callers that already hold node indices (kernels, CSR weight
1083    /// builders, compose walks). Zero node-map probes.
1084    #[must_use]
1085    #[inline]
1086    pub fn edge_attrs_by_indices(&self, left_idx: usize, right_idx: usize) -> Option<&AttrMap> {
1087        self.edges.get(&Self::canon_pair(left_idx, right_idx))
1088    }
1089
1090    /// Read an edge's attributes by a storage slot supplied by the
1091    /// row-order-preserving edge-slot cache.
1092    #[must_use]
1093    #[inline]
1094    pub fn edge_attrs_by_slot(&self, edge_slot: usize) -> Option<&AttrMap> {
1095        self.edges.get_index(edge_slot).map(|(_, attrs)| attrs)
1096    }
1097
1098    /// Give a reader the edge-storage slot for every entry in every adjacency
1099    /// row. The first reader after a structural revision resolves pairs once;
1100    /// steady-state readers avoid pair hashing entirely.
1101    pub fn with_edge_slots_by_neighbor<T>(&self, f: impl FnOnce(&[Vec<usize>]) -> T) -> T {
1102        if let Ok(guard) = self.edge_slots_by_neighbor.read()
1103            && let Some((revision, rows)) = guard.as_ref()
1104            && *revision == self.revision
1105        {
1106            return f(rows);
1107        }
1108
1109        let mut guard = self
1110            .edge_slots_by_neighbor
1111            .write()
1112            .expect("edge slot cache lock poisoned");
1113        if !matches!(guard.as_ref(), Some((revision, _)) if *revision == self.revision) {
1114            let mut rows = Vec::with_capacity(self.adj_indices.len());
1115            for (node, neighbors) in self.adj_indices.iter().enumerate() {
1116                let mut slots = Vec::with_capacity(neighbors.len());
1117                for &neighbor in neighbors {
1118                    let slot = self
1119                        .edges
1120                        .get_index_of(&Self::canon_pair(node, neighbor))
1121                        .expect("adjacency entries have matching edge storage");
1122                    slots.push(slot);
1123                }
1124                rows.push(slots);
1125            }
1126            *guard = Some((self.revision, rows));
1127        }
1128        let (_, rows) = guard
1129            .as_ref()
1130            .expect("edge slot cache is initialized before use");
1131        f(rows)
1132    }
1133
1134    /// br-r37-c1-hasattrlazyfix: does ANY edge carry the attribute `key` in the
1135    /// authoritative Rust storage? Unlike the `edge_py_attrs` Python mirror (which is
1136    /// LAZILY materialized — a batch-built graph leaves it empty), this scans the inner
1137    /// `edges` AttrMaps directly (no node-name resolution, no allocation), so a
1138    /// freshly-constructed weighted graph reports True. ~O(E) BTreeMap key checks.
1139    #[must_use]
1140    pub fn any_edge_has_attr(&self, key: &str) -> bool {
1141        self.edges.values().any(|attrs| attrs.contains_key(key))
1142    }
1143
1144    /// True if EVERY inner edge attr value is a faithful scalar (Int/Float/Bool)
1145    /// — a String/Map value is a coerced non-scalar (repr/JSON) or genuine str,
1146    /// ambiguous for lossless cloning. Direct no-alloc scan over the edge store
1147    /// values (contrast `edges_ordered_borrowed`, which builds an O(E) dedup
1148    /// HashMap). Used by the compose store-read gate.
1149    #[must_use]
1150    pub fn all_edge_attr_values_scalar(&self) -> bool {
1151        self.edges.values().all(|attrs| {
1152            attrs.values().all(|v| {
1153                matches!(
1154                    v,
1155                    fnx_runtime::CgseValue::Int(_)
1156                        | fnx_runtime::CgseValue::Float(_)
1157                        | fnx_runtime::CgseValue::Bool(_)
1158                )
1159            })
1160        })
1161    }
1162
1163    /// br-r37-c1-hasanyattrlazyfix: does ANY node carry a Python-visible attr, per the
1164    /// authoritative inner storage (not the lazy `node_py_attrs` mirror)?
1165    #[must_use]
1166    pub fn any_node_has_attrs(&self) -> bool {
1167        self.nodes.values().any(|attrs| !attrs.is_empty())
1168    }
1169
1170    /// br-r37-c1-hasanyattrlazyfix: does ANY edge carry a Python-visible attr, per the
1171    /// authoritative inner storage (not the lazy `edge_py_attrs` mirror)?
1172    #[must_use]
1173    pub fn any_edge_has_attrs(&self) -> bool {
1174        self.edges.values().any(|attrs| !attrs.is_empty())
1175    }
1176
1177    #[must_use]
1178    pub fn nodes_are_contiguous_int_prefix(&self) -> bool {
1179        self.nodes
1180            .keys()
1181            .enumerate()
1182            .all(|(index, key)| Self::decimal_key_matches_index(key, index))
1183    }
1184
1185    fn decimal_key_matches_index(key: &str, index: usize) -> bool {
1186        let bytes = key.as_bytes();
1187        if bytes.is_empty() || (bytes.len() > 1 && bytes[0] == b'0') {
1188            return false;
1189        }
1190
1191        let mut parsed = 0usize;
1192        for &byte in bytes {
1193            if !byte.is_ascii_digit() {
1194                return false;
1195            }
1196            let digit = usize::from(byte - b'0');
1197            let Some(next) = parsed
1198                .checked_mul(10)
1199                .and_then(|value| value.checked_add(digit))
1200            else {
1201                return false;
1202            };
1203            parsed = next;
1204        }
1205        parsed == index
1206    }
1207
1208    #[must_use]
1209    pub fn evidence_ledger(&self) -> &EvidenceLedger {
1210        self.runtime_policy.decision_log()
1211    }
1212
1213    pub fn drain_decision_records(&mut self) -> Vec<DecisionRecord> {
1214        self.runtime_policy.drain_decision_records()
1215    }
1216
1217    #[must_use]
1218    pub fn runtime_policy(&self) -> &RuntimePolicy {
1219        &self.runtime_policy
1220    }
1221
1222    pub fn set_runtime_policy(&mut self, runtime_policy: RuntimePolicy) {
1223        self.mode = runtime_policy.mode();
1224        self.runtime_policy = runtime_policy;
1225    }
1226
1227    /// Type identity: always `false` for undirected Graph.
1228    #[must_use]
1229    pub fn is_directed(&self) -> bool {
1230        false
1231    }
1232
1233    /// Type identity: always `false` for Graph (not a multigraph).
1234    #[must_use]
1235    pub fn is_multigraph(&self) -> bool {
1236        false
1237    }
1238
1239    pub fn add_node(&mut self, node: impl AsRef<str> + Into<String>) -> bool {
1240        self.add_node_with_attrs(node, AttrMap::new())
1241    }
1242
1243    /// br-r37-c1-wa1b9: every storage effect of [`Self::add_node_with_attrs`]
1244    /// and none of its ledger record. Returns `(changed, existed, attrs_count,
1245    /// inserted_index)` so the caller can file the record itself if it is a
1246    /// user-visible `add_node`. `inserted_index` is present only for a newly
1247    /// inserted node, allowing an autocreating caller to reuse the `IndexMap`
1248    /// index it already paid to assign. Extracted rather than reimplemented so
1249    /// the recorded and unrecorded paths cannot drift: node insertion ORDER,
1250    /// the adjacency extension and the revision bump are literally the same
1251    /// code.
1252    /// br-r37-c1-jc9e4: the key is materialised only on the VACANT path.
1253    ///
1254    /// This used to open with `let node = node.into();`, so a `&str` caller
1255    /// allocated an owned `String` on every call — including the calls that
1256    /// insert nothing, where it was built purely to probe the map and then
1257    /// dropped by `entry` on the occupied branch. The allocation census
1258    /// measured that as one allocation per endpoint against a `has_node` read
1259    /// control at zero. Probing borrowed and owning only when the entry is
1260    /// genuinely new keeps the occupied path allocation-free.
1261    ///
1262    /// The raw entry keeps that single borrowed-key hash for a vacant insert,
1263    /// too. That retains the allocation-free occupied branch and turns the
1264    /// fresh branch's former lookup-plus-insert pair into one probe.
1265    fn add_node_with_attrs_unrecorded(
1266        &mut self,
1267        node: impl AsRef<str> + Into<String>,
1268        attrs: AttrMap,
1269    ) -> (bool, bool, usize, Option<usize>) {
1270        let node_hash = self.nodes.hasher().hash_one(node.as_ref());
1271        let (existed, changed, attrs_count, inserted_index) = match self
1272            .nodes
1273            .raw_entry_mut_v1()
1274            .from_key_hashed_nocheck(node_hash, node.as_ref())
1275        {
1276            RawEntryMut::Occupied(mut entry) => {
1277                let bucket = entry.get_mut();
1278                let changed = !attrs.is_empty()
1279                    && attrs
1280                        .iter()
1281                        .any(|(key, value)| bucket.get(key) != Some(value));
1282                bucket.extend(attrs);
1283                (true, changed, bucket.len(), None)
1284            }
1285            RawEntryMut::Vacant(entry) => {
1286                let index = entry.index();
1287                let (_, bucket) =
1288                    entry.insert_hashed_nocheck(node_hash, node.into(), AttrMap::new());
1289                bucket.extend(attrs);
1290                (false, true, bucket.len(), Some(index))
1291            }
1292        };
1293        // Extend integer adjacency list for new nodes
1294        if !existed {
1295            self.adj_indices.push(Vec::new());
1296        }
1297        if changed {
1298            self.revision = self.revision.saturating_add(1);
1299        }
1300        (changed, existed, attrs_count, inserted_index)
1301    }
1302
1303    /// Resolve an endpoint for `add_edge_with_attrs`, creating an empty node
1304    /// bucket only when it is absent.
1305    ///
1306    /// `add_edge_with_attrs` receives borrowed endpoint names.  Sending a miss
1307    /// through `get_index_of` and then `add_node_with_attrs_unrecorded` used to
1308    /// hash that same borrowed name twice: once to discover the miss and once
1309    /// more for the raw-entry insert.  Keep the no-attribute specialization
1310    /// here so the edge path can decide and insert from one raw entry while the
1311    /// general `add_node_with_attrs_unrecorded` path continues to move an owned
1312    /// caller key into its vacant bucket.
1313    ///
1314    /// The storage effects deliberately match an empty-attribute call to
1315    /// `add_node_with_attrs_unrecorded`: an inserted node appends exactly one
1316    /// adjacency slot and advances `revision` once; an existing node is a
1317    /// no-op.  The caller owns the one operation-level ledger record.
1318    fn resolve_empty_node_for_edge_unrecorded(&mut self, node: &str) -> (usize, bool) {
1319        let node_hash = self.nodes.hasher().hash_one(node);
1320        match self
1321            .nodes
1322            .raw_entry_mut_v1()
1323            .from_key_hashed_nocheck(node_hash, node)
1324        {
1325            RawEntryMut::Occupied(entry) => (entry.index(), false),
1326            RawEntryMut::Vacant(entry) => {
1327                let index = entry.index();
1328                entry.insert_hashed_nocheck(node_hash, node.to_owned(), AttrMap::new());
1329                self.adj_indices.push(Vec::new());
1330                self.revision = self.revision.saturating_add(1);
1331                (index, true)
1332            }
1333        }
1334    }
1335
1336    pub fn add_node_with_attrs(
1337        &mut self,
1338        node: impl AsRef<str> + Into<String>,
1339        attrs: AttrMap,
1340    ) -> bool {
1341        let (changed, existed, attrs_count, _) = self.add_node_with_attrs_unrecorded(node, attrs);
1342        self.record_decision(
1343            "add_node",
1344            0.0,
1345            false,
1346            vec![
1347                EvidenceTerm {
1348                    signal: "node_preexisting".into(),
1349                    observed_value: bool_evidence(existed),
1350                    log_likelihood_ratio: -3.0,
1351                },
1352                EvidenceTerm {
1353                    signal: "attrs_count".into(),
1354                    observed_value: count_evidence(attrs_count),
1355                    log_likelihood_ratio: -1.0,
1356                },
1357            ],
1358        );
1359        changed
1360    }
1361
1362    /// Bulk node insertion for generated/batched construction paths where
1363    /// per-node compatibility evidence would be pure constant-factor overhead.
1364    #[must_use]
1365    pub fn extend_nodes_unrecorded<I, N>(&mut self, nodes: I) -> usize
1366    where
1367        I: IntoIterator<Item = N>,
1368        N: Into<String>,
1369    {
1370        let iterator = nodes.into_iter();
1371        let (lower_bound, _) = iterator.size_hint();
1372        self.nodes.reserve(lower_bound);
1373        self.adj_indices.reserve(lower_bound);
1374
1375        let mut inserted = 0usize;
1376        for node in iterator {
1377            let node = node.into();
1378            if self.nodes.contains_key(&node) {
1379                continue;
1380            }
1381            self.nodes.insert(node.clone(), AttrMap::new());
1382            self.adj_indices.push(Vec::new());
1383            inserted += 1;
1384        }
1385        if inserted > 0 {
1386            self.revision = self
1387                .revision
1388                .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
1389            self.record_decision(
1390                "extend_nodes_unrecorded",
1391                0.0,
1392                false,
1393                vec![EvidenceTerm {
1394                    signal: "batch_node_count".into(),
1395                    observed_value: count_evidence(inserted),
1396                    log_likelihood_ratio: -1.0,
1397                }],
1398            );
1399        }
1400        inserted
1401    }
1402
1403    /// br-r37-c1-l5ve7: bulk node insert WITH attrs, one ledger record —
1404    /// mirror of DiGraph::extend_nodes_with_attrs_unrecorded. Existing
1405    /// nodes merge attrs (extend), matching add_node_with_attrs.
1406    pub fn extend_nodes_with_attrs_unrecorded<I>(&mut self, nodes: I) -> usize
1407    where
1408        I: IntoIterator<Item = (String, AttrMap)>,
1409    {
1410        let iterator = nodes.into_iter();
1411        let (lower_bound, _) = iterator.size_hint();
1412        self.nodes.reserve(lower_bound);
1413        self.adj_indices.reserve(lower_bound);
1414
1415        let mut inserted = 0usize;
1416        for (node, attrs) in iterator {
1417            if let Some(existing) = self.nodes.get_mut(&node) {
1418                existing.extend(attrs);
1419                continue;
1420            }
1421            self.nodes.insert(node.clone(), attrs);
1422            self.adj_indices.push(Vec::new());
1423            inserted += 1;
1424        }
1425        if inserted > 0 {
1426            self.revision = self
1427                .revision
1428                .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
1429            self.record_decision(
1430                "extend_nodes_with_attrs_unrecorded",
1431                0.0,
1432                false,
1433                vec![EvidenceTerm {
1434                    signal: "batch_node_count".into(),
1435                    observed_value: count_evidence(inserted),
1436                    log_likelihood_ratio: -1.0,
1437                }],
1438            );
1439        }
1440        inserted
1441    }
1442
1443    pub fn apply_node_defaults(&mut self, defaults: &AttrMap) -> bool {
1444        if defaults.is_empty() {
1445            return false;
1446        }
1447        let mut inserted = 0usize;
1448        for attrs in self.nodes.values_mut() {
1449            for (key, value) in defaults {
1450                if !attrs.contains_key(key) {
1451                    attrs.insert(key.clone(), value.clone());
1452                    inserted += 1;
1453                }
1454            }
1455        }
1456        if inserted > 0 {
1457            self.revision = self.revision.saturating_add(1);
1458            self.record_decision(
1459                "apply_node_defaults",
1460                0.0,
1461                false,
1462                vec![EvidenceTerm {
1463                    signal: "defaults_applied".into(),
1464                    observed_value: count_evidence(inserted),
1465                    log_likelihood_ratio: 0.0,
1466                }],
1467            );
1468            return true;
1469        }
1470        false
1471    }
1472
1473    pub fn add_edge(
1474        &mut self,
1475        left: impl AsRef<str>,
1476        right: impl AsRef<str>,
1477    ) -> Result<(), GraphError> {
1478        self.add_edge_with_attrs(left, right, AttrMap::new())
1479    }
1480
1481    /// Bulk-add a sequence of attribute-free edges. Bypasses the
1482    /// per-edge `runtime_policy.record_decision` call that
1483    /// [`add_edge_with_attrs`] makes. Instead, emit one summary record
1484    /// for the whole batch.
1485    ///
1486    /// Intended for fnx-internal callers that build a fresh graph
1487    /// from a known-good edge list (e.g., `complement`,
1488    /// `transitive_closure`, etc.) where the per-edge
1489    /// incompatibility-probability accounting would just add
1490    /// constant-factor overhead and is uninteresting for the policy
1491    /// log.
1492    ///
1493    /// Nodes referenced by edges are auto-created if absent
1494    /// (matching `add_edge` semantics). br-r37-c1-4jd8m.
1495    #[must_use]
1496    pub fn extend_edges_unrecorded<I, L, R>(&mut self, edges: I) -> usize
1497    where
1498        I: IntoIterator<Item = (L, R)>,
1499        L: Into<String>,
1500        R: Into<String>,
1501    {
1502        let iterator = edges.into_iter();
1503        let (lower_bound, _) = iterator.size_hint();
1504        self.nodes.reserve(lower_bound);
1505        self.adj_indices.reserve(lower_bound);
1506        self.edges.reserve(lower_bound);
1507        self.edge_index_endpoints.reserve(lower_bound);
1508
1509        let mut inserted = 0usize;
1510        let mut nodes_added = false;
1511        for (left, right) in iterator {
1512            let left = left.into();
1513            let right = right.into();
1514            let left_idx = match self.nodes.get_index_of(&left) {
1515                Some(index) => index,
1516                None => {
1517                    let index = self.nodes.len();
1518                    self.nodes.insert(left.clone(), AttrMap::new());
1519                    self.adj_indices.push(Vec::new());
1520                    nodes_added = true;
1521                    index
1522                }
1523            };
1524            let right_idx = if left == right {
1525                left_idx
1526            } else {
1527                match self.nodes.get_index_of(&right) {
1528                    Some(index) => index,
1529                    None => {
1530                        let index = self.nodes.len();
1531                        self.nodes.insert(right.clone(), AttrMap::new());
1532                        self.adj_indices.push(Vec::new());
1533                        nodes_added = true;
1534                        index
1535                    }
1536                }
1537            };
1538            let edge_key = Self::canon_pair(left_idx, right_idx);
1539            if self.edges.contains_key(&edge_key) {
1540                continue;
1541            }
1542            if left <= right {
1543                self.edge_index_endpoints.push((left_idx, right_idx));
1544            } else {
1545                self.edge_index_endpoints.push((right_idx, left_idx));
1546            }
1547            self.edges.insert(edge_key, AttrMap::new());
1548
1549            self.adj_indices[left_idx].push(right_idx);
1550            if left_idx != right_idx {
1551                self.adj_indices[right_idx].push(left_idx);
1552            }
1553            inserted += 1;
1554        }
1555        self.record_bulk_edge_summary(inserted, nodes_added);
1556        inserted
1557    }
1558
1559    /// Bulk-add attribute-free edges whose endpoints have already been
1560    /// resolved to existing node indices. This is the index-space sibling of
1561    /// [`extend_edges_unrecorded`]: it preserves duplicate handling, adjacency
1562    /// row order, edge storage order, and string-canonical endpoint orientation
1563    /// without per-edge node-name allocation or map lookup.
1564    #[must_use]
1565    pub fn extend_existing_index_edges_unrecorded<I>(&mut self, edges: I) -> usize
1566    where
1567        I: IntoIterator<Item = (usize, usize)>,
1568    {
1569        let iterator = edges.into_iter();
1570        let (lower_bound, _) = iterator.size_hint();
1571        self.edges.reserve(lower_bound);
1572        self.edge_index_endpoints.reserve(lower_bound);
1573
1574        let mut inserted = 0usize;
1575        for (left_idx, right_idx) in iterator {
1576            debug_assert!(left_idx < self.nodes.len());
1577            debug_assert!(right_idx < self.nodes.len());
1578
1579            let edge_key = Self::canon_pair(left_idx, right_idx);
1580            if self.edges.contains_key(&edge_key) {
1581                continue;
1582            }
1583
1584            let left_name = self
1585                .nodes
1586                .get_index(left_idx)
1587                .expect("validated left node index")
1588                .0
1589                .as_str();
1590            let right_name = self
1591                .nodes
1592                .get_index(right_idx)
1593                .expect("validated right node index")
1594                .0
1595                .as_str();
1596            if left_name <= right_name {
1597                self.edge_index_endpoints.push((left_idx, right_idx));
1598            } else {
1599                self.edge_index_endpoints.push((right_idx, left_idx));
1600            }
1601            self.edges.insert(edge_key, AttrMap::new());
1602
1603            self.adj_indices[left_idx].push(right_idx);
1604            if left_idx != right_idx {
1605                self.adj_indices[right_idx].push(left_idx);
1606            }
1607            inserted += 1;
1608        }
1609        self.record_bulk_edge_summary(inserted, false);
1610        inserted
1611    }
1612
1613    /// br-r37-c1-dodattrbatch: bulk-add ATTRIBUTED edges by EXISTING node index
1614    /// — the attributed sibling of [`extend_existing_index_edges_unrecorded`].
1615    /// All endpoints MUST already exist (callers gate on a contiguous-int node
1616    /// prefix). A new edge takes the given AttrMap; a duplicate edge MERGES
1617    /// (last-writer-wins per key) exactly like `add_edge_with_attrs`. Replaces
1618    /// the per-edge ledger with one batch summary; no String hashing for the
1619    /// endpoints (they arrive as integer indices).
1620    pub fn extend_existing_index_edges_with_attrs_unrecorded<I>(&mut self, edges: I) -> usize
1621    where
1622        I: IntoIterator<Item = (usize, usize, AttrMap)>,
1623    {
1624        let iterator = edges.into_iter();
1625        let (lower_bound, _) = iterator.size_hint();
1626        self.edges.reserve(lower_bound);
1627        self.edge_index_endpoints.reserve(lower_bound);
1628
1629        let mut inserted = 0usize;
1630        for (left_idx, right_idx, attrs) in iterator {
1631            debug_assert!(left_idx < self.nodes.len());
1632            debug_assert!(right_idx < self.nodes.len());
1633
1634            let edge_key = Self::canon_pair(left_idx, right_idx);
1635            if let Some(existing) = self.edges.get_mut(&edge_key) {
1636                // Duplicate edge within the batch: merge attrs (last wins).
1637                for (key, value) in attrs {
1638                    existing.insert(key, value);
1639                }
1640                continue;
1641            }
1642
1643            let left_name = self
1644                .nodes
1645                .get_index(left_idx)
1646                .expect("validated left node index")
1647                .0
1648                .as_str();
1649            let right_name = self
1650                .nodes
1651                .get_index(right_idx)
1652                .expect("validated right node index")
1653                .0
1654                .as_str();
1655            if left_name <= right_name {
1656                self.edge_index_endpoints.push((left_idx, right_idx));
1657            } else {
1658                self.edge_index_endpoints.push((right_idx, left_idx));
1659            }
1660            self.edges.insert(edge_key, attrs);
1661
1662            self.adj_indices[left_idx].push(right_idx);
1663            if left_idx != right_idx {
1664                self.adj_indices[right_idx].push(left_idx);
1665            }
1666            inserted += 1;
1667        }
1668        self.record_bulk_edge_summary(inserted, false);
1669        inserted
1670    }
1671
1672    /// br-r37-c1-pr8q6: bulk-add ATTRIBUTED edges without per-edge ledger
1673    /// records — the attributed sibling of [`extend_edges_unrecorded`].
1674    ///
1675    /// Semantics match a sequence of `add_edge_with_attrs` calls exactly:
1676    /// nodes auto-created in first-appearance order, duplicate edges MERGE
1677    /// their attrs into the existing map (`extend`), adjacency /
1678    /// `adj_indices` / `edge_index_endpoints` maintained identically. Only
1679    /// the per-edge `record_decision` push (timestamp + String allocs per
1680    /// edge, the dominant cost of attributed bulk construction) is replaced
1681    /// by one batch summary record.
1682    ///
1683    /// Callers MUST pre-screen attr keys starting with
1684    /// `"__fnx_incompatible"` and route those edges through
1685    /// `add_edge_with_attrs`, which owns the FailClosed contract for them.
1686    pub fn extend_edges_with_attrs_unrecorded<I>(&mut self, edges: I) -> usize
1687    where
1688        I: IntoIterator<Item = (String, String, AttrMap)>,
1689    {
1690        let iterator = edges.into_iter();
1691        let (lower_bound, _) = iterator.size_hint();
1692        self.nodes.reserve(lower_bound);
1693        self.adj_indices.reserve(lower_bound);
1694        self.edges.reserve(lower_bound);
1695        self.edge_index_endpoints.reserve(lower_bound);
1696
1697        let mut inserted = 0usize;
1698        let mut nodes_added = false;
1699        let mut merged_changed = false;
1700        for (left, right, attrs) in iterator {
1701            let left_idx = match self.nodes.get_index_of(&left) {
1702                Some(index) => index,
1703                None => {
1704                    let index = self.nodes.len();
1705                    self.nodes.insert(left.clone(), AttrMap::new());
1706                    self.adj_indices.push(Vec::new());
1707                    nodes_added = true;
1708                    index
1709                }
1710            };
1711            let right_idx = if left == right {
1712                left_idx
1713            } else {
1714                match self.nodes.get_index_of(&right) {
1715                    Some(index) => index,
1716                    None => {
1717                        let index = self.nodes.len();
1718                        self.nodes.insert(right.clone(), AttrMap::new());
1719                        self.adj_indices.push(Vec::new());
1720                        nodes_added = true;
1721                        index
1722                    }
1723                }
1724            };
1725            let edge_key = Self::canon_pair(left_idx, right_idx);
1726            if let Some(existing) = self.edges.get_mut(&edge_key) {
1727                // Duplicate edge (pre-existing or earlier in this batch):
1728                // merge attrs, matching add_edge_with_attrs' `extend`.
1729                // An empty attr map merges to a no-op; returning early avoids
1730                // building and dropping a BTreeMap iterator per duplicate edge,
1731                // which dominates unweighted bulk loads.
1732                if attrs.is_empty() {
1733                    continue;
1734                }
1735                if attrs
1736                    .iter()
1737                    .any(|(key, value)| existing.get(key) != Some(value))
1738                {
1739                    merged_changed = true;
1740                }
1741                existing.extend(attrs);
1742                continue;
1743            }
1744            if left <= right {
1745                self.edge_index_endpoints.push((left_idx, right_idx));
1746            } else {
1747                self.edge_index_endpoints.push((right_idx, left_idx));
1748            }
1749            self.edges.insert(edge_key, attrs);
1750
1751            self.adj_indices[left_idx].push(right_idx);
1752            if left_idx != right_idx {
1753                self.adj_indices[right_idx].push(left_idx);
1754            }
1755            inserted += 1;
1756        }
1757        if merged_changed {
1758            // Attr-merge on an existing edge mutates observable state even
1759            // when no new edge was inserted — bump revision so caches
1760            // invalidate (over-invalidation is safe; staleness is not).
1761            self.revision = self.revision.saturating_add(1);
1762        }
1763        self.record_bulk_edge_summary(inserted, nodes_added || merged_changed);
1764        inserted
1765    }
1766
1767    /// Bulk-add attributed undirected edges into a fresh graph when the caller
1768    /// has already assigned NetworkX-compatible node indices in first-seen
1769    /// order.
1770    ///
1771    /// This is the fresh/indexed sibling of
1772    /// [`extend_edges_with_attrs_unrecorded`]. It preserves duplicate
1773    /// insert-or-merge semantics, adjacency row order, self-loop handling, and
1774    /// the public `edges()` orientation rule used by the string-key path.
1775    #[must_use]
1776    pub fn extend_fresh_index_edges_with_attrs_unrecorded<I, N>(
1777        &mut self,
1778        nodes: N,
1779        edges: I,
1780    ) -> usize
1781    where
1782        I: IntoIterator<Item = (usize, usize, AttrMap)>,
1783        N: IntoIterator<Item = String>,
1784    {
1785        if !self.nodes.is_empty()
1786            || !self.adj_indices.is_empty()
1787            || !self.edges.is_empty()
1788            || !self.edge_index_endpoints.is_empty()
1789        {
1790            return 0;
1791        }
1792
1793        let node_labels: Vec<String> = nodes.into_iter().collect();
1794        self.nodes.reserve(node_labels.len());
1795        self.adj_indices = vec![Vec::new(); node_labels.len()];
1796        for node in &node_labels {
1797            self.nodes.insert(node.clone(), AttrMap::new());
1798        }
1799
1800        let iterator = edges.into_iter();
1801        let (lower_bound, _) = iterator.size_hint();
1802        self.edges.reserve(lower_bound);
1803        self.edge_index_endpoints.reserve(lower_bound);
1804
1805        let node_count = node_labels.len();
1806        let mut inserted = 0usize;
1807        let mut merged_changed = false;
1808        for (left_idx, right_idx, attrs) in iterator {
1809            if left_idx >= node_count || right_idx >= node_count {
1810                continue;
1811            }
1812
1813            let edge_key = Self::canon_pair(left_idx, right_idx);
1814            if let Some(existing) = self.edges.get_mut(&edge_key) {
1815                // An empty attr map merges to a no-op; returning early avoids
1816                // building and dropping a BTreeMap iterator per duplicate edge,
1817                // which dominates unweighted bulk loads.
1818                if attrs.is_empty() {
1819                    continue;
1820                }
1821                if attrs
1822                    .iter()
1823                    .any(|(key, value)| existing.get(key) != Some(value))
1824                {
1825                    merged_changed = true;
1826                }
1827                existing.extend(attrs);
1828                continue;
1829            }
1830
1831            if node_labels[left_idx] <= node_labels[right_idx] {
1832                self.edge_index_endpoints.push((left_idx, right_idx));
1833            } else {
1834                self.edge_index_endpoints.push((right_idx, left_idx));
1835            }
1836            self.edges.insert(edge_key, attrs);
1837            self.adj_indices[left_idx].push(right_idx);
1838            if left_idx != right_idx {
1839                self.adj_indices[right_idx].push(left_idx);
1840            }
1841            inserted += 1;
1842        }
1843
1844        self.record_bulk_edge_summary(inserted, node_count > 0 || merged_changed);
1845        inserted
1846    }
1847
1848    fn record_bulk_edge_summary(&mut self, inserted: usize, nodes_added: bool) {
1849        if inserted == 0 && !nodes_added {
1850            return;
1851        }
1852        self.revision = self
1853            .revision
1854            .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
1855        // One summary record covers the whole batch and keeps the
1856        // policy ledger non-empty so existing diagnostics still
1857        // see the operation.
1858        self.record_decision(
1859            "extend_edges_unrecorded",
1860            0.0,
1861            false,
1862            vec![EvidenceTerm {
1863                signal: "batch_edge_count".into(),
1864                observed_value: count_evidence(inserted),
1865                log_likelihood_ratio: -1.0,
1866            }],
1867        );
1868    }
1869
1870    /// br-r37-c1-jc9e4: the endpoint keys are never owned by this function.
1871    ///
1872    /// This used to open with `let left = left.into(); let right =
1873    /// right.into();`, then hand `left.clone()` / `right.clone()` to
1874    /// autocreation — up to four owned `String`s per call, none of which this
1875    /// function keeps. Every use below is a comparison, a map probe, or a
1876    /// forward to `add_node_with_attrs_unrecorded`, and that callee now owns
1877    /// the key only on the vacant path. So the keys stay borrowed here and the
1878    /// single allocation an insert genuinely needs happens once, inside the
1879    /// callee, exactly when a node is created.
1880    ///
1881    /// Comparison semantics are unchanged: `left == right` and `left <= right`
1882    /// on `&str` are the same byte-wise comparisons `String` performed, so the
1883    /// self-loop test and the `edge_index_endpoints` push order are identical.
1884    pub fn add_edge_with_attrs(
1885        &mut self,
1886        left: impl AsRef<str>,
1887        right: impl AsRef<str>,
1888        attrs: AttrMap,
1889    ) -> Result<(), GraphError> {
1890        let left = left.as_ref();
1891        let right = right.as_ref();
1892
1893        let unknown_feature = attrs
1894            .keys()
1895            .any(|key| key.starts_with("__fnx_incompatible"));
1896        let incompatibility_probability = if unknown_feature {
1897            1.0
1898        } else if left == right {
1899            0.22
1900        } else {
1901            0.08
1902        };
1903
1904        // br-r37-c1-eo88t: the gate decision earns a ledger record only when it
1905        // REFUSES. A refusal returns from here, so the outcome record at the end
1906        // of this function never runs and the gate record is the whole audit
1907        // trail for the rejected mutation. On the allow path the outcome record
1908        // carries the same operation/mode/action/incompatibility_probability
1909        // plus strictly richer evidence, so recording the gate as well wrote two
1910        // near-identical records for every edge on the hottest mutation path.
1911        let action = self
1912            .runtime_policy
1913            .action_for(incompatibility_probability, unknown_feature);
1914
1915        if action == DecisionAction::FailClosed || action == DecisionAction::FullValidate {
1916            self.record_decision(
1917                "add_edge",
1918                incompatibility_probability,
1919                unknown_feature,
1920                vec![EvidenceTerm {
1921                    signal: "unknown_incompatible_feature".into(),
1922                    observed_value: bool_evidence(unknown_feature),
1923                    log_likelihood_ratio: 12.0,
1924                }],
1925            );
1926            return Err(GraphError::FailClosed {
1927                operation: "add_edge",
1928                reason: "incompatible edge metadata".to_owned(),
1929            });
1930        }
1931
1932        // br-r37-c1-wa1b9: autocreating an endpoint is an internal STEP of this
1933        // add_edge, not a user-visible add_node, so it does not file its own
1934        // ledger record. The outcome record below already reports the event via
1935        // `left_autocreated` / `right_autocreated`, so no audit information is
1936        // lost. This matches the ledger's per-OPERATION contract, which the
1937        // bulk paths already assert (`extend_nodes_unrecorded` files ONE record
1938        // for N nodes), and follows br-r37-c1-eo88t, which removed the other
1939        // redundant record from this same function. A caller-visible
1940        // `add_node` still records — only this internal path is unrecorded.
1941        // br-r37-c1-jc9e4: resolve each endpoint with ONE raw-entry probe.
1942        // A missing endpoint formerly did `get_index_of` followed by
1943        // `add_node_with_attrs_unrecorded`, which probed the same borrowed key
1944        // a second time before it could insert.  The specialized resolver
1945        // returns the existing index or inserts the empty bucket from that
1946        // single raw entry. A self-loop is resolved without probing the second
1947        // endpoint at all, since `left == right` makes the indices equal by
1948        // construction.
1949        //
1950        // Order is untouched: the left endpoint is still resolved and
1951        // autocreated before the right one, and `IndexMap` appends, so
1952        // autocreating the right endpoint cannot move the left one's index.
1953        let (left_key_idx, left_autocreated) = self.resolve_empty_node_for_edge_unrecorded(left);
1954        let (right_key_idx, right_autocreated) = if left == right {
1955            (left_key_idx, left_autocreated)
1956        } else {
1957            self.resolve_empty_node_for_edge_unrecorded(right)
1958        };
1959        let edge_key = Self::canon_pair(left_key_idx, right_key_idx);
1960        let self_loop = left == right;
1961        // One `entry` probe decides both whether this is a fresh edge and
1962        // which attribute bucket receives the update.  The former
1963        // `contains_key` + `entry` sequence hashed the same index pair twice
1964        // on every add_edge call, including the dominant fresh-edge build arm.
1965        let (new_edge, changed, edge_attr_count) = match self.edges.entry(edge_key) {
1966            indexmap::map::Entry::Vacant(entry) => {
1967                let edge_attr_count = attrs.len();
1968                entry.insert(attrs);
1969                (true, true, edge_attr_count)
1970            }
1971            indexmap::map::Entry::Occupied(mut entry) => {
1972                let edge_attrs = entry.get_mut();
1973                let changed = !attrs.is_empty()
1974                    && attrs
1975                        .iter()
1976                        .any(|(key, value)| edge_attrs.get(key) != Some(value));
1977                edge_attrs.extend(attrs);
1978                (false, changed, edge_attrs.len())
1979            }
1980        };
1981
1982        // Update integer adjacency (only for new edges, avoid duplicates)
1983        if changed {
1984            // br-r37-c1-jc9e4 (cc): reuse the indices resolved above instead of
1985            // re-running two String-keyed IndexMap lookups. `left_key_idx` /
1986            // `right_key_idx` came from the same `self.nodes` a few lines up and
1987            // nothing between them touches the node map — the intervening work
1988            // is the `self.edges` entry and the attr extend — so the old
1989            // `if let (Some(..), Some(..))` could never fail and never differed.
1990            // Byte-identical: same indices, same push order, same self-loop
1991            // single-push behaviour.
1992            if new_edge {
1993                if left <= right {
1994                    self.edge_index_endpoints
1995                        .push((left_key_idx, right_key_idx));
1996                } else {
1997                    self.edge_index_endpoints
1998                        .push((right_key_idx, left_key_idx));
1999                }
2000                // br-r37-c1-addedgenewedge (cc): adj_indices membership is EXACTLY edge
2001                // existence, which `new_edge` (= !self.edges.contains_key(edge_key)) already
2002                // decided in O(1). The old `!adj_indices[left_idx].contains(&right_idx)` guards
2003                // were an O(degree) linear rescan of the adjacency row computing the SAME
2004                // answer — O(n²) to build a star/hub via add_edge. Push only when new_edge
2005                // (byte-identical: existing edge ⇒ new_edge false ⇒ both endpoints already in
2006                // adj_indices ⇒ old guards were false too; self-loop ⇒ left_idx==right_idx ⇒
2007                // single push in both).
2008                self.adj_indices[left_key_idx].push(right_key_idx);
2009                if left_key_idx != right_key_idx {
2010                    self.adj_indices[right_key_idx].push(left_key_idx);
2011                }
2012            }
2013            self.revision = self.revision.saturating_add(1);
2014        }
2015
2016        self.record_decision(
2017            "add_edge",
2018            incompatibility_probability,
2019            unknown_feature,
2020            vec![
2021                EvidenceTerm {
2022                    signal: "self_loop".into(),
2023                    observed_value: bool_evidence(self_loop),
2024                    log_likelihood_ratio: -0.5,
2025                },
2026                EvidenceTerm {
2027                    signal: "edge_attr_count".into(),
2028                    observed_value: count_evidence(edge_attr_count),
2029                    log_likelihood_ratio: -2.0,
2030                },
2031                EvidenceTerm {
2032                    signal: "left_autocreated".into(),
2033                    observed_value: bool_evidence(left_autocreated),
2034                    log_likelihood_ratio: -1.25,
2035                },
2036                EvidenceTerm {
2037                    signal: "right_autocreated".into(),
2038                    observed_value: bool_evidence(right_autocreated),
2039                    log_likelihood_ratio: -1.25,
2040                },
2041            ],
2042        );
2043
2044        Ok(())
2045    }
2046
2047    pub fn apply_edge_defaults(&mut self, defaults: &AttrMap) -> bool {
2048        if defaults.is_empty() {
2049            return false;
2050        }
2051        let mut inserted = 0usize;
2052        for attrs in self.edges.values_mut() {
2053            for (key, value) in defaults {
2054                if !attrs.contains_key(key) {
2055                    attrs.insert(key.clone(), value.clone());
2056                    inserted += 1;
2057                }
2058            }
2059        }
2060        if inserted > 0 {
2061            self.revision = self.revision.saturating_add(1);
2062            self.record_decision(
2063                "apply_edge_defaults",
2064                0.0,
2065                false,
2066                vec![EvidenceTerm {
2067                    signal: "defaults_applied".into(),
2068                    observed_value: count_evidence(inserted),
2069                    log_likelihood_ratio: 0.0,
2070                }],
2071            );
2072            return true;
2073        }
2074        false
2075    }
2076
2077    /// br-r37-c1-sjf4t: overwrite the attribute map for an existing edge,
2078    /// matching post-creation Python-side mutations. Unlike
2079    /// `add_edge_with_attrs` which extends, this replaces. Returns
2080    /// `true` if the edge existed and was updated, `false` otherwise.
2081    pub fn replace_edge_attrs(&mut self, left: &str, right: &str, attrs: AttrMap) -> bool {
2082        let Some(edge_key) = self.edge_pair_key(left, right) else {
2083            return false;
2084        };
2085        if let Some(slot) = self.edges.get_mut(&edge_key) {
2086            if *slot != attrs {
2087                *slot = attrs;
2088                self.revision = self.revision.saturating_add(1);
2089            }
2090            true
2091        } else {
2092            false
2093        }
2094    }
2095
2096    /// br-r37-c1-sjf4t: overwrite the attribute map for an existing node.
2097    pub fn replace_node_attrs(&mut self, node: &str, attrs: AttrMap) -> bool {
2098        if let Some(slot) = self.nodes.get_mut(node) {
2099            if *slot != attrs {
2100                *slot = attrs;
2101                self.revision = self.revision.saturating_add(1);
2102            }
2103            true
2104        } else {
2105            false
2106        }
2107    }
2108
2109    pub fn remove_edge(&mut self, left: &str, right: &str) -> bool {
2110        // `shift_remove_full` returns the edge's index in `self.edges` *before*
2111        // removal so we can keep `edge_index_endpoints` (maintained strictly
2112        // parallel to `self.edges`: one entry per edge, same order — see
2113        // `add_edge_with_attrs` / `extend_edges_unrecorded`) in sync by dropping
2114        // the single corresponding entry, instead of the O(|E|) hashing
2115        // `rebuild_edge_index_endpoints()`. Bit-identical result (the parallel
2116        // vector ends up exactly as a full rebuild would leave it), but turns a
2117        // remove-heavy build like watts_strogatz from O(|E|^2) into O(|E|·shift).
2118        let Some(pair) = self.edge_pair_key(left, right) else {
2119            return false;
2120        };
2121        // br-r37-c1-vbwpl: swap_remove (O(1)) instead of shift_remove
2122        // (O(|E|)) — turns remove-heavy algos (double_edge_swap 188x,
2123        // watts) from O(k*|E|) into O(k). The edge's slot is filled by
2124        // the LAST edge; edge_index_endpoints does the SAME swap_remove
2125        // so the two vectors stay element-parallel. Output is unaffected:
2126        // edges_ordered() and all user-visible iteration order off
2127        // ADJACENCY (adj_indices), not edges-map storage order; the only
2128        // storage-order consumer is the COO matrix builder, where triple
2129        // order is irrelevant (scipy CSR canonicalises). Edge keys are
2130        // (node_idx,node_idx) so no key rekey is needed on edge removal.
2131        let removed = self.edges.swap_remove_full(&pair);
2132        if let Some((edge_pos, _, _)) = removed {
2133            // br-r37-c1-d58s8 P2(c) slice 2: integer rows are the single
2134            // row store — drop the pair entries there.
2135            if let (Some(left_idx), Some(right_idx)) = (
2136                self.nodes.get_index_of(left),
2137                self.nodes.get_index_of(right),
2138            ) {
2139                self.adj_indices[left_idx].retain(|&i| i != right_idx);
2140                if left_idx != right_idx {
2141                    self.adj_indices[right_idx].retain(|&i| i != left_idx);
2142                }
2143            }
2144            self.edge_index_endpoints.swap_remove(edge_pos);
2145            self.revision = self.revision.saturating_add(1);
2146            true
2147        } else {
2148            false
2149        }
2150    }
2151
2152    pub fn remove_node(&mut self, node: &str) -> bool {
2153        let Some(idx) = self.nodes.get_index_of(node) else {
2154            return false;
2155        };
2156
2157        // br-r37-c1-rmnode: incremental index maintenance. The previous version
2158        // called `rebuild_adj_indices()` + `rebuild_edge_index_endpoints()` —
2159        // two O(|E|) passes that do a `get_index_of` HashMap lookup for EVERY
2160        // neighbour of every node and every edge — on EVERY removal, so
2161        // `remove_node` was O(|V|+|E|)-with-hashing and `remove_nodes_from` was
2162        // O(k*(|V|+|E|)) (~50x slower than nx). Instead: drop all incident edges
2163        // in a single O(|E|) retain pass, then repair the integer caches in
2164        // place (drop dangling refs to the removed index, decrement indices that
2165        // shifted down) — no hashing, no full rebuild.
2166
2167        // br-cc-rmnode-fuse: the previous version made ~9 separate O(|E|)
2168        // passes over the edge storage per removal (keep-mask, edges.retain,
2169        // endpoints.retain, adj retain, adj decrement, endpoint decrement,
2170        // full edges rekey). Since the removal ALWAYS rebuilds the edges map
2171        // (indices > idx shift down when `nodes.shift_remove` renumbers),
2172        // FUSE incident-drop + index-decrement + rekey into ONE order-
2173        // preserving rebuild pass, and FUSE the adjacency retain+decrement
2174        // into one pass per row. Survivor order is untouched (both the old
2175        // retain and the old rekey preserved insertion order), so
2176        // `edges_ordered()` — the only observed edge order, walked from the
2177        // adjacency rows — is byte-identical. Cuts the per-removal edge work
2178        // from ~9|E| to ~3|E| (the O(|V|+|E|) renumber floor stays; killing
2179        // it needs stable node ids, see docs/NEGATIVE_EVIDENCE.md).
2180
2181        // br-r37-c1-qxtlj: the renumber exists because `nodes.shift_remove`
2182        // moves every position ABOVE `idx` down by one. When `idx` IS the last
2183        // position there is nothing above it, so no index changes anywhere; and
2184        // when the node is additionally isolated, no edge references it either.
2185        // Both of the O(|V|+|E|) repairs below are then provably no-ops over
2186        // their whole input, and the only real work is dropping two trailing
2187        // slots.
2188        //
2189        // This is not a dent in the compact-index wall - a node in the middle
2190        // still renumbers, and that floor needs stable ids (see
2191        // docs/NEGATIVE_EVIDENCE.md). It is the case the wall did not have to
2192        // cover, and it is the common one: a scratch node added and removed
2193        // again. Measured before this: removing ONE isolated node from a
2194        // 12800-node Graph cost 264.86us against networkx's 0.61us (0.0023x),
2195        // and cost the SAME whether the node sat at the first index or the
2196        // last - the fast path simply was not there.
2197        //
2198        // The isolation test reads the integer adjacency row, which is the same
2199        // source step 3 below treats as authoritative for incident edges. A
2200        // self-loop puts `idx` in its own row, so a looped node is non-empty
2201        // here and takes the general path.
2202        if idx + 1 == self.nodes.len() && self.adj_indices[idx].is_empty() {
2203            self.adj_indices.remove(idx);
2204            self.nodes.shift_remove(node);
2205            self.revision = self.revision.saturating_add(1);
2206            return true;
2207        }
2208
2209        // 1. Drop the node's own adjacency-index vec (outer Vec shifts to stay
2210        //    aligned with `nodes` after the shift_remove below).
2211        self.adj_indices.remove(idx);
2212        // 2. Remove from the node map (renumbers positions > idx down by 1).
2213        self.nodes.shift_remove(node);
2214        // 3. Adjacency repair — ONE pass per row: drop any remaining reference
2215        //    to the removed index and decrement every index that shifted down.
2216        for nbrs in &mut self.adj_indices {
2217            let mut w = 0usize;
2218            for read in 0..nbrs.len() {
2219                let e = nbrs[read];
2220                if e == idx {
2221                    continue;
2222                }
2223                nbrs[w] = if e > idx { e - 1 } else { e };
2224                w += 1;
2225            }
2226            nbrs.truncate(w);
2227        }
2228        // 4. Edge storage — ONE fused pass over the element-parallel
2229        //    (edges, edge_index_endpoints): skip incident edges (either
2230        //    endpoint index == idx), decrement shifted indices, and rebuild
2231        //    both containers in the SAME survivor order they had before.
2232        let old_edges = std::mem::take(&mut self.edges);
2233        let old_endpoints = std::mem::take(&mut self.edge_index_endpoints);
2234        let mut new_edges =
2235            FxIndexMap::with_capacity_and_hasher(old_edges.len(), rustc_hash::FxBuildHasher);
2236        let mut new_endpoints = Vec::with_capacity(old_endpoints.len());
2237        for (((l, r), attrs), (le, re)) in old_edges.into_iter().zip(old_endpoints) {
2238            if le == idx || re == idx {
2239                continue;
2240            }
2241            new_edges.insert(
2242                (
2243                    if l > idx { l - 1 } else { l },
2244                    if r > idx { r - 1 } else { r },
2245                ),
2246                attrs,
2247            );
2248            new_endpoints.push((
2249                if le > idx { le - 1 } else { le },
2250                if re > idx { re - 1 } else { re },
2251            ));
2252        }
2253        self.edges = new_edges;
2254        self.edge_index_endpoints = new_endpoints;
2255
2256        self.revision = self.revision.saturating_add(1);
2257        true
2258    }
2259
2260    pub fn remove_nodes_from<'a, I>(&mut self, nodes: I) -> (usize, usize)
2261    where
2262        I: IntoIterator<Item = &'a str>,
2263    {
2264        let remove_set: HashSet<&str> = nodes
2265            .into_iter()
2266            .filter(|node| self.nodes.contains_key(*node))
2267            .collect();
2268        if remove_set.is_empty() {
2269            return (0, 0);
2270        }
2271
2272        let old_node_count = self.nodes.len();
2273        let old_edge_count = self.edges.len();
2274
2275        // br-r37-c1-d58s8 P2(c) slice 1: integer-side removal. Compute the
2276        // removed-index mask + old->new remap BEFORE any map shifts, then
2277        // filter edges via endpoint indices (no String hashing) and
2278        // rebuild adj_indices through the remap from the OLD integer rows
2279        // (the old rebuild_adj_indices re-hashed every neighbor String).
2280        let removed_mask: Vec<bool> = self
2281            .nodes
2282            .keys()
2283            .map(|k| remove_set.contains(k.as_str()))
2284            .collect();
2285        let mut remap: Vec<usize> = Vec::with_capacity(old_node_count);
2286        let mut next = 0usize;
2287        for &gone in &removed_mask {
2288            remap.push(next);
2289            if !gone {
2290                next += 1;
2291            }
2292        }
2293        let keep_edges: Vec<bool> = self
2294            .edge_index_endpoints
2295            .iter()
2296            .map(|&(l, r)| !removed_mask[l] && !removed_mask[r])
2297            .collect();
2298
2299        self.nodes
2300            .retain(|node, _| !remove_set.contains(node.as_str()));
2301        let mut i = 0usize;
2302        self.edges.retain(|_, _| {
2303            let k = keep_edges[i];
2304            i += 1;
2305            k
2306        });
2307        let mut j = 0usize;
2308        self.edge_index_endpoints.retain(|_| {
2309            let k = keep_edges[j];
2310            j += 1;
2311            k
2312        });
2313        for endpoints in &mut self.edge_index_endpoints {
2314            endpoints.0 = remap[endpoints.0];
2315            endpoints.1 = remap[endpoints.1];
2316        }
2317        // br-r37-c1-d58s8 edges-map flip: rekey survivors through the remap.
2318        self.edges = std::mem::take(&mut self.edges)
2319            .into_iter()
2320            .map(|((l, r), attrs)| ((remap[l], remap[r]), attrs))
2321            .collect();
2322        let old_rows = std::mem::take(&mut self.adj_indices);
2323        self.adj_indices = old_rows
2324            .into_iter()
2325            .enumerate()
2326            .filter(|(old_idx, _)| !removed_mask[*old_idx])
2327            .map(|(_, row)| {
2328                row.into_iter()
2329                    .filter(|&nbr| !removed_mask[nbr])
2330                    .map(|nbr| remap[nbr])
2331                    .collect()
2332            })
2333            .collect();
2334
2335        let removed_nodes = old_node_count - self.nodes.len();
2336        let removed_edges = old_edge_count - self.edges.len();
2337        self.revision = self
2338            .revision
2339            .saturating_add(u64::try_from(removed_nodes).unwrap_or(u64::MAX));
2340        (removed_nodes, removed_edges)
2341    }
2342
2343    /// Rebuild integer adjacency from string adjacency. Called after node
2344    /// removal since indices shift.
2345    #[allow(dead_code)]
2346    /// br-r37-c1-d58s8: revision-keyed memo of "every edge's `attr`
2347    /// value is an integer (missing = default 1 = int)".
2348    #[must_use]
2349    pub fn edge_weights_all_int(&self, attr: &str) -> bool {
2350        if let Ok(guard) = self.all_int_cache.read()
2351            && let Some((rev, a, v)) = guard.as_ref()
2352            && *rev == self.revision
2353            && a == attr
2354        {
2355            return *v;
2356        }
2357        let v = self
2358            .edges
2359            .values()
2360            .all(|attrs| attrs.get(attr).is_none_or(fnx_runtime::CgseValue::is_int));
2361        if let Ok(mut guard) = self.all_int_cache.write() {
2362            *guard = Some((self.revision, attr.to_owned(), v));
2363        }
2364        v
2365    }
2366
2367    pub fn edges_ordered(&self) -> Vec<EdgeSnapshot> {
2368        // br-r37-c1-d58s8 P2(b): index-native u-major walk — integer
2369        // pair dedup + an integer (min,max)->attr map built once from
2370        // edge_index_endpoints; zero String hashing in the loop. Walk
2371        // order (u-major over adj_indices rows) is identical to the
2372        // String-row walk (rows are order-faithful mirrors).
2373        let node_labels: Vec<&str> = self.nodes.keys().map(|s| s.as_str()).collect();
2374        let mut ordered = Vec::with_capacity(self.edges.len());
2375        let mut seen = vec![false; self.nodes.len()];
2376        for (u, row) in self.adj_indices.iter().enumerate() {
2377            for &v in row {
2378                if !seen[v] {
2379                    let pair = if u <= v { (u, v) } else { (v, u) };
2380                    if let Some(attrs) = self.edges.get(&pair) {
2381                        ordered.push(EdgeSnapshot {
2382                            left: node_labels[u].to_owned(),
2383                            right: node_labels[v].to_owned(),
2384                            attrs: attrs.clone(),
2385                        });
2386                    }
2387                }
2388            }
2389            seen[u] = true;
2390        }
2391
2392        ordered
2393    }
2394
2395    #[must_use]
2396    pub fn edges_ordered_borrowed(&self) -> Vec<(&str, &str, &AttrMap)> {
2397        // br-r37-c1-d58s8 P2(b): index-native (see edges_ordered).
2398        let node_labels: Vec<&str> = self.nodes.keys().map(|s| s.as_str()).collect();
2399        let mut ordered = Vec::with_capacity(self.edges.len());
2400        let mut seen = vec![false; self.nodes.len()];
2401        for (u, row) in self.adj_indices.iter().enumerate() {
2402            for &v in row {
2403                if !seen[v] {
2404                    let pair = if u <= v { (u, v) } else { (v, u) };
2405                    if let Some(attrs) = self.edges.get(&pair) {
2406                        ordered.push((node_labels[u], node_labels[v], attrs));
2407                    }
2408                }
2409            }
2410            seen[u] = true;
2411        }
2412
2413        ordered
2414    }
2415
2416    /// br-r37-c1-wsize (cc): integer `size(weight)` straight from the CgseValue
2417    /// store. `size(weight)` is `sum(degree(weight))/2`; the `/2` exactly cancels
2418    /// the double-endpoint counting, so the weighted size equals each stored
2419    /// edge's weight summed ONCE (a self-loop is one stored edge → counted once,
2420    /// matching nx's degree-counts-it-twice-then-halves). Each edge appears once
2421    /// in `self.edges`, so a single store walk is the whole answer — no per-node
2422    /// PyObject degree materialization. Returns `None` (caller falls back to the
2423    /// exact float/PyObject degree path) on ANY non-integer weight value; a
2424    /// missing weight defaults to nx's int `1`. Integer addition is associative,
2425    /// so iteration order is irrelevant.
2426    #[must_use]
2427    pub fn weighted_size_int(&self, weight: &str) -> Option<i128> {
2428        let mut total: i128 = 0;
2429        for attrs in self.edges.values() {
2430            let value = match attrs.get(weight) {
2431                Some(CgseValue::Int(v)) => i128::from(*v),
2432                Some(_) => return None,
2433                None => 1,
2434            };
2435            total = total.checked_add(value)?;
2436        }
2437        Some(total)
2438    }
2439
2440    /// br-r37-c1-2a00r: index-space twin of `edges_ordered_borrowed` — yields
2441    /// `(u, v)` node indices in the SAME node-major traversed order/orientation,
2442    /// without resolving endpoints to `&str`. Lets the EdgeView iterate edges and
2443    /// look up the per-index Python node-key object directly (O(1) incref) instead
2444    /// of hashing the canonical String per endpoint via `py_node_key`.
2445    #[must_use]
2446    pub fn edges_ordered_indices(&self) -> Vec<(usize, usize)> {
2447        // cc-edgesnodeded: nx's O(N) first-encounter edge dedup — yield (u, v) when
2448        // v has NOT yet been processed as a source (so each undirected edge emits
2449        // once, from its earlier-processed endpoint). Replaces the O(E)
2450        // `HashSet<(usize,usize)>` pair-dedup + `present` membership set that made
2451        // `list(G.edges())` ~0.57x nx on dense graphs (37k edges = ~150k pair
2452        // hashes). Same node-major adjacency order and same orientation, so
2453        // byte-identical. A `vec![bool]` node marker is O(1) per neighbour, no
2454        // hashing. Falls back to the exact present/seen_pairs rebuild only if the
2455        // adjacency walk does not reproduce the full edge set (degenerate /
2456        // inconsistent adjacency), preserving the original contract there.
2457        let node_count = self.adj_indices.len();
2458        let mut ordered = Vec::with_capacity(self.edges.len());
2459        let mut seen_source = vec![false; node_count];
2460        for (u, row) in self.adj_indices.iter().enumerate() {
2461            for &v in row {
2462                if !seen_source[v] {
2463                    ordered.push((u, v));
2464                }
2465            }
2466            seen_source[u] = true;
2467        }
2468        if ordered.len() == self.edges.len() {
2469            return ordered;
2470        }
2471        // Fallback (degenerate adjacency): the exact present/seen_pairs rebuild.
2472        let mut present: HashSet<(usize, usize)> = HashSet::with_capacity(self.edges.len());
2473        for &(l, r) in &self.edge_index_endpoints {
2474            present.insert(if l <= r { (l, r) } else { (r, l) });
2475        }
2476        let mut ordered = Vec::with_capacity(self.edges.len());
2477        let mut seen_pairs = HashSet::<(usize, usize)>::with_capacity(self.edges.len());
2478        for (u, row) in self.adj_indices.iter().enumerate() {
2479            for &v in row {
2480                let pair = if u <= v { (u, v) } else { (v, u) };
2481                if !seen_pairs.insert(pair) {
2482                    continue;
2483                }
2484                if present.contains(&pair) {
2485                    ordered.push((u, v));
2486                }
2487            }
2488        }
2489        if ordered.len() < self.edges.len() {
2490            // Mirror edges_ordered_borrowed's fallback EXACTLY: iterate
2491            // self.edges with the raw (l, r) key orientation and dedup on the
2492            // raw key (not (min,max)) so degenerate cases stay byte-identical.
2493            for &(l, r) in self.edges.keys() {
2494                if seen_pairs.insert((l, r)) {
2495                    ordered.push((l, r));
2496                }
2497            }
2498        }
2499        ordered
2500    }
2501
2502    #[must_use]
2503    pub fn edges_storage_order_borrowed(&self) -> Vec<(&str, &str, &AttrMap)> {
2504        // orientation from edge_index_endpoints (string-canonical, kept
2505        // element-parallel with edges).
2506        self.edge_index_endpoints
2507            .iter()
2508            .zip(self.edges.values())
2509            .map(|(&(l, r), attrs)| {
2510                (
2511                    self.nodes.get_index(l).expect("valid index").0.as_str(),
2512                    self.nodes.get_index(r).expect("valid index").0.as_str(),
2513                    attrs,
2514                )
2515            })
2516            .collect()
2517    }
2518
2519    pub fn edges_storage_order_iter(&self) -> impl Iterator<Item = (&str, &str, &AttrMap)> + '_ {
2520        self.edge_index_endpoints
2521            .iter()
2522            .zip(self.edges.values())
2523            .map(|(&(l, r), attrs)| {
2524                (
2525                    self.nodes.get_index(l).expect("valid index").0.as_str(),
2526                    self.nodes.get_index(r).expect("valid index").0.as_str(),
2527                    attrs,
2528                )
2529            })
2530    }
2531
2532    #[must_use]
2533    pub fn snapshot(&self) -> GraphSnapshot {
2534        // br-snapnodeattrs: preserve per-node attributes alongside the
2535        // bare node name list so callers replaying a snapshot don't lose
2536        // ``add_node(n, attrs)`` data.
2537        let node_attrs: BTreeMap<String, AttrMap> = self
2538            .nodes
2539            .iter()
2540            .filter(|(_, attrs)| !attrs.is_empty())
2541            .map(|(name, attrs)| (name.clone(), attrs.clone()))
2542            .collect();
2543        GraphSnapshot {
2544            mode: self.mode,
2545            nodes: self.nodes.keys().cloned().collect(),
2546            node_attrs,
2547            edges: self.edges_ordered(),
2548        }
2549    }
2550
2551    fn record_decision(
2552        &mut self,
2553        operation: &'static str,
2554        incompatibility_probability: f64,
2555        unknown_incompatible_feature: bool,
2556        evidence: Vec<EvidenceTerm>,
2557    ) -> DecisionAction {
2558        let action = self
2559            .runtime_policy
2560            .action_for(incompatibility_probability, unknown_incompatible_feature);
2561        self.runtime_policy.record(
2562            operation,
2563            action,
2564            incompatibility_probability,
2565            "argmin expected loss over {allow,full_validate,fail_closed}",
2566            evidence,
2567        );
2568        action
2569    }
2570}
2571
2572/// br-r37-c1-thp6w slice 1: revision-keyed integer-adjacency memo for MultiGraph. Holds the
2573/// integer neighbor rows (`get_node_index` of each adjacency-row neighbor, in row order),
2574/// lazily built from the authoritative stable-slot rows and reused until the graph mutates.
2575/// Interior mutability (RwLock) so it can populate on a `&self` read while staying `Sync`,
2576/// matching `Graph::all_int_cache`. Clone yields a FRESH (empty) memo — never shared — so a
2577/// clone that mutates independently can never serve another graph's rows.
2578#[derive(Debug, Default)]
2579struct IntAdjCache(std::sync::RwLock<Option<(u64, Vec<Vec<usize>>)>>);
2580
2581impl Clone for IntAdjCache {
2582    fn clone(&self) -> Self {
2583        Self::default()
2584    }
2585}
2586
2587/// br-r37-c1-thp6w S4: the structural row delta of one single-edge MultiGraph
2588/// mutation, used by `advance_int_adj_memo` to keep a warm integer-adjacency
2589/// memo live across the mutation instead of invalidating it.
2590enum IntAdjEdgeDelta<'a> {
2591    Add {
2592        left: &'a str,
2593        right: &'a str,
2594        left_new: bool,
2595        right_new: bool,
2596        pair_new: bool,
2597    },
2598    Remove {
2599        left: &'a str,
2600        right: &'a str,
2601        pair_gone: bool,
2602    },
2603}
2604
2605/// Stable-slot storage for `MultiGraph`. Test-only positional prototypes stay
2606/// alongside it as parity and benchmark comparators for the storage cutover.
2607mod multigraph_storage {
2608    use super::{AttrMap, CompatibilityMode, MultiEdgeSnapshot, MultiGraphSnapshot};
2609    use indexmap::{IndexMap, IndexSet};
2610
2611    /// Index-keyed analog of the MultiGraph storage core. Internal pair
2612    /// canonicalization is (min_idx, max_idx) — deliberately DIFFERENT from
2613    /// the String-lex `EdgeKey` canonical ("10" < "2" lex, 2 < 10 numeric) —
2614    /// because pair-key orientation is never observable: every public
2615    /// ordering derives from the rows walk + per-cell key sets, which the
2616    /// parity gates pin byte-for-byte.
2617    #[cfg(test)]
2618    #[derive(Debug, Default)]
2619    pub struct MgIntStorageProto {
2620        /// Node names in insertion order (the surviving String table).
2621        pub node_names: Vec<String>,
2622        /// name -> index resolution (the `nodes` map analog).
2623        pub node_index: IndexMap<String, usize, rustc_hash::FxBuildHasher>,
2624        /// node idx -> distinct-neighbor idx -> parallel-edge key set,
2625        /// insertion-ordered exactly like the String rows.
2626        pub rows: Vec<IndexMap<usize, IndexSet<usize>>>,
2627        /// (min_idx, max_idx) -> key -> attrs.
2628        pub edges: IndexMap<(usize, usize), IndexMap<usize, AttrMap>, rustc_hash::FxBuildHasher>,
2629        pub edge_count: usize,
2630    }
2631
2632    #[cfg(test)]
2633    impl MgIntStorageProto {
2634        #[must_use]
2635        pub fn new() -> Self {
2636            Self::default()
2637        }
2638
2639        fn ensure_node(&mut self, name: &str) -> usize {
2640            if let Some(&idx) = self.node_index.get(name) {
2641                return idx;
2642            }
2643            let idx = self.node_names.len();
2644            self.node_names.push(name.to_owned());
2645            self.node_index.insert(name.to_owned(), idx);
2646            self.rows.push(IndexMap::new());
2647            idx
2648        }
2649
2650        /// Mirror of `MultiGraph::extend_keyed_edges_with_attrs_unrecorded`'s
2651        /// per-edge storage ops: endpoint nodes created on demand, an existing
2652        /// (pair, key) cell merges attrs, new cells count toward `edge_count`.
2653        pub fn add_keyed_edge(&mut self, left: &str, right: &str, key: usize, attrs: AttrMap) {
2654            let l = self.ensure_node(left);
2655            let r = if left == right {
2656                l
2657            } else {
2658                self.ensure_node(right)
2659            };
2660            let pair = (l.min(r), l.max(r));
2661            let bucket = self.edges.entry(pair).or_default();
2662            if !bucket.contains_key(&key) {
2663                self.edge_count += 1;
2664            }
2665            bucket.entry(key).or_default().extend(attrs);
2666            self.rows[l].entry(r).or_default().insert(key);
2667            if l != r {
2668                self.rows[r].entry(l).or_default().insert(key);
2669            }
2670        }
2671
2672        /// Mirror of `MultiGraph::extend_fresh_int_prefix_keyed_edges_unrecorded`
2673        /// for the A/B: same node-name String table build (fairness — the name
2674        /// table survives the flip), then pure index-keyed edge inserts with no
2675        /// String clone/hash per edge.
2676        #[must_use]
2677        pub fn bulk_load_int_prefix<I>(node_count: usize, edges: I) -> Self
2678        where
2679            I: IntoIterator<Item = (usize, usize, usize)>,
2680        {
2681            let iterator = edges.into_iter();
2682            let (lower_bound, _) = iterator.size_hint();
2683            let mut proto = Self::new();
2684            proto.node_names.reserve(node_count);
2685            proto.node_index.reserve(node_count);
2686            proto.rows.reserve(node_count);
2687            proto.edges.reserve(lower_bound);
2688            for node in 0..node_count {
2689                let name = node.to_string();
2690                proto.node_names.push(name.clone());
2691                proto.node_index.insert(name, node);
2692                proto.rows.push(IndexMap::new());
2693            }
2694            for (left_idx, right_idx, key) in iterator {
2695                debug_assert!(left_idx < node_count);
2696                debug_assert!(right_idx < node_count);
2697                let pair = (left_idx.min(right_idx), left_idx.max(right_idx));
2698                proto
2699                    .edges
2700                    .entry(pair)
2701                    .or_default()
2702                    .insert(key, AttrMap::new());
2703                proto.rows[left_idx]
2704                    .entry(right_idx)
2705                    .or_default()
2706                    .insert(key);
2707                if left_idx != right_idx {
2708                    proto.rows[right_idx]
2709                        .entry(left_idx)
2710                        .or_default()
2711                        .insert(key);
2712                }
2713                proto.edge_count += 1;
2714            }
2715            proto
2716        }
2717
2718        /// Distinct neighbors of node `i` as names, in row order.
2719        #[must_use]
2720        pub fn neighbor_names(&self, i: usize) -> Vec<&str> {
2721            self.rows[i]
2722                .keys()
2723                .map(|&j| self.node_names[j].as_str())
2724                .collect()
2725        }
2726
2727        /// Parallel-edge key order for the (left, right) pair, `edge_keys_vec` analog.
2728        #[must_use]
2729        pub fn key_order(&self, left: &str, right: &str) -> Vec<usize> {
2730            let (Some(&l), Some(&r)) = (self.node_index.get(left), self.node_index.get(right))
2731            else {
2732                return Vec::new();
2733            };
2734            self.edges
2735                .get(&(l.min(r), l.max(r)))
2736                .map(|bucket| bucket.keys().copied().collect())
2737                .unwrap_or_default()
2738        }
2739
2740        /// br-r37-c1-thp6w S7: mirror of `MultiGraph::remove_edge` on the
2741        /// index layout — `key=None` picks the LAST bucket key (`next_back`),
2742        /// the surviving bucket keeps its key order (shift semantics), an
2743        /// emptied pair drops from the outer edges map (order unobservable)
2744        /// and shift-removes the neighbor cell from both rows.
2745        pub fn remove_edge(&mut self, left: &str, right: &str, key: Option<usize>) -> bool {
2746            let (Some(&l), Some(&r)) = (self.node_index.get(left), self.node_index.get(right))
2747            else {
2748                return false;
2749            };
2750            let pair = (l.min(r), l.max(r));
2751            let Some(bucket) = self.edges.get_mut(&pair) else {
2752                return false;
2753            };
2754            let Some(removal_key) = key.or_else(|| bucket.keys().next_back().copied()) else {
2755                return false;
2756            };
2757            if bucket.shift_remove(&removal_key).is_none() {
2758                return false;
2759            }
2760            self.edge_count -= 1;
2761            let pair_gone = bucket.is_empty();
2762            if pair_gone {
2763                self.edges.swap_remove(&pair);
2764            }
2765            let mut drop_cell = |u: usize, v: usize| {
2766                let mut cell_empty = false;
2767                if let Some(keys) = self.rows[u].get_mut(&v) {
2768                    keys.shift_remove(&removal_key);
2769                    cell_empty = keys.is_empty();
2770                }
2771                if cell_empty {
2772                    self.rows[u].shift_remove(&v);
2773                }
2774            };
2775            drop_cell(l, r);
2776            if l != r {
2777                drop_cell(r, l);
2778            }
2779            true
2780        }
2781
2782        /// br-r37-c1-thp6w S7: mirror of `MultiGraph::remove_node` under the
2783        /// index layout — the d58s8-pattern fused renumber (Graph::remove_node
2784        /// template): drop the node's row, decrement every surviving index
2785        /// above it in one order-preserving pass per row, rebuild the edges
2786        /// map order-preserving with incident pairs skipped and both pair
2787        /// members decremented (order within the pair is preserved by a
2788        /// uniform decrement, so the index-canonical invariant survives).
2789        pub fn remove_node(&mut self, name: &str) -> bool {
2790            let Some(&idx) = self.node_index.get(name) else {
2791                return false;
2792            };
2793            self.node_names.remove(idx);
2794            self.node_index.shift_remove(name);
2795            for stored in self.node_index.values_mut() {
2796                if *stored > idx {
2797                    *stored -= 1;
2798                }
2799            }
2800            self.rows.remove(idx);
2801            for row in &mut self.rows {
2802                let old = std::mem::take(row);
2803                for (j, keys) in old {
2804                    if j == idx {
2805                        continue;
2806                    }
2807                    row.insert(if j > idx { j - 1 } else { j }, keys);
2808                }
2809            }
2810            let old_edges = std::mem::take(&mut self.edges);
2811            let mut removed_cells = 0usize;
2812            for ((a, b), bucket) in old_edges {
2813                if a == idx || b == idx {
2814                    removed_cells += bucket.len();
2815                    continue;
2816                }
2817                self.edges.insert(
2818                    (
2819                        if a > idx { a - 1 } else { a },
2820                        if b > idx { b - 1 } else { b },
2821                    ),
2822                    bucket,
2823                );
2824            }
2825            self.edge_count -= removed_cells;
2826            true
2827        }
2828    }
2829
2830    /// br-r37-c1-thp6w S6: singleton-optimized parallel-key cell. The
2831    /// insertion-ordered set semantics of `IndexSet<usize>` are preserved
2832    /// exactly (dedup on insert, One -> Many promotion appends), but the
2833    /// singleton case — the overwhelmingly common one in real multigraphs —
2834    /// allocates NOTHING.
2835    #[derive(Debug, Clone)]
2836    pub enum CompactKeys {
2837        One(usize),
2838        Many(IndexSet<usize>),
2839    }
2840
2841    impl CompactKeys {
2842        /// Set-insert preserving insertion order; returns true if newly added.
2843        pub fn insert(&mut self, key: usize) -> bool {
2844            match self {
2845                Self::One(existing) => {
2846                    if *existing == key {
2847                        return false;
2848                    }
2849                    let mut set = IndexSet::with_capacity(2);
2850                    set.insert(*existing);
2851                    set.insert(key);
2852                    *self = Self::Many(set);
2853                    true
2854                }
2855                Self::Many(set) => set.insert(key),
2856            }
2857        }
2858
2859        #[cfg(test)]
2860        #[must_use]
2861        pub fn order(&self) -> Vec<usize> {
2862            match self {
2863                Self::One(key) => vec![*key],
2864                Self::Many(set) => set.iter().copied().collect(),
2865            }
2866        }
2867
2868        /// br-r37-c1-thp6w S27: number of parallel keys (never zero — a cell is
2869        /// dropped wholesale when its last key is removed).
2870        #[must_use]
2871        pub fn len(&self) -> usize {
2872            match self {
2873                Self::One(_) => 1,
2874                Self::Many(set) => set.len(),
2875            }
2876        }
2877
2878        /// br-r37-c1-thp6w S9: order-preserving key removal; returns
2879        /// (removed, cell_now_empty). A `Many` that shrinks to one entry stays
2880        /// `Many` — only iteration order is observable, not the variant.
2881        pub fn shift_remove(&mut self, key: usize) -> (bool, bool) {
2882            match self {
2883                Self::One(existing) => {
2884                    let removed = *existing == key;
2885                    (removed, removed)
2886                }
2887                Self::Many(set) => {
2888                    let removed = set.shift_remove(&key);
2889                    (removed, set.is_empty())
2890                }
2891            }
2892        }
2893    }
2894
2895    /// br-r37-c1-thp6w S6: singleton-optimized (pair -> key -> attrs) bucket,
2896    /// insertion-key-ordered like `IndexMap<usize, AttrMap>`; the singleton
2897    /// case stores the key + attrs inline with no map allocation (an empty
2898    /// `AttrMap`/BTreeMap does not allocate).
2899    #[derive(Debug, Clone)]
2900    pub enum CompactBucket {
2901        One(usize, AttrMap),
2902        Many(IndexMap<usize, AttrMap>),
2903    }
2904
2905    impl CompactBucket {
2906        /// `entry(key).or_default().extend(attrs)` analog; returns true if the
2907        /// (key) cell is new.
2908        pub fn merge(&mut self, key: usize, attrs: AttrMap) -> bool {
2909            match self {
2910                Self::One(existing, existing_attrs) => {
2911                    if *existing == key {
2912                        existing_attrs.extend(attrs);
2913                        return false;
2914                    }
2915                    let mut map = IndexMap::with_capacity(2);
2916                    map.insert(*existing, std::mem::take(existing_attrs));
2917                    map.insert(key, attrs);
2918                    *self = Self::Many(map);
2919                    true
2920                }
2921                Self::Many(map) => {
2922                    let is_new = !map.contains_key(&key);
2923                    map.entry(key).or_default().extend(attrs);
2924                    is_new
2925                }
2926            }
2927        }
2928
2929        #[must_use]
2930        pub fn key_order(&self) -> Vec<usize> {
2931            match self {
2932                Self::One(key, _) => vec![*key],
2933                Self::Many(map) => map.keys().copied().collect(),
2934            }
2935        }
2936
2937        #[must_use]
2938        pub fn attrs_for(&self, key: usize) -> Option<&AttrMap> {
2939            match self {
2940                Self::One(existing, attrs) => (*existing == key).then_some(attrs),
2941                Self::Many(map) => map.get(&key),
2942            }
2943        }
2944
2945        #[must_use]
2946        pub fn contains_key(&self, key: usize) -> bool {
2947            self.attrs_for(key).is_some()
2948        }
2949
2950        /// br-r37-c1-thp6w S22: OVERWRITE the attr map for an existing key
2951        /// (`merge` extends; this replaces). Returns true if the key was
2952        /// present. Mirror of one cell of `MultiGraph::replace_edge_attrs`.
2953        pub fn replace_attrs(&mut self, key: usize, attrs: AttrMap) -> bool {
2954            match self {
2955                Self::One(existing, existing_attrs) => {
2956                    if *existing == key {
2957                        *existing_attrs = attrs;
2958                        true
2959                    } else {
2960                        false
2961                    }
2962                }
2963                Self::Many(map) => {
2964                    if let Some(slot) = map.get_mut(&key) {
2965                        *slot = attrs;
2966                        true
2967                    } else {
2968                        false
2969                    }
2970                }
2971            }
2972        }
2973
2974        /// br-r37-c1-thp6w S9: number of parallel keys in the bucket.
2975        #[must_use]
2976        pub fn len(&self) -> usize {
2977            match self {
2978                Self::One(..) => 1,
2979                Self::Many(map) => map.len(),
2980            }
2981        }
2982
2983        #[must_use]
2984        pub fn is_empty(&self) -> bool {
2985            self.len() == 0
2986        }
2987
2988        /// br-r37-c1-thp6w S9: the LAST key in insertion order (the real
2989        /// `remove_edge(key=None)` default via `next_back`).
2990        #[must_use]
2991        pub fn last_key(&self) -> Option<usize> {
2992            match self {
2993                Self::One(key, _) => Some(*key),
2994                Self::Many(map) => map.keys().next_back().copied(),
2995            }
2996        }
2997
2998        /// br-r37-c1-thp6w S9: order-preserving key removal; returns
2999        /// (removed, bucket_now_empty).
3000        pub fn shift_remove(&mut self, key: usize) -> (bool, bool) {
3001            match self {
3002                Self::One(existing, _) => {
3003                    let removed = *existing == key;
3004                    (removed, removed)
3005                }
3006                Self::Many(map) => {
3007                    let removed = map.shift_remove(&key).is_some();
3008                    (removed, map.is_empty())
3009                }
3010            }
3011        }
3012
3013        /// br-r37-c1-thp6w S12: key-ordered (key, attrs) iteration without a
3014        /// per-bucket allocation (read-path hot loop).
3015        pub fn iter(&self) -> CompactBucketIter<'_> {
3016            match self {
3017                Self::One(key, attrs) => CompactBucketIter::One(std::iter::once((*key, attrs))),
3018                Self::Many(map) => CompactBucketIter::Many(map.iter()),
3019            }
3020        }
3021
3022        /// br-r37-c1-thp6w S33: zero-alloc parallel-key iterator yielding
3023        /// `&usize`, matching the String adjacency `IndexSet<usize>::iter` that
3024        /// backs `edge_keys_iter`.
3025        pub fn keys(&self) -> CompactBucketKeys<'_> {
3026            match self {
3027                Self::One(key, _) => CompactBucketKeys::One(Some(key)),
3028                Self::Many(map) => CompactBucketKeys::Many(map.keys()),
3029            }
3030        }
3031
3032        pub fn values(&self) -> CompactBucketValues<'_> {
3033            match self {
3034                Self::One(_, attrs) => CompactBucketValues::One(Some(attrs)),
3035                Self::Many(map) => CompactBucketValues::Many(map.values()),
3036            }
3037        }
3038    }
3039
3040    /// br-r37-c1-thp6w S12: enum iterator for `CompactBucket::iter` (no Box,
3041    /// no per-bucket allocation).
3042    pub enum CompactBucketIter<'a> {
3043        One(std::iter::Once<(usize, &'a AttrMap)>),
3044        Many(indexmap::map::Iter<'a, usize, AttrMap>),
3045    }
3046
3047    impl<'a> Iterator for CompactBucketIter<'a> {
3048        type Item = (usize, &'a AttrMap);
3049
3050        fn next(&mut self) -> Option<Self::Item> {
3051            match self {
3052                Self::One(it) => it.next(),
3053                Self::Many(it) => it.next().map(|(key, attrs)| (*key, attrs)),
3054            }
3055        }
3056    }
3057
3058    /// br-r37-c1-thp6w S33: zero-alloc `&usize` parallel-key iterator over a
3059    /// `CompactBucket` (One = the single key once, Many = the map's keys),
3060    /// matching `IndexSet<usize>::Iter` so `edge_keys_iter` keeps `Item=&usize`.
3061    pub enum CompactBucketKeys<'a> {
3062        One(Option<&'a usize>),
3063        Many(indexmap::map::Keys<'a, usize, AttrMap>),
3064    }
3065
3066    impl<'a> Iterator for CompactBucketKeys<'a> {
3067        type Item = &'a usize;
3068
3069        fn next(&mut self) -> Option<Self::Item> {
3070            match self {
3071                Self::One(opt) => opt.take(),
3072                Self::Many(keys) => keys.next(),
3073            }
3074        }
3075    }
3076
3077    pub enum CompactBucketValues<'a> {
3078        One(Option<&'a AttrMap>),
3079        Many(indexmap::map::Values<'a, usize, AttrMap>),
3080    }
3081
3082    impl<'a> Iterator for CompactBucketValues<'a> {
3083        type Item = &'a AttrMap;
3084
3085        fn next(&mut self) -> Option<Self::Item> {
3086            match self {
3087                Self::One(opt) => opt.take(),
3088                Self::Many(values) => values.next(),
3089            }
3090        }
3091    }
3092
3093    /// br-r37-c1-thp6w S6: the compact-bucket variant of the index-keyed
3094    /// layout — identical structure to `MgIntStorageProto` except both nested
3095    /// bucket levels use the singleton-optimized enums, so a simple-graph-like
3096    /// multigraph (no parallel edges) performs ZERO per-pair bucket
3097    /// allocations.
3098    #[cfg(test)]
3099    #[derive(Debug, Default)]
3100    pub struct MgIntStorageProtoCompact {
3101        pub node_names: Vec<String>,
3102        pub node_index: IndexMap<String, usize, rustc_hash::FxBuildHasher>,
3103        pub rows: Vec<IndexMap<usize, CompactKeys>>,
3104        pub edges: IndexMap<(usize, usize), CompactBucket, rustc_hash::FxBuildHasher>,
3105        pub edge_count: usize,
3106    }
3107
3108    #[cfg(test)]
3109    impl MgIntStorageProtoCompact {
3110        #[must_use]
3111        pub fn new() -> Self {
3112            Self::default()
3113        }
3114
3115        fn ensure_node(&mut self, name: &str) -> usize {
3116            if let Some(&idx) = self.node_index.get(name) {
3117                return idx;
3118            }
3119            let idx = self.node_names.len();
3120            self.node_names.push(name.to_owned());
3121            self.node_index.insert(name.to_owned(), idx);
3122            self.rows.push(IndexMap::new());
3123            idx
3124        }
3125
3126        /// Mirror of `MgIntStorageProto::add_keyed_edge` on compact buckets.
3127        pub fn add_keyed_edge(
3128            &mut self,
3129            left: &str,
3130            right: &str,
3131            key: usize,
3132            attrs: AttrMap,
3133        ) -> bool {
3134            let l = self.ensure_node(left);
3135            let r = if left == right {
3136                l
3137            } else {
3138                self.ensure_node(right)
3139            };
3140            let pair = (l.min(r), l.max(r));
3141            let is_new = match self.edges.entry(pair) {
3142                indexmap::map::Entry::Occupied(mut bucket) => bucket.get_mut().merge(key, attrs),
3143                indexmap::map::Entry::Vacant(slot) => {
3144                    slot.insert(CompactBucket::One(key, attrs));
3145                    true
3146                }
3147            };
3148            if is_new {
3149                self.edge_count += 1;
3150            }
3151            match self.rows[l].entry(r) {
3152                indexmap::map::Entry::Occupied(mut cell) => {
3153                    let _ = cell.get_mut().insert(key);
3154                }
3155                indexmap::map::Entry::Vacant(slot) => {
3156                    slot.insert(CompactKeys::One(key));
3157                }
3158            }
3159            if l != r {
3160                match self.rows[r].entry(l) {
3161                    indexmap::map::Entry::Occupied(mut cell) => {
3162                        let _ = cell.get_mut().insert(key);
3163                    }
3164                    indexmap::map::Entry::Vacant(slot) => {
3165                        slot.insert(CompactKeys::One(key));
3166                    }
3167                }
3168            }
3169            is_new
3170        }
3171
3172        /// Mirror of `MgIntStorageProto::bulk_load_int_prefix` on compact buckets.
3173        #[cfg(test)]
3174        #[must_use]
3175        pub fn bulk_load_int_prefix<I>(node_count: usize, edges: I) -> Self
3176        where
3177            I: IntoIterator<Item = (usize, usize, usize)>,
3178        {
3179            let iterator = edges.into_iter();
3180            let (lower_bound, _) = iterator.size_hint();
3181            let mut proto = Self::new();
3182            proto.node_names.reserve(node_count);
3183            proto.node_index.reserve(node_count);
3184            proto.rows.reserve(node_count);
3185            proto.edges.reserve(lower_bound);
3186            for node in 0..node_count {
3187                let name = node.to_string();
3188                proto.node_names.push(name.clone());
3189                proto.node_index.insert(name, node);
3190                proto.rows.push(IndexMap::new());
3191            }
3192            for (left_idx, right_idx, key) in iterator {
3193                debug_assert!(left_idx < node_count);
3194                debug_assert!(right_idx < node_count);
3195                let pair = (left_idx.min(right_idx), left_idx.max(right_idx));
3196                match proto.edges.entry(pair) {
3197                    indexmap::map::Entry::Occupied(mut bucket) => {
3198                        let _ = bucket.get_mut().merge(key, AttrMap::new());
3199                    }
3200                    indexmap::map::Entry::Vacant(slot) => {
3201                        slot.insert(CompactBucket::One(key, AttrMap::new()));
3202                    }
3203                }
3204                match proto.rows[left_idx].entry(right_idx) {
3205                    indexmap::map::Entry::Occupied(mut cell) => {
3206                        let _ = cell.get_mut().insert(key);
3207                    }
3208                    indexmap::map::Entry::Vacant(slot) => {
3209                        slot.insert(CompactKeys::One(key));
3210                    }
3211                }
3212                if left_idx != right_idx {
3213                    match proto.rows[right_idx].entry(left_idx) {
3214                        indexmap::map::Entry::Occupied(mut cell) => {
3215                            let _ = cell.get_mut().insert(key);
3216                        }
3217                        indexmap::map::Entry::Vacant(slot) => {
3218                            slot.insert(CompactKeys::One(key));
3219                        }
3220                    }
3221                }
3222                proto.edge_count += 1;
3223            }
3224            proto
3225        }
3226
3227        /// Distinct neighbors of node `i` as names, in row order.
3228        #[must_use]
3229        pub fn neighbor_names(&self, i: usize) -> Vec<&str> {
3230            self.rows[i]
3231                .keys()
3232                .map(|&j| self.node_names[j].as_str())
3233                .collect()
3234        }
3235
3236        /// Parallel-edge key order for the (left, right) pair.
3237        #[must_use]
3238        pub fn key_order(&self, left: &str, right: &str) -> Vec<usize> {
3239            let (Some(&l), Some(&r)) = (self.node_index.get(left), self.node_index.get(right))
3240            else {
3241                return Vec::new();
3242            };
3243            self.edges
3244                .get(&(l.min(r), l.max(r)))
3245                .map(CompactBucket::key_order)
3246                .unwrap_or_default()
3247        }
3248    }
3249
3250    /// br-r37-c1-thp6w S9: the slab/stable-slot layout MANDATED by the S8
3251    /// removal A/B (positional renumber measured 190-203x slower than the
3252    /// String store). Node identity = a STABLE SLOT that never renumbers;
3253    /// removal tombstones the slot (free-list reuse) and shift-removes only
3254    /// the order entry, so rows and edge pair-keys stay valid with ZERO
3255    /// rekeying. nx-observable insertion order lives entirely in `node_order`
3256    /// (name -> slot, insertion-ordered IndexMap — the same structure and
3257    /// O(V) shift_remove the production String store already pays). Buckets
3258    /// are the S6 compact enums (the winning construction layout).
3259    #[derive(Debug, Clone, Default)]
3260    pub struct MgSlabStorage {
3261        /// name -> stable slot, iteration = nx node insertion order.
3262        pub node_order: IndexMap<String, usize, rustc_hash::FxBuildHasher>,
3263        /// slot -> name (None = tombstone awaiting reuse/compaction).
3264        pub slot_names: Vec<Option<String>>,
3265        /// slot -> node attribute map (parallel to `slot_names`; empty for
3266        /// attr-less / edge-created nodes and for tombstoned slots).
3267        /// Slot-parallel node attributes. Empty for edge-created nodes and
3268        /// cleared when a slot is tombstoned.
3269        pub node_attrs: Vec<AttrMap>,
3270        /// LIFO free-list of tombstoned slots.
3271        pub free_slots: Vec<usize>,
3272        /// slot -> neighbor slot -> parallel-key cell (row insertion order).
3273        pub rows: Vec<IndexMap<usize, CompactKeys>>,
3274        /// slot-canonical (min, max) pair -> compact bucket. Slots are stable,
3275        /// so pair keys survive any removal without rekeying.
3276        pub edges: IndexMap<(usize, usize), CompactBucket, rustc_hash::FxBuildHasher>,
3277        pub edge_count: usize,
3278    }
3279
3280    impl MgSlabStorage {
3281        #[must_use]
3282        pub fn new() -> Self {
3283            Self::default()
3284        }
3285
3286        fn ensure_node(&mut self, name: &str) -> usize {
3287            if let Some(&slot) = self.node_order.get(name) {
3288                return slot;
3289            }
3290            let slot = if let Some(reused) = self.free_slots.pop() {
3291                debug_assert!(self.slot_names[reused].is_none());
3292                debug_assert!(self.rows[reused].is_empty());
3293                self.slot_names[reused] = Some(name.to_owned());
3294                self.node_attrs[reused] = AttrMap::new();
3295                reused
3296            } else {
3297                self.slot_names.push(Some(name.to_owned()));
3298                self.rows.push(IndexMap::new());
3299                self.node_attrs.push(AttrMap::new());
3300                self.slot_names.len() - 1
3301            };
3302            self.node_order.insert(name.to_owned(), slot);
3303            slot
3304        }
3305
3306        /// Same insert semantics as the S5/S6 variants, slot-keyed.
3307        pub fn add_keyed_edge(
3308            &mut self,
3309            left: &str,
3310            right: &str,
3311            key: usize,
3312            attrs: AttrMap,
3313        ) -> bool {
3314            let l = self.ensure_node(left);
3315            let r = if left == right {
3316                l
3317            } else {
3318                self.ensure_node(right)
3319            };
3320            self.add_keyed_edge_by_pair(l, r, key, attrs)
3321        }
3322
3323        /// br-r37-c1-4c29a: slot-keyed half of [`Self::add_keyed_edge`], for a
3324        /// caller that has already ensured both endpoints exist and knows their
3325        /// slots. Identical insert semantics — the only thing skipped is the
3326        /// `ensure_node` pair, which for such a caller is two redundant
3327        /// `String` hashes.
3328        pub fn add_keyed_edge_by_pair(
3329            &mut self,
3330            l: usize,
3331            r: usize,
3332            key: usize,
3333            attrs: AttrMap,
3334        ) -> bool {
3335            let pair = (l.min(r), l.max(r));
3336            let is_new = match self.edges.entry(pair) {
3337                indexmap::map::Entry::Occupied(mut bucket) => bucket.get_mut().merge(key, attrs),
3338                indexmap::map::Entry::Vacant(slot) => {
3339                    slot.insert(CompactBucket::One(key, attrs));
3340                    true
3341                }
3342            };
3343            if is_new {
3344                self.edge_count += 1;
3345            }
3346            match self.rows[l].entry(r) {
3347                indexmap::map::Entry::Occupied(mut cell) => {
3348                    let _ = cell.get_mut().insert(key);
3349                }
3350                indexmap::map::Entry::Vacant(slot) => {
3351                    slot.insert(CompactKeys::One(key));
3352                }
3353            }
3354            if l != r {
3355                match self.rows[r].entry(l) {
3356                    indexmap::map::Entry::Occupied(mut cell) => {
3357                        let _ = cell.get_mut().insert(key);
3358                    }
3359                    indexmap::map::Entry::Vacant(slot) => {
3360                        slot.insert(CompactKeys::One(key));
3361                    }
3362                }
3363            }
3364            is_new
3365        }
3366
3367        /// Bulk loader mirroring the S5/S6 int-prefix arms (fresh graph, slots
3368        /// == 0..n, no tombstones).
3369        #[cfg(test)]
3370        #[must_use]
3371        pub fn bulk_load_int_prefix<I>(node_count: usize, edges: I) -> Self
3372        where
3373            I: IntoIterator<Item = (usize, usize, usize)>,
3374        {
3375            let iterator = edges.into_iter();
3376            let (lower_bound, _) = iterator.size_hint();
3377            let mut proto = Self::new();
3378            proto.node_order.reserve(node_count);
3379            proto.slot_names.reserve(node_count);
3380            proto.rows.reserve(node_count);
3381            proto.edges.reserve(lower_bound);
3382            proto.node_attrs.reserve(node_count);
3383            for node in 0..node_count {
3384                let name = node.to_string();
3385                proto.node_order.insert(name.clone(), node);
3386                proto.slot_names.push(Some(name));
3387                proto.rows.push(IndexMap::new());
3388                proto.node_attrs.push(AttrMap::new());
3389            }
3390            for (left_slot, right_slot, key) in iterator {
3391                debug_assert!(left_slot < node_count);
3392                debug_assert!(right_slot < node_count);
3393                let pair = (left_slot.min(right_slot), left_slot.max(right_slot));
3394                match proto.edges.entry(pair) {
3395                    indexmap::map::Entry::Occupied(mut bucket) => {
3396                        let _ = bucket.get_mut().merge(key, AttrMap::new());
3397                    }
3398                    indexmap::map::Entry::Vacant(slot) => {
3399                        slot.insert(CompactBucket::One(key, AttrMap::new()));
3400                    }
3401                }
3402                match proto.rows[left_slot].entry(right_slot) {
3403                    indexmap::map::Entry::Occupied(mut cell) => {
3404                        let _ = cell.get_mut().insert(key);
3405                    }
3406                    indexmap::map::Entry::Vacant(slot) => {
3407                        slot.insert(CompactKeys::One(key));
3408                    }
3409                }
3410                if left_slot != right_slot {
3411                    match proto.rows[right_slot].entry(left_slot) {
3412                        indexmap::map::Entry::Occupied(mut cell) => {
3413                            let _ = cell.get_mut().insert(key);
3414                        }
3415                        indexmap::map::Entry::Vacant(slot) => {
3416                            slot.insert(CompactKeys::One(key));
3417                        }
3418                    }
3419                }
3420                proto.edge_count += 1;
3421            }
3422            proto
3423        }
3424
3425        /// Mirror of `MultiGraph::remove_edge` (key=None -> LAST bucket key).
3426        pub fn remove_edge(&mut self, left: &str, right: &str, key: Option<usize>) -> bool {
3427            let (Some(&l), Some(&r)) = (self.node_order.get(left), self.node_order.get(right))
3428            else {
3429                return false;
3430            };
3431            let pair = (l.min(r), l.max(r));
3432            let Some(bucket) = self.edges.get_mut(&pair) else {
3433                return false;
3434            };
3435            let Some(removal_key) = key.or_else(|| bucket.last_key()) else {
3436                return false;
3437            };
3438            let (removed, bucket_empty) = bucket.shift_remove(removal_key);
3439            if !removed {
3440                return false;
3441            }
3442            self.edge_count -= 1;
3443            if bucket_empty {
3444                self.edges.swap_remove(&pair);
3445            }
3446            let mut drop_cell = |u: usize, v: usize| {
3447                let mut cell_empty = false;
3448                if let Some(keys) = self.rows[u].get_mut(&v) {
3449                    let (cell_removed, now_empty) = keys.shift_remove(removal_key);
3450                    debug_assert!(cell_removed);
3451                    cell_empty = now_empty;
3452                }
3453                if cell_empty {
3454                    self.rows[u].shift_remove(&v);
3455                }
3456            };
3457            drop_cell(l, r);
3458            if l != r {
3459                drop_cell(r, l);
3460            }
3461            true
3462        }
3463
3464        /// Slab removal: O(V) order-entry shift + O(degree) row/bucket drops.
3465        /// NO renumber, NO edges rekey — the S8-mandated shape.
3466        pub fn remove_node(&mut self, name: &str) -> bool {
3467            let Some(&slot) = self.node_order.get(name) else {
3468                return false;
3469            };
3470            let neighbors: Vec<usize> = self.rows[slot].keys().copied().collect();
3471            for nbr in neighbors {
3472                if nbr != slot {
3473                    self.rows[nbr].shift_remove(&slot);
3474                }
3475                if let Some(bucket) = self.edges.swap_remove(&(slot.min(nbr), slot.max(nbr))) {
3476                    self.edge_count -= bucket.len();
3477                }
3478            }
3479            self.rows[slot] = IndexMap::new();
3480            self.node_order.shift_remove(name);
3481            self.slot_names[slot] = None;
3482            self.node_attrs[slot] = AttrMap::new();
3483            self.free_slots.push(slot);
3484            true
3485        }
3486
3487        /// `MultiGraph::add_node_with_attrs` semantics: ensure the node exists,
3488        /// then extend its attribute map.
3489        pub fn set_node_attrs(&mut self, name: &str, attrs: AttrMap) -> (bool, bool, usize) {
3490            let existed = self.node_order.contains_key(name);
3491            let changed = !existed
3492                || attrs.iter().any(|(key, value)| {
3493                    self.node_attrs_for(name).and_then(|a| a.get(key)) != Some(value)
3494                });
3495            let slot = self.ensure_node(name);
3496            self.node_attrs[slot].extend(attrs);
3497            (existed, changed, self.node_attrs[slot].len())
3498        }
3499
3500        /// Overwrite an existing node's attribute map. No-op if absent.
3501        pub fn replace_node_attrs(&mut self, name: &str, attrs: AttrMap) {
3502            if let Some(&slot) = self.node_order.get(name) {
3503                self.node_attrs[slot] = attrs;
3504            }
3505        }
3506
3507        /// Distinct neighbors (names, row order) of the node at insertion-order
3508        /// position `pos`.
3509        #[cfg(test)]
3510        #[must_use]
3511        pub fn neighbor_names(&self, pos: usize) -> Vec<&str> {
3512            let Some((_, &slot)) = self.node_order.get_index(pos) else {
3513                return Vec::new();
3514            };
3515            self.rows[slot]
3516                .keys()
3517                .map(|&s| {
3518                    self.slot_names[s]
3519                        .as_deref()
3520                        .expect("row references a live slot")
3521                })
3522                .collect()
3523        }
3524
3525        /// br-r37-c1-thp6w S26: name-keyed distinct neighbors in row order
3526        /// (mirror of `MultiGraph::neighbors`); `None` if the node is absent.
3527        #[must_use]
3528        pub fn neighbors_by_name(&self, name: &str) -> Option<Vec<&str>> {
3529            let &slot = self.node_order.get(name)?;
3530            Some(
3531                self.rows[slot]
3532                    .keys()
3533                    .map(|&s| {
3534                        self.slot_names[s]
3535                            .as_deref()
3536                            .expect("row references a live slot")
3537                    })
3538                    .collect(),
3539            )
3540        }
3541
3542        /// br-r37-c1-thp6w S27: node degree (mirror of `MultiGraph::degree`) —
3543        /// sum of per-neighbor parallel-edge counts, self-loops counted twice
3544        /// (nx convention). 0 if the node is absent.
3545        #[must_use]
3546        pub fn degree(&self, name: &str) -> usize {
3547            let Some(&slot) = self.node_order.get(name) else {
3548                return 0;
3549            };
3550            let mut deg = 0;
3551            for (&nbr_slot, keys) in &self.rows[slot] {
3552                let count = keys.len();
3553                if nbr_slot == slot {
3554                    deg += count * 2;
3555                } else {
3556                    deg += count;
3557                }
3558            }
3559            deg
3560        }
3561
3562        #[must_use]
3563        pub fn number_of_selfloops(&self) -> usize {
3564            self.edges
3565                .iter()
3566                .filter(|((left, right), _)| left == right)
3567                .map(|(_, bucket)| bucket.len())
3568                .sum()
3569        }
3570
3571        /// br-r37-c1-thp6w S28: node membership (mirror of `MultiGraph::has_node`).
3572        #[must_use]
3573        pub fn has_node(&self, name: &str) -> bool {
3574            self.node_order.contains_key(name)
3575        }
3576
3577        /// br-r37-c1-thp6w S28: edge membership (mirror of `MultiGraph::has_edge`)
3578        /// — true iff a non-empty (left, right) bucket exists.
3579        #[must_use]
3580        pub fn has_edge(&self, left: &str, right: &str) -> bool {
3581            let (Some(&l), Some(&r)) = (self.node_order.get(left), self.node_order.get(right))
3582            else {
3583                return false;
3584            };
3585            self.has_edge_by_pair(l, r)
3586        }
3587
3588        /// br-r37-c1-4c29a: the slot-keyed half of [`Self::has_edge`].
3589        ///
3590        /// The callgrind profile of the MultiGraph build put
3591        /// `IndexMap<String, usize>::get_index_of` at 17.39% — the largest
3592        /// entry, ahead of the allocator — because `add_edge_impl` re-resolved
3593        /// both endpoint strings inside every one of six storage calls. These
3594        /// `_by_pair` variants let a caller that has already resolved its
3595        /// endpoints skip the lookup, and the `&str` methods keep working by
3596        /// resolving once and delegating here, so there is a single
3597        /// implementation of the logic.
3598        #[must_use]
3599        pub fn has_edge_by_pair(&self, l: usize, r: usize) -> bool {
3600            self.edges
3601                .get(&(l.min(r), l.max(r)))
3602                .is_some_and(|bucket| !bucket.is_empty())
3603        }
3604
3605        /// Slot for a node name, or `None` when absent.
3606        #[must_use]
3607        pub fn node_slot(&self, name: &str) -> Option<usize> {
3608            self.node_order.get(name).copied()
3609        }
3610
3611        /// br-r37-c1-2ndmw: the stable SLOT of the node at an insertion-order
3612        /// POSITION, in O(1) and without hashing a node key.
3613        ///
3614        /// This is the missing conversion between the two index spaces. Callers
3615        /// hold POSITIONS (`get_node_index`, and the cached exact-string index
3616        /// on the Python side); the edge map is keyed by SLOTS. Feeding one to
3617        /// the other reports a real edge as ABSENT once a node has been removed
3618        /// -- see `multigraph_node_slot_and_position_are_distinct_index_spaces`.
3619        /// `node_order` maps name to slot in insertion order, so the slot at a
3620        /// position is just that entry's value.
3621        #[must_use]
3622        pub fn slot_at_position(&self, position: usize) -> Option<usize> {
3623            self.node_order.get_index(position).map(|(_, slot)| *slot)
3624        }
3625
3626        /// br-r37-c1-thp6w S30: node is isolated (mirror of
3627        /// `MultiGraph::is_isolate`) — present with an empty adjacency row.
3628        #[must_use]
3629        pub fn is_isolate(&self, name: &str) -> bool {
3630            self.node_order
3631                .get(name)
3632                .is_some_and(|&slot| self.rows[slot].is_empty())
3633        }
3634
3635        #[must_use]
3636        pub fn isolates(&self) -> Vec<String> {
3637            self.node_order
3638                .iter()
3639                .filter(|(_, slot)| self.rows[**slot].is_empty())
3640                .map(|(name, _)| name.clone())
3641                .collect()
3642        }
3643
3644        #[must_use]
3645        pub fn number_of_isolates(&self) -> usize {
3646            self.node_order
3647                .values()
3648                .filter(|&&slot| self.rows[slot].is_empty())
3649                .count()
3650        }
3651
3652        /// br-r37-c1-thp6w S31: integer weighted size (mirror of
3653        /// `MultiGraph::weighted_size_int`) — sum of the `weight` attr over
3654        /// every edge cell (int -> value, missing -> 1); `None` on any non-int
3655        /// weight. Order-independent (commutative sum; `None` iff any non-int).
3656        #[must_use]
3657        pub fn weighted_size_int(&self, weight: &str) -> Option<i128> {
3658            let mut total: i128 = 0;
3659            for bucket in self.edges.values() {
3660                for (_key, attrs) in bucket.iter() {
3661                    let value = match attrs.get(weight) {
3662                        Some(fnx_runtime::CgseValue::Int(v)) => i128::from(*v),
3663                        Some(_) => return None,
3664                        None => 1,
3665                    };
3666                    total = total.checked_add(value)?;
3667                }
3668            }
3669            Some(total)
3670        }
3671
3672        /// Parallel-edge key order for the (left, right) pair.
3673        #[must_use]
3674        pub fn key_order(&self, left: &str, right: &str) -> Vec<usize> {
3675            let (Some(&l), Some(&r)) = (self.node_order.get(left), self.node_order.get(right))
3676            else {
3677                return Vec::new();
3678            };
3679            self.edges
3680                .get(&(l.min(r), l.max(r)))
3681                .map(CompactBucket::key_order)
3682                .unwrap_or_default()
3683        }
3684
3685        #[must_use]
3686        pub fn edge_keys(&self, left: &str, right: &str) -> Option<Vec<usize>> {
3687            let (Some(&l), Some(&r)) = (self.node_order.get(left), self.node_order.get(right))
3688            else {
3689                return None;
3690            };
3691            self.edges
3692                .get(&(l.min(r), l.max(r)))
3693                .map(CompactBucket::key_order)
3694        }
3695
3696        pub fn edge_keys_iter(&self, left: &str, right: &str) -> Option<CompactBucketKeys<'_>> {
3697            let (Some(&l), Some(&r)) = (self.node_order.get(left), self.node_order.get(right))
3698            else {
3699                return None;
3700            };
3701            self.edges
3702                .get(&(l.min(r), l.max(r)))
3703                .map(CompactBucket::keys)
3704        }
3705
3706        pub fn edge_attr_values(&self, left: &str, right: &str) -> Option<CompactBucketValues<'_>> {
3707            let (Some(&l), Some(&r)) = (self.node_order.get(left), self.node_order.get(right))
3708            else {
3709                return None;
3710            };
3711            self.edges
3712                .get(&(l.min(r), l.max(r)))
3713                .map(CompactBucket::values)
3714        }
3715
3716        /// br-r37-c1-4c29a: slot-keyed edge-cell count. The `&str` wrapper this
3717        /// replaced had exactly one caller — `add_edge_impl`, which now holds
3718        /// the slots — so it was removed rather than left dead.
3719        #[must_use]
3720        pub fn edge_attr_count_by_pair(&self, l: usize, r: usize) -> usize {
3721            self.edges
3722                .get(&(l.min(r), l.max(r)))
3723                .map_or(0, CompactBucket::len)
3724        }
3725
3726        /// br-r37-c1-4c29a: slot-keyed auto-key selection. As with
3727        /// `edge_attr_count_by_pair`, the `&str` wrapper had a single caller
3728        /// that now holds the slots, so it was removed rather than left dead.
3729        #[must_use]
3730        pub fn next_edge_key_by_pair(&self, l: usize, r: usize) -> usize {
3731            let Some(bucket) = self.edges.get(&(l.min(r), l.max(r))) else {
3732                return 0;
3733            };
3734            let mut key = bucket.len();
3735            while bucket.contains_key(key) {
3736                key += 1;
3737            }
3738            key
3739        }
3740
3741        /// Overwrite the attribute map for one edge cell.
3742        pub fn replace_edge_attrs(
3743            &mut self,
3744            left: &str,
3745            right: &str,
3746            key: usize,
3747            attrs: AttrMap,
3748        ) -> bool {
3749            let (Some(&l), Some(&r)) = (self.node_order.get(left), self.node_order.get(right))
3750            else {
3751                return false;
3752            };
3753            self.edges
3754                .get_mut(&(l.min(r), l.max(r)))
3755                .is_some_and(|bucket| bucket.replace_attrs(key, attrs))
3756        }
3757
3758        /// Attributes of one `(left, right, key)` edge cell.
3759        #[must_use]
3760        pub fn edge_attrs(&self, left: &str, right: &str, key: usize) -> Option<&AttrMap> {
3761            let (Some(&l), Some(&r)) = (self.node_order.get(left), self.node_order.get(right))
3762            else {
3763                return None;
3764            };
3765            self.edge_attrs_by_pair(l, r, key)
3766        }
3767
3768        /// br-r37-c1-4c29a: slot-keyed half of [`Self::edge_attrs`].
3769        #[must_use]
3770        pub fn edge_attrs_by_pair(&self, l: usize, r: usize, key: usize) -> Option<&AttrMap> {
3771            self.edges
3772                .get(&(l.min(r), l.max(r)))
3773                .and_then(|bucket| bucket.attrs_for(key))
3774        }
3775
3776        /// The node's attribute map, or `None` when the node is absent.
3777        #[must_use]
3778        pub fn node_attrs_for(&self, name: &str) -> Option<&AttrMap> {
3779            let &slot = self.node_order.get(name)?;
3780            Some(&self.node_attrs[slot])
3781        }
3782
3783        #[must_use]
3784        pub fn get_node_index(&self, name: &str) -> Option<usize> {
3785            self.node_order.get_index_of(name)
3786        }
3787
3788        #[must_use]
3789        pub fn get_node_name(&self, index: usize) -> Option<&str> {
3790            self.node_order
3791                .get_index(index)
3792                .map(|(name, _)| name.as_str())
3793        }
3794
3795        #[must_use]
3796        pub fn node_index_matches_int(&self, index: usize) -> bool {
3797            self.node_order
3798                .get_index(index)
3799                .is_some_and(|(name, _)| name.parse::<usize>() == Ok(index))
3800        }
3801
3802        #[must_use]
3803        pub fn build_int_adjacency(&self) -> Vec<Vec<usize>> {
3804            let slot_to_position = self.slot_positions();
3805            self.node_order
3806                .values()
3807                .map(|&slot| {
3808                    self.rows[slot]
3809                        .keys()
3810                        .map(|&neighbor_slot| slot_to_position[neighbor_slot])
3811                        .collect()
3812                })
3813                .collect()
3814        }
3815
3816        /// br-r37-c1-thp6w S10: slot -> insertion-order position (usize::MAX
3817        /// for tombstones), rebuilt on demand in O(V).
3818        fn slot_positions(&self) -> Vec<usize> {
3819            let mut pos = vec![usize::MAX; self.slot_names.len()];
3820            for (p, (_, &slot)) in self.node_order.iter().enumerate() {
3821                pos[slot] = p;
3822            }
3823            pos
3824        }
3825
3826        /// br-r37-c1-thp6w S12: full `edges_ordered_borrowed` analog with
3827        /// attrs — the production read candidate. Same walk contract as
3828        /// `edges_ordered_names` (node order x row order x bucket key order,
3829        /// first-touch dedup, String-lex canonical emission orientation) but
3830        /// hashing only usizes where the String store hashes `EdgeKeyRef`
3831        /// String pairs per cell and per seen-set probe.
3832        #[must_use]
3833        pub fn edges_ordered_borrowed(&self) -> Vec<(&str, &str, usize, &AttrMap)> {
3834            let mut ordered = Vec::with_capacity(self.edge_count);
3835            let mut seen: std::collections::HashSet<(usize, usize, usize)> =
3836                std::collections::HashSet::with_capacity(self.edge_count);
3837            for (u_name, &u_slot) in &self.node_order {
3838                for &v_slot in self.rows[u_slot].keys() {
3839                    let pair = (u_slot.min(v_slot), u_slot.max(v_slot));
3840                    let Some(bucket) = self.edges.get(&pair) else {
3841                        continue;
3842                    };
3843                    for (key, attrs) in bucket.iter() {
3844                        if !seen.insert((pair.0, pair.1, key)) {
3845                            continue;
3846                        }
3847                        let v_name = self.slot_names[v_slot]
3848                            .as_deref()
3849                            .expect("row references a live slot");
3850                        let (left, right) = if u_name.as_str() <= v_name {
3851                            (u_name.as_str(), v_name)
3852                        } else {
3853                            (v_name, u_name.as_str())
3854                        };
3855                        ordered.push((left, right, key, attrs));
3856                    }
3857                }
3858            }
3859            ordered
3860        }
3861
3862        #[must_use]
3863        pub fn snapshot(&self, mode: CompatibilityMode) -> MultiGraphSnapshot {
3864            let node_attrs = self
3865                .node_order
3866                .iter()
3867                .filter_map(|(name, &slot)| {
3868                    let attrs = &self.node_attrs[slot];
3869                    (!attrs.is_empty()).then(|| (name.clone(), attrs.clone()))
3870                })
3871                .collect();
3872            let edges = self
3873                .edges_ordered_borrowed()
3874                .into_iter()
3875                .map(|(left, right, key, attrs)| MultiEdgeSnapshot {
3876                    left: left.to_owned(),
3877                    right: right.to_owned(),
3878                    key,
3879                    attrs: attrs.clone(),
3880                })
3881                .collect();
3882            MultiGraphSnapshot {
3883                mode,
3884                nodes: self.node_order.keys().cloned().collect(),
3885                node_attrs,
3886                edges,
3887            }
3888        }
3889
3890        /// br-r37-c1-thp6w S13: index-space analog of `edges_ordered_borrowed`
3891        /// — the same walk/order/orientation, but each endpoint emitted as its
3892        /// POSITION (node_order iteration index = nx node position), not its
3893        /// name. A `slot -> position` array is built once up front (O(n),
3894        /// hash-free — positions come straight from `node_order` iteration, so
3895        /// this is correct under any slot recycling, where slot != position),
3896        /// then the walk resolves both endpoints by O(1) array index. The only
3897        /// per-edge string op is the lex compare for the canonical orientation
3898        /// (production `edges_ordered_indices_borrowed` pays a `get_index_of`
3899        /// String hash per endpoint plus an `EdgeKey` pair hash instead).
3900        #[must_use]
3901        pub fn edges_ordered_indices_borrowed(&self) -> Vec<(usize, usize, usize, &AttrMap)> {
3902            let mut slot_to_pos = vec![0usize; self.slot_names.len()];
3903            for (pos, (_name, &slot)) in self.node_order.iter().enumerate() {
3904                slot_to_pos[slot] = pos;
3905            }
3906            let mut ordered = Vec::with_capacity(self.edge_count);
3907            let mut seen: std::collections::HashSet<(usize, usize, usize)> =
3908                std::collections::HashSet::with_capacity(self.edge_count);
3909            for (u_name, &u_slot) in &self.node_order {
3910                for &v_slot in self.rows[u_slot].keys() {
3911                    let pair = (u_slot.min(v_slot), u_slot.max(v_slot));
3912                    let Some(bucket) = self.edges.get(&pair) else {
3913                        continue;
3914                    };
3915                    for (key, attrs) in bucket.iter() {
3916                        if !seen.insert((pair.0, pair.1, key)) {
3917                            continue;
3918                        }
3919                        let v_name = self.slot_names[v_slot]
3920                            .as_deref()
3921                            .expect("row references a live slot");
3922                        let (left_pos, right_pos) = if u_name.as_str() <= v_name {
3923                            (slot_to_pos[u_slot], slot_to_pos[v_slot])
3924                        } else {
3925                            (slot_to_pos[v_slot], slot_to_pos[u_slot])
3926                        };
3927                        ordered.push((left_pos, right_pos, key, attrs));
3928                    }
3929                }
3930            }
3931            ordered
3932        }
3933
3934        /// br-r37-c1-thp6w S10: `edges_ordered_borrowed` analog — the ONLY
3935        /// observed edge order. Walk = node insertion order, row order, bucket
3936        /// key order; each (pair, key) emitted once (first touch); emitted
3937        /// orientation = STRING-LEX canonical (`EdgeKey` semantics), derived
3938        /// from the names at emission since the internal pair key is
3939        /// slot-canonical. This is the snapshot/pickle orientation gate.
3940        #[cfg(test)]
3941        #[must_use]
3942        pub fn edges_ordered_names(&self) -> Vec<(&str, &str, usize)> {
3943            let mut ordered = Vec::with_capacity(self.edge_count);
3944            let mut seen: std::collections::HashSet<(usize, usize, usize)> =
3945                std::collections::HashSet::with_capacity(self.edge_count);
3946            for (u_name, &u_slot) in &self.node_order {
3947                for &v_slot in self.rows[u_slot].keys() {
3948                    let pair = (u_slot.min(v_slot), u_slot.max(v_slot));
3949                    let Some(bucket) = self.edges.get(&pair) else {
3950                        continue;
3951                    };
3952                    for key in bucket.key_order() {
3953                        if !seen.insert((pair.0, pair.1, key)) {
3954                            continue;
3955                        }
3956                        let v_name = self.slot_names[v_slot]
3957                            .as_deref()
3958                            .expect("row references a live slot");
3959                        let (left, right) = if u_name.as_str() <= v_name {
3960                            (u_name.as_str(), v_name)
3961                        } else {
3962                            (v_name, u_name.as_str())
3963                        };
3964                        ordered.push((left, right, key));
3965                    }
3966                }
3967            }
3968            ordered
3969        }
3970
3971        pub fn clear_edges(&mut self) {
3972            self.edges.clear();
3973            for row in &mut self.rows {
3974                row.clear();
3975            }
3976            self.edge_count = 0;
3977        }
3978
3979        pub fn remove_nodes_from<'a, I>(&mut self, nodes: I) -> (usize, usize)
3980        where
3981            I: IntoIterator<Item = &'a str>,
3982        {
3983            let remove_slots: rustc_hash::FxHashSet<usize> = nodes
3984                .into_iter()
3985                .filter_map(|name| self.node_order.get(name).copied())
3986                .collect();
3987            if remove_slots.is_empty() {
3988                return (0, 0);
3989            }
3990
3991            let old_edge_count = self.edge_count;
3992            self.edges.retain(|(left, right), _| {
3993                !remove_slots.contains(left) && !remove_slots.contains(right)
3994            });
3995            self.edge_count = self.edges.values().map(CompactBucket::len).sum();
3996
3997            for (slot, row) in self.rows.iter_mut().enumerate() {
3998                if remove_slots.contains(&slot) {
3999                    row.clear();
4000                } else {
4001                    row.retain(|neighbor, _| !remove_slots.contains(neighbor));
4002                }
4003            }
4004            self.node_order
4005                .retain(|_, slot| !remove_slots.contains(slot));
4006            for &slot in &remove_slots {
4007                self.slot_names[slot] = None;
4008                self.node_attrs[slot].clear();
4009                self.free_slots.push(slot);
4010            }
4011            (
4012                remove_slots.len(),
4013                old_edge_count.saturating_sub(self.edge_count),
4014            )
4015        }
4016
4017        /// br-r37-c1-thp6w S10: `MultiGraph::reorder_rows_for_nx_copy_walk`
4018        /// under slot storage. Two-phase exactly like the real one: ALL new
4019        /// row orders are computed against the PRE-reorder rows (early =
4020        /// earlier-position neighbors sorted by (pos(v), index of u within
4021        /// row v), then late in original order), then applied. Sort key
4022        /// (pos, idx, slot) is equivalent to the real (pos, idx, name)
4023        /// because pos is unique per neighbor.
4024        pub fn reorder_rows_for_nx_copy_walk(&mut self) {
4025            let pos = self.slot_positions();
4026            let order_slots: Vec<usize> = self.node_order.values().copied().collect();
4027            let mut new_orders: Vec<(usize, Vec<usize>)> = Vec::with_capacity(order_slots.len());
4028            for &u_slot in &order_slots {
4029                let pu = pos[u_slot];
4030                let mut early: Vec<(usize, usize, usize)> = Vec::new();
4031                let mut late: Vec<usize> = Vec::new();
4032                for &v_slot in self.rows[u_slot].keys() {
4033                    let pv = pos[v_slot];
4034                    if pv < pu {
4035                        let idx = self.rows[v_slot]
4036                            .get_index_of(&u_slot)
4037                            .unwrap_or(usize::MAX);
4038                        early.push((pv, idx, v_slot));
4039                    } else {
4040                        late.push(v_slot);
4041                    }
4042                }
4043                early.sort_unstable();
4044                let mut order: Vec<usize> = early.into_iter().map(|(_, _, v)| v).collect();
4045                order.extend(late);
4046                new_orders.push((u_slot, order));
4047            }
4048            for (u_slot, order) in new_orders {
4049                let mut old = std::mem::take(&mut self.rows[u_slot]);
4050                let mut rebuilt = IndexMap::with_capacity(old.len());
4051                for v_slot in order {
4052                    if let Some(cell) = old.shift_remove(&v_slot) {
4053                        rebuilt.insert(v_slot, cell);
4054                    }
4055                }
4056                debug_assert!(old.is_empty(), "copy-walk reorder dropped a cell");
4057                self.rows[u_slot] = rebuilt;
4058            }
4059        }
4060
4061        /// Apply arbitrary name-keyed row orders to the slot-keyed rows, with
4062        /// requested neighbors first and untouched neighbors retaining order.
4063        pub fn apply_row_orders(&mut self, orders: &[(String, Vec<String>)]) {
4064            for (node, order) in orders {
4065                let Some(&slot) = self.node_order.get(node.as_str()) else {
4066                    continue;
4067                };
4068                let mut old = std::mem::take(&mut self.rows[slot]);
4069                let mut new_row = IndexMap::with_capacity(old.len());
4070                for v in order {
4071                    if let Some(&v_slot) = self.node_order.get(v.as_str())
4072                        && let Some((k, val)) = old.shift_remove_entry(&v_slot)
4073                    {
4074                        new_row.insert(k, val);
4075                    }
4076                }
4077                for (k, val) in old {
4078                    new_row.insert(k, val);
4079                }
4080                self.rows[slot] = new_row;
4081            }
4082        }
4083    }
4084}
4085
4086#[derive(Debug, Clone)]
4087pub struct MultiGraph {
4088    mode: CompatibilityMode,
4089    revision: u64,
4090    storage: multigraph_storage::MgSlabStorage,
4091    runtime_policy: RuntimePolicy,
4092    edge_count: usize,
4093    /// br-r37-c1-thp6w slice 1: lazy revision-keyed integer adjacency (see `IntAdjCache`).
4094    int_adj_cache: IntAdjCache,
4095}
4096
4097/// Neighbor-name iterator over a stable-slot integer row.
4098pub struct MgNeighborNames<'a> {
4099    rows: indexmap::map::Keys<'a, usize, multigraph_storage::CompactKeys>,
4100    slot_names: &'a [Option<String>],
4101}
4102
4103impl<'a> Iterator for MgNeighborNames<'a> {
4104    type Item = &'a str;
4105
4106    fn next(&mut self) -> Option<Self::Item> {
4107        self.rows.next().map(|&slot| {
4108            self.slot_names[slot]
4109                .as_deref()
4110                .expect("row references a live slot")
4111        })
4112    }
4113}
4114
4115/// Zero-allocation parallel-key iterator over a stable-slot edge bucket.
4116pub struct MgEdgeKeys<'a>(multigraph_storage::CompactBucketKeys<'a>);
4117
4118impl<'a> Iterator for MgEdgeKeys<'a> {
4119    type Item = &'a usize;
4120
4121    fn next(&mut self) -> Option<Self::Item> {
4122        self.0.next()
4123    }
4124}
4125
4126impl MultiGraph {
4127    /// br-r37-c1-7dpyg: structural clone with a FRESH RuntimePolicy —
4128    /// see Graph::clone_with_fresh_policy.
4129    #[must_use]
4130    pub fn clone_with_fresh_policy(&self) -> Self {
4131        Self {
4132            mode: self.mode,
4133            revision: self.revision,
4134            storage: self.storage.clone(),
4135            runtime_policy: RuntimePolicy::new(self.mode),
4136            edge_count: self.edge_count,
4137            int_adj_cache: IntAdjCache::default(),
4138        }
4139    }
4140
4141    /// br-r37-c1-s0d4x: reorder every adjacency row into NetworkX's
4142    /// `MultiGraph.copy()` walk order — the multigraph counterpart of
4143    /// Graph::reorder_rows_for_nx_copy_walk (cells move wholesale). A
4144    /// pair's cells enter both rows at the first u-major touch: row u =
4145    /// earlier-position neighbors sorted by (pos(v), index of u within
4146    /// row v), then the rest (self-loops included) in original order.
4147    pub fn reorder_rows_for_nx_copy_walk(&mut self) {
4148        self.storage.reorder_rows_for_nx_copy_walk();
4149        *self
4150            .int_adj_cache
4151            .0
4152            .get_mut()
4153            .expect("int_adj_cache poisoned") = None;
4154    }
4155
4156    /// br-r37-c1-u3qyn: restore explicit adjacency row orders (pickle
4157    /// round-trip) — see Graph::apply_row_orders. Multigraph rows are
4158    /// keyed cells (neighbor -> key set); the cells move wholesale.
4159    pub fn apply_row_orders(&mut self, orders: &[(String, Vec<String>)]) {
4160        self.storage.apply_row_orders(orders);
4161        // br-r37-c1-thp6w slice 1: a pure ROW-ORDER change (MultiGraph.copy walk order,
4162        // pickle round-trip) does NOT bump `revision`, so the revision-keyed int-adjacency
4163        // memo would otherwise serve stale row order. Drop it explicitly here — this is the
4164        // only adjacency mutator that changes order without a content (revision) bump.
4165        *self
4166            .int_adj_cache
4167            .0
4168            .get_mut()
4169            .expect("int_adj_cache poisoned") = None;
4170    }
4171
4172    #[must_use]
4173    pub fn new(mode: CompatibilityMode) -> Self {
4174        Self {
4175            mode,
4176            revision: 0,
4177            storage: multigraph_storage::MgSlabStorage::new(),
4178            runtime_policy: RuntimePolicy::new(mode),
4179            edge_count: 0,
4180            int_adj_cache: IntAdjCache::default(),
4181        }
4182    }
4183
4184    #[must_use]
4185    pub fn with_runtime_policy(runtime_policy: RuntimePolicy) -> Self {
4186        let mode = runtime_policy.mode();
4187        Self {
4188            mode,
4189            revision: 0,
4190            storage: multigraph_storage::MgSlabStorage::new(),
4191            runtime_policy,
4192            edge_count: 0,
4193            int_adj_cache: IntAdjCache::default(),
4194        }
4195    }
4196
4197    fn slab_store(&self) -> &multigraph_storage::MgSlabStorage {
4198        &self.storage
4199    }
4200
4201    fn slab_store_mut(&mut self) -> &mut multigraph_storage::MgSlabStorage {
4202        &mut self.storage
4203    }
4204
4205    #[must_use]
4206    pub fn strict() -> Self {
4207        Self::new(CompatibilityMode::Strict)
4208    }
4209
4210    #[must_use]
4211    pub fn hardened() -> Self {
4212        Self::new(CompatibilityMode::Hardened)
4213    }
4214
4215    #[must_use]
4216    pub fn mode(&self) -> CompatibilityMode {
4217        self.mode
4218    }
4219
4220    #[must_use]
4221    pub fn node_count(&self) -> usize {
4222        self.slab_store().node_order.len()
4223    }
4224
4225    #[must_use]
4226    pub fn edge_count(&self) -> usize {
4227        self.edge_count
4228    }
4229
4230    #[must_use]
4231    pub fn number_of_selfloops(&self) -> usize {
4232        self.slab_store().number_of_selfloops()
4233    }
4234
4235    /// Return edge keys as Vec (needed by Python bindings).
4236    #[must_use]
4237    pub fn edge_keys(&self, left: &str, right: &str) -> Option<Vec<usize>> {
4238        self.slab_store().edge_keys(left, right)
4239    }
4240
4241    /// Return an iterator over keys for edges between left and right.
4242    pub fn edge_keys_iter(&self, left: &str, right: &str) -> Option<MgEdgeKeys<'_>> {
4243        self.slab_store()
4244            .edge_keys_iter(left, right)
4245            .map(MgEdgeKeys)
4246    }
4247
4248    #[must_use]
4249    pub fn revision(&self) -> u64 {
4250        self.revision
4251    }
4252
4253    #[must_use]
4254    pub fn has_node(&self, node: &str) -> bool {
4255        self.slab_store().has_node(node)
4256    }
4257
4258    #[must_use]
4259    pub fn has_edge(&self, left: &str, right: &str) -> bool {
4260        self.slab_store().has_edge(left, right)
4261    }
4262
4263    /// br-r37-c1-04z53 (cc): resolve `has_edge` straight from insertion-order
4264    /// indices, skipping the `i.to_string()` heap alloc the Python binding pays
4265    /// for int nodes. The names come from the node table by index (borrowed, no
4266    /// alloc); the caller (`PyMultiGraph::has_edge`) guards each index with
4267    /// `node_index_matches_int` so the identity `index == int-value` holds
4268    /// (any removal / remap that broke it fails the guard and falls through to
4269    /// the String path). Mirror of `Graph::has_edge_by_indices`.
4270    ///
4271    /// br-r37-c1-2ndmw: THIS TAKES A POSITION AND THE EDGE MAP IS KEYED BY SLOT,
4272    /// which is the whole subtlety. It used to bridge the two spaces by
4273    /// resolving both positions back to NAMES and calling the string
4274    /// `has_edge`, which re-hashes both node keys -- so this was O(node key
4275    /// length) while `Graph::has_edge_by_indices` was a genuine O(1)
4276    /// `contains_key`, and every caller taking the "index fast path"
4277    /// (`PyMultiGraph::has_edge`, the edge-view probe, the algorithms bulk
4278    /// paths) still paid for the key length it was trying to avoid.
4279    ///
4280    /// `slot_at_position` bridges the same gap in O(1) with no hashing, and
4281    /// substitutes an EQUAL value rather than changing behaviour:
4282    /// `MgSlabStorage::has_edge(l, r)` is itself defined as `node_order.get(l)`
4283    /// and `node_order.get(r)` followed by `has_edge_by_pair`, and the slot
4284    /// `get(name)` returns for an entry is the slot `get_index(position)`
4285    /// returns for that same entry.
4286    ///
4287    /// What must NEVER happen is passing the position straight to
4288    /// `has_edge_by_pair`: the spaces coincide only until a node is removed,
4289    /// after which that reports a real edge as absent. Pinned by
4290    /// `multigraph_node_slot_and_position_are_distinct_index_spaces`, and the
4291    /// equivalence pinned by
4292    /// `multigraph_has_edge_by_indices_matches_the_string_path_across_removals`.
4293    #[must_use]
4294    pub fn has_edge_by_indices(&self, li: usize, ri: usize) -> bool {
4295        let store = self.slab_store();
4296        match (store.slot_at_position(li), store.slot_at_position(ri)) {
4297            (Some(left), Some(right)) => store.has_edge_by_pair(left, right),
4298            _ => false,
4299        }
4300    }
4301
4302    /// br-r37-c1-s8dj1: attributes of a KEYED edge, addressed by node POSITION.
4303    ///
4304    /// The keyed sibling of [`Self::has_edge_by_indices`], and it exists for the
4305    /// same reason: `PyMultiGraph::has_edge` had an O(1) path only while `key`
4306    /// was `None`, so `(u, v) in G.edges` -- which passes key 0 -- paid two
4307    /// canonicalisations and several node-key hashes and grew 3.6x from 2- to
4308    /// 2000-character keys while the keyless form stayed flat.
4309    ///
4310    /// Takes POSITIONS and converts them to SLOTS, exactly as the keyless
4311    /// sibling does. Passing a position straight to the slot-keyed store
4312    /// reports a real edge as absent after any node removal -- see
4313    /// `multigraph_node_slot_and_position_are_distinct_index_spaces`.
4314    #[must_use]
4315    pub fn edge_attrs_by_indices(&self, li: usize, ri: usize, key: usize) -> Option<&AttrMap> {
4316        let store = self.slab_store();
4317        match (store.slot_at_position(li), store.slot_at_position(ri)) {
4318            (Some(left), Some(right)) => store.edge_attrs_by_pair(left, right, key),
4319            _ => None,
4320        }
4321    }
4322
4323    #[must_use]
4324    pub fn nodes_ordered(&self) -> Vec<&str> {
4325        self.slab_store()
4326            .node_order
4327            .keys()
4328            .map(String::as_str)
4329            .collect()
4330    }
4331
4332    /// Resolve a node through the insertion-ordered node table without
4333    /// rebuilding a query-local name-to-index map.
4334    #[must_use]
4335    #[inline]
4336    pub fn get_node_index(&self, node: &str) -> Option<usize> {
4337        self.slab_store().get_node_index(node)
4338    }
4339
4340    /// Resolve the current node name for an insertion-order index.
4341    #[must_use]
4342    #[inline]
4343    pub fn get_node_name(&self, index: usize) -> Option<&str> {
4344        self.slab_store().get_node_name(index)
4345    }
4346
4347    /// br-cc-nbunchbulk: int membership fast path for `_nbunch_present` — see the
4348    /// simple-Graph accessor.
4349    #[must_use]
4350    pub fn node_index_matches_int(&self, idx: usize) -> bool {
4351        self.slab_store().node_index_matches_int(idx)
4352    }
4353
4354    #[must_use]
4355    pub fn neighbors(&self, node: &str) -> Option<Vec<&str>> {
4356        self.slab_store().neighbors_by_name(node)
4357    }
4358
4359    pub fn neighbors_iter(&self, node: &str) -> Option<MgNeighborNames<'_>> {
4360        let slab = self.slab_store();
4361        let &slot = slab.node_order.get(node)?;
4362        Some(MgNeighborNames {
4363            rows: slab.rows[slot].keys(),
4364            slot_names: &slab.slot_names,
4365        })
4366    }
4367
4368    /// br-r37-c1-thp6w slice 1: build integer adjacency from the authoritative stable-slot
4369    /// rows. Row `i` (node at insertion-index `i`) lists the node indices of that node's
4370    /// neighbors in adjacency-row order — i.e. exactly
4371    /// `[get_node_index(v) for v in neighbors_iter(node_i)]`. Distinct neighbors only
4372    /// (parallel-edge multiplicity lives in `edges`).
4373    fn build_int_adjacency(&self) -> Vec<Vec<usize>> {
4374        self.slab_store().build_int_adjacency()
4375    }
4376
4377    /// br-r37-c1-thp6w slice 1: run `f` with the integer adjacency (hash-free neighbor rows),
4378    /// built lazily from the stable-slot rows and memoized until the next mutation. The
4379    /// memo is keyed on `revision` (bumped by every content mutation) and cleared by
4380    /// `apply_row_orders` (the one order-only mutator), so the rows always match the current
4381    /// storage byte-for-byte. The closure form lets a whole traversal borrow the rows
4382    /// under one read lock without a per-node allocation. Foundation for the epoch's
4383    /// `neighbors_indices` traversal; no caller yet (infrastructure).
4384    pub fn with_int_adjacency<R>(&self, f: impl FnOnce(&[Vec<usize>]) -> R) -> R {
4385        {
4386            let guard = self.int_adj_cache.0.read().expect("int_adj_cache poisoned");
4387            if let Some((rev, adj)) = guard.as_ref()
4388                && *rev == self.revision
4389            {
4390                return f(adj);
4391            }
4392        }
4393        let adj = self.build_int_adjacency();
4394        let result = f(&adj);
4395        *self
4396            .int_adj_cache
4397            .0
4398            .write()
4399            .expect("int_adj_cache poisoned") = Some((self.revision, adj));
4400        result
4401    }
4402
4403    /// br-r37-c1-thp6w S4: advance a warm integer-adjacency memo across one
4404    /// single-edge mutation instead of invalidating it. The memo must still be
4405    /// keyed at `prev_revision` (the revision on entry to the mutating call);
4406    /// the delta mirrors the String-row edit exactly (new distinct neighbors
4407    /// append, emptied pairs shift-remove, new nodes push fresh rows), then the
4408    /// memo re-keys to the post-mutation revision. Every mutation path that
4409    /// does NOT call this leaves the memo keyed at a stale revision, so
4410    /// `with_int_adjacency` lazily rebuilds — unhandled sites stay exactly as
4411    /// safe as the invalidate-only scheme.
4412    fn advance_int_adj_memo(&mut self, prev_revision: u64, delta: IntAdjEdgeDelta<'_>) {
4413        let needs_indices = matches!(
4414            delta,
4415            IntAdjEdgeDelta::Add { pair_new: true, .. }
4416                | IntAdjEdgeDelta::Remove {
4417                    pair_gone: true,
4418                    ..
4419                }
4420        );
4421        let indices = if needs_indices {
4422            let (left, right) = match &delta {
4423                IntAdjEdgeDelta::Add { left, right, .. }
4424                | IntAdjEdgeDelta::Remove { left, right, .. } => (*left, *right),
4425            };
4426            match (
4427                self.storage.get_node_index(left),
4428                self.storage.get_node_index(right),
4429            ) {
4430                (Some(u), Some(v)) => Some((u, v)),
4431                _ => None,
4432            }
4433        } else {
4434            None
4435        };
4436        let Ok(slot) = self.int_adj_cache.0.get_mut() else {
4437            return;
4438        };
4439        if needs_indices && indices.is_none() {
4440            // Endpoint missing from the node table (should be unreachable for a
4441            // live edge): the delta cannot be mirrored, so drop the memo.
4442            *slot = None;
4443            return;
4444        }
4445        let Some((rev, rows)) = slot.as_mut() else {
4446            return;
4447        };
4448        if *rev != prev_revision {
4449            return;
4450        }
4451        let mut advanced = true;
4452        match delta {
4453            IntAdjEdgeDelta::Add {
4454                left,
4455                right,
4456                left_new,
4457                right_new,
4458                pair_new,
4459            } => {
4460                if left_new {
4461                    rows.push(Vec::new());
4462                }
4463                if right_new && left != right {
4464                    rows.push(Vec::new());
4465                }
4466                if pair_new && let Some((u, v)) = indices {
4467                    rows[u].push(v);
4468                    if u != v {
4469                        rows[v].push(u);
4470                    }
4471                }
4472            }
4473            IntAdjEdgeDelta::Remove { pair_gone, .. } => {
4474                if pair_gone && let Some((u, v)) = indices {
4475                    // Mirror `IndexMap::shift_remove` on the String row:
4476                    // order-preserving removal of the emptied neighbor cell.
4477                    if let Some(pu) = rows[u].iter().position(|&x| x == v) {
4478                        rows[u].remove(pu);
4479                        if u != v {
4480                            if let Some(pv) = rows[v].iter().position(|&x| x == u) {
4481                                rows[v].remove(pv);
4482                            } else {
4483                                advanced = false;
4484                            }
4485                        }
4486                    } else {
4487                        advanced = false;
4488                    }
4489                }
4490            }
4491        }
4492        if advanced {
4493            *rev = self.revision;
4494        } else {
4495            *slot = None;
4496        }
4497    }
4498
4499    #[must_use]
4500    pub fn edge_keys_vec(&self, left: &str, right: &str) -> Vec<usize> {
4501        self.slab_store().key_order(left, right)
4502    }
4503
4504    #[must_use]
4505    pub fn node_attrs(&self, node: &str) -> Option<&AttrMap> {
4506        self.slab_store().node_attrs_for(node)
4507    }
4508
4509    #[must_use]
4510    pub fn edge_attrs(&self, left: &str, right: &str, key: usize) -> Option<&AttrMap> {
4511        self.slab_store().edge_attrs(left, right, key)
4512    }
4513
4514    /// Iterator over the `AttrMap`s of every parallel edge between `left` and
4515    /// `right` (undirected: the single canonical bucket), yielded in the SAME
4516    /// order as `edge_keys` / `edge_keys_iter`. The adjacency `IndexSet<usize>`
4517    /// and this `edges` `IndexMap<usize, AttrMap>` are appended together on add
4518    /// and both `shift_remove`d on per-key remove (whole bucket dropped on
4519    /// node/endpoint removal), so their key order stays identical — callers that
4520    /// need adjacency-order iteration (e.g. the order-sensitive Neumaier weighted
4521    /// degree) may consume this directly instead of re-looking-up each key.
4522    /// Undirected sibling of `MultiDiGraph::edge_attr_values`.
4523    pub fn edge_attr_values(
4524        &self,
4525        left: &str,
4526        right: &str,
4527    ) -> Option<impl Iterator<Item = &AttrMap> + '_> {
4528        self.slab_store().edge_attr_values(left, right)
4529    }
4530
4531    #[must_use]
4532    pub fn evidence_ledger(&self) -> &EvidenceLedger {
4533        self.runtime_policy.decision_log()
4534    }
4535
4536    pub fn drain_decision_records(&mut self) -> Vec<DecisionRecord> {
4537        self.runtime_policy.drain_decision_records()
4538    }
4539
4540    #[must_use]
4541    pub fn runtime_policy(&self) -> &RuntimePolicy {
4542        &self.runtime_policy
4543    }
4544
4545    pub fn set_runtime_policy(&mut self, runtime_policy: RuntimePolicy) {
4546        self.mode = runtime_policy.mode();
4547        self.runtime_policy = runtime_policy;
4548    }
4549
4550    #[must_use]
4551    pub fn is_directed(&self) -> bool {
4552        false
4553    }
4554
4555    #[must_use]
4556    pub fn is_multigraph(&self) -> bool {
4557        true
4558    }
4559
4560    /// Return the degree of a node (total number of parallel edges incident).
4561    /// Self-loops contribute 2 to the degree each (NetworkX convention).
4562    #[must_use]
4563    pub fn degree(&self, node: &str) -> usize {
4564        self.slab_store().degree(node)
4565    }
4566
4567    /// br-r37-c1-mgisol (cc): native isolate detection for MultiGraph. A node
4568    /// is isolated iff it has no incident edges (empty/absent adjacency row);
4569    /// a self-loop keeps a node non-isolated (the loop records the node in its
4570    /// own row, matching nx's degree-2 self-loop convention). Yields nodes in
4571    /// insertion order — identical to the `G.degree()`-driven nx generator and
4572    /// to the old `multigraph_to_simple_graph` projection path, but WITHOUT the
4573    /// per-call O(V+E) simple-graph rebuild that dominated the binding.
4574    #[must_use]
4575    pub fn isolates(&self) -> Vec<String> {
4576        self.slab_store().isolates()
4577    }
4578
4579    /// br-r37-c1-mgisol (cc): isolate count without the simple-graph projection.
4580    #[must_use]
4581    pub fn number_of_isolates(&self) -> usize {
4582        self.slab_store().number_of_isolates()
4583    }
4584
4585    /// br-r37-c1-mgisol (cc): O(1) isolate predicate. Absent node -> false
4586    /// (mirrors `is_isolate(&Graph)`); the binding validates presence first.
4587    #[must_use]
4588    pub fn is_isolate(&self, node: &str) -> bool {
4589        self.slab_store().is_isolate(node)
4590    }
4591
4592    pub fn add_node(&mut self, node: impl AsRef<str>) -> bool {
4593        self.add_node_with_attrs(node, AttrMap::new())
4594    }
4595
4596    /// br-r37-c1-wa1b9: the storage half of [`Self::add_node_with_attrs`],
4597    /// without its ledger record. See the undirected `Graph` sibling.
4598    /// br-r37-c1-jc9e4: no owned key at all on this path. `set_node_attrs`
4599    /// already takes `&str` and owns internally only when it inserts, so the
4600    /// `let node = node.into();` this used to open with was allocating a
4601    /// `String` purely to hand out a borrow of it — waste on every call, not
4602    /// just the occupied ones.
4603    fn add_node_with_attrs_unrecorded(
4604        &mut self,
4605        node: impl AsRef<str>,
4606        attrs: AttrMap,
4607    ) -> (bool, bool, usize) {
4608        let (existed, changed, attrs_count) = self.storage.set_node_attrs(node.as_ref(), attrs);
4609        if changed {
4610            self.revision = self.revision.saturating_add(1);
4611        }
4612        (changed, existed, attrs_count)
4613    }
4614
4615    pub fn add_node_with_attrs(&mut self, node: impl AsRef<str>, attrs: AttrMap) -> bool {
4616        let (changed, existed, attrs_count) = self.add_node_with_attrs_unrecorded(node, attrs);
4617        self.record_decision(
4618            "add_node",
4619            0.0,
4620            false,
4621            vec![
4622                EvidenceTerm {
4623                    signal: "node_preexisting".into(),
4624                    observed_value: bool_evidence(existed),
4625                    log_likelihood_ratio: -3.0,
4626                },
4627                EvidenceTerm {
4628                    signal: "attrs_count".into(),
4629                    observed_value: count_evidence(attrs_count),
4630                    log_likelihood_ratio: -1.0,
4631                },
4632            ],
4633        );
4634        changed
4635    }
4636
4637    pub fn add_edge(
4638        &mut self,
4639        left: impl Into<String>,
4640        right: impl Into<String>,
4641    ) -> Result<usize, GraphError> {
4642        self.add_edge_impl(left, right, None, AttrMap::new())
4643    }
4644
4645    pub fn add_edge_with_attrs(
4646        &mut self,
4647        left: impl Into<String>,
4648        right: impl Into<String>,
4649        attrs: AttrMap,
4650    ) -> Result<usize, GraphError> {
4651        self.add_edge_impl(left, right, None, attrs)
4652    }
4653
4654    pub fn add_edge_with_key_and_attrs(
4655        &mut self,
4656        left: impl Into<String>,
4657        right: impl Into<String>,
4658        key: usize,
4659        attrs: AttrMap,
4660    ) -> Result<usize, GraphError> {
4661        self.add_edge_impl(left, right, Some(key), attrs)
4662    }
4663
4664    /// Insert one attribute-free edge for a pair known to have no existing
4665    /// parallel edge bucket. Returns `None` if the pair already exists.
4666    #[must_use]
4667    pub fn add_fresh_edge_unrecorded(
4668        &mut self,
4669        left: impl Into<String>,
4670        right: impl Into<String>,
4671    ) -> Option<usize> {
4672        self.add_fresh_edge_with_key_unrecorded(left, right, 0)
4673    }
4674
4675    /// Insert one attribute-free edge with an explicit key for a pair known to
4676    /// have no existing parallel edge bucket. Returns `None` if the pair
4677    /// already exists.
4678    #[must_use]
4679    pub fn add_fresh_edge_with_key_unrecorded(
4680        &mut self,
4681        left: impl Into<String>,
4682        right: impl Into<String>,
4683        key: usize,
4684    ) -> Option<usize> {
4685        let left = left.into();
4686        let right = right.into();
4687        self.storage.set_node_attrs(&left, AttrMap::new());
4688        if left != right {
4689            self.storage.set_node_attrs(&right, AttrMap::new());
4690        }
4691
4692        if self.storage.has_edge(&left, &right) {
4693            return None;
4694        }
4695        let inserted = self
4696            .storage
4697            .add_keyed_edge(&left, &right, key, AttrMap::new());
4698        debug_assert!(inserted, "fresh edge unexpectedly merged");
4699        self.edge_count += 1;
4700        self.revision = self.revision.saturating_add(1);
4701        Some(key)
4702    }
4703
4704    /// br-r37-c1-l5ve7: bulk node insert WITH attrs, one ledger record —
4705    /// MultiGraph mirror of MultiDiGraph::extend_nodes_with_attrs_unrecorded.
4706    /// Existing nodes merge attrs (extend).
4707    pub fn extend_nodes_with_attrs_unrecorded<I>(&mut self, nodes: I) -> usize
4708    where
4709        I: IntoIterator<Item = (String, AttrMap)>,
4710    {
4711        let mut inserted = 0usize;
4712        for (node, attrs) in nodes {
4713            let (existed, _, _) = self.storage.set_node_attrs(&node, attrs);
4714            if !existed {
4715                inserted += 1;
4716            }
4717        }
4718        if inserted > 0 {
4719            self.revision = self
4720                .revision
4721                .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
4722            self.record_decision(
4723                "extend_nodes_with_attrs_unrecorded",
4724                0.0,
4725                false,
4726                vec![EvidenceTerm {
4727                    signal: "batch_node_count".into(),
4728                    observed_value: count_evidence(inserted),
4729                    log_likelihood_ratio: -1.0,
4730                }],
4731            );
4732        }
4733        inserted
4734    }
4735
4736    /// br-r37-c1-l5ve7: bulk KEYED edge insert WITH attrs, one ledger
4737    /// record — MultiGraph mirror of
4738    /// MultiDiGraph::extend_keyed_edges_with_attrs_unrecorded
4739    /// (add_edge_impl pays TWO record_decision calls per edge). Endpoint
4740    /// nodes are created on demand; an existing (u, v, key) cell merges
4741    /// attrs (extend), matching add_edge_with_key_and_attrs.
4742    pub fn extend_keyed_edges_with_attrs_unrecorded<I>(&mut self, edges: I) -> usize
4743    where
4744        I: IntoIterator<Item = (String, String, usize, AttrMap)>,
4745    {
4746        let mut inserted = 0usize;
4747        for (left, right, key, attrs) in edges {
4748            if self.storage.add_keyed_edge(&left, &right, key, attrs) {
4749                self.edge_count += 1;
4750                inserted += 1;
4751            }
4752        }
4753        if inserted > 0 {
4754            self.revision = self
4755                .revision
4756                .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
4757            self.record_decision(
4758                "extend_keyed_edges_with_attrs_unrecorded",
4759                0.0,
4760                false,
4761                vec![EvidenceTerm {
4762                    signal: "batch_edge_count".into(),
4763                    observed_value: count_evidence(inserted),
4764                    log_likelihood_ratio: -1.0,
4765                }],
4766            );
4767        }
4768        inserted
4769    }
4770
4771    /// Bulk-load attribute-free keyed edges for a freshly-created integer
4772    /// prefix graph. The caller has already validated that node first-seen
4773    /// order is exactly `0..node_count` and that internal keys are sequential
4774    /// per undirected pair, so this builds the string-keyed MultiGraph state
4775    /// directly from indices instead of canonicalizing both endpoints for every
4776    /// edge in the Python boundary layer.
4777    #[must_use]
4778    pub fn extend_fresh_int_prefix_keyed_edges_unrecorded<I>(
4779        &mut self,
4780        node_count: usize,
4781        edges: I,
4782    ) -> usize
4783    where
4784        I: IntoIterator<Item = (usize, usize, usize)>,
4785    {
4786        debug_assert_eq!(self.node_count(), 0);
4787        debug_assert_eq!(self.edge_count(), 0);
4788
4789        let iterator = edges.into_iter();
4790        let (lower_bound, _) = iterator.size_hint();
4791
4792        let node_names = (0..node_count)
4793            .map(|node| node.to_string())
4794            .collect::<Vec<_>>();
4795        let mut fresh_slab = {
4796            use multigraph_storage::MgSlabStorage;
4797            let mut slab = MgSlabStorage::new();
4798            slab.node_order.reserve(node_count);
4799            slab.slot_names.reserve(node_count);
4800            slab.node_attrs.reserve(node_count);
4801            slab.rows.reserve(node_count);
4802            slab.edges.reserve(lower_bound);
4803            for (slot, name) in node_names.iter().enumerate() {
4804                slab.node_order.insert(name.clone(), slot);
4805                slab.slot_names.push(Some(name.clone()));
4806                slab.node_attrs.push(AttrMap::new());
4807                slab.rows.push(IndexMap::new());
4808            }
4809            slab
4810        };
4811
4812        let mut inserted = 0usize;
4813        for (left_idx, right_idx, key) in iterator {
4814            debug_assert!(left_idx < node_names.len());
4815            debug_assert!(right_idx < node_names.len());
4816            {
4817                use multigraph_storage::{CompactBucket, CompactKeys};
4818                let pair = (left_idx.min(right_idx), left_idx.max(right_idx));
4819                match fresh_slab.edges.entry(pair) {
4820                    indexmap::map::Entry::Occupied(mut bucket) => {
4821                        let _ = bucket.get_mut().merge(key, AttrMap::new());
4822                    }
4823                    indexmap::map::Entry::Vacant(slot) => {
4824                        slot.insert(CompactBucket::One(key, AttrMap::new()));
4825                    }
4826                }
4827                match fresh_slab.rows[left_idx].entry(right_idx) {
4828                    indexmap::map::Entry::Occupied(mut cell) => {
4829                        let _ = cell.get_mut().insert(key);
4830                    }
4831                    indexmap::map::Entry::Vacant(slot) => {
4832                        slot.insert(CompactKeys::One(key));
4833                    }
4834                }
4835                if left_idx != right_idx {
4836                    match fresh_slab.rows[right_idx].entry(left_idx) {
4837                        indexmap::map::Entry::Occupied(mut cell) => {
4838                            let _ = cell.get_mut().insert(key);
4839                        }
4840                        indexmap::map::Entry::Vacant(slot) => {
4841                            slot.insert(CompactKeys::One(key));
4842                        }
4843                    }
4844                }
4845                fresh_slab.edge_count += 1;
4846            }
4847
4848            inserted += 1;
4849        }
4850        self.edge_count = fresh_slab.edge_count;
4851        if inserted > 0 || node_count > 0 {
4852            self.revision = self.revision.saturating_add(
4853                u64::try_from(inserted.saturating_add(node_count)).unwrap_or(u64::MAX),
4854            );
4855            self.record_decision(
4856                "extend_fresh_int_prefix_keyed_edges_unrecorded",
4857                0.0,
4858                false,
4859                vec![EvidenceTerm {
4860                    signal: "batch_edge_count".into(),
4861                    observed_value: count_evidence(inserted),
4862                    log_likelihood_ratio: -1.0,
4863                }],
4864            );
4865        }
4866        debug_assert_eq!(fresh_slab.edge_count, self.edge_count);
4867        self.storage = fresh_slab;
4868        inserted
4869    }
4870
4871    /// Bulk-load attributed keyed edges for a freshly-created integer-indexed
4872    /// graph. The caller has already assigned NetworkX-compatible node labels
4873    /// and per-undirected-pair keys, so this avoids endpoint string hashing in
4874    /// the Python boundary layer while preserving the normal MultiGraph storage
4875    /// layout.
4876    #[must_use]
4877    pub fn extend_fresh_index_keyed_edges_with_attrs_unrecorded<I, N>(
4878        &mut self,
4879        nodes: N,
4880        edges: I,
4881    ) -> usize
4882    where
4883        I: IntoIterator<Item = (usize, usize, usize, AttrMap)>,
4884        N: IntoIterator<Item = String>,
4885    {
4886        if self.node_count() != 0 || self.edge_count() != 0 {
4887            return 0;
4888        }
4889
4890        let node_labels: Vec<String> = nodes.into_iter().collect();
4891        let mut fresh_slab = {
4892            use multigraph_storage::MgSlabStorage;
4893            let mut slab = MgSlabStorage::new();
4894            slab.node_order.reserve(node_labels.len());
4895            slab.slot_names.reserve(node_labels.len());
4896            slab.node_attrs.reserve(node_labels.len());
4897            slab.rows.reserve(node_labels.len());
4898            for (slot, name) in node_labels.iter().enumerate() {
4899                slab.node_order.insert(name.clone(), slot);
4900                slab.slot_names.push(Some(name.clone()));
4901                slab.node_attrs.push(AttrMap::new());
4902                slab.rows.push(IndexMap::new());
4903            }
4904            slab
4905        };
4906
4907        let node_count = node_labels.len();
4908        let mut inserted = 0usize;
4909        for (left_idx, right_idx, key, attrs) in edges {
4910            if node_labels.get(left_idx).is_none() {
4911                continue;
4912            }
4913            if node_labels.get(right_idx).is_none() {
4914                continue;
4915            }
4916            {
4917                use multigraph_storage::{CompactBucket, CompactKeys};
4918                let pair = (left_idx.min(right_idx), left_idx.max(right_idx));
4919                let is_new = match fresh_slab.edges.entry(pair) {
4920                    indexmap::map::Entry::Occupied(mut bucket) => {
4921                        bucket.get_mut().merge(key, attrs)
4922                    }
4923                    indexmap::map::Entry::Vacant(slot) => {
4924                        slot.insert(CompactBucket::One(key, attrs));
4925                        true
4926                    }
4927                };
4928                if is_new {
4929                    fresh_slab.edge_count += 1;
4930                    inserted += 1;
4931                }
4932                match fresh_slab.rows[left_idx].entry(right_idx) {
4933                    indexmap::map::Entry::Occupied(mut cell) => {
4934                        let _ = cell.get_mut().insert(key);
4935                    }
4936                    indexmap::map::Entry::Vacant(slot) => {
4937                        slot.insert(CompactKeys::One(key));
4938                    }
4939                }
4940                if left_idx != right_idx {
4941                    match fresh_slab.rows[right_idx].entry(left_idx) {
4942                        indexmap::map::Entry::Occupied(mut cell) => {
4943                            let _ = cell.get_mut().insert(key);
4944                        }
4945                        indexmap::map::Entry::Vacant(slot) => {
4946                            slot.insert(CompactKeys::One(key));
4947                        }
4948                    }
4949                }
4950            }
4951        }
4952
4953        self.edge_count = fresh_slab.edge_count;
4954        if node_count > 0 || inserted > 0 {
4955            self.revision = self.revision.saturating_add(
4956                u64::try_from(node_count.saturating_add(inserted)).unwrap_or(u64::MAX),
4957            );
4958            self.record_decision(
4959                "extend_fresh_index_keyed_edges_with_attrs_unrecorded",
4960                0.0,
4961                false,
4962                vec![
4963                    EvidenceTerm {
4964                        signal: "batch_node_count".into(),
4965                        observed_value: count_evidence(node_count),
4966                        log_likelihood_ratio: -1.0,
4967                    },
4968                    EvidenceTerm {
4969                        signal: "batch_edge_count".into(),
4970                        observed_value: count_evidence(inserted),
4971                        log_likelihood_ratio: -1.0,
4972                    },
4973                ],
4974            );
4975        }
4976        debug_assert_eq!(fresh_slab.edge_count, self.edge_count);
4977        self.storage = fresh_slab;
4978        inserted
4979    }
4980
4981    fn add_edge_impl(
4982        &mut self,
4983        left: impl Into<String>,
4984        right: impl Into<String>,
4985        explicit_key: Option<usize>,
4986        attrs: AttrMap,
4987    ) -> Result<usize, GraphError> {
4988        let left = left.into();
4989        let right = right.into();
4990        // br-r37-c1-thp6w S4: revision on entry — a warm int-adjacency memo
4991        // keyed here is advanced across this mutation instead of invalidated.
4992        let prev_revision = self.revision;
4993
4994        let unknown_feature = attrs
4995            .keys()
4996            .any(|key| key.starts_with("__fnx_incompatible"));
4997        let incompatibility_probability = if unknown_feature {
4998            1.0
4999        } else if left == right {
5000            0.22
5001        } else {
5002            0.08
5003        };
5004
5005        // br-r37-c1-eo88t: the gate decision earns a ledger record only when it
5006        // REFUSES. A refusal returns from here, so the outcome record at the end
5007        // of this function never runs and the gate record is the whole audit
5008        // trail for the rejected mutation. On the allow path the outcome record
5009        // carries the same operation/mode/action/incompatibility_probability
5010        // plus strictly richer evidence, so recording the gate as well wrote two
5011        // near-identical records for every edge on the hottest mutation path.
5012        let action = self
5013            .runtime_policy
5014            .action_for(incompatibility_probability, unknown_feature);
5015
5016        if action == DecisionAction::FailClosed || action == DecisionAction::FullValidate {
5017            self.record_decision(
5018                "add_edge",
5019                incompatibility_probability,
5020                unknown_feature,
5021                vec![EvidenceTerm {
5022                    signal: "unknown_incompatible_feature".into(),
5023                    observed_value: bool_evidence(unknown_feature),
5024                    log_likelihood_ratio: 12.0,
5025                }],
5026            );
5027            return Err(GraphError::FailClosed {
5028                operation: "add_edge",
5029                reason: "incompatible edge metadata".to_owned(),
5030            });
5031        }
5032
5033        // br-r37-c1-wa1b9: internal autocreation files no record of its own —
5034        // see the undirected `Graph` sibling for the ruling.
5035        let mut left_autocreated = false;
5036        if !self.has_node(&left) {
5037            let _ = self.add_node_with_attrs_unrecorded(left.clone(), AttrMap::new());
5038            left_autocreated = true;
5039        }
5040        let mut right_autocreated = false;
5041        if left == right {
5042            right_autocreated = left_autocreated;
5043        } else if !self.has_node(&right) {
5044            let _ = self.add_node_with_attrs_unrecorded(right.clone(), AttrMap::new());
5045            right_autocreated = true;
5046        }
5047
5048        // br-r37-c1-4c29a: resolve each endpoint to its slot ONCE. Both nodes
5049        // are guaranteed present by the autocreation above, and the six storage
5050        // calls below used to re-resolve BOTH endpoint strings independently —
5051        // `IndexMap<String, usize>::get_index_of` was 17.39% of the MultiGraph
5052        // build profile, the largest entry, ahead of the allocator. The
5053        // `_by_pair` variants take the slots and are otherwise identical.
5054        let left_slot = self
5055            .slab_store()
5056            .node_slot(&left)
5057            .expect("autocreated above");
5058        let right_slot = if left == right {
5059            left_slot
5060        } else {
5061            self.slab_store()
5062                .node_slot(&right)
5063                .expect("autocreated above")
5064        };
5065
5066        let key = explicit_key.unwrap_or_else(|| {
5067            self.slab_store()
5068                .next_edge_key_by_pair(left_slot, right_slot)
5069        });
5070        let pair_was_present = self.slab_store().has_edge_by_pair(left_slot, right_slot);
5071        let changed = self
5072            .slab_store()
5073            .edge_attrs_by_pair(left_slot, right_slot, key)
5074            .is_none_or(|edge_attrs| {
5075                attrs
5076                    .iter()
5077                    .any(|(attr_key, value)| edge_attrs.get(attr_key) != Some(value))
5078            });
5079        let inserted = self
5080            .slab_store_mut()
5081            .add_keyed_edge_by_pair(left_slot, right_slot, key, attrs);
5082        if inserted {
5083            self.edge_count += 1;
5084        }
5085        let edge_attr_count = self
5086            .slab_store()
5087            .edge_attr_count_by_pair(left_slot, right_slot);
5088        if changed {
5089            self.revision = self.revision.saturating_add(1);
5090        }
5091        if self.revision != prev_revision {
5092            self.advance_int_adj_memo(
5093                prev_revision,
5094                IntAdjEdgeDelta::Add {
5095                    left: &left,
5096                    right: &right,
5097                    left_new: left_autocreated,
5098                    right_new: right_autocreated,
5099                    pair_new: !pair_was_present,
5100                },
5101            );
5102        }
5103
5104        self.record_decision(
5105            "add_edge",
5106            incompatibility_probability,
5107            unknown_feature,
5108            vec![
5109                EvidenceTerm {
5110                    signal: "edge_key".into(),
5111                    observed_value: key.to_string().into(),
5112                    log_likelihood_ratio: -2.0,
5113                },
5114                EvidenceTerm {
5115                    signal: "edge_attr_count".into(),
5116                    observed_value: count_evidence(edge_attr_count),
5117                    log_likelihood_ratio: -2.0,
5118                },
5119                EvidenceTerm {
5120                    signal: "left_autocreated".into(),
5121                    observed_value: bool_evidence(left_autocreated),
5122                    log_likelihood_ratio: -1.25,
5123                },
5124                EvidenceTerm {
5125                    signal: "right_autocreated".into(),
5126                    observed_value: bool_evidence(right_autocreated),
5127                    log_likelihood_ratio: -1.25,
5128                },
5129            ],
5130        );
5131
5132        Ok(key)
5133    }
5134
5135    /// br-r37-c1-sjf4t: overwrite the attribute map for an existing
5136    /// (u, v, key) edge. Returns whether the edge existed.
5137    pub fn replace_edge_attrs(
5138        &mut self,
5139        left: &str,
5140        right: &str,
5141        key: usize,
5142        attrs: AttrMap,
5143    ) -> bool {
5144        let Some(existing) = self.slab_store().edge_attrs(left, right, key) else {
5145            return false;
5146        };
5147        let changed = *existing != attrs;
5148        if changed {
5149            let replaced = self
5150                .slab_store_mut()
5151                .replace_edge_attrs(left, right, key, attrs);
5152            debug_assert!(replaced, "slab edge disappeared during replacement");
5153            self.revision = self.revision.saturating_add(1);
5154        }
5155        true
5156    }
5157
5158    /// br-r37-c1-sjf4t: overwrite the attribute map for an existing node.
5159    pub fn replace_node_attrs(&mut self, node: &str, attrs: AttrMap) -> bool {
5160        let Some(existing) = self.slab_store().node_attrs_for(node) else {
5161            return false;
5162        };
5163        let changed = *existing != attrs;
5164        if changed {
5165            self.slab_store_mut().replace_node_attrs(node, attrs);
5166            self.revision = self.revision.saturating_add(1);
5167        }
5168        true
5169    }
5170
5171    pub fn remove_edge(&mut self, left: &str, right: &str, key: Option<usize>) -> bool {
5172        // br-r37-c1-thp6w S4: revision on entry — a warm int-adjacency memo
5173        // keyed here is advanced across this mutation instead of invalidated.
5174        let prev_revision = self.revision;
5175        let removal_key = key.or_else(|| {
5176            self.slab_store()
5177                .edge_keys_iter(left, right)
5178                .and_then(|keys| keys.last().copied())
5179        });
5180
5181        let Some(removal_key) = removal_key else {
5182            return false;
5183        };
5184
5185        if !self
5186            .slab_store_mut()
5187            .remove_edge(left, right, Some(removal_key))
5188        {
5189            return false;
5190        }
5191        self.edge_count -= 1;
5192        let pair_gone = !self.slab_store().has_edge(left, right);
5193        self.revision = self.revision.saturating_add(1);
5194        self.advance_int_adj_memo(
5195            prev_revision,
5196            IntAdjEdgeDelta::Remove {
5197                left,
5198                right,
5199                pair_gone,
5200            },
5201        );
5202        true
5203    }
5204
5205    pub fn clear_edges(&mut self) {
5206        if self.edge_count == 0 {
5207            return;
5208        }
5209
5210        self.slab_store_mut().clear_edges();
5211        self.edge_count = 0;
5212        self.revision = self.revision.saturating_add(1);
5213    }
5214
5215    pub fn remove_node(&mut self, node: &str) -> bool {
5216        if !self.has_node(node) {
5217            return false;
5218        }
5219
5220        let removed = self.slab_store_mut().remove_node(node);
5221        debug_assert!(removed, "node disappeared during remove_node");
5222        self.edge_count = self.slab_store().edge_count;
5223        self.revision = self.revision.saturating_add(1);
5224        true
5225    }
5226
5227    /// br-r37-c1-mgrnf: batch node removal — the amortised analogue of
5228    /// `remove_node`. `MultiGraph::remove_node` pays two O(|V|) `shift_remove`s
5229    /// (the `adjacency` and `nodes` IndexMaps preserve insertion order), so a
5230    /// caller loop of `k` removals was O(k·|V|). This does each pass ONCE via
5231    /// `retain` — O(|V|+|E|) total, matching the simple-`Graph` batch
5232    /// (`Graph::remove_nodes_from`) that the directed types already carry.
5233    /// Returns `(removed_node_count, removed_edge_instance_count)`.
5234    pub fn remove_nodes_from<'a, I>(&mut self, nodes: I) -> (usize, usize)
5235    where
5236        I: IntoIterator<Item = &'a str>,
5237    {
5238        let (removed_nodes, removed_edges) = self.slab_store_mut().remove_nodes_from(nodes);
5239        if removed_nodes == 0 {
5240            return (0, 0);
5241        }
5242        self.edge_count = self.slab_store().edge_count;
5243        self.revision = self
5244            .revision
5245            .saturating_add(u64::try_from(removed_nodes).unwrap_or(u64::MAX));
5246        (removed_nodes, removed_edges)
5247    }
5248
5249    #[must_use]
5250    pub fn edges_ordered(&self) -> Vec<MultiEdgeSnapshot> {
5251        self.slab_store()
5252            .edges_ordered_borrowed()
5253            .into_iter()
5254            .map(|(left, right, key, attrs)| MultiEdgeSnapshot {
5255                left: left.to_owned(),
5256                right: right.to_owned(),
5257                key,
5258                attrs: attrs.clone(),
5259            })
5260            .collect()
5261    }
5262
5263    #[must_use]
5264    pub fn edges_ordered_borrowed(&self) -> Vec<(&str, &str, usize, &AttrMap)> {
5265        self.slab_store().edges_ordered_borrowed()
5266    }
5267
5268    /// br-r37-c1-wsize (cc): integer `size(weight)` from the store — the
5269    /// multigraph twin of `Graph::weighted_size_int`. Each parallel edge is one
5270    /// bucket entry in `self.edges` and contributes its weight once (size halves
5271    /// degree's double count); a self-loop key is one stored edge counted once.
5272    /// Returns `None` on any non-integer weight (caller uses the exact
5273    /// float/PyObject degree path); missing weight defaults to nx's int `1`.
5274    #[must_use]
5275    pub fn weighted_size_int(&self, weight: &str) -> Option<i128> {
5276        self.slab_store().weighted_size_int(weight)
5277    }
5278
5279    #[must_use]
5280    pub fn edges_ordered_indices_borrowed(&self) -> Vec<(usize, usize, usize, &AttrMap)> {
5281        self.slab_store().edges_ordered_indices_borrowed()
5282    }
5283
5284    #[must_use]
5285    pub fn snapshot(&self) -> MultiGraphSnapshot {
5286        self.slab_store().snapshot(self.mode)
5287    }
5288
5289    fn record_decision(
5290        &mut self,
5291        operation: &'static str,
5292        incompatibility_probability: f64,
5293        unknown_incompatible_feature: bool,
5294        evidence: Vec<EvidenceTerm>,
5295    ) -> DecisionAction {
5296        let action = self
5297            .runtime_policy
5298            .action_for(incompatibility_probability, unknown_incompatible_feature);
5299        self.runtime_policy.record(
5300            operation,
5301            action,
5302            incompatibility_probability,
5303            "argmin expected loss over {allow,full_validate,fail_closed}",
5304            evidence,
5305        );
5306        action
5307    }
5308}
5309
5310#[cfg(test)]
5311mod tests {
5312    /// br-r37-c1-2ndmw: `has_edge_by_indices` must agree with the string path
5313    /// for EVERY pair, including after removals move the positions away from the
5314    /// slots.
5315    ///
5316    /// The implementation no longer resolves positions back to names; it
5317    /// converts position to slot and probes the slot-keyed edge map directly.
5318    /// That is only correct if the slot at a position is the slot the name at
5319    /// that position maps to, which is an IndexMap invariant rather than
5320    /// something this crate enforces — so it is asserted here exhaustively
5321    /// rather than assumed, against the string lookup as the oracle.
5322    #[test]
5323    fn multigraph_has_edge_by_indices_matches_the_string_path_across_removals() {
5324        let mut g = MultiGraph::new(CompatibilityMode::Strict);
5325        let names: Vec<String> = (0..8).map(|i| format!("n{i}")).collect();
5326        for name in &names {
5327            g.add_node(name.as_str());
5328        }
5329        for (a, b) in [(0, 1), (2, 3), (3, 5), (5, 7), (0, 7), (4, 4)] {
5330            g.add_edge(names[a].as_str(), names[b].as_str()).unwrap();
5331        }
5332
5333        // Removals chosen to shift positions out from under the slots, plus a
5334        // re-add so a reused slot is exercised too.
5335        for step in 0..4 {
5336            if step == 1 {
5337                g.remove_node("n0");
5338            }
5339            if step == 2 {
5340                g.remove_node("n4");
5341            }
5342            if step == 3 {
5343                g.add_node("n8");
5344                g.add_edge("n8", "n3").unwrap();
5345            }
5346            let live: Vec<String> = g.nodes_ordered().iter().map(|s| (*s).to_string()).collect();
5347            for (li, left) in live.iter().enumerate() {
5348                for (ri, right) in live.iter().enumerate() {
5349                    let by_index = g.has_edge_by_indices(li, ri);
5350                    let by_name = g.has_edge(left.as_str(), right.as_str());
5351                    assert_eq!(
5352                        by_index, by_name,
5353                        "step {step}: has_edge_by_indices({li}, {ri}) disagreed with \
5354                         has_edge({left:?}, {right:?})"
5355                    );
5356                }
5357            }
5358            // Out-of-range positions must answer false, not panic.
5359            let n = live.len();
5360            assert!(!g.has_edge_by_indices(n, 0));
5361            assert!(!g.has_edge_by_indices(0, n + 50));
5362        }
5363    }
5364
5365    /// br-r37-c1-2ndmw: THE SLOT AND THE POSITION ARE DIFFERENT INDEX SPACES,
5366    /// and confusing them is a silent wrong-answer bug rather than a type error.
5367    ///
5368    /// `MultiGraph::get_node_index` returns `node_order.get_index_of(name)` — a
5369    /// POSITION in the IndexMap. `MgSlabStorage::has_edge_by_pair` is keyed on
5370    /// `node_order.get(name)` — the stored SLOT. On a graph that has never had a
5371    /// node removed these are equal, which is precisely what makes the confusion
5372    /// dangerous: every fresh-graph test passes and the divergence only appears
5373    /// after a removal, as a `false` from an edge that exists.
5374    ///
5375    /// WHY THIS TEST EXISTS. `MultiGraph::has_edge_by_indices` reads like an
5376    /// O(1) index fast path — it is named like one and documented as a "Mirror
5377    /// of `Graph::has_edge_by_indices`", which genuinely is one
5378    /// (`edges.contains_key(&canon_pair)`). It is not. It resolves both indices
5379    /// back to NAMES and calls the string `has_edge`, so it is O(node key
5380    /// length). Anyone optimising the multigraph row probe will find the
5381    /// slot-keyed `has_edge_by_pair`, reach for a cached `get_node_index`, and
5382    /// ship a read that returns the wrong answer after any node removal. That
5383    /// was attempted on this pane and stopped by this test.
5384    ///
5385    /// The assertions below pin all three facts: the positions move, the
5386    /// position-keyed lookup is WRONG, and the slot-keyed lookup is RIGHT.
5387    #[test]
5388    fn multigraph_node_slot_and_position_are_distinct_index_spaces() {
5389        let mut g = MultiGraph::new(CompatibilityMode::Strict);
5390        for name in ["n0", "n1", "n2", "n3"] {
5391            g.add_node(name);
5392        }
5393        g.add_edge("n2", "n3").unwrap();
5394        assert!(g.has_edge("n2", "n3"));
5395
5396        // Before any removal the two spaces agree, which is the trap.
5397        let pos_before = (g.get_node_index("n2"), g.get_node_index("n3"));
5398        let slot_before = (
5399            g.slab_store().node_slot("n2"),
5400            g.slab_store().node_slot("n3"),
5401        );
5402        assert_eq!(
5403            pos_before, slot_before,
5404            "on a graph with no removals the position and the slot coincide — \
5405             this is why confusing them survives fresh-graph testing"
5406        );
5407
5408        g.remove_node("n0");
5409
5410        let (Some(p2), Some(p3)) = (g.get_node_index("n2"), g.get_node_index("n3")) else {
5411            panic!("nodes vanished after removing an unrelated node");
5412        };
5413        let (Some(s2), Some(s3)) = (
5414            g.slab_store().node_slot("n2"),
5415            g.slab_store().node_slot("n3"),
5416        ) else {
5417            panic!("slots vanished after removing an unrelated node");
5418        };
5419
5420        // The edge is untouched: only an unrelated node was removed.
5421        assert!(
5422            g.has_edge("n2", "n3"),
5423            "removing an unrelated node must not remove this edge"
5424        );
5425
5426        // The positions MOVED and the slots did not follow them.
5427        assert_ne!(
5428            (p2, p3),
5429            (s2, s3),
5430            "after a removal the position and slot spaces must diverge; if this \
5431             ever holds, re-derive whether the fast path below is now safe"
5432        );
5433
5434        // THE TRAP: position indices through the slot-keyed edge map are WRONG.
5435        assert!(
5436            !g.slab_store().has_edge_by_pair(p2, p3),
5437            "position indices happened to answer correctly here — that is luck, \
5438             not a contract; do not take it as licence to feed get_node_index() \
5439             into has_edge_by_pair()"
5440        );
5441
5442        // THE CORRECT SPACE: slots answer correctly.
5443        assert!(
5444            g.slab_store().has_edge_by_pair(s2, s3),
5445            "slot indices must agree with the string lookup"
5446        );
5447    }
5448
5449    /// br-r37-c1-f3i50: two helpers on the SAME struct use DIFFERENT index
5450    /// spaces, so you cannot infer the space from where a function lives.
5451    ///
5452    /// The test above pins that `MgSlabStorage::has_edge_by_pair` is SLOT-keyed.
5453    /// `MgSlabStorage::node_index_matches_int` sits in the same `impl`, is named
5454    /// in the same style, and is POSITION-keyed: it calls
5455    /// `node_order.get_index(index)`, which is the IndexMap POSITION, not the
5456    /// stored slot. So it composes correctly with `MultiGraph::get_node_index`
5457    /// and would be WRONG to pair with `has_edge_by_pair`.
5458    ///
5459    /// I got this backwards first: the helper delegates to `slab_store()`, which
5460    /// reads like slot territory, and I wrote the opposite assertion and watched
5461    /// it fail. Reading the body is the only way to know. That is exactly why
5462    /// this is pinned rather than left to the next reader's inference.
5463    ///
5464    /// SECOND FACT, and the one that bites: once a node is removed, name and
5465    /// position stop agreeing for EVERY surviving node, so this predicate goes
5466    /// uniformly false on a graph whose int nodes are all still present. A
5467    /// caller treating it as "is int N in the graph" would answer no for nodes
5468    /// that plainly exist.
5469    #[test]
5470    fn multigraph_node_index_matches_int_is_position_keyed_not_slot_keyed() {
5471        let mut g = MultiGraph::new(CompatibilityMode::Strict);
5472        for name in ["0", "1", "2", "3"] {
5473            g.add_node(name);
5474        }
5475
5476        // Fresh graph: name, position and slot all coincide — the trap state.
5477        for idx in 0..4 {
5478            assert!(
5479                g.node_index_matches_int(idx),
5480                "on a fresh graph int {idx} sits at position {idx}"
5481            );
5482            assert_eq!(g.get_node_index(&idx.to_string()), Some(idx));
5483            assert_eq!(g.slab_store().node_slot(&idx.to_string()), Some(idx));
5484        }
5485
5486        g.remove_node("0");
5487
5488        // Node "3" survives; its SLOT is unchanged and its POSITION has moved.
5489        let slot_3 = g.slab_store().node_slot("3").expect("node 3 survives");
5490        let pos_3 = g.get_node_index("3").expect("node 3 survives");
5491        assert_eq!(slot_3, 3, "slots are stable across removals");
5492        assert_eq!(pos_3, 2, "positions compact after a removal");
5493        assert_ne!(slot_3, pos_3, "the two spaces have diverged");
5494
5495        // POSITION-KEYED: asking with the out-of-range slot is simply absent,
5496        // which is what proves the argument is a position and not a slot — a
5497        // slot-keyed predicate would have answered for the live node 3.
5498        assert!(
5499            !g.node_index_matches_int(slot_3),
5500            "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"
5501        );
5502
5503        // And the predicate is now uniformly false, because no surviving name
5504        // equals its position — even though all three nodes are present.
5505        for idx in 0..3 {
5506            assert!(
5507                !g.node_index_matches_int(idx),
5508                "after the removal no name equals its position, so int {idx} must                  not report as sitting at position {idx}"
5509            );
5510            assert!(
5511                g.has_node(&(idx + 1).to_string()),
5512                "...while the node itself is still very much present"
5513            );
5514        }
5515    }
5516
5517    use super::{AttrMap, Graph, GraphError, MultiGraph};
5518    use fnx_runtime::{CgseValue, CompatibilityMode, DecisionAction, DecisionRecord};
5519    use proptest::prelude::*;
5520    use std::collections::BTreeSet;
5521
5522    /// br-r37-c1-addedgenewedge parity: the new_edge-gated adjacency push must never create a
5523    /// duplicate adjacency entry, even under duplicate edges / self-loops / edges added in both
5524    /// orientations. (The old `!adj_indices[..].contains(..)` guard prevented dups; the O(1)
5525    /// `new_edge` flag must too, since adj-membership ⟺ edge existence.)
5526    #[test]
5527    fn add_edge_newedge_no_duplicate_adjacency() {
5528        let mut g = Graph::strict();
5529        let _ = g.add_edge("h", "a");
5530        let _ = g.add_edge("h", "b");
5531        let _ = g.add_edge("h", "a"); // exact duplicate
5532        let _ = g.add_edge("a", "h"); // reversed duplicate
5533        let _ = g.add_edge("s", "s"); // self-loop
5534        let _ = g.add_edge("s", "s"); // duplicate self-loop
5535        let n = g.node_count();
5536        for i in 0..n {
5537            if let Some(row) = g.neighbors_indices(i) {
5538                let uniq: BTreeSet<usize> = row.iter().copied().collect();
5539                assert_eq!(
5540                    uniq.len(),
5541                    row.len(),
5542                    "node {i} has duplicate adjacency entries"
5543                );
5544            }
5545        }
5546        // Unique edges: h-a, h-b, s-s.
5547        assert_eq!(
5548            g.edge_count(),
5549            3,
5550            "duplicate/reversed adds must not create new edges"
5551        );
5552    }
5553
5554    /// br-r37-c1-addedgenewedge: paired-interleaved median A/B for the exact change — building a
5555    /// high-degree hub's adjacency row. The OLD code guarded each push with
5556    /// `adj_indices[hub].contains(&x)` (an O(row.len) linear rescan → O(n²) to build a star via
5557    /// add_edge); the NEW code pushes on the O(1) `new_edge` flag → O(n). This isolates the two
5558    /// changed lines; the rest of add_edge is O(1)/edge in both arms (so the full-function win is
5559    /// this, diluted by that per-edge overhead). `#[ignore]`; run with
5560    /// `cargo test --release -p fnx-classes --lib add_edge_newedge_ab -- --ignored --nocapture`.
5561    #[test]
5562    #[ignore = "measurement; run with --release --ignored --nocapture"]
5563    fn add_edge_newedge_ab() {
5564        use std::hint::black_box;
5565        use std::time::Instant;
5566
5567        let n = 40000usize;
5568        let time = |use_new: bool| -> f64 {
5569            let t0 = Instant::now();
5570            let mut row: Vec<usize> = Vec::with_capacity(n);
5571            for i in 0..n {
5572                if use_new || !row.contains(&i) {
5573                    row.push(i);
5574                }
5575            }
5576            black_box(&row);
5577            t0.elapsed().as_secs_f64()
5578        };
5579        for _ in 0..2 {
5580            black_box(time(true));
5581            black_box(time(false));
5582        }
5583        let median = |v: &[f64]| {
5584            let mut s = v.to_vec();
5585            s.sort_by(|a, b| a.partial_cmp(b).unwrap());
5586            s[s.len() / 2]
5587        };
5588        let rounds = 41usize;
5589        let paired = |cand: bool, base: bool| -> Vec<f64> {
5590            let mut v = Vec::with_capacity(rounds);
5591            for r in 0..rounds {
5592                let (tb, tc) = if r % 2 == 0 {
5593                    let b = time(base);
5594                    let c = time(cand);
5595                    (b, c)
5596                } else {
5597                    let c = time(cand);
5598                    let b = time(base);
5599                    (b, c)
5600                };
5601                v.push(tb / tc);
5602            }
5603            v
5604        };
5605        let report = |name: &str, ratios: &[f64]| {
5606            let wins = ratios.iter().filter(|&&r| r > 1.0).count();
5607            let mut sorted = ratios.to_vec();
5608            sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
5609            println!(
5610                "ADDEDGE_AB {name}: median={:.4}x win_rate={wins}/{rounds} p5_p95=[{:.4},{:.4}]",
5611                median(ratios),
5612                sorted[rounds * 5 / 100],
5613                sorted[rounds * 95 / 100],
5614            );
5615        };
5616        println!("ADDEDGE_AB adj-row push n={n} rounds={rounds} (>1 = new_edge-flag faster)");
5617        report("NEWEDGE_vs_contains", &paired(true, false));
5618        report("NULL_new_vs_new", &paired(true, true));
5619    }
5620
5621    /// br-r37-c1-degselfloopidx: parity + paired-interleaved A/B for `degree_by_index` — the O(1)
5622    /// `has_edge_by_indices(idx,idx)` self-loop check vs the pre-lever O(degree)
5623    /// `adj_indices[idx].contains(&idx)` rescan, over a degree histogram of a dense graph. Byte-exact
5624    /// parity (also vs the `&str` `degree`). `#[ignore]`; run with
5625    /// `cargo test --release -p fnx-classes --lib degree_by_index_selfloop_ab -- --ignored --nocapture`.
5626    #[test]
5627    #[ignore = "measurement; run with --release --ignored --nocapture"]
5628    fn degree_by_index_selfloop_ab() {
5629        use std::hint::black_box;
5630        use std::time::Instant;
5631
5632        let n = 20000usize;
5633        let deg = 20usize;
5634        let mut g = Graph::strict();
5635        for i in 0..n {
5636            let _ = g.add_node(format!("n{i}"));
5637        }
5638        for i in 0..n {
5639            for step in 1..=deg {
5640                let _ = g.add_edge(format!("n{i}"), format!("n{}", (i + step) % n));
5641            }
5642        }
5643        for i in (0..n).step_by(1000) {
5644            let _ = g.add_edge(format!("n{i}"), format!("n{i}"));
5645        }
5646
5647        // Byte-exact parity: new == old (row.contains) == the &str degree, incl. self-loop nodes.
5648        for idx in 0..g.node_count() {
5649            let new = g.degree_by_index(idx);
5650            let old = g.adj_indices[idx].len() + usize::from(g.adj_indices[idx].contains(&idx));
5651            assert_eq!(new, old, "degree_by_index parity vs row.contains at {idx}");
5652            assert_eq!(
5653                new,
5654                g.degree(&format!("n{idx}")),
5655                "degree_by_index parity vs &str degree at {idx}"
5656            );
5657        }
5658
5659        let time = |use_new: bool| -> f64 {
5660            let t0 = Instant::now();
5661            let mut sum = 0usize;
5662            for idx in 0..g.node_count() {
5663                sum += if use_new {
5664                    g.degree_by_index(idx)
5665                } else {
5666                    g.adj_indices[idx].len() + usize::from(g.adj_indices[idx].contains(&idx))
5667                };
5668            }
5669            black_box(sum);
5670            t0.elapsed().as_secs_f64()
5671        };
5672        for _ in 0..3 {
5673            black_box(time(true));
5674            black_box(time(false));
5675        }
5676        let median = |v: &[f64]| {
5677            let mut s = v.to_vec();
5678            s.sort_by(|a, b| a.partial_cmp(b).unwrap());
5679            s[s.len() / 2]
5680        };
5681        let rounds = 41usize;
5682        let paired = |cand: bool, base: bool| -> Vec<f64> {
5683            let mut v = Vec::with_capacity(rounds);
5684            for r in 0..rounds {
5685                let (tb, tc) = if r % 2 == 0 {
5686                    let b = time(base);
5687                    let c = time(cand);
5688                    (b, c)
5689                } else {
5690                    let c = time(cand);
5691                    let b = time(base);
5692                    (b, c)
5693                };
5694                v.push(tb / tc);
5695            }
5696            v
5697        };
5698        let report = |name: &str, ratios: &[f64]| {
5699            let wins = ratios.iter().filter(|&&r| r > 1.0).count();
5700            let mut sorted = ratios.to_vec();
5701            sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
5702            println!(
5703                "DEGIDX_AB {name}: median={:.4}x win_rate={wins}/{rounds} p5_p95=[{:.4},{:.4}]",
5704                median(ratios),
5705                sorted[rounds * 5 / 100],
5706                sorted[rounds * 95 / 100],
5707            );
5708        };
5709        println!(
5710            "DEGIDX_AB degree histogram n={n} deg={deg} rounds={rounds} (>1 = O(1) has_edge faster)"
5711        );
5712        report("HASEDGE_vs_contains", &paired(true, false));
5713        report("NULL_new_vs_new", &paired(true, true));
5714    }
5715
5716    /// br-r37-c1-isoedgecount: paired-interleaved A/B for the graph-isomorphism early-out edge
5717    /// count check. `undirected_isomorphism_mappings` / `directed_isomorphism_mappings` (fnx-python,
5718    /// reached by public `is_isomorphic`) compared edge COUNTS via `edges_ordered().len()`, which
5719    /// materialises a full `Vec<EdgeSnapshot>` (two owned Strings per edge) only to read its length;
5720    /// the O(1) `edge_count()` (== `self.edges.len()`) is byte-identical. This times the exact
5721    /// early-out compare on the non-isomorphic same-node / different-edge case (where the check is
5722    /// the dominant cost). Byte-exact parity asserted (`edge_count() == edges_ordered().len()`).
5723    /// `#[ignore]`; run with
5724    /// `cargo test --release -p fnx-classes --lib iso_edge_count_ab -- --ignored --nocapture`.
5725    #[test]
5726    #[ignore = "measurement; run with --release --ignored --nocapture"]
5727    fn iso_edge_count_ab() {
5728        use std::hint::black_box;
5729        use std::time::Instant;
5730
5731        let n = 4000usize;
5732        let deg = 25usize;
5733        let build = |steps: usize| -> Graph {
5734            let mut g = Graph::strict();
5735            for i in 0..n {
5736                let _ = g.add_node(format!("n{i}"));
5737            }
5738            for i in 0..n {
5739                for step in 1..=steps {
5740                    let _ = g.add_edge(format!("n{i}"), format!("n{}", (i + step) % n));
5741                }
5742            }
5743            g
5744        };
5745        // Same node count, DIFFERENT edge count → the early-out returns at the edge-count compare.
5746        let g1 = build(deg);
5747        let g2 = build(deg - 1);
5748
5749        // Byte-exact parity: edge_count() must equal edges_ordered().len() for both graphs.
5750        assert_eq!(
5751            g1.edge_count(),
5752            g1.edges_ordered().len(),
5753            "edge_count parity g1"
5754        );
5755        assert_eq!(
5756            g2.edge_count(),
5757            g2.edges_ordered().len(),
5758            "edge_count parity g2"
5759        );
5760        assert_ne!(
5761            g1.edge_count(),
5762            g2.edge_count(),
5763            "workload must exercise the differing-count early-out"
5764        );
5765
5766        let time = |use_new: bool| -> f64 {
5767            let t0 = Instant::now();
5768            let mut acc = 0usize;
5769            for _ in 0..8 {
5770                let differ = if use_new {
5771                    g1.edge_count() != g2.edge_count()
5772                } else {
5773                    g1.edges_ordered().len() != g2.edges_ordered().len()
5774                };
5775                acc += usize::from(differ);
5776            }
5777            black_box(acc);
5778            t0.elapsed().as_secs_f64()
5779        };
5780        for _ in 0..3 {
5781            black_box(time(true));
5782            black_box(time(false));
5783        }
5784        let median = |v: &[f64]| {
5785            let mut s = v.to_vec();
5786            s.sort_by(|a, b| a.partial_cmp(b).unwrap());
5787            s[s.len() / 2]
5788        };
5789        let rounds = 41usize;
5790        let paired = |cand: bool, base: bool| -> Vec<f64> {
5791            let mut v = Vec::with_capacity(rounds);
5792            for r in 0..rounds {
5793                let (tb, tc) = if r % 2 == 0 {
5794                    let b = time(base);
5795                    let c = time(cand);
5796                    (b, c)
5797                } else {
5798                    let c = time(cand);
5799                    let b = time(base);
5800                    (b, c)
5801                };
5802                v.push(tb / tc);
5803            }
5804            v
5805        };
5806        let report = |name: &str, ratios: &[f64]| {
5807            let wins = ratios.iter().filter(|&&r| r > 1.0).count();
5808            let mut sorted = ratios.to_vec();
5809            sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
5810            println!(
5811                "ISOEDGE_AB {name}: median={:.4}x win_rate={wins}/{rounds} p5_p95=[{:.4},{:.4}]",
5812                median(ratios),
5813                sorted[rounds * 5 / 100],
5814                sorted[rounds * 95 / 100],
5815            );
5816        };
5817        println!(
5818            "ISOEDGE_AB same-node/different-edge early-out n={n} deg={deg} rounds={rounds} (>1 = edge_count faster)"
5819        );
5820        report("EDGECOUNT_vs_edgesordered", &paired(true, false));
5821        report("NULL_new_vs_new", &paired(true, true));
5822    }
5823
5824    /// br-r37-c1-selfloopidx: paired-interleaved A/B for the `nodes_with_selfloops` per-node self-loop
5825    /// probe (feeds `number_of_selfloops` / `selfloop_edges` / `nodes_with_selfloops`, many callers).
5826    /// The kernel checked `has_edge(node, node)` — resolving BOTH `&str` endpoints via
5827    /// `edge_pair_key` (two String-hash lookups per node) — vs the index probe
5828    /// `has_edge_by_indices(i, i)` (a direct integer `canon_pair` + `contains_key`, no String
5829    /// resolution). Byte-exact parity asserted (same self-loop node set). `#[ignore]`; run with
5830    /// `cargo test --release -p fnx-classes --lib nodes_selfloop_idx_ab -- --ignored --nocapture`.
5831    #[test]
5832    #[ignore = "measurement; run with --release --ignored --nocapture"]
5833    fn nodes_selfloop_idx_ab() {
5834        use std::hint::black_box;
5835        use std::time::Instant;
5836
5837        // 40k nodes, ring-of-chords, NO self-loops (the common case: every node is probed and misses).
5838        let n = 40_000usize;
5839        let deg = 10usize;
5840        let mut g = Graph::strict();
5841        for i in 0..n {
5842            let _ = g.add_node(i.to_string());
5843        }
5844        for i in 0..n {
5845            for step in 1..=deg {
5846                let _ = g.add_edge(i.to_string(), ((i + step) % n).to_string());
5847            }
5848        }
5849
5850        let names = g.nodes_ordered();
5851        let old_scan =
5852            |g: &Graph| -> usize { names.iter().filter(|&&node| g.has_edge(node, node)).count() };
5853        let new_scan = |g: &Graph| -> usize {
5854            (0..names.len())
5855                .filter(|&i| g.has_edge_by_indices(i, i))
5856                .count()
5857        };
5858        assert_eq!(
5859            old_scan(&g),
5860            new_scan(&g),
5861            "nodes_with_selfloops parity (self-loop count)"
5862        );
5863
5864        let time = |cand: bool| -> f64 {
5865            let t0 = Instant::now();
5866            let c = if cand { new_scan(&g) } else { old_scan(&g) };
5867            black_box(c);
5868            t0.elapsed().as_secs_f64()
5869        };
5870        for _ in 0..3 {
5871            black_box(time(true));
5872            black_box(time(false));
5873        }
5874        let median = |v: &[f64]| {
5875            let mut s = v.to_vec();
5876            s.sort_by(|a, b| a.partial_cmp(b).unwrap());
5877            s[s.len() / 2]
5878        };
5879        let rounds = 61usize;
5880        let paired = |cand: bool, base: bool| -> Vec<f64> {
5881            let mut v = Vec::with_capacity(rounds);
5882            for r in 0..rounds {
5883                let (tb, tc) = if r % 2 == 0 {
5884                    let b = time(base);
5885                    let c = time(cand);
5886                    (b, c)
5887                } else {
5888                    let c = time(cand);
5889                    let b = time(base);
5890                    (b, c)
5891                };
5892                v.push(tb / tc);
5893            }
5894            v
5895        };
5896        let report = |name: &str, ratios: &[f64]| {
5897            let wins = ratios.iter().filter(|&&r| r > 1.0).count();
5898            let mut sorted = ratios.to_vec();
5899            sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
5900            println!(
5901                "SELFLOOPIDX_AB {name}: median={:.4}x win_rate={wins}/{rounds} p5_p95=[{:.4},{:.4}]",
5902                median(ratios),
5903                sorted[rounds * 5 / 100],
5904                sorted[rounds * 95 / 100],
5905            );
5906        };
5907        println!(
5908            "SELFLOOPIDX_AB nodes_with_selfloops n={n} deg={deg} rounds={rounds} (>1 = index probe faster)"
5909        );
5910        report("HASEDGEIDX_vs_hasedge", &paired(true, false));
5911        report("NULL_new_vs_new", &paired(true, true));
5912    }
5913
5914    /// br-r37-c1-numselfidx: paired-interleaved A/B for `number_of_selfloops` (feeds the is_planar
5915    /// dense-graph `planarity_euler_reject` fast path). The kernel counted self-loops with
5916    /// `edges_ordered().filter(|e| e.left == e.right).count()` — materialising a full
5917    /// `Vec<EdgeSnapshot>` (two owned Strings + an attr clone PER edge) — vs the integer per-node
5918    /// probe `(0..n).filter(|i| has_edge_by_indices(i, i)).count()` (no allocation). Byte-exact parity
5919    /// asserted (same self-loop count). `#[ignore]`; run with
5920    /// `cargo test --release -p fnx-classes --lib number_of_selfloops_idx_ab -- --ignored --nocapture`.
5921    #[test]
5922    #[ignore = "measurement; run with --release --ignored --nocapture"]
5923    fn number_of_selfloops_idx_ab() {
5924        use std::hint::black_box;
5925        use std::time::Instant;
5926
5927        // Dense-ish ring-of-chords, ~20k nodes * 20 = ~200k edges, no self-loops (the count is 0 and
5928        // the old arm materialises the whole edge Vec anyway).
5929        let n = 20_000usize;
5930        let deg = 20usize;
5931        let mut g = Graph::strict();
5932        for i in 0..n {
5933            let _ = g.add_node(i.to_string());
5934        }
5935        for i in 0..n {
5936            for step in 1..=deg {
5937                let _ = g.add_edge(i.to_string(), ((i + step) % n).to_string());
5938            }
5939        }
5940
5941        let old_count = |g: &Graph| -> usize {
5942            g.edges_ordered()
5943                .iter()
5944                .filter(|e| e.left == e.right)
5945                .count()
5946        };
5947        let new_count = |g: &Graph| -> usize {
5948            (0..g.node_count())
5949                .filter(|&i| g.has_edge_by_indices(i, i))
5950                .count()
5951        };
5952        assert_eq!(old_count(&g), new_count(&g), "number_of_selfloops parity");
5953
5954        let time = |cand: bool| -> f64 {
5955            let t0 = Instant::now();
5956            let c = if cand { new_count(&g) } else { old_count(&g) };
5957            black_box(c);
5958            t0.elapsed().as_secs_f64()
5959        };
5960        for _ in 0..3 {
5961            black_box(time(true));
5962            black_box(time(false));
5963        }
5964        let median = |v: &[f64]| {
5965            let mut s = v.to_vec();
5966            s.sort_by(|a, b| a.partial_cmp(b).unwrap());
5967            s[s.len() / 2]
5968        };
5969        let rounds = 61usize;
5970        let paired = |cand: bool, base: bool| -> Vec<f64> {
5971            let mut v = Vec::with_capacity(rounds);
5972            for r in 0..rounds {
5973                let (tb, tc) = if r % 2 == 0 {
5974                    let b = time(base);
5975                    let c = time(cand);
5976                    (b, c)
5977                } else {
5978                    let c = time(cand);
5979                    let b = time(base);
5980                    (b, c)
5981                };
5982                v.push(tb / tc);
5983            }
5984            v
5985        };
5986        let report = |name: &str, ratios: &[f64]| {
5987            let wins = ratios.iter().filter(|&&r| r > 1.0).count();
5988            let mut sorted = ratios.to_vec();
5989            sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
5990            println!(
5991                "NUMSELFIDX_AB {name}: median={:.4}x win_rate={wins}/{rounds} p5_p95=[{:.4},{:.4}]",
5992                median(ratios),
5993                sorted[rounds * 5 / 100],
5994                sorted[rounds * 95 / 100],
5995            );
5996        };
5997        println!(
5998            "NUMSELFIDX_AB number_of_selfloops n={n} deg={deg} rounds={rounds} (>1 = index count faster)"
5999        );
6000        report("INDEXCOUNT_vs_edgesordered", &paired(true, false));
6001        report("NULL_new_vs_new", &paired(true, true));
6002    }
6003
6004    /// br-r37-c1-kneserpush parity: removing the redundant `adj_indices.contains` guards under the
6005    /// `seen.insert(pair)` new-pair guard must keep the Kneser build byte-identical — correct
6006    /// node/edge counts and NO duplicate adjacency entries. (Same lever + adj_indices.push operation
6007    /// as br-r37-c1-addedgenewedge, whose A/B measured the O(degree)→O(1) win.)
6008    #[test]
6009    fn kneser_push_no_duplicate_adjacency() {
6010        // Kneser(10,3): C(10,3)=120 nodes, degree C(7,3)=35, edges = 120*35/2 = 2100.
6011        let g = Graph::kneser(CompatibilityMode::Strict, 10, 3);
6012        assert_eq!(g.node_count(), 120, "C(10,3) nodes");
6013        assert_eq!(g.edge_count(), 2100, "120*35/2 Kneser edges");
6014        let mut total_deg = 0usize;
6015        for (i, row) in g.adj_indices.iter().enumerate() {
6016            let uniq: BTreeSet<usize> = row.iter().copied().collect();
6017            assert_eq!(
6018                uniq.len(),
6019                row.len(),
6020                "node {i} has duplicate adjacency entries"
6021            );
6022            assert_eq!(row.len(), 35, "Kneser(10,3) is 35-regular");
6023            total_deg += row.len();
6024        }
6025        assert_eq!(total_deg, 2 * 2100, "handshake: sum of degrees == 2|E|");
6026    }
6027
6028    fn node_name(id: u8) -> String {
6029        format!("n{}", id % 8)
6030    }
6031
6032    fn canonical_edge(left: &str, right: &str) -> (String, String) {
6033        if left <= right {
6034            (left.to_owned(), right.to_owned())
6035        } else {
6036            (right.to_owned(), left.to_owned())
6037        }
6038    }
6039
6040    /// br-r37-c1-thp6w slice 1 INVARIANT: the cached integer adjacency
6041    /// (`with_int_adjacency`) must equal, byte-for-byte, a fresh derivation from the
6042    /// authoritative stable-slot rows — row `i` == `[get_node_index(v) for v in
6043    /// neighbors_iter(node_i)]` — for the CURRENT graph state. Because the memo is keyed on
6044    /// `revision` + explicitly cleared by `apply_row_orders`, calling this after a mutation
6045    /// (with a prior populating read) catches any mutation path that fails to invalidate.
6046    fn assert_int_adjacency_matches(g: &MultiGraph) {
6047        let expected: Vec<Vec<usize>> = g
6048            .nodes_ordered()
6049            .iter()
6050            .map(|name| {
6051                g.neighbors_iter(name).map_or_else(Vec::new, |it| {
6052                    it.map(|v| g.get_node_index(v).unwrap()).collect()
6053                })
6054            })
6055            .collect();
6056        g.with_int_adjacency(|adj| {
6057            assert_eq!(
6058                adj,
6059                expected.as_slice(),
6060                "int adjacency must mirror the stable-slot rows exactly"
6061            );
6062        });
6063    }
6064
6065    #[test]
6066    fn thp6w_int_adjacency_invariant_across_mutations() {
6067        let mut g = MultiGraph::strict();
6068        for i in 0..8 {
6069            g.add_node(format!("n{i}"));
6070        }
6071        // Edges incl. parallels and a self-loop.
6072        let _ = g.add_edge("n0", "n1");
6073        let _ = g.add_edge("n0", "n1"); // parallel
6074        let _ = g.add_edge("n1", "n2");
6075        let _ = g.add_edge("n2", "n3");
6076        let _ = g.add_edge("n3", "n0");
6077        let _ = g.add_edge("n4", "n4"); // self-loop
6078        let _ = g.add_edge("n5", "n6");
6079        // Populate the memo, then verify.
6080        assert_int_adjacency_matches(&g);
6081
6082        // read-mutate-read for EACH mutation kind: a stale memo would fail the re-check.
6083        // add_node
6084        assert_int_adjacency_matches(&g);
6085        g.add_node("n9");
6086        assert_int_adjacency_matches(&g);
6087        // add_edge (new neighbor -> row grows)
6088        assert_int_adjacency_matches(&g);
6089        let _ = g.add_edge("n9", "n0");
6090        assert_int_adjacency_matches(&g);
6091        // parallel add_edge (row unchanged, content bumps revision)
6092        assert_int_adjacency_matches(&g);
6093        let _ = g.add_edge("n9", "n0");
6094        assert_int_adjacency_matches(&g);
6095        // remove one parallel edge (row must stay; neighbor still present)
6096        assert_int_adjacency_matches(&g);
6097        assert!(g.remove_edge("n0", "n1", None));
6098        assert_int_adjacency_matches(&g);
6099        // remove last edge of a pair (neighbor drops from both rows)
6100        assert_int_adjacency_matches(&g);
6101        assert!(g.remove_edge("n5", "n6", None));
6102        assert_int_adjacency_matches(&g);
6103        // remove_node (renumber: indices shift down)
6104        assert_int_adjacency_matches(&g);
6105        assert!(g.remove_node("n2"));
6106        assert_int_adjacency_matches(&g);
6107        // ORDER-ONLY change via the copy walk reorder (does NOT bump revision — the
6108        // apply_row_orders cache-clear is what keeps this correct).
6109        assert_int_adjacency_matches(&g);
6110        g.reorder_rows_for_nx_copy_walk();
6111        assert_int_adjacency_matches(&g);
6112        // clear_edges
6113        assert_int_adjacency_matches(&g);
6114        g.clear_edges();
6115        assert_int_adjacency_matches(&g);
6116    }
6117
6118    /// br-r37-c1-thp6w S4: the memo is keyed at the CURRENT revision without any
6119    /// intervening read — i.e. the mutation advanced it in place rather than
6120    /// leaving it stale for the next `with_int_adjacency` rebuild.
6121    fn int_adj_memo_is_warm(g: &MultiGraph) -> bool {
6122        g.int_adj_cache
6123            .0
6124            .read()
6125            .expect("int_adj_cache poisoned")
6126            .as_ref()
6127            .is_some_and(|(rev, _)| *rev == g.revision)
6128    }
6129
6130    /// br-r37-c1-thp6w S4 INVARIANT: single-edge `add_edge`/`remove_edge` must
6131    /// ADVANCE a warm integer-adjacency memo across the mutation (no rebuild),
6132    /// and the advanced rows must equal a fresh derivation from the String
6133    /// adjacency after every step. Unhandled mutation kinds (`remove_node`)
6134    /// must still leave the memo stale so the lazy rebuild path takes over.
6135    #[test]
6136    fn thp6w_s4_single_edge_mutations_advance_warm_memo() {
6137        let mut g = MultiGraph::strict();
6138        let _ = g.add_edge("a", "b");
6139        assert_int_adjacency_matches(&g); // populate the memo
6140
6141        // new neighbor with an auto-created right endpoint
6142        let _ = g.add_edge("b", "c");
6143        assert!(
6144            int_adj_memo_is_warm(&g),
6145            "add_edge must advance, not invalidate"
6146        );
6147        assert_int_adjacency_matches(&g);
6148        // both endpoints auto-created
6149        let _ = g.add_edge("d", "e");
6150        assert!(int_adj_memo_is_warm(&g));
6151        assert_int_adjacency_matches(&g);
6152        // self-loop on an auto-created node
6153        let _ = g.add_edge("f", "f");
6154        assert!(int_adj_memo_is_warm(&g));
6155        assert_int_adjacency_matches(&g);
6156        // parallel edge: rows unchanged, revision bumps -> re-key only
6157        let _ = g.add_edge("a", "b");
6158        assert!(int_adj_memo_is_warm(&g));
6159        assert_int_adjacency_matches(&g);
6160        // attr-only change on an existing key: rows unchanged, revision bumps
6161        let mut attrs = AttrMap::new();
6162        attrs.insert("w".to_owned(), fnx_runtime::CgseValue::Int(2));
6163        let _ = g.add_edge_with_key_and_attrs("a", "b", 0, attrs);
6164        assert!(int_adj_memo_is_warm(&g));
6165        assert_int_adjacency_matches(&g);
6166        // remove one parallel key (pair survives -> rows unchanged)
6167        assert!(g.remove_edge("a", "b", None));
6168        assert!(int_adj_memo_is_warm(&g));
6169        assert_int_adjacency_matches(&g);
6170        // remove the last key (pair gone -> shift-remove mirrored in the rows)
6171        assert!(g.remove_edge("a", "b", None));
6172        assert!(int_adj_memo_is_warm(&g));
6173        assert_int_adjacency_matches(&g);
6174        // order-sensitive middle-of-row removal: row b = [a-gone..., c, ...]; build
6175        // b->x, b->y then drop (b,c) so the survivors must keep String-row order.
6176        let _ = g.add_edge("b", "x");
6177        let _ = g.add_edge("b", "y");
6178        assert!(g.remove_edge("b", "c", None));
6179        assert!(int_adj_memo_is_warm(&g));
6180        assert_int_adjacency_matches(&g);
6181        // self-loop removal
6182        assert!(g.remove_edge("f", "f", None));
6183        assert!(int_adj_memo_is_warm(&g));
6184        assert_int_adjacency_matches(&g);
6185        // unhandled mutation kind: remove_node renumbers -> memo must go stale
6186        assert!(g.remove_node("x"));
6187        assert!(
6188            !int_adj_memo_is_warm(&g),
6189            "remove_node stays invalidate-only; a warm memo here would serve stale indices"
6190        );
6191        assert_int_adjacency_matches(&g);
6192    }
6193
6194    /// br-r37-c1-thp6w S5 PARITY GATES: the index-keyed prototype must derive
6195    /// byte-identical observable orderings (node order, per-row distinct-
6196    /// neighbor order, per-pair parallel-key order) and identical edge count +
6197    /// merged attrs from the same insert stream as the real String-keyed
6198    /// MultiGraph batch path. The stream deliberately includes names where
6199    /// String-lex and index canonicalization DIVERGE ("10" < "2" lex, 2 < 10
6200    /// numeric), reversed re-adds, self-loops, sparse explicit keys, dup
6201    /// (pair, key) attr merges, and late out-of-order node introduction.
6202    #[test]
6203    fn thp6w_s5_int_storage_proto_parity_gates() {
6204        use super::multigraph_storage::MgIntStorageProto;
6205
6206        let stream: Vec<(&str, &str, usize)> = vec![
6207            ("2", "10", 0),
6208            ("10", "2", 1), // reversed orientation, same pair, next key
6209            ("1", "1", 0),  // self-loop on a fresh node
6210            ("2", "3", 0),
6211            ("3", "2", 0), // dup (pair, key) from the reverse orientation -> attr merge
6212            ("10", "1", 5), // sparse explicit key
6213            ("0", "2", 0), // node "0" introduced late (insertion order != numeric)
6214            ("2", "10", 1), // dup (pair, key) again -> attr merge
6215            ("4", "5", 2),
6216            ("5", "4", 0), // parallel keys, both orientations
6217            ("1", "1", 1), // parallel self-loop key
6218        ];
6219        let attrs_for = |i: usize| {
6220            let mut attrs = AttrMap::new();
6221            attrs.insert(
6222                format!("a{}", i % 3),
6223                fnx_runtime::CgseValue::Int(i64::try_from(i).unwrap()),
6224            );
6225            attrs
6226        };
6227
6228        let mut mg = MultiGraph::strict();
6229        let _ = mg.extend_keyed_edges_with_attrs_unrecorded(
6230            stream
6231                .iter()
6232                .enumerate()
6233                .map(|(i, (l, r, k))| ((*l).to_owned(), (*r).to_owned(), *k, attrs_for(i))),
6234        );
6235        let mut proto = MgIntStorageProto::new();
6236        for (i, (l, r, k)) in stream.iter().enumerate() {
6237            proto.add_keyed_edge(l, r, *k, attrs_for(i));
6238        }
6239        // br-r37-c1-thp6w S6: the compact-bucket variant must pass the same gates.
6240        let mut compact = super::multigraph_storage::MgIntStorageProtoCompact::new();
6241        for (i, (l, r, k)) in stream.iter().enumerate() {
6242            compact.add_keyed_edge(l, r, *k, attrs_for(i));
6243        }
6244
6245        // Gate 1: node insertion order.
6246        assert_eq!(
6247            proto
6248                .node_names
6249                .iter()
6250                .map(String::as_str)
6251                .collect::<Vec<_>>(),
6252            mg.nodes_ordered(),
6253            "node order must match"
6254        );
6255        assert_eq!(
6256            compact
6257                .node_names
6258                .iter()
6259                .map(String::as_str)
6260                .collect::<Vec<_>>(),
6261            mg.nodes_ordered(),
6262            "compact node order must match"
6263        );
6264        // Gate 2 + 3: per-row distinct-neighbor order and per-pair key order.
6265        for (i, name) in mg.nodes_ordered().iter().enumerate() {
6266            let mg_row: Vec<&str> = mg
6267                .neighbors_iter(name)
6268                .map_or_else(Vec::new, Iterator::collect);
6269            assert_eq!(proto.neighbor_names(i), mg_row, "row order for node {name}");
6270            assert_eq!(
6271                compact.neighbor_names(i),
6272                mg_row,
6273                "compact row order for node {name}"
6274            );
6275            for v in &mg_row {
6276                assert_eq!(
6277                    proto.key_order(name, v),
6278                    mg.edge_keys_vec(name, v),
6279                    "key order for pair ({name}, {v})"
6280                );
6281                assert_eq!(
6282                    compact.key_order(name, v),
6283                    mg.edge_keys_vec(name, v),
6284                    "compact key order for pair ({name}, {v})"
6285                );
6286            }
6287        }
6288        // Gate 4: edge count.
6289        assert_eq!(proto.edge_count, mg.edge_count(), "edge_count must match");
6290        assert_eq!(
6291            compact.edge_count,
6292            mg.edge_count(),
6293            "compact edge_count must match"
6294        );
6295        // Gate 5: merged attrs on a dup-inserted cell ((2,10) key 1 saw inserts
6296        // at stream positions 1 and 7 -> extend semantics on both sides).
6297        let l = *proto.node_index.get("2").expect("proto must retain node 2");
6298        let r = *proto
6299            .node_index
6300            .get("10")
6301            .expect("proto must retain node 10");
6302        let proto_attrs = proto.edges[&(l.min(r), l.max(r))]
6303            .get(&1)
6304            .expect("proto (2,10,1) cell");
6305        assert_eq!(
6306            Some(proto_attrs),
6307            mg.edge_attrs("2", "10", 1),
6308            "merged attrs on the dup (pair, key) cell must match"
6309        );
6310        assert_eq!(
6311            compact.edges[&(l.min(r), l.max(r))].attrs_for(1),
6312            mg.edge_attrs("2", "10", 1),
6313            "compact merged attrs on the dup (pair, key) cell must match"
6314        );
6315    }
6316
6317    /// br-r37-c1-thp6w S7 helper: full observable-order parity between the
6318    /// real MultiGraph and the index-keyed prototype, plus the prototype's own
6319    /// name->index consistency (the renumber invariant).
6320    fn assert_proto_matches_mg(
6321        mg: &MultiGraph,
6322        proto: &super::multigraph_storage::MgIntStorageProto,
6323    ) {
6324        assert_eq!(
6325            proto
6326                .node_names
6327                .iter()
6328                .map(String::as_str)
6329                .collect::<Vec<_>>(),
6330            mg.nodes_ordered(),
6331            "node order"
6332        );
6333        for (i, name) in proto.node_names.iter().enumerate() {
6334            assert_eq!(
6335                proto.node_index.get(name),
6336                Some(&i),
6337                "node_index desync for {name}"
6338            );
6339        }
6340        for (i, name) in mg.nodes_ordered().iter().enumerate() {
6341            let mg_row: Vec<&str> = mg
6342                .neighbors_iter(name)
6343                .map_or_else(Vec::new, Iterator::collect);
6344            assert_eq!(proto.neighbor_names(i), mg_row, "row order for {name}");
6345            for v in &mg_row {
6346                assert_eq!(
6347                    proto.key_order(name, v),
6348                    mg.edge_keys_vec(name, v),
6349                    "key order for ({name}, {v})"
6350                );
6351            }
6352        }
6353        assert_eq!(proto.edge_count, mg.edge_count(), "edge_count");
6354    }
6355
6356    /// br-r37-c1-thp6w S7 PARITY GAUNTLET (removal + renumber): the epoch's
6357    /// riskiest semantics — `remove_edge` shift/`next_back` behavior and the
6358    /// `remove_node` index renumber — must keep every observable ordering
6359    /// byte-identical to the real String-keyed MultiGraph after EVERY step,
6360    /// including re-adds AFTER a renumber (stale-index detection).
6361    #[test]
6362    fn thp6w_s7_removal_renumber_parity_gauntlet() {
6363        use super::multigraph_storage::MgIntStorageProto;
6364
6365        let stream: Vec<(&str, &str, usize)> = vec![
6366            ("2", "10", 0),
6367            ("10", "2", 1),
6368            ("1", "1", 0),
6369            ("2", "3", 0),
6370            ("3", "2", 0),
6371            ("10", "1", 5),
6372            ("0", "2", 0),
6373            ("2", "10", 1),
6374            ("4", "5", 2),
6375            ("5", "4", 0),
6376            ("1", "1", 1),
6377        ];
6378        let mut mg = MultiGraph::strict();
6379        let _ = mg.extend_keyed_edges_with_attrs_unrecorded(
6380            stream
6381                .iter()
6382                .map(|(l, r, k)| ((*l).to_owned(), (*r).to_owned(), *k, AttrMap::new())),
6383        );
6384        let mut proto = MgIntStorageProto::new();
6385        for (l, r, k) in &stream {
6386            proto.add_keyed_edge(l, r, *k, AttrMap::new());
6387        }
6388        assert_proto_matches_mg(&mg, &proto);
6389
6390        // key=None must pick the LAST bucket key (next_back): (4,5) keys are
6391        // [2, 0] in insertion order -> removes 0, survivor [2].
6392        assert_eq!(
6393            mg.remove_edge("4", "5", None),
6394            proto.remove_edge("4", "5", None)
6395        );
6396        assert_proto_matches_mg(&mg, &proto);
6397        // Partial removal of a parallel bundle: survivor key order preserved.
6398        assert_eq!(
6399            mg.remove_edge("2", "10", Some(0)),
6400            proto.remove_edge("2", "10", Some(0))
6401        );
6402        assert_proto_matches_mg(&mg, &proto);
6403        // Last key of the pair -> neighbor cell drops from both rows.
6404        assert_eq!(
6405            mg.remove_edge("2", "10", None),
6406            proto.remove_edge("2", "10", None)
6407        );
6408        assert_proto_matches_mg(&mg, &proto);
6409        // Self-loop partial removal.
6410        assert_eq!(
6411            mg.remove_edge("1", "1", Some(0)),
6412            proto.remove_edge("1", "1", Some(0))
6413        );
6414        assert_proto_matches_mg(&mg, &proto);
6415        // Missing pair / missing key: both sides must refuse identically.
6416        assert_eq!(
6417            mg.remove_edge("0", "10", None),
6418            proto.remove_edge("0", "10", None)
6419        );
6420        assert_eq!(
6421            mg.remove_edge("2", "3", Some(9)),
6422            proto.remove_edge("2", "3", Some(9))
6423        );
6424        assert_proto_matches_mg(&mg, &proto);
6425
6426        // THE renumber: remove a mid-order node with live edges.
6427        assert_eq!(mg.remove_node("2"), proto.remove_node("2"));
6428        assert_proto_matches_mg(&mg, &proto);
6429        // Re-adds AFTER the renumber: fresh node + edges into shifted indices.
6430        mg.extend_keyed_edges_with_attrs_unrecorded(vec![
6431            ("3".to_owned(), "99".to_owned(), 0, AttrMap::new()),
6432            ("0".to_owned(), "3".to_owned(), 0, AttrMap::new()),
6433        ]);
6434        proto.add_keyed_edge("3", "99", 0, AttrMap::new());
6435        proto.add_keyed_edge("0", "3", 0, AttrMap::new());
6436        assert_proto_matches_mg(&mg, &proto);
6437        // Renumber again from the other end, then the self-loop node.
6438        assert_eq!(mg.remove_node("10"), proto.remove_node("10"));
6439        assert_proto_matches_mg(&mg, &proto);
6440        assert_eq!(mg.remove_node("1"), proto.remove_node("1"));
6441        assert_proto_matches_mg(&mg, &proto);
6442        // Removing a missing node refuses identically.
6443        assert_eq!(mg.remove_node("2"), proto.remove_node("2"));
6444        assert_proto_matches_mg(&mg, &proto);
6445    }
6446
6447    /// br-r37-c1-thp6w S8 A/B: the flip's COST side — `remove_node` under the
6448    /// index layout pays the d58s8 positional renumber (O(V+E) per removal:
6449    /// row drop+decrement passes + full edges-map rebuild) where the current
6450    /// String store pays only O(V + degree) (shift_remove memmoves, no edge
6451    /// rekey). Same build stream, same 200-name removal sequence, removal
6452    /// phase timed alone, paired-interleaved with a null arm. This number
6453    /// decides whether the real flip needs the slab/stable-slot + tombstone
6454    /// node store (see the bead's S8 design note) instead of positional
6455    /// renumber.
6456    /// `cargo test --release -p fnx-classes --lib thp6w_s8_removal_cost_ab -- --ignored --nocapture`
6457    #[test]
6458    #[ignore = "paired A/B benchmark; run --release with --ignored --nocapture"]
6459    fn thp6w_s8_removal_cost_ab() {
6460        use super::multigraph_storage::MgIntStorageProto;
6461        use std::collections::HashMap;
6462        use std::hint::black_box;
6463        use std::time::Instant;
6464
6465        fn push_edge(
6466            stream: &mut Vec<(usize, usize, usize)>,
6467            key_counter: &mut HashMap<(usize, usize), usize>,
6468            u: usize,
6469            v: usize,
6470        ) {
6471            let entry = key_counter.entry((u.min(v), u.max(v))).or_insert(0);
6472            stream.push((u, v, *entry));
6473            *entry += 1;
6474        }
6475
6476        let n = 20000usize;
6477        let mut stream = Vec::new();
6478        let mut key_counter = HashMap::new();
6479        for i in 0..n {
6480            push_edge(&mut stream, &mut key_counter, i, (i + 1) % n);
6481        }
6482        let mut state = 0x2545_F491_4F6C_DD1D_u64;
6483        let draw = |state: &mut u64| -> usize {
6484            *state = state
6485                .wrapping_mul(6_364_136_223_846_793_005)
6486                .wrapping_add(1_442_695_040_888_963_407);
6487            usize::try_from(*state >> 33).unwrap() % n
6488        };
6489        for _ in 0..3 * n {
6490            let u = draw(&mut state);
6491            let v = draw(&mut state);
6492            push_edge(&mut stream, &mut key_counter, u, v);
6493        }
6494        let victims: Vec<String> = (0..n).step_by(100).map(|i| i.to_string()).collect();
6495
6496        let time_current_removals = |stream: &[(usize, usize, usize)]| -> f64 {
6497            let mut g = MultiGraph::strict();
6498            let _ = g.extend_fresh_int_prefix_keyed_edges_unrecorded(n, stream.iter().copied());
6499            let t0 = Instant::now();
6500            for name in &victims {
6501                assert!(g.remove_node(name));
6502            }
6503            let dt = t0.elapsed().as_secs_f64();
6504            black_box(g.edge_count());
6505            dt
6506        };
6507        let time_proto_removals = |stream: &[(usize, usize, usize)]| -> f64 {
6508            let mut proto = MgIntStorageProto::bulk_load_int_prefix(n, stream.iter().copied());
6509            let t0 = Instant::now();
6510            for name in &victims {
6511                assert!(proto.remove_node(name));
6512            }
6513            let dt = t0.elapsed().as_secs_f64();
6514            black_box(proto.edge_count);
6515            dt
6516        };
6517
6518        // Post-removal structural agreement (once, outside timing).
6519        let mut g = MultiGraph::strict();
6520        let _ = g.extend_fresh_int_prefix_keyed_edges_unrecorded(n, stream.iter().copied());
6521        let mut proto = MgIntStorageProto::bulk_load_int_prefix(n, stream.iter().copied());
6522        for name in &victims {
6523            assert!(g.remove_node(name));
6524            assert!(proto.remove_node(name));
6525        }
6526        assert_eq!(
6527            proto.edge_count,
6528            g.edge_count(),
6529            "removal arms diverged in edge_count"
6530        );
6531        assert_eq!(
6532            proto
6533                .node_names
6534                .iter()
6535                .map(String::as_str)
6536                .collect::<Vec<_>>(),
6537            g.nodes_ordered(),
6538            "removal arms diverged in node order"
6539        );
6540
6541        let median = |samples: &mut Vec<f64>| -> f64 {
6542            samples.sort_by(f64::total_cmp);
6543            samples[samples.len() / 2]
6544        };
6545        let (mut cur, mut pro, mut nul) = (Vec::new(), Vec::new(), Vec::new());
6546        for _ in 0..7 {
6547            cur.push(time_current_removals(&stream));
6548            pro.push(time_proto_removals(&stream));
6549            nul.push(time_current_removals(&stream));
6550        }
6551        // br-r37-c1-thp6w S9: slab arm — must be ~parity with the String store.
6552        let time_slab_removals = |stream: &[(usize, usize, usize)]| -> f64 {
6553            let mut slab = super::multigraph_storage::MgSlabStorage::bulk_load_int_prefix(
6554                n,
6555                stream.iter().copied(),
6556            );
6557            let t0 = Instant::now();
6558            for name in &victims {
6559                assert!(slab.remove_node(name));
6560            }
6561            let dt = t0.elapsed().as_secs_f64();
6562            black_box(slab.edge_count);
6563            dt
6564        };
6565        let mut g2 = MultiGraph::strict();
6566        let _ = g2.extend_fresh_int_prefix_keyed_edges_unrecorded(n, stream.iter().copied());
6567        let mut slab = super::multigraph_storage::MgSlabStorage::bulk_load_int_prefix(
6568            n,
6569            stream.iter().copied(),
6570        );
6571        for name in &victims {
6572            assert!(g2.remove_node(name));
6573            assert!(slab.remove_node(name));
6574        }
6575        assert_eq!(
6576            slab.edge_count,
6577            g2.edge_count(),
6578            "slab removal arm diverged in edge_count"
6579        );
6580        assert_eq!(
6581            slab.node_order
6582                .keys()
6583                .map(String::as_str)
6584                .collect::<Vec<_>>(),
6585            g2.nodes_ordered(),
6586            "slab removal arm diverged in node order"
6587        );
6588        let mut sla = Vec::new();
6589        for _ in 0..7 {
6590            sla.push(time_slab_removals(&stream));
6591        }
6592        let msl = median(&mut sla);
6593
6594        let (mc, mp, mn) = (median(&mut cur), median(&mut pro), median(&mut nul));
6595        println!(
6596            "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}",
6597            stream.len(),
6598            victims.len(),
6599            mp / mc,
6600            msl / mc,
6601            mc / mn,
6602        );
6603    }
6604
6605    /// br-r37-c1-thp6w S9 helper: full observable-order parity between the
6606    /// real MultiGraph and the slab prototype (order from `node_order`, rows
6607    /// resolved slot -> name), plus slab self-consistency (live slots named,
6608    /// free slots tombstoned).
6609    fn assert_slab_matches_mg(mg: &MultiGraph, slab: &super::multigraph_storage::MgSlabStorage) {
6610        let mg_node_order = mg.nodes_ordered();
6611        assert_eq!(
6612            slab.node_order
6613                .keys()
6614                .map(String::as_str)
6615                .collect::<Vec<_>>(),
6616            mg_node_order,
6617            "slab node order"
6618        );
6619        for (name, &slot) in &slab.node_order {
6620            assert_eq!(
6621                slab.slot_names[slot].as_deref(),
6622                Some(name.as_str()),
6623                "slot_names desync for {name}"
6624            );
6625        }
6626        for &free in &slab.free_slots {
6627            assert!(
6628                slab.slot_names[free].is_none(),
6629                "free slot {free} still named"
6630            );
6631            assert!(slab.rows[free].is_empty(), "free slot {free} has row cells");
6632            assert!(
6633                slab.node_attrs[free].is_empty(),
6634                "free slot {free} still has node attrs"
6635            );
6636        }
6637        for &name in &mg_node_order {
6638            assert_eq!(
6639                slab.node_attrs_for(name),
6640                mg.node_attrs(name),
6641                "slab node attrs for {name}"
6642            );
6643        }
6644        for (pos, &name) in mg_node_order.iter().enumerate() {
6645            let mg_row = mg.neighbors(name).unwrap_or_default();
6646            assert_eq!(
6647                slab.neighbor_names(pos),
6648                mg_row,
6649                "slab row order for {name}"
6650            );
6651            for &v in &mg_row {
6652                let mg_keys = mg.edge_keys_vec(name, v);
6653                assert_eq!(
6654                    slab.key_order(name, v),
6655                    mg_keys,
6656                    "slab key order for ({name}, {v})"
6657                );
6658            }
6659        }
6660        assert_eq!(slab.edge_count, mg.edge_count, "slab edge_count");
6661    }
6662
6663    /// br-r37-c1-thp6w S9 PARITY GAUNTLET (slab + slot recycling): the
6664    /// S8-mandated layout must stay byte-identical to the real MultiGraph
6665    /// through the S7 removal shapes AND the slab-specific hazards — slot
6666    /// reuse after removal (fresh name AND the removed name re-added must
6667    /// append at the END of node order with no phantom edges from the slot's
6668    /// previous occupant).
6669    #[test]
6670    fn thp6w_s9_slab_recycling_parity_gauntlet() {
6671        use super::multigraph_storage::MgSlabStorage;
6672
6673        let stream: Vec<(&str, &str, usize)> = vec![
6674            ("2", "10", 0),
6675            ("10", "2", 1),
6676            ("1", "1", 0),
6677            ("2", "3", 0),
6678            ("3", "2", 0),
6679            ("10", "1", 5),
6680            ("0", "2", 0),
6681            ("2", "10", 1),
6682            ("4", "5", 2),
6683            ("5", "4", 0),
6684            ("1", "1", 1),
6685        ];
6686        let mut mg = MultiGraph::strict();
6687        let _ = mg.extend_keyed_edges_with_attrs_unrecorded(
6688            stream
6689                .iter()
6690                .map(|(l, r, k)| ((*l).to_owned(), (*r).to_owned(), *k, AttrMap::new())),
6691        );
6692        let mut slab = MgSlabStorage::new();
6693        for (l, r, k) in &stream {
6694            slab.add_keyed_edge(l, r, *k, AttrMap::new());
6695        }
6696        assert_slab_matches_mg(&mg, &slab);
6697
6698        // S7 removal shapes.
6699        assert_eq!(
6700            mg.remove_edge("4", "5", None),
6701            slab.remove_edge("4", "5", None)
6702        );
6703        assert_slab_matches_mg(&mg, &slab);
6704        assert_eq!(
6705            mg.remove_edge("2", "10", Some(0)),
6706            slab.remove_edge("2", "10", Some(0))
6707        );
6708        assert_slab_matches_mg(&mg, &slab);
6709        assert_eq!(
6710            mg.remove_edge("2", "10", None),
6711            slab.remove_edge("2", "10", None)
6712        );
6713        assert_slab_matches_mg(&mg, &slab);
6714        assert_eq!(
6715            mg.remove_edge("1", "1", Some(0)),
6716            slab.remove_edge("1", "1", Some(0))
6717        );
6718        assert_slab_matches_mg(&mg, &slab);
6719        assert_eq!(
6720            mg.remove_edge("0", "10", None),
6721            slab.remove_edge("0", "10", None)
6722        );
6723        assert_eq!(
6724            mg.remove_edge("2", "3", Some(9)),
6725            slab.remove_edge("2", "3", Some(9))
6726        );
6727        assert_slab_matches_mg(&mg, &slab);
6728
6729        // Tombstone a mid-order node with live parallel edges...
6730        assert_eq!(mg.remove_node("2"), slab.remove_node("2"));
6731        assert_slab_matches_mg(&mg, &slab);
6732        // ...then RECYCLE its slot with a FRESH name: must append at the END
6733        // of node order and carry no phantom adjacency from the old occupant.
6734        mg.extend_keyed_edges_with_attrs_unrecorded(vec![(
6735            "fresh".to_owned(),
6736            "3".to_owned(),
6737            0,
6738            AttrMap::new(),
6739        )]);
6740        slab.add_keyed_edge("fresh", "3", 0, AttrMap::new());
6741        assert_slab_matches_mg(&mg, &slab);
6742        // ...and re-add the REMOVED name too (recycles another slot or a fresh
6743        // one; order must be append-at-end either way).
6744        mg.extend_keyed_edges_with_attrs_unrecorded(vec![(
6745            "2".to_owned(),
6746            "fresh".to_owned(),
6747            7,
6748            AttrMap::new(),
6749        )]);
6750        slab.add_keyed_edge("2", "fresh", 7, AttrMap::new());
6751        assert_slab_matches_mg(&mg, &slab);
6752        // Remove the recycled node again (double-recycle path).
6753        assert_eq!(mg.remove_node("fresh"), slab.remove_node("fresh"));
6754        assert_slab_matches_mg(&mg, &slab);
6755        // Self-loop node and end nodes.
6756        assert_eq!(mg.remove_node("1"), slab.remove_node("1"));
6757        assert_slab_matches_mg(&mg, &slab);
6758        assert_eq!(mg.remove_node("10"), slab.remove_node("10"));
6759        assert_slab_matches_mg(&mg, &slab);
6760        // Missing-node refusal parity.
6761        assert_eq!(mg.remove_node("gone"), slab.remove_node("gone"));
6762        assert_slab_matches_mg(&mg, &slab);
6763    }
6764
6765    /// br-r37-c1-thp6w S10 helper: the ONLY observed edge order — walk-order
6766    /// emission with String-lex canonical orientation — must be byte-identical
6767    /// between the real MultiGraph and the slab layout.
6768    fn assert_slab_edges_ordered_matches_mg(
6769        mg: &MultiGraph,
6770        slab: &super::multigraph_storage::MgSlabStorage,
6771    ) {
6772        let mg_ordered: Vec<(&str, &str, usize)> = mg
6773            .edges_ordered_borrowed()
6774            .into_iter()
6775            .map(|(l, r, k, _)| (l, r, k))
6776            .collect();
6777        assert_eq!(
6778            slab.edges_ordered_names(),
6779            mg_ordered,
6780            "edges_ordered walk emission must match"
6781        );
6782        // br-r37-c1-thp6w S12: the attrs-carrying walk must match in FULL,
6783        // including per-cell attr payloads.
6784        assert_eq!(
6785            slab.edges_ordered_borrowed(),
6786            mg.edges_ordered_borrowed(),
6787            "attrs-carrying edges_ordered walk must match"
6788        );
6789    }
6790
6791    /// br-r37-c1-thp6w S10 PARITY GAUNTLET (copy-walk reorder + snapshot
6792    /// orientation): the two remaining tie-break axes for the real flip.
6793    /// `edges_ordered` (walk emission with lex-canonical orientation) and
6794    /// `reorder_rows_for_nx_copy_walk` (the MultiGraph.copy row-order
6795    /// contract) must stay byte-identical through reorders, post-reorder
6796    /// mutations, removals, and slot recycling.
6797    #[test]
6798    fn thp6w_s10_copy_walk_and_snapshot_orientation_gauntlet() {
6799        use super::multigraph_storage::MgSlabStorage;
6800
6801        let stream: Vec<(&str, &str, usize)> = vec![
6802            ("2", "10", 0),
6803            ("10", "2", 1),
6804            ("1", "1", 0),
6805            ("2", "3", 0),
6806            ("10", "1", 5),
6807            ("0", "2", 0),
6808            ("4", "5", 2),
6809            ("5", "4", 0),
6810            ("3", "0", 0),
6811            ("1", "1", 1),
6812            ("5", "0", 3),
6813        ];
6814        let mut mg = MultiGraph::strict();
6815        let _ = mg.extend_keyed_edges_with_attrs_unrecorded(
6816            stream
6817                .iter()
6818                .map(|(l, r, k)| ((*l).to_owned(), (*r).to_owned(), *k, AttrMap::new())),
6819        );
6820        let mut slab = MgSlabStorage::new();
6821        for (l, r, k) in &stream {
6822            slab.add_keyed_edge(l, r, *k, AttrMap::new());
6823        }
6824        assert_slab_matches_mg(&mg, &slab);
6825        assert_slab_edges_ordered_matches_mg(&mg, &slab);
6826
6827        // THE copy-walk reorder (MultiGraph.copy row-order contract).
6828        mg.reorder_rows_for_nx_copy_walk();
6829        slab.reorder_rows_for_nx_copy_walk();
6830        assert_slab_matches_mg(&mg, &slab);
6831        assert_slab_edges_ordered_matches_mg(&mg, &slab);
6832
6833        // Reorder must be idempotent on both sides.
6834        mg.reorder_rows_for_nx_copy_walk();
6835        slab.reorder_rows_for_nx_copy_walk();
6836        assert_slab_matches_mg(&mg, &slab);
6837        assert_slab_edges_ordered_matches_mg(&mg, &slab);
6838
6839        // Post-reorder mutations: new edges append after the reordered cells.
6840        mg.extend_keyed_edges_with_attrs_unrecorded(vec![
6841            ("0".to_owned(), "9".to_owned(), 0, AttrMap::new()),
6842            ("9".to_owned(), "2".to_owned(), 0, AttrMap::new()),
6843        ]);
6844        slab.add_keyed_edge("0", "9", 0, AttrMap::new());
6845        slab.add_keyed_edge("9", "2", 0, AttrMap::new());
6846        assert_slab_matches_mg(&mg, &slab);
6847        assert_slab_edges_ordered_matches_mg(&mg, &slab);
6848
6849        // Removal + slot recycling, then reorder AGAIN on the recycled state.
6850        assert_eq!(mg.remove_node("2"), slab.remove_node("2"));
6851        mg.extend_keyed_edges_with_attrs_unrecorded(vec![(
6852            "fresh".to_owned(),
6853            "9".to_owned(),
6854            0,
6855            AttrMap::new(),
6856        )]);
6857        slab.add_keyed_edge("fresh", "9", 0, AttrMap::new());
6858        assert_slab_matches_mg(&mg, &slab);
6859        assert_slab_edges_ordered_matches_mg(&mg, &slab);
6860        mg.reorder_rows_for_nx_copy_walk();
6861        slab.reorder_rows_for_nx_copy_walk();
6862        assert_slab_matches_mg(&mg, &slab);
6863        assert_slab_edges_ordered_matches_mg(&mg, &slab);
6864    }
6865
6866    /// br-r37-c1-thp6w S12 A/B: the `edges_ordered_borrowed` read on the
6867    /// String store (per-cell `EdgeKeyRef` String-pair hashing + String-pair
6868    /// seen-set probes) vs the slab walk (usize hashing throughout). Same
6869    /// graph state on both sides (slab via `from_string_state`), full-output
6870    /// equality asserted outside timing, paired-interleaved with a null arm.
6871    /// `cargo test --release -p fnx-classes --lib thp6w_s12_edges_ordered_read_ab -- --ignored --nocapture`
6872    #[test]
6873    #[ignore = "paired A/B benchmark; run --release with --ignored --nocapture"]
6874    fn thp6w_s12_edges_ordered_read_ab() {
6875        use std::collections::HashMap;
6876        use std::hint::black_box;
6877        use std::time::Instant;
6878
6879        fn push_edge(
6880            stream: &mut Vec<(usize, usize, usize)>,
6881            key_counter: &mut HashMap<(usize, usize), usize>,
6882            u: usize,
6883            v: usize,
6884        ) {
6885            let entry = key_counter.entry((u.min(v), u.max(v))).or_insert(0);
6886            stream.push((u, v, *entry));
6887            *entry += 1;
6888        }
6889
6890        let n = 20000usize;
6891        let mut stream = Vec::new();
6892        let mut key_counter = HashMap::new();
6893        for i in 0..n {
6894            push_edge(&mut stream, &mut key_counter, i, (i + 1) % n);
6895        }
6896        let mut state = 0x2545_F491_4F6C_DD1D_u64;
6897        let draw = |state: &mut u64| -> usize {
6898            *state = state
6899                .wrapping_mul(6_364_136_223_846_793_005)
6900                .wrapping_add(1_442_695_040_888_963_407);
6901            usize::try_from(*state >> 33).unwrap() % n
6902        };
6903        for _ in 0..3 * n {
6904            let u = draw(&mut state);
6905            let v = draw(&mut state);
6906            push_edge(&mut stream, &mut key_counter, u, v);
6907        }
6908
6909        let mut g = MultiGraph::strict();
6910        let _ = g.extend_fresh_int_prefix_keyed_edges_unrecorded(n, stream.iter().copied());
6911        let slab = g.storage.clone();
6912        assert_eq!(
6913            slab.edges_ordered_borrowed(),
6914            g.edges_ordered_borrowed(),
6915            "arms must emit identical sequences"
6916        );
6917
6918        let time_string = || -> f64 {
6919            let t0 = Instant::now();
6920            let out = g.edges_ordered_borrowed();
6921            let dt = t0.elapsed().as_secs_f64();
6922            black_box(out.len());
6923            dt
6924        };
6925        let time_slab = || -> f64 {
6926            let t0 = Instant::now();
6927            let out = slab.edges_ordered_borrowed();
6928            let dt = t0.elapsed().as_secs_f64();
6929            black_box(out.len());
6930            dt
6931        };
6932        let median = |samples: &mut Vec<f64>| -> f64 {
6933            samples.sort_by(f64::total_cmp);
6934            samples[samples.len() / 2]
6935        };
6936        let (mut cur, mut sla, mut nul) = (Vec::new(), Vec::new(), Vec::new());
6937        for _ in 0..9 {
6938            cur.push(time_string());
6939            sla.push(time_slab());
6940            nul.push(time_string());
6941        }
6942        let (mc, ms, mn) = (median(&mut cur), median(&mut sla), median(&mut nul));
6943        println!(
6944            "THP6W_S12_EDGES_ORDERED_AB n={n} m={} string={mc:.6}s slab={ms:.6}s ratio_string_over_slab={:.3} null={:.3}",
6945            stream.len(),
6946            mc / ms,
6947            mc / mn,
6948        );
6949    }
6950
6951    /// br-r37-c1-thp6w S13 A/B: the INDEX-space `edges_ordered_indices_borrowed`
6952    /// read — String store (per-edge `nodes.get_index_of` String hash + `EdgeKey`
6953    /// pair hash) vs the slab (hash-free `slot->position` array + usize walk).
6954    /// Same graph state, output equality asserted outside timing, paired-
6955    /// interleaved with a null arm.
6956    /// `cargo test --release -p fnx-classes --lib thp6w_s13_edges_ordered_indices_ab -- --ignored --nocapture`
6957    #[test]
6958    #[ignore = "paired A/B benchmark; run --release with --ignored --nocapture"]
6959    fn thp6w_s13_edges_ordered_indices_ab() {
6960        use std::collections::HashMap;
6961        use std::hint::black_box;
6962        use std::time::Instant;
6963
6964        fn push_edge(
6965            stream: &mut Vec<(usize, usize, usize)>,
6966            key_counter: &mut HashMap<(usize, usize), usize>,
6967            u: usize,
6968            v: usize,
6969        ) {
6970            let entry = key_counter.entry((u.min(v), u.max(v))).or_insert(0);
6971            stream.push((u, v, *entry));
6972            *entry += 1;
6973        }
6974
6975        let n = 20000usize;
6976        let mut stream = Vec::new();
6977        let mut key_counter = HashMap::new();
6978        for i in 0..n {
6979            push_edge(&mut stream, &mut key_counter, i, (i + 1) % n);
6980        }
6981        let mut state = 0x2545_F491_4F6C_DD1D_u64;
6982        let draw = |state: &mut u64| -> usize {
6983            *state = state
6984                .wrapping_mul(6_364_136_223_846_793_005)
6985                .wrapping_add(1_442_695_040_888_963_407);
6986            usize::try_from(*state >> 33).unwrap() % n
6987        };
6988        for _ in 0..3 * n {
6989            let u = draw(&mut state);
6990            let v = draw(&mut state);
6991            push_edge(&mut stream, &mut key_counter, u, v);
6992        }
6993
6994        let mut g = MultiGraph::strict();
6995        let _ = g.extend_fresh_int_prefix_keyed_edges_unrecorded(n, stream.iter().copied());
6996        let slab = g.storage.clone();
6997        assert_eq!(
6998            slab.edges_ordered_indices_borrowed(),
6999            g.edges_ordered_indices_borrowed(),
7000            "arms must emit identical index sequences"
7001        );
7002
7003        let time_string = || -> f64 {
7004            let t0 = Instant::now();
7005            let out = g.edges_ordered_indices_borrowed();
7006            let dt = t0.elapsed().as_secs_f64();
7007            black_box(out.len());
7008            dt
7009        };
7010        let time_slab = || -> f64 {
7011            let t0 = Instant::now();
7012            let out = slab.edges_ordered_indices_borrowed();
7013            let dt = t0.elapsed().as_secs_f64();
7014            black_box(out.len());
7015            dt
7016        };
7017        let median = |samples: &mut Vec<f64>| -> f64 {
7018            samples.sort_by(f64::total_cmp);
7019            samples[samples.len() / 2]
7020        };
7021        let (mut cur, mut sla, mut nul) = (Vec::new(), Vec::new(), Vec::new());
7022        for _ in 0..9 {
7023            cur.push(time_string());
7024            sla.push(time_slab());
7025            nul.push(time_string());
7026        }
7027        let (mc, ms, mn) = (median(&mut cur), median(&mut sla), median(&mut nul));
7028        println!(
7029            "THP6W_S13_EDGES_ORDERED_INDICES_AB n={n} m={} string={mc:.6}s slab={ms:.6}s ratio_string_over_slab={:.3} null={:.3}",
7030            stream.len(),
7031            mc / ms,
7032            mc / mn,
7033        );
7034    }
7035
7036    fn assert_multigraph_storage_invariants(graph: &MultiGraph) {
7037        let storage = &graph.storage;
7038        assert_eq!(storage.slot_names.len(), storage.node_attrs.len());
7039        assert_eq!(storage.slot_names.len(), storage.rows.len());
7040        assert_eq!(graph.edge_count, storage.edge_count);
7041        assert_eq!(
7042            storage.edge_count,
7043            storage
7044                .edges
7045                .values()
7046                .map(super::multigraph_storage::CompactBucket::len)
7047                .sum::<usize>()
7048        );
7049
7050        let mut live_slots = vec![false; storage.slot_names.len()];
7051        for (name, &slot) in &storage.node_order {
7052            assert!(!live_slots[slot], "slot {slot} is assigned twice");
7053            live_slots[slot] = true;
7054            assert_eq!(storage.slot_names[slot].as_deref(), Some(name.as_str()));
7055        }
7056        assert_eq!(
7057            live_slots.iter().filter(|&&live| live).count(),
7058            graph.node_count()
7059        );
7060
7061        let mut free_slots = BTreeSet::new();
7062        for &slot in &storage.free_slots {
7063            assert!(free_slots.insert(slot), "free slot {slot} listed twice");
7064            assert!(!live_slots[slot], "live slot {slot} is also free");
7065            assert!(storage.slot_names[slot].is_none());
7066            assert!(storage.node_attrs[slot].is_empty());
7067            assert!(storage.rows[slot].is_empty());
7068        }
7069
7070        for (left, row) in storage.rows.iter().enumerate() {
7071            if !live_slots[left] {
7072                assert!(row.is_empty(), "tombstoned slot {left} has neighbors");
7073                continue;
7074            }
7075            for (&right, keys) in row {
7076                assert!(live_slots[right], "row points to tombstoned slot {right}");
7077                let pair = (left.min(right), left.max(right));
7078                let bucket = storage
7079                    .edges
7080                    .get(&pair)
7081                    .expect("every row cell must have an edge bucket");
7082                assert_eq!(keys.order(), bucket.key_order());
7083                if left != right {
7084                    assert_eq!(
7085                        storage.rows[right]
7086                            .get(&left)
7087                            .expect("undirected reverse row is missing")
7088                            .order(),
7089                        keys.order()
7090                    );
7091                }
7092            }
7093        }
7094        for (&(left, right), bucket) in &storage.edges {
7095            assert!(live_slots[left] && live_slots[right]);
7096            assert_eq!(
7097                storage.rows[left]
7098                    .get(&right)
7099                    .expect("edge bucket missing left row")
7100                    .order(),
7101                bucket.key_order()
7102            );
7103            if left != right {
7104                assert_eq!(
7105                    storage.rows[right]
7106                        .get(&left)
7107                        .expect("edge bucket missing right row")
7108                        .order(),
7109                    bucket.key_order()
7110                );
7111            }
7112        }
7113    }
7114
7115    #[test]
7116    fn thp6w_s35_slab_is_sole_store_mutation_gauntlet() {
7117        let attr = |key: &str, value: i64| {
7118            let mut attrs = AttrMap::new();
7119            attrs.insert(key.to_owned(), fnx_runtime::CgseValue::Int(value));
7120            attrs
7121        };
7122
7123        let mut graph = MultiGraph::strict();
7124        assert!(graph.add_node_with_attrs("iso", attr("kind", 1)));
7125        assert_eq!(
7126            graph.add_edge_with_key_and_attrs("b", "a", 3, attr("weight", 2)),
7127            Ok(3)
7128        );
7129        assert_eq!(
7130            graph.add_edge_with_key_and_attrs("a", "b", 3, attr("color", 7)),
7131            Ok(3)
7132        );
7133        assert_eq!(
7134            graph.add_edge_with_key_and_attrs("a", "b", 0, AttrMap::new()),
7135            Ok(0)
7136        );
7137        assert_eq!(
7138            graph.add_edge_with_key_and_attrs("c", "c", 5, attr("weight", 11)),
7139            Ok(5)
7140        );
7141        assert_eq!(graph.nodes_ordered(), vec!["iso", "b", "a", "c"]);
7142        assert_eq!(graph.edge_keys_vec("a", "b"), vec![3, 0]);
7143        assert_eq!(
7144            graph.edge_attrs("a", "b", 3),
7145            Some(&{
7146                let mut attrs = attr("weight", 2);
7147                attrs.extend(attr("color", 7));
7148                attrs
7149            })
7150        );
7151        assert_eq!(graph.number_of_selfloops(), 1);
7152        assert_eq!(graph.degree("c"), 2);
7153        assert_eq!(graph.isolates(), vec!["iso"]);
7154        assert_multigraph_storage_invariants(&graph);
7155        assert_int_adjacency_matches(&graph);
7156
7157        let baseline = graph.snapshot();
7158        let cloned = graph.clone_with_fresh_policy();
7159        assert_eq!(cloned.snapshot(), baseline);
7160        assert_multigraph_storage_invariants(&cloned);
7161
7162        let revision = graph.revision();
7163        assert!(graph.replace_edge_attrs("a", "b", 3, attr("replacement", 9)));
7164        assert_eq!(graph.revision(), revision + 1);
7165        let revision = graph.revision();
7166        assert!(graph.replace_edge_attrs("a", "b", 3, attr("replacement", 9)));
7167        assert_eq!(
7168            graph.revision(),
7169            revision,
7170            "no-op edge replace bumped revision"
7171        );
7172        assert!(graph.replace_node_attrs("iso", attr("kind", 4)));
7173        assert!(graph.remove_edge("b", "a", Some(3)));
7174        assert_eq!(graph.edge_keys_vec("a", "b"), vec![0]);
7175        assert!(graph.remove_node("b"));
7176        assert!(!graph.has_edge("a", "b"));
7177        assert_eq!(graph.nodes_ordered(), vec!["iso", "a", "c"]);
7178        assert_eq!(graph.add_edge("d", "a"), Ok(0));
7179        assert_eq!(graph.nodes_ordered(), vec!["iso", "a", "c", "d"]);
7180        assert_multigraph_storage_invariants(&graph);
7181
7182        let (removed_nodes, removed_edges) = graph.remove_nodes_from(["iso", "c", "missing"]);
7183        assert_eq!((removed_nodes, removed_edges), (2, 1));
7184        assert_eq!(graph.nodes_ordered(), vec!["a", "d"]);
7185        assert_eq!(graph.edge_count(), 1);
7186        assert_multigraph_storage_invariants(&graph);
7187        graph.clear_edges();
7188        assert_eq!(graph.edge_count(), 0);
7189        assert_eq!(graph.isolates(), vec!["a", "d"]);
7190        assert_multigraph_storage_invariants(&graph);
7191    }
7192
7193    #[test]
7194    fn thp6w_s35_fresh_loaders_populate_sole_store() {
7195        let mut integer = MultiGraph::strict();
7196        assert_eq!(
7197            integer.extend_fresh_int_prefix_keyed_edges_unrecorded(
7198                4,
7199                [(0, 1, 0), (1, 0, 1), (2, 2, 0), (3, 0, 0)]
7200            ),
7201            4
7202        );
7203        assert_eq!(integer.nodes_ordered(), vec!["0", "1", "2", "3"]);
7204        assert_eq!(integer.edge_keys_vec("0", "1"), vec![0, 1]);
7205        assert_eq!(integer.number_of_selfloops(), 1);
7206        assert_multigraph_storage_invariants(&integer);
7207        assert_int_adjacency_matches(&integer);
7208
7209        let mut weight = AttrMap::new();
7210        weight.insert("weight".to_owned(), fnx_runtime::CgseValue::Int(2));
7211        let mut color = AttrMap::new();
7212        color.insert("color".to_owned(), fnx_runtime::CgseValue::Int(7));
7213        let mut attributed = MultiGraph::strict();
7214        assert_eq!(
7215            attributed.extend_fresh_index_keyed_edges_with_attrs_unrecorded(
7216                ["b", "a", "c"].into_iter().map(str::to_owned),
7217                [
7218                    (0, 1, 4, weight),
7219                    (1, 0, 4, color),
7220                    (2, 2, 0, AttrMap::new()),
7221                    (0, 9, 0, AttrMap::new()),
7222                ]
7223            ),
7224            2
7225        );
7226        assert_eq!(attributed.nodes_ordered(), vec!["b", "a", "c"]);
7227        assert_eq!(attributed.edge_count(), 2);
7228        assert_eq!(attributed.edge_keys_vec("a", "b"), vec![4]);
7229        let attrs = attributed
7230            .edge_attrs("a", "b", 4)
7231            .expect("merged attributed edge");
7232        assert_eq!(attrs.len(), 2);
7233        assert!(attrs.contains_key("weight"));
7234        assert!(attrs.contains_key("color"));
7235        assert_multigraph_storage_invariants(&attributed);
7236    }
7237
7238    /// br-r37-c1-thp6w S14: a fresh bulk-built graph must be WARM from birth
7239    /// (the slab co-built during construction), byte-identical, and stay warm
7240    /// through instrumented mutations. NOTE: `edges_ordered_borrowed` is a
7241    /// ROUTED read (S13) — comparing it against the shadow would be circular
7242    /// with a warm shadow, so this gauntlet uses only unrouted ground-truth
7243    /// accessors (`nodes_ordered`, `neighbors_iter`, `edge_keys_vec`); the
7244    /// walk itself is gated non-circularly by the S13 route gate.
7245    #[cfg(any())]
7246    #[test]
7247    fn thp6w_s14_fresh_bulk_graph_is_warm_from_birth() {
7248        let mut g = MultiGraph::strict();
7249        let stream = vec![
7250            (0usize, 1usize, 0usize),
7251            (1, 2, 0),
7252            (0, 1, 1), // parallel
7253            (2, 2, 0), // self-loop
7254            (3, 0, 0),
7255        ];
7256        let _ = g.extend_fresh_int_prefix_keyed_edges_unrecorded(5, stream.into_iter());
7257        assert!(
7258            g.slab_shadow_is_warm(),
7259            "fresh bulk graph must be born warm"
7260        );
7261        {
7262            let shadow = g.slab_shadow.as_deref().expect("shadow present");
7263            assert_slab_matches_mg(&g, &shadow.1);
7264        }
7265        // Instrumented mutations keep the born-warm shadow warm.
7266        let _ = g.add_edge("2", "4");
7267        assert!(g.slab_shadow_is_warm());
7268        assert!(g.remove_edge("0", "1", None));
7269        assert!(g.slab_shadow_is_warm());
7270        assert!(g.remove_node("1"));
7271        assert!(g.slab_shadow_is_warm());
7272        let shadow = g.slab_shadow.as_deref().expect("shadow present");
7273        assert_slab_matches_mg(&g, &shadow.1);
7274
7275        // br-r37-c1-thp6w S17: a node-attr set now ADVANCES the warm shadow in
7276        // place (was stale->rebuild in S16). Existing node "2":
7277        let mut nattrs = AttrMap::new();
7278        nattrs.insert("color".to_owned(), fnx_runtime::CgseValue::Int(7));
7279        let _ = g.add_node_with_attrs("2", nattrs);
7280        assert!(
7281            g.slab_shadow_is_warm(),
7282            "node-attr set must advance (not stale) the warm shadow"
7283        );
7284        {
7285            let shadow = g.slab_shadow.as_deref().expect("shadow present");
7286            assert_slab_matches_mg(&g, &shadow.1);
7287            assert_eq!(
7288                shadow.1.node_attrs_for("2"),
7289                g.nodes.get("2"),
7290                "advanced shadow must carry the set node attr for node 2"
7291            );
7292        }
7293        // A node-attr set that CREATES a new node advances the shadow too.
7294        let mut nattrs2 = AttrMap::new();
7295        nattrs2.insert("w".to_owned(), fnx_runtime::CgseValue::Int(9));
7296        let _ = g.add_node_with_attrs("attr_only", nattrs2);
7297        assert!(g.slab_shadow_is_warm());
7298        {
7299            let shadow = g.slab_shadow.as_deref().expect("shadow present");
7300            assert_slab_matches_mg(&g, &shadow.1);
7301            assert_eq!(
7302                shadow.1.node_attrs_for("attr_only"),
7303                g.nodes.get("attr_only"),
7304                "advanced shadow must carry a new attr-only node"
7305            );
7306        }
7307        // A full rebuild still reproduces the same node attrs (from_string_state).
7308        g.sync_slab_shadow();
7309        {
7310            let shadow = g.slab_shadow.as_deref().expect("shadow present");
7311            assert_slab_matches_mg(&g, &shadow.1);
7312        }
7313
7314        // br-r37-c1-thp6w S15: the ATTRIBUTED fresh path is born warm too —
7315        // arbitrary labels, dup-(pair,key) attr merge, out-of-bounds skip.
7316        let mut h = MultiGraph::strict();
7317        let mut w1 = AttrMap::new();
7318        w1.insert("w".to_owned(), fnx_runtime::CgseValue::Int(1));
7319        let mut w2 = AttrMap::new();
7320        w2.insert("x".to_owned(), fnx_runtime::CgseValue::Int(2));
7321        let _ = h.extend_fresh_index_keyed_edges_with_attrs_unrecorded(
7322            vec!["b".to_owned(), "a".to_owned(), "c".to_owned()],
7323            vec![
7324                (0, 1, 0, w1.clone()),
7325                (1, 0, 0, w2),             // dup (pair, key) reversed -> attr merge
7326                (2, 2, 0, w1),             // self-loop
7327                (0, 9, 0, AttrMap::new()), // out-of-bounds -> skipped by BOTH sides
7328                (1, 2, 3, AttrMap::new()), // sparse key
7329            ],
7330        );
7331        assert!(h.slab_shadow_is_warm(), "attributed fresh graph born warm");
7332        let shadow = h.slab_shadow.as_deref().expect("shadow present");
7333        assert_slab_matches_mg(&h, &shadow.1);
7334        assert_eq!(
7335            shadow.1.edges[&(0, 1)].attrs_for(0),
7336            h.edges
7337                .get(&super::EdgeKeyRef::new("b", "a"))
7338                .and_then(|kb| kb.get(&0)),
7339            "merged attrs on the dup cell must match"
7340        );
7341    }
7342
7343    /// br-r37-c1-thp6w S19: batch `remove_nodes_from` advances the warm shadow
7344    /// (per-node slab remove, stable slots) on BOTH its exit paths — the
7345    /// small-fraction incident fast path and the large-fraction whole-graph
7346    /// retain — and never spuriously warms a stale shadow.
7347    #[cfg(any())]
7348    #[test]
7349    fn thp6w_s19_remove_nodes_from_advances_shadow() {
7350        let mut g = MultiGraph::strict();
7351        for i in 0..12u32 {
7352            let _ = g.add_edge(&i.to_string(), &((i + 1) % 12).to_string());
7353        }
7354        let _ = g.add_edge("0", "6"); // a chord
7355        let _ = g.add_edge("3", "3"); // a self-loop
7356        g.sync_slab_shadow();
7357        assert!(g.slab_shadow_is_warm());
7358
7359        // Fast path: small fraction (2*4 <= 12 nodes).
7360        let (rn, _) = g.remove_nodes_from(["3", "9"].into_iter());
7361        assert_eq!(rn, 2);
7362        assert!(
7363            g.slab_shadow_is_warm(),
7364            "remove_nodes_from fast path must advance the shadow"
7365        );
7366        {
7367            let shadow = g.slab_shadow.as_deref().expect("shadow present");
7368            assert_slab_matches_mg(&g, &shadow.1);
7369        }
7370
7371        // Fall-through path: large fraction (5*4 > 10 remaining nodes).
7372        let (rn2, _) = g.remove_nodes_from(["0", "1", "2", "4", "5"].into_iter());
7373        assert_eq!(rn2, 5);
7374        assert!(
7375            g.slab_shadow_is_warm(),
7376            "remove_nodes_from fall-through must advance the shadow"
7377        );
7378        {
7379            let shadow = g.slab_shadow.as_deref().expect("shadow present");
7380            assert_slab_matches_mg(&g, &shadow.1);
7381        }
7382
7383        // A STALE shadow must not be spuriously warmed by the removal.
7384        g.add_node("island"); // structural (empty attrs) -> stales
7385        assert!(!g.slab_shadow_is_warm());
7386        let _ = g.remove_nodes_from(["island"].into_iter());
7387        assert!(
7388            !g.slab_shadow_is_warm(),
7389            "remove_nodes_from must not warm a stale shadow"
7390        );
7391        g.sync_slab_shadow();
7392        let shadow = g.slab_shadow.as_deref().expect("shadow present");
7393        assert_slab_matches_mg(&g, &shadow.1);
7394    }
7395
7396    /// br-r37-c1-thp6w S20: batch `extend_nodes_with_attrs_unrecorded` advances
7397    /// the warm shadow — including the latent attr-only case (no NEW nodes, so
7398    /// no revision bump) where a warm shadow would otherwise carry stale attrs.
7399    #[cfg(any())]
7400    #[test]
7401    fn thp6w_s20_extend_nodes_with_attrs_advances_shadow() {
7402        let mut g = MultiGraph::strict();
7403        for i in 0..6u32 {
7404            let _ = g.add_edge(&i.to_string(), &((i + 1) % 6).to_string());
7405        }
7406        g.sync_slab_shadow();
7407        assert!(g.slab_shadow_is_warm());
7408
7409        let attr = |k: &str, v: i64| {
7410            let mut a = AttrMap::new();
7411            a.insert(k.to_owned(), fnx_runtime::CgseValue::Int(v));
7412            a
7413        };
7414        // Batch: 2 new attr-only nodes + an attr update on existing node "0".
7415        let ins = g.extend_nodes_with_attrs_unrecorded(vec![
7416            ("new_a".to_owned(), attr("c", 1)),
7417            ("new_b".to_owned(), attr("c", 2)),
7418            ("0".to_owned(), attr("k", 3)),
7419        ]);
7420        assert_eq!(ins, 2, "two new nodes");
7421        assert!(
7422            g.slab_shadow_is_warm(),
7423            "node-attr batch must advance the shadow"
7424        );
7425        {
7426            let shadow = g.slab_shadow.as_deref().expect("shadow present");
7427            assert_slab_matches_mg(&g, &shadow.1);
7428            assert_eq!(shadow.1.node_attrs_for("new_a"), g.nodes.get("new_a"));
7429            assert_eq!(shadow.1.node_attrs_for("0"), g.nodes.get("0"));
7430        }
7431
7432        // ATTR-ONLY batch (no new nodes -> no revision bump): the shadow must
7433        // still carry the updated attr, not stay warm-but-stale.
7434        let ins2 = g.extend_nodes_with_attrs_unrecorded(vec![("new_a".to_owned(), attr("c", 99))]);
7435        assert_eq!(ins2, 0, "no new nodes");
7436        assert!(
7437            g.slab_shadow_is_warm(),
7438            "attr-only batch keeps the shadow warm"
7439        );
7440        {
7441            let shadow = g.slab_shadow.as_deref().expect("shadow present");
7442            assert_eq!(
7443                shadow.1.node_attrs_for("new_a"),
7444                g.nodes.get("new_a"),
7445                "attr-only update must be reflected in the (still-warm) shadow"
7446            );
7447        }
7448    }
7449
7450    /// br-r37-c1-thp6w S21: `replace_node_attrs` (node attr OVERWRITE) advances
7451    /// the warm shadow; a no-op replace (no revision bump) and a missing-node
7452    /// replace leave it warm and uncorrupted.
7453    #[cfg(any())]
7454    #[test]
7455    fn thp6w_s21_replace_node_attrs_advances_shadow() {
7456        let attr = |k: &str, v: i64| {
7457            let mut a = AttrMap::new();
7458            a.insert(k.to_owned(), fnx_runtime::CgseValue::Int(v));
7459            a
7460        };
7461        let mut g = MultiGraph::strict();
7462        let _ = g.add_node_with_attrs("x", attr("c", 1));
7463        let _ = g.add_edge("x", "y");
7464        g.sync_slab_shadow();
7465        assert!(g.slab_shadow_is_warm());
7466
7467        // Overwrite x's attrs -> advances the warm shadow.
7468        assert!(g.replace_node_attrs("x", attr("d", 2)));
7469        assert!(
7470            g.slab_shadow_is_warm(),
7471            "replace_node_attrs must advance the shadow"
7472        );
7473        {
7474            let shadow = g.slab_shadow.as_deref().expect("shadow present");
7475            assert_slab_matches_mg(&g, &shadow.1);
7476            assert_eq!(
7477                shadow.1.node_attrs_for("x"),
7478                g.nodes.get("x"),
7479                "shadow must reflect the overwrite"
7480            );
7481        }
7482
7483        // No-op replace (same attrs) does not bump revision; shadow stays warm.
7484        let same = g.nodes.get("x").cloned().unwrap_or_default();
7485        assert!(g.replace_node_attrs("x", same));
7486        assert!(g.slab_shadow_is_warm());
7487
7488        // Replace on a missing node returns false and leaves the shadow warm.
7489        assert!(!g.replace_node_attrs("missing", attr("z", 9)));
7490        assert!(g.slab_shadow_is_warm());
7491        let shadow = g.slab_shadow.as_deref().expect("shadow present");
7492        assert_slab_matches_mg(&g, &shadow.1);
7493    }
7494
7495    /// br-r37-c1-thp6w S22: `replace_edge_attrs` (edge-cell attr OVERWRITE)
7496    /// advances the warm shadow, leaves parallel cells untouched, and a no-op
7497    /// or missing-edge replace leaves the shadow warm/uncorrupted.
7498    #[cfg(any())]
7499    #[test]
7500    fn thp6w_s22_replace_edge_attrs_advances_shadow() {
7501        let attr = |k: &str, v: i64| {
7502            let mut a = AttrMap::new();
7503            a.insert(k.to_owned(), fnx_runtime::CgseValue::Int(v));
7504            a
7505        };
7506        let mut g = MultiGraph::strict();
7507        let _ = g.add_edge_with_key_and_attrs("a", "b", 0, attr("w", 1));
7508        let _ = g.add_edge_with_key_and_attrs("a", "b", 1, attr("w", 2)); // parallel
7509        let _ = g.add_edge("b", "c");
7510        g.sync_slab_shadow();
7511        assert!(g.slab_shadow_is_warm());
7512
7513        // Overwrite the (a,b,0) cell attrs -> advances the warm shadow.
7514        assert!(g.replace_edge_attrs("a", "b", 0, attr("x", 5)));
7515        assert!(
7516            g.slab_shadow_is_warm(),
7517            "replace_edge_attrs must advance the shadow"
7518        );
7519        {
7520            let shadow = g.slab_shadow.as_deref().expect("shadow present");
7521            assert_slab_matches_mg(&g, &shadow.1);
7522            // a=slot0, b=slot1 (fresh graph) -> the a,b bucket is (0,1).
7523            assert_eq!(
7524                shadow.1.edges[&(0, 1)].attrs_for(0),
7525                g.edges
7526                    .get(&super::EdgeKeyRef::new("a", "b"))
7527                    .and_then(|kb| kb.get(&0)),
7528                "advanced shadow must carry the overwritten cell attrs"
7529            );
7530            assert_eq!(
7531                shadow.1.edges[&(0, 1)].attrs_for(1),
7532                g.edges
7533                    .get(&super::EdgeKeyRef::new("a", "b"))
7534                    .and_then(|kb| kb.get(&1)),
7535                "the parallel cell (key 1) must be untouched"
7536            );
7537        }
7538
7539        // No-op replace (same attrs): no revision bump, shadow stays warm.
7540        let same = g.edge_attrs("a", "b", 0).cloned().unwrap_or_default();
7541        assert!(g.replace_edge_attrs("a", "b", 0, same));
7542        assert!(g.slab_shadow_is_warm());
7543
7544        // Missing edge -> false, shadow left warm/uncorrupted.
7545        assert!(!g.replace_edge_attrs("a", "z", 0, attr("q", 1)));
7546        assert!(g.slab_shadow_is_warm());
7547        let shadow = g.slab_shadow.as_deref().expect("shadow present");
7548        assert_slab_matches_mg(&g, &shadow.1);
7549    }
7550
7551    /// br-r37-c1-thp6w S23: feature-on `node_attrs` READS route through the
7552    /// always-warm slab. Verified against the unrouted String ground truth
7553    /// (`g.nodes` direct field access) — non-circular — across present (with
7554    /// and without attrs) and missing nodes, and after a mutation keeps the
7555    /// shadow warm so the routed read stays correct.
7556    #[cfg(any())]
7557    #[test]
7558    fn thp6w_s23_node_attrs_reads_route_to_slab() {
7559        let attr = |k: &str, v: i64| {
7560            let mut a = AttrMap::new();
7561            a.insert(k.to_owned(), fnx_runtime::CgseValue::Int(v));
7562            a
7563        };
7564        let mut g = MultiGraph::strict();
7565        let _ = g.add_node_with_attrs("x", attr("c", 1));
7566        let _ = g.add_edge("x", "y"); // y born attr-less
7567        // A fresh graph built by individual adds has no shadow yet; sync it warm
7568        // (individual adds only ADVANCE an existing warm shadow, never create one).
7569        g.sync_slab_shadow();
7570        assert!(g.slab_shadow_is_warm());
7571        // Routed read (-> slab) == String field ground truth.
7572        assert_eq!(
7573            g.node_attrs("x"),
7574            g.nodes.get("x"),
7575            "attr node routes to slab"
7576        );
7577        assert_eq!(g.node_attrs("y"), g.nodes.get("y"), "attr-less node routes");
7578        assert_eq!(g.node_attrs("missing"), g.nodes.get("missing"), "both None");
7579        assert!(g.node_attrs("x").is_some_and(|a| a.get("c").is_some()));
7580        // After an overwrite the shadow stays warm; the routed read reflects it.
7581        assert!(g.replace_node_attrs("x", attr("d", 9)));
7582        assert!(g.slab_shadow_is_warm());
7583        assert_eq!(
7584            g.node_attrs("x"),
7585            g.nodes.get("x"),
7586            "routed read tracks overwrite"
7587        );
7588        // With a STALE/absent shadow the read falls through to the String store.
7589        let mut h = MultiGraph::strict();
7590        let _ = h.add_node_with_attrs("p", attr("c", 3));
7591        assert!(
7592            !h.slab_shadow_is_warm(),
7593            "fresh individual-add graph has no shadow"
7594        );
7595        assert_eq!(
7596            h.node_attrs("p"),
7597            h.nodes.get("p"),
7598            "unrouted fallback still correct"
7599        );
7600    }
7601
7602    /// br-r37-c1-thp6w S24: feature-on `edge_attrs` READS route through the
7603    /// always-warm slab. Verified against the unrouted String field ground
7604    /// truth (`g.edges` direct access) across present cells (incl. a parallel
7605    /// key), a missing key, and a missing edge, plus the no-shadow fallback.
7606    #[cfg(any())]
7607    #[test]
7608    fn thp6w_s24_edge_attrs_reads_route_to_slab() {
7609        let attr = |k: &str, v: i64| {
7610            let mut a = AttrMap::new();
7611            a.insert(k.to_owned(), fnx_runtime::CgseValue::Int(v));
7612            a
7613        };
7614        let gt = |g: &MultiGraph, l: &str, r: &str, k: usize| {
7615            g.edges
7616                .get(&super::EdgeKeyRef::new(l, r))
7617                .and_then(|kb| kb.get(&k))
7618                .cloned()
7619        };
7620        let mut g = MultiGraph::strict();
7621        let _ = g.add_edge_with_key_and_attrs("a", "b", 0, attr("w", 1));
7622        let _ = g.add_edge_with_key_and_attrs("a", "b", 1, attr("w", 2)); // parallel
7623        let _ = g.add_edge("b", "c");
7624        g.sync_slab_shadow();
7625        assert!(g.slab_shadow_is_warm());
7626        // Routed read (-> slab) == String field ground truth.
7627        assert_eq!(g.edge_attrs("a", "b", 0).cloned(), gt(&g, "a", "b", 0));
7628        assert_eq!(g.edge_attrs("a", "b", 1).cloned(), gt(&g, "a", "b", 1));
7629        assert_eq!(g.edge_attrs("a", "b", 9), None, "missing key");
7630        assert_eq!(g.edge_attrs("a", "z", 0), None, "missing edge");
7631        // Overwrite advances the shadow; the routed read reflects it.
7632        assert!(g.replace_edge_attrs("a", "b", 0, attr("x", 8)));
7633        assert!(g.slab_shadow_is_warm());
7634        assert_eq!(g.edge_attrs("a", "b", 0).cloned(), gt(&g, "a", "b", 0));
7635        // No-shadow graph falls through to the String store.
7636        let mut h = MultiGraph::strict();
7637        let _ = h.add_edge_with_key_and_attrs("p", "q", 0, attr("z", 5));
7638        assert!(!h.slab_shadow_is_warm());
7639        assert_eq!(h.edge_attrs("p", "q", 0).cloned(), gt(&h, "p", "q", 0));
7640    }
7641
7642    /// br-r37-c1-thp6w S25: feature-on `edge_keys_vec` READS route through the
7643    /// always-warm slab (`key_order`). Verified against the unrouted String
7644    /// field ground truth (`g.edges` bucket keys), incl. parallel keys, a
7645    /// removal advance, missing edge, and the no-shadow fallback.
7646    #[cfg(any())]
7647    #[test]
7648    fn thp6w_s25_edge_keys_vec_reads_route_to_slab() {
7649        let gt = |g: &MultiGraph, l: &str, r: &str| -> Vec<usize> {
7650            g.edges
7651                .get(&super::EdgeKeyRef::new(l, r))
7652                .map(|b| b.keys().copied().collect())
7653                .unwrap_or_default()
7654        };
7655        let mut g = MultiGraph::strict();
7656        let _ = g.add_edge("a", "b"); // auto key 0
7657        let _ = g.add_edge("a", "b"); // auto key 1
7658        let _ = g.add_edge("a", "b"); // auto key 2
7659        let _ = g.add_edge("b", "c");
7660        g.sync_slab_shadow();
7661        assert!(g.slab_shadow_is_warm());
7662        assert_eq!(
7663            g.edge_keys_vec("a", "b"),
7664            gt(&g, "a", "b"),
7665            "routed key order"
7666        );
7667        assert_eq!(g.edge_keys_vec("b", "c"), gt(&g, "b", "c"));
7668        assert_eq!(
7669            g.edge_keys_vec("a", "z"),
7670            Vec::<usize>::new(),
7671            "missing edge"
7672        );
7673        // A per-key removal advances the shadow; the routed read reflects it.
7674        assert!(g.remove_edge("a", "b", Some(1)));
7675        assert!(g.slab_shadow_is_warm());
7676        assert_eq!(g.edge_keys_vec("a", "b"), gt(&g, "a", "b"), "after removal");
7677        // No-shadow graph falls through to the String store.
7678        let mut h = MultiGraph::strict();
7679        let _ = h.add_edge("p", "q");
7680        assert!(!h.slab_shadow_is_warm());
7681        assert_eq!(h.edge_keys_vec("p", "q"), gt(&h, "p", "q"));
7682    }
7683
7684    /// br-r37-c1-thp6w S26: feature-on `neighbors` READS route through the
7685    /// always-warm slab. Verified against the unrouted String field ground
7686    /// truth (`g.adjacency` row keys) incl. row order, a removal advance, a
7687    /// missing node (None), and the no-shadow fallback.
7688    #[cfg(any())]
7689    #[test]
7690    fn thp6w_s26_neighbors_reads_route_to_slab() {
7691        fn gt<'a>(g: &'a MultiGraph, n: &str) -> Option<Vec<&'a str>> {
7692            g.adjacency
7693                .get(n)
7694                .map(|row| row.keys().map(String::as_str).collect())
7695        }
7696        let mut g = MultiGraph::strict();
7697        let _ = g.add_edge("a", "b");
7698        let _ = g.add_edge("a", "c");
7699        let _ = g.add_edge("a", "b"); // parallel — distinct neighbor unchanged
7700        let _ = g.add_edge("c", "d");
7701        g.sync_slab_shadow();
7702        assert!(g.slab_shadow_is_warm());
7703        assert_eq!(
7704            g.neighbors("a"),
7705            gt(&g, "a"),
7706            "routed distinct neighbors, row order"
7707        );
7708        assert_eq!(g.neighbors("c"), gt(&g, "c"));
7709        assert_eq!(g.neighbors("missing"), None, "absent node -> None");
7710        assert_eq!(g.neighbors("missing"), gt(&g, "missing"));
7711        // A removal advances the shadow; the routed read reflects it.
7712        assert!(g.remove_edge("a", "c", None));
7713        assert!(g.slab_shadow_is_warm());
7714        assert_eq!(g.neighbors("a"), gt(&g, "a"), "after removal");
7715        // No-shadow graph falls through to the String store.
7716        let mut h = MultiGraph::strict();
7717        let _ = h.add_edge("p", "q");
7718        assert!(!h.slab_shadow_is_warm());
7719        assert_eq!(h.neighbors("p"), gt(&h, "p"));
7720    }
7721
7722    /// br-r37-c1-thp6w S27: feature-on `degree` READS route through the
7723    /// always-warm slab. Verified against the unrouted String field ground
7724    /// truth (same self-loop-double + parallel-count formula over
7725    /// `g.adjacency`) incl. a removal advance, a missing node (0), and the
7726    /// no-shadow fallback.
7727    #[cfg(any())]
7728    #[test]
7729    fn thp6w_s27_degree_reads_route_to_slab() {
7730        let gt = |g: &MultiGraph, n: &str| -> usize {
7731            g.adjacency.get(n).map_or(0, |row| {
7732                let mut d = 0;
7733                for (nbr, keys) in row {
7734                    let c = keys.len();
7735                    if nbr.as_str() == n {
7736                        d += c * 2;
7737                    } else {
7738                        d += c;
7739                    }
7740                }
7741                d
7742            })
7743        };
7744        let mut g = MultiGraph::strict();
7745        let _ = g.add_edge("a", "b");
7746        let _ = g.add_edge("a", "b"); // parallel
7747        let _ = g.add_edge("a", "c");
7748        let _ = g.add_edge("a", "a"); // self-loop -> +2 to a
7749        g.sync_slab_shadow();
7750        assert!(g.slab_shadow_is_warm());
7751        assert_eq!(g.degree("a"), gt(&g, "a"), "self-loop double + parallel");
7752        assert_eq!(g.degree("b"), gt(&g, "b"));
7753        assert_eq!(g.degree("missing"), 0, "absent node -> 0");
7754        assert_eq!(g.degree("missing"), gt(&g, "missing"));
7755        // A removal advances the shadow; the routed read reflects it.
7756        assert!(g.remove_edge("a", "b", None));
7757        assert!(g.slab_shadow_is_warm());
7758        assert_eq!(g.degree("a"), gt(&g, "a"), "after removal");
7759        // No-shadow graph falls through to the String store.
7760        let mut h = MultiGraph::strict();
7761        let _ = h.add_edge("p", "q");
7762        assert!(!h.slab_shadow_is_warm());
7763        assert_eq!(h.degree("p"), gt(&h, "p"));
7764    }
7765
7766    /// br-r37-c1-thp6w S28: feature-on `has_node` / `has_edge` membership READS
7767    /// route through the always-warm slab. Verified against the unrouted String
7768    /// field ground truth incl. isolated node, undirected symmetry, a removal
7769    /// advance, missing node/edge, and the no-shadow fallback.
7770    #[cfg(any())]
7771    #[test]
7772    fn thp6w_s28_membership_reads_route_to_slab() {
7773        let he = |g: &MultiGraph, l: &str, r: &str| {
7774            g.edges
7775                .get(&super::EdgeKeyRef::new(l, r))
7776                .is_some_and(|b| !b.is_empty())
7777        };
7778        let mut g = MultiGraph::strict();
7779        let _ = g.add_edge("a", "b");
7780        let _ = g.add_edge("b", "c");
7781        let _ = g.add_node("iso"); // isolated node
7782        g.sync_slab_shadow();
7783        assert!(g.slab_shadow_is_warm());
7784        // has_node
7785        assert_eq!(g.has_node("a"), g.nodes.contains_key("a"));
7786        assert_eq!(g.has_node("iso"), g.nodes.contains_key("iso"));
7787        assert_eq!(g.has_node("missing"), g.nodes.contains_key("missing"));
7788        assert!(g.has_node("iso") && !g.has_node("missing"));
7789        // has_edge (undirected: symmetric; missing nodes -> false)
7790        assert_eq!(g.has_edge("a", "b"), he(&g, "a", "b"));
7791        assert_eq!(g.has_edge("b", "a"), he(&g, "b", "a"));
7792        assert_eq!(g.has_edge("a", "c"), he(&g, "a", "c"));
7793        assert_eq!(g.has_edge("x", "y"), he(&g, "x", "y"));
7794        assert!(g.has_edge("a", "b") && g.has_edge("b", "a") && !g.has_edge("a", "c"));
7795        // A removal advances the shadow; membership reflects it.
7796        assert!(g.remove_edge("a", "b", None));
7797        assert!(g.slab_shadow_is_warm());
7798        assert_eq!(g.has_edge("a", "b"), he(&g, "a", "b"));
7799        assert!(!g.has_edge("a", "b"));
7800        // No-shadow graph falls through to the String store.
7801        let mut h = MultiGraph::strict();
7802        let _ = h.add_edge("p", "q");
7803        assert!(!h.slab_shadow_is_warm());
7804        assert_eq!(h.has_edge("p", "q"), he(&h, "p", "q"));
7805        assert_eq!(h.has_node("p"), h.nodes.contains_key("p"));
7806    }
7807
7808    /// br-r37-c1-thp6w S29: feature-on `nodes_ordered` READS route through the
7809    /// always-warm slab. Verified against the unrouted String field ground
7810    /// truth (`g.nodes.keys()`) + exact insertion order, a node-removal advance
7811    /// (order compaction), and the no-shadow fallback.
7812    #[cfg(any())]
7813    #[test]
7814    fn thp6w_s29_nodes_ordered_reads_route_to_slab() {
7815        fn gt<'a>(g: &'a MultiGraph) -> Vec<&'a str> {
7816            g.nodes.keys().map(String::as_str).collect()
7817        }
7818        let mut g = MultiGraph::strict();
7819        let _ = g.add_edge("c", "a"); // insertion: c, a
7820        let _ = g.add_edge("a", "b"); // b
7821        let _ = g.add_node("z"); // z
7822        g.sync_slab_shadow();
7823        assert!(g.slab_shadow_is_warm());
7824        assert_eq!(g.nodes_ordered(), gt(&g), "routed node order == String");
7825        assert_eq!(
7826            g.nodes_ordered(),
7827            vec!["c", "a", "b", "z"],
7828            "insertion order"
7829        );
7830        // A node removal advances the shadow; order compacts.
7831        assert!(g.remove_node("a"));
7832        assert!(g.slab_shadow_is_warm());
7833        assert_eq!(g.nodes_ordered(), gt(&g), "after removal");
7834        assert_eq!(g.nodes_ordered(), vec!["c", "b", "z"]);
7835        // No-shadow graph falls through to the String store.
7836        let mut h = MultiGraph::strict();
7837        let _ = h.add_edge("p", "q");
7838        assert!(!h.slab_shadow_is_warm());
7839        assert_eq!(h.nodes_ordered(), gt(&h));
7840    }
7841
7842    /// br-r37-c1-thp6w S30: feature-on `is_isolate` READS route through the
7843    /// always-warm slab. Verified against the unrouted String field ground
7844    /// truth incl. an isolated node, a self-loop node (NOT isolated), a node
7845    /// with edges, a missing node, a removal that isolates, and the no-shadow
7846    /// fallback.
7847    #[cfg(any())]
7848    #[test]
7849    fn thp6w_s30_is_isolate_reads_route_to_slab() {
7850        let gt = |g: &MultiGraph, n: &str| -> bool {
7851            g.nodes.contains_key(n) && g.adjacency.get(n).is_none_or(|row| row.is_empty())
7852        };
7853        let mut g = MultiGraph::strict();
7854        let _ = g.add_edge("a", "b");
7855        let _ = g.add_node("iso"); // isolated
7856        let _ = g.add_edge("s", "s"); // self-loop -> NOT isolated
7857        g.sync_slab_shadow();
7858        assert!(g.slab_shadow_is_warm());
7859        assert_eq!(g.is_isolate("iso"), gt(&g, "iso"));
7860        assert!(g.is_isolate("iso"));
7861        assert_eq!(g.is_isolate("a"), gt(&g, "a"));
7862        assert!(!g.is_isolate("a"));
7863        assert_eq!(g.is_isolate("s"), gt(&g, "s"));
7864        assert!(!g.is_isolate("s"), "self-loop node is not isolated");
7865        assert_eq!(g.is_isolate("missing"), gt(&g, "missing"));
7866        assert!(!g.is_isolate("missing"));
7867        // A removal isolates node "a"; the routed read reflects it.
7868        assert!(g.remove_edge("a", "b", None));
7869        assert!(g.slab_shadow_is_warm());
7870        assert_eq!(g.is_isolate("a"), gt(&g, "a"));
7871        assert!(g.is_isolate("a"));
7872        // No-shadow graph falls through to the String store.
7873        let mut h = MultiGraph::strict();
7874        let _ = h.add_node("lonely");
7875        assert!(!h.slab_shadow_is_warm());
7876        assert_eq!(h.is_isolate("lonely"), gt(&h, "lonely"));
7877    }
7878
7879    /// br-r37-c1-thp6w S31: feature-on `weighted_size_int` READS route through
7880    /// the always-warm slab. Verified against the unrouted String field ground
7881    /// truth (same int-sum/missing=1/non-int=None formula over `g.edges`) incl.
7882    /// parallel + self-loop weights, a removal advance, a non-int (Float) weight
7883    /// -> None, and the no-shadow fallback.
7884    #[cfg(any())]
7885    #[test]
7886    fn thp6w_s31_weighted_size_int_routes_to_slab() {
7887        fn gt(g: &MultiGraph, w: &str) -> Option<i128> {
7888            let mut total = 0i128;
7889            for bucket in g.edges.values() {
7890                for attrs in bucket.values() {
7891                    let v = match attrs.get(w) {
7892                        Some(fnx_runtime::CgseValue::Int(x)) => i128::from(*x),
7893                        Some(_) => return None,
7894                        None => 1,
7895                    };
7896                    total = total.checked_add(v)?;
7897                }
7898            }
7899            Some(total)
7900        }
7901        let w = |v: i64| -> AttrMap {
7902            let mut a = AttrMap::new();
7903            a.insert("weight".to_owned(), fnx_runtime::CgseValue::Int(v));
7904            a
7905        };
7906        let mut g = MultiGraph::strict();
7907        let _ = g.add_edge_with_key_and_attrs("a", "b", 0, w(5));
7908        let _ = g.add_edge_with_key_and_attrs("a", "b", 1, w(3)); // parallel
7909        let _ = g.add_edge("b", "c"); // no weight -> counts as 1
7910        let _ = g.add_edge_with_key_and_attrs("c", "c", 0, w(2)); // self-loop
7911        g.sync_slab_shadow();
7912        assert!(g.slab_shadow_is_warm());
7913        assert_eq!(
7914            g.weighted_size_int("weight"),
7915            gt(&g, "weight"),
7916            "routed == String"
7917        );
7918        assert_eq!(g.weighted_size_int("weight"), Some(11), "5+3+1+2");
7919        // A removal advances the shadow; the routed sum reflects it.
7920        assert!(g.remove_edge("a", "b", Some(0)));
7921        assert!(g.slab_shadow_is_warm());
7922        assert_eq!(g.weighted_size_int("weight"), gt(&g, "weight"));
7923        assert_eq!(g.weighted_size_int("weight"), Some(6), "3+1+2");
7924        // A non-int (Float) weight makes it None on both arms.
7925        let mut fattr = AttrMap::new();
7926        fattr.insert("weight".to_owned(), fnx_runtime::CgseValue::Float(1.5));
7927        let _ = g.add_edge_with_key_and_attrs("d", "e", 0, fattr);
7928        assert!(g.slab_shadow_is_warm());
7929        assert_eq!(g.weighted_size_int("weight"), gt(&g, "weight"));
7930        assert!(
7931            g.weighted_size_int("weight").is_none(),
7932            "non-int weight -> None"
7933        );
7934        // No-shadow graph falls through to the String store.
7935        let mut h = MultiGraph::strict();
7936        let _ = h.add_edge_with_key_and_attrs("p", "q", 0, w(7));
7937        assert!(!h.slab_shadow_is_warm());
7938        assert_eq!(h.weighted_size_int("weight"), gt(&h, "weight"));
7939        assert_eq!(h.weighted_size_int("weight"), Some(7));
7940    }
7941
7942    /// br-r37-c1-thp6w S32: feature-on `neighbors_iter` READS route through the
7943    /// always-warm slab via the `MgNeighborNames` enum iterator. Collected
7944    /// output verified against the unrouted String field ground truth
7945    /// (`g.adjacency` row keys) incl. row order, a removal advance, a missing
7946    /// node (None), and the no-shadow fallback.
7947    #[cfg(any())]
7948    #[test]
7949    fn thp6w_s32_neighbors_iter_reads_route_to_slab() {
7950        fn gt<'a>(g: &'a MultiGraph, n: &str) -> Option<Vec<&'a str>> {
7951            g.adjacency
7952                .get(n)
7953                .map(|row| row.keys().map(String::as_str).collect())
7954        }
7955        let collect = |g: &MultiGraph, n: &str| -> Option<Vec<String>> {
7956            g.neighbors_iter(n)
7957                .map(|it| it.map(str::to_owned).collect())
7958        };
7959        let want = |g: &MultiGraph, n: &str| -> Option<Vec<String>> {
7960            gt(g, n).map(|v| v.into_iter().map(str::to_owned).collect())
7961        };
7962        let mut g = MultiGraph::strict();
7963        let _ = g.add_edge("a", "b");
7964        let _ = g.add_edge("a", "c");
7965        let _ = g.add_edge("a", "b"); // parallel — distinct neighbor unchanged
7966        let _ = g.add_edge("c", "d");
7967        g.sync_slab_shadow();
7968        assert!(g.slab_shadow_is_warm());
7969        assert_eq!(
7970            collect(&g, "a"),
7971            want(&g, "a"),
7972            "routed iter == String, row order"
7973        );
7974        assert_eq!(collect(&g, "c"), want(&g, "c"));
7975        assert!(g.neighbors_iter("missing").is_none(), "absent node -> None");
7976        // A removal advances the shadow; the routed iteration reflects it.
7977        assert!(g.remove_edge("a", "c", None));
7978        assert!(g.slab_shadow_is_warm());
7979        assert_eq!(collect(&g, "a"), want(&g, "a"), "after removal");
7980        // No-shadow graph falls through to the String store.
7981        let mut h = MultiGraph::strict();
7982        let _ = h.add_edge("p", "q");
7983        assert!(!h.slab_shadow_is_warm());
7984        assert_eq!(collect(&h, "p"), want(&h, "p"));
7985    }
7986
7987    /// br-r37-c1-thp6w S33: feature-on `edge_keys_iter` READS route through the
7988    /// always-warm slab via the `MgEdgeKeys` enum iterator. Collected output
7989    /// verified against the unrouted String field ground truth (`g.adjacency`
7990    /// key set) incl. the Many bucket (parallel keys), the One bucket (single
7991    /// key), a per-key removal advance, a missing edge (None), and the no-shadow
7992    /// fallback.
7993    #[cfg(any())]
7994    #[test]
7995    fn thp6w_s33_edge_keys_iter_reads_route_to_slab() {
7996        fn gt(g: &MultiGraph, l: &str, r: &str) -> Option<Vec<usize>> {
7997            g.adjacency
7998                .get(l)?
7999                .get(r)
8000                .map(|keys| keys.iter().copied().collect())
8001        }
8002        let collect = |g: &MultiGraph, l: &str, r: &str| -> Option<Vec<usize>> {
8003            g.edge_keys_iter(l, r).map(|it| it.copied().collect())
8004        };
8005        let mut g = MultiGraph::strict();
8006        let _ = g.add_edge("a", "b"); // key 0
8007        let _ = g.add_edge("a", "b"); // key 1
8008        let _ = g.add_edge("a", "b"); // key 2 (Many bucket)
8009        let _ = g.add_edge("b", "c"); // single key (One bucket)
8010        g.sync_slab_shadow();
8011        assert!(g.slab_shadow_is_warm());
8012        assert_eq!(collect(&g, "a", "b"), gt(&g, "a", "b"), "Many == String");
8013        assert_eq!(collect(&g, "a", "b"), Some(vec![0, 1, 2]));
8014        assert_eq!(collect(&g, "b", "c"), gt(&g, "b", "c"), "One == String");
8015        assert_eq!(collect(&g, "b", "c"), Some(vec![0]));
8016        assert!(g.edge_keys_iter("a", "z").is_none(), "missing edge -> None");
8017        // A per-key removal advances the shadow; iteration reflects it.
8018        assert!(g.remove_edge("a", "b", Some(1)));
8019        assert!(g.slab_shadow_is_warm());
8020        assert_eq!(collect(&g, "a", "b"), gt(&g, "a", "b"), "after removal");
8021        assert_eq!(collect(&g, "a", "b"), Some(vec![0, 2]));
8022        // No-shadow graph falls through to the String store.
8023        let mut h = MultiGraph::strict();
8024        let _ = h.add_edge("p", "q");
8025        assert!(!h.slab_shadow_is_warm());
8026        assert_eq!(collect(&h, "p", "q"), gt(&h, "p", "q"));
8027    }
8028
8029    /// br-r37-c1-thp6w S11 GAUNTLET (production dual-write shadow): with the
8030    /// `mg-int-storage` feature on, every instrumented mutation (single-edge
8031    /// add incl. autocreate/parallel/self-loop/attr-merge, keyed batch,
8032    /// edge/node removal with slot recycling, node-attr set (S17), copy-walk
8033    /// row reorder (S18)) must ADVANCE the shadow in place (warm +
8034    /// byte-identical), and every UNINSTRUMENTED mutation must leave it stale
8035    /// (revision key) — never silently wrong.
8036    #[cfg(any())]
8037    #[test]
8038    fn thp6w_s11_slab_shadow_dual_write_gauntlet() {
8039        let shadow_parity = |g: &MultiGraph| {
8040            let shadow = g.slab_shadow.as_deref().expect("shadow must be present");
8041            assert_eq!(shadow.0, g.revision, "shadow must be warm (advanced)");
8042            assert_slab_matches_mg(g, &shadow.1);
8043        };
8044
8045        let mut g = MultiGraph::strict();
8046        let _ = g.add_edge("a", "b");
8047        g.sync_slab_shadow();
8048        assert!(g.slab_shadow_is_warm());
8049        shadow_parity(&g);
8050
8051        // Instrumented single-edge adds.
8052        let _ = g.add_edge("b", "c"); // autocreated endpoint
8053        shadow_parity(&g);
8054        let _ = g.add_edge("a", "b"); // parallel key
8055        shadow_parity(&g);
8056        let _ = g.add_edge("d", "d"); // self-loop, fresh node
8057        shadow_parity(&g);
8058        let mut attrs = AttrMap::new();
8059        attrs.insert("w".to_owned(), fnx_runtime::CgseValue::Int(3));
8060        let _ = g.add_edge_with_key_and_attrs("a", "b", 0, attrs); // attr merge
8061        shadow_parity(&g);
8062
8063        // Instrumented keyed batch (write-through).
8064        let _ = g.extend_keyed_edges_with_attrs_unrecorded(vec![
8065            ("x".to_owned(), "y".to_owned(), 0, AttrMap::new()),
8066            ("y".to_owned(), "x".to_owned(), 1, AttrMap::new()),
8067            ("a".to_owned(), "x".to_owned(), 0, AttrMap::new()),
8068        ]);
8069        shadow_parity(&g);
8070
8071        // Instrumented removals: parallel key (next_back), last key, node
8072        // removal, then slot recycling via a fresh name.
8073        assert!(g.remove_edge("a", "b", None));
8074        shadow_parity(&g);
8075        assert!(g.remove_edge("a", "b", None));
8076        shadow_parity(&g);
8077        assert!(g.remove_node("b"));
8078        shadow_parity(&g);
8079        let _ = g.add_edge("recycled", "c");
8080        shadow_parity(&g);
8081
8082        // br-r37-c1-thp6w S18: an order-only mutation (copy-walk reorder) now
8083        // ADVANCES the warm shadow in place — rows reordered on the slab,
8084        // revision unchanged so it stays warm and byte-identical.
8085        g.reorder_rows_for_nx_copy_walk();
8086        assert!(
8087            g.slab_shadow_is_warm(),
8088            "apply_row_orders must advance (not drop) the warm shadow"
8089        );
8090        shadow_parity(&g);
8091        // A full rebuild still reproduces the same post-reorder row orders.
8092        g.sync_slab_shadow();
8093        shadow_parity(&g);
8094
8095        // Uninstrumented content mutations leave the shadow STALE, never wrong.
8096        g.add_node("island");
8097        assert!(
8098            !g.slab_shadow_is_warm(),
8099            "uninstrumented mutation must stale the shadow"
8100        );
8101        g.sync_slab_shadow();
8102        shadow_parity(&g);
8103        g.clear_edges();
8104        assert!(!g.slab_shadow_is_warm());
8105        g.sync_slab_shadow();
8106        shadow_parity(&g);
8107
8108        // br-r37-c1-thp6w S13 ROUTE GATE: with a warm shadow the production
8109        // `edges_ordered_borrowed` serves from the slab; the sequence must be
8110        // byte-identical to what the String walk produced while the shadow
8111        // was STALE (owned snapshot so it survives the sync).
8112        let _ = g.add_edge("r1", "r2"); // stales nothing — shadow advances...
8113        g.add_node("stale_maker"); // ...so force staleness via an uninstrumented op
8114        assert!(!g.slab_shadow_is_warm());
8115        let _ = g.add_edge("r2", "r1"); // parallel via reverse orientation (shadow stays stale)
8116        let _ = g.add_edge("r3", "r3");
8117        let expected: Vec<(String, String, usize, AttrMap)> = g
8118            .edges_ordered_borrowed() // stale shadow -> String walk
8119            .into_iter()
8120            .map(|(l, r, k, a)| (l.to_owned(), r.to_owned(), k, a.clone()))
8121            .collect();
8122        // Same gate for the OWNED `edges_ordered` route (captured stale first).
8123        let expected_owned: Vec<(String, String, usize, AttrMap)> = g
8124            .edges_ordered() // stale shadow -> String walk (owned)
8125            .into_iter()
8126            .map(|e| (e.left, e.right, e.key, e.attrs))
8127            .collect();
8128        g.sync_slab_shadow();
8129        assert!(g.slab_shadow_is_warm());
8130        let served: Vec<(String, String, usize, AttrMap)> = g
8131            .edges_ordered_borrowed() // warm shadow -> slab walk
8132            .into_iter()
8133            .map(|(l, r, k, a)| (l.to_owned(), r.to_owned(), k, a.clone()))
8134            .collect();
8135        assert_eq!(
8136            served, expected,
8137            "shadow-served edges_ordered must equal the String walk"
8138        );
8139        let served_owned: Vec<(String, String, usize, AttrMap)> = g
8140            .edges_ordered() // warm shadow -> slab walk (owned)
8141            .into_iter()
8142            .map(|e| (e.left, e.right, e.key, e.attrs))
8143            .collect();
8144        assert_eq!(
8145            served_owned, expected_owned,
8146            "shadow-served owned edges_ordered must equal the String walk"
8147        );
8148    }
8149
8150    /// br-r37-c1-thp6w S5 A/B: pure storage-representation tax of the String-
8151    /// keyed MultiGraph core vs the index-keyed prototype layout, on the SAME
8152    /// pre-resolved (u_idx, v_idx, key) stream via the SAME-shape bulk loaders
8153    /// (`extend_fresh_int_prefix_keyed_edges_unrecorded` vs
8154    /// `bulk_load_int_prefix`) — no canonicalization, no Python boundary, no
8155    /// per-edge ledger in either arm. Paired-interleaved (current, proto,
8156    /// current-null per round), medians over 9 rounds.
8157    /// `cargo test --release -p fnx-classes --lib thp6w_s5_int_storage_construction_ab -- --ignored --nocapture`
8158    #[test]
8159    #[ignore = "paired A/B benchmark; run --release with --ignored --nocapture"]
8160    fn thp6w_s5_int_storage_construction_ab() {
8161        use super::multigraph_storage::MgIntStorageProto;
8162        use std::collections::HashMap;
8163        use std::hint::black_box;
8164        use std::time::Instant;
8165
8166        fn push_edge(
8167            stream: &mut Vec<(usize, usize, usize)>,
8168            key_counter: &mut HashMap<(usize, usize), usize>,
8169            u: usize,
8170            v: usize,
8171        ) {
8172            let entry = key_counter.entry((u.min(v), u.max(v))).or_insert(0);
8173            stream.push((u, v, *entry));
8174            *entry += 1;
8175        }
8176
8177        let n = 20000usize;
8178        let mut stream = Vec::new();
8179        let mut key_counter = HashMap::new();
8180        for i in 0..n {
8181            push_edge(&mut stream, &mut key_counter, i, (i + 1) % n);
8182        }
8183        // Deterministic LCG chords (incl. incidental self-loops) + explicit parallels.
8184        let mut state = 0x2545_F491_4F6C_DD1D_u64;
8185        let draw = |state: &mut u64| -> usize {
8186            *state = state
8187                .wrapping_mul(6_364_136_223_846_793_005)
8188                .wrapping_add(1_442_695_040_888_963_407);
8189            usize::try_from(*state >> 33).unwrap() % n
8190        };
8191        for _ in 0..3 * n {
8192            let u = draw(&mut state);
8193            let v = draw(&mut state);
8194            push_edge(&mut stream, &mut key_counter, u, v);
8195        }
8196        for i in (0..2000).step_by(2) {
8197            push_edge(&mut stream, &mut key_counter, i, i + 1);
8198        }
8199
8200        let time_current = |stream: &[(usize, usize, usize)]| -> f64 {
8201            let t0 = Instant::now();
8202            let mut g = MultiGraph::strict();
8203            let _ = g.extend_fresh_int_prefix_keyed_edges_unrecorded(n, stream.iter().copied());
8204            let dt = t0.elapsed().as_secs_f64();
8205            black_box(&g);
8206            dt
8207        };
8208        let time_proto = |stream: &[(usize, usize, usize)]| -> f64 {
8209            let t0 = Instant::now();
8210            let proto = MgIntStorageProto::bulk_load_int_prefix(n, stream.iter().copied());
8211            let dt = t0.elapsed().as_secs_f64();
8212            black_box(&proto);
8213            dt
8214        };
8215        // br-r37-c1-thp6w S6: compact One/Many bucket arm.
8216        let time_compact = |stream: &[(usize, usize, usize)]| -> f64 {
8217            let t0 = Instant::now();
8218            let compact = super::multigraph_storage::MgIntStorageProtoCompact::bulk_load_int_prefix(
8219                n,
8220                stream.iter().copied(),
8221            );
8222            let dt = t0.elapsed().as_secs_f64();
8223            black_box(&compact);
8224            dt
8225        };
8226
8227        // Structural agreement between the arms (once, outside timing):
8228        // the real store's derived integer rows must equal both proto rows.
8229        let mut g = MultiGraph::strict();
8230        let _ = g.extend_fresh_int_prefix_keyed_edges_unrecorded(n, stream.iter().copied());
8231        let proto = MgIntStorageProto::bulk_load_int_prefix(n, stream.iter().copied());
8232        let compact = super::multigraph_storage::MgIntStorageProtoCompact::bulk_load_int_prefix(
8233            n,
8234            stream.iter().copied(),
8235        );
8236        g.with_int_adjacency(|adj| {
8237            for (i, row) in adj.iter().enumerate() {
8238                let proto_row: Vec<usize> = proto.rows[i].keys().copied().collect();
8239                assert_eq!(row, &proto_row, "derived integer row {i} must agree");
8240                let compact_row: Vec<usize> = compact.rows[i].keys().copied().collect();
8241                assert_eq!(row, &compact_row, "compact integer row {i} must agree");
8242            }
8243        });
8244        assert_eq!(
8245            proto.edge_count,
8246            g.edge_count(),
8247            "A/B arms built different graphs"
8248        );
8249        assert_eq!(
8250            compact.edge_count,
8251            g.edge_count(),
8252            "compact arm built a different graph"
8253        );
8254        // br-r37-c1-thp6w S9: slab arm — must hold the compact win.
8255        let time_slab = |stream: &[(usize, usize, usize)]| -> f64 {
8256            let t0 = Instant::now();
8257            let slab = super::multigraph_storage::MgSlabStorage::bulk_load_int_prefix(
8258                n,
8259                stream.iter().copied(),
8260            );
8261            let dt = t0.elapsed().as_secs_f64();
8262            black_box(&slab);
8263            dt
8264        };
8265        let slab = super::multigraph_storage::MgSlabStorage::bulk_load_int_prefix(
8266            n,
8267            stream.iter().copied(),
8268        );
8269        g.with_int_adjacency(|adj| {
8270            for (i, row) in adj.iter().enumerate() {
8271                let slab_row: Vec<usize> = slab.rows[i].keys().copied().collect();
8272                assert_eq!(row, &slab_row, "slab integer row {i} must agree");
8273            }
8274        });
8275        assert_eq!(
8276            slab.edge_count,
8277            g.edge_count(),
8278            "slab arm built a different graph"
8279        );
8280
8281        let median = |samples: &mut Vec<f64>| -> f64 {
8282            samples.sort_by(f64::total_cmp);
8283            samples[samples.len() / 2]
8284        };
8285        let (mut cur, mut pro, mut com, mut sla, mut nul) =
8286            (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new());
8287        for _ in 0..9 {
8288            cur.push(time_current(&stream));
8289            pro.push(time_proto(&stream));
8290            com.push(time_compact(&stream));
8291            sla.push(time_slab(&stream));
8292            nul.push(time_current(&stream));
8293        }
8294        let (mc, mp, mk, ms, mn) = (
8295            median(&mut cur),
8296            median(&mut pro),
8297            median(&mut com),
8298            median(&mut sla),
8299            median(&mut nul),
8300        );
8301        println!(
8302            "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}",
8303            stream.len(),
8304            mc / mp,
8305            mc / mk,
8306            mc / ms,
8307            mk / ms,
8308            mc / mn,
8309        );
8310    }
8311
8312    #[test]
8313    fn thp6w_int_adjacency_cache_is_fresh_per_clone() {
8314        let mut g = MultiGraph::strict();
8315        for i in 0..4 {
8316            g.add_node(format!("n{i}"));
8317        }
8318        let _ = g.add_edge("n0", "n1");
8319        // Populate g's memo.
8320        assert_int_adjacency_matches(&g);
8321        // Clone, then mutate the CLONE differently. The clone must NOT serve g's rows
8322        // (fresh-per-clone memo), and g must keep its own.
8323        let mut clone = g.clone();
8324        let _ = clone.add_edge("n2", "n3");
8325        assert_int_adjacency_matches(&clone);
8326        assert_int_adjacency_matches(&g);
8327        // And mutate g after the clone; both stay self-consistent.
8328        let _ = g.add_edge("n0", "n2");
8329        assert_int_adjacency_matches(&g);
8330        assert_int_adjacency_matches(&clone);
8331    }
8332
8333    fn single_attr(key: &str, value: &str) -> AttrMap {
8334        let mut attrs = AttrMap::new();
8335        attrs.insert(key.to_owned(), CgseValue::from(value));
8336        attrs
8337    }
8338
8339    #[test]
8340    fn edge_slot_rows_follow_adj_order_and_refresh_after_mutation() {
8341        let mut graph = Graph::strict();
8342        let weighted = |value| {
8343            let mut attrs = AttrMap::new();
8344            attrs.insert("weight".to_owned(), CgseValue::Int(value));
8345            attrs
8346        };
8347        graph
8348            .add_edge_with_attrs("a", "b", weighted(1))
8349            .expect("edge add should succeed");
8350        graph
8351            .add_edge_with_attrs("a", "c", weighted(2))
8352            .expect("edge add should succeed");
8353        graph
8354            .add_edge_with_attrs("b", "c", weighted(3))
8355            .expect("edge add should succeed");
8356
8357        let a = graph.get_node_index("a").expect("a exists");
8358        graph.with_edge_slots_by_neighbor(|rows| {
8359            let weights = rows[a]
8360                .iter()
8361                .map(|&slot| {
8362                    graph
8363                        .edge_attrs_by_slot(slot)
8364                        .and_then(|attrs| attrs.get("weight"))
8365                })
8366                .collect::<Vec<_>>();
8367            assert_eq!(
8368                weights,
8369                vec![Some(&CgseValue::Int(1)), Some(&CgseValue::Int(2))]
8370            );
8371        });
8372
8373        graph.apply_row_orders(&[("a".to_owned(), vec!["c".to_owned(), "b".to_owned()])]);
8374        graph.with_edge_slots_by_neighbor(|rows| {
8375            let weights = rows[a]
8376                .iter()
8377                .map(|&slot| {
8378                    graph
8379                        .edge_attrs_by_slot(slot)
8380                        .and_then(|attrs| attrs.get("weight"))
8381                })
8382                .collect::<Vec<_>>();
8383            assert_eq!(
8384                weights,
8385                vec![Some(&CgseValue::Int(2)), Some(&CgseValue::Int(1))]
8386            );
8387        });
8388
8389        assert!(graph.remove_edge("a", "c"));
8390        graph.with_edge_slots_by_neighbor(|rows| {
8391            assert_eq!(rows[a].len(), 1);
8392            assert_eq!(
8393                graph
8394                    .edge_attrs_by_slot(rows[a][0])
8395                    .and_then(|attrs| attrs.get("weight")),
8396                Some(&CgseValue::Int(1))
8397            );
8398        });
8399    }
8400
8401    #[test]
8402    fn edges_ordered_tracks_node_and_neighbor_iteration_order() {
8403        let mut graph = Graph::strict();
8404        graph
8405            .add_edge_with_attrs("a", "b", AttrMap::new())
8406            .expect("edge add should succeed");
8407        graph
8408            .add_edge_with_attrs("b", "c", AttrMap::new())
8409            .expect("edge add should succeed");
8410        graph
8411            .add_edge_with_attrs("a", "c", AttrMap::new())
8412            .expect("edge add should succeed");
8413
8414        let pairs = graph
8415            .edges_ordered()
8416            .into_iter()
8417            .map(|edge| (edge.left, edge.right))
8418            .collect::<Vec<(String, String)>>();
8419        assert_eq!(
8420            pairs,
8421            vec![
8422                ("a".to_owned(), "b".to_owned()),
8423                ("a".to_owned(), "c".to_owned()),
8424                ("b".to_owned(), "c".to_owned()),
8425            ]
8426        );
8427    }
8428
8429    #[test]
8430    fn edges_ordered_preserves_traversed_endpoint_orientation() {
8431        let mut graph = Graph::strict();
8432        graph
8433            .add_edge_with_attrs("2", "1", AttrMap::new())
8434            .expect("edge add should succeed");
8435
8436        let pairs = graph
8437            .edges_ordered()
8438            .into_iter()
8439            .map(|edge| (edge.left, edge.right))
8440            .collect::<Vec<(String, String)>>();
8441        assert_eq!(pairs, vec![("2".to_owned(), "1".to_owned())]);
8442
8443        let borrowed_pairs = graph
8444            .edges_ordered_borrowed()
8445            .into_iter()
8446            .map(|(left, right, _)| (left.to_owned(), right.to_owned()))
8447            .collect::<Vec<(String, String)>>();
8448        assert_eq!(borrowed_pairs, vec![("2".to_owned(), "1".to_owned())]);
8449    }
8450
8451    #[test]
8452    fn edges_ordered_uses_node_order_for_preexisting_nodes() {
8453        let mut graph = Graph::strict();
8454        graph.add_node("1".to_owned());
8455        graph.add_node("2".to_owned());
8456        graph
8457            .add_edge_with_attrs("2", "1", AttrMap::new())
8458            .expect("edge add should succeed");
8459
8460        let pairs = graph
8461            .edges_ordered()
8462            .into_iter()
8463            .map(|edge| (edge.left, edge.right))
8464            .collect::<Vec<(String, String)>>();
8465        assert_eq!(pairs, vec![("1".to_owned(), "2".to_owned())]);
8466    }
8467
8468    #[test]
8469    fn edge_storage_order_index_iter_tracks_mutations() {
8470        let mut graph = Graph::strict();
8471        graph.add_node("c");
8472        graph.add_node("a");
8473        graph.add_node("b");
8474        graph
8475            .add_edge_with_attrs("c", "a", single_attr("weight", "1"))
8476            .expect("edge add should succeed");
8477        graph
8478            .add_edge_with_attrs("b", "c", single_attr("weight", "2"))
8479            .expect("edge add should succeed");
8480        graph
8481            .add_edge_with_attrs("a", "c", single_attr("color", "red"))
8482            .expect("duplicate edge update should succeed");
8483
8484        let indexed_edges = |graph: &Graph| {
8485            graph
8486                .edges_storage_order_index_iter()
8487                .map(|(left, right, attrs)| {
8488                    (
8489                        graph
8490                            .get_node_name(left)
8491                            .expect("left endpoint index should resolve")
8492                            .to_owned(),
8493                        graph
8494                            .get_node_name(right)
8495                            .expect("right endpoint index should resolve")
8496                            .to_owned(),
8497                        attrs.get("weight").cloned(),
8498                    )
8499                })
8500                .collect::<Vec<_>>()
8501        };
8502
8503        assert_eq!(
8504            indexed_edges(&graph),
8505            vec![
8506                (
8507                    "a".to_owned(),
8508                    "c".to_owned(),
8509                    Some(CgseValue::String("1".to_owned())),
8510                ),
8511                (
8512                    "b".to_owned(),
8513                    "c".to_owned(),
8514                    Some(CgseValue::String("2".to_owned())),
8515                ),
8516            ]
8517        );
8518
8519        assert!(graph.remove_edge("a", "c"));
8520        assert_eq!(
8521            indexed_edges(&graph),
8522            vec![(
8523                "b".to_owned(),
8524                "c".to_owned(),
8525                Some(CgseValue::String("2".to_owned())),
8526            )]
8527        );
8528
8529        assert!(graph.remove_node("b"));
8530        assert!(indexed_edges(&graph).is_empty());
8531    }
8532
8533    fn assert_graph_core_invariants(graph: &Graph) {
8534        let mut unique_edges = BTreeSet::new();
8535        for node in graph.nodes_ordered() {
8536            let neighbors = graph
8537                .neighbors(node)
8538                .expect("graph nodes should always have adjacency buckets");
8539            for neighbor in neighbors {
8540                assert!(graph.has_node(neighbor));
8541                assert!(graph.has_edge(node, neighbor));
8542                let reverse_neighbors = graph
8543                    .neighbors(neighbor)
8544                    .expect("neighbor should always have adjacency bucket");
8545                assert!(reverse_neighbors.contains(&node));
8546                unique_edges.insert(canonical_edge(node, neighbor));
8547            }
8548        }
8549        assert_eq!(graph.edge_count(), unique_edges.len());
8550    }
8551
8552    fn assert_multigraph_core_invariants(graph: &MultiGraph) {
8553        let mut edge_instances = BTreeSet::new();
8554        for node in graph.nodes_ordered() {
8555            let neighbors = graph
8556                .neighbors(node)
8557                .expect("multigraph nodes should always have adjacency buckets");
8558            for neighbor in neighbors {
8559                assert!(graph.has_node(neighbor));
8560                assert!(graph.has_edge(node, neighbor));
8561                let reverse_neighbors = graph
8562                    .neighbors(neighbor)
8563                    .expect("neighbor should always have adjacency bucket");
8564                assert!(reverse_neighbors.contains(&node));
8565                let keys = graph
8566                    .edge_keys(node, neighbor)
8567                    .expect("parallel edge bucket should exist");
8568                for key in keys {
8569                    let canonical = canonical_edge(node, neighbor);
8570                    edge_instances.insert((canonical.0, canonical.1, key));
8571                }
8572            }
8573        }
8574        assert_eq!(graph.edge_count(), edge_instances.len());
8575    }
8576
8577    fn assert_decision_record_schema(record: &DecisionRecord, expected_mode: CompatibilityMode) {
8578        assert!(record.ts_unix_ms > 0);
8579        assert!(!record.operation.trim().is_empty());
8580        assert_eq!(record.mode, expected_mode);
8581        assert!((0.0..=1.0).contains(&record.incompatibility_probability));
8582        assert!(!record.rationale.trim().is_empty());
8583        assert!(!record.evidence.is_empty());
8584        for term in &record.evidence {
8585            assert!(!term.signal.trim().is_empty());
8586            assert!(!term.observed_value.trim().is_empty());
8587        }
8588    }
8589
8590    #[test]
8591    fn add_edge_autocreates_nodes_and_preserves_order() {
8592        let mut graph = Graph::strict();
8593        graph
8594            .add_edge_with_attrs("a", "b", AttrMap::new())
8595            .expect("edge insert should succeed");
8596        graph
8597            .add_edge_with_attrs("a", "c", AttrMap::new())
8598            .expect("edge insert should succeed");
8599
8600        assert_eq!(graph.node_count(), 3);
8601        assert_eq!(graph.edge_count(), 2);
8602        assert_eq!(graph.nodes_ordered(), vec!["a", "b", "c"]);
8603        assert_eq!(graph.neighbors("a"), Some(vec!["b", "c"]));
8604    }
8605
8606    #[test]
8607    fn add_edge_empty_endpoint_resolution_preserves_autocreation_effects() {
8608        let mut graph = Graph::strict();
8609        let before = graph.revision();
8610
8611        graph
8612            .add_edge("c", "a")
8613            .expect("fresh endpoints should be autocreated");
8614
8615        // The single-probe endpoint resolver keeps the public storage effects
8616        // of two empty add_node calls plus the new edge mutation: first-seen
8617        // node order, one adjacency bucket per node, and three revision bumps.
8618        assert_eq!(graph.nodes_ordered(), vec!["c", "a"]);
8619        assert_eq!(graph.neighbors("c"), Some(vec!["a"]));
8620        assert_eq!(graph.neighbors("a"), Some(vec!["c"]));
8621        assert_eq!(graph.revision(), before + 3);
8622
8623        graph
8624            .add_edge("a", "c")
8625            .expect("an existing empty edge is a no-op");
8626        assert_eq!(graph.nodes_ordered(), vec!["c", "a"]);
8627        assert_eq!(graph.revision(), before + 3);
8628    }
8629
8630    #[test]
8631    fn extend_edges_unrecorded_preserves_adjacency_and_records_once() {
8632        let mut graph = Graph::strict();
8633        graph.add_node("a");
8634        graph.add_node("b");
8635        graph.add_node("c");
8636        let before = graph.evidence_ledger().records().len();
8637
8638        let inserted = graph.extend_edges_unrecorded([("a", "b"), ("a", "c"), ("b", "a")]);
8639
8640        assert_eq!(inserted, 2);
8641        assert_eq!(graph.edge_count(), 2);
8642        assert_eq!(graph.neighbors("a"), Some(vec!["b", "c"]));
8643        let records = graph.evidence_ledger().records();
8644        assert_eq!(records.len(), before + 1);
8645        assert_eq!(
8646            records.last().map(|record| record.operation.as_ref()),
8647            Some("extend_edges_unrecorded")
8648        );
8649    }
8650
8651    #[test]
8652    fn extend_existing_index_edges_unrecorded_matches_string_batch_on_int_prefix() {
8653        let nodes = (0..12).map(|node| node.to_string()).collect::<Vec<_>>();
8654        let edge_indices = vec![(10usize, 2usize), (2, 10), (3, 3), (1, 11), (11, 1)];
8655        let edge_strings = edge_indices
8656            .iter()
8657            .map(|(left, right)| (left.to_string(), right.to_string()))
8658            .collect::<Vec<_>>();
8659
8660        let mut by_string = Graph::strict();
8661        let mut by_index = Graph::strict();
8662        let _ = by_string.extend_nodes_unrecorded(nodes.clone());
8663        let _ = by_index.extend_nodes_unrecorded(nodes);
8664
8665        assert!(by_index.nodes_are_contiguous_int_prefix());
8666        assert_eq!(by_string.extend_edges_unrecorded(edge_strings), 3);
8667        assert_eq!(
8668            by_index.extend_existing_index_edges_unrecorded(edge_indices),
8669            3
8670        );
8671
8672        assert_eq!(by_index.snapshot(), by_string.snapshot());
8673        assert_graph_core_invariants(&by_index);
8674    }
8675
8676    #[test]
8677    fn extend_edges_with_attrs_unrecorded_matches_add_edge_with_attrs() {
8678        // br-r37-c1-pr8q6: bulk attributed insertion must be observationally
8679        // identical to a sequence of add_edge_with_attrs calls (node order,
8680        // adjacency order, attr merge on duplicates) minus the per-edge
8681        // ledger records.
8682        let mut attrs1 = AttrMap::new();
8683        attrs1.insert("w".to_owned(), CgseValue::Int(1));
8684        let mut attrs2 = AttrMap::new();
8685        attrs2.insert("w".to_owned(), CgseValue::Int(7));
8686        attrs2.insert("c".to_owned(), CgseValue::String("x".to_owned()));
8687
8688        let mut reference = Graph::strict();
8689        reference
8690            .add_edge_with_attrs("a", "b", attrs1.clone())
8691            .unwrap();
8692        reference
8693            .add_edge_with_attrs("b", "c", AttrMap::new())
8694            .unwrap();
8695        // duplicate edge: attrs merge
8696        reference
8697            .add_edge_with_attrs("b", "a", attrs2.clone())
8698            .unwrap();
8699        // self-loop
8700        reference
8701            .add_edge_with_attrs("d", "d", attrs1.clone())
8702            .unwrap();
8703
8704        let mut bulk = Graph::strict();
8705        let before = bulk.evidence_ledger().records().len();
8706        let inserted = bulk.extend_edges_with_attrs_unrecorded([
8707            ("a".to_owned(), "b".to_owned(), attrs1.clone()),
8708            ("b".to_owned(), "c".to_owned(), AttrMap::new()),
8709            ("b".to_owned(), "a".to_owned(), attrs2.clone()),
8710            ("d".to_owned(), "d".to_owned(), attrs1.clone()),
8711        ]);
8712
8713        assert_eq!(inserted, 3);
8714        assert_eq!(bulk.edge_count(), reference.edge_count());
8715        assert_eq!(bulk.nodes_ordered(), reference.nodes_ordered());
8716        for node in bulk.nodes_ordered() {
8717            assert_eq!(bulk.neighbors(node), reference.neighbors(node));
8718        }
8719        assert_eq!(bulk.edge_attrs("a", "b"), reference.edge_attrs("a", "b"));
8720        assert_eq!(bulk.edge_attrs("d", "d"), reference.edge_attrs("d", "d"));
8721        // one summary record, not one per edge
8722        assert_eq!(bulk.evidence_ledger().records().len(), before + 1);
8723    }
8724
8725    #[test]
8726    fn multigraph_int_prefix_keyed_edges_match_string_batch() {
8727        let indexed_edges = vec![
8728            (0usize, 1usize, 0usize),
8729            (1, 2, 0),
8730            (2, 0, 0),
8731            (0, 1, 1),
8732            (2, 2, 0),
8733        ];
8734        let string_edges = indexed_edges
8735            .iter()
8736            .map(|(left, right, key)| (left.to_string(), right.to_string(), *key, AttrMap::new()))
8737            .collect::<Vec<_>>();
8738
8739        let mut by_string = MultiGraph::strict();
8740        let mut by_index = MultiGraph::strict();
8741
8742        assert_eq!(
8743            by_string.extend_keyed_edges_with_attrs_unrecorded(string_edges),
8744            indexed_edges.len()
8745        );
8746        assert_eq!(
8747            by_index.extend_fresh_int_prefix_keyed_edges_unrecorded(3, indexed_edges),
8748            5
8749        );
8750
8751        assert_eq!(by_index.snapshot(), by_string.snapshot());
8752        assert_multigraph_core_invariants(&by_index);
8753    }
8754
8755    #[test]
8756    fn extend_nodes_unrecorded_preserves_order_and_records_once() {
8757        let mut graph = Graph::strict();
8758        graph.add_node("1");
8759        let before = graph.evidence_ledger().records().len();
8760
8761        let inserted =
8762            graph.extend_nodes_unrecorded(["0".to_owned(), "1".to_owned(), "2".to_owned()]);
8763
8764        assert_eq!(inserted, 2);
8765        assert_eq!(graph.node_count(), 3);
8766        assert_eq!(graph.nodes_ordered(), vec!["1", "0", "2"]);
8767        assert_eq!(graph.neighbor_count("0"), 0);
8768        assert_eq!(graph.neighbor_count("2"), 0);
8769        let records = graph.evidence_ledger().records();
8770        assert_eq!(records.len(), before + 1);
8771        assert_eq!(
8772            records.last().map(|record| record.operation.as_ref()),
8773            Some("extend_nodes_unrecorded")
8774        );
8775    }
8776
8777    #[test]
8778    fn neighbors_iter_preserves_deterministic_order() {
8779        let mut graph = Graph::strict();
8780        graph.add_edge("a", "b").expect("edge add should succeed");
8781        graph.add_edge("a", "c").expect("edge add should succeed");
8782        graph.add_edge("a", "d").expect("edge add should succeed");
8783
8784        let neighbors = graph
8785            .neighbors_iter("a")
8786            .expect("neighbors should exist")
8787            .collect::<Vec<&str>>();
8788        assert_eq!(neighbors, vec!["b", "c", "d"]);
8789    }
8790
8791    #[test]
8792    fn neighbor_count_matches_neighbors_len() {
8793        let mut graph = Graph::strict();
8794        graph.add_edge("a", "b").expect("edge add should succeed");
8795        graph.add_edge("a", "c").expect("edge add should succeed");
8796        assert_eq!(graph.neighbor_count("a"), 2);
8797        assert_eq!(graph.neighbor_count("missing"), 0);
8798    }
8799
8800    #[test]
8801    fn repeated_edge_updates_attrs_in_place() {
8802        let mut graph = Graph::strict();
8803        let mut first = AttrMap::new();
8804        first.insert("weight".to_owned(), "1".into());
8805        graph
8806            .add_edge_with_attrs("a", "b", first)
8807            .expect("edge insert should succeed");
8808
8809        let mut second = AttrMap::new();
8810        second.insert("color".to_owned(), "blue".into());
8811        graph
8812            .add_edge_with_attrs("b", "a", second)
8813            .expect("edge update should succeed");
8814
8815        let attrs = graph
8816            .edge_attrs("a", "b")
8817            .expect("edge attrs should be present");
8818        assert_eq!(
8819            attrs.get("weight"),
8820            Some(&CgseValue::String("1".to_owned()))
8821        );
8822        assert_eq!(
8823            attrs.get("color"),
8824            Some(&CgseValue::String("blue".to_owned()))
8825        );
8826        assert_eq!(graph.edge_count(), 1);
8827    }
8828
8829    #[test]
8830    fn complete_graph_bulk_constructor_preserves_ordered_contract() {
8831        let graph = Graph::complete_graph(CompatibilityMode::Strict, 5);
8832
8833        assert_eq!(graph.node_count(), 5);
8834        assert_eq!(graph.edge_count(), 10);
8835        assert_eq!(graph.nodes_ordered(), vec!["0", "1", "2", "3", "4"]);
8836        assert_eq!(
8837            graph.neighbors("0").expect("node 0 should exist"),
8838            vec!["1", "2", "3", "4"]
8839        );
8840        assert_eq!(
8841            graph.neighbors("4").expect("node 4 should exist"),
8842            vec!["0", "1", "2", "3"]
8843        );
8844        assert_eq!(
8845            graph
8846                .edges_ordered()
8847                .into_iter()
8848                .map(|edge| (edge.left, edge.right))
8849                .collect::<Vec<_>>(),
8850            vec![
8851                ("0".to_owned(), "1".to_owned()),
8852                ("0".to_owned(), "2".to_owned()),
8853                ("0".to_owned(), "3".to_owned()),
8854                ("0".to_owned(), "4".to_owned()),
8855                ("1".to_owned(), "2".to_owned()),
8856                ("1".to_owned(), "3".to_owned()),
8857                ("1".to_owned(), "4".to_owned()),
8858                ("2".to_owned(), "3".to_owned()),
8859                ("2".to_owned(), "4".to_owned()),
8860                ("3".to_owned(), "4".to_owned()),
8861            ]
8862        );
8863
8864        let operations = graph
8865            .evidence_ledger()
8866            .records()
8867            .iter()
8868            .map(|record| record.operation.as_ref())
8869            .collect::<Vec<_>>();
8870        assert_eq!(operations, vec!["complete_graph_bulk"]);
8871    }
8872
8873    #[test]
8874    fn remove_node_removes_incident_edges() {
8875        let mut graph = Graph::strict();
8876        graph.add_edge("a", "b").expect("edge add should succeed");
8877        graph.add_edge("b", "c").expect("edge add should succeed");
8878        assert!(graph.remove_node("b"));
8879        assert_eq!(graph.node_count(), 2);
8880        assert_eq!(graph.edge_count(), 0);
8881    }
8882
8883    /// br-r37-c1-8n3j3: attribution bench for the per-mutation CGSE decision
8884    /// record. `Graph::add_edge_with_attrs` calls `record_decision` twice per
8885    /// edge (5 `EvidenceTerm`s total), and every field of a `DecisionRecord`
8886    /// that comes from a `&'static str` is materialised as an owned `String`.
8887    /// This measures, in one invocation and interleaved, how much of a single
8888    /// `add_edge_with_attrs` is the ledger: arm A records the exact terms
8889    /// `add_edge_with_attrs` builds, arm B builds the identical terms and
8890    /// discards them. `add_edge_full` is the denominator the ratio is against.
8891    /// `#[ignore]`; run with
8892    /// `cargo test --release -p fnx-classes --lib ledger_record_cost_ab -- --ignored --nocapture`.
8893    #[test]
8894    #[ignore = "measurement; run with --release --ignored --nocapture"]
8895    fn ledger_record_cost_ab() {
8896        use fnx_runtime::{EvidenceTerm, RuntimePolicy, bool_evidence, count_evidence};
8897        use std::hint::black_box;
8898        use std::time::Instant;
8899
8900        const EDGES: usize = 20_000;
8901        const ROUNDS: usize = 21;
8902        const RATIONALE: &str = "argmin expected loss over {allow,full_validate,fail_closed}";
8903
8904        // The exact evidence `add_edge_with_attrs` builds for a fresh
8905        // non-self-loop edge between two autocreated nodes.
8906        fn gate_terms() -> Vec<EvidenceTerm> {
8907            vec![EvidenceTerm {
8908                signal: "unknown_incompatible_feature".into(),
8909                observed_value: bool_evidence(false),
8910                log_likelihood_ratio: 12.0,
8911            }]
8912        }
8913        fn outcome_terms(edge_attr_count: usize) -> Vec<EvidenceTerm> {
8914            vec![
8915                EvidenceTerm {
8916                    signal: "self_loop".into(),
8917                    observed_value: bool_evidence(false),
8918                    log_likelihood_ratio: -0.5,
8919                },
8920                EvidenceTerm {
8921                    signal: "edge_attr_count".into(),
8922                    observed_value: count_evidence(edge_attr_count),
8923                    log_likelihood_ratio: -2.0,
8924                },
8925                EvidenceTerm {
8926                    signal: "left_autocreated".into(),
8927                    observed_value: bool_evidence(true),
8928                    log_likelihood_ratio: -1.25,
8929                },
8930                EvidenceTerm {
8931                    signal: "right_autocreated".into(),
8932                    observed_value: bool_evidence(true),
8933                    log_likelihood_ratio: -1.25,
8934                },
8935            ]
8936        }
8937
8938        // br-r37-c1-jc9e4: the record the AUTOCREATED ENDPOINT files.
8939        // `add_edge_with_attrs` creates a missing endpoint via `add_node` ->
8940        // `add_node_with_attrs`, which calls `record_decision` UNCONDITIONALLY,
8941        // so an edge-BUILDING workload files this record in addition to the
8942        // outcome record. Two evidence terms, against the outcome record's four.
8943        fn node_terms() -> Vec<EvidenceTerm> {
8944            vec![
8945                EvidenceTerm {
8946                    signal: "node_preexisting".into(),
8947                    observed_value: bool_evidence(false),
8948                    log_likelihood_ratio: -3.0,
8949                },
8950                EvidenceTerm {
8951                    signal: "attrs_count".into(),
8952                    observed_value: count_evidence(0),
8953                    log_likelihood_ratio: -1.0,
8954                },
8955            ]
8956        }
8957
8958        // Arm A: build the terms AND record them (the shipped behaviour).
8959        let recorded = || {
8960            let mut policy = RuntimePolicy::strict();
8961            let start = Instant::now();
8962            for index in 0..EDGES {
8963                let action = policy.action_for(0.08, false);
8964                policy.record("add_edge", action, 0.08, RATIONALE, gate_terms());
8965                policy.record(
8966                    "add_edge",
8967                    action,
8968                    0.08,
8969                    RATIONALE,
8970                    outcome_terms(index & 3),
8971                );
8972            }
8973            let elapsed = start.elapsed();
8974            assert_eq!(policy.decision_log().total_recorded(), (EDGES * 2) as u64);
8975            elapsed
8976        };
8977
8978        // Arm A1: the shipped path after br-r37-c1-eo88t — the gate decision is
8979        // taken without a record, so an allowed edge writes only the outcome
8980        // record. Same bounded ledger as arm A, half the traffic.
8981        let recorded_single = || {
8982            let mut policy = RuntimePolicy::strict();
8983            let start = Instant::now();
8984            for index in 0..EDGES {
8985                let action = policy.action_for(0.08, false);
8986                policy.record(
8987                    "add_edge",
8988                    action,
8989                    0.08,
8990                    RATIONALE,
8991                    outcome_terms(index & 3),
8992                );
8993            }
8994            let elapsed = start.elapsed();
8995            assert_eq!(policy.decision_log().total_recorded(), EDGES as u64);
8996            elapsed
8997        };
8998
8999        // Arm A2: two records per edge — the autocreated endpoint's `add_node`
9000        // record PLUS the `add_edge` outcome record. This was the shipped
9001        // behaviour until br-r37-c1-wa1b9 stopped the internal autocreation
9002        // filing its own record; it is now the COUNTERFACTUAL that prices what
9003        // that change removed, and A1 is the shipped path. Keep it: it is the
9004        // only arm that shows what a per-call (rather than per-operation)
9005        // ledger contract would cost on the hottest mutation path.
9006        let recorded_edge_build = || {
9007            let mut policy = RuntimePolicy::strict();
9008            let start = Instant::now();
9009            for index in 0..EDGES {
9010                let node_action = policy.action_for(0.0, false);
9011                policy.record("add_node", node_action, 0.0, RATIONALE, node_terms());
9012                let action = policy.action_for(0.08, false);
9013                policy.record(
9014                    "add_edge",
9015                    action,
9016                    0.08,
9017                    RATIONALE,
9018                    outcome_terms(index & 3),
9019                );
9020            }
9021            let elapsed = start.elapsed();
9022            assert_eq!(policy.decision_log().total_recorded(), (EDGES * 2) as u64);
9023            elapsed
9024        };
9025
9026        // Arm A0: the pre-br-r37-c1-8n3j3 ledger — the identical records pushed
9027        // into an unbounded `Vec` that is never retired. This is the incumbent
9028        // arm for the bound, run in the SAME invocation as arm A.
9029        let unbounded = || {
9030            let policy = RuntimePolicy::strict();
9031            let mode = policy.mode();
9032            let mut records: Vec<DecisionRecord> = Vec::new();
9033            let start = Instant::now();
9034            for index in 0..EDGES {
9035                let action = policy.action_for(0.08, false);
9036                records.push(DecisionRecord {
9037                    ts_unix_ms: fnx_runtime::unix_time_ms(),
9038                    operation: "add_edge".into(),
9039                    mode,
9040                    action,
9041                    incompatibility_probability: 0.08,
9042                    rationale: RATIONALE.into(),
9043                    evidence: gate_terms(),
9044                });
9045                records.push(DecisionRecord {
9046                    ts_unix_ms: fnx_runtime::unix_time_ms(),
9047                    operation: "add_edge".into(),
9048                    mode,
9049                    action,
9050                    incompatibility_probability: 0.08,
9051                    rationale: RATIONALE.into(),
9052                    evidence: outcome_terms(index & 3),
9053                });
9054            }
9055            let elapsed = start.elapsed();
9056            assert_eq!(records.len(), EDGES * 2);
9057            elapsed
9058        };
9059
9060        // Arm B: build the identical terms, skip only the recording.
9061        let discarded = || {
9062            let policy = RuntimePolicy::strict();
9063            let start = Instant::now();
9064            for index in 0..EDGES {
9065                let action = policy.action_for(0.08, false);
9066                black_box((action, "add_edge", 0.08, RATIONALE, gate_terms()));
9067                black_box((
9068                    action,
9069                    "add_edge",
9070                    0.08,
9071                    RATIONALE,
9072                    outcome_terms(index & 3),
9073                ));
9074            }
9075            let elapsed = start.elapsed();
9076            assert!(policy.decision_log().records().is_empty());
9077            elapsed
9078        };
9079
9080        // Arm C: terms + the clock read, discarded. Isolates `unix_time_ms`.
9081        let clocked = || {
9082            let policy = RuntimePolicy::strict();
9083            let start = Instant::now();
9084            for index in 0..EDGES {
9085                let action = policy.action_for(0.08, false);
9086                black_box((action, fnx_runtime::unix_time_ms(), gate_terms()));
9087                black_box((
9088                    action,
9089                    fnx_runtime::unix_time_ms(),
9090                    outcome_terms(index & 3),
9091                ));
9092            }
9093            start.elapsed()
9094        };
9095
9096        // Arm D: terms + clock + the two owned `&'static str` clones + the whole
9097        // `DecisionRecord`, discarded instead of pushed. Isolates the ledger Vec
9098        // push and its growth memcpy from the per-record materialisation cost.
9099        let materialized = || {
9100            let policy = RuntimePolicy::strict();
9101            let mode = policy.mode();
9102            let start = Instant::now();
9103            for index in 0..EDGES {
9104                let action = policy.action_for(0.08, false);
9105                black_box(DecisionRecord {
9106                    ts_unix_ms: fnx_runtime::unix_time_ms(),
9107                    operation: "add_edge".into(),
9108                    mode,
9109                    action,
9110                    incompatibility_probability: 0.08,
9111                    rationale: RATIONALE.into(),
9112                    evidence: gate_terms(),
9113                });
9114                black_box(DecisionRecord {
9115                    ts_unix_ms: fnx_runtime::unix_time_ms(),
9116                    operation: "add_edge".into(),
9117                    mode,
9118                    action,
9119                    incompatibility_probability: 0.08,
9120                    rationale: RATIONALE.into(),
9121                    evidence: outcome_terms(index & 3),
9122                });
9123            }
9124            start.elapsed()
9125        };
9126
9127        // Denominator: the whole shipped add_edge_with_attrs path.
9128        let add_edge_full = || {
9129            let mut graph = Graph::strict();
9130            let keys = (0..=EDGES).map(|n| n.to_string()).collect::<Vec<_>>();
9131            let start = Instant::now();
9132            for index in 0..EDGES {
9133                graph
9134                    .add_edge_with_attrs(
9135                        keys[index].clone(),
9136                        keys[index + 1].clone(),
9137                        AttrMap::new(),
9138                    )
9139                    .expect("edge add should succeed");
9140            }
9141            let elapsed = start.elapsed();
9142            assert_eq!(graph.edge_count(), EDGES);
9143            elapsed
9144        };
9145
9146        let mut record_ns = Vec::with_capacity(ROUNDS);
9147        let mut single_ns = Vec::with_capacity(ROUNDS);
9148        let mut edge_build_ns = Vec::with_capacity(ROUNDS);
9149        let mut unbounded_ns = Vec::with_capacity(ROUNDS);
9150        let mut discard_ns = Vec::with_capacity(ROUNDS);
9151        let mut clock_ns = Vec::with_capacity(ROUNDS);
9152        let mut material_ns = Vec::with_capacity(ROUNDS);
9153        let mut full_ns = Vec::with_capacity(ROUNDS);
9154        for _ in 0..ROUNDS {
9155            record_ns.push(recorded().as_nanos() as f64 / EDGES as f64);
9156            single_ns.push(recorded_single().as_nanos() as f64 / EDGES as f64);
9157            edge_build_ns.push(recorded_edge_build().as_nanos() as f64 / EDGES as f64);
9158            unbounded_ns.push(unbounded().as_nanos() as f64 / EDGES as f64);
9159            discard_ns.push(discarded().as_nanos() as f64 / EDGES as f64);
9160            clock_ns.push(clocked().as_nanos() as f64 / EDGES as f64);
9161            material_ns.push(materialized().as_nanos() as f64 / EDGES as f64);
9162            full_ns.push(add_edge_full().as_nanos() as f64 / EDGES as f64);
9163        }
9164        let median = |samples: &mut Vec<f64>| {
9165            samples.sort_by(f64::total_cmp);
9166            samples[samples.len() / 2]
9167        };
9168        let recorded_ns = median(&mut record_ns);
9169        let single_median_ns = median(&mut single_ns);
9170        let edge_build_median_ns = median(&mut edge_build_ns);
9171        let unbounded_median_ns = median(&mut unbounded_ns);
9172        let discarded_ns = median(&mut discard_ns);
9173        let clocked_ns = median(&mut clock_ns);
9174        let materialized_ns = median(&mut material_ns);
9175        let full = median(&mut full_ns);
9176        println!("ledger_record_cost_ab n={EDGES} rounds={ROUNDS} (2 records/edge)");
9177        println!("  A  bounded ledger, 2 records: {recorded_ns:8.1} ns/edge");
9178        println!("  A1 bounded ledger, 1 record : {single_median_ns:8.1} ns/edge  (SHIPPED)");
9179        println!(
9180            "  A2 two records per edge      : {edge_build_median_ns:8.1} ns/edge  (pre-wa1b9 counterfactual)"
9181        );
9182        println!("  A0 unbounded Vec (incumbent): {unbounded_median_ns:8.1} ns/edge");
9183        println!("  B  build terms only         : {discarded_ns:8.1} ns/edge");
9184        println!("  C  terms + clock            : {clocked_ns:8.1} ns/edge");
9185        println!("  D  terms + clock + record   : {materialized_ns:8.1} ns/edge");
9186        println!("  add_edge_with_attrs (total) : {full:8.1} ns/edge");
9187        println!(
9188            "  -> clock (C-B)              : {:8.1} ns/edge",
9189            clocked_ns - discarded_ns
9190        );
9191        println!(
9192            "  -> static str clones (D-C)  : {:8.1} ns/edge",
9193            materialized_ns - clocked_ns
9194        );
9195        println!(
9196            "  -> unbounded retention (A0-D): {:8.1} ns/edge",
9197            unbounded_median_ns - materialized_ns
9198        );
9199        println!(
9200            "  -> bounded retention (A-D)  : {:8.1} ns/edge",
9201            recorded_ns - materialized_ns
9202        );
9203        println!(
9204            "  -> bound speedup (A0/A)     : {:8.4}x",
9205            unbounded_median_ns / recorded_ns
9206        );
9207        println!(
9208            "  -> single-record (A/A1)     : {:8.4}x",
9209            recorded_ns / single_median_ns
9210        );
9211        println!(
9212            "  -> both levers (A0/A1)      : {:8.4}x",
9213            unbounded_median_ns / single_median_ns
9214        );
9215        println!(
9216            "  -> ledger share, SHIPPED    : {:6.1}%  (A1/full — one record per add_edge)",
9217            single_median_ns / full * 100.0
9218        );
9219        println!(
9220            "  -> ledger share, pre-wa1b9  : {:6.1}%  (A2/full — when autocreation also recorded)",
9221            edge_build_median_ns / full * 100.0
9222        );
9223    }
9224
9225    /// br-r37-c1-g2nev: is the `Cow<'static, str>` field migration worth 110
9226    /// hand-edited construction sites?
9227    ///
9228    /// The bead proposes changing `DecisionRecord.operation`/`.rationale` and
9229    /// `EvidenceTerm.signal`/`.observed_value` from `String` to
9230    /// `Cow<'static, str>`, because 13 of the 14 owned strings a shipped
9231    /// `add_edge` materialises are compile-time constants. It has been REJECTed
9232    /// twice — not on the merits, but because it is an all-surface mechanical
9233    /// conversion across seven crates that `AGENTS.md` forbids scripting, and
9234    /// nobody had a number saying whether the payoff justifies it.
9235    ///
9236    /// This produces that number WITHOUT doing the migration first. Both arms
9237    /// run in ONE invocation and differ in exactly one thing: the field type.
9238    ///
9239    /// WHAT IS SIMULATED, AND WHY THE DELTA STILL TRANSFERS. The real ledger
9240    /// retires selectively (oldest `Allow` only) inside `RuntimePolicy`, which
9241    /// a local type cannot reuse. Both arms here therefore use the same plain
9242    /// capped `Vec` instead. That shifts the ABSOLUTE ns/edge away from the
9243    /// shipped path — so these are not ledger-share numbers and must not be
9244    /// quoted as such — but it shifts BOTH arms identically, and the quantity
9245    /// this bead turns on is the DIFFERENCE between them. `ledger_record_cost_ab`
9246    /// above remains the authority on the shipped path's absolute cost.
9247    ///
9248    /// A `Cow` record is also WIDER than a `String` one (a `Cow<str>` carries a
9249    /// discriminant), so a migration trades N allocations for a larger memcpy on
9250    /// every push and retire. That trade is exactly what is being measured, and
9251    /// it is why the answer was not obvious enough to skip measuring.
9252    ///
9253    /// `#[ignore]`; run with
9254    /// `cargo test --release -p fnx-classes --lib ledger_cow_field_cost_ab -- --ignored --nocapture`.
9255    #[test]
9256    #[ignore = "measurement; run with --release --ignored --nocapture"]
9257    fn ledger_cow_field_cost_ab() {
9258        use fnx_runtime::{
9259            DecisionAction, EvidenceTerm, RuntimePolicy, bool_evidence, count_evidence,
9260        };
9261        use std::borrow::Cow;
9262        use std::hint::black_box;
9263        use std::time::{Duration, Instant};
9264
9265        const EDGES: usize = 20_000;
9266        const ROUNDS: usize = 21;
9267        const CAP: usize = fnx_runtime::DECISION_LEDGER_RETAINED_RECORDS;
9268        const RATIONALE: &str = "argmin expected loss over {allow,full_validate,fail_closed}";
9269
9270        /// Shape-for-shape mirror of `DecisionRecord` with borrowed fields.
9271        struct CowRecord {
9272            ts_unix_ms: u128,
9273            operation: Cow<'static, str>,
9274            action: DecisionAction,
9275            incompatibility_probability: f64,
9276            rationale: Cow<'static, str>,
9277            evidence: Vec<CowTerm>,
9278        }
9279        struct CowTerm {
9280            signal: Cow<'static, str>,
9281            observed_value: Cow<'static, str>,
9282            log_likelihood_ratio: f64,
9283        }
9284
9285        // The exact four terms a shipped allowed edge records, in both forms.
9286        // Only `edge_attr_count` is genuinely dynamic; the other nine strings
9287        // are constants, which is the whole premise of the bead.
9288        fn owned_terms(edge_attr_count: usize) -> Vec<EvidenceTerm> {
9289            vec![
9290                EvidenceTerm {
9291                    signal: "self_loop".into(),
9292                    observed_value: bool_evidence(false),
9293                    log_likelihood_ratio: -0.5,
9294                },
9295                EvidenceTerm {
9296                    signal: "edge_attr_count".into(),
9297                    observed_value: count_evidence(edge_attr_count),
9298                    log_likelihood_ratio: -2.0,
9299                },
9300                EvidenceTerm {
9301                    signal: "left_autocreated".into(),
9302                    observed_value: bool_evidence(true),
9303                    log_likelihood_ratio: -1.25,
9304                },
9305                EvidenceTerm {
9306                    signal: "right_autocreated".into(),
9307                    observed_value: bool_evidence(true),
9308                    log_likelihood_ratio: -1.25,
9309                },
9310            ]
9311        }
9312        fn borrowed_terms(edge_attr_count: usize) -> Vec<CowTerm> {
9313            vec![
9314                CowTerm {
9315                    signal: Cow::Borrowed("self_loop"),
9316                    observed_value: Cow::Borrowed("false"),
9317                    log_likelihood_ratio: -0.5,
9318                },
9319                CowTerm {
9320                    signal: Cow::Borrowed("edge_attr_count"),
9321                    // The one field a migration cannot borrow.
9322                    observed_value: Cow::Owned(edge_attr_count.to_string()),
9323                    log_likelihood_ratio: -2.0,
9324                },
9325                CowTerm {
9326                    signal: Cow::Borrowed("left_autocreated"),
9327                    observed_value: Cow::Borrowed("true"),
9328                    log_likelihood_ratio: -1.25,
9329                },
9330                CowTerm {
9331                    signal: Cow::Borrowed("right_autocreated"),
9332                    observed_value: Cow::Borrowed("true"),
9333                    log_likelihood_ratio: -1.25,
9334                },
9335            ]
9336        }
9337
9338        let owned_arm = || -> Duration {
9339            let policy = RuntimePolicy::strict();
9340            let mode = policy.mode();
9341            let mut log: Vec<DecisionRecord> = Vec::new();
9342            let start = Instant::now();
9343            for index in 0..EDGES {
9344                let action = policy.action_for(0.08, false);
9345                if log.len() == CAP {
9346                    log.clear();
9347                }
9348                log.push(DecisionRecord {
9349                    ts_unix_ms: fnx_runtime::unix_time_ms(),
9350                    operation: "add_edge".into(),
9351                    mode,
9352                    action,
9353                    incompatibility_probability: 0.08,
9354                    rationale: RATIONALE.into(),
9355                    evidence: owned_terms(index & 3),
9356                });
9357            }
9358            let elapsed = start.elapsed();
9359            // Consume every field AFTER timing. Both arms do this, symmetrically:
9360            // it keeps the record contents observable so neither arm can have
9361            // its construction elided as dead, which would compare a real build
9362            // against an optimised-away one.
9363            let mut sink = 0usize;
9364            for record in &log {
9365                sink ^= record.operation.len()
9366                    ^ record.rationale.len()
9367                    ^ (record.ts_unix_ms as usize)
9368                    ^ (record.incompatibility_probability.to_bits() as usize)
9369                    ^ usize::from(matches!(record.action, DecisionAction::Allow));
9370                for term in &record.evidence {
9371                    sink ^= term.signal.len()
9372                        ^ term.observed_value.len()
9373                        ^ (term.log_likelihood_ratio.to_bits() as usize);
9374                }
9375            }
9376            black_box(sink);
9377            elapsed
9378        };
9379
9380        let borrowed_arm = || -> Duration {
9381            let policy = RuntimePolicy::strict();
9382            let mut log: Vec<CowRecord> = Vec::new();
9383            let start = Instant::now();
9384            for index in 0..EDGES {
9385                let action = policy.action_for(0.08, false);
9386                if log.len() == CAP {
9387                    log.clear();
9388                }
9389                log.push(CowRecord {
9390                    ts_unix_ms: fnx_runtime::unix_time_ms(),
9391                    operation: Cow::Borrowed("add_edge"),
9392                    action,
9393                    incompatibility_probability: 0.08,
9394                    rationale: Cow::Borrowed(RATIONALE),
9395                    evidence: borrowed_terms(index & 3),
9396                });
9397            }
9398            let elapsed = start.elapsed();
9399            let mut sink = 0usize;
9400            for record in &log {
9401                sink ^= record.operation.len()
9402                    ^ record.rationale.len()
9403                    ^ (record.ts_unix_ms as usize)
9404                    ^ (record.incompatibility_probability.to_bits() as usize)
9405                    ^ usize::from(matches!(record.action, DecisionAction::Allow));
9406                for term in &record.evidence {
9407                    sink ^= term.signal.len()
9408                        ^ term.observed_value.len()
9409                        ^ (term.log_likelihood_ratio.to_bits() as usize);
9410                }
9411            }
9412            black_box(sink);
9413            elapsed
9414        };
9415
9416        // Order-balanced ABBA, and an A/A null measured through the identical
9417        // scheme. br-r37-c1-vz4v9 found a fixed-order sibling bench reporting a
9418        // 12% "effect" that was purely which arm ran first; the null is what
9419        // makes this readable.
9420        let balanced =
9421            |first: &dyn Fn() -> Duration, second: &dyn Fn() -> Duration| -> (f64, f64) {
9422                let a1 = first();
9423                let b1 = second();
9424                let b2 = second();
9425                let a2 = first();
9426                let scale = (2 * EDGES) as f64;
9427                (
9428                    (a1 + a2).as_nanos() as f64 / scale,
9429                    (b1 + b2).as_nanos() as f64 / scale,
9430                )
9431            };
9432
9433        owned_arm();
9434        borrowed_arm();
9435
9436        let mut owned_ns = Vec::with_capacity(ROUNDS);
9437        let mut cow_ns = Vec::with_capacity(ROUNDS);
9438        let mut null_a_ns = Vec::with_capacity(ROUNDS);
9439        let mut null_b_ns = Vec::with_capacity(ROUNDS);
9440        for _ in 0..ROUNDS {
9441            let (na, nb) = balanced(&owned_arm, &owned_arm);
9442            let (owned, cow) = balanced(&owned_arm, &borrowed_arm);
9443            null_a_ns.push(na);
9444            null_b_ns.push(nb);
9445            owned_ns.push(owned);
9446            cow_ns.push(cow);
9447        }
9448
9449        let median = |samples: &mut Vec<f64>| {
9450            samples.sort_by(f64::total_cmp);
9451            samples[samples.len() / 2]
9452        };
9453        let owned = median(&mut owned_ns);
9454        let cow = median(&mut cow_ns);
9455        let null_a = median(&mut null_a_ns);
9456        let null_b = median(&mut null_b_ns);
9457
9458        println!("ledger_cow_field_cost_ab n={EDGES} rounds={ROUNDS} cap={CAP}");
9459        println!("  S  String fields   : {owned:8.1} ns/edge  (incumbent)");
9460        println!("  C  Cow fields      : {cow:8.1} ns/edge  (proposed)");
9461        println!("  N1 String, null A  : {null_a:8.1} ns/edge");
9462        println!("  N2 String, null B  : {null_b:8.1} ns/edge");
9463        println!("  -> A/A null (N2/N1): {:8.4}x", null_b / null_a);
9464        println!("  -> Cow gain (S/C)  : {:8.4}x", owned / cow);
9465        println!("  -> saving          : {:8.1} ns/edge", owned - cow);
9466    }
9467
9468    // br-r37-c1-p6bxu: A/B substrate bench for MultiGraph::remove_node
9469    // (O(degree) swap_remove vs the old O(|E|) retain). Ignored by default;
9470    // run with `cargo test -p fnx-classes --release ab_bench_multigraph_remove_node
9471    // -- --ignored --nocapture`. Also asserts byte-exact parity (node_count,
9472    // edge_count, edges_ordered) between the two paths.
9473    #[test]
9474    #[ignore]
9475    fn ab_bench_multigraph_remove_node() {
9476        use std::time::Instant;
9477        const N: usize = 1000;
9478        const M: usize = 8000;
9479        const ITERS: usize = 500;
9480        let build = || {
9481            let mut g = MultiGraph::new(CompatibilityMode::Strict);
9482            for i in 0..N {
9483                let _ = g.add_node(i.to_string());
9484            }
9485            let mut s: u64 = 0x9E3779B97F4A7C15;
9486            let mut next = || {
9487                s = s
9488                    .wrapping_mul(6364136223846793005)
9489                    .wrapping_add(1442695040888963407);
9490                (s >> 33) as usize % N
9491            };
9492            for _ in 0..M {
9493                let a = next();
9494                let b = next();
9495                let _ = g.add_edge(a.to_string(), b.to_string());
9496            }
9497            g
9498        };
9499
9500        let mut gnew = build();
9501        let victims: Vec<String> = gnew
9502            .nodes_ordered()
9503            .into_iter()
9504            .take(ITERS)
9505            .map(str::to_owned)
9506            .collect();
9507        let t = Instant::now();
9508        for node in &victims {
9509            gnew.remove_node(node);
9510        }
9511        let new_t = t.elapsed();
9512
9513        // A/A null path: the sole store must produce the same state under the
9514        // same removal sequence.
9515        let mut gold = build();
9516        let t = Instant::now();
9517        for node in &victims {
9518            gold.remove_node(node);
9519        }
9520        let null_t = t.elapsed();
9521
9522        assert_eq!(gnew.node_count(), gold.node_count());
9523        assert_eq!(gnew.edge_count(), gold.edge_count());
9524        assert_eq!(gnew.edges_ordered(), gold.edges_ordered());
9525        eprintln!(
9526            "MultiGraph remove_node A/A x{ITERS}: candidate {new_t:?}, null {null_t:?}, ratio={:.3}",
9527            null_t.as_secs_f64() / new_t.as_secs_f64()
9528        );
9529    }
9530
9531    #[test]
9532    fn remove_nodes_from_matches_repeated_removal_and_rebuilds_indices() {
9533        let edges = [
9534            ("0", "1"),
9535            ("1", "2"),
9536            ("2", "3"),
9537            ("3", "4"),
9538            ("4", "5"),
9539            ("0", "5"),
9540            ("1", "4"),
9541            ("2", "5"),
9542        ];
9543        let mut batch = Graph::strict();
9544        let mut repeated = Graph::strict();
9545        for (left, right) in edges {
9546            batch.add_edge(left, right).expect("batch edge add");
9547            repeated.add_edge(left, right).expect("repeated edge add");
9548        }
9549
9550        let victims = ["1", "3", "99", "1"];
9551        let removed = batch.remove_nodes_from(victims);
9552        for victim in victims {
9553            let _ = repeated.remove_node(victim);
9554        }
9555
9556        assert_eq!(removed, (2, 5));
9557        assert_eq!(batch.snapshot(), repeated.snapshot());
9558        let ordered_nodes = batch
9559            .nodes_ordered()
9560            .into_iter()
9561            .map(str::to_owned)
9562            .collect::<Vec<_>>();
9563        for (node_index, node) in ordered_nodes.iter().enumerate() {
9564            let names_from_strings = batch.neighbors(node).expect("node should exist");
9565            let names_from_indices = batch
9566                .neighbors_indices(node_index)
9567                .expect("index should exist")
9568                .iter()
9569                .map(|index| batch.get_node_name(*index).expect("index should resolve"))
9570                .collect::<Vec<_>>();
9571            assert_eq!(names_from_indices, names_from_strings);
9572        }
9573        let names_from_edge_indices = batch
9574            .edges_storage_order_index_iter()
9575            .map(|(left, right, _)| {
9576                (
9577                    batch
9578                        .get_node_name(left)
9579                        .expect("left endpoint should resolve")
9580                        .to_owned(),
9581                    batch
9582                        .get_node_name(right)
9583                        .expect("right endpoint should resolve")
9584                        .to_owned(),
9585                )
9586            })
9587            .collect::<Vec<_>>();
9588        let names_from_edge_storage = batch
9589            .edges_storage_order_iter()
9590            .map(|(left, right, _)| (left.to_owned(), right.to_owned()))
9591            .collect::<Vec<_>>();
9592        assert_eq!(names_from_edge_indices, names_from_edge_storage);
9593    }
9594
9595    #[test]
9596    fn multigraph_ordered_indices_match_borrowed_edges() {
9597        let mut graph = MultiGraph::strict();
9598        graph
9599            .add_edge_with_key_and_attrs("b", "a", 2, single_attr("w", "left"))
9600            .expect("first keyed edge should be added");
9601        graph
9602            .add_edge_with_key_and_attrs("a", "b", 0, single_attr("w", "right"))
9603            .expect("parallel keyed edge should be added");
9604        graph
9605            .add_edge_with_key_and_attrs("a", "c", 3, AttrMap::new())
9606            .expect("third edge should be added");
9607        graph
9608            .add_edge_with_key_and_attrs("c", "c", 1, single_attr("w", "loop"))
9609            .expect("self-loop should be added");
9610
9611        let nodes = graph.nodes_ordered();
9612        let from_indices = graph
9613            .edges_ordered_indices_borrowed()
9614            .into_iter()
9615            .map(|(left, right, key, attrs)| {
9616                (
9617                    nodes[left].to_owned(),
9618                    nodes[right].to_owned(),
9619                    key,
9620                    attrs.clone(),
9621                )
9622            })
9623            .collect::<Vec<_>>();
9624        let from_borrowed = graph
9625            .edges_ordered_borrowed()
9626            .into_iter()
9627            .map(|(left, right, key, attrs)| {
9628                (left.to_owned(), right.to_owned(), key, attrs.clone())
9629            })
9630            .collect::<Vec<_>>();
9631
9632        assert_eq!(from_indices, from_borrowed);
9633    }
9634
9635    #[test]
9636    fn multigraph_node_index_accessors_follow_remove_readd() {
9637        let mut graph = MultiGraph::strict();
9638        for node in ["prefix", "s", "a", "b", "t"] {
9639            let _ = graph.add_node(node);
9640        }
9641
9642        for (expected_index, node) in ["prefix", "s", "a", "b", "t"].into_iter().enumerate() {
9643            assert_eq!(graph.get_node_index(node), Some(expected_index));
9644            assert_eq!(graph.get_node_name(expected_index), Some(node));
9645        }
9646
9647        assert!(graph.remove_node("prefix"));
9648        assert!(graph.remove_node("a"));
9649        let _ = graph.add_node("a");
9650
9651        for (expected_index, node) in ["s", "b", "t", "a"].into_iter().enumerate() {
9652            assert_eq!(graph.get_node_index(node), Some(expected_index));
9653            assert_eq!(graph.get_node_name(expected_index), Some(node));
9654        }
9655        assert_eq!(graph.get_node_index("prefix"), None);
9656        assert_eq!(graph.get_node_name(graph.node_count()), None);
9657    }
9658
9659    #[test]
9660    fn strict_mode_fails_closed_for_unknown_incompatible_feature() {
9661        let mut graph = Graph::strict();
9662        let mut attrs = AttrMap::new();
9663        attrs.insert("__fnx_incompatible_decoder".to_owned(), "v2".into());
9664        let err = graph
9665            .add_edge_with_attrs("a", "b", attrs)
9666            .expect_err("strict mode should fail closed");
9667
9668        assert_eq!(
9669            err,
9670            GraphError::FailClosed {
9671                operation: "add_edge",
9672                reason: "incompatible edge metadata".to_owned(),
9673            }
9674        );
9675
9676        let last_record = graph
9677            .evidence_ledger()
9678            .records()
9679            .last()
9680            .expect("strict fail-closed path should emit a ledger row");
9681        assert_decision_record_schema(last_record, CompatibilityMode::Strict);
9682        assert_eq!(last_record.operation, "add_edge");
9683        assert_eq!(last_record.action, DecisionAction::FailClosed);
9684        assert!(
9685            last_record
9686                .evidence
9687                .iter()
9688                .any(|term| term.signal == "unknown_incompatible_feature")
9689        );
9690    }
9691
9692    /// br-r37-c1-eo88t: an ALLOWED edge add writes exactly one `add_edge`
9693    /// ledger record, and it is the outcome record. Asserting the surviving
9694    /// record's evidence is the point: an implementation that kept the gate
9695    /// record instead of the outcome one would still pass a bare count check
9696    /// but would have thrown away the richer decision.
9697    fn add_edge_records(graph_records: &[DecisionRecord]) -> Vec<Vec<&str>> {
9698        graph_records
9699            .iter()
9700            .filter(|record| record.operation == "add_edge")
9701            .map(|record| {
9702                record
9703                    .evidence
9704                    .iter()
9705                    .map(|term| term.signal.as_ref())
9706                    .collect()
9707            })
9708            .collect()
9709    }
9710
9711    #[test]
9712    fn allowed_add_edge_records_only_the_outcome_decision() {
9713        let mut graph = Graph::strict();
9714        graph
9715            .add_edge_with_attrs("a", "b", AttrMap::new())
9716            .expect("edge add should succeed");
9717
9718        assert_eq!(
9719            add_edge_records(graph.evidence_ledger().records()),
9720            vec![vec![
9721                "self_loop",
9722                "edge_attr_count",
9723                "left_autocreated",
9724                "right_autocreated",
9725            ]]
9726        );
9727        assert_eq!(
9728            graph
9729                .evidence_ledger()
9730                .records()
9731                .iter()
9732                .filter(|record| record.operation == "add_edge")
9733                .map(|record| record.action)
9734                .collect::<Vec<_>>(),
9735            vec![DecisionAction::Allow]
9736        );
9737    }
9738
9739    #[test]
9740    fn add_edge_autocreation_files_no_add_node_record() {
9741        // br-r37-c1-wa1b9: autocreating an endpoint is an internal STEP of
9742        // add_edge, so the ledger's per-OPERATION contract means add_edge files
9743        // exactly ONE record however many endpoints it had to create. The
9744        // information is not lost — it is in that record's autocreated terms.
9745        // A caller-visible add_node still records, which the second half pins.
9746        let mut graph = Graph::strict();
9747        graph
9748            .add_edge_with_attrs("a", "b", AttrMap::new())
9749            .expect("edge add should succeed");
9750
9751        let records = graph.evidence_ledger().records();
9752        assert_eq!(
9753            records.len(),
9754            1,
9755            "add_edge creating two nodes must file one record, got {:?}",
9756            records
9757                .iter()
9758                .map(|record| record.operation.as_ref())
9759                .collect::<Vec<_>>()
9760        );
9761        let record = &records[0];
9762        assert_eq!(record.operation, "add_edge");
9763        assert_eq!(
9764            record
9765                .evidence
9766                .iter()
9767                .filter(
9768                    |term| term.signal == "left_autocreated" || term.signal == "right_autocreated"
9769                )
9770                .map(|term| term.observed_value.as_ref())
9771                .collect::<Vec<_>>(),
9772            vec!["true", "true"],
9773            "the autocreation event must still be auditable from the outcome record"
9774        );
9775
9776        // A user-visible add_node keeps its own record.
9777        let before = graph.evidence_ledger().records().len();
9778        graph.add_node("c");
9779        let records = graph.evidence_ledger().records();
9780        assert_eq!(records.len(), before + 1);
9781        assert_eq!(
9782            records.last().map(|record| record.operation.as_ref()),
9783            Some("add_node")
9784        );
9785    }
9786
9787    /// br-r37-c1-4c29a: `MultiGraph::add_edge_impl` now resolves each endpoint
9788    /// to a storage slot ONCE and calls the `_by_pair` variants, instead of
9789    /// re-resolving both endpoint strings inside six separate storage calls.
9790    /// The behaviour that could break is key assignment and the self-loop
9791    /// shortcut (`left == right` reuses the source slot rather than probing),
9792    /// so both are pinned here against the observable key sequence.
9793    #[test]
9794    fn multigraph_slot_resolution_preserves_key_assignment() {
9795        let mut graph = MultiGraph::strict();
9796
9797        // Parallel edges take successive keys, and the pair is order-insensitive.
9798        assert_eq!(
9799            graph
9800                .add_edge_with_attrs("a", "b", AttrMap::new())
9801                .expect("edge is allowed"),
9802            0
9803        );
9804        assert_eq!(
9805            graph
9806                .add_edge_with_attrs("a", "b", AttrMap::new())
9807                .expect("edge is allowed"),
9808            1
9809        );
9810        assert_eq!(
9811            graph
9812                .add_edge_with_attrs("b", "a", AttrMap::new())
9813                .expect("reversed endpoints are the same undirected pair"),
9814            2
9815        );
9816
9817        // Self-loop takes the shortcut branch and must key independently.
9818        assert_eq!(
9819            graph
9820                .add_edge_with_attrs("a", "a", AttrMap::new())
9821                .expect("self-loop is allowed"),
9822            0
9823        );
9824        assert_eq!(
9825            graph
9826                .add_edge_with_attrs("a", "a", AttrMap::new())
9827                .expect("parallel self-loop is allowed"),
9828            1
9829        );
9830
9831        // An explicit key still lands where asked, on both branches.
9832        assert_eq!(
9833            graph
9834                .add_edge_with_key_and_attrs("a", "b", 9, AttrMap::new())
9835                .expect("explicit key is allowed"),
9836            9
9837        );
9838        assert_eq!(
9839            graph
9840                .add_edge_with_key_and_attrs("a", "a", 9, AttrMap::new())
9841                .expect("explicit key is allowed"),
9842            9
9843        );
9844
9845        assert_eq!(graph.node_count(), 2);
9846        assert_eq!(graph.edge_count(), 7);
9847    }
9848
9849    /// br-r37-c1-jc9e4: `add_edge_with_attrs` now TAKES the caller's attribute
9850    /// map when the edge bucket is empty instead of extending entry by entry.
9851    /// That is a new branch on a CGSE-critical path, so this pins the four
9852    /// bucket-state / incoming-attrs combinations it splits, including the
9853    /// non-obvious one: an edge that already exists but carries NO attributes
9854    /// still takes the empty-bucket branch on a later attributed add.
9855    #[test]
9856    fn add_edge_fresh_bucket_take_matches_merge_semantics() {
9857        fn weight(value: i64) -> AttrMap {
9858            let mut attrs = AttrMap::new();
9859            attrs.insert("w".to_owned(), CgseValue::Int(value));
9860            attrs
9861        }
9862        fn stored(graph: &Graph, left: &str, right: &str) -> Vec<(String, CgseValue)> {
9863            graph
9864                .edge_attrs(left, right)
9865                .expect("edge exists")
9866                .iter()
9867                .map(|(key, value)| (key.clone(), value.clone()))
9868                .collect()
9869        }
9870
9871        // New edge WITH attrs: the taken map is the stored map.
9872        let mut graph = Graph::strict();
9873        graph
9874            .add_edge_with_attrs("a", "b", weight(1))
9875            .expect("edge is allowed");
9876        assert_eq!(
9877            stored(&graph, "a", "b"),
9878            vec![("w".to_owned(), CgseValue::Int(1))]
9879        );
9880
9881        // New edge with EMPTY attrs: bucket stays empty, edge still exists.
9882        graph
9883            .add_edge_with_attrs("c", "d", AttrMap::new())
9884            .expect("edge is allowed");
9885        assert!(stored(&graph, "c", "d").is_empty());
9886
9887        // EXISTING edge whose bucket is still empty — takes the same branch a
9888        // brand-new edge does, and must land the attrs just the same.
9889        graph
9890            .add_edge_with_attrs("c", "d", weight(7))
9891            .expect("edge is allowed");
9892        assert_eq!(
9893            stored(&graph, "c", "d"),
9894            vec![("w".to_owned(), CgseValue::Int(7))]
9895        );
9896
9897        // EXISTING edge with a NON-empty bucket still merges, and a differing
9898        // value overwrites rather than being dropped by the take.
9899        graph
9900            .add_edge_with_attrs("a", "b", weight(9))
9901            .expect("edge is allowed");
9902        let mut extra = AttrMap::new();
9903        extra.insert("k".to_owned(), CgseValue::Int(2));
9904        graph
9905            .add_edge_with_attrs("a", "b", extra)
9906            .expect("edge is allowed");
9907        assert_eq!(
9908            stored(&graph, "a", "b"),
9909            vec![
9910                ("k".to_owned(), CgseValue::Int(2)),
9911                ("w".to_owned(), CgseValue::Int(9)),
9912            ]
9913        );
9914    }
9915
9916    #[test]
9917    fn add_edge_entry_probe_keeps_existing_empty_edge_structurally_unchanged() {
9918        let mut graph = Graph::strict();
9919        graph
9920            .add_edge_with_attrs("a", "b", AttrMap::new())
9921            .expect("initial edge is allowed");
9922        let revision_before = graph.revision;
9923
9924        graph
9925            .add_edge_with_attrs("b", "a", AttrMap::new())
9926            .expect("duplicate edge is allowed");
9927
9928        assert_eq!(graph.edge_count(), 1);
9929        assert_eq!(graph.neighbors("a"), Some(vec!["b"]));
9930        assert_eq!(graph.neighbors("b"), Some(vec!["a"]));
9931        assert_eq!(graph.revision, revision_before);
9932    }
9933
9934    /// br-r37-c1-jc9e4: locks the endpoint-resolution cases that collapsing the
9935    /// per-endpoint `contains_key` + `get_index_of` pair restructured.
9936    ///
9937    /// The self-loop branch is the delicate one: it now returns the left index
9938    /// directly instead of probing the map for the right endpoint, and it
9939    /// still has to report `right_autocreated` as a COPY of `left_autocreated`
9940    /// — true when the loop created its node, false when the node was already
9941    /// there. Node insertion order after autocreation is asserted alongside,
9942    /// because the left endpoint is resolved before the right one and an
9943    /// `IndexMap` append is what keeps the left index valid across the right
9944    /// endpoint's insertion.
9945    #[test]
9946    fn add_edge_endpoint_resolution_preserves_autocreation_and_order() {
9947        fn autocreated(graph: &Graph) -> (String, String) {
9948            let record = graph
9949                .evidence_ledger()
9950                .records()
9951                .last()
9952                .expect("add_edge files an outcome record");
9953            let read = |signal: &str| {
9954                record
9955                    .evidence
9956                    .iter()
9957                    .find(|term| term.signal == signal)
9958                    .map(|term| term.observed_value.to_string())
9959                    .unwrap_or_else(|| panic!("{signal} must be present"))
9960            };
9961            (read("left_autocreated"), read("right_autocreated"))
9962        }
9963
9964        // Self-loop that CREATES its node: both flags true, one node added.
9965        let mut graph = Graph::strict();
9966        graph
9967            .add_edge_with_attrs("s", "s", AttrMap::new())
9968            .expect("self-loop is allowed");
9969        assert_eq!(autocreated(&graph), ("true".to_owned(), "true".to_owned()));
9970        assert_eq!(graph.node_count(), 1);
9971
9972        // Self-loop on an EXISTING node: both flags false, nothing added.
9973        graph
9974            .add_edge_with_attrs("s", "s", AttrMap::new())
9975            .expect("re-adding a self-loop is allowed");
9976        assert_eq!(
9977            autocreated(&graph),
9978            ("false".to_owned(), "false".to_owned())
9979        );
9980        assert_eq!(graph.node_count(), 1);
9981
9982        // Left exists, right is new — the asymmetric case.
9983        graph
9984            .add_edge_with_attrs("s", "t", AttrMap::new())
9985            .expect("edge is allowed");
9986        assert_eq!(autocreated(&graph), ("false".to_owned(), "true".to_owned()));
9987
9988        // Both new: left is resolved and inserted BEFORE right, so insertion
9989        // order is left-then-right and the earlier indices are unmoved.
9990        graph
9991            .add_edge_with_attrs("z", "a", AttrMap::new())
9992            .expect("edge is allowed");
9993        assert_eq!(autocreated(&graph), ("true".to_owned(), "true".to_owned()));
9994        assert_eq!(graph.get_node_index("s"), Some(0));
9995        assert_eq!(graph.get_node_index("t"), Some(1));
9996        assert_eq!(graph.get_node_index("z"), Some(2));
9997        assert_eq!(graph.get_node_index("a"), Some(3));
9998    }
9999
10000    /// br-r37-c1-g2nev: the ledger's compile-time-constant strings must reach
10001    /// the record as BORROWED `Cow`s. That is the whole lever — every one of
10002    /// them used to be a per-mutation heap allocation — so this pins it on the
10003    /// shipped `add_edge`/`add_node` path rather than on a bench mirror. The
10004    /// value assertions pin the other half: the bytes are unchanged, so the
10005    /// CGSE audit trail is untouched.
10006    #[test]
10007    fn mutation_ledger_constants_are_borrowed_not_allocated() {
10008        use std::borrow::Cow;
10009        use std::collections::BTreeMap;
10010
10011        let mut graph = Graph::strict();
10012        let mut attrs = AttrMap::new();
10013        attrs.insert("weight".to_owned(), CgseValue::Int(3));
10014        graph
10015            .add_edge_with_attrs("a", "b", attrs)
10016            .expect("strict add_edge with an int attribute is allowed");
10017        graph.add_node("c");
10018
10019        let records = graph.evidence_ledger().records();
10020        assert_eq!(
10021            records
10022                .iter()
10023                .map(|record| record.operation.as_ref())
10024                .collect::<Vec<_>>(),
10025            vec!["add_edge", "add_node"],
10026            "ledger shape must be unchanged by the Cow migration"
10027        );
10028
10029        // Constant `observed_value`s, by signal, exactly as `bool::to_string()`
10030        // rendered them before the migration.
10031        let expected_borrowed: BTreeMap<&str, &str> = [
10032            ("self_loop", "false"),
10033            ("left_autocreated", "true"),
10034            ("right_autocreated", "true"),
10035            ("node_preexisting", "false"),
10036        ]
10037        .into_iter()
10038        .collect();
10039
10040        for record in records {
10041            assert!(
10042                matches!(record.operation, Cow::Borrowed(_)),
10043                "operation `{}` must be borrowed",
10044                record.operation
10045            );
10046            assert!(
10047                matches!(record.rationale, Cow::Borrowed(_)),
10048                "rationale `{}` must be borrowed",
10049                record.rationale
10050            );
10051            for term in &record.evidence {
10052                assert!(
10053                    matches!(term.signal, Cow::Borrowed(_)),
10054                    "signal `{}` must be borrowed",
10055                    term.signal
10056                );
10057                if let Some(expected) = expected_borrowed.get(term.signal.as_ref()) {
10058                    assert_eq!(&term.observed_value, expected);
10059                    assert!(
10060                        matches!(term.observed_value, Cow::Borrowed(_)),
10061                        "boolean observed_value for `{}` must be borrowed",
10062                        term.signal
10063                    );
10064                }
10065            }
10066        }
10067
10068        // The genuinely dynamic counts still allocate, and still read the same.
10069        let edge_record = &records[0];
10070        assert_eq!(
10071            edge_record
10072                .evidence
10073                .iter()
10074                .find(|term| term.signal == "edge_attr_count")
10075                .map(|term| term.observed_value.as_ref()),
10076            Some("1")
10077        );
10078    }
10079
10080    #[test]
10081    fn allowed_multigraph_add_edge_records_only_the_outcome_decision() {
10082        let mut graph = MultiGraph::strict();
10083        let _ = graph
10084            .add_edge_with_attrs("a", "b", AttrMap::new())
10085            .expect("edge add should succeed");
10086
10087        assert_eq!(
10088            add_edge_records(graph.evidence_ledger().records()),
10089            vec![vec![
10090                "edge_key",
10091                "edge_attr_count",
10092                "left_autocreated",
10093                "right_autocreated",
10094            ]]
10095        );
10096    }
10097
10098    #[test]
10099    fn refused_multigraph_add_edge_still_records_its_gate_decision() {
10100        // The negative case for the pairing above: a refusal returns before the
10101        // outcome record runs, so dropping the gate record unconditionally would
10102        // leave a rejected mutation with no audit trail at all.
10103        let mut graph = MultiGraph::strict();
10104        let mut attrs = AttrMap::new();
10105        attrs.insert("__fnx_incompatible_decoder".to_owned(), "v2".into());
10106        let err = graph
10107            .add_edge_with_attrs("a", "b", attrs)
10108            .expect_err("strict mode should fail closed");
10109        assert_eq!(
10110            err,
10111            GraphError::FailClosed {
10112                operation: "add_edge",
10113                reason: "incompatible edge metadata".to_owned(),
10114            }
10115        );
10116
10117        assert_eq!(
10118            add_edge_records(graph.evidence_ledger().records()),
10119            vec![vec!["unknown_incompatible_feature"]]
10120        );
10121        assert_eq!(
10122            graph
10123                .evidence_ledger()
10124                .records()
10125                .last()
10126                .map(|record| record.action),
10127            Some(DecisionAction::FailClosed)
10128        );
10129    }
10130
10131    #[test]
10132    fn revision_increments_on_mutating_operations() {
10133        let mut graph = Graph::strict();
10134        let r0 = graph.revision();
10135        let _ = graph.add_node("a");
10136        let r1 = graph.revision();
10137        assert!(r1 > r0);
10138
10139        graph.add_edge("a", "b").expect("edge add should succeed");
10140        let r2 = graph.revision();
10141        assert!(r2 > r1);
10142
10143        let _ = graph.remove_edge("a", "b");
10144        let r3 = graph.revision();
10145        assert!(r3 > r2);
10146    }
10147
10148    #[test]
10149    fn add_node_with_attrs_reports_change_on_existing_node() {
10150        let mut graph = Graph::strict();
10151        assert!(graph.add_node_with_attrs("a", single_attr("color", "red")));
10152        let r1 = graph.revision();
10153        assert!(graph.add_node_with_attrs("a", single_attr("color", "blue")));
10154        let r2 = graph.revision();
10155        assert!(r2 > r1);
10156        let expected = CgseValue::from("blue");
10157        assert_eq!(graph.node_attrs("a").unwrap().get("color"), Some(&expected));
10158    }
10159
10160    #[test]
10161    fn add_node_with_identical_attrs_keeps_order_and_revision() {
10162        let mut graph = Graph::strict();
10163        assert!(graph.add_node_with_attrs("a", single_attr("color", "red")));
10164        assert!(graph.add_node("b"));
10165        let revision_before = graph.revision();
10166
10167        assert!(!graph.add_node_with_attrs("a", single_attr("color", "red")));
10168
10169        assert_eq!(graph.nodes_ordered(), vec!["a", "b"]);
10170        assert_eq!(graph.revision(), revision_before);
10171        assert_eq!(
10172            graph.node_attrs("a").and_then(|attrs| attrs.get("color")),
10173            Some(&CgseValue::from("red"))
10174        );
10175    }
10176
10177    #[test]
10178    fn add_node_with_attrs_keeps_fresh_node_order_and_attrs() {
10179        let mut graph = Graph::strict();
10180        assert!(graph.add_node("before"));
10181        assert!(graph.add_node_with_attrs("fresh", single_attr("color", "red")));
10182        assert!(graph.add_node("after"));
10183
10184        assert_eq!(graph.nodes_ordered(), vec!["before", "fresh", "after"]);
10185        assert_eq!(
10186            graph
10187                .node_attrs("fresh")
10188                .and_then(|attrs| attrs.get("color")),
10189            Some(&CgseValue::from("red"))
10190        );
10191    }
10192
10193    #[test]
10194    fn multigraph_add_node_with_attrs_reports_change_on_existing_node() {
10195        let mut graph = MultiGraph::strict();
10196        assert!(graph.add_node_with_attrs("a", single_attr("color", "red")));
10197        let r1 = graph.revision();
10198        assert!(graph.add_node_with_attrs("a", single_attr("color", "blue")));
10199        let r2 = graph.revision();
10200        assert!(r2 > r1);
10201        let expected = CgseValue::from("blue");
10202        assert_eq!(graph.node_attrs("a").unwrap().get("color"), Some(&expected));
10203    }
10204
10205    #[test]
10206    fn hardened_self_loop_records_allow_decision() {
10207        let mut graph = Graph::hardened();
10208        graph
10209            .add_edge("loop", "loop")
10210            .expect("hardened self-loop edge should be accepted");
10211
10212        let add_edge_record = graph
10213            .evidence_ledger()
10214            .records()
10215            .iter()
10216            .rev()
10217            .find(|record| record.operation == "add_edge")
10218            .expect("add_edge operation should emit ledger row");
10219        assert_decision_record_schema(add_edge_record, CompatibilityMode::Hardened);
10220        assert_eq!(add_edge_record.action, DecisionAction::Allow);
10221        assert!(
10222            add_edge_record
10223                .evidence
10224                .iter()
10225                .any(|term| term.signal == "self_loop" && term.observed_value == "true")
10226        );
10227    }
10228
10229    #[test]
10230    fn runtime_policy_tracks_hardened_graph_recovery() {
10231        let mut graph = Graph::hardened();
10232        graph
10233            .add_edge("a", "b")
10234            .expect("hardened graph edge add should succeed");
10235
10236        assert_eq!(graph.runtime_policy().mode(), CompatibilityMode::Hardened);
10237        assert!(!graph.runtime_policy().decision_log().records().is_empty());
10238        assert!(graph.runtime_policy().posterior().observation_count >= 1);
10239    }
10240
10241    #[test]
10242    fn snapshot_roundtrip_replays_to_identical_state() {
10243        let mut graph = Graph::strict();
10244
10245        let mut first_attrs = AttrMap::new();
10246        first_attrs.insert("weight".to_owned(), "7".into());
10247        graph
10248            .add_edge_with_attrs("a", "b", first_attrs)
10249            .expect("edge insert should succeed");
10250
10251        let mut second_attrs = AttrMap::new();
10252        second_attrs.insert("color".to_owned(), "green".into());
10253        graph
10254            .add_edge_with_attrs("b", "c", second_attrs)
10255            .expect("edge insert should succeed");
10256
10257        let snapshot = graph.snapshot();
10258        let mut replayed = Graph::new(snapshot.mode);
10259        for node in &snapshot.nodes {
10260            let _ = replayed.add_node(node.clone());
10261        }
10262        for edge in &snapshot.edges {
10263            replayed
10264                .add_edge_with_attrs(edge.left.clone(), edge.right.clone(), edge.attrs.clone())
10265                .expect("snapshot replay should be valid");
10266        }
10267
10268        assert_eq!(replayed.snapshot(), snapshot);
10269        assert_graph_core_invariants(&replayed);
10270    }
10271
10272    #[test]
10273    fn multigraph_tracks_parallel_edges_with_distinct_keys() {
10274        let mut graph = MultiGraph::strict();
10275        let first = graph.add_edge("a", "b").expect("edge add should succeed");
10276        let second = graph.add_edge("a", "b").expect("edge add should succeed");
10277
10278        assert_ne!(first, second);
10279        assert_eq!(graph.edge_count(), 2);
10280        assert_eq!(graph.edge_keys("a", "b"), Some(vec![0, 1]));
10281        assert_multigraph_core_invariants(&graph);
10282    }
10283
10284    #[test]
10285    fn multigraph_counts_parallel_selfloops_only() {
10286        let mut graph = MultiGraph::strict();
10287        let _ = graph
10288            .add_edge("a", "a")
10289            .expect("self-loop add should succeed");
10290        let _ = graph
10291            .add_edge("a", "a")
10292            .expect("self-loop add should succeed");
10293        let _ = graph
10294            .add_edge("b", "b")
10295            .expect("self-loop add should succeed");
10296        let _ = graph.add_edge("a", "b").expect("edge add should succeed");
10297        let _ = graph.add_edge("a", "b").expect("edge add should succeed");
10298
10299        assert_eq!(graph.number_of_selfloops(), 3);
10300        assert_multigraph_core_invariants(&graph);
10301    }
10302
10303    #[test]
10304    fn multigraph_add_fresh_edge_unrecorded_rejects_occupied_bucket() {
10305        let mut graph = MultiGraph::strict();
10306        assert_eq!(graph.add_fresh_edge_unrecorded("a", "b"), Some(0));
10307        let revision = graph.revision();
10308        let edge_count = graph.edge_count();
10309
10310        assert_eq!(graph.add_fresh_edge_unrecorded("b", "a"), None);
10311
10312        assert_eq!(graph.revision(), revision);
10313        assert_eq!(graph.edge_count(), edge_count);
10314        assert_eq!(graph.edge_keys("a", "b"), Some(vec![0]));
10315        assert_eq!(graph.nodes_ordered(), vec!["a", "b"]);
10316        assert_multigraph_core_invariants(&graph);
10317    }
10318
10319    #[test]
10320    fn multigraph_add_fresh_edge_with_key_unrecorded_preserves_key() {
10321        let mut graph = MultiGraph::strict();
10322
10323        assert_eq!(
10324            graph.add_fresh_edge_with_key_unrecorded("a", "b", 42),
10325            Some(42)
10326        );
10327
10328        assert_eq!(graph.edge_count(), 1);
10329        assert_eq!(graph.edge_keys("a", "b"), Some(vec![42]));
10330        assert_eq!(graph.nodes_ordered(), vec!["a", "b"]);
10331        assert_multigraph_core_invariants(&graph);
10332    }
10333
10334    #[test]
10335    fn multigraph_remove_edge_without_key_removes_latest_instance() {
10336        let mut graph = MultiGraph::strict();
10337        let first = graph.add_edge("a", "b").expect("edge add should succeed");
10338        let second = graph.add_edge("a", "b").expect("edge add should succeed");
10339
10340        assert!(graph.remove_edge("a", "b", None));
10341        assert_eq!(graph.edge_count(), 1);
10342        assert_eq!(graph.edge_keys("a", "b"), Some(vec![first]));
10343        assert_ne!(first, second);
10344        assert_multigraph_core_invariants(&graph);
10345    }
10346
10347    #[test]
10348    fn multigraph_remove_node_clears_parallel_incident_edges() {
10349        let mut graph = MultiGraph::strict();
10350        let _ = graph.add_edge("a", "b").expect("edge add should succeed");
10351        let _ = graph.add_edge("a", "b").expect("edge add should succeed");
10352        let _ = graph.add_edge("b", "c").expect("edge add should succeed");
10353
10354        assert!(graph.remove_node("b"));
10355        assert_eq!(graph.edge_count(), 0);
10356        assert!(!graph.has_node("b"));
10357        assert_eq!(graph.neighbors("a"), Some(vec![]));
10358        assert_eq!(graph.neighbors("c"), Some(vec![]));
10359        assert_multigraph_core_invariants(&graph);
10360    }
10361
10362    #[test]
10363    fn multigraph_clear_edges_preserves_nodes_attrs_and_rows() {
10364        let mut graph = MultiGraph::strict();
10365        assert!(graph.add_node_with_attrs("a", single_attr("color", "red")));
10366        assert!(graph.add_node_with_attrs("b", single_attr("color", "blue")));
10367        let _ = graph
10368            .add_edge_with_key_and_attrs("a", "b", 7, single_attr("weight", "3"))
10369            .expect("edge add should succeed");
10370        let _ = graph
10371            .add_edge_with_key_and_attrs("b", "a", 2, single_attr("weight", "5"))
10372            .expect("edge add should succeed");
10373        let before_revision = graph.revision();
10374
10375        graph.clear_edges();
10376
10377        assert_eq!(graph.node_count(), 2);
10378        assert_eq!(graph.nodes_ordered(), vec!["a", "b"]);
10379        assert_eq!(graph.edge_count(), 0);
10380        assert!(graph.edges_ordered().is_empty());
10381        assert_eq!(graph.neighbors("a"), Some(vec![]));
10382        assert_eq!(graph.neighbors("b"), Some(vec![]));
10383        assert_eq!(
10384            graph.node_attrs("a").unwrap().get("color"),
10385            Some(&CgseValue::from("red"))
10386        );
10387        assert!(graph.revision() > before_revision);
10388        assert_multigraph_core_invariants(&graph);
10389    }
10390
10391    #[test]
10392    fn runtime_policy_tracks_hardened_multigraph_recovery() {
10393        let mut graph = MultiGraph::hardened();
10394        graph
10395            .add_edge("a", "b")
10396            .expect("hardened multigraph edge add should succeed");
10397
10398        assert_eq!(graph.runtime_policy().mode(), CompatibilityMode::Hardened);
10399        assert!(!graph.runtime_policy().decision_log().records().is_empty());
10400        assert!(graph.runtime_policy().posterior().observation_count >= 1);
10401    }
10402
10403    #[test]
10404    fn multigraph_roundtrips_sparse_snapshot_keys() {
10405        let mut graph = MultiGraph::strict();
10406        assert_eq!(
10407            graph.add_edge("a", "b").expect("edge add should succeed"),
10408            0
10409        );
10410        assert_eq!(
10411            graph.add_edge("a", "b").expect("edge add should succeed"),
10412            1
10413        );
10414        assert_eq!(
10415            graph.add_edge("a", "b").expect("edge add should succeed"),
10416            2
10417        );
10418        assert!(graph.remove_edge("a", "b", Some(1)));
10419        assert_eq!(
10420            graph.add_edge("a", "b").expect("edge add should succeed"),
10421            3
10422        );
10423
10424        let snapshot = graph.snapshot();
10425        assert_eq!(
10426            snapshot
10427                .edges
10428                .iter()
10429                .map(|edge| edge.key)
10430                .collect::<Vec<_>>(),
10431            vec![0, 2, 3]
10432        );
10433
10434        let mut replayed = MultiGraph::new(snapshot.mode);
10435        for node in &snapshot.nodes {
10436            let _ = replayed.add_node(node.clone());
10437        }
10438        for edge in &snapshot.edges {
10439            replayed
10440                .add_edge_with_key_and_attrs(
10441                    edge.left.clone(),
10442                    edge.right.clone(),
10443                    edge.key,
10444                    edge.attrs.clone(),
10445                )
10446                .expect("snapshot replay should preserve explicit keys");
10447        }
10448
10449        assert_eq!(replayed.snapshot(), snapshot);
10450        assert_multigraph_core_invariants(&replayed);
10451    }
10452
10453    proptest! {
10454        #[test]
10455        fn prop_core_invariants_hold_for_mixed_edge_mutations(
10456            ops in prop::collection::vec((0_u8..8, 0_u8..8, any::<bool>()), 1..80),
10457        ) {
10458            let mut graph = Graph::strict();
10459            let mut last_revision = graph.revision();
10460
10461            for (left_id, right_id, is_add) in ops {
10462                let left = node_name(left_id);
10463                let right = node_name(right_id);
10464                if is_add {
10465                    prop_assert!(graph.add_edge(left.clone(), right.clone()).is_ok());
10466                } else {
10467                    let _ = graph.remove_edge(&left, &right);
10468                }
10469                let revision = graph.revision();
10470                prop_assert!(revision >= last_revision);
10471                last_revision = revision;
10472                assert_graph_core_invariants(&graph);
10473            }
10474        }
10475
10476        #[test]
10477        fn prop_snapshot_is_deterministic_for_same_operation_stream(
10478            ops in prop::collection::vec((0_u8..8, 0_u8..8, 0_u8..3), 0..64),
10479        ) {
10480            let mut graph_left = Graph::hardened();
10481            let mut graph_right = Graph::hardened();
10482
10483            for (left_id, right_id, attrs_variant) in ops {
10484                let left = node_name(left_id);
10485                let right = node_name(right_id);
10486                let mut attrs = AttrMap::new();
10487                if attrs_variant == 1 {
10488                    attrs.insert("weight".to_owned(), (left_id % 5).to_string().into());
10489                } else if attrs_variant == 2 {
10490                    attrs.insert("tag".to_owned(), format!("k{}", right_id % 4).into());
10491                }
10492                prop_assert!(
10493                    graph_left
10494                        .add_edge_with_attrs(left.clone(), right.clone(), attrs.clone())
10495                        .is_ok()
10496                );
10497                prop_assert!(
10498                    graph_right
10499                        .add_edge_with_attrs(left, right, attrs)
10500                        .is_ok()
10501                );
10502            }
10503
10504            prop_assert_eq!(graph_left.snapshot(), graph_right.snapshot());
10505            prop_assert_eq!(graph_left.snapshot(), graph_left.snapshot());
10506        }
10507
10508        #[test]
10509        fn prop_reapplying_identical_edge_attrs_is_revision_stable(
10510            left_id in 0_u8..8,
10511            right_id in 0_u8..8,
10512            weight in 0_u16..5000,
10513        ) {
10514            let mut graph = Graph::strict();
10515            let left = node_name(left_id);
10516            let right = node_name(right_id);
10517            let mut attrs = AttrMap::new();
10518            attrs.insert("weight".to_owned(), weight.to_string().into());
10519
10520            prop_assert!(
10521                graph
10522                    .add_edge_with_attrs(left.clone(), right.clone(), attrs.clone())
10523                    .is_ok()
10524            );
10525            let revision_after_first = graph.revision();
10526            prop_assert!(
10527                graph
10528                    .add_edge_with_attrs(left, right, attrs)
10529                    .is_ok()
10530            );
10531            prop_assert_eq!(graph.revision(), revision_after_first);
10532        }
10533
10534        #[test]
10535        fn prop_remove_node_clears_incident_edges(
10536            ops in prop::collection::vec((0_u8..8, 0_u8..8), 1..64),
10537            target_id in 0_u8..8,
10538        ) {
10539            let mut graph = Graph::strict();
10540            for (left_id, right_id) in ops {
10541                let left = node_name(left_id);
10542                let right = node_name(right_id);
10543                prop_assert!(graph.add_edge(left, right).is_ok());
10544            }
10545
10546            let target = node_name(target_id);
10547            let removed = graph.remove_node(&target);
10548            if removed {
10549                prop_assert!(!graph.has_node(&target));
10550                for node in graph.nodes_ordered() {
10551                    let neighbors = graph
10552                        .neighbors(node)
10553                        .expect("graph nodes should always have adjacency buckets");
10554                    prop_assert!(!neighbors.contains(&target.as_str()));
10555                    prop_assert!(!graph.has_edge(node, &target));
10556                }
10557            }
10558            assert_graph_core_invariants(&graph);
10559        }
10560
10561        #[test]
10562        fn prop_decision_ledger_records_follow_schema(
10563            ops in prop::collection::vec((0_u8..8, 0_u8..8, 0_u8..4), 1..72),
10564        ) {
10565            let mut graph = Graph::strict();
10566            for (left_id, right_id, attrs_kind) in ops {
10567                let left = node_name(left_id);
10568                let right = node_name(right_id);
10569                let mut attrs = AttrMap::new();
10570                match attrs_kind {
10571                    0 => {}
10572                    1 => {
10573                        attrs.insert("weight".to_owned(), (left_id % 9).to_string().into());
10574                    }
10575                    2 => {
10576                        attrs.insert("color".to_owned(), format!("c{}", right_id % 6).into());
10577                    }
10578                    _ => {
10579                        attrs.insert("__fnx_incompatible_decoder".to_owned(), "v2".into());
10580                    }
10581                }
10582                let _ = graph.add_edge_with_attrs(left, right, attrs);
10583            }
10584
10585            let records = graph.evidence_ledger().records();
10586            prop_assert!(!records.is_empty());
10587            for record in records {
10588                assert_decision_record_schema(record, CompatibilityMode::Strict);
10589                if record.operation == "add_node" {
10590                    prop_assert_eq!(record.action, DecisionAction::Allow);
10591                    prop_assert!(record.evidence.iter().any(|term| term.signal == "node_preexisting"));
10592                    prop_assert!(record.evidence.iter().any(|term| term.signal == "attrs_count"));
10593                } else {
10594                    prop_assert_eq!(&record.operation, "add_edge");
10595                    if record.action == DecisionAction::FailClosed {
10596                        prop_assert!(
10597                            record
10598                                .evidence
10599                                .iter()
10600                                .any(|term| term.signal == "unknown_incompatible_feature")
10601                        );
10602                    } else {
10603                        prop_assert_eq!(record.action, DecisionAction::Allow);
10604                        // add_edge records two decisions: a pre-check (only
10605                        // unknown_incompatible_feature) and a post-check (with
10606                        // self_loop, edge_attr_count, etc.). Both are valid.
10607                        let is_precheck = record
10608                            .evidence
10609                            .iter()
10610                            .any(|term| term.signal == "unknown_incompatible_feature")
10611                            && !record
10612                                .evidence
10613                                .iter()
10614                                .any(|term| term.signal == "edge_attr_count");
10615                        if !is_precheck {
10616                            prop_assert!(record.evidence.iter().any(|term| term.signal == "edge_attr_count"));
10617                            prop_assert!(record.evidence.iter().any(|term| term.signal == "self_loop"));
10618                        }
10619                    }
10620                }
10621            }
10622        }
10623    }
10624}
10625
10626/// br-r37-c1-jc9e4: how much of a no-op `add_node` is the DECISION LEDGER?
10627///
10628/// The closed self-time ladder in `fnx-python` puts `Graph::add_node_with_attrs` at
10629/// `+128` ns of a `~243` ns no-op call — the single largest term inside the body. That
10630/// call is exactly two things: `add_node_with_attrs_unrecorded`, which does the store
10631/// work, and `record_decision`, which builds a `Vec<EvidenceTerm>`, reads the clock via
10632/// `unix_time_ms`, updates the posterior and pushes into the decision log. Which of the
10633/// two carries the 128 ns has never been measured, and the answer decides whether there
10634/// is anything worth trading against the audit doctrine at all.
10635///
10636/// This lives in `fnx-classes` because `add_node_with_attrs_unrecorded` is private to
10637/// this crate; measuring it from the binding would have meant widening its visibility
10638/// for a benchmark, which is a worse trade than putting the probe where the code is.
10639///
10640/// It changes NO shipped behaviour — both arms are existing functions, and the node is
10641/// already present so neither mutates anything.
10642///
10643/// CONTROLS: `recorded/recorded` is an A/A null through the identical ABBA scheme; if it
10644/// is not ~1.0 the instrument is measuring slot position, not the arms.
10645///
10646/// `#[ignore]`; run with
10647/// `rch exec -- cargo test --release -j 2 -p fnx-classes --lib
10648///  node_decision_ledger_self_time_ab -- --ignored --nocapture`.
10649#[test]
10650#[ignore = "measurement; run with --release --ignored --nocapture"]
10651fn node_decision_ledger_self_time_ab() {
10652    use std::hint::black_box;
10653    use std::time::{Duration, Instant};
10654
10655    const NODES: usize = 2000;
10656    const REPS: usize = 200;
10657    const ROUNDS: usize = 41;
10658
10659    let keys: Vec<String> = (0..NODES).map(|n| format!("n{n}")).collect();
10660    let mut graph = Graph::strict();
10661    for key in &keys {
10662        let _ = graph.add_node_with_attrs(key.clone(), AttrMap::new());
10663    }
10664    let before = graph.node_count();
10665
10666    // Both arms re-add nodes that are ALREADY PRESENT, so neither changes the store and
10667    // repeating them is idempotent — the property that makes a no-op control timeable.
10668    let recorded = |g: &mut Graph| -> Duration {
10669        let start = Instant::now();
10670        for _ in 0..REPS {
10671            for key in &keys {
10672                black_box(g.add_node_with_attrs(key.as_str(), AttrMap::new()));
10673            }
10674        }
10675        start.elapsed()
10676    };
10677    let unrecorded = |g: &mut Graph| -> Duration {
10678        let start = Instant::now();
10679        for _ in 0..REPS {
10680            for key in &keys {
10681                black_box(g.add_node_with_attrs_unrecorded(key.as_str(), AttrMap::new()));
10682            }
10683        }
10684        start.elapsed()
10685    };
10686
10687    // ABBA: each arm runs at both ends of a four-slot block, so any cost that depends on
10688    // slot rather than work cancels.
10689    let balanced = |first: &dyn Fn(&mut Graph) -> Duration,
10690                    second: &dyn Fn(&mut Graph) -> Duration,
10691                    g: &mut Graph|
10692     -> (f64, f64) {
10693        let a1 = first(g);
10694        let b1 = second(g);
10695        let b2 = second(g);
10696        let a2 = first(g);
10697        let scale = (2 * NODES * REPS) as f64;
10698        (
10699            (a1 + a2).as_nanos() as f64 / scale,
10700            (b1 + b2).as_nanos() as f64 / scale,
10701        )
10702    };
10703
10704    for _ in 0..3 {
10705        recorded(&mut graph);
10706        unrecorded(&mut graph);
10707    }
10708
10709    let mut rec_ns = Vec::with_capacity(ROUNDS);
10710    let mut unrec_ns = Vec::with_capacity(ROUNDS);
10711    let mut null_a = Vec::with_capacity(ROUNDS);
10712    let mut null_b = Vec::with_capacity(ROUNDS);
10713    for _ in 0..ROUNDS {
10714        let (na, nb) = balanced(&recorded, &recorded, &mut graph);
10715        null_a.push(na);
10716        null_b.push(nb);
10717        let (r, u) = balanced(&recorded, &unrecorded, &mut graph);
10718        rec_ns.push(r);
10719        unrec_ns.push(u);
10720    }
10721
10722    assert_eq!(
10723        graph.node_count(),
10724        before,
10725        "a no-op control that changed the node count measured a real insert"
10726    );
10727
10728    let median = |v: &mut Vec<f64>| -> f64 {
10729        v.sort_by(f64::total_cmp);
10730        v[(v.len() - 1) / 2]
10731    };
10732    let rec = median(&mut rec_ns);
10733    let unrec = median(&mut unrec_ns);
10734    let na = median(&mut null_a);
10735    let nb = median(&mut null_b);
10736
10737    println!("node_decision_ledger_self_time_ab nodes={NODES} reps={REPS} rounds={ROUNDS}");
10738    println!("  A/A null (recorded/recorded) : {:8.4}x", nb / na);
10739    println!("  A  add_node_with_attrs       : {rec:8.1} ns/call  (store + ledger)");
10740    println!("  B  ..._unrecorded            : {unrec:8.1} ns/call  (store only)");
10741    println!(
10742        "  -> decision ledger costs     : {:8.1} ns/call",
10743        rec - unrec
10744    );
10745    println!(
10746        "  -> ledger share of the pair  : {:8.1}%",
10747        100.0 * (rec - unrec) / rec
10748    );
10749}