Skip to main content

fnx_classes/
digraph.rs

1//! Directed graph (DiGraph) storage.
2//!
3//! Mirrors the undirected [`Graph`] API with directed semantics:
4//! - Edge `(u, v)` is distinct from `(v, u)`.
5//! - Adjacency is split into **successors** (outgoing) and **predecessors** (incoming).
6//! - `neighbors(n)` returns successors (matching NetworkX convention).
7
8use crate::{AttrMap, EdgeSnapshot, GraphError};
9use fnx_runtime::{
10    CgseValue, CompatibilityMode, DecisionAction, DecisionRecord, EvidenceLedger, EvidenceTerm,
11    RuntimePolicy, bool_evidence, count_evidence,
12};
13use indexmap::{IndexMap, IndexSet};
14use serde::{Deserialize, Serialize};
15
16// ---------------------------------------------------------------------------
17// DirectedEdgeKey — order-preserving (NOT canonicalized)
18// ---------------------------------------------------------------------------
19
20#[derive(Debug, Clone, PartialEq, Eq, Hash)]
21struct DirectedEdgeKey {
22    source: String,
23    target: String,
24}
25
26impl DirectedEdgeKey {
27    fn new(source: &str, target: &str) -> Self {
28        Self {
29            source: source.to_owned(),
30            target: target.to_owned(),
31        }
32    }
33}
34
35#[derive(Hash, PartialEq, Eq, Clone, Copy)]
36struct DirectedEdgeKeyRef<'a> {
37    source: &'a str,
38    target: &'a str,
39}
40
41impl<'a> DirectedEdgeKeyRef<'a> {
42    fn new(source: &'a str, target: &'a str) -> Self {
43        Self { source, target }
44    }
45}
46
47impl<'a> indexmap::Equivalent<DirectedEdgeKey> for DirectedEdgeKeyRef<'a> {
48    fn equivalent(&self, key: &DirectedEdgeKey) -> bool {
49        self.source == key.source && self.target == key.target
50    }
51}
52
53// ---------------------------------------------------------------------------
54// DiGraphSnapshot
55// ---------------------------------------------------------------------------
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct DiGraphSnapshot {
59    pub mode: CompatibilityMode,
60    pub nodes: Vec<String>,
61    /// br-snapnodeattrs: per-node attributes preserved across snapshot
62    /// round-trip. Defaults to empty for backward compatibility.
63    #[serde(default)]
64    pub node_attrs: std::collections::BTreeMap<String, AttrMap>,
65    /// Edges in source→target order. `left` = source, `right` = target.
66    pub edges: Vec<EdgeSnapshot>,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct MultiDiEdgeSnapshot {
71    pub source: String,
72    pub target: String,
73    pub key: usize,
74    pub attrs: AttrMap,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct MultiDiGraphSnapshot {
79    pub mode: CompatibilityMode,
80    pub nodes: Vec<String>,
81    /// See DiGraphSnapshot::node_attrs.
82    #[serde(default)]
83    pub node_attrs: std::collections::BTreeMap<String, AttrMap>,
84    pub edges: Vec<MultiDiEdgeSnapshot>,
85}
86
87// ---------------------------------------------------------------------------
88// DiGraph
89// ---------------------------------------------------------------------------
90
91/// br-r37-c1-d58s8 P1: integer CSR view of a DiGraph's adjacency —
92/// succ/pred targets are node INDICES (IndexMap insertion order) with
93/// per-row slices in ROW order, so index walks reproduce the String
94/// walks exactly. Built once per revision via `DiGraph::csr()`.
95#[derive(Debug)]
96pub struct DiCsr {
97    pub succ_offsets: Vec<usize>,
98    pub succ_targets: Vec<u32>,
99    pub pred_offsets: Vec<usize>,
100    pub pred_targets: Vec<u32>,
101}
102
103impl DiCsr {
104    #[must_use]
105    pub fn successors(&self, idx: usize) -> &[u32] {
106        &self.succ_targets[self.succ_offsets[idx]..self.succ_offsets[idx + 1]]
107    }
108
109    #[must_use]
110    pub fn predecessors(&self, idx: usize) -> &[u32] {
111        &self.pred_targets[self.pred_offsets[idx]..self.pred_offsets[idx + 1]]
112    }
113}
114
115type DiCsrCache = std::sync::Arc<std::sync::RwLock<Option<(u64, std::sync::Arc<DiCsr>)>>>;
116type DiAllIntCache = std::sync::Arc<std::sync::RwLock<Option<(u64, String, bool)>>>;
117
118/// Revision-keyed integer CSR view of a MultiDiGraph's distinct successor and
119/// predecessor rows. Parallel-edge keys are intentionally collapsed because this
120/// view is for multiplicity-insensitive structural algorithms.
121#[derive(Debug)]
122pub struct MultiDiCsr {
123    pub succ_offsets: Vec<usize>,
124    pub succ_targets: Vec<u32>,
125    pub pred_offsets: Vec<usize>,
126    pub pred_targets: Vec<u32>,
127}
128
129impl MultiDiCsr {
130    #[must_use]
131    pub fn successors(&self, idx: usize) -> &[u32] {
132        &self.succ_targets[self.succ_offsets[idx]..self.succ_offsets[idx + 1]]
133    }
134
135    #[must_use]
136    pub fn predecessors(&self, idx: usize) -> &[u32] {
137        &self.pred_targets[self.pred_offsets[idx]..self.pred_offsets[idx + 1]]
138    }
139}
140
141type MultiDiCsrCache = std::sync::Arc<std::sync::RwLock<Option<(u64, std::sync::Arc<MultiDiCsr>)>>>;
142
143/// br-r37-c1-s8dj1: node POSITION pair -> position in `edges`.
144type EdgePairMap = std::collections::HashMap<(usize, usize), usize, rustc_hash::FxBuildHasher>;
145
146/// br-r37-c1-s8dj1: the pair lookaside, stamped with the graph revision.
147///
148/// CLONES COLD, AND IS NEVER SHARED. `MultiDiGraph` derives `Clone`, which would
149/// otherwise hand both copies the same cache. The stamp does not rescue an
150/// INCREMENTALLY maintained map from that: the two graphs' revisions advance
151/// independently and can COINCIDE, so the clone updates the shared map and
152/// stamps its own revision, and the original then reads a map describing a
153/// different graph. A cold clone costs one rebuild and removes the class.
154#[derive(Debug, Default)]
155pub(crate) struct EdgePairIndex {
156    map: std::sync::RwLock<Option<(u64, EdgePairMap)>>,
157    /// br-r37-c1-s8dj1: the revision the warm-up counter belongs to.
158    probed_revision: std::sync::atomic::AtomicU64,
159    /// Probes seen at that revision without an index. See `PAIR_INDEX_WARMUP`.
160    probes_since_build: std::sync::atomic::AtomicUsize,
161}
162
163impl Clone for EdgePairIndex {
164    fn clone(&self) -> Self {
165        Self::default()
166    }
167}
168
169/// br-r37-c1-s8dj1: probes at one revision before the O(E) map is built.
170///
171/// THE GUARD EXISTS BECAUSE THE BUILD IS NOT FREE. Building costs a hash per
172/// endpoint of every edge, so a caller that probes a large graph a HANDFUL of
173/// times would pay O(E) to save O(node key length) — measured at E=8000 as
174/// 6308 ns/probe against 592 ns for the undirected class, a loss on exactly the
175/// sparse-probing shape that never benefits.
176///
177/// SO THE THRESHOLD SCALES WITH THE BUILD IT AMORTISES, and a constant will not
178/// do: 64 probes justify a build at E=100 and are nowhere near justifying one at
179/// E=8000. Waiting until the caller has made roughly half as many probes as the
180/// build costs entries bounds the waste at a small constant factor of the work
181/// already spent, which is the ordinary amortisation rule. Below the threshold
182/// the string path answers, exactly as the class did before this index existed.
183const PAIR_INDEX_WARMUP_FLOOR: usize = 64;
184
185#[inline]
186fn pair_index_warmup(edge_pairs: usize) -> usize {
187    PAIR_INDEX_WARMUP_FLOOR.max(edge_pairs / 2)
188}
189
190#[derive(Debug, Clone)]
191pub struct DiGraph {
192    mode: CompatibilityMode,
193    revision: u64,
194    nodes: crate::FxIndexMap<String, AttrMap>,
195    // br-r37-c1-d58s8 DiGraph flip P3: String rows GONE — the eager
196    // index rows below are the single row store; String views derive
197    // through the nodes name table.
198    /// br-r37-c1-d58s8 DiGraph row flip phase 1: EAGER integer row
199    /// mirrors (insertion-order-faithful, maintained by every writer
200    /// with the I5 repair pattern on removal). Phase 2 re-points
201    /// readers here; phase 3 deletes the String rows above.
202    succ_indices: Vec<Vec<usize>>,
203    pred_indices: Vec<Vec<usize>>,
204    // br-r37-c1-d58s8 edges-map flip (directed twin): keyed by the
205    // ORIENTED (source_idx, target_idx) pair — NOT canonicalized
206    // (direction IS identity for directed edges). Zero String
207    // allocs/hashes per insert; node removal REKEYS via the remap.
208    edges: crate::FxIndexMap<(usize, usize), AttrMap>,
209    runtime_policy: RuntimePolicy,
210    /// br-r37-c1-d58s8 P1: revision-keyed CSR cache. Derived from the
211    /// String maps on demand (never eagerly maintained — the I5
212    /// renumbering hazard cannot apply); shared across clones safely
213    /// because entries are revision-checked before use. Mutations
214    /// invalidate implicitly by bumping `revision`.
215    csr_cache: DiCsrCache,
216    /// br-r37-c1-d58s8: revision-keyed all-int weights memo (the
217    /// shortest-path int-coercion pre-scan walked every edge attr per
218    /// call). Single entry keyed by (revision, attr).
219    all_int_cache: DiAllIntCache,
220}
221
222impl DiGraph {
223    /// br-r37-c1-clearedgesinplace (cc): drop all edges in place, keeping nodes (index,
224    /// order, attrs). O(E)+O(V): clear the edges map + every succ/pred index row; the
225    /// revision bump invalidates the CSR + all-int caches. Sibling of Graph::clear_edges
226    /// and MultiDiGraph::clear_edges. The binding's prior path collected the mirror keys
227    /// and called remove_edge per edge (each an O(degree) row retain -> O(E*degree),
228    /// measured ~0.003-0.011x vs nx); this is the in-place fix.
229    pub fn clear_edges(&mut self) {
230        if self.edges.is_empty() {
231            return;
232        }
233        self.edges.clear();
234        for row in &mut self.succ_indices {
235            row.clear();
236        }
237        for row in &mut self.pred_indices {
238            row.clear();
239        }
240        self.revision = self.revision.saturating_add(1);
241    }
242
243    /// br-r37-c1-7dpyg: structural clone with a FRESH RuntimePolicy —
244    /// see Graph::clone_with_fresh_policy.
245    #[must_use]
246    pub fn clone_with_fresh_policy(&self) -> Self {
247        Self {
248            mode: self.mode,
249            revision: self.revision,
250            nodes: self.nodes.clone(),
251            succ_indices: self.succ_indices.clone(),
252            pred_indices: self.pred_indices.clone(),
253            edges: self.edges.clone(),
254            runtime_policy: RuntimePolicy::new(self.mode),
255            csr_cache: self.csr_cache.clone(),
256            all_int_cache: self.all_int_cache.clone(),
257        }
258    }
259
260    // -----------------------------------------------------------------------
261    // Constructors
262    // -----------------------------------------------------------------------
263
264    #[must_use]
265    pub fn new(mode: CompatibilityMode) -> Self {
266        Self {
267            mode,
268            revision: 0,
269            nodes: crate::FxIndexMap::default(),
270            succ_indices: Vec::new(),
271            pred_indices: Vec::new(),
272            edges: crate::FxIndexMap::default(),
273            runtime_policy: RuntimePolicy::new(mode),
274            csr_cache: std::sync::Arc::default(),
275            all_int_cache: std::sync::Arc::default(),
276        }
277    }
278
279    #[must_use]
280    pub fn with_runtime_policy(runtime_policy: RuntimePolicy) -> Self {
281        let mode = runtime_policy.mode();
282        Self {
283            mode,
284            revision: 0,
285            nodes: crate::FxIndexMap::default(),
286            succ_indices: Vec::new(),
287            pred_indices: Vec::new(),
288            edges: crate::FxIndexMap::default(),
289            runtime_policy,
290            csr_cache: std::sync::Arc::default(),
291            all_int_cache: std::sync::Arc::default(),
292        }
293    }
294
295    /// br-r37-c1-indsub: directed twin of `Graph::induced_subgraph_ordered` —
296    /// build the induced subgraph over `order` (parent node indices, already in
297    /// the caller's output order) with no node-name hashing.
298    ///
299    /// `add_edge_with_attrs` costs two `String` allocations, four name hashes
300    /// and TWO `record_decision` ledger entries per arc (the second carrying
301    /// four `EvidenceTerm`s, eight more String allocations). Everything it
302    /// re-derives is already known here. Invariants reproduced directly:
303    /// `edges` keyed by the ORIENTED new-index pair (direction is identity for
304    /// a directed edge, so no canonicalization), and a succ/pred row records
305    /// its endpoint when the arc is emitted, so both row orders are the walk
306    /// order — matching nx's `add_edges_from` over the induced walk.
307    ///
308    /// `order` must contain distinct, in-range parent indices.
309    #[must_use]
310    pub fn induced_subgraph_ordered(&self, order: &[usize], runtime_policy: RuntimePolicy) -> Self {
311        let mut position = vec![usize::MAX; self.nodes.len()];
312        for (slot, &parent_idx) in order.iter().enumerate() {
313            position[parent_idx] = slot;
314        }
315
316        let mut nodes = crate::FxIndexMap::default();
317        nodes.reserve(order.len());
318        for &parent_idx in order {
319            let (name, attrs) = self
320                .nodes
321                .get_index(parent_idx)
322                .expect("induced subgraph order must hold valid parent node indices");
323            nodes.insert(name.clone(), attrs.clone());
324        }
325
326        let mut succ_indices = vec![Vec::new(); order.len()];
327        let mut pred_indices = vec![Vec::new(); order.len()];
328        let mut edges = crate::FxIndexMap::default();
329        for (slot, &parent_idx) in order.iter().enumerate() {
330            for &parent_target in &self.succ_indices[parent_idx] {
331                let other = position[parent_target];
332                if other == usize::MAX {
333                    continue;
334                }
335                let attrs = self
336                    .edges
337                    .get(&(parent_idx, parent_target))
338                    .cloned()
339                    .unwrap_or_default();
340                edges.insert((slot, other), attrs);
341                succ_indices[slot].push(other);
342                pred_indices[other].push(slot);
343            }
344        }
345
346        Self {
347            mode: runtime_policy.mode(),
348            revision: 0,
349            nodes,
350            succ_indices,
351            pred_indices,
352            edges,
353            runtime_policy,
354            csr_cache: std::sync::Arc::default(),
355            all_int_cache: std::sync::Arc::default(),
356        }
357    }
358
359    #[must_use]
360    pub fn strict() -> Self {
361        Self::new(CompatibilityMode::Strict)
362    }
363
364    #[must_use]
365    pub fn hardened() -> Self {
366        Self::new(CompatibilityMode::Hardened)
367    }
368
369    // -----------------------------------------------------------------------
370    // Read-only queries
371    // -----------------------------------------------------------------------
372
373    #[must_use]
374    pub fn mode(&self) -> CompatibilityMode {
375        self.mode
376    }
377
378    #[must_use]
379    pub fn node_count(&self) -> usize {
380        self.nodes.len()
381    }
382
383    #[must_use]
384    pub fn edge_count(&self) -> usize {
385        self.edges.len()
386    }
387
388    #[must_use]
389    pub fn revision(&self) -> u64 {
390        self.revision
391    }
392
393    /// br-r37-c1-d58s8 P1: revision-keyed integer CSR of both row
394    /// families. O(V+E) rebuild only when the graph changed since the
395    /// cached build; kernels get index walks identical to the String
396    /// walks (per-row order preserved).
397    #[must_use]
398    pub fn csr(&self) -> std::sync::Arc<DiCsr> {
399        if let Ok(guard) = self.csr_cache.read()
400            && let Some((rev, csr)) = guard.as_ref()
401            && *rev == self.revision
402        {
403            return csr.clone();
404        }
405        let built = std::sync::Arc::new(self.build_csr());
406        if let Ok(mut guard) = self.csr_cache.write() {
407            *guard = Some((self.revision, built.clone()));
408        }
409        built
410    }
411
412    /// br-r37-c1-d58s8: revision-keyed memo of "every edge's `attr`
413    /// value is an integer (missing = default 1 = int)".
414    #[must_use]
415    pub fn edge_weights_all_int(&self, attr: &str) -> bool {
416        if let Ok(guard) = self.all_int_cache.read()
417            && let Some((rev, a, v)) = guard.as_ref()
418            && *rev == self.revision
419            && a == attr
420        {
421            return *v;
422        }
423        let v = self
424            .edges
425            .values()
426            .all(|attrs| attrs.get(attr).is_none_or(fnx_runtime::CgseValue::is_int));
427        if let Ok(mut guard) = self.all_int_cache.write() {
428            *guard = Some((self.revision, attr.to_owned(), v));
429        }
430        v
431    }
432
433    fn build_csr(&self) -> DiCsr {
434        // br-r37-c1-d58s8 DiGraph flip P1: build from the EAGER index rows
435        // — zero String hashing (was a get_index_of per neighbor).
436        let n = self.nodes.len();
437        let mut succ_offsets = Vec::with_capacity(n + 1);
438        let mut pred_offsets = Vec::with_capacity(n + 1);
439        let mut succ_targets = Vec::new();
440        let mut pred_targets = Vec::new();
441        succ_offsets.push(0);
442        pred_offsets.push(0);
443        for i in 0..n {
444            succ_targets.extend(
445                self.succ_indices[i]
446                    .iter()
447                    .map(|&v| u32::try_from(v).unwrap_or(u32::MAX)),
448            );
449            succ_offsets.push(succ_targets.len());
450            pred_targets.extend(
451                self.pred_indices[i]
452                    .iter()
453                    .map(|&v| u32::try_from(v).unwrap_or(u32::MAX)),
454            );
455            pred_offsets.push(pred_targets.len());
456        }
457        DiCsr {
458            succ_offsets,
459            succ_targets,
460            pred_offsets,
461            pred_targets,
462        }
463    }
464
465    /// br-r37-c1-d58s8: consistency oracle — eager index rows must mirror
466    /// the String rows exactly (order included). Test-only.
467    #[doc(hidden)]
468    #[must_use]
469    pub fn debug_index_rows_consistent(&self) -> bool {
470        // br-r37-c1-d58s8 P3: the String rows this oracle compared
471        // against are GONE; the index rows are the single store. Length
472        // sanity only (parity batteries cover content).
473        self.succ_indices.len() == self.nodes.len() && self.pred_indices.len() == self.nodes.len()
474    }
475
476    #[must_use]
477    pub fn has_node(&self, node: &str) -> bool {
478        self.nodes.contains_key(node)
479    }
480
481    /// Check for directed edge source→target.
482    #[must_use]
483    /// br-r37-c1-d58s8: resolve a String pair to the ORIENTED index key.
484    #[inline]
485    fn edge_pair_key(&self, source: &str, target: &str) -> Option<(usize, usize)> {
486        Some((
487            self.nodes.get_index_of(source)?,
488            self.nodes.get_index_of(target)?,
489        ))
490    }
491
492    /// br-r37-c1-1l8s0: direct slice access to the eager succ index row.
493    #[must_use]
494    #[inline]
495    pub fn successors_indices(&self, idx: usize) -> Option<&[usize]> {
496        self.succ_indices.get(idx).map(Vec::as_slice)
497    }
498
499    /// Direct slice access to the eager pred index row.
500    #[must_use]
501    #[inline]
502    pub fn predecessors_indices(&self, idx: usize) -> Option<&[usize]> {
503        self.pred_indices.get(idx).map(Vec::as_slice)
504    }
505
506    pub fn has_edge(&self, source: &str, target: &str) -> bool {
507        self.edge_pair_key(source, target)
508            .is_some_and(|k| self.edges.contains_key(&k))
509    }
510
511    /// br-r37-c1-04z53 (cc): directed `has_edge` straight from insertion-order
512    /// indices (source-major), skipping the `i.to_string()` heap alloc for int
513    /// nodes. Names come from the node table by index (borrowed); the caller
514    /// guards each index with `node_index_matches_int`. Mirror of
515    /// `Graph::has_edge_by_indices`.
516    ///
517    /// br-r37-c1-p1tvg: this used to resolve both indices back to NAMES and
518    /// call `has_edge`, which hashes two Strings to rediscover the very
519    /// indices it was handed — exactly the cost a caller pays an index route
520    /// to avoid. The edges map has been keyed by the ORIENTED
521    /// `(source_idx, target_idx)` pair since the d58s8 flip, so the pair IS
522    /// the key. An out-of-range index still answers `false`: every key in the
523    /// map names two live nodes, so no key can carry one.
524    #[must_use]
525    pub fn has_edge_by_indices(&self, source_idx: usize, target_idx: usize) -> bool {
526        self.edges.contains_key(&(source_idx, target_idx))
527    }
528
529    #[must_use]
530    pub fn nodes_ordered(&self) -> Vec<&str> {
531        self.nodes.keys().map(String::as_str).collect()
532    }
533
534    /// br-cc-nbunchbulk: does the node at contiguous index `idx` have the exact int
535    /// name `idx`? O(1) index probe + no-alloc parse — the int membership fast path
536    /// for `_nbunch_present` (sibling of the undirected Graph accessor).
537    #[must_use]
538    pub fn node_index_matches_int(&self, idx: usize) -> bool {
539        self.nodes
540            .get_index(idx)
541            .is_some_and(|(k, _)| k.parse::<usize>() == Ok(idx))
542    }
543
544    #[must_use]
545    pub fn get_node_index(&self, node: &str) -> Option<usize> {
546        self.nodes.get_index_of(node)
547    }
548
549    #[must_use]
550    pub fn get_node_name(&self, index: usize) -> Option<&str> {
551        self.nodes.get_index(index).map(|(k, _)| k.as_str())
552    }
553
554    // -- Directed adjacency queries ----------------------------------------
555
556    /// Successors of `node` (outgoing neighbors). Returns `None` if node absent.
557    #[must_use]
558    pub fn successors(&self, node: &str) -> Option<Vec<&str>> {
559        // br-r37-c1-d58s8 DiGraph flip P2: serve from the eager index
560        // rows + name table (order-faithful; oracle-validated in P1).
561        let idx = self.nodes.get_index_of(node)?;
562        Some(
563            self.succ_indices[idx]
564                .iter()
565                .map(|&i| {
566                    self.nodes
567                        .get_index(i)
568                        .expect("index rows hold valid node indices")
569                        .0
570                        .as_str()
571                })
572                .collect(),
573        )
574    }
575
576    #[must_use]
577    pub fn successors_iter(&self, node: &str) -> Option<impl Iterator<Item = &str> + '_> {
578        // br-r37-c1-d58s8 DiGraph flip P2: index-backed.
579        let idx = self.nodes.get_index_of(node)?;
580        Some(self.succ_indices[idx].iter().map(move |&i| {
581            self.nodes
582                .get_index(i)
583                .expect("index rows hold valid node indices")
584                .0
585                .as_str()
586        }))
587    }
588
589    /// Predecessors of `node` (incoming neighbors). Returns `None` if node absent.
590    #[must_use]
591    pub fn predecessors(&self, node: &str) -> Option<Vec<&str>> {
592        // br-r37-c1-d58s8 DiGraph flip P2: index-backed.
593        let idx = self.nodes.get_index_of(node)?;
594        Some(
595            self.pred_indices[idx]
596                .iter()
597                .map(|&i| {
598                    self.nodes
599                        .get_index(i)
600                        .expect("index rows hold valid node indices")
601                        .0
602                        .as_str()
603                })
604                .collect(),
605        )
606    }
607
608    /// br-r37-c1-u3qyn: restore explicit succ/pred row orders (pickle
609    /// round-trip) — see Graph::apply_row_orders. `which` selects the
610    /// adjacency side.
611    pub fn apply_row_orders(&mut self, orders: &[(String, Vec<String>)], pred: bool) {
612        // br-r37-c1-d58s8 P3: pure integer-row reorder per family.
613        for (node, order) in orders {
614            let Some(idx) = self.nodes.get_index_of(node.as_str()) else {
615                continue;
616            };
617            let rows = if pred {
618                &mut self.pred_indices
619            } else {
620                &mut self.succ_indices
621            };
622            let row = &rows[idx];
623            let row_set: std::collections::HashSet<usize> = row.iter().copied().collect();
624            let mut placed: std::collections::HashSet<usize> = std::collections::HashSet::new();
625            let mut new_row: Vec<usize> = Vec::with_capacity(row.len());
626            for v in order {
627                if let Some(v_idx) = self.nodes.get_index_of(v.as_str())
628                    && row_set.contains(&v_idx)
629                    && placed.insert(v_idx)
630                {
631                    new_row.push(v_idx);
632                }
633            }
634            for &v_idx in row {
635                if placed.insert(v_idx) {
636                    new_row.push(v_idx);
637                }
638            }
639            rows[idx] = new_row;
640        }
641    }
642
643    /// br-r37-c1-0ek49: reorder every PRED row into NetworkX's
644    /// `DiGraph.copy()` walk order. nx copy rebuilds via the u-major
645    /// succ walk (`(u, v, d) for u in _adj for v in _adj[u]`), which
646    /// recreates succ rows in their original order but fills each pred
647    /// row in walk order: `(pos(u), index of v within succ[u])`. Call on
648    /// a fresh clone inside copy-shaped constructors; graph content is
649    /// unchanged, only pred row order moves.
650    pub fn reorder_pred_rows_for_nx_copy_walk(&mut self) {
651        // br-r37-c1-predrebuild: rebuild pred rows in nx's u-major copy-walk order
652        // DIRECTLY from succ rows in O(E), replacing the prior per-pred-entry
653        // linear search `succ_indices[u].position(|w| w == v)` (O(E * degree) —
654        // quadratic on dense graphs). Iterating succ in (u, succ-index) order and
655        // appending u to pred[v] reproduces exactly the
656        // (pos(u), index-of-v-within-succ[u]) ordering the old sort produced,
657        // including parallel-edge multiplicity — byte-identical pred row order.
658        let mut new_rows: Vec<Vec<usize>> = self
659            .pred_indices
660            .iter()
661            .map(|row| Vec::with_capacity(row.len()))
662            .collect();
663        for (u, succ_row) in self.succ_indices.iter().enumerate() {
664            for &v in succ_row {
665                new_rows[v].push(u);
666            }
667        }
668        self.pred_indices = new_rows;
669    }
670
671    #[must_use]
672    pub fn predecessors_iter(&self, node: &str) -> Option<impl Iterator<Item = &str> + '_> {
673        // br-r37-c1-d58s8 DiGraph flip P2: index-backed.
674        let idx = self.nodes.get_index_of(node)?;
675        Some(self.pred_indices[idx].iter().map(move |&i| {
676            self.nodes
677                .get_index(i)
678                .expect("index rows hold valid node indices")
679                .0
680                .as_str()
681        }))
682    }
683
684    /// Neighbors = successors (matches NetworkX `DiGraph.neighbors()` convention).
685    #[must_use]
686    pub fn neighbors(&self, node: &str) -> Option<Vec<&str>> {
687        self.successors(node)
688    }
689
690    #[must_use]
691    pub fn neighbors_iter(&self, node: &str) -> Option<impl Iterator<Item = &str> + '_> {
692        self.successors_iter(node)
693    }
694
695    #[must_use]
696    pub fn neighbor_count(&self, node: &str) -> usize {
697        // br-r37-c1-d58s8 DiGraph flip P2: index-backed.
698        self.nodes
699            .get_index_of(node)
700            .map_or(0, |i| self.succ_indices[i].len())
701    }
702
703    /// Out-degree: number of successors.
704    #[must_use]
705    pub fn out_degree(&self, node: &str) -> usize {
706        self.nodes
707            .get_index_of(node)
708            .map_or(0, |i| self.succ_indices[i].len())
709    }
710
711    /// In-degree: number of predecessors.
712    #[must_use]
713    pub fn in_degree(&self, node: &str) -> usize {
714        self.nodes
715            .get_index_of(node)
716            .map_or(0, |i| self.pred_indices[i].len())
717    }
718
719    /// Total degree: in_degree + out_degree.
720    #[must_use]
721    pub fn degree(&self, node: &str) -> usize {
722        // br-r37-c1-3gt4y: both rows share the same contiguous node index.
723        // Resolve the name once instead of paying the IndexMap lookup in both
724        // in_degree() and out_degree(). Missing nodes still report degree zero.
725        self.nodes.get_index_of(node).map_or(0, |idx| {
726            self.pred_indices[idx].len() + self.succ_indices[idx].len()
727        })
728    }
729
730    /// br-r37-c1-degidx: index-based degree accessors — O(1), zero
731    /// String hashing (the &str path costs get_index_of per call;
732    /// degree pays it twice). Used by the directed DegreeView iterator
733    /// which walks nodes in index order.
734    #[must_use]
735    pub fn out_degree_by_index(&self, idx: usize) -> usize {
736        self.succ_indices[idx].len()
737    }
738
739    #[must_use]
740    pub fn in_degree_by_index(&self, idx: usize) -> usize {
741        self.pred_indices[idx].len()
742    }
743
744    #[must_use]
745    pub fn degree_by_index(&self, idx: usize) -> usize {
746        self.succ_indices[idx].len() + self.pred_indices[idx].len()
747    }
748
749    /// Outgoing edges from `node` as (source, target) pairs.
750    #[must_use]
751    pub fn out_edges<'a>(&'a self, node: &'a str) -> Vec<(&'a str, &'a str)> {
752        // br-r37-c1-d58s8 DiGraph flip P2: index-backed.
753        self.nodes.get_index_of(node).map_or_else(Vec::new, |idx| {
754            self.succ_indices[idx]
755                .iter()
756                .map(|&i| {
757                    (
758                        node,
759                        self.nodes
760                            .get_index(i)
761                            .expect("index rows hold valid node indices")
762                            .0
763                            .as_str(),
764                    )
765                })
766                .collect()
767        })
768    }
769
770    /// Incoming edges to `node` as (source, target) pairs.
771    #[must_use]
772    pub fn in_edges<'a>(&'a self, node: &'a str) -> Vec<(&'a str, &'a str)> {
773        // br-r37-c1-d58s8 DiGraph flip P2: index-backed.
774        self.nodes.get_index_of(node).map_or_else(Vec::new, |idx| {
775            self.pred_indices[idx]
776                .iter()
777                .map(|&i| {
778                    (
779                        self.nodes
780                            .get_index(i)
781                            .expect("index rows hold valid node indices")
782                            .0
783                            .as_str(),
784                        node,
785                    )
786                })
787                .collect()
788        })
789    }
790
791    // -- Attribute queries -------------------------------------------------
792
793    #[must_use]
794    pub fn node_attrs(&self, node: &str) -> Option<&AttrMap> {
795        self.nodes.get(node)
796    }
797
798    /// Attributes of directed edge source→target.
799    #[must_use]
800    pub fn edge_attrs(&self, source: &str, target: &str) -> Option<&AttrMap> {
801        self.edges.get(&self.edge_pair_key(source, target)?)
802    }
803
804    /// br-r37-c1-d58s8: index-keyed attr access (oriented). Zero
805    /// node-map probes for callers already holding indices.
806    #[must_use]
807    #[inline]
808    pub fn edge_attrs_by_indices(&self, source_idx: usize, target_idx: usize) -> Option<&AttrMap> {
809        self.edges.get(&(source_idx, target_idx))
810    }
811
812    /// br-r37-c1-hasattrlazyfix: does ANY directed edge carry the attribute `key` in the
813    /// authoritative Rust storage (the inner `edges` AttrMaps), regardless of whether the
814    /// lazy `edge_py_attrs` Python mirror has been materialized? ~O(E) key checks.
815    #[must_use]
816    pub fn any_edge_has_attr(&self, key: &str) -> bool {
817        self.edges.values().any(|attrs| attrs.contains_key(key))
818    }
819
820    /// Directed twin of `Graph::all_edge_attr_values_scalar`: true if EVERY inner
821    /// edge attr value is a faithful scalar (Int/Float/Bool). No-alloc scan over
822    /// the edge store values — contrast `edges_ordered_borrowed`, which allocates
823    /// an O(E) Vec and resolves node names per edge. Used by the reverse gate.
824    #[must_use]
825    pub fn all_edge_attr_values_scalar(&self) -> bool {
826        self.edges.values().all(|attrs| {
827            attrs.values().all(|v| {
828                matches!(
829                    v,
830                    fnx_runtime::CgseValue::Int(_)
831                        | fnx_runtime::CgseValue::Float(_)
832                        | fnx_runtime::CgseValue::Bool(_)
833                )
834            })
835        })
836    }
837
838    /// br-r37-c1-hasanyattrlazyfix: any node / any edge carries a Python-visible attr per
839    /// the authoritative inner storage (not the lazy `*_py_attrs` mirrors).
840    #[must_use]
841    pub fn any_node_has_attrs(&self) -> bool {
842        self.nodes.values().any(|attrs| !attrs.is_empty())
843    }
844
845    #[must_use]
846    pub fn any_edge_has_attrs(&self) -> bool {
847        self.edges.values().any(|attrs| !attrs.is_empty())
848    }
849
850    /// br-r37-c1-prdir: iterate every directed edge as
851    /// `((source_idx, target_idx), &AttrMap)` straight from the index-keyed edge
852    /// store, in insertion order. Lets COO/CSR exporters read each edge's weight
853    /// with ONE attr lookup instead of the per-edge `edges.get(&(u,v))` hash that
854    /// the successors-then-`edge_attrs_by_indices` path pays.
855    pub fn edges_indexed(&self) -> impl Iterator<Item = ((usize, usize), &AttrMap)> + '_ {
856        self.edges.iter().map(|(&pair, attrs)| (pair, attrs))
857    }
858
859    #[must_use]
860    pub fn evidence_ledger(&self) -> &EvidenceLedger {
861        self.runtime_policy.decision_log()
862    }
863
864    pub fn drain_decision_records(&mut self) -> Vec<DecisionRecord> {
865        self.runtime_policy.drain_decision_records()
866    }
867
868    #[must_use]
869    pub fn runtime_policy(&self) -> &RuntimePolicy {
870        &self.runtime_policy
871    }
872
873    pub fn set_runtime_policy(&mut self, runtime_policy: RuntimePolicy) {
874        self.mode = runtime_policy.mode();
875        self.runtime_policy = runtime_policy;
876    }
877
878    /// Type identity: always `true` for DiGraph.
879    #[must_use]
880    pub fn is_directed(&self) -> bool {
881        true
882    }
883
884    /// Type identity: always `false` for DiGraph (not a multigraph).
885    #[must_use]
886    pub fn is_multigraph(&self) -> bool {
887        false
888    }
889
890    // -----------------------------------------------------------------------
891    // Mutations
892    // -----------------------------------------------------------------------
893
894    pub fn add_node(&mut self, node: impl AsRef<str> + Into<String>) -> bool {
895        self.add_node_with_attrs(node, AttrMap::new())
896    }
897
898    /// br-r37-c1-wa1b9: the storage half of [`Self::add_node_with_attrs`],
899    /// without its ledger record. See the undirected `Graph` sibling.
900    /// br-r37-c1-jc9e4: the key is materialised only on the VACANT path, the
901    /// directed mirror of the `Graph` sibling. This used to open with `let node
902    /// = node.into();`, so a `&str` caller allocated an owned `String` on every
903    /// call — including the calls that insert nothing, where it was built purely
904    /// to probe the map and then dropped by `entry`. The allocation census
905    /// measured the directed pair at the undirected pair's pre-lever numbers,
906    /// so the fix did not transfer on its own and had to be applied here too.
907    ///
908    /// Occupied-branch semantics are unchanged: `IndexMap::entry(..).or_default()`
909    /// on a present key returns the existing value without reordering, which is
910    /// exactly what `get_mut` returns.
911    fn add_node_with_attrs_unrecorded(
912        &mut self,
913        node: impl AsRef<str> + Into<String>,
914        attrs: AttrMap,
915    ) -> (bool, bool, usize) {
916        let existed = self.nodes.contains_key(node.as_ref());
917        let mut changed = !existed;
918        let attrs_count = {
919            let bucket = if existed {
920                self.nodes
921                    .get_mut(node.as_ref())
922                    .expect("contains_key reported this node present")
923            } else {
924                self.nodes.entry(node.into()).or_default()
925            };
926            if !attrs.is_empty()
927                && attrs
928                    .iter()
929                    .any(|(key, value)| bucket.get(key) != Some(value))
930            {
931                changed = true;
932            }
933            bucket.extend(attrs);
934            bucket.len()
935        };
936        if self.succ_indices.len() < self.nodes.len() {
937            self.succ_indices.push(Vec::new());
938            self.pred_indices.push(Vec::new());
939        }
940        if changed {
941            self.revision = self.revision.saturating_add(1);
942        }
943        (changed, existed, attrs_count)
944    }
945
946    pub fn add_node_with_attrs(
947        &mut self,
948        node: impl AsRef<str> + Into<String>,
949        attrs: AttrMap,
950    ) -> bool {
951        let (changed, existed, attrs_count) = self.add_node_with_attrs_unrecorded(node, attrs);
952        self.record_decision(
953            "add_node",
954            0.0,
955            false,
956            vec![
957                EvidenceTerm {
958                    signal: "node_preexisting".into(),
959                    observed_value: bool_evidence(existed),
960                    log_likelihood_ratio: -3.0,
961                },
962                EvidenceTerm {
963                    signal: "attrs_count".into(),
964                    observed_value: count_evidence(attrs_count),
965                    log_likelihood_ratio: -1.0,
966                },
967            ],
968        );
969        changed
970    }
971
972    pub fn apply_node_defaults(&mut self, defaults: &AttrMap) -> bool {
973        if defaults.is_empty() {
974            return false;
975        }
976        let mut inserted = 0usize;
977        for attrs in self.nodes.values_mut() {
978            for (key, value) in defaults {
979                if !attrs.contains_key(key) {
980                    attrs.insert(key.clone(), value.clone());
981                    inserted += 1;
982                }
983            }
984        }
985        if inserted > 0 {
986            self.revision = self.revision.saturating_add(1);
987            self.record_decision(
988                "apply_node_defaults",
989                0.0,
990                false,
991                vec![EvidenceTerm {
992                    signal: "defaults_applied".into(),
993                    observed_value: count_evidence(inserted),
994                    log_likelihood_ratio: 0.0,
995                }],
996            );
997            return true;
998        }
999        false
1000    }
1001
1002    pub fn add_edge(
1003        &mut self,
1004        source: impl AsRef<str>,
1005        target: impl AsRef<str>,
1006    ) -> Result<(), GraphError> {
1007        self.add_edge_with_attrs(source, target, AttrMap::new())
1008    }
1009
1010    /// br-r37-c1-jc9e4: the directed mirror of the `Graph` sibling — the
1011    /// endpoint keys are never owned here, and each is resolved with ONE map
1012    /// probe. This used to own both keys, clone each again for autocreation,
1013    /// and then hash each up to three times (`contains_key`, `get_index_of`
1014    /// for the edge key, `get_index_of` again for the successor/predecessor
1015    /// rows). The census measured the directed pair at the undirected pair's
1016    /// pre-lever numbers, so none of that transferred on its own.
1017    pub fn add_edge_with_attrs(
1018        &mut self,
1019        source: impl AsRef<str>,
1020        target: impl AsRef<str>,
1021        attrs: AttrMap,
1022    ) -> Result<(), GraphError> {
1023        let source = source.as_ref();
1024        let target = target.as_ref();
1025
1026        let unknown_feature = attrs
1027            .keys()
1028            .any(|key| key.starts_with("__fnx_incompatible"));
1029        let self_loop = source == target;
1030        let incompatibility_probability = if unknown_feature {
1031            1.0
1032        } else if self_loop {
1033            0.22
1034        } else {
1035            0.08
1036        };
1037
1038        // br-r37-c1-eo88t: the gate decision earns a ledger record only when it
1039        // REFUSES. A refusal returns from here, so the outcome record at the end
1040        // of this function never runs and the gate record is the whole audit
1041        // trail for the rejected mutation. On the allow path the outcome record
1042        // carries the same operation/mode/action/incompatibility_probability
1043        // plus strictly richer evidence, so recording the gate as well wrote two
1044        // near-identical records for every edge on the hottest mutation path.
1045        let action = self
1046            .runtime_policy
1047            .action_for(incompatibility_probability, unknown_feature);
1048
1049        if action == DecisionAction::FailClosed || action == DecisionAction::FullValidate {
1050            self.record_decision(
1051                "add_edge",
1052                incompatibility_probability,
1053                unknown_feature,
1054                vec![EvidenceTerm {
1055                    signal: "unknown_incompatible_feature".into(),
1056                    observed_value: bool_evidence(unknown_feature),
1057                    log_likelihood_ratio: 12.0,
1058                }],
1059            );
1060            return Err(GraphError::FailClosed {
1061                operation: "add_edge",
1062                reason: "incompatible edge metadata".to_owned(),
1063            });
1064        }
1065
1066        // Auto-create nodes. br-r37-c1-wa1b9: no record of their own — the
1067        // outcome record below reports them via source/target_autocreated. See
1068        // the undirected `Graph` sibling for the ruling.
1069        // Resolve each endpoint with ONE probe: `get_index_of` answers both
1070        // "does it exist" and "what is its index", so the old `contains_key`
1071        // that preceded it was a second hash of the same key. A self-loop does
1072        // not probe the target at all — `self_loop` makes the indices equal by
1073        // construction. Order is untouched: the source is still resolved and
1074        // autocreated before the target, and `IndexMap` appends, so
1075        // autocreating the target cannot move the source's index.
1076        let mut source_autocreated = false;
1077        let source_idx = match self.nodes.get_index_of(source) {
1078            Some(index) => index,
1079            None => {
1080                let _ = self.add_node_with_attrs_unrecorded(source, AttrMap::new());
1081                source_autocreated = true;
1082                self.nodes.get_index_of(source).expect("autocreated above")
1083            }
1084        };
1085        let mut target_autocreated = false;
1086        let target_idx = if self_loop {
1087            target_autocreated = source_autocreated;
1088            source_idx
1089        } else {
1090            match self.nodes.get_index_of(target) {
1091                Some(index) => index,
1092                None => {
1093                    let _ = self.add_node_with_attrs_unrecorded(target, AttrMap::new());
1094                    target_autocreated = true;
1095                    self.nodes.get_index_of(target).expect("autocreated above")
1096                }
1097            }
1098        };
1099
1100        let edge_key = (source_idx, target_idx);
1101        let new_edge = !self.edges.contains_key(&edge_key);
1102        let mut changed = new_edge;
1103        let edge_attr_count = {
1104            let edge_attrs = self.edges.entry(edge_key).or_default();
1105            if edge_attrs.is_empty() {
1106                // br-r37-c1-jc9e4: a fresh bucket TAKES the caller's map whole.
1107                // Directed mirror of the `Graph` sibling — see it for why, and
1108                // for the byte-identity argument.
1109                if !attrs.is_empty() {
1110                    changed = true;
1111                }
1112                *edge_attrs = attrs;
1113            } else {
1114                if !attrs.is_empty()
1115                    && attrs
1116                        .iter()
1117                        .any(|(key, value)| edge_attrs.get(key) != Some(value))
1118                {
1119                    changed = true;
1120                }
1121                edge_attrs.extend(attrs);
1122            }
1123            edge_attrs.len()
1124        };
1125        // br-r37-c1-d58s8 DiGraph flip P1: eager index rows (dup-guarded
1126        // by edge newness; the IndexSet inserts above are idempotent).
1127        if new_edge {
1128            // br-r37-c1-jc9e4: reuse the indices resolved above rather than
1129            // hashing both keys a third time. Nothing between the resolution
1130            // and here touches `self.nodes` — the intervening work is the
1131            // `self.edges` entry and the attr extend — so these are the same
1132            // two indices the lookups produced.
1133            self.succ_indices[source_idx].push(target_idx);
1134            self.pred_indices[target_idx].push(source_idx);
1135        }
1136
1137        if changed {
1138            self.revision = self.revision.saturating_add(1);
1139        }
1140
1141        self.record_decision(
1142            "add_edge",
1143            incompatibility_probability,
1144            unknown_feature,
1145            vec![
1146                EvidenceTerm {
1147                    signal: "self_loop".into(),
1148                    observed_value: bool_evidence(self_loop),
1149                    log_likelihood_ratio: -0.5,
1150                },
1151                EvidenceTerm {
1152                    signal: "edge_attr_count".into(),
1153                    observed_value: count_evidence(edge_attr_count),
1154                    log_likelihood_ratio: -2.0,
1155                },
1156                EvidenceTerm {
1157                    signal: "source_autocreated".into(),
1158                    observed_value: bool_evidence(source_autocreated),
1159                    log_likelihood_ratio: -1.25,
1160                },
1161                EvidenceTerm {
1162                    signal: "target_autocreated".into(),
1163                    observed_value: bool_evidence(target_autocreated),
1164                    log_likelihood_ratio: -1.25,
1165                },
1166            ],
1167        );
1168
1169        Ok(())
1170    }
1171
1172    /// Bulk-add a sequence of attribute-free directed edges. Bypasses
1173    /// the per-edge `runtime_policy.record_decision` call that
1174    /// [`add_edge_with_attrs`] makes. Instead, emit one summary record
1175    /// for the whole batch.
1176    ///
1177    /// Intended for fnx-internal callers that build a fresh graph
1178    /// from a known-good edge list where per-edge compatibility
1179    /// accounting would add constant-factor overhead without adding
1180    /// useful policy evidence. Nodes referenced by edges are
1181    /// auto-created if absent, matching `add_edge` semantics.
1182    #[must_use]
1183    pub fn extend_edges_unrecorded<I, S, T>(&mut self, edges: I) -> usize
1184    where
1185        I: IntoIterator<Item = (S, T)>,
1186        S: Into<String>,
1187        T: Into<String>,
1188    {
1189        // br-r37-c1-digbatch: compute each endpoint's index ONCE via match-insert
1190        // (mirroring Graph::extend_edges_unrecorded) instead of contains_key + TWO
1191        // get_index_of per endpoint. The old form did ~6 IndexMap hashes/edge, so
1192        // DiGraph.add_edges_from ran 4x slower than Graph's (taxing every DiGraph-
1193        // returning constructor). Reserve the collections up front as well.
1194        let iterator = edges.into_iter();
1195        let (lower_bound, _) = iterator.size_hint();
1196        self.nodes.reserve(lower_bound);
1197        self.succ_indices.reserve(lower_bound);
1198        self.pred_indices.reserve(lower_bound);
1199        self.edges.reserve(lower_bound);
1200
1201        let mut inserted = 0usize;
1202        for (source, target) in iterator {
1203            let source = source.into();
1204            let target = target.into();
1205            let s_idx = match self.nodes.get_index_of(&source) {
1206                Some(index) => index,
1207                None => {
1208                    let index = self.nodes.len();
1209                    self.nodes.insert(source.clone(), AttrMap::new());
1210                    self.succ_indices.push(Vec::new());
1211                    self.pred_indices.push(Vec::new());
1212                    index
1213                }
1214            };
1215            let t_idx = if source == target {
1216                s_idx
1217            } else {
1218                match self.nodes.get_index_of(&target) {
1219                    Some(index) => index,
1220                    None => {
1221                        let index = self.nodes.len();
1222                        self.nodes.insert(target.clone(), AttrMap::new());
1223                        self.succ_indices.push(Vec::new());
1224                        self.pred_indices.push(Vec::new());
1225                        index
1226                    }
1227                }
1228            };
1229            let edge_key = (s_idx, t_idx);
1230            if self.edges.contains_key(&edge_key) {
1231                continue;
1232            }
1233            self.edges.insert(edge_key, AttrMap::new());
1234            self.succ_indices[s_idx].push(t_idx);
1235            self.pred_indices[t_idx].push(s_idx);
1236            inserted += 1;
1237        }
1238
1239        if inserted > 0 {
1240            self.revision = self
1241                .revision
1242                .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
1243            self.record_decision(
1244                "extend_edges_unrecorded",
1245                0.0,
1246                false,
1247                vec![EvidenceTerm {
1248                    signal: "batch_edge_count".into(),
1249                    observed_value: count_evidence(inserted),
1250                    log_likelihood_ratio: -1.0,
1251                }],
1252            );
1253        }
1254
1255        inserted
1256    }
1257
1258    /// br-r37-c1-tcidx: bulk-add directed edges by EXISTING node index — the directed sibling of
1259    /// `Graph::extend_existing_index_edges_unrecorded`. All endpoints MUST already exist (callers
1260    /// gate on all nodes being pre-added). A new edge takes an empty AttrMap; a duplicate
1261    /// `(source_idx, target_idx)` pair is skipped. Emits the IDENTICAL succ/pred adjacency,
1262    /// revision bump, and batch ledger summary as `extend_edges_unrecorded` given the same
1263    /// resolved index sequence — but with ZERO String hashing (endpoints arrive as integer
1264    /// indices). Unblocks the O(|V|²)-closure `transitive_closure` from paying a per-edge
1265    /// `get_index_of` on both endpoints.
1266    pub fn extend_existing_index_edges_unrecorded<I>(&mut self, edges: I) -> usize
1267    where
1268        I: IntoIterator<Item = (usize, usize)>,
1269    {
1270        let iterator = edges.into_iter();
1271        self.edges.reserve(iterator.size_hint().0);
1272
1273        let mut inserted = 0usize;
1274        for (s_idx, t_idx) in iterator {
1275            debug_assert!(s_idx < self.nodes.len());
1276            debug_assert!(t_idx < self.nodes.len());
1277            let edge_key = (s_idx, t_idx);
1278            if self.edges.contains_key(&edge_key) {
1279                continue;
1280            }
1281            self.edges.insert(edge_key, AttrMap::new());
1282            self.succ_indices[s_idx].push(t_idx);
1283            self.pred_indices[t_idx].push(s_idx);
1284            inserted += 1;
1285        }
1286
1287        if inserted > 0 {
1288            self.revision = self
1289                .revision
1290                .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
1291            self.record_decision(
1292                "extend_edges_unrecorded",
1293                0.0,
1294                false,
1295                vec![EvidenceTerm {
1296                    signal: "batch_edge_count".into(),
1297                    observed_value: count_evidence(inserted),
1298                    log_likelihood_ratio: -1.0,
1299                }],
1300            );
1301        }
1302
1303        inserted
1304    }
1305
1306    /// br-r37-c1-digbatch: bulk-add plain nodes (no attrs), one summary ledger
1307    /// record — the directed sibling of `Graph::extend_nodes_unrecorded`, backing
1308    /// PyDiGraph's fast integer-node path (`add_nodes_from(range)` / int list).
1309    /// First insertion wins order; existing nodes are skipped.
1310    pub fn extend_nodes_unrecorded<I, N>(&mut self, nodes: I) -> usize
1311    where
1312        I: IntoIterator<Item = N>,
1313        N: Into<String>,
1314    {
1315        let iterator = nodes.into_iter();
1316        let (lower_bound, _) = iterator.size_hint();
1317        self.nodes.reserve(lower_bound);
1318        self.succ_indices.reserve(lower_bound);
1319        self.pred_indices.reserve(lower_bound);
1320
1321        let mut inserted = 0usize;
1322        for node in iterator {
1323            let node = node.into();
1324            if self.nodes.contains_key(&node) {
1325                continue;
1326            }
1327            self.nodes.insert(node.clone(), AttrMap::new());
1328            self.succ_indices.push(Vec::new());
1329            self.pred_indices.push(Vec::new());
1330            inserted += 1;
1331        }
1332        if inserted > 0 {
1333            self.revision = self
1334                .revision
1335                .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
1336            self.record_decision(
1337                "extend_nodes_unrecorded",
1338                0.0,
1339                false,
1340                vec![EvidenceTerm {
1341                    signal: "batch_node_count".into(),
1342                    observed_value: count_evidence(inserted),
1343                    log_likelihood_ratio: -1.0,
1344                }],
1345            );
1346        }
1347        inserted
1348    }
1349
1350    /// br-r37-c1-dgctor: bulk-add nodes WITH attrs, one summary ledger
1351    /// record — the directed sibling of `Graph::extend_nodes_unrecorded`
1352    /// extended with per-node AttrMaps. First insertion wins the order;
1353    /// re-inserting an existing node merges attrs (matching
1354    /// `add_node_with_attrs`).
1355    #[must_use]
1356    pub fn extend_nodes_with_attrs_unrecorded<I>(&mut self, nodes: I) -> usize
1357    where
1358        I: IntoIterator<Item = (String, AttrMap)>,
1359    {
1360        let mut inserted = 0usize;
1361        for (node, attrs) in nodes {
1362            if let Some(existing) = self.nodes.get_mut(&node) {
1363                existing.extend(attrs);
1364                continue;
1365            }
1366            self.nodes.insert(node.clone(), attrs);
1367            self.succ_indices.push(Vec::new());
1368            self.pred_indices.push(Vec::new());
1369            inserted += 1;
1370        }
1371        if inserted > 0 {
1372            self.revision = self
1373                .revision
1374                .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
1375            self.record_decision(
1376                "extend_nodes_unrecorded",
1377                0.0,
1378                false,
1379                vec![EvidenceTerm {
1380                    signal: "batch_node_count".into(),
1381                    observed_value: count_evidence(inserted),
1382                    log_likelihood_ratio: -1.0,
1383                }],
1384            );
1385        }
1386        inserted
1387    }
1388
1389    /// br-r37-c1-dgctor: bulk-add ATTRIBUTED directed edges without
1390    /// per-edge ledger records — the directed sibling of
1391    /// `Graph::extend_edges_with_attrs_unrecorded`. Insert-or-MERGE
1392    /// (duplicate (source, target) extends the existing AttrMap, matching
1393    /// `add_edge_with_attrs`); nodes auto-created in first-appearance
1394    /// order. Callers must pre-screen "__fnx_incompatible" attr keys.
1395    #[must_use]
1396    pub fn extend_edges_with_attrs_unrecorded<I>(&mut self, edges: I) -> usize
1397    where
1398        I: IntoIterator<Item = (String, String, AttrMap)>,
1399    {
1400        let mut inserted = 0usize;
1401        let mut merged_changed = false;
1402        for (source, target, attrs) in edges {
1403            if !self.nodes.contains_key(&source) {
1404                self.nodes.insert(source.clone(), AttrMap::new());
1405                self.succ_indices.push(Vec::new());
1406                self.pred_indices.push(Vec::new());
1407            }
1408            if source != target && !self.nodes.contains_key(&target) {
1409                self.nodes.insert(target.clone(), AttrMap::new());
1410                self.succ_indices.push(Vec::new());
1411                self.pred_indices.push(Vec::new());
1412            }
1413            let edge_key = (
1414                self.nodes.get_index_of(&source).expect("created above"),
1415                self.nodes.get_index_of(&target).expect("created above"),
1416            );
1417            if let Some(existing) = self.edges.get_mut(&edge_key) {
1418                // An empty attr map merges to a no-op; returning early avoids
1419                // building and dropping a BTreeMap iterator per duplicate edge,
1420                // which dominates unweighted bulk loads.
1421                if attrs.is_empty() {
1422                    continue;
1423                }
1424                if attrs
1425                    .iter()
1426                    .any(|(key, value)| existing.get(key) != Some(value))
1427                {
1428                    merged_changed = true;
1429                }
1430                existing.extend(attrs);
1431                continue;
1432            }
1433            self.edges.insert(edge_key, attrs);
1434            let s_idx = self
1435                .nodes
1436                .get_index_of(&source)
1437                .expect("source node exists");
1438            let t_idx = self
1439                .nodes
1440                .get_index_of(&target)
1441                .expect("target node exists");
1442            self.succ_indices[s_idx].push(t_idx);
1443            self.pred_indices[t_idx].push(s_idx);
1444            inserted += 1;
1445        }
1446        if inserted > 0 || merged_changed {
1447            self.revision = self
1448                .revision
1449                .saturating_add(u64::try_from(inserted.max(1)).unwrap_or(u64::MAX));
1450            self.record_decision(
1451                "extend_edges_unrecorded",
1452                0.0,
1453                false,
1454                vec![EvidenceTerm {
1455                    signal: "batch_edge_count".into(),
1456                    observed_value: count_evidence(inserted),
1457                    log_likelihood_ratio: -1.0,
1458                }],
1459            );
1460        }
1461        inserted
1462    }
1463
1464    /// Bulk-add ATTRIBUTED directed edges when the caller has already
1465    /// identified newly-created nodes. This keeps edge-map insertion in global
1466    /// input order, then stages successor and predecessor row commits by row so
1467    /// adjacency maps are probed once per touched row rather than once per edge.
1468    #[must_use]
1469    pub fn extend_prepared_edges_with_attrs_row_staged_unrecorded<I, N>(
1470        &mut self,
1471        new_nodes: N,
1472        edges: I,
1473    ) -> usize
1474    where
1475        I: IntoIterator<Item = (String, String, AttrMap)>,
1476        N: IntoIterator<Item = String>,
1477    {
1478        for node in new_nodes {
1479            if self.nodes.contains_key(&node) {
1480                continue;
1481            }
1482            self.nodes.insert(node.clone(), AttrMap::new());
1483            self.succ_indices.push(Vec::new());
1484            self.pred_indices.push(Vec::new());
1485        }
1486
1487        let mut inserted = 0usize;
1488        let mut merged_changed = false;
1489        let mut successor_rows: IndexMap<String, Vec<String>> = IndexMap::new();
1490        let mut predecessor_rows: IndexMap<String, Vec<String>> = IndexMap::new();
1491
1492        for (source, target, attrs) in edges {
1493            let edge_key = (
1494                self.nodes.get_index_of(&source).expect("created above"),
1495                self.nodes.get_index_of(&target).expect("created above"),
1496            );
1497            if let Some(existing) = self.edges.get_mut(&edge_key) {
1498                // An empty attr map merges to a no-op; returning early avoids
1499                // building and dropping a BTreeMap iterator per duplicate edge,
1500                // which dominates unweighted bulk loads.
1501                if attrs.is_empty() {
1502                    continue;
1503                }
1504                if attrs
1505                    .iter()
1506                    .any(|(key, value)| existing.get(key) != Some(value))
1507                {
1508                    merged_changed = true;
1509                }
1510                existing.extend(attrs);
1511                continue;
1512            }
1513
1514            successor_rows
1515                .entry(source.clone())
1516                .or_default()
1517                .push(target.clone());
1518            predecessor_rows
1519                .entry(target.clone())
1520                .or_default()
1521                .push(source.clone());
1522            let s_idx = self
1523                .nodes
1524                .get_index_of(&source)
1525                .expect("source node exists");
1526            let t_idx = self
1527                .nodes
1528                .get_index_of(&target)
1529                .expect("target node exists");
1530            self.succ_indices[s_idx].push(t_idx);
1531            self.pred_indices[t_idx].push(s_idx);
1532            self.edges.insert(edge_key, attrs);
1533            inserted += 1;
1534        }
1535
1536        if inserted > 0 || merged_changed {
1537            self.revision = self
1538                .revision
1539                .saturating_add(u64::try_from(inserted.max(1)).unwrap_or(u64::MAX));
1540            self.record_decision(
1541                "extend_edges_unrecorded",
1542                0.0,
1543                false,
1544                vec![EvidenceTerm {
1545                    signal: "batch_edge_count".into(),
1546                    observed_value: count_evidence(inserted),
1547                    log_likelihood_ratio: -1.0,
1548                }],
1549            );
1550        }
1551
1552        inserted
1553    }
1554
1555    /// Bulk-add attributed directed edges into a fresh graph when the caller has
1556    /// already assigned NetworkX-compatible node indices in first-seen order.
1557    ///
1558    /// This bypasses the string-key lookup path in
1559    /// [`extend_prepared_edges_with_attrs_row_staged_unrecorded`]: node labels
1560    /// are installed once, then edges are committed directly by `(source_idx,
1561    /// target_idx)`. Duplicate directed edges merge attributes without appending
1562    /// another successor/predecessor entry, matching `DiGraph.add_edges_from`.
1563    #[must_use]
1564    /// br-r37-c1-dodattrbatch: bulk-add ATTRIBUTED directed edges by EXISTING
1565    /// node index. All endpoints MUST already exist (callers gate on int-labeled
1566    /// nodes). A new edge takes the AttrMap; a duplicate (source, target) MERGES
1567    /// last-wins like add_edge_with_attrs. Sibling of the fresh-index attr extend
1568    /// minus node creation; integer index rows only (no String hashing).
1569    pub fn extend_existing_index_edges_with_attrs_unrecorded<I>(&mut self, edges: I) -> usize
1570    where
1571        I: IntoIterator<Item = (usize, usize, AttrMap)>,
1572    {
1573        let node_count = self.nodes.len();
1574        let edges = edges.into_iter();
1575        self.edges.reserve(edges.size_hint().0);
1576        let mut inserted = 0usize;
1577        let mut merged_changed = false;
1578        for (source_idx, target_idx, attrs) in edges {
1579            debug_assert!(source_idx < node_count && target_idx < node_count);
1580            let edge_key = (source_idx, target_idx);
1581            if let Some(existing) = self.edges.get_mut(&edge_key) {
1582                // An empty attr map merges to a no-op; returning early avoids
1583                // building and dropping a BTreeMap iterator per duplicate edge,
1584                // which dominates unweighted bulk loads.
1585                if attrs.is_empty() {
1586                    continue;
1587                }
1588                if attrs
1589                    .iter()
1590                    .any(|(key, value)| existing.get(key) != Some(value))
1591                {
1592                    merged_changed = true;
1593                }
1594                existing.extend(attrs);
1595                continue;
1596            }
1597            self.edges.insert(edge_key, attrs);
1598            self.succ_indices[source_idx].push(target_idx);
1599            self.pred_indices[target_idx].push(source_idx);
1600            inserted += 1;
1601        }
1602        if inserted > 0 || merged_changed {
1603            self.revision = self
1604                .revision
1605                .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
1606        }
1607        inserted
1608    }
1609
1610    pub fn extend_fresh_index_edges_with_attrs_unrecorded<I, N>(
1611        &mut self,
1612        nodes: N,
1613        edges: I,
1614    ) -> usize
1615    where
1616        I: IntoIterator<Item = (usize, usize, AttrMap)>,
1617        N: IntoIterator<Item = String>,
1618    {
1619        if !self.nodes.is_empty()
1620            || !self.succ_indices.is_empty()
1621            || !self.pred_indices.is_empty()
1622            || !self.edges.is_empty()
1623        {
1624            return 0;
1625        }
1626
1627        let node_labels: Vec<String> = nodes.into_iter().collect();
1628        self.nodes.reserve(node_labels.len());
1629        for node in node_labels {
1630            self.nodes.insert(node, AttrMap::new());
1631        }
1632        let node_count = self.nodes.len();
1633        self.succ_indices = vec![Vec::new(); node_count];
1634        self.pred_indices = vec![Vec::new(); node_count];
1635
1636        let mut inserted = 0usize;
1637        let mut merged_changed = false;
1638        let edges = edges.into_iter();
1639        self.edges.reserve(edges.size_hint().0);
1640        for (source_idx, target_idx, attrs) in edges {
1641            if source_idx >= node_count || target_idx >= node_count {
1642                continue;
1643            }
1644            let edge_key = (source_idx, target_idx);
1645            if let Some(existing) = self.edges.get_mut(&edge_key) {
1646                // An empty attr map merges to a no-op; returning early avoids
1647                // building and dropping a BTreeMap iterator per duplicate edge,
1648                // which dominates unweighted bulk loads.
1649                if attrs.is_empty() {
1650                    continue;
1651                }
1652                if attrs
1653                    .iter()
1654                    .any(|(key, value)| existing.get(key) != Some(value))
1655                {
1656                    merged_changed = true;
1657                }
1658                existing.extend(attrs);
1659                continue;
1660            }
1661            self.edges.insert(edge_key, attrs);
1662            self.succ_indices[source_idx].push(target_idx);
1663            self.pred_indices[target_idx].push(source_idx);
1664            inserted += 1;
1665        }
1666
1667        if node_count > 0 || inserted > 0 || merged_changed {
1668            self.revision = self.revision.saturating_add(
1669                u64::try_from(node_count.saturating_add(inserted)).unwrap_or(u64::MAX),
1670            );
1671            self.record_decision(
1672                "extend_fresh_index_edges_unrecorded",
1673                0.0,
1674                false,
1675                vec![
1676                    EvidenceTerm {
1677                        signal: "batch_node_count".into(),
1678                        observed_value: count_evidence(node_count),
1679                        log_likelihood_ratio: -1.0,
1680                    },
1681                    EvidenceTerm {
1682                        signal: "batch_edge_count".into(),
1683                        observed_value: count_evidence(inserted),
1684                        log_likelihood_ratio: -1.0,
1685                    },
1686                ],
1687            );
1688        }
1689
1690        inserted
1691    }
1692
1693    pub fn apply_edge_defaults(&mut self, defaults: &AttrMap) -> bool {
1694        if defaults.is_empty() {
1695            return false;
1696        }
1697        let mut inserted = 0usize;
1698        for attrs in self.edges.values_mut() {
1699            for (key, value) in defaults {
1700                if !attrs.contains_key(key) {
1701                    attrs.insert(key.clone(), value.clone());
1702                    inserted += 1;
1703                }
1704            }
1705        }
1706        if inserted > 0 {
1707            self.revision = self.revision.saturating_add(1);
1708            self.record_decision(
1709                "apply_edge_defaults",
1710                0.0,
1711                false,
1712                vec![EvidenceTerm {
1713                    signal: "defaults_applied".into(),
1714                    observed_value: count_evidence(inserted),
1715                    log_likelihood_ratio: 0.0,
1716                }],
1717            );
1718            return true;
1719        }
1720        false
1721    }
1722
1723    /// br-r37-c1-sjf4t: overwrite the attribute map for an existing edge.
1724    pub fn replace_edge_attrs(&mut self, source: &str, target: &str, attrs: AttrMap) -> bool {
1725        let Some(edge_key) = self.edge_pair_key(source, target) else {
1726            return false;
1727        };
1728        if let Some(slot) = self.edges.get_mut(&edge_key) {
1729            if *slot != attrs {
1730                *slot = attrs;
1731                self.revision = self.revision.saturating_add(1);
1732            }
1733            true
1734        } else {
1735            false
1736        }
1737    }
1738
1739    /// br-r37-c1-sjf4t: overwrite the attribute map for an existing node.
1740    pub fn replace_node_attrs(&mut self, node: &str, attrs: AttrMap) -> bool {
1741        if let Some(slot) = self.nodes.get_mut(node) {
1742            if *slot != attrs {
1743                *slot = attrs;
1744                self.revision = self.revision.saturating_add(1);
1745            }
1746            true
1747        } else {
1748            false
1749        }
1750    }
1751
1752    /// Remove directed edge source→target. Returns `true` if it existed.
1753    pub fn remove_edge(&mut self, source: &str, target: &str) -> bool {
1754        let Some(pair) = self.edge_pair_key(source, target) else {
1755            return false;
1756        };
1757        // br-r37-c1-vbwpl (cc, ported from PyGraph::remove_edge): swap_remove
1758        // (O(1)) instead of shift_remove (O(|E|)) — turns remove-heavy work
1759        // (remove_edges_from, double_edge_swap) from O(k*|E|) into O(k). The
1760        // `edges` map's STORAGE order is never observed externally: `edges_ordered`
1761        // /`edges_ordered_borrowed` walk `succ_indices` and look edges up by key;
1762        // COO/CSR exporters (`edges_indexed`) are triple-order-irrelevant (scipy
1763        // CSR canonicalises); `weighted_size_int`/attr scans are order-insensitive
1764        // sums/`any`. `remove_node` already swap_removes this same map (see below),
1765        // so the "unobserved" invariant is already committed. DiGraph has no
1766        // edge_index_endpoints parallel vector (that is PyGraph-only), so no
1767        // parallel structure needs the matching swap.
1768        let removed = self.edges.swap_remove(&pair).is_some();
1769        if removed {
1770            if let (Some(s_idx), Some(t_idx)) = (
1771                self.nodes.get_index_of(source),
1772                self.nodes.get_index_of(target),
1773            ) {
1774                self.succ_indices[s_idx].retain(|&i| i != t_idx);
1775                self.pred_indices[t_idx].retain(|&i| i != s_idx);
1776            }
1777            self.revision = self.revision.saturating_add(1);
1778        }
1779        removed
1780    }
1781
1782    /// Remove node and all incident edges (both incoming and outgoing).
1783    pub fn remove_node(&mut self, node: &str) -> bool {
1784        if !self.nodes.contains_key(node) {
1785            return false;
1786        }
1787
1788        // br-r37-c1-rmnode-di2: drop each incident edge with O(1) `swap_remove`
1789        // (the `edges` map order is never observed externally — `edges_ordered`,
1790        // used by every public consumer, walks node->successor order, and the one
1791        // internal map-order use, `to_undirected`, was canonicalised). The
1792        // incident edges are known exactly from successors/predecessors, so the
1793        // whole removal is O(degree) instead of the O(|E|) `retain` scan (let
1794        // alone the original O(degree*|E|) per-edge shift_remove). Matches nx's
1795        // O(degree) remove_node.
1796        // br-r37-c1-d58s8 P3: incident-edge walk from the index rows;
1797        // the per-neighbor String-row maintenance vanished with the rows
1798        // (the index repair below handles the integer side).
1799        let node_idx = self
1800            .nodes
1801            .get_index_of(node)
1802            .expect("node existence checked above");
1803
1804        // br-r37-c1-qxtlj: undirected sibling's fast path, same reasoning. The
1805        // incident-edge removal above is already O(degree) here, so what is left
1806        // to skip is the succ/pred index repair, which walks EVERY row to drop
1807        // references to `idx` and decrement the indices that shifted down. When
1808        // `idx` is the last position nothing shifts, and when both rows are
1809        // empty no row references it, so that repair is a no-op over its whole
1810        // input. Measured before this: one isolated node off a 12800-node
1811        // DiGraph cost 178.22us against networkx's 0.71us (0.0040x), the same
1812        // whether the node sat first or last.
1813        if node_idx + 1 == self.nodes.len()
1814            && self.succ_indices[node_idx].is_empty()
1815            && self.pred_indices[node_idx].is_empty()
1816        {
1817            self.nodes.shift_remove(node);
1818            self.succ_indices.remove(node_idx);
1819            self.pred_indices.remove(node_idx);
1820            self.revision = self.revision.saturating_add(1);
1821            return true;
1822        }
1823
1824        let targets: Vec<usize> = self.succ_indices[node_idx].clone();
1825        for t in targets {
1826            self.edges.swap_remove(&(node_idx, t));
1827        }
1828        let sources: Vec<usize> = self.pred_indices[node_idx].clone();
1829        for s in sources {
1830            self.edges.swap_remove(&(s, node_idx));
1831        }
1832
1833        // br-r37-c1-d58s8 DiGraph flip P1: I5 repair on both index
1834        // families — drop the removed index's rows, purge refs, decrement
1835        // indices that shift down.
1836        let idx = self
1837            .nodes
1838            .get_index_of(node)
1839            .expect("node existence checked above");
1840        self.nodes.shift_remove(node);
1841        self.succ_indices.remove(idx);
1842        self.pred_indices.remove(idx);
1843        // br-cc-rmnode-fuse: one pass per row (was retain THEN decrement) —
1844        // drop refs to the removed index and decrement shifted indices in a
1845        // single traversal. Order preserved.
1846        for rows in [&mut self.succ_indices, &mut self.pred_indices] {
1847            for row in rows.iter_mut() {
1848                let mut w = 0usize;
1849                for read in 0..row.len() {
1850                    let e = row[read];
1851                    if e == idx {
1852                        continue;
1853                    }
1854                    row[w] = if e > idx { e - 1 } else { e };
1855                    w += 1;
1856                }
1857                row.truncate(w);
1858            }
1859        }
1860        // br-r37-c1-d58s8 edges-map flip: REKEY surviving edges past the
1861        // removed index (integer rehash, order preserved).
1862        self.edges = std::mem::take(&mut self.edges)
1863            .into_iter()
1864            .map(|((s, t), attrs)| {
1865                (
1866                    (
1867                        if s > idx { s - 1 } else { s },
1868                        if t > idx { t - 1 } else { t },
1869                    ),
1870                    attrs,
1871                )
1872            })
1873            .collect();
1874        self.revision = self.revision.saturating_add(1);
1875        true
1876    }
1877
1878    pub fn remove_nodes_from<'a, I>(&mut self, nodes: I) -> (usize, usize)
1879    where
1880        I: IntoIterator<Item = &'a str>,
1881    {
1882        let remove_set: std::collections::HashSet<&str> = nodes
1883            .into_iter()
1884            .filter(|node| self.nodes.contains_key(*node))
1885            .collect();
1886        if remove_set.is_empty() {
1887            return (0, 0);
1888        }
1889
1890        let old_node_count = self.nodes.len();
1891        let old_edge_count = self.edges.len();
1892
1893        // br-r37-c1-d58s8 DiGraph flip P1: removed-mask + old->new remap
1894        // BEFORE the map shifts; index rows filtered + remapped from the
1895        // OLD rows (the slice-1 recipe).
1896        let removed_mask: Vec<bool> = self
1897            .nodes
1898            .keys()
1899            .map(|k| remove_set.contains(k.as_str()))
1900            .collect();
1901        let mut remap: Vec<usize> = Vec::with_capacity(old_node_count);
1902        let mut next = 0usize;
1903        for &gone in &removed_mask {
1904            remap.push(next);
1905            if !gone {
1906                next += 1;
1907            }
1908        }
1909        for rows in [&mut self.succ_indices, &mut self.pred_indices] {
1910            let old_rows = std::mem::take(rows);
1911            *rows = old_rows
1912                .into_iter()
1913                .enumerate()
1914                .filter(|(old_idx, _)| !removed_mask[*old_idx])
1915                .map(|(_, row)| {
1916                    row.into_iter()
1917                        .filter(|&nbr| !removed_mask[nbr])
1918                        .map(|nbr| remap[nbr])
1919                        .collect()
1920                })
1921                .collect();
1922        }
1923        self.nodes
1924            .retain(|node, _| !remove_set.contains(node.as_str()));
1925        self.edges
1926            .retain(|&(s, t), _| !removed_mask[s] && !removed_mask[t]);
1927        // br-r37-c1-d58s8 edges-map flip: rekey survivors through the remap.
1928        self.edges = std::mem::take(&mut self.edges)
1929            .into_iter()
1930            .map(|((s, t), attrs)| ((remap[s], remap[t]), attrs))
1931            .collect();
1932
1933        let removed_nodes = old_node_count - self.nodes.len();
1934        let removed_edges = old_edge_count - self.edges.len();
1935        self.revision = self
1936            .revision
1937            .saturating_add(u64::try_from(removed_nodes).unwrap_or(u64::MAX));
1938        (removed_nodes, removed_edges)
1939    }
1940
1941    // -----------------------------------------------------------------------
1942    // Snapshot / ordered iteration
1943    // -----------------------------------------------------------------------
1944
1945    /// Edges in deterministic order: iterate nodes in insertion order, then
1946    /// each node's successors in insertion order.
1947    #[must_use]
1948    pub fn edges_ordered(&self) -> Vec<EdgeSnapshot> {
1949        let mut ordered = Vec::with_capacity(self.edges.len());
1950
1951        for (u, row) in self.succ_indices.iter().enumerate() {
1952            let node = self.nodes.get_index(u).expect("valid index").0;
1953            for &t in row {
1954                let target = self.nodes.get_index(t).expect("valid index").0;
1955                if let Some(attrs) = self.edges.get(&(u, t)) {
1956                    ordered.push(EdgeSnapshot {
1957                        left: node.clone(),
1958                        right: target.clone(),
1959                        attrs: attrs.clone(),
1960                    });
1961                }
1962            }
1963        }
1964
1965        ordered
1966    }
1967
1968    #[must_use]
1969    pub fn edges_ordered_borrowed(&self) -> Vec<(&str, &str, &AttrMap)> {
1970        let mut ordered = Vec::with_capacity(self.edges.len());
1971
1972        for (u, row) in self.succ_indices.iter().enumerate() {
1973            let node = self.nodes.get_index(u).expect("valid index").0;
1974            for &t in row {
1975                let target = self.nodes.get_index(t).expect("valid index").0;
1976                if let Some(attrs) = self.edges.get(&(u, t)) {
1977                    ordered.push((node.as_str(), target.as_str(), attrs));
1978                }
1979            }
1980        }
1981
1982        ordered
1983    }
1984
1985    /// br-r37-c1-wsize (cc): integer `size(weight)` from the store — directed
1986    /// twin of `Graph::weighted_size_int`. A directed edge `(u,v,w)` adds `w` to
1987    /// `out_degree(u)` and `in_degree(v)`, so `sum(degree)/2` halves back to each
1988    /// edge's weight once; a self-loop adds `w` to both in and out of one node →
1989    /// `2w` → halves to `w`, again one stored edge counted once. Returns `None` on
1990    /// any non-integer weight (caller uses the exact degree path); missing weight
1991    /// defaults to nx's int `1`.
1992    #[must_use]
1993    pub fn weighted_size_int(&self, weight: &str) -> Option<i128> {
1994        let mut total: i128 = 0;
1995        for attrs in self.edges.values() {
1996            let value = match attrs.get(weight) {
1997                Some(CgseValue::Int(v)) => i128::from(*v),
1998                Some(_) => return None,
1999                None => 1,
2000            };
2001            total = total.checked_add(value)?;
2002        }
2003        Some(total)
2004    }
2005
2006    /// br-r37-c1-revborrow: O(V+E) directed transpose. The reverse of a
2007    /// `DiGraph` keeps the SAME node table (identical insertion order, identical
2008    /// indices), so reversing is a pure topology flip — no String hashing, no
2009    /// per-edge node-index lookups, no re-insertion through the name table (the
2010    /// edge-iterate + `edges_ordered()` clone + `extend_edges_with_attrs_*`
2011    /// re-hash path is O(E) String/hash work this avoids entirely).
2012    ///
2013    /// Successor rows are rebuilt by walking the ORIGINAL successor rows in
2014    /// node-major (ascending source index) order and appending the reversed
2015    /// endpoint, so the reversed graph's `edges_ordered()` stream is
2016    /// byte-identical to NetworkX's `reverse()`, which walks `self.edges()`
2017    /// (u-major successor order) and adds each `(v, u)`. `pred_indices[u]` ends
2018    /// up equal to the original `succ_indices[u]`, matching NetworkX's reversed
2019    /// `pred` order as well. Node + edge attribute maps are cloned verbatim.
2020    #[must_use]
2021    pub fn reversed(&self) -> Self {
2022        let n = self.nodes.len();
2023        let mut succ_indices: Vec<Vec<usize>> = vec![Vec::new(); n];
2024        let mut pred_indices: Vec<Vec<usize>> = vec![Vec::new(); n];
2025        for (i, (succ_row, pred_row)) in succ_indices
2026            .iter_mut()
2027            .zip(pred_indices.iter_mut())
2028            .enumerate()
2029        {
2030            // reversed succ row = original pred row; reversed pred row = original
2031            // succ row — pre-size so neither row reallocates.
2032            succ_row.reserve(self.pred_indices[i].len());
2033            pred_row.reserve(self.succ_indices[i].len());
2034        }
2035        let mut edges: crate::FxIndexMap<(usize, usize), AttrMap> =
2036            crate::FxIndexMap::with_capacity_and_hasher(
2037                self.edges.len(),
2038                rustc_hash::FxBuildHasher,
2039            );
2040        for (u, (source_succs, reversed_preds)) in self
2041            .succ_indices
2042            .iter()
2043            .zip(pred_indices.iter_mut())
2044            .enumerate()
2045        {
2046            for &t in source_succs {
2047                // original edge u -> t becomes t -> u in the reverse
2048                succ_indices[t].push(u);
2049                reversed_preds.push(t);
2050                if let Some(attrs) = self.edges.get(&(u, t)) {
2051                    edges.insert((t, u), attrs.clone());
2052                }
2053            }
2054        }
2055        Self {
2056            mode: self.mode,
2057            revision: 0,
2058            nodes: self.nodes.clone(),
2059            succ_indices,
2060            pred_indices,
2061            edges,
2062            runtime_policy: self.runtime_policy.clone(),
2063            csr_cache: std::sync::Arc::default(),
2064            all_int_cache: std::sync::Arc::default(),
2065        }
2066    }
2067
2068    /// Directed edge endpoints in the same order as `edges_ordered_borrowed`.
2069    #[must_use]
2070    pub fn edges_ordered_indices(&self) -> Vec<(usize, usize)> {
2071        let mut ordered = Vec::with_capacity(self.edges.len());
2072
2073        for (u, row) in self.succ_indices.iter().enumerate() {
2074            for &t in row {
2075                if self.edges.contains_key(&(u, t)) {
2076                    ordered.push((u, t));
2077                }
2078            }
2079        }
2080
2081        ordered
2082    }
2083
2084    #[must_use]
2085    pub fn snapshot(&self) -> DiGraphSnapshot {
2086        let node_attrs: std::collections::BTreeMap<String, AttrMap> = self
2087            .nodes
2088            .iter()
2089            .filter(|(_, attrs)| !attrs.is_empty())
2090            .map(|(name, attrs)| (name.clone(), attrs.clone()))
2091            .collect();
2092        DiGraphSnapshot {
2093            mode: self.mode,
2094            nodes: self.nodes.keys().cloned().collect(),
2095            node_attrs,
2096            edges: self.edges_ordered(),
2097        }
2098    }
2099
2100    /// Convert to an undirected Graph by dropping directionality.
2101    /// Both (u→v) and (v→u) merge into a single undirected edge.
2102    /// When both exist, the latter's attributes overwrite the former's.
2103    #[must_use]
2104    pub fn to_undirected(&self) -> crate::Graph {
2105        let mut g = crate::Graph::with_runtime_policy(self.runtime_policy.clone());
2106        for (node, attrs) in &self.nodes {
2107            g.add_node_with_attrs(node.clone(), attrs.clone());
2108        }
2109        // Iterate in canonical node->successor order (`edges_ordered`) rather
2110        // than the private `edges` IndexMap order: this matches networkx's
2111        // reciprocal-edge merge order (for a<->b the later-processed direction's
2112        // attrs win) AND keeps the `edges` map order unobservable, so
2113        // `remove_node` can drop incident edges with O(1) swap_remove.
2114        //
2115        // br-r37-c1-toundirbatch (cc): collect the directed edges (same
2116        // edges_ordered order) + one extend_edges_with_attrs_unrecorded instead
2117        // of per-edge add_edge_with_attrs (a policy record EACH — and for a<->b
2118        // reciprocal digraphs HALF the calls are merge-dups). The batch helper
2119        // dedups on the canonical endpoint pair and merges attrs via the same
2120        // `extend` (later direction wins), in the identical order; all nodes are
2121        // pre-added so endpoints resolve to existing indices → byte-identical.
2122        let edges: Vec<(String, String, AttrMap)> = self
2123            .edges_ordered()
2124            .into_iter()
2125            .map(|snap| (snap.left, snap.right, snap.attrs))
2126            .collect();
2127        let _ = g.extend_edges_with_attrs_unrecorded(edges);
2128        g
2129    }
2130
2131    // -----------------------------------------------------------------------
2132    // Internal
2133    // -----------------------------------------------------------------------
2134
2135    fn record_decision(
2136        &mut self,
2137        operation: &'static str,
2138        incompatibility_probability: f64,
2139        unknown_incompatible_feature: bool,
2140        evidence: Vec<EvidenceTerm>,
2141    ) -> DecisionAction {
2142        let action = self
2143            .runtime_policy
2144            .action_for(incompatibility_probability, unknown_incompatible_feature);
2145        self.runtime_policy.record(
2146            operation,
2147            action,
2148            incompatibility_probability,
2149            "argmin expected loss over {allow,full_validate,fail_closed}",
2150            evidence,
2151        );
2152        action
2153    }
2154}
2155
2156#[derive(Debug, Clone)]
2157pub struct MultiDiGraph {
2158    mode: CompatibilityMode,
2159    revision: u64,
2160    nodes: crate::FxIndexMap<String, AttrMap>,
2161    successors: crate::FxIndexMap<String, crate::FxIndexMap<String, crate::FxIndexSet<usize>>>,
2162    predecessors: crate::FxIndexMap<String, crate::FxIndexMap<String, crate::FxIndexSet<usize>>>,
2163    edges: crate::FxIndexMap<DirectedEdgeKey, IndexMap<usize, AttrMap>>,
2164    runtime_policy: RuntimePolicy,
2165    edge_count: usize,
2166    csr_cache: MultiDiCsrCache,
2167    /// br-r37-c1-s8dj1: (source position, target position) -> position in
2168    /// `edges`, so an edge can be reached without hashing the node keys.
2169    ///
2170    /// Every level of this graph's storage is keyed by `String`, so resolving an
2171    /// edge cost a hash of the whole node key however the caller arrived — which
2172    /// is why `has_edge_by_indices` could only convert its indices BACK to names,
2173    /// and the directed class kept a 3.7x key-length slope on `has_edge(u, v)`
2174    /// and 4.4x on the keyed form while the slot-addressed undirected twin sat at
2175    /// networkx's own 1.9x.
2176    ///
2177    /// BUILT LAZILY AND MAINTAINED INCREMENTALLY. A first attempt rebuilt it on
2178    /// every revision change, which made an add-then-check loop O(E) per probe
2179    /// and measured 137x slower than networkx at E=8000 — the rebuild was worse
2180    /// than the hashing it removed. Adds now patch the map in O(1), because
2181    /// inserting into `edges` APPENDS and adding a node APPENDS, so no existing
2182    /// position moves.
2183    ///
2184    /// THE STAMP IS THE SAFETY NET, not merely an optimisation. Removals do move
2185    /// both index spaces (`remove_node` shift-removes from `nodes`,
2186    /// `remove_edge` swap-removes from `edges`) and are deliberately NOT patched
2187    /// here: they bump `revision` without re-stamping, so the next read finds a
2188    /// stale stamp and rebuilds. Any mutation path not handled explicitly
2189    /// therefore degrades to a correct rebuild rather than a wrong answer.
2190    edge_pair_index: EdgePairIndex,
2191}
2192
2193impl MultiDiGraph {
2194    /// br-r37-c1-7qqr8: positional index of a node, matching
2195    /// `DiGraph::get_node_index`. MultiDiGraph carried the
2196    /// `NodeIndexLookupCache` (br-r37-c1-ic4cv) but only ever used its
2197    /// present-set half, so there was no accessor to populate the index half
2198    /// from — which is what the edge-attr index lookaside needs.
2199    #[must_use]
2200    pub fn get_node_index(&self, node: &str) -> Option<usize> {
2201        self.nodes.get_index_of(node)
2202    }
2203
2204    /// br-r37-c1-7dpyg: structural clone with a FRESH RuntimePolicy —
2205    /// see Graph::clone_with_fresh_policy.
2206    #[must_use]
2207    pub fn clone_with_fresh_policy(&self) -> Self {
2208        Self {
2209            mode: self.mode,
2210            revision: self.revision,
2211            nodes: self.nodes.clone(),
2212            successors: self.successors.clone(),
2213            predecessors: self.predecessors.clone(),
2214            edges: self.edges.clone(),
2215            runtime_policy: RuntimePolicy::new(self.mode),
2216            edge_count: self.edge_count,
2217            csr_cache: self.csr_cache.clone(),
2218            edge_pair_index: EdgePairIndex::default(),
2219        }
2220    }
2221
2222    /// br-r37-c1-s0d4x: reorder every PRED row into NetworkX's
2223    /// `MultiDiGraph.copy()` walk order — the multigraph counterpart of
2224    /// DiGraph::reorder_pred_rows_for_nx_copy_walk: each pred row's cells
2225    /// sort by `(pos(u), index of v within succ[u])`; succ rows are
2226    /// recreated in their original order by the u-major walk.
2227    pub fn reorder_pred_rows_for_nx_copy_walk(&mut self) {
2228        // br-r37-c1-predrebuild: build each pred row's u-major copy-walk order
2229        // DIRECTLY by walking succ in pos(u) order, replacing the prior
2230        // per-pred-entry double `get_index_of` lookup + per-row sort. Each pred
2231        // row holds DISTINCT predecessors u, so pos(u) is the sole ordering key
2232        // (the succ-index tiebreak never fires), and appending u to pred[v] while
2233        // iterating succ in insertion (= position) order yields the exact same
2234        // ordering — byte-identical pred row order, no sort, no index lookups.
2235        let mut order_map: IndexMap<String, Vec<String>> =
2236            IndexMap::with_capacity(self.predecessors.len());
2237        for (u, succ_row) in &self.successors {
2238            for v in succ_row.keys() {
2239                order_map.entry(v.clone()).or_default().push(u.clone());
2240            }
2241        }
2242        let orders: Vec<(String, Vec<String>)> = order_map.into_iter().collect();
2243        self.apply_row_orders(&orders, true);
2244    }
2245
2246    /// br-r37-c1-u3qyn: restore explicit succ/pred row orders (pickle
2247    /// round-trip) — see Graph::apply_row_orders. Keyed cells move
2248    /// wholesale; `pred` selects the adjacency side.
2249    pub fn apply_row_orders(&mut self, orders: &[(String, Vec<String>)], pred: bool) {
2250        let map = if pred {
2251            &mut self.predecessors
2252        } else {
2253            &mut self.successors
2254        };
2255        for (node, order) in orders {
2256            let Some(row) = map.get_mut(node.as_str()) else {
2257                continue;
2258            };
2259            let mut old = std::mem::take(row);
2260            let mut new_row =
2261                crate::FxIndexMap::with_capacity_and_hasher(old.len(), rustc_hash::FxBuildHasher);
2262            for v in order {
2263                if let Some((k, val)) = old.shift_remove_entry(v.as_str()) {
2264                    new_row.insert(k, val);
2265                }
2266            }
2267            for (k, val) in old {
2268                new_row.insert(k, val);
2269            }
2270            *row = new_row;
2271        }
2272    }
2273
2274    #[must_use]
2275    pub fn new(mode: CompatibilityMode) -> Self {
2276        Self {
2277            mode,
2278            revision: 0,
2279            nodes: crate::FxIndexMap::default(),
2280            successors: crate::FxIndexMap::default(),
2281            predecessors: crate::FxIndexMap::default(),
2282            edges: crate::FxIndexMap::default(),
2283            runtime_policy: RuntimePolicy::new(mode),
2284            edge_count: 0,
2285            csr_cache: std::sync::Arc::default(),
2286            edge_pair_index: EdgePairIndex::default(),
2287        }
2288    }
2289
2290    #[must_use]
2291    pub fn with_runtime_policy(runtime_policy: RuntimePolicy) -> Self {
2292        let mode = runtime_policy.mode();
2293        Self {
2294            mode,
2295            revision: 0,
2296            nodes: crate::FxIndexMap::default(),
2297            successors: crate::FxIndexMap::default(),
2298            predecessors: crate::FxIndexMap::default(),
2299            edges: crate::FxIndexMap::default(),
2300            runtime_policy,
2301            edge_count: 0,
2302            csr_cache: std::sync::Arc::default(),
2303            edge_pair_index: EdgePairIndex::default(),
2304        }
2305    }
2306
2307    #[must_use]
2308    pub fn strict() -> Self {
2309        Self::new(CompatibilityMode::Strict)
2310    }
2311
2312    #[must_use]
2313    pub fn hardened() -> Self {
2314        Self::new(CompatibilityMode::Hardened)
2315    }
2316
2317    #[must_use]
2318    pub fn mode(&self) -> CompatibilityMode {
2319        self.mode
2320    }
2321
2322    #[must_use]
2323    pub fn node_count(&self) -> usize {
2324        self.nodes.len()
2325    }
2326
2327    #[must_use]
2328    pub fn edge_count(&self) -> usize {
2329        self.edge_count
2330    }
2331
2332    #[must_use]
2333    pub fn number_of_selfloops(&self) -> usize {
2334        self.edges
2335            .iter()
2336            .filter(|(edge_key, _)| edge_key.source == edge_key.target)
2337            .map(|(_, edge_bucket)| edge_bucket.len())
2338            .sum()
2339    }
2340
2341    /// Return an iterator over keys for edges from source to target.
2342    /// Edge keys as Vec.
2343    #[must_use]
2344    pub fn edge_keys(&self, source: &str, target: &str) -> Option<Vec<usize>> {
2345        self.successors
2346            .get(source)?
2347            .get(target)
2348            .map(|keys| keys.iter().copied().collect())
2349    }
2350
2351    /// Edge keys as iterator.
2352    pub fn edge_keys_iter(
2353        &self,
2354        source: &str,
2355        target: &str,
2356    ) -> Option<impl Iterator<Item = &usize>> {
2357        self.successors
2358            .get(source)?
2359            .get(target)
2360            .map(|keys| keys.iter())
2361    }
2362
2363    #[must_use]
2364    pub fn revision(&self) -> u64 {
2365        self.revision
2366    }
2367
2368    /// Revision-keyed integer CSR of distinct successor and predecessor rows.
2369    /// Row order follows the existing map-backed rows exactly, so consumers can
2370    /// replace string-key walks without changing observable traversal order.
2371    #[must_use]
2372    pub fn csr(&self) -> std::sync::Arc<MultiDiCsr> {
2373        if let Ok(guard) = self.csr_cache.read()
2374            && let Some((rev, csr)) = guard.as_ref()
2375            && *rev == self.revision
2376        {
2377            return csr.clone();
2378        }
2379        let built = std::sync::Arc::new(self.build_csr());
2380        if let Ok(mut guard) = self.csr_cache.write() {
2381            *guard = Some((self.revision, built.clone()));
2382        }
2383        built
2384    }
2385
2386    fn build_csr(&self) -> MultiDiCsr {
2387        let n = self.nodes.len();
2388        let distinct_succ_edges: usize = self.successors.values().map(IndexMap::len).sum();
2389        let distinct_pred_edges: usize = self.predecessors.values().map(IndexMap::len).sum();
2390        let mut succ_offsets = Vec::with_capacity(n + 1);
2391        let mut pred_offsets = Vec::with_capacity(n + 1);
2392        let mut succ_targets = Vec::with_capacity(distinct_succ_edges);
2393        let mut pred_targets = Vec::with_capacity(distinct_pred_edges);
2394        succ_offsets.push(0);
2395        pred_offsets.push(0);
2396        for node in self.nodes.keys() {
2397            if let Some(row) = self.successors.get(node.as_str()) {
2398                succ_targets.extend(row.keys().filter_map(|target| {
2399                    self.nodes
2400                        .get_index_of(target.as_str())
2401                        .map(|idx| u32::try_from(idx).unwrap_or(u32::MAX))
2402                }));
2403            }
2404            succ_offsets.push(succ_targets.len());
2405
2406            if let Some(row) = self.predecessors.get(node.as_str()) {
2407                pred_targets.extend(row.keys().filter_map(|source| {
2408                    self.nodes
2409                        .get_index_of(source.as_str())
2410                        .map(|idx| u32::try_from(idx).unwrap_or(u32::MAX))
2411                }));
2412            }
2413            pred_offsets.push(pred_targets.len());
2414        }
2415        MultiDiCsr {
2416            succ_offsets,
2417            succ_targets,
2418            pred_offsets,
2419            pred_targets,
2420        }
2421    }
2422
2423    #[must_use]
2424    pub fn has_node(&self, node: &str) -> bool {
2425        self.nodes.contains_key(node)
2426    }
2427
2428    #[must_use]
2429    pub fn has_edge(&self, source: &str, target: &str) -> bool {
2430        self.edges
2431            .get(&DirectedEdgeKeyRef::new(source, target))
2432            .is_some_and(|edge_bucket| !edge_bucket.is_empty())
2433    }
2434
2435    /// br-r37-c1-04z53 (cc): directed `has_edge` straight from insertion-order
2436    /// indices (source-major), skipping the `i.to_string()` heap alloc for int
2437    /// nodes. Names come from the node table by index (borrowed); the caller
2438    /// guards each index with `node_index_matches_int`. Mirror of
2439    /// `Graph::has_edge_by_indices`.
2440    /// br-r37-c1-s8dj1: O(E) rebuild of the pair lookaside. Paid on the first
2441    /// probe, and again after any mutation that did not patch it.
2442    fn build_edge_pair_index(&self) -> EdgePairMap {
2443        let mut built =
2444            EdgePairMap::with_capacity_and_hasher(self.edges.len(), rustc_hash::FxBuildHasher);
2445        for (position, edge_key) in self.edges.keys().enumerate() {
2446            if let Some(source_idx) = self.nodes.get_index_of(edge_key.source.as_str())
2447                && let Some(target_idx) = self.nodes.get_index_of(edge_key.target.as_str())
2448            {
2449                built.insert((source_idx, target_idx), position);
2450            }
2451        }
2452        built
2453    }
2454
2455    /// br-r37-c1-s8dj1: position of the (source, target) bucket in `edges`,
2456    /// addressed by node POSITION and never by name.
2457    /// TRI-STATE: `None` means "no index available, use the name path" -- which
2458    /// is what the warm-up guard returns until a caller has proved it will probe
2459    /// enough to amortise the build. `Some(inner)` is an authoritative answer,
2460    /// `inner` being the bucket position or `None` for absent.
2461    fn edge_pair_position(&self, source_idx: usize, target_idx: usize) -> Option<Option<usize>> {
2462        use std::sync::atomic::Ordering;
2463        if let Ok(guard) = self.edge_pair_index.map.read()
2464            && let Some((stamp, map)) = guard.as_ref()
2465            && *stamp == self.revision
2466        {
2467            return Some(map.get(&(source_idx, target_idx)).copied());
2468        }
2469        let revision = self.revision;
2470        if self.edge_pair_index.probed_revision.load(Ordering::Relaxed) != revision {
2471            self.edge_pair_index
2472                .probed_revision
2473                .store(revision, Ordering::Relaxed);
2474            self.edge_pair_index
2475                .probes_since_build
2476                .store(1, Ordering::Relaxed);
2477            return None;
2478        }
2479        let seen = self
2480            .edge_pair_index
2481            .probes_since_build
2482            .fetch_add(1, Ordering::Relaxed)
2483            + 1;
2484        if seen < pair_index_warmup(self.edges.len()) {
2485            return None;
2486        }
2487        let built = self.build_edge_pair_index();
2488        let found = built.get(&(source_idx, target_idx)).copied();
2489        if let Ok(mut guard) = self.edge_pair_index.map.write() {
2490            *guard = Some((revision, built));
2491        }
2492        Some(found)
2493    }
2494
2495    /// br-r37-c1-s8dj1: patch the lookaside through an edge ADD rather than
2496    /// letting the stamp drop it, which is what made the rebuild version a loss
2497    /// on add-then-check loops.
2498    ///
2499    /// Sound because an add MOVES NOTHING: `IndexMap::insert` appends, so a new
2500    /// pair takes the last position and every existing position stands, and a
2501    /// node added by this same call appends to `nodes` so every existing node
2502    /// position stands too.
2503    ///
2504    /// Costs nothing on a graph nobody has probed: the liveness check comes
2505    /// first, and a cold cache returns before any node-key hash is paid.
2506    fn pair_index_note_edge_added(
2507        &mut self,
2508        previous_revision: u64,
2509        source: &str,
2510        target: &str,
2511        new_pair_position: Option<usize>,
2512    ) {
2513        match self.edge_pair_index.map.get_mut() {
2514            Ok(Some((stamp, _))) if *stamp == previous_revision => {}
2515            _ => return,
2516        }
2517        let resolved = match new_pair_position {
2518            Some(position) => match (
2519                self.nodes.get_index_of(source),
2520                self.nodes.get_index_of(target),
2521            ) {
2522                (Some(source_idx), Some(target_idx)) => Some(((source_idx, target_idx), position)),
2523                // Cannot describe the new edge: drop rather than half-patch.
2524                _ => {
2525                    if let Ok(slot) = self.edge_pair_index.map.get_mut() {
2526                        *slot = None;
2527                    }
2528                    return;
2529                }
2530            },
2531            None => None,
2532        };
2533        let current = self.revision;
2534        if let Ok(Some((stamp, map))) = self.edge_pair_index.map.get_mut() {
2535            if let Some((pair, position)) = resolved {
2536                map.insert(pair, position);
2537            }
2538            *stamp = current;
2539        }
2540    }
2541
2542    /// br-r37-c1-s8dj1: is the pair index worth maintaining right now? A graph
2543    /// nobody has probed carries no map, and must pay nothing for one.
2544    fn edge_pair_index_is_live(&self, at_revision: u64) -> bool {
2545        matches!(
2546            self.edge_pair_index.map.read().as_deref(),
2547            Ok(Some((stamp, _))) if *stamp == at_revision
2548        )
2549    }
2550
2551    /// br-r37-c1-s8dj1: patch the lookaside through an edge REMOVAL.
2552    ///
2553    /// `remove_edge` SWAP-removes the bucket when its last key goes, which moves
2554    /// exactly ONE entry: whatever sat last now sits in the freed slot. That is a
2555    /// bounded repair, unlike `remove_node`, which shift-removes from `nodes` and
2556    /// renumbers every later node — that one still drops the map and rebuilds,
2557    /// which is proportionate because the removal itself is already O(V+E).
2558    ///
2559    /// `dropped_position` is `None` when the bucket survived (a parallel key went
2560    /// but the pair remains), in which case nothing moved and only the stamp
2561    /// needs carrying forward.
2562    fn pair_index_note_edge_removed(
2563        &mut self,
2564        previous_revision: u64,
2565        source: &str,
2566        target: &str,
2567        dropped_position: Option<usize>,
2568    ) {
2569        match self.edge_pair_index.map.get_mut() {
2570            Ok(Some((stamp, _))) if *stamp == previous_revision => {}
2571            _ => return,
2572        }
2573        let removed_pair = match dropped_position {
2574            Some(_) => match (
2575                self.nodes.get_index_of(source),
2576                self.nodes.get_index_of(target),
2577            ) {
2578                (Some(source_idx), Some(target_idx)) => Some((source_idx, target_idx)),
2579                _ => {
2580                    if let Ok(slot) = self.edge_pair_index.map.get_mut() {
2581                        *slot = None;
2582                    }
2583                    return;
2584                }
2585            },
2586            None => None,
2587        };
2588        // The entry that swap_remove relocated into the freed slot, if any.
2589        let relocated = match dropped_position {
2590            Some(position) if position < self.edges.len() => match self.edges.get_index(position) {
2591                Some((moved_key, _)) => {
2592                    let moved_source = moved_key.source.clone();
2593                    let moved_target = moved_key.target.clone();
2594                    match (
2595                        self.nodes.get_index_of(moved_source.as_str()),
2596                        self.nodes.get_index_of(moved_target.as_str()),
2597                    ) {
2598                        (Some(source_idx), Some(target_idx)) => {
2599                            Some(((source_idx, target_idx), position))
2600                        }
2601                        _ => {
2602                            if let Ok(slot) = self.edge_pair_index.map.get_mut() {
2603                                *slot = None;
2604                            }
2605                            return;
2606                        }
2607                    }
2608                }
2609                None => None,
2610            },
2611            _ => None,
2612        };
2613        let current = self.revision;
2614        if let Ok(Some((stamp, map))) = self.edge_pair_index.map.get_mut() {
2615            if let Some(pair) = removed_pair {
2616                map.remove(&pair);
2617            }
2618            if let Some((pair, position)) = relocated {
2619                map.insert(pair, position);
2620            }
2621            *stamp = current;
2622        }
2623    }
2624
2625    /// br-r37-c1-s8dj1: now a REAL index path.
2626    ///
2627    /// It used to resolve both indices back to names and call `has_edge(&str,
2628    /// &str)`, which hashes the full-length keys again — so a caller's O(1)
2629    /// index resolution bought nothing, and `PyMultiDiGraph`'s exact-string
2630    /// branch (br-r37-c1-ptiz2) inherited the slope it was written to remove.
2631    ///
2632    /// An `edges` bucket is dropped as soon as its last key goes (see
2633    /// `remove_edge`), so membership of the pair map is exactly `has_edge`.
2634    #[must_use]
2635    pub fn has_edge_by_indices(&self, source_idx: usize, target_idx: usize) -> bool {
2636        if let Some(position) = self.edge_pair_position(source_idx, target_idx) {
2637            return position.is_some();
2638        }
2639        match (
2640            self.get_node_name(source_idx),
2641            self.get_node_name(target_idx),
2642        ) {
2643            (Some(source), Some(target)) => self.has_edge(source, target),
2644            _ => false,
2645        }
2646    }
2647
2648    /// br-r37-c1-s8dj1: how many PARALLEL edges a pair carries, addressed by
2649    /// node POSITION. `number_of_edges(u, v)` built two owned canonicals and
2650    /// hashed the pair to answer what is a bucket length, and measured 0.272x
2651    /// of networkx at 2000-character keys — the worst surviving row on this
2652    /// class after the keyed `has_edge` path landed.
2653    ///
2654    /// Falls back to the name path while the lookaside is still warming up, so
2655    /// it inherits the same amortisation guard rather than forcing a build.
2656    #[must_use]
2657    pub fn edge_key_count_by_indices(&self, source_idx: usize, target_idx: usize) -> usize {
2658        if let Some(position) = self.edge_pair_position(source_idx, target_idx) {
2659            return position
2660                .and_then(|found| self.edges.get_index(found))
2661                .map_or(0, |(_, keyed)| keyed.len());
2662        }
2663        match (
2664            self.get_node_name(source_idx),
2665            self.get_node_name(target_idx),
2666        ) {
2667            (Some(source), Some(target)) => self
2668                .edges
2669                .get(&DirectedEdgeKeyRef::new(source, target))
2670                .map_or(0, IndexMap::len),
2671            _ => 0,
2672        }
2673    }
2674
2675    /// br-r37-c1-s8dj1: attributes of a KEYED directed edge, addressed by node
2676    /// POSITION — the directed twin of `MultiGraph::edge_attrs_by_indices`, and
2677    /// the primitive whose absence is why `PyMultiDiGraph::has_edge` left its
2678    /// keyed form on the string path.
2679    #[must_use]
2680    pub fn edge_attrs_by_indices(
2681        &self,
2682        source_idx: usize,
2683        target_idx: usize,
2684        key: usize,
2685    ) -> Option<&AttrMap> {
2686        if let Some(position) = self.edge_pair_position(source_idx, target_idx) {
2687            let (_, keyed) = self.edges.get_index(position?)?;
2688            return keyed.get(&key);
2689        }
2690        let source = self.get_node_name(source_idx)?;
2691        let target = self.get_node_name(target_idx)?;
2692        self.edges
2693            .get(&DirectedEdgeKeyRef::new(source, target))
2694            .and_then(|bucket| bucket.get(&key))
2695    }
2696
2697    #[must_use]
2698    pub fn nodes_ordered(&self) -> Vec<&str> {
2699        self.nodes.keys().map(String::as_str).collect()
2700    }
2701
2702    /// Resolve the current node name for an insertion-order index.
2703    #[must_use]
2704    #[inline]
2705    pub fn get_node_name(&self, index: usize) -> Option<&str> {
2706        self.nodes.get_index(index).map(|(name, _)| name.as_str())
2707    }
2708
2709    /// br-cc-nbunchbulk: int membership fast path for `_nbunch_present` — see the
2710    /// DiGraph accessor.
2711    #[must_use]
2712    pub fn node_index_matches_int(&self, idx: usize) -> bool {
2713        self.nodes
2714            .get_index(idx)
2715            .is_some_and(|(k, _)| k.parse::<usize>() == Ok(idx))
2716    }
2717
2718    #[must_use]
2719    pub fn successors(&self, node: &str) -> Option<Vec<&str>> {
2720        self.successors
2721            .get(node)
2722            .map(|neighbors| neighbors.keys().map(String::as_str).collect::<Vec<&str>>())
2723    }
2724
2725    #[must_use]
2726    pub fn successors_iter(&self, node: &str) -> Option<impl Iterator<Item = &str> + '_> {
2727        self.successors
2728            .get(node)
2729            .map(|neighbors| neighbors.keys().map(String::as_str))
2730    }
2731
2732    #[must_use]
2733    pub fn predecessors(&self, node: &str) -> Option<Vec<&str>> {
2734        self.predecessors
2735            .get(node)
2736            .map(|neighbors| neighbors.keys().map(String::as_str).collect::<Vec<&str>>())
2737    }
2738
2739    #[must_use]
2740    pub fn predecessors_iter(&self, node: &str) -> Option<impl Iterator<Item = &str> + '_> {
2741        self.predecessors
2742            .get(node)
2743            .map(|neighbors| neighbors.keys().map(String::as_str))
2744    }
2745
2746    #[must_use]
2747    pub fn neighbors(&self, node: &str) -> Option<Vec<&str>> {
2748        self.successors(node)
2749    }
2750
2751    #[must_use]
2752    pub fn neighbors_iter(&self, node: &str) -> Option<impl Iterator<Item = &str> + '_> {
2753        self.successors_iter(node)
2754    }
2755
2756    #[must_use]
2757    pub fn node_attrs(&self, node: &str) -> Option<&AttrMap> {
2758        self.nodes.get(node)
2759    }
2760
2761    #[must_use]
2762    pub fn edge_attrs(&self, source: &str, target: &str, key: usize) -> Option<&AttrMap> {
2763        self.edges
2764            .get(&DirectedEdgeKeyRef::new(source, target))
2765            .and_then(|edge_bucket| edge_bucket.get(&key))
2766    }
2767
2768    #[must_use]
2769    pub fn edge_attr_values(
2770        &self,
2771        source: &str,
2772        target: &str,
2773    ) -> Option<impl Iterator<Item = &AttrMap> + '_> {
2774        self.edges
2775            .get(&DirectedEdgeKeyRef::new(source, target))
2776            .map(|edge_bucket| edge_bucket.values())
2777    }
2778
2779    #[must_use]
2780    pub fn evidence_ledger(&self) -> &EvidenceLedger {
2781        self.runtime_policy.decision_log()
2782    }
2783
2784    pub fn drain_decision_records(&mut self) -> Vec<DecisionRecord> {
2785        self.runtime_policy.drain_decision_records()
2786    }
2787
2788    #[must_use]
2789    pub fn runtime_policy(&self) -> &RuntimePolicy {
2790        &self.runtime_policy
2791    }
2792
2793    pub fn set_runtime_policy(&mut self, runtime_policy: RuntimePolicy) {
2794        self.mode = runtime_policy.mode();
2795        self.runtime_policy = runtime_policy;
2796    }
2797
2798    #[must_use]
2799    pub fn is_directed(&self) -> bool {
2800        true
2801    }
2802
2803    #[must_use]
2804    pub fn is_multigraph(&self) -> bool {
2805        true
2806    }
2807
2808    /// Return the out-degree of a node (number of outgoing parallel edges).
2809    #[must_use]
2810    pub fn out_degree(&self, node: &str) -> usize {
2811        self.successors
2812            .get(node)
2813            .map_or(0, |succs| succs.values().map(IndexSet::len).sum())
2814    }
2815
2816    /// Return the in-degree of a node (number of incoming parallel edges).
2817    #[must_use]
2818    pub fn in_degree(&self, node: &str) -> usize {
2819        self.predecessors
2820            .get(node)
2821            .map_or(0, |preds| preds.values().map(IndexSet::len).sum())
2822    }
2823
2824    /// Return the degree of a node (in-degree + out-degree).
2825    #[must_use]
2826    pub fn degree(&self, node: &str) -> usize {
2827        self.in_degree(node) + self.out_degree(node)
2828    }
2829
2830    /// br-r37-c1-mgisol (cc): native isolate detection for MultiDiGraph. A node
2831    /// is isolated iff it has no incident edges in either direction (empty/absent
2832    /// successor AND predecessor rows). A self-loop records the node in both its
2833    /// own successor and predecessor rows, keeping it non-isolated (matches nx's
2834    /// degree-2 self-loop convention). Yields nodes in insertion order, identical
2835    /// to the old `multidigraph_to_simple_digraph` projection path but without the
2836    /// per-call O(V+E) simple-graph rebuild.
2837    #[must_use]
2838    pub fn isolates(&self) -> Vec<String> {
2839        self.nodes
2840            .keys()
2841            .filter(|node| self.is_isolate(node.as_str()))
2842            .cloned()
2843            .collect()
2844    }
2845
2846    /// br-r37-c1-mgisol (cc): isolate count without the simple-digraph projection.
2847    #[must_use]
2848    pub fn number_of_isolates(&self) -> usize {
2849        self.nodes
2850            .keys()
2851            .filter(|node| self.is_isolate(node.as_str()))
2852            .count()
2853    }
2854
2855    /// br-r37-c1-mgisol (cc): O(1) isolate predicate. Absent node -> false
2856    /// (mirrors `is_isolate_directed(&DiGraph)`); the binding validates presence.
2857    #[must_use]
2858    pub fn is_isolate(&self, node: &str) -> bool {
2859        self.nodes.contains_key(node)
2860            && self.successors.get(node).is_none_or(IndexMap::is_empty)
2861            && self.predecessors.get(node).is_none_or(IndexMap::is_empty)
2862    }
2863
2864    pub fn add_node(&mut self, node: impl AsRef<str> + Into<String>) -> bool {
2865        self.add_node_with_attrs(node, AttrMap::new())
2866    }
2867
2868    /// br-r37-c1-wa1b9: the storage half of [`Self::add_node_with_attrs`],
2869    /// without its ledger record. See the undirected `Graph` sibling.
2870    ///
2871    /// br-r37-c1-jc9e4: this key used to be materialised THREE times per call —
2872    /// `node.into()` plus a `clone()` for the `nodes` entry and another for the
2873    /// `successors` entry — because `entry()` allocates its key eagerly whether
2874    /// or not the slot is vacant. On a node that already exists all three were
2875    /// built only to be dropped. The allocation census measured this pair at
2876    /// 4.000 allocations per call against 1.000 for every other graph type,
2877    /// which is how the outlier was found. Each map is now probed borrowed and
2878    /// the key owned only where a slot is genuinely created.
2879    ///
2880    /// The `successors` / `predecessors` rows are still created unconditionally
2881    /// when missing rather than being skipped whenever the node exists: nothing
2882    /// here may assume that every path which inserts into `nodes` also created
2883    /// those rows, and `contains_key` + `insert`-when-absent is exactly what
2884    /// `entry(..).or_default()` did, including appending at the end when vacant.
2885    fn add_node_with_attrs_unrecorded(
2886        &mut self,
2887        node: impl AsRef<str> + Into<String>,
2888        attrs: AttrMap,
2889    ) -> (bool, bool, usize) {
2890        let existed = self.nodes.contains_key(node.as_ref());
2891        let mut changed = !existed;
2892        let attrs_count = {
2893            let bucket = if existed {
2894                self.nodes
2895                    .get_mut(node.as_ref())
2896                    .expect("contains_key reported this node present")
2897            } else {
2898                self.nodes.entry(node.as_ref().to_owned()).or_default()
2899            };
2900            if !attrs.is_empty()
2901                && attrs
2902                    .iter()
2903                    .any(|(key, value)| bucket.get(key) != Some(value))
2904            {
2905                changed = true;
2906            }
2907            bucket.extend(attrs);
2908            bucket.len()
2909        };
2910        if !self.successors.contains_key(node.as_ref()) {
2911            self.successors
2912                .insert(node.as_ref().to_owned(), IndexMap::default());
2913        }
2914        if !self.predecessors.contains_key(node.as_ref()) {
2915            self.predecessors.insert(node.into(), IndexMap::default());
2916        }
2917        if changed {
2918            self.revision = self.revision.saturating_add(1);
2919        }
2920        (changed, existed, attrs_count)
2921    }
2922
2923    pub fn add_node_with_attrs(
2924        &mut self,
2925        node: impl AsRef<str> + Into<String>,
2926        attrs: AttrMap,
2927    ) -> bool {
2928        let (changed, existed, attrs_count) = self.add_node_with_attrs_unrecorded(node, attrs);
2929        self.record_decision(
2930            "add_node",
2931            0.0,
2932            false,
2933            vec![
2934                EvidenceTerm {
2935                    signal: "node_preexisting".into(),
2936                    observed_value: bool_evidence(existed),
2937                    log_likelihood_ratio: -3.0,
2938                },
2939                EvidenceTerm {
2940                    signal: "attrs_count".into(),
2941                    observed_value: count_evidence(attrs_count),
2942                    log_likelihood_ratio: -1.0,
2943                },
2944            ],
2945        );
2946        changed
2947    }
2948
2949    pub fn add_edge(
2950        &mut self,
2951        source: impl Into<String>,
2952        target: impl Into<String>,
2953    ) -> Result<usize, GraphError> {
2954        self.add_edge_impl(source, target, None, AttrMap::new())
2955    }
2956
2957    /// br-r37-c1-l5ve7: bulk node insert WITH attrs, one ledger record —
2958    /// MultiDiGraph mirror of DiGraph::extend_nodes_with_attrs_unrecorded.
2959    /// Existing nodes merge attrs (extend).
2960    pub fn extend_nodes_with_attrs_unrecorded<I>(&mut self, nodes: I) -> usize
2961    where
2962        I: IntoIterator<Item = (String, AttrMap)>,
2963    {
2964        let mut inserted = 0usize;
2965        for (node, attrs) in nodes {
2966            if let Some(existing) = self.nodes.get_mut(&node) {
2967                existing.extend(attrs);
2968                continue;
2969            }
2970            self.nodes.insert(node.clone(), attrs);
2971            self.successors.entry(node.clone()).or_default();
2972            self.predecessors.entry(node).or_default();
2973            inserted += 1;
2974        }
2975        if inserted > 0 {
2976            self.revision = self
2977                .revision
2978                .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
2979            self.record_decision(
2980                "extend_nodes_with_attrs_unrecorded",
2981                0.0,
2982                false,
2983                vec![EvidenceTerm {
2984                    signal: "batch_node_count".into(),
2985                    observed_value: count_evidence(inserted),
2986                    log_likelihood_ratio: -1.0,
2987                }],
2988            );
2989        }
2990        inserted
2991    }
2992
2993    /// br-r37-c1-l5ve7: bulk KEYED edge insert WITH attrs, one ledger
2994    /// record — for native copy/convert paths that replicate a source
2995    /// multigraph's exact internal keys (add_edge_impl pays TWO
2996    /// record_decision calls per edge). Endpoint nodes are created on
2997    /// demand; an existing (u, v, key) cell merges attrs (extend),
2998    /// matching add_edge_with_key_and_attrs.
2999    pub fn extend_keyed_edges_with_attrs_unrecorded<I>(&mut self, edges: I) -> usize
3000    where
3001        I: IntoIterator<Item = (String, String, usize, AttrMap)>,
3002    {
3003        let mut inserted = 0usize;
3004        for (source, target, key, attrs) in edges {
3005            if !self.nodes.contains_key(&source) {
3006                self.nodes.insert(source.clone(), AttrMap::new());
3007                self.successors.entry(source.clone()).or_default();
3008                self.predecessors.entry(source.clone()).or_default();
3009            }
3010            if source != target && !self.nodes.contains_key(&target) {
3011                self.nodes.insert(target.clone(), AttrMap::new());
3012                self.successors.entry(target.clone()).or_default();
3013                self.predecessors.entry(target.clone()).or_default();
3014            }
3015            let edge_key = DirectedEdgeKey::new(&source, &target);
3016            let bucket = self.edges.entry(edge_key).or_default();
3017            if !bucket.contains_key(&key) {
3018                self.edge_count += 1;
3019                inserted += 1;
3020            }
3021            bucket.entry(key).or_default().extend(attrs);
3022            self.successors
3023                .entry(source.clone())
3024                .or_default()
3025                .entry(target.clone())
3026                .or_default()
3027                .insert(key);
3028            self.predecessors
3029                .entry(target)
3030                .or_default()
3031                .entry(source)
3032                .or_default()
3033                .insert(key);
3034        }
3035        if inserted > 0 {
3036            self.revision = self
3037                .revision
3038                .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
3039            self.record_decision(
3040                "extend_keyed_edges_with_attrs_unrecorded",
3041                0.0,
3042                false,
3043                vec![EvidenceTerm {
3044                    signal: "batch_edge_count".into(),
3045                    observed_value: count_evidence(inserted),
3046                    log_likelihood_ratio: -1.0,
3047                }],
3048            );
3049        }
3050        inserted
3051    }
3052
3053    /// Bulk-add keyed attributed directed multiedges into a fresh graph when the
3054    /// caller has already assigned NetworkX-compatible node indices and per-pair
3055    /// keys. This avoids hashing/copying canonical endpoint strings for every
3056    /// edge in fresh `MultiDiGraph.add_edges_from` batches.
3057    #[must_use]
3058    pub fn extend_fresh_index_keyed_edges_with_attrs_unrecorded<I, N>(
3059        &mut self,
3060        nodes: N,
3061        edges: I,
3062    ) -> usize
3063    where
3064        I: IntoIterator<Item = (usize, usize, usize, AttrMap)>,
3065        N: IntoIterator<Item = String>,
3066    {
3067        if !self.nodes.is_empty()
3068            || !self.successors.is_empty()
3069            || !self.predecessors.is_empty()
3070            || !self.edges.is_empty()
3071        {
3072            return 0;
3073        }
3074
3075        let node_labels: Vec<String> = nodes.into_iter().collect();
3076        for node in &node_labels {
3077            self.nodes.insert(node.clone(), AttrMap::new());
3078            self.successors
3079                .insert(node.clone(), crate::FxIndexMap::default());
3080            self.predecessors
3081                .insert(node.clone(), crate::FxIndexMap::default());
3082        }
3083
3084        let node_count = node_labels.len();
3085        let mut inserted = 0usize;
3086        let mut merged_changed = false;
3087        for (source_idx, target_idx, key, attrs) in edges {
3088            let Some(source) = node_labels.get(source_idx) else {
3089                continue;
3090            };
3091            let Some(target) = node_labels.get(target_idx) else {
3092                continue;
3093            };
3094            let edge_key = DirectedEdgeKey::new(source, target);
3095            let bucket = self.edges.entry(edge_key).or_default();
3096            if !bucket.contains_key(&key) {
3097                self.edge_count += 1;
3098                inserted += 1;
3099            }
3100            let edge_attrs = bucket.entry(key).or_default();
3101            if !attrs.is_empty()
3102                && attrs
3103                    .iter()
3104                    .any(|(attr_key, value)| edge_attrs.get(attr_key) != Some(value))
3105            {
3106                merged_changed = true;
3107            }
3108            edge_attrs.extend(attrs);
3109            self.successors
3110                .get_mut(source.as_str())
3111                .expect("fresh node row exists")
3112                .entry(target.clone())
3113                .or_default()
3114                .insert(key);
3115            self.predecessors
3116                .get_mut(target.as_str())
3117                .expect("fresh node row exists")
3118                .entry(source.clone())
3119                .or_default()
3120                .insert(key);
3121        }
3122
3123        if node_count > 0 || inserted > 0 || merged_changed {
3124            self.revision = self.revision.saturating_add(
3125                u64::try_from(node_count.saturating_add(inserted)).unwrap_or(u64::MAX),
3126            );
3127            self.record_decision(
3128                "extend_fresh_index_keyed_edges_with_attrs_unrecorded",
3129                0.0,
3130                false,
3131                vec![
3132                    EvidenceTerm {
3133                        signal: "batch_node_count".into(),
3134                        observed_value: count_evidence(node_count),
3135                        log_likelihood_ratio: -1.0,
3136                    },
3137                    EvidenceTerm {
3138                        signal: "batch_edge_count".into(),
3139                        observed_value: count_evidence(inserted),
3140                        log_likelihood_ratio: -1.0,
3141                    },
3142                ],
3143            );
3144        }
3145
3146        inserted
3147    }
3148
3149    pub fn add_edge_with_attrs(
3150        &mut self,
3151        source: impl Into<String>,
3152        target: impl Into<String>,
3153        attrs: AttrMap,
3154    ) -> Result<usize, GraphError> {
3155        self.add_edge_impl(source, target, None, attrs)
3156    }
3157
3158    pub fn add_edge_with_key_and_attrs(
3159        &mut self,
3160        source: impl Into<String>,
3161        target: impl Into<String>,
3162        key: usize,
3163        attrs: AttrMap,
3164    ) -> Result<usize, GraphError> {
3165        self.add_edge_impl(source, target, Some(key), attrs)
3166    }
3167
3168    fn add_edge_impl(
3169        &mut self,
3170        source: impl Into<String>,
3171        target: impl Into<String>,
3172        explicit_key: Option<usize>,
3173        attrs: AttrMap,
3174    ) -> Result<usize, GraphError> {
3175        let source = source.into();
3176        let target = target.into();
3177        // br-r37-c1-s8dj1: the revision AT ENTRY, before the node inserts below.
3178        // Those call `add_node_with_attrs_unrecorded`, which bumps the revision
3179        // too -- capturing after them left the pair index stale-looking and the
3180        // patch never fired, so every add still forced an O(E) rebuild.
3181        // Everything this function does to either index space is an APPEND, so a
3182        // map valid at entry is still valid to patch here.
3183        let previous_revision = self.revision;
3184
3185        let unknown_feature = attrs
3186            .keys()
3187            .any(|key| key.starts_with("__fnx_incompatible"));
3188        let self_loop = source == target;
3189        let incompatibility_probability = if unknown_feature {
3190            1.0
3191        } else if self_loop {
3192            0.22
3193        } else {
3194            0.08
3195        };
3196
3197        // br-r37-c1-eo88t: the gate decision earns a ledger record only when it
3198        // REFUSES. A refusal returns from here, so the outcome record at the end
3199        // of this function never runs and the gate record is the whole audit
3200        // trail for the rejected mutation. On the allow path the outcome record
3201        // carries the same operation/mode/action/incompatibility_probability
3202        // plus strictly richer evidence, so recording the gate as well wrote two
3203        // near-identical records for every edge on the hottest mutation path.
3204        let action = self
3205            .runtime_policy
3206            .action_for(incompatibility_probability, unknown_feature);
3207
3208        if action == DecisionAction::FailClosed || action == DecisionAction::FullValidate {
3209            self.record_decision(
3210                "add_edge",
3211                incompatibility_probability,
3212                unknown_feature,
3213                vec![EvidenceTerm {
3214                    signal: "unknown_incompatible_feature".into(),
3215                    observed_value: bool_evidence(unknown_feature),
3216                    log_likelihood_ratio: 12.0,
3217                }],
3218            );
3219            return Err(GraphError::FailClosed {
3220                operation: "add_edge",
3221                reason: "incompatible edge metadata".to_owned(),
3222            });
3223        }
3224
3225        // br-r37-c1-wa1b9: internal autocreation files no record of its own —
3226        // see the undirected `Graph` sibling for the ruling.
3227        let mut source_autocreated = false;
3228        if !self.nodes.contains_key(&source) {
3229            let _ = self.add_node_with_attrs_unrecorded(source.clone(), AttrMap::new());
3230            source_autocreated = true;
3231        }
3232        let mut target_autocreated = false;
3233        if self_loop {
3234            target_autocreated = source_autocreated;
3235        } else if !self.nodes.contains_key(&target) {
3236            let _ = self.add_node_with_attrs_unrecorded(target.clone(), AttrMap::new());
3237            target_autocreated = true;
3238        }
3239
3240        let edge_key = DirectedEdgeKey::new(&source, &target);
3241        let key = explicit_key.unwrap_or_else(|| {
3242            let edge_bucket = self.edges.get(&edge_key);
3243            let mut k = edge_bucket.map_or(0, |b| b.len());
3244            if let Some(b) = edge_bucket {
3245                while b.contains_key(&k) {
3246                    k += 1;
3247                }
3248            }
3249            k
3250        });
3251        // br-r37-c1-s8dj1: captured BEFORE the insert, so the pair-index patch
3252        // below can tell a new bucket from an added parallel key without paying
3253        // a second hash of the pair.
3254        let edges_len_before = self.edges.len();
3255        let mut changed;
3256        let edge_attr_count = {
3257            let edge_bucket = self.edges.entry(edge_key.clone()).or_default();
3258            let is_new = !edge_bucket.contains_key(&key);
3259            if is_new {
3260                self.edge_count += 1;
3261            }
3262            changed = is_new;
3263            let edge_attrs = edge_bucket.entry(key).or_default();
3264            if edge_attrs.is_empty() {
3265                // br-r37-c1-jc9e4: a fresh keyed bucket TAKES the caller's map
3266                // whole. Same lever and same byte-identity argument as the
3267                // `Graph` sibling; `attrs` here is the function's own owned
3268                // parameter, so the move is unambiguous.
3269                if !attrs.is_empty() {
3270                    changed = true;
3271                }
3272                *edge_attrs = attrs;
3273            } else {
3274                if !attrs.is_empty()
3275                    && attrs
3276                        .iter()
3277                        .any(|(attr_key, value)| edge_attrs.get(attr_key) != Some(value))
3278                {
3279                    changed = true;
3280                }
3281                edge_attrs.extend(attrs);
3282            }
3283            edge_bucket.len()
3284        };
3285
3286        self.successors
3287            .entry(source.clone())
3288            .or_default()
3289            .entry(target.clone())
3290            .or_default()
3291            .insert(key);
3292        self.predecessors
3293            .entry(target.clone())
3294            .or_default()
3295            .entry(source.clone())
3296            .or_default()
3297            .insert(key);
3298        if changed {
3299            self.revision = self.revision.saturating_add(1);
3300        }
3301        // br-r37-c1-s8dj1: `insert` APPENDS, so a bucket created by this call
3302        // sits at the LAST position and no existing position moved.
3303        let new_pair_position = if self.edges.len() > edges_len_before {
3304            Some(self.edges.len() - 1)
3305        } else {
3306            None
3307        };
3308        self.pair_index_note_edge_added(previous_revision, &source, &target, new_pair_position);
3309
3310        self.record_decision(
3311            "add_edge",
3312            incompatibility_probability,
3313            unknown_feature,
3314            vec![
3315                EvidenceTerm {
3316                    signal: "edge_key".into(),
3317                    observed_value: key.to_string().into(),
3318                    log_likelihood_ratio: -2.0,
3319                },
3320                EvidenceTerm {
3321                    signal: "edge_attr_count".into(),
3322                    observed_value: count_evidence(edge_attr_count),
3323                    log_likelihood_ratio: -2.0,
3324                },
3325                EvidenceTerm {
3326                    signal: "source_autocreated".into(),
3327                    observed_value: bool_evidence(source_autocreated),
3328                    log_likelihood_ratio: -1.25,
3329                },
3330                EvidenceTerm {
3331                    signal: "target_autocreated".into(),
3332                    observed_value: bool_evidence(target_autocreated),
3333                    log_likelihood_ratio: -1.25,
3334                },
3335            ],
3336        );
3337
3338        Ok(key)
3339    }
3340
3341    /// br-r37-c1-sjf4t: overwrite the attribute map for an existing
3342    /// (source, target, key) edge. Returns whether the edge existed.
3343    pub fn replace_edge_attrs(
3344        &mut self,
3345        source: &str,
3346        target: &str,
3347        key: usize,
3348        attrs: AttrMap,
3349    ) -> bool {
3350        let edge_key = DirectedEdgeKey::new(source, target);
3351        if let Some(bucket) = self.edges.get_mut(&edge_key)
3352            && let Some(slot) = bucket.get_mut(&key)
3353        {
3354            if *slot != attrs {
3355                *slot = attrs;
3356                self.revision = self.revision.saturating_add(1);
3357            }
3358            return true;
3359        }
3360        false
3361    }
3362
3363    /// Overwrite one attribute value on an existing keyed edge. This is the
3364    /// hot path for algorithms that clone graph topology and patch one derived
3365    /// edge field without rebuilding every edge's full attribute map.
3366    pub fn set_edge_attr_value(
3367        &mut self,
3368        source: &str,
3369        target: &str,
3370        key: usize,
3371        attr_key: &str,
3372        value: CgseValue,
3373    ) -> bool {
3374        let edge_key = DirectedEdgeKey::new(source, target);
3375        if let Some(bucket) = self.edges.get_mut(&edge_key)
3376            && let Some(attrs) = bucket.get_mut(&key)
3377        {
3378            if attrs.get(attr_key) != Some(&value) {
3379                attrs.insert(attr_key.to_owned(), value);
3380                self.revision = self.revision.saturating_add(1);
3381            }
3382            return true;
3383        }
3384        false
3385    }
3386
3387    /// Overwrite one attribute on all keyed edges in `edges_ordered_borrowed`
3388    /// order. Returns false if the caller supplied the wrong value count.
3389    pub fn set_ordered_edge_attr_values<I>(&mut self, attr_key: &str, values: I) -> bool
3390    where
3391        I: IntoIterator<Item = CgseValue>,
3392    {
3393        let mut values = values.into_iter();
3394        let mut changed = false;
3395        let mut visited = 0usize;
3396
3397        for node in self.nodes.keys() {
3398            if let Some(neighbors) = self.successors.get(node) {
3399                for target in neighbors.keys() {
3400                    let pair = DirectedEdgeKeyRef::new(node, target);
3401                    if let Some(edge_bucket) = self.edges.get_mut(&pair) {
3402                        for attrs in edge_bucket.values_mut() {
3403                            let Some(value) = values.next() else {
3404                                return false;
3405                            };
3406                            if attrs.get(attr_key) != Some(&value) {
3407                                attrs.insert(attr_key.to_owned(), value);
3408                                changed = true;
3409                            }
3410                            visited = visited.saturating_add(1);
3411                        }
3412                    }
3413                }
3414            }
3415        }
3416
3417        if values.next().is_some() || visited != self.edge_count {
3418            return false;
3419        }
3420        if changed {
3421            self.revision = self.revision.saturating_add(1);
3422        }
3423        true
3424    }
3425
3426    /// br-r37-c1-sjf4t: overwrite the attribute map for an existing node.
3427    pub fn replace_node_attrs(&mut self, node: &str, attrs: AttrMap) -> bool {
3428        if let Some(slot) = self.nodes.get_mut(node) {
3429            if *slot != attrs {
3430                *slot = attrs;
3431                self.revision = self.revision.saturating_add(1);
3432            }
3433            true
3434        } else {
3435            false
3436        }
3437    }
3438
3439    pub fn remove_edge(&mut self, source: &str, target: &str, key: Option<usize>) -> bool {
3440        // br-r37-c1-s8dj1: revision at entry, for the pair-index patch below.
3441        let previous_revision = self.revision;
3442        let edge_key = DirectedEdgeKeyRef::new(source, target);
3443        let removal_key = key.or_else(|| {
3444            self.edges
3445                .get(&edge_key)
3446                .and_then(|edge_bucket| edge_bucket.keys().next_back().copied())
3447        });
3448
3449        let Some(removal_key) = removal_key else {
3450            return false;
3451        };
3452
3453        let removed = self
3454            .edges
3455            .get_mut(&edge_key)
3456            .is_some_and(|edge_bucket| edge_bucket.shift_remove(&removal_key).is_some());
3457        if !removed {
3458            return false;
3459        }
3460
3461        self.edge_count -= 1;
3462
3463        let should_drop_bucket = self.edges.get(&edge_key).is_some_and(IndexMap::is_empty);
3464        // br-r37-c1-s8dj1: the position the bucket occupies BEFORE swap_remove
3465        // frees it. Resolved only when a live pair index will actually use it.
3466        let dropped_position =
3467            if should_drop_bucket && self.edge_pair_index_is_live(previous_revision) {
3468                self.edges.get_index_of(&edge_key)
3469            } else {
3470                None
3471            };
3472        if should_drop_bucket {
3473            // br-r37-c1-vbwpl (cc): swap_remove (O(1)) on the OUTER node-pair map
3474            // — its storage order is never observed (edges_ordered walks
3475            // successors and looks pairs up by key; remove_node already
3476            // swap_removes this same map). The inner bucket above keeps
3477            // shift_remove because per-pair key order IS observed. Turns
3478            // remove_edges_from from O(k*pairs) into O(k).
3479            self.edges.swap_remove(&edge_key);
3480        }
3481
3482        self.remove_successor_key(source, target, removal_key);
3483        self.remove_predecessor_key(target, source, removal_key);
3484        self.revision = self.revision.saturating_add(1);
3485        self.pair_index_note_edge_removed(previous_revision, source, target, dropped_position);
3486        true
3487    }
3488
3489    pub fn clear_edges(&mut self) {
3490        if self.edge_count == 0 {
3491            return;
3492        }
3493
3494        self.edges.clear();
3495        for row in self.successors.values_mut() {
3496            row.clear();
3497        }
3498        for row in self.predecessors.values_mut() {
3499            row.clear();
3500        }
3501        self.edge_count = 0;
3502        self.revision = self.revision.saturating_add(1);
3503    }
3504
3505    pub fn remove_node(&mut self, node: &str) -> bool {
3506        if !self.nodes.contains_key(node) {
3507            return false;
3508        }
3509
3510        // br-r37-c1-p6bxu: drop each incident edge bucket with O(1) `swap_remove`
3511        // (the `edges` IndexMap order is never observed externally — every public
3512        // consumer reads via `edges_ordered`, which walks node->successor order;
3513        // no internal consumer iterates the map order). Out-edges are known from
3514        // `successors`, in-edges from `predecessors`; a self-loop's (node,node)
3515        // bucket appears in both but `swap_remove` returns it only once, so it is
3516        // counted exactly once. Removal is O(degree) instead of the
3517        // O(|distinct pairs|) `retain` scan — matching nx.
3518        let mut removed_count = 0usize;
3519        if let Some(succs) = self.successors.get(node) {
3520            let targets: Vec<String> = succs.keys().cloned().collect();
3521            for target in targets {
3522                if target != node
3523                    && let Some(preds) = self.predecessors.get_mut(&target)
3524                {
3525                    preds.shift_remove(node);
3526                }
3527                if let Some(bucket) = self
3528                    .edges
3529                    .swap_remove(&DirectedEdgeKeyRef::new(node, &target))
3530                {
3531                    removed_count += bucket.len();
3532                }
3533            }
3534        }
3535        if let Some(preds) = self.predecessors.get(node) {
3536            let sources: Vec<String> = preds.keys().cloned().collect();
3537            for source in sources {
3538                if source != node
3539                    && let Some(succs) = self.successors.get_mut(&source)
3540                {
3541                    succs.shift_remove(node);
3542                }
3543                if let Some(bucket) = self
3544                    .edges
3545                    .swap_remove(&DirectedEdgeKeyRef::new(&source, node))
3546                {
3547                    removed_count += bucket.len();
3548                }
3549            }
3550        }
3551        self.edge_count -= removed_count;
3552
3553        self.successors.shift_remove(node);
3554        self.predecessors.shift_remove(node);
3555        self.nodes.shift_remove(node);
3556        self.revision = self.revision.saturating_add(1);
3557        true
3558    }
3559
3560    /// br-r37-c1-mgrnf: batch node removal — amortised analogue of `remove_node`.
3561    /// `MultiDiGraph::remove_node` pays THREE O(|V|) `shift_remove`s (successors,
3562    /// predecessors, nodes) per call, so a caller loop of `k` removals was
3563    /// O(k·|V|). This does each pass ONCE via `retain` — O(|V|+|E|) total, matching
3564    /// the simple `DiGraph::remove_nodes_from` batch. Returns
3565    /// `(removed_node_count, removed_edge_instance_count)`. The `revision` bump
3566    /// invalidates `csr_cache` exactly as repeated `remove_node` would.
3567    pub fn remove_nodes_from<'a, I>(&mut self, nodes: I) -> (usize, usize)
3568    where
3569        I: IntoIterator<Item = &'a str>,
3570    {
3571        let remove_set: rustc_hash::FxHashSet<&str> = nodes
3572            .into_iter()
3573            .filter(|node| self.nodes.contains_key(*node))
3574            .collect();
3575        if remove_set.is_empty() {
3576            return (0, 0);
3577        }
3578
3579        let old_node_count = self.nodes.len();
3580        let old_edge_count = self.edge_count;
3581
3582        // br-r37-c1-mgrnf-incident: SMALL-fraction fast path — walk only the removed
3583        // nodes' out/in adjacency (like `remove_node`), pruning the removed node from
3584        // each surviving neighbour's opposite row and dropping incident edge buckets
3585        // O(1), then compact the three outer maps ONCE. O(|V| + sum_removed_degrees)
3586        // vs the whole-graph O(|V|+|E|) retain (removing 10 nodes from a 2000/10000
3587        // graph was ~100x nx). Large removals fall through (repeated hub shift_remove).
3588        let mut removed_instances = 0usize;
3589        if remove_set.len().saturating_mul(4) <= old_node_count {
3590            for &rn in &remove_set {
3591                // Out-edges: rn -> target. Drop rn from a surviving target's preds.
3592                let targets: Vec<String> = match self.successors.get(rn) {
3593                    Some(row) => row.keys().cloned().collect(),
3594                    None => Vec::new(),
3595                };
3596                for t in &targets {
3597                    if t.as_str() != rn
3598                        && !remove_set.contains(t.as_str())
3599                        && let Some(preds) = self.predecessors.get_mut(t)
3600                    {
3601                        preds.shift_remove(rn);
3602                    }
3603                    if let Some(bucket) = self.edges.swap_remove(&DirectedEdgeKeyRef::new(rn, t)) {
3604                        removed_instances += bucket.len();
3605                    }
3606                }
3607                // In-edges: source -> rn. Drop rn from a surviving source's succs.
3608                let sources: Vec<String> = match self.predecessors.get(rn) {
3609                    Some(row) => row.keys().cloned().collect(),
3610                    None => Vec::new(),
3611                };
3612                for s in &sources {
3613                    if s.as_str() != rn
3614                        && !remove_set.contains(s.as_str())
3615                        && let Some(succs) = self.successors.get_mut(s)
3616                    {
3617                        succs.shift_remove(rn);
3618                    }
3619                    // Self-loop (rn,rn) already dropped in the out-edges pass, so
3620                    // swap_remove returns None here — no double count.
3621                    if let Some(bucket) = self.edges.swap_remove(&DirectedEdgeKeyRef::new(s, rn)) {
3622                        removed_instances += bucket.len();
3623                    }
3624                }
3625            }
3626            self.edge_count -= removed_instances;
3627            self.successors
3628                .retain(|node, _| !remove_set.contains(node.as_str()));
3629            self.predecessors
3630                .retain(|node, _| !remove_set.contains(node.as_str()));
3631            self.nodes
3632                .retain(|node, _| !remove_set.contains(node.as_str()));
3633            let removed_nodes = old_node_count - self.nodes.len();
3634            let removed_edges = old_edge_count - self.edge_count;
3635            self.revision = self
3636                .revision
3637                .saturating_add(u64::try_from(removed_nodes).unwrap_or(u64::MAX));
3638            return (removed_nodes, removed_edges);
3639        }
3640
3641        // Drop every edge bucket incident to a removed node (source OR target) in
3642        // one pass, tallying parallel-edge instances so `edge_count` stays exact.
3643        // `edges` map order is unobserved externally (consumers walk `edges_ordered`
3644        // = successor order), so `retain` is order-safe — same rationale as
3645        // `remove_node`.
3646        self.edges.retain(|key, bucket| {
3647            let keep = !remove_set.contains(key.source.as_str())
3648                && !remove_set.contains(key.target.as_str());
3649            if !keep {
3650                removed_instances += bucket.len();
3651            }
3652            keep
3653        });
3654        self.edge_count -= removed_instances;
3655
3656        // successors + predecessors: drop the removed nodes' own rows, then prune
3657        // references to removed nodes from every surviving row. Order-preserving
3658        // `retain`, so byte-identical to repeated `remove_node`.
3659        self.successors
3660            .retain(|node, _| !remove_set.contains(node.as_str()));
3661        for row in self.successors.values_mut() {
3662            row.retain(|target, _| !remove_set.contains(target.as_str()));
3663        }
3664        self.predecessors
3665            .retain(|node, _| !remove_set.contains(node.as_str()));
3666        for row in self.predecessors.values_mut() {
3667            row.retain(|source, _| !remove_set.contains(source.as_str()));
3668        }
3669        self.nodes
3670            .retain(|node, _| !remove_set.contains(node.as_str()));
3671
3672        let removed_nodes = old_node_count - self.nodes.len();
3673        let removed_edges = old_edge_count - self.edge_count;
3674        self.revision = self
3675            .revision
3676            .saturating_add(u64::try_from(removed_nodes).unwrap_or(u64::MAX));
3677        (removed_nodes, removed_edges)
3678    }
3679
3680    #[must_use]
3681    pub fn edges_ordered(&self) -> Vec<MultiDiEdgeSnapshot> {
3682        let mut ordered = Vec::with_capacity(self.edge_count());
3683
3684        for node in self.nodes.keys() {
3685            if let Some(neighbors) = self.successors.get(node) {
3686                for target in neighbors.keys() {
3687                    let pair = DirectedEdgeKey::new(node, target);
3688                    if let Some(edge_bucket) = self.edges.get(&pair) {
3689                        for (key, attrs) in edge_bucket {
3690                            ordered.push(MultiDiEdgeSnapshot {
3691                                source: node.clone(),
3692                                target: target.clone(),
3693                                key: *key,
3694                                attrs: attrs.clone(),
3695                            });
3696                        }
3697                    }
3698                }
3699            }
3700        }
3701
3702        ordered
3703    }
3704
3705    #[must_use]
3706    pub fn edges_ordered_borrowed(&self) -> Vec<(&str, &str, usize, &AttrMap)> {
3707        let mut ordered = Vec::with_capacity(self.edge_count());
3708
3709        for node in self.nodes.keys() {
3710            if let Some(neighbors) = self.successors.get(node) {
3711                for target in neighbors.keys() {
3712                    let pair = DirectedEdgeKeyRef::new(node, target);
3713                    if let Some(edge_bucket) = self.edges.get(&pair) {
3714                        for (key, attrs) in edge_bucket {
3715                            ordered.push((node.as_str(), target.as_str(), *key, attrs));
3716                        }
3717                    }
3718                }
3719            }
3720        }
3721
3722        ordered
3723    }
3724
3725    /// br-r37-c1-wsize (cc): integer `size(weight)` from the store — directed
3726    /// multigraph twin of `Graph::weighted_size_int`. Each parallel directed edge
3727    /// is one bucket entry contributing its weight once (size halves degree's
3728    /// in+out double count). Returns `None` on any non-integer weight (caller uses
3729    /// the exact degree path); missing weight defaults to nx's int `1`.
3730    #[must_use]
3731    pub fn weighted_size_int(&self, weight: &str) -> Option<i128> {
3732        let mut total: i128 = 0;
3733        for bucket in self.edges.values() {
3734            for attrs in bucket.values() {
3735                let value = match attrs.get(weight) {
3736                    Some(CgseValue::Int(v)) => i128::from(*v),
3737                    Some(_) => return None,
3738                    None => 1,
3739                };
3740                total = total.checked_add(value)?;
3741            }
3742        }
3743        Some(total)
3744    }
3745
3746    pub fn try_for_each_edge_ordered_borrowed<E>(
3747        &self,
3748        mut visit: impl FnMut(&str, &str, usize, &AttrMap) -> Result<(), E>,
3749    ) -> Result<(), E> {
3750        for node in self.nodes.keys() {
3751            if let Some(neighbors) = self.successors.get(node) {
3752                for target in neighbors.keys() {
3753                    let pair = DirectedEdgeKeyRef::new(node, target);
3754                    if let Some(edge_bucket) = self.edges.get(&pair) {
3755                        for (key, attrs) in edge_bucket {
3756                            visit(node.as_str(), target.as_str(), *key, attrs)?;
3757                        }
3758                    }
3759                }
3760            }
3761        }
3762
3763        Ok(())
3764    }
3765
3766    pub fn try_for_each_indexed_edge_ordered_borrowed<E>(
3767        &self,
3768        mut visit: impl FnMut(usize, usize, &str, &str, usize, &AttrMap) -> Result<(), E>,
3769    ) -> Result<(), E> {
3770        for (source_index, node) in self.nodes.keys().enumerate() {
3771            if let Some(neighbors) = self.successors.get(node) {
3772                for target in neighbors.keys() {
3773                    let Some(target_index) = self.nodes.get_index_of(target.as_str()) else {
3774                        continue;
3775                    };
3776                    let pair = DirectedEdgeKeyRef::new(node, target);
3777                    if let Some(edge_bucket) = self.edges.get(&pair) {
3778                        for (key, attrs) in edge_bucket {
3779                            visit(
3780                                source_index,
3781                                target_index,
3782                                node.as_str(),
3783                                target.as_str(),
3784                                *key,
3785                                attrs,
3786                            )?;
3787                        }
3788                    }
3789                }
3790            }
3791        }
3792
3793        Ok(())
3794    }
3795
3796    /// Return a reversed copy with NetworkX-compatible node/edge row order.
3797    ///
3798    /// This is the directed-multigraph sibling of `DiGraph::reversed`, but it
3799    /// cannot simply swap `successors` and `predecessors`: NetworkX materializes
3800    /// `MultiDiGraph.reverse(copy=True)` by walking `G.edges(keys=True, data=True)`
3801    /// in source-node order and adding `(v, u, key)` to the result. Predecessor
3802    /// rows may have been formed by historical edge insertion order, which is not
3803    /// always the same as that source-major edge stream. Rebuild the two row maps
3804    /// from the observed edge stream while avoiding the expensive per-edge
3805    /// `add_edge` policy path.
3806    #[must_use]
3807    pub fn reversed(&self) -> Self {
3808        let mut successors: crate::FxIndexMap<
3809            String,
3810            crate::FxIndexMap<String, crate::FxIndexSet<usize>>,
3811        > = crate::FxIndexMap::with_capacity_and_hasher(
3812            self.nodes.len(),
3813            rustc_hash::FxBuildHasher,
3814        );
3815        let mut predecessors: crate::FxIndexMap<
3816            String,
3817            crate::FxIndexMap<String, crate::FxIndexSet<usize>>,
3818        > = crate::FxIndexMap::with_capacity_and_hasher(
3819            self.nodes.len(),
3820            rustc_hash::FxBuildHasher,
3821        );
3822        for node in self.nodes.keys() {
3823            successors.insert(node.clone(), crate::FxIndexMap::default());
3824            predecessors.insert(node.clone(), crate::FxIndexMap::default());
3825        }
3826
3827        let mut edges: crate::FxIndexMap<DirectedEdgeKey, IndexMap<usize, AttrMap>> =
3828            crate::FxIndexMap::with_capacity_and_hasher(
3829                self.edges.len(),
3830                rustc_hash::FxBuildHasher,
3831            );
3832        let mut edge_count = 0usize;
3833        for source in self.nodes.keys() {
3834            let Some(neighbors) = self.successors.get(source) else {
3835                continue;
3836            };
3837            for target in neighbors.keys() {
3838                let pair = DirectedEdgeKeyRef::new(source, target);
3839                let Some(edge_bucket) = self.edges.get(&pair) else {
3840                    continue;
3841                };
3842                let reversed_pair = DirectedEdgeKey::new(target, source);
3843                let reversed_bucket = edges.entry(reversed_pair).or_default();
3844                let reversed_succ_keys = successors
3845                    .get_mut(target.as_str())
3846                    .expect("target row exists")
3847                    .entry(source.clone())
3848                    .or_default();
3849                let reversed_pred_keys = predecessors
3850                    .get_mut(source.as_str())
3851                    .expect("source row exists")
3852                    .entry(target.clone())
3853                    .or_default();
3854                for (key, attrs) in edge_bucket {
3855                    reversed_bucket.insert(*key, attrs.clone());
3856                    reversed_succ_keys.insert(*key);
3857                    reversed_pred_keys.insert(*key);
3858                    edge_count += 1;
3859                }
3860            }
3861        }
3862
3863        Self {
3864            mode: self.mode,
3865            revision: 0,
3866            nodes: self.nodes.clone(),
3867            successors,
3868            predecessors,
3869            edges,
3870            runtime_policy: self.runtime_policy.clone(),
3871            edge_count,
3872            csr_cache: std::sync::Arc::default(),
3873            edge_pair_index: EdgePairIndex::default(),
3874        }
3875    }
3876
3877    /// Out-edges of a single source node in nx adjacency order
3878    /// (successors[node].keys() then key-bucket order), borrowing attrs.
3879    ///
3880    /// Equivalent to ``edges_ordered_borrowed()`` filtered to one source,
3881    /// but O(out-deg(node)) instead of O(E) — and it clones nothing. Used
3882    /// by ``MultiDiGraph.edges(nbunch)`` to avoid scanning/cloning every
3883    /// edge when only a handful of sources are requested.
3884    #[must_use]
3885    pub fn out_edges_ordered_borrowed<'a>(
3886        &'a self,
3887        node: &str,
3888    ) -> Vec<(&'a str, &'a str, usize, &'a AttrMap)> {
3889        let mut ordered = Vec::new();
3890        if let Some((src, neighbors)) = self.successors.get_key_value(node) {
3891            for target in neighbors.keys() {
3892                let pair = DirectedEdgeKeyRef::new(src, target);
3893                if let Some(edge_bucket) = self.edges.get(&pair) {
3894                    for (key, attrs) in edge_bucket {
3895                        ordered.push((src.as_str(), target.as_str(), *key, attrs));
3896                    }
3897                }
3898            }
3899        }
3900        ordered
3901    }
3902
3903    #[must_use]
3904    pub fn snapshot(&self) -> MultiDiGraphSnapshot {
3905        let node_attrs: std::collections::BTreeMap<String, AttrMap> = self
3906            .nodes
3907            .iter()
3908            .filter(|(_, attrs)| !attrs.is_empty())
3909            .map(|(name, attrs)| (name.clone(), attrs.clone()))
3910            .collect();
3911        MultiDiGraphSnapshot {
3912            mode: self.mode,
3913            nodes: self.nodes.keys().cloned().collect(),
3914            node_attrs,
3915            edges: self.edges_ordered(),
3916        }
3917    }
3918
3919    fn remove_successor_key(&mut self, source: &str, target: &str, key: usize) {
3920        let mut drop_neighbor = false;
3921        if let Some(neighbors) = self.successors.get_mut(source)
3922            && let Some(keys) = neighbors.get_mut(target)
3923        {
3924            keys.shift_remove(&key);
3925            drop_neighbor = keys.is_empty();
3926        }
3927        if drop_neighbor && let Some(neighbors) = self.successors.get_mut(source) {
3928            neighbors.shift_remove(target);
3929        }
3930    }
3931
3932    fn remove_predecessor_key(&mut self, target: &str, source: &str, key: usize) {
3933        let mut drop_neighbor = false;
3934        if let Some(neighbors) = self.predecessors.get_mut(target)
3935            && let Some(keys) = neighbors.get_mut(source)
3936        {
3937            keys.shift_remove(&key);
3938            drop_neighbor = keys.is_empty();
3939        }
3940        if drop_neighbor && let Some(neighbors) = self.predecessors.get_mut(target) {
3941            neighbors.shift_remove(source);
3942        }
3943    }
3944
3945    fn record_decision(
3946        &mut self,
3947        operation: &'static str,
3948        incompatibility_probability: f64,
3949        unknown_incompatible_feature: bool,
3950        evidence: Vec<EvidenceTerm>,
3951    ) -> DecisionAction {
3952        let action = self
3953            .runtime_policy
3954            .action_for(incompatibility_probability, unknown_incompatible_feature);
3955        self.runtime_policy.record(
3956            operation,
3957            action,
3958            incompatibility_probability,
3959            "argmin expected loss over {allow,full_validate,fail_closed}",
3960            evidence,
3961        );
3962        action
3963    }
3964}
3965
3966// ===========================================================================
3967// Tests
3968// ===========================================================================
3969
3970#[cfg(test)]
3971mod tests {
3972    use super::*;
3973    use fnx_runtime::{
3974        CgseValue, CompatibilityMode, DecisionAction, DecisionRecord, RuntimePolicy,
3975    };
3976    use proptest::prelude::*;
3977
3978    fn node_name(id: u8) -> String {
3979        format!("n{}", id % 8)
3980    }
3981
3982    fn single_attr(key: &str, value: &str) -> AttrMap {
3983        let mut attrs = AttrMap::new();
3984        attrs.insert(key.to_owned(), CgseValue::from(value));
3985        attrs
3986    }
3987
3988    fn assert_runtime_policy_preserved(source: &RuntimePolicy, result: &RuntimePolicy) {
3989        assert_eq!(result.mode(), source.mode());
3990        assert_eq!(result.allowlist(), source.allowlist());
3991        assert_eq!(result.loss_matrix(), source.loss_matrix());
3992        assert!(result.posterior().observation_count >= source.posterior().observation_count);
3993        assert!(
3994            result
3995                .decision_log()
3996                .records()
3997                .starts_with(source.decision_log().records())
3998        );
3999    }
4000
4001    // -- Invariant checker --------------------------------------------------
4002
4003    fn assert_digraph_core_invariants(g: &DiGraph) {
4004        // Every edge in the edge map must be reflected in successors/predecessors.
4005        for (&(source_idx, target_idx), _attrs) in &g.edges {
4006            let source = g
4007                .nodes
4008                .get_index(source_idx)
4009                .map(|(name, _)| name.as_str())
4010                .expect("edge source index should be a node");
4011            let target = g
4012                .nodes
4013                .get_index(target_idx)
4014                .map(|(name, _)| name.as_str())
4015                .expect("edge target index should be a node");
4016            assert!(
4017                g.has_node(source),
4018                "edge source {} should be a node",
4019                source
4020            );
4021            assert!(
4022                g.has_node(target),
4023                "edge target {} should be a node",
4024                target
4025            );
4026            let succs = g
4027                .successors(source)
4028                .expect("source should have successors bucket");
4029            assert!(
4030                succs.contains(&target),
4031                "{} should be in successors of {}",
4032                target,
4033                source
4034            );
4035            let preds = g
4036                .predecessors(target)
4037                .expect("target should have predecessors bucket");
4038            assert!(
4039                preds.contains(&source),
4040                "{} should be in predecessors of {}",
4041                source,
4042                target
4043            );
4044        }
4045
4046        // Every successor entry should have a corresponding edge.
4047        let mut edge_count_from_adj = 0usize;
4048        for node in g.nodes_ordered() {
4049            let succs = g
4050                .successors(node)
4051                .expect("node should have successors bucket");
4052            for s in &succs {
4053                assert!(
4054                    g.has_edge(node, s),
4055                    "successor {} of {} should have directed edge",
4056                    s,
4057                    node
4058                );
4059                edge_count_from_adj += 1;
4060            }
4061            // Every predecessor entry should have a corresponding edge.
4062            let preds = g
4063                .predecessors(node)
4064                .expect("node should have predecessors bucket");
4065            for p in &preds {
4066                assert!(
4067                    g.has_edge(p, node),
4068                    "predecessor {} of {} should have directed edge",
4069                    p,
4070                    node
4071                );
4072            }
4073        }
4074        assert_eq!(g.edge_count(), edge_count_from_adj);
4075    }
4076
4077    #[test]
4078    fn add_node_with_attrs_reports_change_on_existing_node() {
4079        let mut graph = DiGraph::strict();
4080        assert!(graph.add_node_with_attrs("a", single_attr("color", "red")));
4081        let r1 = graph.revision();
4082        assert!(graph.add_node_with_attrs("a", single_attr("color", "blue")));
4083        let r2 = graph.revision();
4084        assert!(r2 > r1);
4085        let expected = CgseValue::from("blue");
4086        assert_eq!(graph.node_attrs("a").unwrap().get("color"), Some(&expected));
4087    }
4088
4089    #[test]
4090    fn multidigraph_add_node_with_attrs_reports_change_on_existing_node() {
4091        let mut graph = MultiDiGraph::strict();
4092        assert!(graph.add_node_with_attrs("a", single_attr("color", "red")));
4093        let r1 = graph.revision();
4094        assert!(graph.add_node_with_attrs("a", single_attr("color", "blue")));
4095        let r2 = graph.revision();
4096        assert!(r2 > r1);
4097        let expected = CgseValue::from("blue");
4098        assert_eq!(graph.node_attrs("a").unwrap().get("color"), Some(&expected));
4099    }
4100
4101    fn assert_multidigraph_core_invariants(g: &MultiDiGraph) {
4102        let mut edge_instances = std::collections::BTreeSet::new();
4103        for node in g.nodes_ordered() {
4104            let successors = g
4105                .successors(node)
4106                .expect("multidigraph nodes should always have successor buckets");
4107            for target in successors {
4108                assert!(g.has_node(target));
4109                assert!(g.has_edge(node, target));
4110                let preds = g
4111                    .predecessors(target)
4112                    .expect("target should always have predecessor buckets");
4113                assert!(preds.contains(&node));
4114                let keys = g
4115                    .edge_keys(node, target)
4116                    .expect("parallel directed edge bucket should exist");
4117                for key in keys {
4118                    edge_instances.insert((node.to_owned(), target.to_owned(), key));
4119                }
4120            }
4121        }
4122        assert_eq!(g.edge_count(), edge_instances.len());
4123    }
4124
4125    fn assert_decision_record_schema(record: &DecisionRecord, expected_mode: CompatibilityMode) {
4126        assert!(record.ts_unix_ms > 0);
4127        assert!(!record.operation.trim().is_empty());
4128        assert_eq!(record.mode, expected_mode);
4129        assert!((0.0..=1.0).contains(&record.incompatibility_probability));
4130        assert!(!record.rationale.trim().is_empty());
4131        assert!(!record.evidence.is_empty());
4132        for term in &record.evidence {
4133            assert!(!term.signal.trim().is_empty());
4134            assert!(!term.observed_value.trim().is_empty());
4135        }
4136    }
4137
4138    // -- Basic operations ---------------------------------------------------
4139
4140    #[test]
4141    fn add_directed_edge_autocreates_nodes() {
4142        let mut g = DiGraph::strict();
4143        g.add_edge("a", "b").unwrap();
4144
4145        assert_eq!(g.node_count(), 2);
4146        assert_eq!(g.edge_count(), 1);
4147        assert!(g.has_edge("a", "b"));
4148        assert!(!g.has_edge("b", "a")); // directed: reverse does NOT exist
4149        assert_digraph_core_invariants(&g);
4150    }
4151
4152    #[test]
4153    fn directed_edge_asymmetry() {
4154        let mut g = DiGraph::strict();
4155        g.add_edge("a", "b").unwrap();
4156        g.add_edge("b", "a").unwrap();
4157
4158        assert_eq!(g.edge_count(), 2);
4159        assert!(g.has_edge("a", "b"));
4160        assert!(g.has_edge("b", "a"));
4161        assert_digraph_core_invariants(&g);
4162    }
4163
4164    #[test]
4165    fn successors_and_predecessors() {
4166        let mut g = DiGraph::strict();
4167        g.add_edge("a", "b").unwrap();
4168        g.add_edge("a", "c").unwrap();
4169        g.add_edge("d", "a").unwrap();
4170
4171        assert_eq!(g.successors("a"), Some(vec!["b", "c"]));
4172        assert_eq!(g.predecessors("a"), Some(vec!["d"]));
4173        assert_eq!(g.out_degree("a"), 2);
4174        assert_eq!(g.in_degree("a"), 1);
4175        assert_eq!(g.degree("a"), 3);
4176        assert_digraph_core_invariants(&g);
4177    }
4178
4179    #[test]
4180    fn degree_single_lookup_preserves_missing_and_self_loop_semantics() {
4181        let mut g = DiGraph::strict();
4182        g.add_edge("a", "a").expect("self-loop add should succeed");
4183        g.add_edge("a", "b").expect("edge add should succeed");
4184        g.add_edge("c", "a").expect("edge add should succeed");
4185
4186        for node in ["a", "b", "c", "missing"] {
4187            assert_eq!(g.degree(node), g.in_degree(node) + g.out_degree(node));
4188        }
4189        assert_eq!(g.degree("a"), 4);
4190        assert_eq!(g.degree("missing"), 0);
4191    }
4192
4193    #[test]
4194    fn neighbors_returns_successors() {
4195        let mut g = DiGraph::strict();
4196        g.add_edge("a", "b").unwrap();
4197        g.add_edge("c", "a").unwrap();
4198
4199        // neighbors() = successors() per NetworkX convention
4200        assert_eq!(g.neighbors("a"), Some(vec!["b"]));
4201        assert_digraph_core_invariants(&g);
4202    }
4203
4204    #[test]
4205    fn in_edges_and_out_edges() {
4206        let mut g = DiGraph::strict();
4207        g.add_edge("x", "y").unwrap();
4208        g.add_edge("z", "y").unwrap();
4209        g.add_edge("y", "w").unwrap();
4210
4211        assert_eq!(g.out_edges("y"), vec![("y", "w")]);
4212        assert_eq!(g.in_edges("y"), vec![("x", "y"), ("z", "y")]);
4213        assert_digraph_core_invariants(&g);
4214    }
4215
4216    #[test]
4217    fn remove_directed_edge() {
4218        let mut g = DiGraph::strict();
4219        g.add_edge("a", "b").unwrap();
4220        g.add_edge("b", "a").unwrap();
4221
4222        assert!(g.remove_edge("a", "b"));
4223        assert!(!g.has_edge("a", "b"));
4224        assert!(g.has_edge("b", "a")); // reverse still exists
4225        assert_eq!(g.edge_count(), 1);
4226        assert_digraph_core_invariants(&g);
4227    }
4228
4229    #[test]
4230    fn remove_node_removes_all_incident_directed_edges() {
4231        let mut g = DiGraph::strict();
4232        g.add_edge("a", "b").unwrap();
4233        g.add_edge("b", "c").unwrap();
4234        g.add_edge("c", "a").unwrap();
4235        g.add_edge("d", "b").unwrap();
4236
4237        assert!(g.remove_node("b"));
4238        assert_eq!(g.node_count(), 3);
4239        assert!(!g.has_edge("a", "b"));
4240        assert!(!g.has_edge("b", "c"));
4241        assert!(!g.has_edge("d", "b"));
4242        assert!(g.has_edge("c", "a")); // not incident to b
4243        assert_digraph_core_invariants(&g);
4244    }
4245
4246    #[test]
4247    fn multidigraph_counts_parallel_selfloops_only() {
4248        let mut g = MultiDiGraph::strict();
4249        let _ = g.add_edge("a", "a").expect("self-loop add should succeed");
4250        let _ = g.add_edge("a", "a").expect("self-loop add should succeed");
4251        let _ = g.add_edge("b", "b").expect("self-loop add should succeed");
4252        let _ = g.add_edge("a", "b").expect("edge add should succeed");
4253        let _ = g.add_edge("b", "a").expect("edge add should succeed");
4254
4255        assert_eq!(g.number_of_selfloops(), 3);
4256        assert_multidigraph_core_invariants(&g);
4257    }
4258
4259    // br-r37-c1-p6bxu: A/B substrate bench for MultiDiGraph::remove_node
4260    // (O(degree) swap_remove vs the old O(|E|) retain). Ignored by default; run
4261    // with `cargo test -p fnx-classes --release ab_bench_multidigraph_remove_node
4262    // -- --ignored --nocapture`. Also asserts byte-exact parity (node_count,
4263    // edge_count, edges_ordered, incl. self-loops) between the two paths.
4264    #[test]
4265    #[ignore]
4266    fn ab_bench_multidigraph_remove_node() {
4267        use std::time::Instant;
4268        const N: usize = 1000;
4269        const M: usize = 8000;
4270        const ITERS: usize = 500;
4271        let build = || {
4272            let mut g = MultiDiGraph::new(CompatibilityMode::Strict);
4273            for i in 0..N {
4274                let _ = g.add_node(i.to_string());
4275            }
4276            let mut s: u64 = 0x9E3779B97F4A7C15;
4277            let mut next = || {
4278                s = s
4279                    .wrapping_mul(6364136223846793005)
4280                    .wrapping_add(1442695040888963407);
4281                (s >> 33) as usize % N
4282            };
4283            for _ in 0..M {
4284                let a = next();
4285                let b = next();
4286                let _ = g.add_edge(a.to_string(), b.to_string());
4287            }
4288            g
4289        };
4290
4291        let mut gnew = build();
4292        let victims: Vec<String> = gnew.nodes.keys().take(ITERS).cloned().collect();
4293        let t = Instant::now();
4294        for node in &victims {
4295            gnew.remove_node(node);
4296        }
4297        let new_t = t.elapsed();
4298
4299        // OLD path: full O(|E|) retain per removal.
4300        let mut gold = build();
4301        let t = Instant::now();
4302        for node in &victims {
4303            if !gold.nodes.contains_key(node) {
4304                continue;
4305            }
4306            if let Some(succs) = gold.successors.get(node) {
4307                let targets: Vec<String> = succs.keys().cloned().collect();
4308                for target in targets {
4309                    if target != *node
4310                        && let Some(preds) = gold.predecessors.get_mut(&target)
4311                    {
4312                        preds.shift_remove(node.as_str());
4313                    }
4314                }
4315            }
4316            if let Some(preds) = gold.predecessors.get(node) {
4317                let sources: Vec<String> = preds.keys().cloned().collect();
4318                for source in sources {
4319                    if source != *node
4320                        && let Some(succs) = gold.successors.get_mut(&source)
4321                    {
4322                        succs.shift_remove(node.as_str());
4323                    }
4324                }
4325            }
4326            let mut rc = 0usize;
4327            let nd = node.clone();
4328            gold.edges.retain(|k, bucket| {
4329                let keep = k.source != nd && k.target != nd;
4330                if !keep {
4331                    rc += bucket.len();
4332                }
4333                keep
4334            });
4335            gold.edge_count -= rc;
4336            gold.successors.shift_remove(node);
4337            gold.predecessors.shift_remove(node);
4338            gold.nodes.shift_remove(node);
4339        }
4340        let old_t = t.elapsed();
4341
4342        assert_eq!(gnew.node_count(), gold.node_count());
4343        assert_eq!(gnew.edge_count(), gold.edge_count());
4344        assert_eq!(gnew.edges_ordered(), gold.edges_ordered());
4345        eprintln!(
4346            "MultiDiGraph remove_node x{ITERS}: retain {old_t:?} -> swap_remove {new_t:?} = {:.2}x",
4347            old_t.as_secs_f64() / new_t.as_secs_f64()
4348        );
4349    }
4350
4351    #[test]
4352    fn self_loop_directed() {
4353        let mut g = DiGraph::strict();
4354        g.add_edge("a", "a").unwrap();
4355
4356        assert_eq!(g.edge_count(), 1);
4357        assert!(g.has_edge("a", "a"));
4358        assert_eq!(g.out_degree("a"), 1);
4359        assert_eq!(g.in_degree("a"), 1);
4360        assert_digraph_core_invariants(&g);
4361    }
4362
4363    #[test]
4364    fn edge_attrs_directed() {
4365        let mut g = DiGraph::strict();
4366        let mut attrs = AttrMap::new();
4367        attrs.insert("weight".to_owned(), "5".into());
4368        g.add_edge_with_attrs("a", "b", attrs).unwrap();
4369
4370        assert_eq!(
4371            g.edge_attrs("a", "b").unwrap().get("weight"),
4372            Some(&CgseValue::String("5".to_owned()))
4373        );
4374        assert!(g.edge_attrs("b", "a").is_none()); // reverse has no attrs
4375    }
4376
4377    #[test]
4378    fn extend_edges_unrecorded_preserves_direction_and_records_once() {
4379        let mut g = DiGraph::strict();
4380        g.add_node("a");
4381        g.add_node("b");
4382        g.add_node("c");
4383        let before = g.evidence_ledger().records().len();
4384
4385        let inserted = g.extend_edges_unrecorded([("a", "b"), ("b", "a"), ("a", "b")]);
4386
4387        assert_eq!(inserted, 2);
4388        assert_eq!(g.edge_count(), 2);
4389        assert!(g.has_edge("a", "b"));
4390        assert!(g.has_edge("b", "a"));
4391        assert!(!g.has_edge("a", "c"));
4392        let records = g.evidence_ledger().records();
4393        assert_eq!(records.len(), before + 1);
4394        assert_eq!(
4395            records.last().map(|record| record.operation.as_ref()),
4396            Some("extend_edges_unrecorded")
4397        );
4398    }
4399
4400    #[test]
4401    fn row_staged_attr_edges_preserve_orders_and_duplicate_merges() {
4402        let mut g = DiGraph::strict();
4403        let before = g.evidence_ledger().records().len();
4404
4405        let inserted = g.extend_prepared_edges_with_attrs_row_staged_unrecorded(
4406            ["b", "x", "a", "t"]
4407                .into_iter()
4408                .map(std::borrow::ToOwned::to_owned),
4409            vec![
4410                ("b".to_owned(), "x".to_owned(), single_attr("first", "bx")),
4411                ("a".to_owned(), "t".to_owned(), single_attr("first", "at")),
4412                ("b".to_owned(), "t".to_owned(), single_attr("first", "bt")),
4413                (
4414                    "a".to_owned(),
4415                    "t".to_owned(),
4416                    single_attr("second", "merge"),
4417                ),
4418                ("t".to_owned(), "t".to_owned(), single_attr("loop", "yes")),
4419            ],
4420        );
4421
4422        assert_eq!(inserted, 4);
4423        assert_eq!(g.nodes_ordered(), vec!["b", "x", "a", "t"]);
4424        assert_eq!(
4425            g.edges_ordered_borrowed()
4426                .into_iter()
4427                .map(|(u, v, _)| (u, v))
4428                .collect::<Vec<_>>(),
4429            vec![("b", "x"), ("b", "t"), ("a", "t"), ("t", "t")]
4430        );
4431        assert_eq!(
4432            g.edges_ordered_indices(),
4433            vec![(0, 1), (0, 3), (2, 3), (3, 3)]
4434        );
4435        assert_eq!(g.successors("b"), Some(vec!["x", "t"]));
4436        assert_eq!(g.predecessors("t"), Some(vec!["a", "b", "t"]));
4437        let merged = g.edge_attrs("a", "t").expect("merged edge should exist");
4438        assert_eq!(merged.get("first"), Some(&CgseValue::from("at")));
4439        assert_eq!(merged.get("second"), Some(&CgseValue::from("merge")));
4440        assert_digraph_core_invariants(&g);
4441        let records = g.evidence_ledger().records();
4442        assert_eq!(records.len(), before + 1);
4443        assert_eq!(
4444            records.last().map(|record| record.operation.as_ref()),
4445            Some("extend_edges_unrecorded")
4446        );
4447    }
4448
4449    #[test]
4450    fn repeated_edge_merges_attrs() {
4451        let mut g = DiGraph::strict();
4452        let mut first = AttrMap::new();
4453        first.insert("weight".to_owned(), "1".into());
4454        g.add_edge_with_attrs("a", "b", first).unwrap();
4455
4456        let mut second = AttrMap::new();
4457        second.insert("color".to_owned(), "red".into());
4458        g.add_edge_with_attrs("a", "b", second).unwrap();
4459
4460        assert_eq!(g.edge_count(), 1);
4461        let attrs = g.edge_attrs("a", "b").unwrap();
4462        assert_eq!(
4463            attrs.get("weight"),
4464            Some(&CgseValue::String("1".to_owned()))
4465        );
4466        assert_eq!(
4467            attrs.get("color"),
4468            Some(&CgseValue::String("red".to_owned()))
4469        );
4470    }
4471
4472    #[test]
4473    fn edges_ordered_preserves_direction() {
4474        let mut g = DiGraph::strict();
4475        g.add_edge("b", "a").unwrap();
4476        g.add_edge("a", "c").unwrap();
4477
4478        let pairs: Vec<(String, String)> = g
4479            .edges_ordered()
4480            .into_iter()
4481            .map(|e| (e.left, e.right))
4482            .collect();
4483        // b was added first as source, so b→a first, then a→c
4484        assert_eq!(
4485            pairs,
4486            vec![
4487                ("b".to_owned(), "a".to_owned()),
4488                ("a".to_owned(), "c".to_owned()),
4489            ]
4490        );
4491    }
4492
4493    #[test]
4494    fn type_identity() {
4495        let g = DiGraph::strict();
4496        assert!(g.is_directed());
4497        assert!(!g.is_multigraph());
4498    }
4499
4500    #[test]
4501    fn to_undirected_merges_edges() {
4502        let mut g = DiGraph::strict();
4503        g.add_edge("a", "b").unwrap();
4504        g.add_edge("b", "a").unwrap();
4505        g.add_edge("b", "c").unwrap();
4506
4507        let ug = g.to_undirected();
4508        assert_eq!(ug.node_count(), 3);
4509        assert_eq!(ug.edge_count(), 2); // (a,b) merged, plus (b,c)
4510        assert!(ug.has_edge("a", "b"));
4511        assert!(ug.has_edge("b", "a")); // undirected: same edge
4512        assert!(ug.has_edge("b", "c"));
4513    }
4514
4515    #[test]
4516    fn to_undirected_preserves_runtime_policy() {
4517        let mut g = DiGraph::hardened();
4518        g.add_edge("a", "b").unwrap();
4519        g.add_edge("b", "a").unwrap();
4520        let expected_policy = g.runtime_policy().clone();
4521
4522        let ug = g.to_undirected();
4523
4524        assert_runtime_policy_preserved(&expected_policy, ug.runtime_policy());
4525    }
4526
4527    /// br-r37-c1-toundirbatch: paired-interleaved median A/B for DiGraph::to_undirected —
4528    /// inline per-edge baseline vs the shipped batch, one binary / one worker with a NULL
4529    /// control. A complete digraph K100 (every ordered pair a->b) makes 9900 directed edges
4530    /// collapse to 4950 undirected, so HALF the add_edge_with_attrs calls are merge-dups and
4531    /// each paid a per-edge policy record. Byte-identity asserted first.
4532    /// `#[ignore]`; run with
4533    /// `cargo test --release -p fnx-classes --lib to_undirected_batch_ab -- --ignored --nocapture`.
4534    #[test]
4535    #[ignore = "measurement; run with --release --ignored --nocapture"]
4536    fn to_undirected_batch_ab() {
4537        use std::hint::black_box;
4538        use std::time::Instant;
4539
4540        let mut input = DiGraph::strict();
4541        for i in 0..200 {
4542            input.add_node(format!("{i}"));
4543        }
4544        for a in 0..200 {
4545            for b in 0..200 {
4546                if a != b {
4547                    input.add_edge(format!("{a}"), format!("{b}")).unwrap();
4548                }
4549            }
4550        }
4551
4552        let build = |g: &DiGraph, batch: bool| -> crate::Graph {
4553            let mut out = crate::Graph::with_runtime_policy(g.runtime_policy.clone());
4554            for (node, attrs) in &g.nodes {
4555                out.add_node_with_attrs(node.clone(), attrs.clone());
4556            }
4557            if batch {
4558                let edges: Vec<(String, String, AttrMap)> = g
4559                    .edges_ordered()
4560                    .into_iter()
4561                    .map(|snap| (snap.left, snap.right, snap.attrs))
4562                    .collect();
4563                let _ = out.extend_edges_with_attrs_unrecorded(edges);
4564            } else {
4565                for snap in g.edges_ordered() {
4566                    let _ = out.add_edge_with_attrs(snap.left, snap.right, snap.attrs);
4567                }
4568            }
4569            out
4570        };
4571
4572        let old = build(&input, false);
4573        let new = build(&input, true);
4574        assert_eq!(
4575            old.edges_ordered_borrowed(),
4576            new.edges_ordered_borrowed(),
4577            "batch to_undirected must equal the per-edge baseline"
4578        );
4579        assert_eq!(old.nodes_ordered(), new.nodes_ordered());
4580
4581        let time = |batch: bool| -> f64 {
4582            let t0 = Instant::now();
4583            black_box(build(&input, batch));
4584            t0.elapsed().as_secs_f64()
4585        };
4586        for _ in 0..3 {
4587            black_box(time(true));
4588            black_box(time(false));
4589        }
4590        let median = |v: &[f64]| {
4591            let mut s = v.to_vec();
4592            s.sort_by(|a, b| a.partial_cmp(b).unwrap());
4593            s[s.len() / 2]
4594        };
4595        let rounds = 61usize;
4596        let paired = |cand: bool, base_arm: bool| -> Vec<f64> {
4597            let mut v = Vec::with_capacity(rounds);
4598            for round in 0..rounds {
4599                let (tb, tc) = if round.is_multiple_of(2) {
4600                    let bt = time(base_arm);
4601                    let ct = time(cand);
4602                    (bt, ct)
4603                } else {
4604                    let ct = time(cand);
4605                    let bt = time(base_arm);
4606                    (bt, ct)
4607                };
4608                v.push(tb / tc);
4609            }
4610            v
4611        };
4612        let report = |name: &str, ratios: &[f64]| {
4613            let wins = ratios.iter().filter(|&&r| r > 1.0).count();
4614            let mut sorted = ratios.to_vec();
4615            sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
4616            println!(
4617                "TOUNDIR_BATCH_AB {name}: median={:.4}x win_rate={wins}/{rounds} \
4618                 p5_p95=[{:.4},{:.4}]",
4619                median(ratios),
4620                sorted[rounds * 5 / 100],
4621                sorted[rounds * 95 / 100],
4622            );
4623        };
4624        println!("TOUNDIR_BATCH_AB K200_complete rounds={rounds} (>1 = batch faster)");
4625        report("BATCH_vs_string", &paired(true, false));
4626        report("NULL_batch_vs_batch", &paired(true, true));
4627    }
4628
4629    #[test]
4630    fn snapshot_roundtrip() {
4631        let mut g = DiGraph::strict();
4632        let mut attrs = AttrMap::new();
4633        attrs.insert("weight".to_owned(), "3".into());
4634        g.add_edge_with_attrs("a", "b", attrs).unwrap();
4635        g.add_edge("b", "c").unwrap();
4636        g.add_edge("c", "a").unwrap();
4637
4638        let snap = g.snapshot();
4639        let mut replayed = DiGraph::new(snap.mode);
4640        for node in &snap.nodes {
4641            let _ = replayed.add_node(node.clone());
4642        }
4643        for edge in &snap.edges {
4644            replayed
4645                .add_edge_with_attrs(edge.left.clone(), edge.right.clone(), edge.attrs.clone())
4646                .unwrap();
4647        }
4648
4649        assert_eq!(replayed.snapshot(), snap);
4650        assert_digraph_core_invariants(&replayed);
4651    }
4652
4653    #[test]
4654    fn multidigraph_tracks_parallel_edges_with_distinct_keys() {
4655        let mut graph = MultiDiGraph::strict();
4656        let first = graph.add_edge("a", "b").expect("edge add should succeed");
4657        let second = graph.add_edge("a", "b").expect("edge add should succeed");
4658
4659        assert_ne!(first, second);
4660        assert_eq!(graph.edge_count(), 2);
4661        assert_eq!(graph.edge_keys("a", "b"), Some(vec![0, 1]));
4662        assert_multidigraph_core_invariants(&graph);
4663    }
4664
4665    #[test]
4666    fn multidigraph_remove_node_clears_incoming_and_outgoing_parallel_edges() {
4667        let mut graph = MultiDiGraph::strict();
4668        let _ = graph.add_edge("a", "b").expect("edge add should succeed");
4669        let _ = graph.add_edge("a", "b").expect("edge add should succeed");
4670        let _ = graph.add_edge("b", "a").expect("edge add should succeed");
4671        let _ = graph.add_edge("b", "c").expect("edge add should succeed");
4672
4673        assert!(graph.remove_node("b"));
4674        assert_eq!(graph.edge_count(), 0);
4675        assert!(!graph.has_node("b"));
4676        assert_eq!(graph.successors("a"), Some(vec![]));
4677        assert_eq!(graph.predecessors("a"), Some(vec![]));
4678        assert_multidigraph_core_invariants(&graph);
4679    }
4680
4681    #[test]
4682    fn multidigraph_clear_edges_preserves_nodes_attrs_and_rows() {
4683        let mut graph = MultiDiGraph::strict();
4684        assert!(graph.add_node_with_attrs("a", single_attr("color", "red")));
4685        assert!(graph.add_node_with_attrs("b", single_attr("color", "blue")));
4686        let _ = graph
4687            .add_edge_with_key_and_attrs("a", "b", 7, single_attr("weight", "3"))
4688            .expect("edge add should succeed");
4689        let _ = graph
4690            .add_edge_with_key_and_attrs("b", "a", 2, single_attr("weight", "5"))
4691            .expect("edge add should succeed");
4692        let before_revision = graph.revision();
4693
4694        graph.clear_edges();
4695
4696        assert_eq!(graph.node_count(), 2);
4697        assert_eq!(graph.nodes_ordered(), vec!["a", "b"]);
4698        assert_eq!(graph.edge_count(), 0);
4699        assert_eq!(graph.edges_ordered(), Vec::<MultiDiEdgeSnapshot>::new());
4700        assert_eq!(graph.successors("a"), Some(vec![]));
4701        assert_eq!(graph.predecessors("a"), Some(vec![]));
4702        assert_eq!(
4703            graph.node_attrs("a").unwrap().get("color"),
4704            Some(&CgseValue::from("red"))
4705        );
4706        assert!(graph.revision() > before_revision);
4707        assert_multidigraph_core_invariants(&graph);
4708    }
4709
4710    #[test]
4711    fn multidigraph_roundtrips_sparse_snapshot_keys() {
4712        let mut graph = MultiDiGraph::strict();
4713        assert_eq!(
4714            graph.add_edge("a", "b").expect("edge add should succeed"),
4715            0
4716        );
4717        assert_eq!(
4718            graph.add_edge("a", "b").expect("edge add should succeed"),
4719            1
4720        );
4721        assert_eq!(
4722            graph.add_edge("a", "b").expect("edge add should succeed"),
4723            2
4724        );
4725        assert!(graph.remove_edge("a", "b", Some(1)));
4726        assert_eq!(
4727            graph.add_edge("a", "b").expect("edge add should succeed"),
4728            3
4729        );
4730
4731        let snapshot = graph.snapshot();
4732        assert_eq!(
4733            snapshot
4734                .edges
4735                .iter()
4736                .map(|edge| edge.key)
4737                .collect::<Vec<_>>(),
4738            vec![0, 2, 3]
4739        );
4740
4741        let mut replayed = MultiDiGraph::new(snapshot.mode);
4742        for node in &snapshot.nodes {
4743            let _ = replayed.add_node(node.clone());
4744        }
4745        for edge in &snapshot.edges {
4746            replayed
4747                .add_edge_with_key_and_attrs(
4748                    edge.source.clone(),
4749                    edge.target.clone(),
4750                    edge.key,
4751                    edge.attrs.clone(),
4752                )
4753                .expect("snapshot replay should preserve explicit keys");
4754        }
4755
4756        assert_eq!(replayed.snapshot(), snapshot);
4757        assert_multidigraph_core_invariants(&replayed);
4758    }
4759
4760    #[test]
4761    fn runtime_policy_tracks_hardened_multidigraph_recovery() {
4762        let mut g = MultiDiGraph::hardened();
4763        g.add_edge("a", "b")
4764            .expect("hardened multidigraph edge add should succeed");
4765
4766        assert_eq!(g.runtime_policy().mode(), CompatibilityMode::Hardened);
4767        assert!(!g.runtime_policy().decision_log().records().is_empty());
4768        assert!(g.runtime_policy().posterior().observation_count >= 1);
4769    }
4770
4771    /// br-r37-c1-eo88t: directed mirror of the simple/multi pairing in
4772    /// `fnx-classes/src/lib.rs`. An ALLOWED edge add writes exactly one
4773    /// `add_edge` ledger record and it is the outcome one; a REFUSED add still
4774    /// writes its gate record, which is the rejected mutation's only audit
4775    /// trail because the outcome record never runs.
4776    fn add_edge_evidence_signals(records: &[DecisionRecord]) -> Vec<Vec<&str>> {
4777        records
4778            .iter()
4779            .filter(|record| record.operation == "add_edge")
4780            .map(|record| {
4781                record
4782                    .evidence
4783                    .iter()
4784                    .map(|term| term.signal.as_ref())
4785                    .collect()
4786            })
4787            .collect()
4788    }
4789
4790    /// br-r37-c1-4c29a: `MultiDiGraph`'s adjacency rows moved from the default
4791    /// SipHash to `FxBuildHasher`. That is only admissible because `IndexMap`
4792    /// and `IndexSet` are INSERTION-ordered, so the hasher cannot reach
4793    /// iteration order — and iteration order is CGSE-observable here, since
4794    /// `edges_ordered` walks these rows. Asserted rather than asserted-by-faith:
4795    /// the endpoints below are inserted in an order that is neither sorted nor
4796    /// hash order, and must come back exactly as inserted.
4797    #[test]
4798    fn multidigraph_adjacency_iterates_in_insertion_order_under_fxhash() {
4799        let mut graph = MultiDiGraph::strict();
4800        for (source, target) in [
4801            ("zeta", "mid"),
4802            ("zeta", "alpha"),
4803            ("zeta", "omega"),
4804            ("zeta", "beta"),
4805            ("alpha", "zeta"),
4806        ] {
4807            graph
4808                .add_edge_with_attrs(source, target, AttrMap::new())
4809                .expect("edge is allowed");
4810        }
4811        // Parallel edges on an already-seen pair must append, not reorder.
4812        graph
4813            .add_edge_with_attrs("zeta", "alpha", AttrMap::new())
4814            .expect("parallel edge is allowed");
4815
4816        let observed: Vec<(String, String, usize)> = graph
4817            .edges_ordered_borrowed()
4818            .into_iter()
4819            .map(|(source, target, key, _)| (source.to_owned(), target.to_owned(), key))
4820            .collect();
4821        assert_eq!(
4822            observed,
4823            vec![
4824                ("zeta".to_owned(), "mid".to_owned(), 0),
4825                ("zeta".to_owned(), "alpha".to_owned(), 0),
4826                ("zeta".to_owned(), "alpha".to_owned(), 1),
4827                ("zeta".to_owned(), "omega".to_owned(), 0),
4828                ("zeta".to_owned(), "beta".to_owned(), 0),
4829                ("alpha".to_owned(), "zeta".to_owned(), 0),
4830            ],
4831            "adjacency rows must stay insertion-ordered regardless of hasher"
4832        );
4833    }
4834
4835    /// br-r37-c1-jc9e4: directed-multi mirror of the `Graph` fresh-bucket lock.
4836    /// `add_edge_impl` now TAKES the caller's attribute map when the keyed
4837    /// bucket is empty. The multigraph-only risk is that the take could land on
4838    /// the wrong parallel edge, so each key is asserted separately.
4839    #[test]
4840    fn multidigraph_fresh_bucket_take_matches_merge_semantics() {
4841        fn weight(value: i64) -> AttrMap {
4842            let mut attrs = AttrMap::new();
4843            attrs.insert("w".to_owned(), CgseValue::Int(value));
4844            attrs
4845        }
4846
4847        let mut graph = MultiDiGraph::strict();
4848        let k0 = graph
4849            .add_edge_with_attrs("a", "b", weight(1))
4850            .expect("edge is allowed");
4851        // A PARALLEL edge gets its own fresh bucket and must not disturb k0.
4852        let k1 = graph
4853            .add_edge_with_attrs("a", "b", weight(2))
4854            .expect("parallel edge is allowed");
4855        assert_ne!(k0, k1);
4856        assert_eq!(
4857            graph.edge_attrs("a", "b", k0).and_then(|a| a.get("w")),
4858            Some(&CgseValue::Int(1))
4859        );
4860        assert_eq!(
4861            graph.edge_attrs("a", "b", k1).and_then(|a| a.get("w")),
4862            Some(&CgseValue::Int(2))
4863        );
4864
4865        // A keyed edge created with EMPTY attrs still takes the empty-bucket
4866        // branch when attrs arrive later.
4867        let k2 = graph
4868            .add_edge_with_attrs("c", "d", AttrMap::new())
4869            .expect("edge is allowed");
4870        assert!(
4871            graph
4872                .edge_attrs("c", "d", k2)
4873                .expect("edge exists")
4874                .is_empty()
4875        );
4876        graph
4877            .add_edge_with_key_and_attrs("c", "d", k2, weight(7))
4878            .expect("edge is allowed");
4879        assert_eq!(
4880            graph.edge_attrs("c", "d", k2).and_then(|a| a.get("w")),
4881            Some(&CgseValue::Int(7))
4882        );
4883
4884        // Non-empty bucket still merges rather than being replaced.
4885        let mut extra = AttrMap::new();
4886        extra.insert("k".to_owned(), CgseValue::Int(3));
4887        graph
4888            .add_edge_with_key_and_attrs("c", "d", k2, extra)
4889            .expect("edge is allowed");
4890        let stored = graph.edge_attrs("c", "d", k2).expect("edge exists");
4891        assert_eq!(stored.get("w"), Some(&CgseValue::Int(7)));
4892        assert_eq!(stored.get("k"), Some(&CgseValue::Int(3)));
4893    }
4894
4895    /// br-r37-c1-jc9e4: `MultiDiGraph::add_node_with_attrs_unrecorded` stopped
4896    /// using `entry(node.clone()).or_default()` for the `successors` /
4897    /// `predecessors` rows and now probes borrowed, inserting only when the row
4898    /// is genuinely absent. The risk that swap introduces is a row that fails to
4899    /// get created, which would surface later as a lost edge rather than at the
4900    /// call itself, so this exercises row creation and row preservation
4901    /// directly through the degrees.
4902    #[test]
4903    fn multidigraph_add_node_creates_and_preserves_adjacency_rows() {
4904        let mut graph = MultiDiGraph::strict();
4905
4906        // A node created by add_node — not by an edge — must still get usable
4907        // successor AND predecessor rows.
4908        graph.add_node("x");
4909        graph
4910            .add_edge_with_attrs("x", "y", AttrMap::new())
4911            .expect("edge is allowed");
4912        graph
4913            .add_edge_with_attrs("w", "x", AttrMap::new())
4914            .expect("edge is allowed");
4915        assert_eq!(graph.out_degree("x"), 1);
4916        assert_eq!(graph.in_degree("x"), 1);
4917
4918        // Re-adding an EXISTING node now takes the borrowed path that skips the
4919        // insert entirely. It must not disturb the rows it skips.
4920        for _ in 0..5 {
4921            graph.add_node("x");
4922        }
4923        assert_eq!(graph.out_degree("x"), 1);
4924        assert_eq!(graph.in_degree("x"), 1);
4925        assert_eq!(graph.node_count(), 3);
4926
4927        // Parallel edges still accumulate on the preserved row.
4928        graph
4929            .add_edge_with_attrs("x", "y", AttrMap::new())
4930            .expect("parallel edge is allowed");
4931        assert_eq!(graph.out_degree("x"), 2);
4932    }
4933
4934    /// br-r37-c1-jc9e4: directed mirror of the `Graph` lock. Pins the endpoint
4935    /// resolution cases that collapsing the per-endpoint `contains_key` +
4936    /// `get_index_of` pair restructured — in particular the self-loop branch,
4937    /// which now returns the source index instead of probing for the target and
4938    /// still has to report `target_autocreated` as a COPY of
4939    /// `source_autocreated`. Direction is asserted alongside, because the
4940    /// successor/predecessor rows are now filled from the reused indices.
4941    #[test]
4942    fn digraph_add_edge_endpoint_resolution_preserves_autocreation_and_direction() {
4943        fn autocreated(graph: &DiGraph) -> (String, String) {
4944            let record = graph
4945                .evidence_ledger()
4946                .records()
4947                .last()
4948                .expect("add_edge files an outcome record");
4949            let read = |signal: &str| {
4950                record
4951                    .evidence
4952                    .iter()
4953                    .find(|term| term.signal == signal)
4954                    .map(|term| term.observed_value.to_string())
4955                    .unwrap_or_else(|| panic!("{signal} must be present"))
4956            };
4957            (read("source_autocreated"), read("target_autocreated"))
4958        }
4959
4960        // Self-loop that CREATES its node: both flags true, one node added.
4961        let mut graph = DiGraph::strict();
4962        graph
4963            .add_edge_with_attrs("s", "s", AttrMap::new())
4964            .expect("self-loop is allowed");
4965        assert_eq!(autocreated(&graph), ("true".to_owned(), "true".to_owned()));
4966        assert_eq!(graph.node_count(), 1);
4967
4968        // Self-loop on an EXISTING node: both flags false, nothing added.
4969        graph
4970            .add_edge_with_attrs("s", "s", AttrMap::new())
4971            .expect("re-adding a self-loop is allowed");
4972        assert_eq!(
4973            autocreated(&graph),
4974            ("false".to_owned(), "false".to_owned())
4975        );
4976        assert_eq!(graph.node_count(), 1);
4977
4978        // Source exists, target is new — the asymmetric case.
4979        graph
4980            .add_edge_with_attrs("s", "t", AttrMap::new())
4981            .expect("edge is allowed");
4982        assert_eq!(autocreated(&graph), ("false".to_owned(), "true".to_owned()));
4983
4984        // Both new: source resolved and inserted BEFORE target, so insertion
4985        // order is source-then-target and earlier indices are unmoved.
4986        graph
4987            .add_edge_with_attrs("z", "a", AttrMap::new())
4988            .expect("edge is allowed");
4989        assert_eq!(autocreated(&graph), ("true".to_owned(), "true".to_owned()));
4990        assert_eq!(graph.get_node_index("s"), Some(0));
4991        assert_eq!(graph.get_node_index("t"), Some(1));
4992        assert_eq!(graph.get_node_index("z"), Some(2));
4993        assert_eq!(graph.get_node_index("a"), Some(3));
4994
4995        // Direction survives filling succ/pred from the reused indices.
4996        assert!(graph.has_edge("z", "a"));
4997        assert!(!graph.has_edge("a", "z"));
4998        assert!(graph.has_edge("s", "t"));
4999        assert!(!graph.has_edge("t", "s"));
5000    }
5001
5002    #[test]
5003    fn allowed_digraph_add_edge_records_only_the_outcome_decision() {
5004        let mut g = DiGraph::strict();
5005        g.add_edge_with_attrs("a", "b", AttrMap::new())
5006            .expect("edge add should succeed");
5007
5008        assert_eq!(
5009            add_edge_evidence_signals(g.evidence_ledger().records()),
5010            vec![vec![
5011                "self_loop",
5012                "edge_attr_count",
5013                "source_autocreated",
5014                "target_autocreated",
5015            ]]
5016        );
5017        assert_eq!(
5018            g.evidence_ledger()
5019                .records()
5020                .iter()
5021                .filter(|record| record.operation == "add_edge")
5022                .map(|record| record.action)
5023                .collect::<Vec<_>>(),
5024            vec![DecisionAction::Allow]
5025        );
5026    }
5027
5028    #[test]
5029    fn allowed_multidigraph_add_edge_records_only_the_outcome_decision() {
5030        let mut g = MultiDiGraph::strict();
5031        let _ = g
5032            .add_edge_with_attrs("a", "b", AttrMap::new())
5033            .expect("edge add should succeed");
5034
5035        assert_eq!(
5036            add_edge_evidence_signals(g.evidence_ledger().records()),
5037            vec![vec![
5038                "edge_key",
5039                "edge_attr_count",
5040                "source_autocreated",
5041                "target_autocreated",
5042            ]]
5043        );
5044    }
5045
5046    #[test]
5047    fn refused_multidigraph_add_edge_still_records_its_gate_decision() {
5048        let mut g = MultiDiGraph::strict();
5049        let mut attrs = AttrMap::new();
5050        attrs.insert("__fnx_incompatible_decoder".to_owned(), "v2".into());
5051        let err = g
5052            .add_edge_with_attrs("a", "b", attrs)
5053            .expect_err("strict mode should fail closed");
5054        assert_eq!(
5055            err,
5056            GraphError::FailClosed {
5057                operation: "add_edge",
5058                reason: "incompatible edge metadata".to_owned(),
5059            }
5060        );
5061
5062        assert_eq!(
5063            add_edge_evidence_signals(g.evidence_ledger().records()),
5064            vec![vec!["unknown_incompatible_feature"]]
5065        );
5066        assert_eq!(
5067            g.evidence_ledger().records().last().map(|r| r.action),
5068            Some(DecisionAction::FailClosed)
5069        );
5070    }
5071
5072    #[test]
5073    fn strict_mode_fails_closed_for_incompatible_attrs() {
5074        let mut g = DiGraph::strict();
5075        let mut attrs = AttrMap::new();
5076        attrs.insert("__fnx_incompatible_decoder".to_owned(), "v2".into());
5077        let err = g
5078            .add_edge_with_attrs("a", "b", attrs)
5079            .expect_err("strict mode should fail closed");
5080
5081        assert_eq!(
5082            err,
5083            GraphError::FailClosed {
5084                operation: "add_edge",
5085                reason: "incompatible edge metadata".to_owned(),
5086            }
5087        );
5088    }
5089
5090    #[test]
5091    fn revision_increments_on_mutations() {
5092        let mut g = DiGraph::strict();
5093        let r0 = g.revision();
5094        let _ = g.add_node("a");
5095        let r1 = g.revision();
5096        assert!(r1 > r0);
5097
5098        g.add_edge("a", "b").unwrap();
5099        let r2 = g.revision();
5100        assert!(r2 > r1);
5101
5102        let _ = g.remove_edge("a", "b");
5103        let r3 = g.revision();
5104        assert!(r3 > r2);
5105    }
5106
5107    #[test]
5108    fn hardened_self_loop_records_allow() {
5109        let mut g = DiGraph::hardened();
5110        g.add_edge("loop", "loop").unwrap();
5111
5112        let record = g
5113            .evidence_ledger()
5114            .records()
5115            .iter()
5116            .rev()
5117            .find(|r| r.operation == "add_edge")
5118            .expect("add_edge should emit ledger row");
5119        assert_decision_record_schema(record, CompatibilityMode::Hardened);
5120        assert_eq!(record.action, DecisionAction::Allow);
5121    }
5122
5123    #[test]
5124    fn runtime_policy_tracks_hardened_digraph_recovery() {
5125        let mut g = DiGraph::hardened();
5126        g.add_edge("a", "b")
5127            .expect("hardened digraph edge add should succeed");
5128
5129        assert_eq!(g.runtime_policy().mode(), CompatibilityMode::Hardened);
5130        assert!(!g.runtime_policy().decision_log().records().is_empty());
5131        assert!(g.runtime_policy().posterior().observation_count >= 1);
5132    }
5133
5134    /// br-r37-c1-p1tvg: the index probe answers the ORIENTED pair, and only
5135    /// for live nodes. Probing the undirected pair or a freed index would
5136    /// each pass a test that only checked present edges.
5137    #[test]
5138    fn has_edge_by_indices_is_oriented_and_range_checked() {
5139        let mut g = DiGraph::strict();
5140        g.add_edge("tail", "head").expect("edge add should succeed");
5141        g.add_edge("head", "third")
5142            .expect("edge add should succeed");
5143
5144        let (tail, head, third) = (
5145            g.get_node_index("tail").expect("tail index"),
5146            g.get_node_index("head").expect("head index"),
5147            g.get_node_index("third").expect("third index"),
5148        );
5149        assert!(g.has_edge_by_indices(tail, head));
5150        assert!(!g.has_edge_by_indices(head, tail));
5151        assert!(!g.has_edge_by_indices(tail, third));
5152        assert!(!g.has_edge_by_indices(third, g.node_count() + 7));
5153        assert!(!g.has_edge_by_indices(g.node_count() + 7, third));
5154
5155        // Removal renumbers: "third" takes the freed slot, so a stale key
5156        // left behind by the remap would answer for the wrong node pair.
5157        assert!(g.remove_node("tail"), "node removal should succeed");
5158        let head_after = g.get_node_index("head").expect("head index after removal");
5159        let third_after = g
5160            .get_node_index("third")
5161            .expect("third index after removal");
5162        assert!(g.has_edge_by_indices(head_after, third_after));
5163        assert!(!g.has_edge_by_indices(third_after, head_after));
5164        assert!(!g.has_edge_by_indices(head_after, g.node_count()));
5165    }
5166
5167    // -- Proptest -----------------------------------------------------------
5168
5169    proptest! {
5170        /// br-r37-c1-p1tvg: the index probe must agree with the String path
5171        /// on EVERY pair — present, absent, reversed, and out of range —
5172        /// through arbitrary add/remove sequences that renumber indices.
5173        #[test]
5174        fn prop_has_edge_by_indices_matches_the_name_path(
5175            ops in prop::collection::vec((0_u8..6, 0_u8..6, 0_u8..10), 1..60),
5176        ) {
5177            let mut g = DiGraph::strict();
5178            for (src_id, tgt_id, action) in ops {
5179                let (source, target) = (node_name(src_id), node_name(tgt_id));
5180                match action {
5181                    0..=6 => {
5182                        prop_assert!(g.add_edge(source, target).is_ok());
5183                    }
5184                    7 | 8 => {
5185                        let _ = g.remove_edge(&source, &target);
5186                    }
5187                    _ => {
5188                        let _ = g.remove_node(&source);
5189                    }
5190                }
5191            }
5192
5193            let names: Vec<String> = g.nodes_ordered().iter().map(|n| (*n).to_owned()).collect();
5194            for (i, source) in names.iter().enumerate() {
5195                for (j, target) in names.iter().enumerate() {
5196                    prop_assert_eq!(
5197                        g.has_edge_by_indices(i, j),
5198                        g.has_edge(source, target),
5199                        "index pair ({}, {}) disagrees with ({}, {})",
5200                        i, j, source, target
5201                    );
5202                }
5203                prop_assert!(!g.has_edge_by_indices(i, names.len()));
5204                prop_assert!(!g.has_edge_by_indices(names.len(), i));
5205            }
5206        }
5207
5208        #[test]
5209        fn prop_digraph_invariants_under_mixed_mutations(
5210            ops in prop::collection::vec((0_u8..8, 0_u8..8, any::<bool>()), 1..80),
5211        ) {
5212            let mut g = DiGraph::strict();
5213            let mut last_rev = g.revision();
5214
5215            for (src_id, tgt_id, is_add) in ops {
5216                let src = node_name(src_id);
5217                let tgt = node_name(tgt_id);
5218                if is_add {
5219                    prop_assert!(g.add_edge(src, tgt).is_ok());
5220                } else {
5221                    let _ = g.remove_edge(&src, &tgt);
5222                }
5223                let rev = g.revision();
5224                prop_assert!(rev >= last_rev);
5225                last_rev = rev;
5226                assert_digraph_core_invariants(&g);
5227            }
5228        }
5229
5230        #[test]
5231        fn prop_digraph_snapshot_deterministic(
5232            ops in prop::collection::vec((0_u8..8, 0_u8..8, 0_u8..3), 0..64),
5233        ) {
5234            let mut g1 = DiGraph::hardened();
5235            let mut g2 = DiGraph::hardened();
5236
5237            for (src_id, tgt_id, attrs_variant) in ops {
5238                let src = node_name(src_id);
5239                let tgt = node_name(tgt_id);
5240                let mut attrs = AttrMap::new();
5241                if attrs_variant == 1 {
5242                    attrs.insert("weight".to_owned(), (src_id % 5).to_string().into());
5243                } else if attrs_variant == 2 {
5244                    attrs.insert("tag".to_owned(), format!("k{}", tgt_id % 4).into());
5245                }
5246                prop_assert!(g1.add_edge_with_attrs(src.clone(), tgt.clone(), attrs.clone()).is_ok());
5247                prop_assert!(g2.add_edge_with_attrs(src, tgt, attrs).is_ok());
5248            }
5249
5250            prop_assert_eq!(g1.snapshot(), g2.snapshot());
5251        }
5252
5253        #[test]
5254        fn prop_remove_node_clears_all_directed_edges(
5255            ops in prop::collection::vec((0_u8..8, 0_u8..8), 1..64),
5256            target_id in 0_u8..8,
5257        ) {
5258            let mut g = DiGraph::strict();
5259            for (src_id, tgt_id) in ops {
5260                prop_assert!(g.add_edge(node_name(src_id), node_name(tgt_id)).is_ok());
5261            }
5262
5263            let target = node_name(target_id);
5264            let removed = g.remove_node(&target);
5265            if removed {
5266                prop_assert!(!g.has_node(&target));
5267                for node in g.nodes_ordered() {
5268                    prop_assert!(!g.has_edge(node, &target));
5269                    prop_assert!(!g.has_edge(&target, node));
5270                }
5271            }
5272            assert_digraph_core_invariants(&g);
5273        }
5274
5275        #[test]
5276        fn prop_directed_edge_count_equals_successor_sum(
5277            ops in prop::collection::vec((0_u8..8, 0_u8..8), 1..64),
5278        ) {
5279            let mut g = DiGraph::strict();
5280            for (src_id, tgt_id) in ops {
5281                prop_assert!(g.add_edge(node_name(src_id), node_name(tgt_id)).is_ok());
5282            }
5283
5284            let total_out: usize = g.nodes_ordered().iter().map(|n| g.out_degree(n)).sum();
5285            let total_in: usize = g.nodes_ordered().iter().map(|n| g.in_degree(n)).sum();
5286            prop_assert_eq!(g.edge_count(), total_out);
5287            prop_assert_eq!(g.edge_count(), total_in);
5288        }
5289    }
5290}