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