Skip to main content

fnx_classes/
digraph.rs

1//! Directed graph (DiGraph) storage.
2//!
3//! Mirrors the undirected [`Graph`] API with directed semantics:
4//! - Edge `(u, v)` is distinct from `(v, u)`.
5//! - Adjacency is split into **successors** (outgoing) and **predecessors** (incoming).
6//! - `neighbors(n)` returns successors (matching NetworkX convention).
7
8use crate::{AttrMap, EdgeSnapshot, GraphError};
9use fnx_runtime::{CompatibilityMode, DecisionAction, EvidenceLedger, EvidenceTerm, RuntimePolicy};
10use indexmap::{IndexMap, IndexSet};
11use serde::{Deserialize, Serialize};
12
13// ---------------------------------------------------------------------------
14// DirectedEdgeKey — order-preserving (NOT canonicalized)
15// ---------------------------------------------------------------------------
16
17#[derive(Debug, Clone, PartialEq, Eq, Hash)]
18struct DirectedEdgeKey {
19    source: String,
20    target: String,
21}
22
23impl DirectedEdgeKey {
24    fn new(source: &str, target: &str) -> Self {
25        Self {
26            source: source.to_owned(),
27            target: target.to_owned(),
28        }
29    }
30}
31
32#[derive(Hash, PartialEq, Eq, Clone, Copy)]
33struct DirectedEdgeKeyRef<'a> {
34    source: &'a str,
35    target: &'a str,
36}
37
38impl<'a> DirectedEdgeKeyRef<'a> {
39    fn new(source: &'a str, target: &'a str) -> Self {
40        Self { source, target }
41    }
42}
43
44impl<'a> indexmap::Equivalent<DirectedEdgeKey> for DirectedEdgeKeyRef<'a> {
45    fn equivalent(&self, key: &DirectedEdgeKey) -> bool {
46        self.source == key.source && self.target == key.target
47    }
48}
49
50// ---------------------------------------------------------------------------
51// DiGraphSnapshot
52// ---------------------------------------------------------------------------
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct DiGraphSnapshot {
56    pub mode: CompatibilityMode,
57    pub nodes: Vec<String>,
58    /// br-snapnodeattrs: per-node attributes preserved across snapshot
59    /// round-trip. Defaults to empty for backward compatibility.
60    #[serde(default)]
61    pub node_attrs: std::collections::BTreeMap<String, AttrMap>,
62    /// Edges in source→target order. `left` = source, `right` = target.
63    pub edges: Vec<EdgeSnapshot>,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct MultiDiEdgeSnapshot {
68    pub source: String,
69    pub target: String,
70    pub key: usize,
71    pub attrs: AttrMap,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct MultiDiGraphSnapshot {
76    pub mode: CompatibilityMode,
77    pub nodes: Vec<String>,
78    /// See DiGraphSnapshot::node_attrs.
79    #[serde(default)]
80    pub node_attrs: std::collections::BTreeMap<String, AttrMap>,
81    pub edges: Vec<MultiDiEdgeSnapshot>,
82}
83
84// ---------------------------------------------------------------------------
85// DiGraph
86// ---------------------------------------------------------------------------
87
88/// br-r37-c1-d58s8 P1: integer CSR view of a DiGraph's adjacency —
89/// succ/pred targets are node INDICES (IndexMap insertion order) with
90/// per-row slices in ROW order, so index walks reproduce the String
91/// walks exactly. Built once per revision via `DiGraph::csr()`.
92#[derive(Debug)]
93pub struct DiCsr {
94    pub succ_offsets: Vec<usize>,
95    pub succ_targets: Vec<u32>,
96    pub pred_offsets: Vec<usize>,
97    pub pred_targets: Vec<u32>,
98}
99
100impl DiCsr {
101    #[must_use]
102    pub fn successors(&self, idx: usize) -> &[u32] {
103        &self.succ_targets[self.succ_offsets[idx]..self.succ_offsets[idx + 1]]
104    }
105
106    #[must_use]
107    pub fn predecessors(&self, idx: usize) -> &[u32] {
108        &self.pred_targets[self.pred_offsets[idx]..self.pred_offsets[idx + 1]]
109    }
110}
111
112type DiCsrCache = std::sync::Arc<std::sync::RwLock<Option<(u64, std::sync::Arc<DiCsr>)>>>;
113type DiAllIntCache = std::sync::Arc<std::sync::RwLock<Option<(u64, String, bool)>>>;
114
115#[derive(Debug, Clone)]
116pub struct DiGraph {
117    mode: CompatibilityMode,
118    revision: u64,
119    nodes: IndexMap<String, AttrMap>,
120    // br-r37-c1-d58s8 DiGraph flip P3: String rows GONE — the eager
121    // index rows below are the single row store; String views derive
122    // through the nodes name table.
123    /// br-r37-c1-d58s8 DiGraph row flip phase 1: EAGER integer row
124    /// mirrors (insertion-order-faithful, maintained by every writer
125    /// with the I5 repair pattern on removal). Phase 2 re-points
126    /// readers here; phase 3 deletes the String rows above.
127    succ_indices: Vec<Vec<usize>>,
128    pred_indices: Vec<Vec<usize>>,
129    // br-r37-c1-d58s8 edges-map flip (directed twin): keyed by the
130    // ORIENTED (source_idx, target_idx) pair — NOT canonicalized
131    // (direction IS identity for directed edges). Zero String
132    // allocs/hashes per insert; node removal REKEYS via the remap.
133    edges: IndexMap<(usize, usize), AttrMap>,
134    runtime_policy: RuntimePolicy,
135    /// br-r37-c1-d58s8 P1: revision-keyed CSR cache. Derived from the
136    /// String maps on demand (never eagerly maintained — the I5
137    /// renumbering hazard cannot apply); shared across clones safely
138    /// because entries are revision-checked before use. Mutations
139    /// invalidate implicitly by bumping `revision`.
140    csr_cache: DiCsrCache,
141    /// br-r37-c1-d58s8: revision-keyed all-int weights memo (the
142    /// shortest-path int-coercion pre-scan walked every edge attr per
143    /// call). Single entry keyed by (revision, attr).
144    all_int_cache: DiAllIntCache,
145}
146
147impl DiGraph {
148    /// br-r37-c1-7dpyg: structural clone with a FRESH RuntimePolicy —
149    /// see Graph::clone_with_fresh_policy.
150    #[must_use]
151    pub fn clone_with_fresh_policy(&self) -> Self {
152        Self {
153            mode: self.mode,
154            revision: self.revision,
155            nodes: self.nodes.clone(),
156            succ_indices: self.succ_indices.clone(),
157            pred_indices: self.pred_indices.clone(),
158            edges: self.edges.clone(),
159            runtime_policy: RuntimePolicy::new(self.mode),
160            csr_cache: self.csr_cache.clone(),
161            all_int_cache: self.all_int_cache.clone(),
162        }
163    }
164
165    // -----------------------------------------------------------------------
166    // Constructors
167    // -----------------------------------------------------------------------
168
169    #[must_use]
170    pub fn new(mode: CompatibilityMode) -> Self {
171        Self {
172            mode,
173            revision: 0,
174            nodes: IndexMap::new(),
175            succ_indices: Vec::new(),
176            pred_indices: Vec::new(),
177            edges: IndexMap::new(),
178            runtime_policy: RuntimePolicy::new(mode),
179            csr_cache: std::sync::Arc::default(),
180            all_int_cache: std::sync::Arc::default(),
181        }
182    }
183
184    #[must_use]
185    pub fn with_runtime_policy(runtime_policy: RuntimePolicy) -> Self {
186        let mode = runtime_policy.mode();
187        Self {
188            mode,
189            revision: 0,
190            nodes: IndexMap::new(),
191            succ_indices: Vec::new(),
192            pred_indices: Vec::new(),
193            edges: IndexMap::new(),
194            runtime_policy,
195            csr_cache: std::sync::Arc::default(),
196            all_int_cache: std::sync::Arc::default(),
197        }
198    }
199
200    #[must_use]
201    pub fn strict() -> Self {
202        Self::new(CompatibilityMode::Strict)
203    }
204
205    #[must_use]
206    pub fn hardened() -> Self {
207        Self::new(CompatibilityMode::Hardened)
208    }
209
210    // -----------------------------------------------------------------------
211    // Read-only queries
212    // -----------------------------------------------------------------------
213
214    #[must_use]
215    pub fn mode(&self) -> CompatibilityMode {
216        self.mode
217    }
218
219    #[must_use]
220    pub fn node_count(&self) -> usize {
221        self.nodes.len()
222    }
223
224    #[must_use]
225    pub fn edge_count(&self) -> usize {
226        self.edges.len()
227    }
228
229    #[must_use]
230    pub fn revision(&self) -> u64 {
231        self.revision
232    }
233
234    /// br-r37-c1-d58s8 P1: revision-keyed integer CSR of both row
235    /// families. O(V+E) rebuild only when the graph changed since the
236    /// cached build; kernels get index walks identical to the String
237    /// walks (per-row order preserved).
238    #[must_use]
239    pub fn csr(&self) -> std::sync::Arc<DiCsr> {
240        if let Ok(guard) = self.csr_cache.read()
241            && let Some((rev, csr)) = guard.as_ref()
242            && *rev == self.revision
243        {
244            return csr.clone();
245        }
246        let built = std::sync::Arc::new(self.build_csr());
247        if let Ok(mut guard) = self.csr_cache.write() {
248            *guard = Some((self.revision, built.clone()));
249        }
250        built
251    }
252
253    /// br-r37-c1-d58s8: revision-keyed memo of "every edge's `attr`
254    /// value is an integer (missing = default 1 = int)".
255    #[must_use]
256    pub fn edge_weights_all_int(&self, attr: &str) -> bool {
257        if let Ok(guard) = self.all_int_cache.read()
258            && let Some((rev, a, v)) = guard.as_ref()
259            && *rev == self.revision
260            && a == attr
261        {
262            return *v;
263        }
264        let v = self
265            .edges
266            .values()
267            .all(|attrs| attrs.get(attr).is_none_or(fnx_runtime::CgseValue::is_int));
268        if let Ok(mut guard) = self.all_int_cache.write() {
269            *guard = Some((self.revision, attr.to_owned(), v));
270        }
271        v
272    }
273
274    fn build_csr(&self) -> DiCsr {
275        // br-r37-c1-d58s8 DiGraph flip P1: build from the EAGER index rows
276        // — zero String hashing (was a get_index_of per neighbor).
277        let n = self.nodes.len();
278        let mut succ_offsets = Vec::with_capacity(n + 1);
279        let mut pred_offsets = Vec::with_capacity(n + 1);
280        let mut succ_targets = Vec::new();
281        let mut pred_targets = Vec::new();
282        succ_offsets.push(0);
283        pred_offsets.push(0);
284        for i in 0..n {
285            succ_targets.extend(
286                self.succ_indices[i]
287                    .iter()
288                    .map(|&v| u32::try_from(v).unwrap_or(u32::MAX)),
289            );
290            succ_offsets.push(succ_targets.len());
291            pred_targets.extend(
292                self.pred_indices[i]
293                    .iter()
294                    .map(|&v| u32::try_from(v).unwrap_or(u32::MAX)),
295            );
296            pred_offsets.push(pred_targets.len());
297        }
298        DiCsr {
299            succ_offsets,
300            succ_targets,
301            pred_offsets,
302            pred_targets,
303        }
304    }
305
306    /// br-r37-c1-d58s8: consistency oracle — eager index rows must mirror
307    /// the String rows exactly (order included). Test-only.
308    #[doc(hidden)]
309    #[must_use]
310    pub fn debug_index_rows_consistent(&self) -> bool {
311        // br-r37-c1-d58s8 P3: the String rows this oracle compared
312        // against are GONE; the index rows are the single store. Length
313        // sanity only (parity batteries cover content).
314        self.succ_indices.len() == self.nodes.len() && self.pred_indices.len() == self.nodes.len()
315    }
316
317    #[must_use]
318    pub fn has_node(&self, node: &str) -> bool {
319        self.nodes.contains_key(node)
320    }
321
322    /// Check for directed edge source→target.
323    #[must_use]
324    /// br-r37-c1-d58s8: resolve a String pair to the ORIENTED index key.
325    #[inline]
326    fn edge_pair_key(&self, source: &str, target: &str) -> Option<(usize, usize)> {
327        Some((
328            self.nodes.get_index_of(source)?,
329            self.nodes.get_index_of(target)?,
330        ))
331    }
332
333    /// br-r37-c1-1l8s0: direct slice access to the eager succ index row.
334    #[must_use]
335    #[inline]
336    pub fn successors_indices(&self, idx: usize) -> Option<&[usize]> {
337        self.succ_indices.get(idx).map(Vec::as_slice)
338    }
339
340    /// Direct slice access to the eager pred index row.
341    #[must_use]
342    #[inline]
343    pub fn predecessors_indices(&self, idx: usize) -> Option<&[usize]> {
344        self.pred_indices.get(idx).map(Vec::as_slice)
345    }
346
347    pub fn has_edge(&self, source: &str, target: &str) -> bool {
348        self.edge_pair_key(source, target)
349            .is_some_and(|k| self.edges.contains_key(&k))
350    }
351
352    #[must_use]
353    pub fn nodes_ordered(&self) -> Vec<&str> {
354        self.nodes.keys().map(String::as_str).collect()
355    }
356
357    #[must_use]
358    pub fn get_node_index(&self, node: &str) -> Option<usize> {
359        self.nodes.get_index_of(node)
360    }
361
362    #[must_use]
363    pub fn get_node_name(&self, index: usize) -> Option<&str> {
364        self.nodes.get_index(index).map(|(k, _)| k.as_str())
365    }
366
367    // -- Directed adjacency queries ----------------------------------------
368
369    /// Successors of `node` (outgoing neighbors). Returns `None` if node absent.
370    #[must_use]
371    pub fn successors(&self, node: &str) -> Option<Vec<&str>> {
372        // br-r37-c1-d58s8 DiGraph flip P2: serve from the eager index
373        // rows + name table (order-faithful; oracle-validated in P1).
374        let idx = self.nodes.get_index_of(node)?;
375        Some(
376            self.succ_indices[idx]
377                .iter()
378                .map(|&i| {
379                    self.nodes
380                        .get_index(i)
381                        .expect("index rows hold valid node indices")
382                        .0
383                        .as_str()
384                })
385                .collect(),
386        )
387    }
388
389    #[must_use]
390    pub fn successors_iter(&self, node: &str) -> Option<impl Iterator<Item = &str> + '_> {
391        // br-r37-c1-d58s8 DiGraph flip P2: index-backed.
392        let idx = self.nodes.get_index_of(node)?;
393        Some(self.succ_indices[idx].iter().map(move |&i| {
394            self.nodes
395                .get_index(i)
396                .expect("index rows hold valid node indices")
397                .0
398                .as_str()
399        }))
400    }
401
402    /// Predecessors of `node` (incoming neighbors). Returns `None` if node absent.
403    #[must_use]
404    pub fn predecessors(&self, node: &str) -> Option<Vec<&str>> {
405        // br-r37-c1-d58s8 DiGraph flip P2: index-backed.
406        let idx = self.nodes.get_index_of(node)?;
407        Some(
408            self.pred_indices[idx]
409                .iter()
410                .map(|&i| {
411                    self.nodes
412                        .get_index(i)
413                        .expect("index rows hold valid node indices")
414                        .0
415                        .as_str()
416                })
417                .collect(),
418        )
419    }
420
421    /// br-r37-c1-u3qyn: restore explicit succ/pred row orders (pickle
422    /// round-trip) — see Graph::apply_row_orders. `which` selects the
423    /// adjacency side.
424    pub fn apply_row_orders(&mut self, orders: &[(String, Vec<String>)], pred: bool) {
425        // br-r37-c1-d58s8 P3: pure integer-row reorder per family.
426        for (node, order) in orders {
427            let Some(idx) = self.nodes.get_index_of(node.as_str()) else {
428                continue;
429            };
430            let rows = if pred {
431                &mut self.pred_indices
432            } else {
433                &mut self.succ_indices
434            };
435            let row = &rows[idx];
436            let row_set: std::collections::HashSet<usize> = row.iter().copied().collect();
437            let mut placed: std::collections::HashSet<usize> = std::collections::HashSet::new();
438            let mut new_row: Vec<usize> = Vec::with_capacity(row.len());
439            for v in order {
440                if let Some(v_idx) = self.nodes.get_index_of(v.as_str())
441                    && row_set.contains(&v_idx)
442                    && placed.insert(v_idx)
443                {
444                    new_row.push(v_idx);
445                }
446            }
447            for &v_idx in row {
448                if placed.insert(v_idx) {
449                    new_row.push(v_idx);
450                }
451            }
452            rows[idx] = new_row;
453        }
454    }
455
456    /// br-r37-c1-0ek49: reorder every PRED row into NetworkX's
457    /// `DiGraph.copy()` walk order. nx copy rebuilds via the u-major
458    /// succ walk (`(u, v, d) for u in _adj for v in _adj[u]`), which
459    /// recreates succ rows in their original order but fills each pred
460    /// row in walk order: `(pos(u), index of v within succ[u])`. Call on
461    /// a fresh clone inside copy-shaped constructors; graph content is
462    /// unchanged, only pred row order moves.
463    pub fn reorder_pred_rows_for_nx_copy_walk(&mut self) {
464        // br-r37-c1-predrebuild: rebuild pred rows in nx's u-major copy-walk order
465        // DIRECTLY from succ rows in O(E), replacing the prior per-pred-entry
466        // linear search `succ_indices[u].position(|w| w == v)` (O(E * degree) —
467        // quadratic on dense graphs). Iterating succ in (u, succ-index) order and
468        // appending u to pred[v] reproduces exactly the
469        // (pos(u), index-of-v-within-succ[u]) ordering the old sort produced,
470        // including parallel-edge multiplicity — byte-identical pred row order.
471        let mut new_rows: Vec<Vec<usize>> = self
472            .pred_indices
473            .iter()
474            .map(|row| Vec::with_capacity(row.len()))
475            .collect();
476        for (u, succ_row) in self.succ_indices.iter().enumerate() {
477            for &v in succ_row {
478                new_rows[v].push(u);
479            }
480        }
481        self.pred_indices = new_rows;
482    }
483
484    #[must_use]
485    pub fn predecessors_iter(&self, node: &str) -> Option<impl Iterator<Item = &str> + '_> {
486        // br-r37-c1-d58s8 DiGraph flip P2: index-backed.
487        let idx = self.nodes.get_index_of(node)?;
488        Some(self.pred_indices[idx].iter().map(move |&i| {
489            self.nodes
490                .get_index(i)
491                .expect("index rows hold valid node indices")
492                .0
493                .as_str()
494        }))
495    }
496
497    /// Neighbors = successors (matches NetworkX `DiGraph.neighbors()` convention).
498    #[must_use]
499    pub fn neighbors(&self, node: &str) -> Option<Vec<&str>> {
500        self.successors(node)
501    }
502
503    #[must_use]
504    pub fn neighbors_iter(&self, node: &str) -> Option<impl Iterator<Item = &str> + '_> {
505        self.successors_iter(node)
506    }
507
508    #[must_use]
509    pub fn neighbor_count(&self, node: &str) -> usize {
510        // br-r37-c1-d58s8 DiGraph flip P2: index-backed.
511        self.nodes
512            .get_index_of(node)
513            .map_or(0, |i| self.succ_indices[i].len())
514    }
515
516    /// Out-degree: number of successors.
517    #[must_use]
518    pub fn out_degree(&self, node: &str) -> usize {
519        self.nodes
520            .get_index_of(node)
521            .map_or(0, |i| self.succ_indices[i].len())
522    }
523
524    /// In-degree: number of predecessors.
525    #[must_use]
526    pub fn in_degree(&self, node: &str) -> usize {
527        self.nodes
528            .get_index_of(node)
529            .map_or(0, |i| self.pred_indices[i].len())
530    }
531
532    /// Total degree: in_degree + out_degree.
533    #[must_use]
534    pub fn degree(&self, node: &str) -> usize {
535        self.in_degree(node) + self.out_degree(node)
536    }
537
538    /// br-r37-c1-degidx: index-based degree accessors — O(1), zero
539    /// String hashing (the &str path costs get_index_of per call;
540    /// degree pays it twice). Used by the directed DegreeView iterator
541    /// which walks nodes in index order.
542    #[must_use]
543    pub fn out_degree_by_index(&self, idx: usize) -> usize {
544        self.succ_indices[idx].len()
545    }
546
547    #[must_use]
548    pub fn in_degree_by_index(&self, idx: usize) -> usize {
549        self.pred_indices[idx].len()
550    }
551
552    #[must_use]
553    pub fn degree_by_index(&self, idx: usize) -> usize {
554        self.succ_indices[idx].len() + self.pred_indices[idx].len()
555    }
556
557    /// Outgoing edges from `node` as (source, target) pairs.
558    #[must_use]
559    pub fn out_edges<'a>(&'a self, node: &'a str) -> Vec<(&'a str, &'a str)> {
560        // br-r37-c1-d58s8 DiGraph flip P2: index-backed.
561        self.nodes.get_index_of(node).map_or_else(Vec::new, |idx| {
562            self.succ_indices[idx]
563                .iter()
564                .map(|&i| {
565                    (
566                        node,
567                        self.nodes
568                            .get_index(i)
569                            .expect("index rows hold valid node indices")
570                            .0
571                            .as_str(),
572                    )
573                })
574                .collect()
575        })
576    }
577
578    /// Incoming edges to `node` as (source, target) pairs.
579    #[must_use]
580    pub fn in_edges<'a>(&'a self, node: &'a str) -> Vec<(&'a str, &'a str)> {
581        // br-r37-c1-d58s8 DiGraph flip P2: index-backed.
582        self.nodes.get_index_of(node).map_or_else(Vec::new, |idx| {
583            self.pred_indices[idx]
584                .iter()
585                .map(|&i| {
586                    (
587                        self.nodes
588                            .get_index(i)
589                            .expect("index rows hold valid node indices")
590                            .0
591                            .as_str(),
592                        node,
593                    )
594                })
595                .collect()
596        })
597    }
598
599    // -- Attribute queries -------------------------------------------------
600
601    #[must_use]
602    pub fn node_attrs(&self, node: &str) -> Option<&AttrMap> {
603        self.nodes.get(node)
604    }
605
606    /// Attributes of directed edge source→target.
607    #[must_use]
608    pub fn edge_attrs(&self, source: &str, target: &str) -> Option<&AttrMap> {
609        self.edges.get(&self.edge_pair_key(source, target)?)
610    }
611
612    /// br-r37-c1-d58s8: index-keyed attr access (oriented). Zero
613    /// node-map probes for callers already holding indices.
614    #[must_use]
615    #[inline]
616    pub fn edge_attrs_by_indices(&self, source_idx: usize, target_idx: usize) -> Option<&AttrMap> {
617        self.edges.get(&(source_idx, target_idx))
618    }
619
620    /// br-r37-c1-prdir: iterate every directed edge as
621    /// `((source_idx, target_idx), &AttrMap)` straight from the index-keyed edge
622    /// store, in insertion order. Lets COO/CSR exporters read each edge's weight
623    /// with ONE attr lookup instead of the per-edge `edges.get(&(u,v))` hash that
624    /// the successors-then-`edge_attrs_by_indices` path pays.
625    pub fn edges_indexed(&self) -> impl Iterator<Item = ((usize, usize), &AttrMap)> + '_ {
626        self.edges.iter().map(|(&pair, attrs)| (pair, attrs))
627    }
628
629    #[must_use]
630    pub fn evidence_ledger(&self) -> &EvidenceLedger {
631        self.runtime_policy.decision_log()
632    }
633
634    #[must_use]
635    pub fn runtime_policy(&self) -> &RuntimePolicy {
636        &self.runtime_policy
637    }
638
639    pub fn set_runtime_policy(&mut self, runtime_policy: RuntimePolicy) {
640        self.mode = runtime_policy.mode();
641        self.runtime_policy = runtime_policy;
642    }
643
644    /// Type identity: always `true` for DiGraph.
645    #[must_use]
646    pub fn is_directed(&self) -> bool {
647        true
648    }
649
650    /// Type identity: always `false` for DiGraph (not a multigraph).
651    #[must_use]
652    pub fn is_multigraph(&self) -> bool {
653        false
654    }
655
656    // -----------------------------------------------------------------------
657    // Mutations
658    // -----------------------------------------------------------------------
659
660    pub fn add_node(&mut self, node: impl Into<String>) -> bool {
661        self.add_node_with_attrs(node, AttrMap::new())
662    }
663
664    pub fn add_node_with_attrs(&mut self, node: impl Into<String>, attrs: AttrMap) -> bool {
665        let node = node.into();
666        let existed = self.nodes.contains_key(&node);
667        let mut changed = !existed;
668        let attrs_count = {
669            let bucket = self.nodes.entry(node.clone()).or_default();
670            if !attrs.is_empty()
671                && attrs
672                    .iter()
673                    .any(|(key, value)| bucket.get(key) != Some(value))
674            {
675                changed = true;
676            }
677            bucket.extend(attrs);
678            bucket.len()
679        };
680        if self.succ_indices.len() < self.nodes.len() {
681            self.succ_indices.push(Vec::new());
682            self.pred_indices.push(Vec::new());
683        }
684        if changed {
685            self.revision = self.revision.saturating_add(1);
686        }
687        self.record_decision(
688            "add_node",
689            0.0,
690            false,
691            vec![
692                EvidenceTerm {
693                    signal: "node_preexisting".to_owned(),
694                    observed_value: existed.to_string(),
695                    log_likelihood_ratio: -3.0,
696                },
697                EvidenceTerm {
698                    signal: "attrs_count".to_owned(),
699                    observed_value: attrs_count.to_string(),
700                    log_likelihood_ratio: -1.0,
701                },
702            ],
703        );
704        changed
705    }
706
707    pub fn apply_node_defaults(&mut self, defaults: &AttrMap) -> bool {
708        if defaults.is_empty() {
709            return false;
710        }
711        let mut inserted = 0usize;
712        for attrs in self.nodes.values_mut() {
713            for (key, value) in defaults {
714                if !attrs.contains_key(key) {
715                    attrs.insert(key.clone(), value.clone());
716                    inserted += 1;
717                }
718            }
719        }
720        if inserted > 0 {
721            self.revision = self.revision.saturating_add(1);
722            self.record_decision(
723                "apply_node_defaults",
724                0.0,
725                false,
726                vec![EvidenceTerm {
727                    signal: "defaults_applied".to_owned(),
728                    observed_value: inserted.to_string(),
729                    log_likelihood_ratio: 0.0,
730                }],
731            );
732            return true;
733        }
734        false
735    }
736
737    pub fn add_edge(
738        &mut self,
739        source: impl Into<String>,
740        target: impl Into<String>,
741    ) -> Result<(), GraphError> {
742        self.add_edge_with_attrs(source, target, AttrMap::new())
743    }
744
745    pub fn add_edge_with_attrs(
746        &mut self,
747        source: impl Into<String>,
748        target: impl Into<String>,
749        attrs: AttrMap,
750    ) -> Result<(), GraphError> {
751        let source = source.into();
752        let target = target.into();
753
754        let unknown_feature = attrs
755            .keys()
756            .any(|key| key.starts_with("__fnx_incompatible"));
757        let self_loop = source == target;
758        let incompatibility_probability = if unknown_feature {
759            1.0
760        } else if self_loop {
761            0.22
762        } else {
763            0.08
764        };
765
766        let action = self.record_decision(
767            "add_edge",
768            incompatibility_probability,
769            unknown_feature,
770            vec![EvidenceTerm {
771                signal: "unknown_incompatible_feature".to_owned(),
772                observed_value: unknown_feature.to_string(),
773                log_likelihood_ratio: 12.0,
774            }],
775        );
776
777        if action == DecisionAction::FailClosed || action == DecisionAction::FullValidate {
778            return Err(GraphError::FailClosed {
779                operation: "add_edge",
780                reason: "incompatible edge metadata".to_owned(),
781            });
782        }
783
784        // Auto-create nodes.
785        let mut source_autocreated = false;
786        if !self.nodes.contains_key(&source) {
787            let _ = self.add_node(source.clone());
788            source_autocreated = true;
789        }
790        let mut target_autocreated = false;
791        if self_loop {
792            target_autocreated = source_autocreated;
793        } else if !self.nodes.contains_key(&target) {
794            let _ = self.add_node(target.clone());
795            target_autocreated = true;
796        }
797
798        let edge_key = (
799            self.nodes.get_index_of(&source).expect("autocreated above"),
800            self.nodes.get_index_of(&target).expect("autocreated above"),
801        );
802        let new_edge = !self.edges.contains_key(&edge_key);
803        let mut changed = new_edge;
804        let edge_attr_count = {
805            let edge_attrs = self.edges.entry(edge_key).or_default();
806            if !attrs.is_empty()
807                && attrs
808                    .iter()
809                    .any(|(key, value)| edge_attrs.get(key) != Some(value))
810            {
811                changed = true;
812            }
813            edge_attrs.extend(attrs);
814            edge_attrs.len()
815        };
816        // br-r37-c1-d58s8 DiGraph flip P1: eager index rows (dup-guarded
817        // by edge newness; the IndexSet inserts above are idempotent).
818        if new_edge {
819            let s_idx = self
820                .nodes
821                .get_index_of(&source)
822                .expect("source node exists");
823            let t_idx = self
824                .nodes
825                .get_index_of(&target)
826                .expect("target node exists");
827            self.succ_indices[s_idx].push(t_idx);
828            self.pred_indices[t_idx].push(s_idx);
829        }
830
831        if changed {
832            self.revision = self.revision.saturating_add(1);
833        }
834
835        self.record_decision(
836            "add_edge",
837            incompatibility_probability,
838            unknown_feature,
839            vec![
840                EvidenceTerm {
841                    signal: "self_loop".to_owned(),
842                    observed_value: self_loop.to_string(),
843                    log_likelihood_ratio: -0.5,
844                },
845                EvidenceTerm {
846                    signal: "edge_attr_count".to_owned(),
847                    observed_value: edge_attr_count.to_string(),
848                    log_likelihood_ratio: -2.0,
849                },
850                EvidenceTerm {
851                    signal: "source_autocreated".to_owned(),
852                    observed_value: source_autocreated.to_string(),
853                    log_likelihood_ratio: -1.25,
854                },
855                EvidenceTerm {
856                    signal: "target_autocreated".to_owned(),
857                    observed_value: target_autocreated.to_string(),
858                    log_likelihood_ratio: -1.25,
859                },
860            ],
861        );
862
863        Ok(())
864    }
865
866    /// Bulk-add a sequence of attribute-free directed edges. Bypasses
867    /// the per-edge `runtime_policy.record_decision` call that
868    /// [`add_edge_with_attrs`] makes. Instead, emit one summary record
869    /// for the whole batch.
870    ///
871    /// Intended for fnx-internal callers that build a fresh graph
872    /// from a known-good edge list where per-edge compatibility
873    /// accounting would add constant-factor overhead without adding
874    /// useful policy evidence. Nodes referenced by edges are
875    /// auto-created if absent, matching `add_edge` semantics.
876    #[must_use]
877    pub fn extend_edges_unrecorded<I, S, T>(&mut self, edges: I) -> usize
878    where
879        I: IntoIterator<Item = (S, T)>,
880        S: Into<String>,
881        T: Into<String>,
882    {
883        // br-r37-c1-digbatch: compute each endpoint's index ONCE via match-insert
884        // (mirroring Graph::extend_edges_unrecorded) instead of contains_key + TWO
885        // get_index_of per endpoint. The old form did ~6 IndexMap hashes/edge, so
886        // DiGraph.add_edges_from ran 4x slower than Graph's (taxing every DiGraph-
887        // returning constructor). Reserve the collections up front as well.
888        let iterator = edges.into_iter();
889        let (lower_bound, _) = iterator.size_hint();
890        self.nodes.reserve(lower_bound);
891        self.succ_indices.reserve(lower_bound);
892        self.pred_indices.reserve(lower_bound);
893        self.edges.reserve(lower_bound);
894
895        let mut inserted = 0usize;
896        for (source, target) in iterator {
897            let source = source.into();
898            let target = target.into();
899            let s_idx = match self.nodes.get_index_of(&source) {
900                Some(index) => index,
901                None => {
902                    let index = self.nodes.len();
903                    self.nodes.insert(source.clone(), AttrMap::new());
904                    self.succ_indices.push(Vec::new());
905                    self.pred_indices.push(Vec::new());
906                    index
907                }
908            };
909            let t_idx = if source == target {
910                s_idx
911            } else {
912                match self.nodes.get_index_of(&target) {
913                    Some(index) => index,
914                    None => {
915                        let index = self.nodes.len();
916                        self.nodes.insert(target.clone(), AttrMap::new());
917                        self.succ_indices.push(Vec::new());
918                        self.pred_indices.push(Vec::new());
919                        index
920                    }
921                }
922            };
923            let edge_key = (s_idx, t_idx);
924            if self.edges.contains_key(&edge_key) {
925                continue;
926            }
927            self.edges.insert(edge_key, AttrMap::new());
928            self.succ_indices[s_idx].push(t_idx);
929            self.pred_indices[t_idx].push(s_idx);
930            inserted += 1;
931        }
932
933        if inserted > 0 {
934            self.revision = self
935                .revision
936                .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
937            self.record_decision(
938                "extend_edges_unrecorded",
939                0.0,
940                false,
941                vec![EvidenceTerm {
942                    signal: "batch_edge_count".to_owned(),
943                    observed_value: inserted.to_string(),
944                    log_likelihood_ratio: -1.0,
945                }],
946            );
947        }
948
949        inserted
950    }
951
952    /// br-r37-c1-digbatch: bulk-add plain nodes (no attrs), one summary ledger
953    /// record — the directed sibling of `Graph::extend_nodes_unrecorded`, backing
954    /// PyDiGraph's fast integer-node path (`add_nodes_from(range)` / int list).
955    /// First insertion wins order; existing nodes are skipped.
956    pub fn extend_nodes_unrecorded<I, N>(&mut self, nodes: I) -> usize
957    where
958        I: IntoIterator<Item = N>,
959        N: Into<String>,
960    {
961        let iterator = nodes.into_iter();
962        let (lower_bound, _) = iterator.size_hint();
963        self.nodes.reserve(lower_bound);
964        self.succ_indices.reserve(lower_bound);
965        self.pred_indices.reserve(lower_bound);
966
967        let mut inserted = 0usize;
968        for node in iterator {
969            let node = node.into();
970            if self.nodes.contains_key(&node) {
971                continue;
972            }
973            self.nodes.insert(node.clone(), AttrMap::new());
974            self.succ_indices.push(Vec::new());
975            self.pred_indices.push(Vec::new());
976            inserted += 1;
977        }
978        if inserted > 0 {
979            self.revision = self
980                .revision
981                .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
982            self.record_decision(
983                "extend_nodes_unrecorded",
984                0.0,
985                false,
986                vec![EvidenceTerm {
987                    signal: "batch_node_count".to_owned(),
988                    observed_value: inserted.to_string(),
989                    log_likelihood_ratio: -1.0,
990                }],
991            );
992        }
993        inserted
994    }
995
996    /// br-r37-c1-dgctor: bulk-add nodes WITH attrs, one summary ledger
997    /// record — the directed sibling of `Graph::extend_nodes_unrecorded`
998    /// extended with per-node AttrMaps. First insertion wins the order;
999    /// re-inserting an existing node merges attrs (matching
1000    /// `add_node_with_attrs`).
1001    #[must_use]
1002    pub fn extend_nodes_with_attrs_unrecorded<I>(&mut self, nodes: I) -> usize
1003    where
1004        I: IntoIterator<Item = (String, AttrMap)>,
1005    {
1006        let mut inserted = 0usize;
1007        for (node, attrs) in nodes {
1008            if let Some(existing) = self.nodes.get_mut(&node) {
1009                existing.extend(attrs);
1010                continue;
1011            }
1012            self.nodes.insert(node.clone(), attrs);
1013            self.succ_indices.push(Vec::new());
1014            self.pred_indices.push(Vec::new());
1015            inserted += 1;
1016        }
1017        if inserted > 0 {
1018            self.revision = self
1019                .revision
1020                .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
1021            self.record_decision(
1022                "extend_nodes_unrecorded",
1023                0.0,
1024                false,
1025                vec![EvidenceTerm {
1026                    signal: "batch_node_count".to_owned(),
1027                    observed_value: inserted.to_string(),
1028                    log_likelihood_ratio: -1.0,
1029                }],
1030            );
1031        }
1032        inserted
1033    }
1034
1035    /// br-r37-c1-dgctor: bulk-add ATTRIBUTED directed edges without
1036    /// per-edge ledger records — the directed sibling of
1037    /// `Graph::extend_edges_with_attrs_unrecorded`. Insert-or-MERGE
1038    /// (duplicate (source, target) extends the existing AttrMap, matching
1039    /// `add_edge_with_attrs`); nodes auto-created in first-appearance
1040    /// order. Callers must pre-screen "__fnx_incompatible" attr keys.
1041    #[must_use]
1042    pub fn extend_edges_with_attrs_unrecorded<I>(&mut self, edges: I) -> usize
1043    where
1044        I: IntoIterator<Item = (String, String, AttrMap)>,
1045    {
1046        let mut inserted = 0usize;
1047        let mut merged_changed = false;
1048        for (source, target, attrs) in edges {
1049            if !self.nodes.contains_key(&source) {
1050                self.nodes.insert(source.clone(), AttrMap::new());
1051                self.succ_indices.push(Vec::new());
1052                self.pred_indices.push(Vec::new());
1053            }
1054            if source != target && !self.nodes.contains_key(&target) {
1055                self.nodes.insert(target.clone(), AttrMap::new());
1056                self.succ_indices.push(Vec::new());
1057                self.pred_indices.push(Vec::new());
1058            }
1059            let edge_key = (
1060                self.nodes.get_index_of(&source).expect("created above"),
1061                self.nodes.get_index_of(&target).expect("created above"),
1062            );
1063            if let Some(existing) = self.edges.get_mut(&edge_key) {
1064                if !attrs.is_empty()
1065                    && attrs
1066                        .iter()
1067                        .any(|(key, value)| existing.get(key) != Some(value))
1068                {
1069                    merged_changed = true;
1070                }
1071                existing.extend(attrs);
1072                continue;
1073            }
1074            self.edges.insert(edge_key, attrs);
1075            let s_idx = self
1076                .nodes
1077                .get_index_of(&source)
1078                .expect("source node exists");
1079            let t_idx = self
1080                .nodes
1081                .get_index_of(&target)
1082                .expect("target node exists");
1083            self.succ_indices[s_idx].push(t_idx);
1084            self.pred_indices[t_idx].push(s_idx);
1085            inserted += 1;
1086        }
1087        if inserted > 0 || merged_changed {
1088            self.revision = self
1089                .revision
1090                .saturating_add(u64::try_from(inserted.max(1)).unwrap_or(u64::MAX));
1091            self.record_decision(
1092                "extend_edges_unrecorded",
1093                0.0,
1094                false,
1095                vec![EvidenceTerm {
1096                    signal: "batch_edge_count".to_owned(),
1097                    observed_value: inserted.to_string(),
1098                    log_likelihood_ratio: -1.0,
1099                }],
1100            );
1101        }
1102        inserted
1103    }
1104
1105    /// Bulk-add ATTRIBUTED directed edges when the caller has already
1106    /// identified newly-created nodes. This keeps edge-map insertion in global
1107    /// input order, then stages successor and predecessor row commits by row so
1108    /// adjacency maps are probed once per touched row rather than once per edge.
1109    #[must_use]
1110    pub fn extend_prepared_edges_with_attrs_row_staged_unrecorded<I, N>(
1111        &mut self,
1112        new_nodes: N,
1113        edges: I,
1114    ) -> usize
1115    where
1116        I: IntoIterator<Item = (String, String, AttrMap)>,
1117        N: IntoIterator<Item = String>,
1118    {
1119        for node in new_nodes {
1120            if self.nodes.contains_key(&node) {
1121                continue;
1122            }
1123            self.nodes.insert(node.clone(), AttrMap::new());
1124            self.succ_indices.push(Vec::new());
1125            self.pred_indices.push(Vec::new());
1126        }
1127
1128        let mut inserted = 0usize;
1129        let mut merged_changed = false;
1130        let mut successor_rows: IndexMap<String, Vec<String>> = IndexMap::new();
1131        let mut predecessor_rows: IndexMap<String, Vec<String>> = IndexMap::new();
1132
1133        for (source, target, attrs) in edges {
1134            let edge_key = (
1135                self.nodes.get_index_of(&source).expect("created above"),
1136                self.nodes.get_index_of(&target).expect("created above"),
1137            );
1138            if let Some(existing) = self.edges.get_mut(&edge_key) {
1139                if !attrs.is_empty()
1140                    && attrs
1141                        .iter()
1142                        .any(|(key, value)| existing.get(key) != Some(value))
1143                {
1144                    merged_changed = true;
1145                }
1146                existing.extend(attrs);
1147                continue;
1148            }
1149
1150            successor_rows
1151                .entry(source.clone())
1152                .or_default()
1153                .push(target.clone());
1154            predecessor_rows
1155                .entry(target.clone())
1156                .or_default()
1157                .push(source.clone());
1158            let s_idx = self
1159                .nodes
1160                .get_index_of(&source)
1161                .expect("source node exists");
1162            let t_idx = self
1163                .nodes
1164                .get_index_of(&target)
1165                .expect("target node exists");
1166            self.succ_indices[s_idx].push(t_idx);
1167            self.pred_indices[t_idx].push(s_idx);
1168            self.edges.insert(edge_key, attrs);
1169            inserted += 1;
1170        }
1171
1172        if inserted > 0 || merged_changed {
1173            self.revision = self
1174                .revision
1175                .saturating_add(u64::try_from(inserted.max(1)).unwrap_or(u64::MAX));
1176            self.record_decision(
1177                "extend_edges_unrecorded",
1178                0.0,
1179                false,
1180                vec![EvidenceTerm {
1181                    signal: "batch_edge_count".to_owned(),
1182                    observed_value: inserted.to_string(),
1183                    log_likelihood_ratio: -1.0,
1184                }],
1185            );
1186        }
1187
1188        inserted
1189    }
1190
1191    /// Bulk-add attributed directed edges into a fresh graph when the caller has
1192    /// already assigned NetworkX-compatible node indices in first-seen order.
1193    ///
1194    /// This bypasses the string-key lookup path in
1195    /// [`extend_prepared_edges_with_attrs_row_staged_unrecorded`]: node labels
1196    /// are installed once, then edges are committed directly by `(source_idx,
1197    /// target_idx)`. Duplicate directed edges merge attributes without appending
1198    /// another successor/predecessor entry, matching `DiGraph.add_edges_from`.
1199    #[must_use]
1200    /// br-r37-c1-dodattrbatch: bulk-add ATTRIBUTED directed edges by EXISTING
1201    /// node index. All endpoints MUST already exist (callers gate on int-labeled
1202    /// nodes). A new edge takes the AttrMap; a duplicate (source, target) MERGES
1203    /// last-wins like add_edge_with_attrs. Sibling of the fresh-index attr extend
1204    /// minus node creation; integer index rows only (no String hashing).
1205    pub fn extend_existing_index_edges_with_attrs_unrecorded<I>(&mut self, edges: I) -> usize
1206    where
1207        I: IntoIterator<Item = (usize, usize, AttrMap)>,
1208    {
1209        let node_count = self.nodes.len();
1210        let edges = edges.into_iter();
1211        self.edges.reserve(edges.size_hint().0);
1212        let mut inserted = 0usize;
1213        let mut merged_changed = false;
1214        for (source_idx, target_idx, attrs) in edges {
1215            debug_assert!(source_idx < node_count && target_idx < node_count);
1216            let edge_key = (source_idx, target_idx);
1217            if let Some(existing) = self.edges.get_mut(&edge_key) {
1218                if !attrs.is_empty()
1219                    && attrs
1220                        .iter()
1221                        .any(|(key, value)| existing.get(key) != Some(value))
1222                {
1223                    merged_changed = true;
1224                }
1225                existing.extend(attrs);
1226                continue;
1227            }
1228            self.edges.insert(edge_key, attrs);
1229            self.succ_indices[source_idx].push(target_idx);
1230            self.pred_indices[target_idx].push(source_idx);
1231            inserted += 1;
1232        }
1233        if inserted > 0 || merged_changed {
1234            self.revision = self
1235                .revision
1236                .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
1237        }
1238        inserted
1239    }
1240
1241    pub fn extend_fresh_index_edges_with_attrs_unrecorded<I, N>(
1242        &mut self,
1243        nodes: N,
1244        edges: I,
1245    ) -> usize
1246    where
1247        I: IntoIterator<Item = (usize, usize, AttrMap)>,
1248        N: IntoIterator<Item = String>,
1249    {
1250        if !self.nodes.is_empty()
1251            || !self.succ_indices.is_empty()
1252            || !self.pred_indices.is_empty()
1253            || !self.edges.is_empty()
1254        {
1255            return 0;
1256        }
1257
1258        let node_labels: Vec<String> = nodes.into_iter().collect();
1259        self.nodes.reserve(node_labels.len());
1260        for node in node_labels {
1261            self.nodes.insert(node, AttrMap::new());
1262        }
1263        let node_count = self.nodes.len();
1264        self.succ_indices = vec![Vec::new(); node_count];
1265        self.pred_indices = vec![Vec::new(); node_count];
1266
1267        let mut inserted = 0usize;
1268        let mut merged_changed = false;
1269        let edges = edges.into_iter();
1270        self.edges.reserve(edges.size_hint().0);
1271        for (source_idx, target_idx, attrs) in edges {
1272            if source_idx >= node_count || target_idx >= node_count {
1273                continue;
1274            }
1275            let edge_key = (source_idx, target_idx);
1276            if let Some(existing) = self.edges.get_mut(&edge_key) {
1277                if !attrs.is_empty()
1278                    && attrs
1279                        .iter()
1280                        .any(|(key, value)| existing.get(key) != Some(value))
1281                {
1282                    merged_changed = true;
1283                }
1284                existing.extend(attrs);
1285                continue;
1286            }
1287            self.edges.insert(edge_key, attrs);
1288            self.succ_indices[source_idx].push(target_idx);
1289            self.pred_indices[target_idx].push(source_idx);
1290            inserted += 1;
1291        }
1292
1293        if node_count > 0 || inserted > 0 || merged_changed {
1294            self.revision = self.revision.saturating_add(
1295                u64::try_from(node_count.saturating_add(inserted)).unwrap_or(u64::MAX),
1296            );
1297            self.record_decision(
1298                "extend_fresh_index_edges_unrecorded",
1299                0.0,
1300                false,
1301                vec![
1302                    EvidenceTerm {
1303                        signal: "batch_node_count".to_owned(),
1304                        observed_value: node_count.to_string(),
1305                        log_likelihood_ratio: -1.0,
1306                    },
1307                    EvidenceTerm {
1308                        signal: "batch_edge_count".to_owned(),
1309                        observed_value: inserted.to_string(),
1310                        log_likelihood_ratio: -1.0,
1311                    },
1312                ],
1313            );
1314        }
1315
1316        inserted
1317    }
1318
1319    pub fn apply_edge_defaults(&mut self, defaults: &AttrMap) -> bool {
1320        if defaults.is_empty() {
1321            return false;
1322        }
1323        let mut inserted = 0usize;
1324        for attrs in self.edges.values_mut() {
1325            for (key, value) in defaults {
1326                if !attrs.contains_key(key) {
1327                    attrs.insert(key.clone(), value.clone());
1328                    inserted += 1;
1329                }
1330            }
1331        }
1332        if inserted > 0 {
1333            self.revision = self.revision.saturating_add(1);
1334            self.record_decision(
1335                "apply_edge_defaults",
1336                0.0,
1337                false,
1338                vec![EvidenceTerm {
1339                    signal: "defaults_applied".to_owned(),
1340                    observed_value: inserted.to_string(),
1341                    log_likelihood_ratio: 0.0,
1342                }],
1343            );
1344            return true;
1345        }
1346        false
1347    }
1348
1349    /// br-r37-c1-sjf4t: overwrite the attribute map for an existing edge.
1350    pub fn replace_edge_attrs(&mut self, source: &str, target: &str, attrs: AttrMap) -> bool {
1351        let Some(edge_key) = self.edge_pair_key(source, target) else {
1352            return false;
1353        };
1354        if let Some(slot) = self.edges.get_mut(&edge_key) {
1355            if *slot != attrs {
1356                *slot = attrs;
1357                self.revision = self.revision.saturating_add(1);
1358            }
1359            true
1360        } else {
1361            false
1362        }
1363    }
1364
1365    /// br-r37-c1-sjf4t: overwrite the attribute map for an existing node.
1366    pub fn replace_node_attrs(&mut self, node: &str, attrs: AttrMap) -> bool {
1367        if let Some(slot) = self.nodes.get_mut(node) {
1368            if *slot != attrs {
1369                *slot = attrs;
1370                self.revision = self.revision.saturating_add(1);
1371            }
1372            true
1373        } else {
1374            false
1375        }
1376    }
1377
1378    /// Remove directed edge source→target. Returns `true` if it existed.
1379    pub fn remove_edge(&mut self, source: &str, target: &str) -> bool {
1380        let Some(pair) = self.edge_pair_key(source, target) else {
1381            return false;
1382        };
1383        let removed = self.edges.shift_remove(&pair).is_some();
1384        if removed {
1385            if let (Some(s_idx), Some(t_idx)) = (
1386                self.nodes.get_index_of(source),
1387                self.nodes.get_index_of(target),
1388            ) {
1389                self.succ_indices[s_idx].retain(|&i| i != t_idx);
1390                self.pred_indices[t_idx].retain(|&i| i != s_idx);
1391            }
1392            self.revision = self.revision.saturating_add(1);
1393        }
1394        removed
1395    }
1396
1397    /// Remove node and all incident edges (both incoming and outgoing).
1398    pub fn remove_node(&mut self, node: &str) -> bool {
1399        if !self.nodes.contains_key(node) {
1400            return false;
1401        }
1402
1403        // br-r37-c1-rmnode-di2: drop each incident edge with O(1) `swap_remove`
1404        // (the `edges` map order is never observed externally — `edges_ordered`,
1405        // used by every public consumer, walks node->successor order, and the one
1406        // internal map-order use, `to_undirected`, was canonicalised). The
1407        // incident edges are known exactly from successors/predecessors, so the
1408        // whole removal is O(degree) instead of the O(|E|) `retain` scan (let
1409        // alone the original O(degree*|E|) per-edge shift_remove). Matches nx's
1410        // O(degree) remove_node.
1411        // br-r37-c1-d58s8 P3: incident-edge walk from the index rows;
1412        // the per-neighbor String-row maintenance vanished with the rows
1413        // (the index repair below handles the integer side).
1414        let node_idx = self
1415            .nodes
1416            .get_index_of(node)
1417            .expect("node existence checked above");
1418        let targets: Vec<usize> = self.succ_indices[node_idx].clone();
1419        for t in targets {
1420            self.edges.swap_remove(&(node_idx, t));
1421        }
1422        let sources: Vec<usize> = self.pred_indices[node_idx].clone();
1423        for s in sources {
1424            self.edges.swap_remove(&(s, node_idx));
1425        }
1426
1427        // br-r37-c1-d58s8 DiGraph flip P1: I5 repair on both index
1428        // families — drop the removed index's rows, purge refs, decrement
1429        // indices that shift down.
1430        let idx = self
1431            .nodes
1432            .get_index_of(node)
1433            .expect("node existence checked above");
1434        self.nodes.shift_remove(node);
1435        self.succ_indices.remove(idx);
1436        self.pred_indices.remove(idx);
1437        for rows in [&mut self.succ_indices, &mut self.pred_indices] {
1438            for row in rows.iter_mut() {
1439                row.retain(|&e| e != idx);
1440                for e in row.iter_mut() {
1441                    if *e > idx {
1442                        *e -= 1;
1443                    }
1444                }
1445            }
1446        }
1447        // br-r37-c1-d58s8 edges-map flip: REKEY surviving edges past the
1448        // removed index (integer rehash, order preserved).
1449        self.edges = std::mem::take(&mut self.edges)
1450            .into_iter()
1451            .map(|((s, t), attrs)| {
1452                (
1453                    (
1454                        if s > idx { s - 1 } else { s },
1455                        if t > idx { t - 1 } else { t },
1456                    ),
1457                    attrs,
1458                )
1459            })
1460            .collect();
1461        self.revision = self.revision.saturating_add(1);
1462        true
1463    }
1464
1465    pub fn remove_nodes_from<'a, I>(&mut self, nodes: I) -> (usize, usize)
1466    where
1467        I: IntoIterator<Item = &'a str>,
1468    {
1469        let remove_set: std::collections::HashSet<&str> = nodes
1470            .into_iter()
1471            .filter(|node| self.nodes.contains_key(*node))
1472            .collect();
1473        if remove_set.is_empty() {
1474            return (0, 0);
1475        }
1476
1477        let old_node_count = self.nodes.len();
1478        let old_edge_count = self.edges.len();
1479
1480        // br-r37-c1-d58s8 DiGraph flip P1: removed-mask + old->new remap
1481        // BEFORE the map shifts; index rows filtered + remapped from the
1482        // OLD rows (the slice-1 recipe).
1483        let removed_mask: Vec<bool> = self
1484            .nodes
1485            .keys()
1486            .map(|k| remove_set.contains(k.as_str()))
1487            .collect();
1488        let mut remap: Vec<usize> = Vec::with_capacity(old_node_count);
1489        let mut next = 0usize;
1490        for &gone in &removed_mask {
1491            remap.push(next);
1492            if !gone {
1493                next += 1;
1494            }
1495        }
1496        for rows in [&mut self.succ_indices, &mut self.pred_indices] {
1497            let old_rows = std::mem::take(rows);
1498            *rows = old_rows
1499                .into_iter()
1500                .enumerate()
1501                .filter(|(old_idx, _)| !removed_mask[*old_idx])
1502                .map(|(_, row)| {
1503                    row.into_iter()
1504                        .filter(|&nbr| !removed_mask[nbr])
1505                        .map(|nbr| remap[nbr])
1506                        .collect()
1507                })
1508                .collect();
1509        }
1510        self.nodes
1511            .retain(|node, _| !remove_set.contains(node.as_str()));
1512        self.edges
1513            .retain(|&(s, t), _| !removed_mask[s] && !removed_mask[t]);
1514        // br-r37-c1-d58s8 edges-map flip: rekey survivors through the remap.
1515        self.edges = std::mem::take(&mut self.edges)
1516            .into_iter()
1517            .map(|((s, t), attrs)| ((remap[s], remap[t]), attrs))
1518            .collect();
1519
1520        let removed_nodes = old_node_count - self.nodes.len();
1521        let removed_edges = old_edge_count - self.edges.len();
1522        self.revision = self
1523            .revision
1524            .saturating_add(u64::try_from(removed_nodes).unwrap_or(u64::MAX));
1525        (removed_nodes, removed_edges)
1526    }
1527
1528    // -----------------------------------------------------------------------
1529    // Snapshot / ordered iteration
1530    // -----------------------------------------------------------------------
1531
1532    /// Edges in deterministic order: iterate nodes in insertion order, then
1533    /// each node's successors in insertion order.
1534    #[must_use]
1535    pub fn edges_ordered(&self) -> Vec<EdgeSnapshot> {
1536        let mut ordered = Vec::with_capacity(self.edges.len());
1537
1538        for (u, row) in self.succ_indices.iter().enumerate() {
1539            let node = self.nodes.get_index(u).expect("valid index").0;
1540            for &t in row {
1541                let target = self.nodes.get_index(t).expect("valid index").0;
1542                if let Some(attrs) = self.edges.get(&(u, t)) {
1543                    ordered.push(EdgeSnapshot {
1544                        left: node.clone(),
1545                        right: target.clone(),
1546                        attrs: attrs.clone(),
1547                    });
1548                }
1549            }
1550        }
1551
1552        ordered
1553    }
1554
1555    #[must_use]
1556    pub fn edges_ordered_borrowed(&self) -> Vec<(&str, &str, &AttrMap)> {
1557        let mut ordered = Vec::with_capacity(self.edges.len());
1558
1559        for (u, row) in self.succ_indices.iter().enumerate() {
1560            let node = self.nodes.get_index(u).expect("valid index").0;
1561            for &t in row {
1562                let target = self.nodes.get_index(t).expect("valid index").0;
1563                if let Some(attrs) = self.edges.get(&(u, t)) {
1564                    ordered.push((node.as_str(), target.as_str(), attrs));
1565                }
1566            }
1567        }
1568
1569        ordered
1570    }
1571
1572    /// br-r37-c1-revborrow: O(V+E) directed transpose. The reverse of a
1573    /// `DiGraph` keeps the SAME node table (identical insertion order, identical
1574    /// indices), so reversing is a pure topology flip — no String hashing, no
1575    /// per-edge node-index lookups, no re-insertion through the name table (the
1576    /// edge-iterate + `edges_ordered()` clone + `extend_edges_with_attrs_*`
1577    /// re-hash path is O(E) String/hash work this avoids entirely).
1578    ///
1579    /// Successor rows are rebuilt by walking the ORIGINAL successor rows in
1580    /// node-major (ascending source index) order and appending the reversed
1581    /// endpoint, so the reversed graph's `edges_ordered()` stream is
1582    /// byte-identical to NetworkX's `reverse()`, which walks `self.edges()`
1583    /// (u-major successor order) and adds each `(v, u)`. `pred_indices[u]` ends
1584    /// up equal to the original `succ_indices[u]`, matching NetworkX's reversed
1585    /// `pred` order as well. Node + edge attribute maps are cloned verbatim.
1586    #[must_use]
1587    pub fn reversed(&self) -> Self {
1588        let n = self.nodes.len();
1589        let mut succ_indices: Vec<Vec<usize>> = vec![Vec::new(); n];
1590        let mut pred_indices: Vec<Vec<usize>> = vec![Vec::new(); n];
1591        for (i, (succ_row, pred_row)) in succ_indices
1592            .iter_mut()
1593            .zip(pred_indices.iter_mut())
1594            .enumerate()
1595        {
1596            // reversed succ row = original pred row; reversed pred row = original
1597            // succ row — pre-size so neither row reallocates.
1598            succ_row.reserve(self.pred_indices[i].len());
1599            pred_row.reserve(self.succ_indices[i].len());
1600        }
1601        let mut edges: IndexMap<(usize, usize), AttrMap> =
1602            IndexMap::with_capacity(self.edges.len());
1603        for (u, (source_succs, reversed_preds)) in self
1604            .succ_indices
1605            .iter()
1606            .zip(pred_indices.iter_mut())
1607            .enumerate()
1608        {
1609            for &t in source_succs {
1610                // original edge u -> t becomes t -> u in the reverse
1611                succ_indices[t].push(u);
1612                reversed_preds.push(t);
1613                if let Some(attrs) = self.edges.get(&(u, t)) {
1614                    edges.insert((t, u), attrs.clone());
1615                }
1616            }
1617        }
1618        Self {
1619            mode: self.mode,
1620            revision: 0,
1621            nodes: self.nodes.clone(),
1622            succ_indices,
1623            pred_indices,
1624            edges,
1625            runtime_policy: self.runtime_policy.clone(),
1626            csr_cache: std::sync::Arc::default(),
1627            all_int_cache: std::sync::Arc::default(),
1628        }
1629    }
1630
1631    /// Directed edge endpoints in the same order as `edges_ordered_borrowed`.
1632    #[must_use]
1633    pub fn edges_ordered_indices(&self) -> Vec<(usize, usize)> {
1634        let mut ordered = Vec::with_capacity(self.edges.len());
1635
1636        for (u, row) in self.succ_indices.iter().enumerate() {
1637            for &t in row {
1638                if self.edges.contains_key(&(u, t)) {
1639                    ordered.push((u, t));
1640                }
1641            }
1642        }
1643
1644        ordered
1645    }
1646
1647    #[must_use]
1648    pub fn snapshot(&self) -> DiGraphSnapshot {
1649        let node_attrs: std::collections::BTreeMap<String, AttrMap> = self
1650            .nodes
1651            .iter()
1652            .filter(|(_, attrs)| !attrs.is_empty())
1653            .map(|(name, attrs)| (name.clone(), attrs.clone()))
1654            .collect();
1655        DiGraphSnapshot {
1656            mode: self.mode,
1657            nodes: self.nodes.keys().cloned().collect(),
1658            node_attrs,
1659            edges: self.edges_ordered(),
1660        }
1661    }
1662
1663    /// Convert to an undirected Graph by dropping directionality.
1664    /// Both (u→v) and (v→u) merge into a single undirected edge.
1665    /// When both exist, the latter's attributes overwrite the former's.
1666    #[must_use]
1667    pub fn to_undirected(&self) -> crate::Graph {
1668        let mut g = crate::Graph::with_runtime_policy(self.runtime_policy.clone());
1669        for (node, attrs) in &self.nodes {
1670            g.add_node_with_attrs(node.clone(), attrs.clone());
1671        }
1672        // Iterate in canonical node->successor order (`edges_ordered`) rather
1673        // than the private `edges` IndexMap order: this matches networkx's
1674        // reciprocal-edge merge order (for a<->b the later-processed direction's
1675        // attrs win) AND keeps the `edges` map order unobservable, so
1676        // `remove_node` can drop incident edges with O(1) swap_remove.
1677        for snap in self.edges_ordered() {
1678            let _ = g.add_edge_with_attrs(snap.left, snap.right, snap.attrs);
1679        }
1680        g
1681    }
1682
1683    // -----------------------------------------------------------------------
1684    // Internal
1685    // -----------------------------------------------------------------------
1686
1687    fn record_decision(
1688        &mut self,
1689        operation: &'static str,
1690        incompatibility_probability: f64,
1691        unknown_incompatible_feature: bool,
1692        evidence: Vec<EvidenceTerm>,
1693    ) -> DecisionAction {
1694        let action = self
1695            .runtime_policy
1696            .action_for(incompatibility_probability, unknown_incompatible_feature);
1697        self.runtime_policy.record(
1698            operation,
1699            action,
1700            incompatibility_probability,
1701            "argmin expected loss over {allow,full_validate,fail_closed}",
1702            evidence,
1703        );
1704        action
1705    }
1706}
1707
1708#[derive(Debug, Clone)]
1709pub struct MultiDiGraph {
1710    mode: CompatibilityMode,
1711    revision: u64,
1712    nodes: IndexMap<String, AttrMap>,
1713    successors: IndexMap<String, IndexMap<String, IndexSet<usize>>>,
1714    predecessors: IndexMap<String, IndexMap<String, IndexSet<usize>>>,
1715    edges: IndexMap<DirectedEdgeKey, IndexMap<usize, AttrMap>>,
1716    runtime_policy: RuntimePolicy,
1717    edge_count: usize,
1718}
1719
1720impl MultiDiGraph {
1721    /// br-r37-c1-7dpyg: structural clone with a FRESH RuntimePolicy —
1722    /// see Graph::clone_with_fresh_policy.
1723    #[must_use]
1724    pub fn clone_with_fresh_policy(&self) -> Self {
1725        Self {
1726            mode: self.mode,
1727            revision: self.revision,
1728            nodes: self.nodes.clone(),
1729            successors: self.successors.clone(),
1730            predecessors: self.predecessors.clone(),
1731            edges: self.edges.clone(),
1732            runtime_policy: RuntimePolicy::new(self.mode),
1733            edge_count: self.edge_count,
1734        }
1735    }
1736
1737    /// br-r37-c1-s0d4x: reorder every PRED row into NetworkX's
1738    /// `MultiDiGraph.copy()` walk order — the multigraph counterpart of
1739    /// DiGraph::reorder_pred_rows_for_nx_copy_walk: each pred row's cells
1740    /// sort by `(pos(u), index of v within succ[u])`; succ rows are
1741    /// recreated in their original order by the u-major walk.
1742    pub fn reorder_pred_rows_for_nx_copy_walk(&mut self) {
1743        // br-r37-c1-predrebuild: build each pred row's u-major copy-walk order
1744        // DIRECTLY by walking succ in pos(u) order, replacing the prior
1745        // per-pred-entry double `get_index_of` lookup + per-row sort. Each pred
1746        // row holds DISTINCT predecessors u, so pos(u) is the sole ordering key
1747        // (the succ-index tiebreak never fires), and appending u to pred[v] while
1748        // iterating succ in insertion (= position) order yields the exact same
1749        // ordering — byte-identical pred row order, no sort, no index lookups.
1750        let mut order_map: IndexMap<String, Vec<String>> =
1751            IndexMap::with_capacity(self.predecessors.len());
1752        for (u, succ_row) in &self.successors {
1753            for v in succ_row.keys() {
1754                order_map.entry(v.clone()).or_default().push(u.clone());
1755            }
1756        }
1757        let orders: Vec<(String, Vec<String>)> = order_map.into_iter().collect();
1758        self.apply_row_orders(&orders, true);
1759    }
1760
1761    /// br-r37-c1-u3qyn: restore explicit succ/pred row orders (pickle
1762    /// round-trip) — see Graph::apply_row_orders. Keyed cells move
1763    /// wholesale; `pred` selects the adjacency side.
1764    pub fn apply_row_orders(&mut self, orders: &[(String, Vec<String>)], pred: bool) {
1765        let map = if pred {
1766            &mut self.predecessors
1767        } else {
1768            &mut self.successors
1769        };
1770        for (node, order) in orders {
1771            let Some(row) = map.get_mut(node.as_str()) else {
1772                continue;
1773            };
1774            let mut old = std::mem::take(row);
1775            let mut new_row = IndexMap::with_capacity(old.len());
1776            for v in order {
1777                if let Some((k, val)) = old.shift_remove_entry(v.as_str()) {
1778                    new_row.insert(k, val);
1779                }
1780            }
1781            for (k, val) in old {
1782                new_row.insert(k, val);
1783            }
1784            *row = new_row;
1785        }
1786    }
1787
1788    #[must_use]
1789    pub fn new(mode: CompatibilityMode) -> Self {
1790        Self {
1791            mode,
1792            revision: 0,
1793            nodes: IndexMap::new(),
1794            successors: IndexMap::new(),
1795            predecessors: IndexMap::new(),
1796            edges: IndexMap::new(),
1797            runtime_policy: RuntimePolicy::new(mode),
1798            edge_count: 0,
1799        }
1800    }
1801
1802    #[must_use]
1803    pub fn with_runtime_policy(runtime_policy: RuntimePolicy) -> Self {
1804        let mode = runtime_policy.mode();
1805        Self {
1806            mode,
1807            revision: 0,
1808            nodes: IndexMap::new(),
1809            successors: IndexMap::new(),
1810            predecessors: IndexMap::new(),
1811            edges: IndexMap::new(),
1812            runtime_policy,
1813            edge_count: 0,
1814        }
1815    }
1816
1817    #[must_use]
1818    pub fn strict() -> Self {
1819        Self::new(CompatibilityMode::Strict)
1820    }
1821
1822    #[must_use]
1823    pub fn hardened() -> Self {
1824        Self::new(CompatibilityMode::Hardened)
1825    }
1826
1827    #[must_use]
1828    pub fn mode(&self) -> CompatibilityMode {
1829        self.mode
1830    }
1831
1832    #[must_use]
1833    pub fn node_count(&self) -> usize {
1834        self.nodes.len()
1835    }
1836
1837    #[must_use]
1838    pub fn edge_count(&self) -> usize {
1839        self.edge_count
1840    }
1841
1842    #[must_use]
1843    pub fn number_of_selfloops(&self) -> usize {
1844        self.edges
1845            .iter()
1846            .filter(|(edge_key, _)| edge_key.source == edge_key.target)
1847            .map(|(_, edge_bucket)| edge_bucket.len())
1848            .sum()
1849    }
1850
1851    /// Return an iterator over keys for edges from source to target.
1852    /// Edge keys as Vec.
1853    #[must_use]
1854    pub fn edge_keys(&self, source: &str, target: &str) -> Option<Vec<usize>> {
1855        self.successors
1856            .get(source)?
1857            .get(target)
1858            .map(|keys| keys.iter().copied().collect())
1859    }
1860
1861    /// Edge keys as iterator.
1862    pub fn edge_keys_iter(
1863        &self,
1864        source: &str,
1865        target: &str,
1866    ) -> Option<impl Iterator<Item = &usize>> {
1867        self.successors
1868            .get(source)?
1869            .get(target)
1870            .map(|keys| keys.iter())
1871    }
1872
1873    #[must_use]
1874    pub fn revision(&self) -> u64 {
1875        self.revision
1876    }
1877
1878    #[must_use]
1879    pub fn has_node(&self, node: &str) -> bool {
1880        self.nodes.contains_key(node)
1881    }
1882
1883    #[must_use]
1884    pub fn has_edge(&self, source: &str, target: &str) -> bool {
1885        self.edges
1886            .get(&DirectedEdgeKeyRef::new(source, target))
1887            .is_some_and(|edge_bucket| !edge_bucket.is_empty())
1888    }
1889
1890    #[must_use]
1891    pub fn nodes_ordered(&self) -> Vec<&str> {
1892        self.nodes.keys().map(String::as_str).collect()
1893    }
1894
1895    #[must_use]
1896    pub fn successors(&self, node: &str) -> Option<Vec<&str>> {
1897        self.successors
1898            .get(node)
1899            .map(|neighbors| neighbors.keys().map(String::as_str).collect::<Vec<&str>>())
1900    }
1901
1902    #[must_use]
1903    pub fn predecessors(&self, node: &str) -> Option<Vec<&str>> {
1904        self.predecessors
1905            .get(node)
1906            .map(|neighbors| neighbors.keys().map(String::as_str).collect::<Vec<&str>>())
1907    }
1908
1909    #[must_use]
1910    pub fn neighbors(&self, node: &str) -> Option<Vec<&str>> {
1911        self.successors(node)
1912    }
1913
1914    #[must_use]
1915    pub fn node_attrs(&self, node: &str) -> Option<&AttrMap> {
1916        self.nodes.get(node)
1917    }
1918
1919    #[must_use]
1920    pub fn edge_attrs(&self, source: &str, target: &str, key: usize) -> Option<&AttrMap> {
1921        self.edges
1922            .get(&DirectedEdgeKeyRef::new(source, target))
1923            .and_then(|edge_bucket| edge_bucket.get(&key))
1924    }
1925
1926    #[must_use]
1927    pub fn evidence_ledger(&self) -> &EvidenceLedger {
1928        self.runtime_policy.decision_log()
1929    }
1930
1931    #[must_use]
1932    pub fn runtime_policy(&self) -> &RuntimePolicy {
1933        &self.runtime_policy
1934    }
1935
1936    pub fn set_runtime_policy(&mut self, runtime_policy: RuntimePolicy) {
1937        self.mode = runtime_policy.mode();
1938        self.runtime_policy = runtime_policy;
1939    }
1940
1941    #[must_use]
1942    pub fn is_directed(&self) -> bool {
1943        true
1944    }
1945
1946    #[must_use]
1947    pub fn is_multigraph(&self) -> bool {
1948        true
1949    }
1950
1951    /// Return the out-degree of a node (number of outgoing parallel edges).
1952    #[must_use]
1953    pub fn out_degree(&self, node: &str) -> usize {
1954        self.successors
1955            .get(node)
1956            .map_or(0, |succs| succs.values().map(IndexSet::len).sum())
1957    }
1958
1959    /// Return the in-degree of a node (number of incoming parallel edges).
1960    #[must_use]
1961    pub fn in_degree(&self, node: &str) -> usize {
1962        self.predecessors
1963            .get(node)
1964            .map_or(0, |preds| preds.values().map(IndexSet::len).sum())
1965    }
1966
1967    /// Return the degree of a node (in-degree + out-degree).
1968    #[must_use]
1969    pub fn degree(&self, node: &str) -> usize {
1970        self.in_degree(node) + self.out_degree(node)
1971    }
1972
1973    pub fn add_node(&mut self, node: impl Into<String>) -> bool {
1974        self.add_node_with_attrs(node, AttrMap::new())
1975    }
1976
1977    pub fn add_node_with_attrs(&mut self, node: impl Into<String>, attrs: AttrMap) -> bool {
1978        let node = node.into();
1979        let existed = self.nodes.contains_key(&node);
1980        let mut changed = !existed;
1981        let attrs_count = {
1982            let bucket = self.nodes.entry(node.clone()).or_default();
1983            if !attrs.is_empty()
1984                && attrs
1985                    .iter()
1986                    .any(|(key, value)| bucket.get(key) != Some(value))
1987            {
1988                changed = true;
1989            }
1990            bucket.extend(attrs);
1991            bucket.len()
1992        };
1993        self.successors.entry(node.clone()).or_default();
1994        self.predecessors.entry(node.clone()).or_default();
1995        if changed {
1996            self.revision = self.revision.saturating_add(1);
1997        }
1998        self.record_decision(
1999            "add_node",
2000            0.0,
2001            false,
2002            vec![
2003                EvidenceTerm {
2004                    signal: "node_preexisting".to_owned(),
2005                    observed_value: existed.to_string(),
2006                    log_likelihood_ratio: -3.0,
2007                },
2008                EvidenceTerm {
2009                    signal: "attrs_count".to_owned(),
2010                    observed_value: attrs_count.to_string(),
2011                    log_likelihood_ratio: -1.0,
2012                },
2013            ],
2014        );
2015        changed
2016    }
2017
2018    pub fn add_edge(
2019        &mut self,
2020        source: impl Into<String>,
2021        target: impl Into<String>,
2022    ) -> Result<usize, GraphError> {
2023        self.add_edge_impl(source, target, None, AttrMap::new())
2024    }
2025
2026    /// br-r37-c1-l5ve7: bulk node insert WITH attrs, one ledger record —
2027    /// MultiDiGraph mirror of DiGraph::extend_nodes_with_attrs_unrecorded.
2028    /// Existing nodes merge attrs (extend).
2029    pub fn extend_nodes_with_attrs_unrecorded<I>(&mut self, nodes: I) -> usize
2030    where
2031        I: IntoIterator<Item = (String, AttrMap)>,
2032    {
2033        let mut inserted = 0usize;
2034        for (node, attrs) in nodes {
2035            if let Some(existing) = self.nodes.get_mut(&node) {
2036                existing.extend(attrs);
2037                continue;
2038            }
2039            self.nodes.insert(node.clone(), attrs);
2040            self.successors.entry(node.clone()).or_default();
2041            self.predecessors.entry(node).or_default();
2042            inserted += 1;
2043        }
2044        if inserted > 0 {
2045            self.revision = self
2046                .revision
2047                .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
2048            self.record_decision(
2049                "extend_nodes_with_attrs_unrecorded",
2050                0.0,
2051                false,
2052                vec![EvidenceTerm {
2053                    signal: "batch_node_count".to_owned(),
2054                    observed_value: inserted.to_string(),
2055                    log_likelihood_ratio: -1.0,
2056                }],
2057            );
2058        }
2059        inserted
2060    }
2061
2062    /// br-r37-c1-l5ve7: bulk KEYED edge insert WITH attrs, one ledger
2063    /// record — for native copy/convert paths that replicate a source
2064    /// multigraph's exact internal keys (add_edge_impl pays TWO
2065    /// record_decision calls per edge). Endpoint nodes are created on
2066    /// demand; an existing (u, v, key) cell merges attrs (extend),
2067    /// matching add_edge_with_key_and_attrs.
2068    pub fn extend_keyed_edges_with_attrs_unrecorded<I>(&mut self, edges: I) -> usize
2069    where
2070        I: IntoIterator<Item = (String, String, usize, AttrMap)>,
2071    {
2072        let mut inserted = 0usize;
2073        for (source, target, key, attrs) in edges {
2074            if !self.nodes.contains_key(&source) {
2075                self.nodes.insert(source.clone(), AttrMap::new());
2076                self.successors.entry(source.clone()).or_default();
2077                self.predecessors.entry(source.clone()).or_default();
2078            }
2079            if source != target && !self.nodes.contains_key(&target) {
2080                self.nodes.insert(target.clone(), AttrMap::new());
2081                self.successors.entry(target.clone()).or_default();
2082                self.predecessors.entry(target.clone()).or_default();
2083            }
2084            let edge_key = DirectedEdgeKey::new(&source, &target);
2085            let bucket = self.edges.entry(edge_key).or_default();
2086            if !bucket.contains_key(&key) {
2087                self.edge_count += 1;
2088                inserted += 1;
2089            }
2090            bucket.entry(key).or_default().extend(attrs);
2091            self.successors
2092                .entry(source.clone())
2093                .or_default()
2094                .entry(target.clone())
2095                .or_default()
2096                .insert(key);
2097            self.predecessors
2098                .entry(target)
2099                .or_default()
2100                .entry(source)
2101                .or_default()
2102                .insert(key);
2103        }
2104        if inserted > 0 {
2105            self.revision = self
2106                .revision
2107                .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
2108            self.record_decision(
2109                "extend_keyed_edges_with_attrs_unrecorded",
2110                0.0,
2111                false,
2112                vec![EvidenceTerm {
2113                    signal: "batch_edge_count".to_owned(),
2114                    observed_value: inserted.to_string(),
2115                    log_likelihood_ratio: -1.0,
2116                }],
2117            );
2118        }
2119        inserted
2120    }
2121
2122    /// Bulk-add keyed attributed directed multiedges into a fresh graph when the
2123    /// caller has already assigned NetworkX-compatible node indices and per-pair
2124    /// keys. This avoids hashing/copying canonical endpoint strings for every
2125    /// edge in fresh `MultiDiGraph.add_edges_from` batches.
2126    #[must_use]
2127    pub fn extend_fresh_index_keyed_edges_with_attrs_unrecorded<I, N>(
2128        &mut self,
2129        nodes: N,
2130        edges: I,
2131    ) -> usize
2132    where
2133        I: IntoIterator<Item = (usize, usize, usize, AttrMap)>,
2134        N: IntoIterator<Item = String>,
2135    {
2136        if !self.nodes.is_empty()
2137            || !self.successors.is_empty()
2138            || !self.predecessors.is_empty()
2139            || !self.edges.is_empty()
2140        {
2141            return 0;
2142        }
2143
2144        let node_labels: Vec<String> = nodes.into_iter().collect();
2145        for node in &node_labels {
2146            self.nodes.insert(node.clone(), AttrMap::new());
2147            self.successors.insert(node.clone(), IndexMap::new());
2148            self.predecessors.insert(node.clone(), IndexMap::new());
2149        }
2150
2151        let node_count = node_labels.len();
2152        let mut inserted = 0usize;
2153        let mut merged_changed = false;
2154        for (source_idx, target_idx, key, attrs) in edges {
2155            let Some(source) = node_labels.get(source_idx) else {
2156                continue;
2157            };
2158            let Some(target) = node_labels.get(target_idx) else {
2159                continue;
2160            };
2161            let edge_key = DirectedEdgeKey::new(source, target);
2162            let bucket = self.edges.entry(edge_key).or_default();
2163            if !bucket.contains_key(&key) {
2164                self.edge_count += 1;
2165                inserted += 1;
2166            }
2167            let edge_attrs = bucket.entry(key).or_default();
2168            if !attrs.is_empty()
2169                && attrs
2170                    .iter()
2171                    .any(|(attr_key, value)| edge_attrs.get(attr_key) != Some(value))
2172            {
2173                merged_changed = true;
2174            }
2175            edge_attrs.extend(attrs);
2176            self.successors
2177                .get_mut(source.as_str())
2178                .expect("fresh node row exists")
2179                .entry(target.clone())
2180                .or_default()
2181                .insert(key);
2182            self.predecessors
2183                .get_mut(target.as_str())
2184                .expect("fresh node row exists")
2185                .entry(source.clone())
2186                .or_default()
2187                .insert(key);
2188        }
2189
2190        if node_count > 0 || inserted > 0 || merged_changed {
2191            self.revision = self.revision.saturating_add(
2192                u64::try_from(node_count.saturating_add(inserted)).unwrap_or(u64::MAX),
2193            );
2194            self.record_decision(
2195                "extend_fresh_index_keyed_edges_with_attrs_unrecorded",
2196                0.0,
2197                false,
2198                vec![
2199                    EvidenceTerm {
2200                        signal: "batch_node_count".to_owned(),
2201                        observed_value: node_count.to_string(),
2202                        log_likelihood_ratio: -1.0,
2203                    },
2204                    EvidenceTerm {
2205                        signal: "batch_edge_count".to_owned(),
2206                        observed_value: inserted.to_string(),
2207                        log_likelihood_ratio: -1.0,
2208                    },
2209                ],
2210            );
2211        }
2212
2213        inserted
2214    }
2215
2216    pub fn add_edge_with_attrs(
2217        &mut self,
2218        source: impl Into<String>,
2219        target: impl Into<String>,
2220        attrs: AttrMap,
2221    ) -> Result<usize, GraphError> {
2222        self.add_edge_impl(source, target, None, attrs)
2223    }
2224
2225    pub fn add_edge_with_key_and_attrs(
2226        &mut self,
2227        source: impl Into<String>,
2228        target: impl Into<String>,
2229        key: usize,
2230        attrs: AttrMap,
2231    ) -> Result<usize, GraphError> {
2232        self.add_edge_impl(source, target, Some(key), attrs)
2233    }
2234
2235    fn add_edge_impl(
2236        &mut self,
2237        source: impl Into<String>,
2238        target: impl Into<String>,
2239        explicit_key: Option<usize>,
2240        attrs: AttrMap,
2241    ) -> Result<usize, GraphError> {
2242        let source = source.into();
2243        let target = target.into();
2244
2245        let unknown_feature = attrs
2246            .keys()
2247            .any(|key| key.starts_with("__fnx_incompatible"));
2248        let self_loop = source == target;
2249        let incompatibility_probability = if unknown_feature {
2250            1.0
2251        } else if self_loop {
2252            0.22
2253        } else {
2254            0.08
2255        };
2256
2257        let action = self.record_decision(
2258            "add_edge",
2259            incompatibility_probability,
2260            unknown_feature,
2261            vec![EvidenceTerm {
2262                signal: "unknown_incompatible_feature".to_owned(),
2263                observed_value: unknown_feature.to_string(),
2264                log_likelihood_ratio: 12.0,
2265            }],
2266        );
2267
2268        if action == DecisionAction::FailClosed || action == DecisionAction::FullValidate {
2269            return Err(GraphError::FailClosed {
2270                operation: "add_edge",
2271                reason: "incompatible edge metadata".to_owned(),
2272            });
2273        }
2274
2275        let mut source_autocreated = false;
2276        if !self.nodes.contains_key(&source) {
2277            let _ = self.add_node(source.clone());
2278            source_autocreated = true;
2279        }
2280        let mut target_autocreated = false;
2281        if self_loop {
2282            target_autocreated = source_autocreated;
2283        } else if !self.nodes.contains_key(&target) {
2284            let _ = self.add_node(target.clone());
2285            target_autocreated = true;
2286        }
2287
2288        let edge_key = DirectedEdgeKey::new(&source, &target);
2289        let key = explicit_key.unwrap_or_else(|| {
2290            let edge_bucket = self.edges.get(&edge_key);
2291            let mut k = edge_bucket.map_or(0, |b| b.len());
2292            if let Some(b) = edge_bucket {
2293                while b.contains_key(&k) {
2294                    k += 1;
2295                }
2296            }
2297            k
2298        });
2299        let mut changed;
2300        let edge_attr_count = {
2301            let edge_bucket = self.edges.entry(edge_key.clone()).or_default();
2302            let is_new = !edge_bucket.contains_key(&key);
2303            if is_new {
2304                self.edge_count += 1;
2305            }
2306            changed = is_new;
2307            let edge_attrs = edge_bucket.entry(key).or_default();
2308            if !attrs.is_empty()
2309                && attrs
2310                    .iter()
2311                    .any(|(attr_key, value)| edge_attrs.get(attr_key) != Some(value))
2312            {
2313                changed = true;
2314            }
2315            edge_attrs.extend(attrs);
2316            edge_bucket.len()
2317        };
2318
2319        self.successors
2320            .entry(source.clone())
2321            .or_default()
2322            .entry(target.clone())
2323            .or_default()
2324            .insert(key);
2325        self.predecessors
2326            .entry(target.clone())
2327            .or_default()
2328            .entry(source.clone())
2329            .or_default()
2330            .insert(key);
2331        if changed {
2332            self.revision = self.revision.saturating_add(1);
2333        }
2334
2335        self.record_decision(
2336            "add_edge",
2337            incompatibility_probability,
2338            unknown_feature,
2339            vec![
2340                EvidenceTerm {
2341                    signal: "edge_key".to_owned(),
2342                    observed_value: key.to_string(),
2343                    log_likelihood_ratio: -2.0,
2344                },
2345                EvidenceTerm {
2346                    signal: "edge_attr_count".to_owned(),
2347                    observed_value: edge_attr_count.to_string(),
2348                    log_likelihood_ratio: -2.0,
2349                },
2350                EvidenceTerm {
2351                    signal: "source_autocreated".to_owned(),
2352                    observed_value: source_autocreated.to_string(),
2353                    log_likelihood_ratio: -1.25,
2354                },
2355                EvidenceTerm {
2356                    signal: "target_autocreated".to_owned(),
2357                    observed_value: target_autocreated.to_string(),
2358                    log_likelihood_ratio: -1.25,
2359                },
2360            ],
2361        );
2362
2363        Ok(key)
2364    }
2365
2366    /// br-r37-c1-sjf4t: overwrite the attribute map for an existing
2367    /// (source, target, key) edge. Returns whether the edge existed.
2368    pub fn replace_edge_attrs(
2369        &mut self,
2370        source: &str,
2371        target: &str,
2372        key: usize,
2373        attrs: AttrMap,
2374    ) -> bool {
2375        let edge_key = DirectedEdgeKey::new(source, target);
2376        if let Some(bucket) = self.edges.get_mut(&edge_key)
2377            && let Some(slot) = bucket.get_mut(&key)
2378        {
2379            if *slot != attrs {
2380                *slot = attrs;
2381                self.revision = self.revision.saturating_add(1);
2382            }
2383            return true;
2384        }
2385        false
2386    }
2387
2388    /// br-r37-c1-sjf4t: overwrite the attribute map for an existing node.
2389    pub fn replace_node_attrs(&mut self, node: &str, attrs: AttrMap) -> bool {
2390        if let Some(slot) = self.nodes.get_mut(node) {
2391            if *slot != attrs {
2392                *slot = attrs;
2393                self.revision = self.revision.saturating_add(1);
2394            }
2395            true
2396        } else {
2397            false
2398        }
2399    }
2400
2401    pub fn remove_edge(&mut self, source: &str, target: &str, key: Option<usize>) -> bool {
2402        let edge_key = DirectedEdgeKeyRef::new(source, target);
2403        let removal_key = key.or_else(|| {
2404            self.edges
2405                .get(&edge_key)
2406                .and_then(|edge_bucket| edge_bucket.keys().next_back().copied())
2407        });
2408
2409        let Some(removal_key) = removal_key else {
2410            return false;
2411        };
2412
2413        let removed = self
2414            .edges
2415            .get_mut(&edge_key)
2416            .is_some_and(|edge_bucket| edge_bucket.shift_remove(&removal_key).is_some());
2417        if !removed {
2418            return false;
2419        }
2420
2421        self.edge_count -= 1;
2422
2423        let should_drop_bucket = self.edges.get(&edge_key).is_some_and(IndexMap::is_empty);
2424        if should_drop_bucket {
2425            self.edges.shift_remove(&edge_key);
2426        }
2427
2428        self.remove_successor_key(source, target, removal_key);
2429        self.remove_predecessor_key(target, source, removal_key);
2430        self.revision = self.revision.saturating_add(1);
2431        true
2432    }
2433
2434    pub fn remove_node(&mut self, node: &str) -> bool {
2435        if !self.nodes.contains_key(node) {
2436            return false;
2437        }
2438
2439        // br-r37-c1-p6bxu: drop each incident edge bucket with O(1) `swap_remove`
2440        // (the `edges` IndexMap order is never observed externally — every public
2441        // consumer reads via `edges_ordered`, which walks node->successor order;
2442        // no internal consumer iterates the map order). Out-edges are known from
2443        // `successors`, in-edges from `predecessors`; a self-loop's (node,node)
2444        // bucket appears in both but `swap_remove` returns it only once, so it is
2445        // counted exactly once. Removal is O(degree) instead of the
2446        // O(|distinct pairs|) `retain` scan — matching nx.
2447        let mut removed_count = 0usize;
2448        if let Some(succs) = self.successors.get(node) {
2449            let targets: Vec<String> = succs.keys().cloned().collect();
2450            for target in targets {
2451                if target != node
2452                    && let Some(preds) = self.predecessors.get_mut(&target)
2453                {
2454                    preds.shift_remove(node);
2455                }
2456                if let Some(bucket) = self
2457                    .edges
2458                    .swap_remove(&DirectedEdgeKeyRef::new(node, &target))
2459                {
2460                    removed_count += bucket.len();
2461                }
2462            }
2463        }
2464        if let Some(preds) = self.predecessors.get(node) {
2465            let sources: Vec<String> = preds.keys().cloned().collect();
2466            for source in sources {
2467                if source != node
2468                    && let Some(succs) = self.successors.get_mut(&source)
2469                {
2470                    succs.shift_remove(node);
2471                }
2472                if let Some(bucket) = self
2473                    .edges
2474                    .swap_remove(&DirectedEdgeKeyRef::new(&source, node))
2475                {
2476                    removed_count += bucket.len();
2477                }
2478            }
2479        }
2480        self.edge_count -= removed_count;
2481
2482        self.successors.shift_remove(node);
2483        self.predecessors.shift_remove(node);
2484        self.nodes.shift_remove(node);
2485        self.revision = self.revision.saturating_add(1);
2486        true
2487    }
2488
2489    #[must_use]
2490    pub fn edges_ordered(&self) -> Vec<MultiDiEdgeSnapshot> {
2491        let mut ordered = Vec::with_capacity(self.edge_count());
2492
2493        for node in self.nodes.keys() {
2494            if let Some(neighbors) = self.successors.get(node) {
2495                for target in neighbors.keys() {
2496                    let pair = DirectedEdgeKey::new(node, target);
2497                    if let Some(edge_bucket) = self.edges.get(&pair) {
2498                        for (key, attrs) in edge_bucket {
2499                            ordered.push(MultiDiEdgeSnapshot {
2500                                source: node.clone(),
2501                                target: target.clone(),
2502                                key: *key,
2503                                attrs: attrs.clone(),
2504                            });
2505                        }
2506                    }
2507                }
2508            }
2509        }
2510
2511        ordered
2512    }
2513
2514    #[must_use]
2515    pub fn edges_ordered_borrowed(&self) -> Vec<(&str, &str, usize, &AttrMap)> {
2516        let mut ordered = Vec::with_capacity(self.edge_count());
2517
2518        for node in self.nodes.keys() {
2519            if let Some(neighbors) = self.successors.get(node) {
2520                for target in neighbors.keys() {
2521                    let pair = DirectedEdgeKeyRef::new(node, target);
2522                    if let Some(edge_bucket) = self.edges.get(&pair) {
2523                        for (key, attrs) in edge_bucket {
2524                            ordered.push((node.as_str(), target.as_str(), *key, attrs));
2525                        }
2526                    }
2527                }
2528            }
2529        }
2530
2531        ordered
2532    }
2533
2534    pub fn try_for_each_edge_ordered_borrowed<E>(
2535        &self,
2536        mut visit: impl FnMut(&str, &str, usize, &AttrMap) -> Result<(), E>,
2537    ) -> Result<(), E> {
2538        for node in self.nodes.keys() {
2539            if let Some(neighbors) = self.successors.get(node) {
2540                for target in neighbors.keys() {
2541                    let pair = DirectedEdgeKeyRef::new(node, target);
2542                    if let Some(edge_bucket) = self.edges.get(&pair) {
2543                        for (key, attrs) in edge_bucket {
2544                            visit(node.as_str(), target.as_str(), *key, attrs)?;
2545                        }
2546                    }
2547                }
2548            }
2549        }
2550
2551        Ok(())
2552    }
2553
2554    pub fn try_for_each_indexed_edge_ordered_borrowed<E>(
2555        &self,
2556        mut visit: impl FnMut(usize, usize, &str, &str, usize, &AttrMap) -> Result<(), E>,
2557    ) -> Result<(), E> {
2558        for (source_index, node) in self.nodes.keys().enumerate() {
2559            if let Some(neighbors) = self.successors.get(node) {
2560                for target in neighbors.keys() {
2561                    let Some(target_index) = self.nodes.get_index_of(target.as_str()) else {
2562                        continue;
2563                    };
2564                    let pair = DirectedEdgeKeyRef::new(node, target);
2565                    if let Some(edge_bucket) = self.edges.get(&pair) {
2566                        for (key, attrs) in edge_bucket {
2567                            visit(
2568                                source_index,
2569                                target_index,
2570                                node.as_str(),
2571                                target.as_str(),
2572                                *key,
2573                                attrs,
2574                            )?;
2575                        }
2576                    }
2577                }
2578            }
2579        }
2580
2581        Ok(())
2582    }
2583
2584    /// Return a reversed copy with NetworkX-compatible node/edge row order.
2585    ///
2586    /// This is the directed-multigraph sibling of `DiGraph::reversed`, but it
2587    /// cannot simply swap `successors` and `predecessors`: NetworkX materializes
2588    /// `MultiDiGraph.reverse(copy=True)` by walking `G.edges(keys=True, data=True)`
2589    /// in source-node order and adding `(v, u, key)` to the result. Predecessor
2590    /// rows may have been formed by historical edge insertion order, which is not
2591    /// always the same as that source-major edge stream. Rebuild the two row maps
2592    /// from the observed edge stream while avoiding the expensive per-edge
2593    /// `add_edge` policy path.
2594    #[must_use]
2595    pub fn reversed(&self) -> Self {
2596        let mut successors: IndexMap<String, IndexMap<String, IndexSet<usize>>> =
2597            IndexMap::with_capacity(self.nodes.len());
2598        let mut predecessors: IndexMap<String, IndexMap<String, IndexSet<usize>>> =
2599            IndexMap::with_capacity(self.nodes.len());
2600        for node in self.nodes.keys() {
2601            successors.insert(node.clone(), IndexMap::new());
2602            predecessors.insert(node.clone(), IndexMap::new());
2603        }
2604
2605        let mut edges: IndexMap<DirectedEdgeKey, IndexMap<usize, AttrMap>> =
2606            IndexMap::with_capacity(self.edges.len());
2607        let mut edge_count = 0usize;
2608        for source in self.nodes.keys() {
2609            let Some(neighbors) = self.successors.get(source) else {
2610                continue;
2611            };
2612            for target in neighbors.keys() {
2613                let pair = DirectedEdgeKeyRef::new(source, target);
2614                let Some(edge_bucket) = self.edges.get(&pair) else {
2615                    continue;
2616                };
2617                let reversed_pair = DirectedEdgeKey::new(target, source);
2618                let reversed_bucket = edges.entry(reversed_pair).or_default();
2619                let reversed_succ_keys = successors
2620                    .get_mut(target.as_str())
2621                    .expect("target row exists")
2622                    .entry(source.clone())
2623                    .or_default();
2624                let reversed_pred_keys = predecessors
2625                    .get_mut(source.as_str())
2626                    .expect("source row exists")
2627                    .entry(target.clone())
2628                    .or_default();
2629                for (key, attrs) in edge_bucket {
2630                    reversed_bucket.insert(*key, attrs.clone());
2631                    reversed_succ_keys.insert(*key);
2632                    reversed_pred_keys.insert(*key);
2633                    edge_count += 1;
2634                }
2635            }
2636        }
2637
2638        Self {
2639            mode: self.mode,
2640            revision: 0,
2641            nodes: self.nodes.clone(),
2642            successors,
2643            predecessors,
2644            edges,
2645            runtime_policy: self.runtime_policy.clone(),
2646            edge_count,
2647        }
2648    }
2649
2650    /// Out-edges of a single source node in nx adjacency order
2651    /// (successors[node].keys() then key-bucket order), borrowing attrs.
2652    ///
2653    /// Equivalent to ``edges_ordered_borrowed()`` filtered to one source,
2654    /// but O(out-deg(node)) instead of O(E) — and it clones nothing. Used
2655    /// by ``MultiDiGraph.edges(nbunch)`` to avoid scanning/cloning every
2656    /// edge when only a handful of sources are requested.
2657    #[must_use]
2658    pub fn out_edges_ordered_borrowed<'a>(
2659        &'a self,
2660        node: &str,
2661    ) -> Vec<(&'a str, &'a str, usize, &'a AttrMap)> {
2662        let mut ordered = Vec::new();
2663        if let Some((src, neighbors)) = self.successors.get_key_value(node) {
2664            for target in neighbors.keys() {
2665                let pair = DirectedEdgeKeyRef::new(src, target);
2666                if let Some(edge_bucket) = self.edges.get(&pair) {
2667                    for (key, attrs) in edge_bucket {
2668                        ordered.push((src.as_str(), target.as_str(), *key, attrs));
2669                    }
2670                }
2671            }
2672        }
2673        ordered
2674    }
2675
2676    #[must_use]
2677    pub fn snapshot(&self) -> MultiDiGraphSnapshot {
2678        let node_attrs: std::collections::BTreeMap<String, AttrMap> = self
2679            .nodes
2680            .iter()
2681            .filter(|(_, attrs)| !attrs.is_empty())
2682            .map(|(name, attrs)| (name.clone(), attrs.clone()))
2683            .collect();
2684        MultiDiGraphSnapshot {
2685            mode: self.mode,
2686            nodes: self.nodes.keys().cloned().collect(),
2687            node_attrs,
2688            edges: self.edges_ordered(),
2689        }
2690    }
2691
2692    fn remove_successor_key(&mut self, source: &str, target: &str, key: usize) {
2693        let mut drop_neighbor = false;
2694        if let Some(neighbors) = self.successors.get_mut(source)
2695            && let Some(keys) = neighbors.get_mut(target)
2696        {
2697            keys.shift_remove(&key);
2698            drop_neighbor = keys.is_empty();
2699        }
2700        if drop_neighbor && let Some(neighbors) = self.successors.get_mut(source) {
2701            neighbors.shift_remove(target);
2702        }
2703    }
2704
2705    fn remove_predecessor_key(&mut self, target: &str, source: &str, key: usize) {
2706        let mut drop_neighbor = false;
2707        if let Some(neighbors) = self.predecessors.get_mut(target)
2708            && let Some(keys) = neighbors.get_mut(source)
2709        {
2710            keys.shift_remove(&key);
2711            drop_neighbor = keys.is_empty();
2712        }
2713        if drop_neighbor && let Some(neighbors) = self.predecessors.get_mut(target) {
2714            neighbors.shift_remove(source);
2715        }
2716    }
2717
2718    fn record_decision(
2719        &mut self,
2720        operation: &'static str,
2721        incompatibility_probability: f64,
2722        unknown_incompatible_feature: bool,
2723        evidence: Vec<EvidenceTerm>,
2724    ) -> DecisionAction {
2725        let action = self
2726            .runtime_policy
2727            .action_for(incompatibility_probability, unknown_incompatible_feature);
2728        self.runtime_policy.record(
2729            operation,
2730            action,
2731            incompatibility_probability,
2732            "argmin expected loss over {allow,full_validate,fail_closed}",
2733            evidence,
2734        );
2735        action
2736    }
2737}
2738
2739// ===========================================================================
2740// Tests
2741// ===========================================================================
2742
2743#[cfg(test)]
2744mod tests {
2745    use super::*;
2746    use fnx_runtime::{
2747        CgseValue, CompatibilityMode, DecisionAction, DecisionRecord, RuntimePolicy,
2748    };
2749    use proptest::prelude::*;
2750
2751    fn node_name(id: u8) -> String {
2752        format!("n{}", id % 8)
2753    }
2754
2755    fn single_attr(key: &str, value: &str) -> AttrMap {
2756        let mut attrs = AttrMap::new();
2757        attrs.insert(key.to_owned(), CgseValue::from(value));
2758        attrs
2759    }
2760
2761    fn assert_runtime_policy_preserved(source: &RuntimePolicy, result: &RuntimePolicy) {
2762        assert_eq!(result.mode(), source.mode());
2763        assert_eq!(result.allowlist(), source.allowlist());
2764        assert_eq!(result.loss_matrix(), source.loss_matrix());
2765        assert!(result.posterior().observation_count >= source.posterior().observation_count);
2766        assert!(
2767            result
2768                .decision_log()
2769                .records()
2770                .starts_with(source.decision_log().records())
2771        );
2772    }
2773
2774    // -- Invariant checker --------------------------------------------------
2775
2776    fn assert_digraph_core_invariants(g: &DiGraph) {
2777        // Every edge in the edge map must be reflected in successors/predecessors.
2778        for (&(source_idx, target_idx), _attrs) in &g.edges {
2779            let source = g
2780                .nodes
2781                .get_index(source_idx)
2782                .map(|(name, _)| name.as_str())
2783                .expect("edge source index should be a node");
2784            let target = g
2785                .nodes
2786                .get_index(target_idx)
2787                .map(|(name, _)| name.as_str())
2788                .expect("edge target index should be a node");
2789            assert!(
2790                g.has_node(source),
2791                "edge source {} should be a node",
2792                source
2793            );
2794            assert!(
2795                g.has_node(target),
2796                "edge target {} should be a node",
2797                target
2798            );
2799            let succs = g
2800                .successors(source)
2801                .expect("source should have successors bucket");
2802            assert!(
2803                succs.contains(&target),
2804                "{} should be in successors of {}",
2805                target,
2806                source
2807            );
2808            let preds = g
2809                .predecessors(target)
2810                .expect("target should have predecessors bucket");
2811            assert!(
2812                preds.contains(&source),
2813                "{} should be in predecessors of {}",
2814                source,
2815                target
2816            );
2817        }
2818
2819        // Every successor entry should have a corresponding edge.
2820        let mut edge_count_from_adj = 0usize;
2821        for node in g.nodes_ordered() {
2822            let succs = g
2823                .successors(node)
2824                .expect("node should have successors bucket");
2825            for s in &succs {
2826                assert!(
2827                    g.has_edge(node, s),
2828                    "successor {} of {} should have directed edge",
2829                    s,
2830                    node
2831                );
2832                edge_count_from_adj += 1;
2833            }
2834            // Every predecessor entry should have a corresponding edge.
2835            let preds = g
2836                .predecessors(node)
2837                .expect("node should have predecessors bucket");
2838            for p in &preds {
2839                assert!(
2840                    g.has_edge(p, node),
2841                    "predecessor {} of {} should have directed edge",
2842                    p,
2843                    node
2844                );
2845            }
2846        }
2847        assert_eq!(g.edge_count(), edge_count_from_adj);
2848    }
2849
2850    #[test]
2851    fn add_node_with_attrs_reports_change_on_existing_node() {
2852        let mut graph = DiGraph::strict();
2853        assert!(graph.add_node_with_attrs("a", single_attr("color", "red")));
2854        let r1 = graph.revision();
2855        assert!(graph.add_node_with_attrs("a", single_attr("color", "blue")));
2856        let r2 = graph.revision();
2857        assert!(r2 > r1);
2858        let expected = CgseValue::from("blue");
2859        assert_eq!(graph.node_attrs("a").unwrap().get("color"), Some(&expected));
2860    }
2861
2862    #[test]
2863    fn multidigraph_add_node_with_attrs_reports_change_on_existing_node() {
2864        let mut graph = MultiDiGraph::strict();
2865        assert!(graph.add_node_with_attrs("a", single_attr("color", "red")));
2866        let r1 = graph.revision();
2867        assert!(graph.add_node_with_attrs("a", single_attr("color", "blue")));
2868        let r2 = graph.revision();
2869        assert!(r2 > r1);
2870        let expected = CgseValue::from("blue");
2871        assert_eq!(graph.node_attrs("a").unwrap().get("color"), Some(&expected));
2872    }
2873
2874    fn assert_multidigraph_core_invariants(g: &MultiDiGraph) {
2875        let mut edge_instances = std::collections::BTreeSet::new();
2876        for node in g.nodes_ordered() {
2877            let successors = g
2878                .successors(node)
2879                .expect("multidigraph nodes should always have successor buckets");
2880            for target in successors {
2881                assert!(g.has_node(target));
2882                assert!(g.has_edge(node, target));
2883                let preds = g
2884                    .predecessors(target)
2885                    .expect("target should always have predecessor buckets");
2886                assert!(preds.contains(&node));
2887                let keys = g
2888                    .edge_keys(node, target)
2889                    .expect("parallel directed edge bucket should exist");
2890                for key in keys {
2891                    edge_instances.insert((node.to_owned(), target.to_owned(), key));
2892                }
2893            }
2894        }
2895        assert_eq!(g.edge_count(), edge_instances.len());
2896    }
2897
2898    fn assert_decision_record_schema(record: &DecisionRecord, expected_mode: CompatibilityMode) {
2899        assert!(record.ts_unix_ms > 0);
2900        assert!(!record.operation.trim().is_empty());
2901        assert_eq!(record.mode, expected_mode);
2902        assert!((0.0..=1.0).contains(&record.incompatibility_probability));
2903        assert!(!record.rationale.trim().is_empty());
2904        assert!(!record.evidence.is_empty());
2905        for term in &record.evidence {
2906            assert!(!term.signal.trim().is_empty());
2907            assert!(!term.observed_value.trim().is_empty());
2908        }
2909    }
2910
2911    // -- Basic operations ---------------------------------------------------
2912
2913    #[test]
2914    fn add_directed_edge_autocreates_nodes() {
2915        let mut g = DiGraph::strict();
2916        g.add_edge("a", "b").unwrap();
2917
2918        assert_eq!(g.node_count(), 2);
2919        assert_eq!(g.edge_count(), 1);
2920        assert!(g.has_edge("a", "b"));
2921        assert!(!g.has_edge("b", "a")); // directed: reverse does NOT exist
2922        assert_digraph_core_invariants(&g);
2923    }
2924
2925    #[test]
2926    fn directed_edge_asymmetry() {
2927        let mut g = DiGraph::strict();
2928        g.add_edge("a", "b").unwrap();
2929        g.add_edge("b", "a").unwrap();
2930
2931        assert_eq!(g.edge_count(), 2);
2932        assert!(g.has_edge("a", "b"));
2933        assert!(g.has_edge("b", "a"));
2934        assert_digraph_core_invariants(&g);
2935    }
2936
2937    #[test]
2938    fn successors_and_predecessors() {
2939        let mut g = DiGraph::strict();
2940        g.add_edge("a", "b").unwrap();
2941        g.add_edge("a", "c").unwrap();
2942        g.add_edge("d", "a").unwrap();
2943
2944        assert_eq!(g.successors("a"), Some(vec!["b", "c"]));
2945        assert_eq!(g.predecessors("a"), Some(vec!["d"]));
2946        assert_eq!(g.out_degree("a"), 2);
2947        assert_eq!(g.in_degree("a"), 1);
2948        assert_eq!(g.degree("a"), 3);
2949        assert_digraph_core_invariants(&g);
2950    }
2951
2952    #[test]
2953    fn neighbors_returns_successors() {
2954        let mut g = DiGraph::strict();
2955        g.add_edge("a", "b").unwrap();
2956        g.add_edge("c", "a").unwrap();
2957
2958        // neighbors() = successors() per NetworkX convention
2959        assert_eq!(g.neighbors("a"), Some(vec!["b"]));
2960        assert_digraph_core_invariants(&g);
2961    }
2962
2963    #[test]
2964    fn in_edges_and_out_edges() {
2965        let mut g = DiGraph::strict();
2966        g.add_edge("x", "y").unwrap();
2967        g.add_edge("z", "y").unwrap();
2968        g.add_edge("y", "w").unwrap();
2969
2970        assert_eq!(g.out_edges("y"), vec![("y", "w")]);
2971        assert_eq!(g.in_edges("y"), vec![("x", "y"), ("z", "y")]);
2972        assert_digraph_core_invariants(&g);
2973    }
2974
2975    #[test]
2976    fn remove_directed_edge() {
2977        let mut g = DiGraph::strict();
2978        g.add_edge("a", "b").unwrap();
2979        g.add_edge("b", "a").unwrap();
2980
2981        assert!(g.remove_edge("a", "b"));
2982        assert!(!g.has_edge("a", "b"));
2983        assert!(g.has_edge("b", "a")); // reverse still exists
2984        assert_eq!(g.edge_count(), 1);
2985        assert_digraph_core_invariants(&g);
2986    }
2987
2988    #[test]
2989    fn remove_node_removes_all_incident_directed_edges() {
2990        let mut g = DiGraph::strict();
2991        g.add_edge("a", "b").unwrap();
2992        g.add_edge("b", "c").unwrap();
2993        g.add_edge("c", "a").unwrap();
2994        g.add_edge("d", "b").unwrap();
2995
2996        assert!(g.remove_node("b"));
2997        assert_eq!(g.node_count(), 3);
2998        assert!(!g.has_edge("a", "b"));
2999        assert!(!g.has_edge("b", "c"));
3000        assert!(!g.has_edge("d", "b"));
3001        assert!(g.has_edge("c", "a")); // not incident to b
3002        assert_digraph_core_invariants(&g);
3003    }
3004
3005    #[test]
3006    fn multidigraph_counts_parallel_selfloops_only() {
3007        let mut g = MultiDiGraph::strict();
3008        let _ = g.add_edge("a", "a").expect("self-loop add should succeed");
3009        let _ = g.add_edge("a", "a").expect("self-loop add should succeed");
3010        let _ = g.add_edge("b", "b").expect("self-loop add should succeed");
3011        let _ = g.add_edge("a", "b").expect("edge add should succeed");
3012        let _ = g.add_edge("b", "a").expect("edge add should succeed");
3013
3014        assert_eq!(g.number_of_selfloops(), 3);
3015        assert_multidigraph_core_invariants(&g);
3016    }
3017
3018    // br-r37-c1-p6bxu: A/B substrate bench for MultiDiGraph::remove_node
3019    // (O(degree) swap_remove vs the old O(|E|) retain). Ignored by default; run
3020    // with `cargo test -p fnx-classes --release ab_bench_multidigraph_remove_node
3021    // -- --ignored --nocapture`. Also asserts byte-exact parity (node_count,
3022    // edge_count, edges_ordered, incl. self-loops) between the two paths.
3023    #[test]
3024    #[ignore]
3025    fn ab_bench_multidigraph_remove_node() {
3026        use std::time::Instant;
3027        const N: usize = 1000;
3028        const M: usize = 8000;
3029        const ITERS: usize = 500;
3030        let build = || {
3031            let mut g = MultiDiGraph::new(CompatibilityMode::Strict);
3032            for i in 0..N {
3033                let _ = g.add_node(i.to_string());
3034            }
3035            let mut s: u64 = 0x9E3779B97F4A7C15;
3036            let mut next = || {
3037                s = s
3038                    .wrapping_mul(6364136223846793005)
3039                    .wrapping_add(1442695040888963407);
3040                (s >> 33) as usize % N
3041            };
3042            for _ in 0..M {
3043                let a = next();
3044                let b = next();
3045                let _ = g.add_edge(a.to_string(), b.to_string());
3046            }
3047            g
3048        };
3049
3050        let mut gnew = build();
3051        let victims: Vec<String> = gnew.nodes.keys().take(ITERS).cloned().collect();
3052        let t = Instant::now();
3053        for node in &victims {
3054            gnew.remove_node(node);
3055        }
3056        let new_t = t.elapsed();
3057
3058        // OLD path: full O(|E|) retain per removal.
3059        let mut gold = build();
3060        let t = Instant::now();
3061        for node in &victims {
3062            if !gold.nodes.contains_key(node) {
3063                continue;
3064            }
3065            if let Some(succs) = gold.successors.get(node) {
3066                let targets: Vec<String> = succs.keys().cloned().collect();
3067                for target in targets {
3068                    if target != *node
3069                        && let Some(preds) = gold.predecessors.get_mut(&target)
3070                    {
3071                        preds.shift_remove(node.as_str());
3072                    }
3073                }
3074            }
3075            if let Some(preds) = gold.predecessors.get(node) {
3076                let sources: Vec<String> = preds.keys().cloned().collect();
3077                for source in sources {
3078                    if source != *node
3079                        && let Some(succs) = gold.successors.get_mut(&source)
3080                    {
3081                        succs.shift_remove(node.as_str());
3082                    }
3083                }
3084            }
3085            let mut rc = 0usize;
3086            let nd = node.clone();
3087            gold.edges.retain(|k, bucket| {
3088                let keep = k.source != nd && k.target != nd;
3089                if !keep {
3090                    rc += bucket.len();
3091                }
3092                keep
3093            });
3094            gold.edge_count -= rc;
3095            gold.successors.shift_remove(node);
3096            gold.predecessors.shift_remove(node);
3097            gold.nodes.shift_remove(node);
3098        }
3099        let old_t = t.elapsed();
3100
3101        assert_eq!(gnew.node_count(), gold.node_count());
3102        assert_eq!(gnew.edge_count(), gold.edge_count());
3103        assert_eq!(gnew.edges_ordered(), gold.edges_ordered());
3104        eprintln!(
3105            "MultiDiGraph remove_node x{ITERS}: retain {old_t:?} -> swap_remove {new_t:?} = {:.2}x",
3106            old_t.as_secs_f64() / new_t.as_secs_f64()
3107        );
3108    }
3109
3110    #[test]
3111    fn self_loop_directed() {
3112        let mut g = DiGraph::strict();
3113        g.add_edge("a", "a").unwrap();
3114
3115        assert_eq!(g.edge_count(), 1);
3116        assert!(g.has_edge("a", "a"));
3117        assert_eq!(g.out_degree("a"), 1);
3118        assert_eq!(g.in_degree("a"), 1);
3119        assert_digraph_core_invariants(&g);
3120    }
3121
3122    #[test]
3123    fn edge_attrs_directed() {
3124        let mut g = DiGraph::strict();
3125        let mut attrs = AttrMap::new();
3126        attrs.insert("weight".to_owned(), "5".into());
3127        g.add_edge_with_attrs("a", "b", attrs).unwrap();
3128
3129        assert_eq!(
3130            g.edge_attrs("a", "b").unwrap().get("weight"),
3131            Some(&CgseValue::String("5".to_owned()))
3132        );
3133        assert!(g.edge_attrs("b", "a").is_none()); // reverse has no attrs
3134    }
3135
3136    #[test]
3137    fn extend_edges_unrecorded_preserves_direction_and_records_once() {
3138        let mut g = DiGraph::strict();
3139        g.add_node("a");
3140        g.add_node("b");
3141        g.add_node("c");
3142        let before = g.evidence_ledger().records().len();
3143
3144        let inserted = g.extend_edges_unrecorded([("a", "b"), ("b", "a"), ("a", "b")]);
3145
3146        assert_eq!(inserted, 2);
3147        assert_eq!(g.edge_count(), 2);
3148        assert!(g.has_edge("a", "b"));
3149        assert!(g.has_edge("b", "a"));
3150        assert!(!g.has_edge("a", "c"));
3151        let records = g.evidence_ledger().records();
3152        assert_eq!(records.len(), before + 1);
3153        assert_eq!(
3154            records.last().map(|record| record.operation.as_str()),
3155            Some("extend_edges_unrecorded")
3156        );
3157    }
3158
3159    #[test]
3160    fn row_staged_attr_edges_preserve_orders_and_duplicate_merges() {
3161        let mut g = DiGraph::strict();
3162        let before = g.evidence_ledger().records().len();
3163
3164        let inserted = g.extend_prepared_edges_with_attrs_row_staged_unrecorded(
3165            ["b", "x", "a", "t"]
3166                .into_iter()
3167                .map(std::borrow::ToOwned::to_owned),
3168            vec![
3169                ("b".to_owned(), "x".to_owned(), single_attr("first", "bx")),
3170                ("a".to_owned(), "t".to_owned(), single_attr("first", "at")),
3171                ("b".to_owned(), "t".to_owned(), single_attr("first", "bt")),
3172                (
3173                    "a".to_owned(),
3174                    "t".to_owned(),
3175                    single_attr("second", "merge"),
3176                ),
3177                ("t".to_owned(), "t".to_owned(), single_attr("loop", "yes")),
3178            ],
3179        );
3180
3181        assert_eq!(inserted, 4);
3182        assert_eq!(g.nodes_ordered(), vec!["b", "x", "a", "t"]);
3183        assert_eq!(
3184            g.edges_ordered_borrowed()
3185                .into_iter()
3186                .map(|(u, v, _)| (u, v))
3187                .collect::<Vec<_>>(),
3188            vec![("b", "x"), ("b", "t"), ("a", "t"), ("t", "t")]
3189        );
3190        assert_eq!(
3191            g.edges_ordered_indices(),
3192            vec![(0, 1), (0, 3), (2, 3), (3, 3)]
3193        );
3194        assert_eq!(g.successors("b"), Some(vec!["x", "t"]));
3195        assert_eq!(g.predecessors("t"), Some(vec!["a", "b", "t"]));
3196        let merged = g.edge_attrs("a", "t").expect("merged edge should exist");
3197        assert_eq!(merged.get("first"), Some(&CgseValue::from("at")));
3198        assert_eq!(merged.get("second"), Some(&CgseValue::from("merge")));
3199        assert_digraph_core_invariants(&g);
3200        let records = g.evidence_ledger().records();
3201        assert_eq!(records.len(), before + 1);
3202        assert_eq!(
3203            records.last().map(|record| record.operation.as_str()),
3204            Some("extend_edges_unrecorded")
3205        );
3206    }
3207
3208    #[test]
3209    fn repeated_edge_merges_attrs() {
3210        let mut g = DiGraph::strict();
3211        let mut first = AttrMap::new();
3212        first.insert("weight".to_owned(), "1".into());
3213        g.add_edge_with_attrs("a", "b", first).unwrap();
3214
3215        let mut second = AttrMap::new();
3216        second.insert("color".to_owned(), "red".into());
3217        g.add_edge_with_attrs("a", "b", second).unwrap();
3218
3219        assert_eq!(g.edge_count(), 1);
3220        let attrs = g.edge_attrs("a", "b").unwrap();
3221        assert_eq!(
3222            attrs.get("weight"),
3223            Some(&CgseValue::String("1".to_owned()))
3224        );
3225        assert_eq!(
3226            attrs.get("color"),
3227            Some(&CgseValue::String("red".to_owned()))
3228        );
3229    }
3230
3231    #[test]
3232    fn edges_ordered_preserves_direction() {
3233        let mut g = DiGraph::strict();
3234        g.add_edge("b", "a").unwrap();
3235        g.add_edge("a", "c").unwrap();
3236
3237        let pairs: Vec<(String, String)> = g
3238            .edges_ordered()
3239            .into_iter()
3240            .map(|e| (e.left, e.right))
3241            .collect();
3242        // b was added first as source, so b→a first, then a→c
3243        assert_eq!(
3244            pairs,
3245            vec![
3246                ("b".to_owned(), "a".to_owned()),
3247                ("a".to_owned(), "c".to_owned()),
3248            ]
3249        );
3250    }
3251
3252    #[test]
3253    fn type_identity() {
3254        let g = DiGraph::strict();
3255        assert!(g.is_directed());
3256        assert!(!g.is_multigraph());
3257    }
3258
3259    #[test]
3260    fn to_undirected_merges_edges() {
3261        let mut g = DiGraph::strict();
3262        g.add_edge("a", "b").unwrap();
3263        g.add_edge("b", "a").unwrap();
3264        g.add_edge("b", "c").unwrap();
3265
3266        let ug = g.to_undirected();
3267        assert_eq!(ug.node_count(), 3);
3268        assert_eq!(ug.edge_count(), 2); // (a,b) merged, plus (b,c)
3269        assert!(ug.has_edge("a", "b"));
3270        assert!(ug.has_edge("b", "a")); // undirected: same edge
3271        assert!(ug.has_edge("b", "c"));
3272    }
3273
3274    #[test]
3275    fn to_undirected_preserves_runtime_policy() {
3276        let mut g = DiGraph::hardened();
3277        g.add_edge("a", "b").unwrap();
3278        g.add_edge("b", "a").unwrap();
3279        let expected_policy = g.runtime_policy().clone();
3280
3281        let ug = g.to_undirected();
3282
3283        assert_runtime_policy_preserved(&expected_policy, ug.runtime_policy());
3284    }
3285
3286    #[test]
3287    fn snapshot_roundtrip() {
3288        let mut g = DiGraph::strict();
3289        let mut attrs = AttrMap::new();
3290        attrs.insert("weight".to_owned(), "3".into());
3291        g.add_edge_with_attrs("a", "b", attrs).unwrap();
3292        g.add_edge("b", "c").unwrap();
3293        g.add_edge("c", "a").unwrap();
3294
3295        let snap = g.snapshot();
3296        let mut replayed = DiGraph::new(snap.mode);
3297        for node in &snap.nodes {
3298            let _ = replayed.add_node(node.clone());
3299        }
3300        for edge in &snap.edges {
3301            replayed
3302                .add_edge_with_attrs(edge.left.clone(), edge.right.clone(), edge.attrs.clone())
3303                .unwrap();
3304        }
3305
3306        assert_eq!(replayed.snapshot(), snap);
3307        assert_digraph_core_invariants(&replayed);
3308    }
3309
3310    #[test]
3311    fn multidigraph_tracks_parallel_edges_with_distinct_keys() {
3312        let mut graph = MultiDiGraph::strict();
3313        let first = graph.add_edge("a", "b").expect("edge add should succeed");
3314        let second = graph.add_edge("a", "b").expect("edge add should succeed");
3315
3316        assert_ne!(first, second);
3317        assert_eq!(graph.edge_count(), 2);
3318        assert_eq!(graph.edge_keys("a", "b"), Some(vec![0, 1]));
3319        assert_multidigraph_core_invariants(&graph);
3320    }
3321
3322    #[test]
3323    fn multidigraph_remove_node_clears_incoming_and_outgoing_parallel_edges() {
3324        let mut graph = MultiDiGraph::strict();
3325        let _ = graph.add_edge("a", "b").expect("edge add should succeed");
3326        let _ = graph.add_edge("a", "b").expect("edge add should succeed");
3327        let _ = graph.add_edge("b", "a").expect("edge add should succeed");
3328        let _ = graph.add_edge("b", "c").expect("edge add should succeed");
3329
3330        assert!(graph.remove_node("b"));
3331        assert_eq!(graph.edge_count(), 0);
3332        assert!(!graph.has_node("b"));
3333        assert_eq!(graph.successors("a"), Some(vec![]));
3334        assert_eq!(graph.predecessors("a"), Some(vec![]));
3335        assert_multidigraph_core_invariants(&graph);
3336    }
3337
3338    #[test]
3339    fn multidigraph_roundtrips_sparse_snapshot_keys() {
3340        let mut graph = MultiDiGraph::strict();
3341        assert_eq!(
3342            graph.add_edge("a", "b").expect("edge add should succeed"),
3343            0
3344        );
3345        assert_eq!(
3346            graph.add_edge("a", "b").expect("edge add should succeed"),
3347            1
3348        );
3349        assert_eq!(
3350            graph.add_edge("a", "b").expect("edge add should succeed"),
3351            2
3352        );
3353        assert!(graph.remove_edge("a", "b", Some(1)));
3354        assert_eq!(
3355            graph.add_edge("a", "b").expect("edge add should succeed"),
3356            3
3357        );
3358
3359        let snapshot = graph.snapshot();
3360        assert_eq!(
3361            snapshot
3362                .edges
3363                .iter()
3364                .map(|edge| edge.key)
3365                .collect::<Vec<_>>(),
3366            vec![0, 2, 3]
3367        );
3368
3369        let mut replayed = MultiDiGraph::new(snapshot.mode);
3370        for node in &snapshot.nodes {
3371            let _ = replayed.add_node(node.clone());
3372        }
3373        for edge in &snapshot.edges {
3374            replayed
3375                .add_edge_with_key_and_attrs(
3376                    edge.source.clone(),
3377                    edge.target.clone(),
3378                    edge.key,
3379                    edge.attrs.clone(),
3380                )
3381                .expect("snapshot replay should preserve explicit keys");
3382        }
3383
3384        assert_eq!(replayed.snapshot(), snapshot);
3385        assert_multidigraph_core_invariants(&replayed);
3386    }
3387
3388    #[test]
3389    fn runtime_policy_tracks_hardened_multidigraph_recovery() {
3390        let mut g = MultiDiGraph::hardened();
3391        g.add_edge("a", "b")
3392            .expect("hardened multidigraph edge add should succeed");
3393
3394        assert_eq!(g.runtime_policy().mode(), CompatibilityMode::Hardened);
3395        assert!(!g.runtime_policy().decision_log().records().is_empty());
3396        assert!(g.runtime_policy().posterior().observation_count >= 1);
3397    }
3398
3399    #[test]
3400    fn strict_mode_fails_closed_for_incompatible_attrs() {
3401        let mut g = DiGraph::strict();
3402        let mut attrs = AttrMap::new();
3403        attrs.insert("__fnx_incompatible_decoder".to_owned(), "v2".into());
3404        let err = g
3405            .add_edge_with_attrs("a", "b", attrs)
3406            .expect_err("strict mode should fail closed");
3407
3408        assert_eq!(
3409            err,
3410            GraphError::FailClosed {
3411                operation: "add_edge",
3412                reason: "incompatible edge metadata".to_owned(),
3413            }
3414        );
3415    }
3416
3417    #[test]
3418    fn revision_increments_on_mutations() {
3419        let mut g = DiGraph::strict();
3420        let r0 = g.revision();
3421        let _ = g.add_node("a");
3422        let r1 = g.revision();
3423        assert!(r1 > r0);
3424
3425        g.add_edge("a", "b").unwrap();
3426        let r2 = g.revision();
3427        assert!(r2 > r1);
3428
3429        let _ = g.remove_edge("a", "b");
3430        let r3 = g.revision();
3431        assert!(r3 > r2);
3432    }
3433
3434    #[test]
3435    fn hardened_self_loop_records_allow() {
3436        let mut g = DiGraph::hardened();
3437        g.add_edge("loop", "loop").unwrap();
3438
3439        let record = g
3440            .evidence_ledger()
3441            .records()
3442            .iter()
3443            .rev()
3444            .find(|r| r.operation == "add_edge")
3445            .expect("add_edge should emit ledger row");
3446        assert_decision_record_schema(record, CompatibilityMode::Hardened);
3447        assert_eq!(record.action, DecisionAction::Allow);
3448    }
3449
3450    #[test]
3451    fn runtime_policy_tracks_hardened_digraph_recovery() {
3452        let mut g = DiGraph::hardened();
3453        g.add_edge("a", "b")
3454            .expect("hardened digraph edge add should succeed");
3455
3456        assert_eq!(g.runtime_policy().mode(), CompatibilityMode::Hardened);
3457        assert!(!g.runtime_policy().decision_log().records().is_empty());
3458        assert!(g.runtime_policy().posterior().observation_count >= 1);
3459    }
3460
3461    // -- Proptest -----------------------------------------------------------
3462
3463    proptest! {
3464        #[test]
3465        fn prop_digraph_invariants_under_mixed_mutations(
3466            ops in prop::collection::vec((0_u8..8, 0_u8..8, any::<bool>()), 1..80),
3467        ) {
3468            let mut g = DiGraph::strict();
3469            let mut last_rev = g.revision();
3470
3471            for (src_id, tgt_id, is_add) in ops {
3472                let src = node_name(src_id);
3473                let tgt = node_name(tgt_id);
3474                if is_add {
3475                    prop_assert!(g.add_edge(src, tgt).is_ok());
3476                } else {
3477                    let _ = g.remove_edge(&src, &tgt);
3478                }
3479                let rev = g.revision();
3480                prop_assert!(rev >= last_rev);
3481                last_rev = rev;
3482                assert_digraph_core_invariants(&g);
3483            }
3484        }
3485
3486        #[test]
3487        fn prop_digraph_snapshot_deterministic(
3488            ops in prop::collection::vec((0_u8..8, 0_u8..8, 0_u8..3), 0..64),
3489        ) {
3490            let mut g1 = DiGraph::hardened();
3491            let mut g2 = DiGraph::hardened();
3492
3493            for (src_id, tgt_id, attrs_variant) in ops {
3494                let src = node_name(src_id);
3495                let tgt = node_name(tgt_id);
3496                let mut attrs = AttrMap::new();
3497                if attrs_variant == 1 {
3498                    attrs.insert("weight".to_owned(), (src_id % 5).to_string().into());
3499                } else if attrs_variant == 2 {
3500                    attrs.insert("tag".to_owned(), format!("k{}", tgt_id % 4).into());
3501                }
3502                prop_assert!(g1.add_edge_with_attrs(src.clone(), tgt.clone(), attrs.clone()).is_ok());
3503                prop_assert!(g2.add_edge_with_attrs(src, tgt, attrs).is_ok());
3504            }
3505
3506            prop_assert_eq!(g1.snapshot(), g2.snapshot());
3507        }
3508
3509        #[test]
3510        fn prop_remove_node_clears_all_directed_edges(
3511            ops in prop::collection::vec((0_u8..8, 0_u8..8), 1..64),
3512            target_id in 0_u8..8,
3513        ) {
3514            let mut g = DiGraph::strict();
3515            for (src_id, tgt_id) in ops {
3516                prop_assert!(g.add_edge(node_name(src_id), node_name(tgt_id)).is_ok());
3517            }
3518
3519            let target = node_name(target_id);
3520            let removed = g.remove_node(&target);
3521            if removed {
3522                prop_assert!(!g.has_node(&target));
3523                for node in g.nodes_ordered() {
3524                    prop_assert!(!g.has_edge(node, &target));
3525                    prop_assert!(!g.has_edge(&target, node));
3526                }
3527            }
3528            assert_digraph_core_invariants(&g);
3529        }
3530
3531        #[test]
3532        fn prop_directed_edge_count_equals_successor_sum(
3533            ops in prop::collection::vec((0_u8..8, 0_u8..8), 1..64),
3534        ) {
3535            let mut g = DiGraph::strict();
3536            for (src_id, tgt_id) in ops {
3537                prop_assert!(g.add_edge(node_name(src_id), node_name(tgt_id)).is_ok());
3538            }
3539
3540            let total_out: usize = g.nodes_ordered().iter().map(|n| g.out_degree(n)).sum();
3541            let total_in: usize = g.nodes_ordered().iter().map(|n| g.in_degree(n)).sum();
3542            prop_assert_eq!(g.edge_count(), total_out);
3543            prop_assert_eq!(g.edge_count(), total_in);
3544        }
3545    }
3546}