Skip to main content

fnx_classes/
lib.rs

1#![forbid(unsafe_code)]
2
3pub mod digraph;
4
5use fnx_runtime::{
6    CgseValue, CompatibilityMode, DecisionAction, EvidenceLedger, EvidenceTerm, RuntimePolicy,
7};
8use indexmap::{IndexMap, IndexSet};
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::collections::HashSet;
12use std::fmt;
13
14pub type AttrMap = BTreeMap<String, CgseValue>;
15
16#[derive(Debug, Clone, PartialEq, Eq, Hash)]
17struct EdgeKey {
18    left: String,
19    right: String,
20}
21
22impl EdgeKey {
23    fn new(left: &str, right: &str) -> Self {
24        if left <= right {
25            Self {
26                left: left.to_owned(),
27                right: right.to_owned(),
28            }
29        } else {
30            Self {
31                left: right.to_owned(),
32                right: left.to_owned(),
33            }
34        }
35    }
36}
37
38#[derive(Hash, PartialEq, Eq, Clone, Copy)]
39struct EdgeKeyRef<'a> {
40    left: &'a str,
41    right: &'a str,
42}
43
44impl<'a> EdgeKeyRef<'a> {
45    fn new(left: &'a str, right: &'a str) -> Self {
46        if left <= right {
47            Self { left, right }
48        } else {
49            Self {
50                left: right,
51                right: left,
52            }
53        }
54    }
55}
56
57impl<'a> indexmap::Equivalent<EdgeKey> for EdgeKeyRef<'a> {
58    fn equivalent(&self, key: &EdgeKey) -> bool {
59        self.left == key.left && self.right == key.right
60    }
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum GraphError {
65    FailClosed {
66        operation: &'static str,
67        reason: String,
68    },
69}
70
71impl fmt::Display for GraphError {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match self {
74            Self::FailClosed { operation, reason } => {
75                write!(f, "operation `{operation}` failed closed: {reason}")
76            }
77        }
78    }
79}
80
81impl std::error::Error for GraphError {}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84pub struct EdgeSnapshot {
85    pub left: String,
86    pub right: String,
87    pub attrs: AttrMap,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct GraphSnapshot {
92    pub mode: CompatibilityMode,
93    pub nodes: Vec<String>,
94    /// br-snapnodeattrs: node attributes keyed by node name. Preserved
95    /// across the ``snapshot`` round-trip so a graph with
96    /// ``add_node(n, attrs)`` doesn't lose those attrs when replayed.
97    /// Defaults to empty for snapshots produced by old code paths;
98    /// consumers that don't care can ignore it.
99    #[serde(default)]
100    pub node_attrs: BTreeMap<String, AttrMap>,
101    pub edges: Vec<EdgeSnapshot>,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct MultiEdgeSnapshot {
106    pub left: String,
107    pub right: String,
108    pub key: usize,
109    pub attrs: AttrMap,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct MultiGraphSnapshot {
114    pub mode: CompatibilityMode,
115    pub nodes: Vec<String>,
116    /// See ``GraphSnapshot::node_attrs``.
117    #[serde(default)]
118    pub node_attrs: BTreeMap<String, AttrMap>,
119    pub edges: Vec<MultiEdgeSnapshot>,
120}
121
122#[derive(Debug, Clone)]
123pub struct Graph {
124    mode: CompatibilityMode,
125    revision: u64,
126    nodes: IndexMap<String, AttrMap>,
127    // br-r37-c1-d58s8 P2(c) slice 2: the String adjacency rows are GONE —
128    // adj_indices (order-faithful integer rows) is the single row store;
129    // String views derive through the nodes name table.
130    /// Integer-indexed adjacency list for O(1) traversal. Each entry
131    /// `adj_indices[i]` contains the node indices of neighbors of node i.
132    /// This mirrors `adjacency` but avoids string hashing during BFS/CC.
133    adj_indices: Vec<Vec<usize>>,
134    /// br-r37-c1-d58s8: revision-keyed all-int weights memo (see
135    /// DiGraph::all_int_cache).
136    all_int_cache: std::sync::Arc<std::sync::RwLock<Option<(u64, String, bool)>>>,
137    edge_index_endpoints: Vec<(usize, usize)>,
138    // br-r37-c1-d58s8 edges-map flip: keyed by the INDEX-CANONICAL
139    // (min_idx, max_idx) node pair — zero String allocs/hashes per
140    // insert (the profiled remaining store tax). Node removal REKEYS
141    // via the same remap as the row repair. edge_index_endpoints keeps
142    // the string-canonical orientation for snapshot derivation.
143    edges: IndexMap<(usize, usize), AttrMap>,
144    runtime_policy: RuntimePolicy,
145}
146
147impl Graph {
148    #[must_use]
149    pub fn new(mode: CompatibilityMode) -> Self {
150        Self {
151            mode,
152            revision: 0,
153            nodes: IndexMap::new(),
154            adj_indices: Vec::new(),
155            all_int_cache: std::sync::Arc::default(),
156            edge_index_endpoints: Vec::new(),
157            edges: IndexMap::new(),
158            runtime_policy: RuntimePolicy::new(mode),
159        }
160    }
161
162    #[must_use]
163    pub fn with_runtime_policy(runtime_policy: RuntimePolicy) -> Self {
164        let mode = runtime_policy.mode();
165        Self {
166            mode,
167            revision: 0,
168            nodes: IndexMap::new(),
169            adj_indices: Vec::new(),
170            all_int_cache: std::sync::Arc::default(),
171            edge_index_endpoints: Vec::new(),
172            edges: IndexMap::new(),
173            runtime_policy,
174        }
175    }
176
177    #[must_use]
178    pub fn strict() -> Self {
179        Self::new(CompatibilityMode::Strict)
180    }
181
182    #[must_use]
183    pub fn complete_graph(mode: CompatibilityMode, n: usize) -> Self {
184        let edge_capacity = n.saturating_mul(n.saturating_sub(1)) / 2;
185        let revision = u64::try_from(n)
186            .unwrap_or(u64::MAX)
187            .saturating_add(u64::try_from(edge_capacity).unwrap_or(u64::MAX));
188        let node_labels = (0..n).map(|node| node.to_string()).collect::<Vec<_>>();
189        let mut graph = Self {
190            mode,
191            revision,
192            nodes: IndexMap::with_capacity(n),
193            adj_indices: vec![Vec::with_capacity(n.saturating_sub(1)); n],
194            all_int_cache: std::sync::Arc::default(),
195            edge_index_endpoints: Vec::with_capacity(edge_capacity),
196            edges: IndexMap::with_capacity(edge_capacity),
197            runtime_policy: RuntimePolicy::new(mode),
198        };
199
200        for node in &node_labels {
201            graph.nodes.insert(node.clone(), AttrMap::new());
202        }
203
204        for left_index in 0..n {
205            let _left = &node_labels[left_index];
206            for (right_index, _right) in node_labels.iter().enumerate().skip(left_index + 1) {
207                // Maintain integer adjacency
208                graph.adj_indices[left_index].push(right_index);
209                graph.adj_indices[right_index].push(left_index);
210                graph.edge_index_endpoints.push((left_index, right_index));
211                graph
212                    .edges
213                    .insert(Graph::canon_pair(left_index, right_index), AttrMap::new());
214            }
215        }
216
217        graph.record_decision(
218            "complete_graph_bulk",
219            0.0,
220            false,
221            vec![EvidenceTerm {
222                signal: "nodes".to_owned(),
223                observed_value: n.to_string(),
224                log_likelihood_ratio: 0.0,
225            }],
226        );
227        graph
228    }
229
230    /// Build a non-periodic 2-D grid with NetworkX's node and edge insertion
231    /// sequence. Canonical node keys are row-major all-int tuple strings like
232    /// "(i, j)".
233    #[must_use]
234    pub fn grid_2d(mode: CompatibilityMode, m: usize, n: usize) -> Self {
235        let node_count = m.saturating_mul(n);
236        let edge_capacity = m
237            .saturating_sub(1)
238            .saturating_mul(n)
239            .saturating_add(n.saturating_sub(1).saturating_mul(m));
240        let revision = u64::try_from(node_count)
241            .unwrap_or(u64::MAX)
242            .saturating_add(u64::try_from(edge_capacity).unwrap_or(u64::MAX));
243        let label = |i: usize, j: usize| format!("({i}, {j})");
244        let idx = |i: usize, j: usize| i * n + j;
245        let mut graph = Self {
246            mode,
247            revision,
248            nodes: IndexMap::with_capacity(node_count),
249            adj_indices: vec![Vec::with_capacity(4); node_count],
250            all_int_cache: std::sync::Arc::default(),
251            edge_index_endpoints: Vec::with_capacity(edge_capacity),
252            edges: IndexMap::with_capacity(edge_capacity),
253            runtime_policy: RuntimePolicy::new(mode),
254        };
255        for i in 0..m {
256            for j in 0..n {
257                let key = label(i, j);
258                graph.nodes.insert(key.clone(), AttrMap::new());
259            }
260        }
261        fn add_grid_edge(graph: &mut Graph, _u: String, _v: String, u_idx: usize, v_idx: usize) {
262            // add_edge appends v to row[u] first, then u to row[v].
263            graph.adj_indices[u_idx].push(v_idx);
264            graph.adj_indices[v_idx].push(u_idx);
265            graph.edge_index_endpoints.push((u_idx, v_idx));
266            graph
267                .edges
268                .insert(Graph::canon_pair(u_idx, v_idx), AttrMap::new());
269        }
270        // Phase 1: for (pi, i) in pairwise(rows), for j in cols.
271        for i in 1..m {
272            for j in 0..n {
273                add_grid_edge(
274                    &mut graph,
275                    label(i, j),
276                    label(i - 1, j),
277                    idx(i, j),
278                    idx(i - 1, j),
279                );
280            }
281        }
282        // Phase 2: for i in rows, for (pj, j) in pairwise(cols).
283        for i in 0..m {
284            for j in 1..n {
285                add_grid_edge(
286                    &mut graph,
287                    label(i, j),
288                    label(i, j - 1),
289                    idx(i, j),
290                    idx(i, j - 1),
291                );
292            }
293        }
294        graph.record_decision(
295            "grid_2d_bulk",
296            0.0,
297            false,
298            vec![EvidenceTerm {
299                signal: "nodes".to_owned(),
300                observed_value: node_count.to_string(),
301                log_likelihood_ratio: 0.0,
302            }],
303        );
304        graph
305    }
306
307    /// Build NetworkX's n-dimensional integer grid graph in one bulk pass.
308    ///
309    /// NetworkX constructs this as repeated cartesian products of path/cycle
310    /// graphs, then flattens the nested tuple labels. The product order matters:
311    /// periodic axes use cycle edge-view order, not a simple coordinate loop.
312    pub fn grid_nd(
313        mode: CompatibilityMode,
314        dimensions: &[usize],
315        periodic: &[bool],
316    ) -> Result<Self, GraphError> {
317        if dimensions.len() != periodic.len() {
318            return Err(GraphError::FailClosed {
319                operation: "grid_nd",
320                reason: format!(
321                    "periodic flag count {} does not match dimension count {}",
322                    periodic.len(),
323                    dimensions.len()
324                ),
325            });
326        }
327        if dimensions.is_empty() {
328            return Ok(Self::new(mode));
329        }
330
331        #[derive(Clone)]
332        struct ProductState {
333            node_count: usize,
334            adjacency: Vec<Vec<usize>>,
335            edge_set: HashSet<(usize, usize)>,
336        }
337
338        impl ProductState {
339            fn empty() -> Self {
340                Self {
341                    node_count: 0,
342                    adjacency: Vec::new(),
343                    edge_set: HashSet::new(),
344                }
345            }
346
347            fn axis(size: usize, periodic: bool) -> Self {
348                let mut state = Self {
349                    node_count: size,
350                    adjacency: vec![Vec::new(); size],
351                    edge_set: HashSet::new(),
352                };
353                if size == 0 {
354                    return state;
355                }
356                if periodic {
357                    if size == 1 {
358                        state.add_edge(0, 0);
359                    } else {
360                        for node in 0..(size - 1) {
361                            state.add_edge(node, node + 1);
362                        }
363                        state.add_edge(size - 1, 0);
364                    }
365                } else {
366                    for node in 0..size.saturating_sub(1) {
367                        state.add_edge(node, node + 1);
368                    }
369                }
370                state
371            }
372
373            fn product(axis: &Self, old: &Self) -> Result<Self, GraphError> {
374                let Some(node_count) = axis.node_count.checked_mul(old.node_count) else {
375                    return Err(GraphError::FailClosed {
376                        operation: "grid_nd",
377                        reason: "node count overflow during cartesian product".to_owned(),
378                    });
379                };
380                if node_count == 0 {
381                    return Ok(Self::empty());
382                }
383
384                let axis_edges = axis.edges_view();
385                let old_edges = old.edges_view();
386                let mut state = Self {
387                    node_count,
388                    adjacency: vec![Vec::new(); node_count],
389                    edge_set: HashSet::with_capacity(
390                        axis_edges
391                            .len()
392                            .saturating_mul(old.node_count)
393                            .saturating_add(old_edges.len().saturating_mul(axis.node_count)),
394                    ),
395                };
396
397                for (axis_left, axis_right) in axis_edges {
398                    let left_base = axis_left * old.node_count;
399                    let right_base = axis_right * old.node_count;
400                    for old_index in 0..old.node_count {
401                        state.add_edge(left_base + old_index, right_base + old_index);
402                    }
403                }
404                for axis_index in 0..axis.node_count {
405                    let base = axis_index * old.node_count;
406                    for &(old_left, old_right) in &old_edges {
407                        state.add_edge(base + old_left, base + old_right);
408                    }
409                }
410                Ok(state)
411            }
412
413            fn add_edge(&mut self, left: usize, right: usize) {
414                let key = if left <= right {
415                    (left, right)
416                } else {
417                    (right, left)
418                };
419                if !self.edge_set.insert(key) {
420                    return;
421                }
422                self.adjacency[left].push(right);
423                if left != right {
424                    self.adjacency[right].push(left);
425                }
426            }
427
428            fn edges_view(&self) -> Vec<(usize, usize)> {
429                let mut edges = Vec::with_capacity(self.edge_set.len());
430                let mut seen = vec![false; self.node_count];
431                for left in 0..self.node_count {
432                    for &right in &self.adjacency[left] {
433                        if !seen[right] {
434                            edges.push((left, right));
435                        }
436                    }
437                    seen[left] = true;
438                }
439                edges
440            }
441        }
442
443        fn coords_from_index(mut index: usize, sizes: &[usize]) -> Vec<usize> {
444            let mut coords = vec![0; sizes.len()];
445            for axis in (0..sizes.len()).rev() {
446                let size = sizes[axis];
447                coords[axis] = index % size;
448                index /= size;
449            }
450            coords
451        }
452
453        fn tuple_canonical(coords: &[usize]) -> String {
454            if let [value] = coords {
455                return value.to_string();
456            }
457            let mut s = String::with_capacity(coords.len() * 6 + 2);
458            s.push('(');
459            for (i, value) in coords.iter().enumerate() {
460                if i > 0 {
461                    s.push_str(", ");
462                }
463                s.push_str(&value.to_string());
464            }
465            s.push(')');
466            s
467        }
468
469        let mut state = ProductState::axis(dimensions[0], periodic[0]);
470        for (&size, &periodic_axis) in dimensions.iter().zip(periodic).skip(1) {
471            let axis = ProductState::axis(size, periodic_axis);
472            state = ProductState::product(&axis, &state)?;
473        }
474
475        let output_sizes = dimensions.iter().rev().copied().collect::<Vec<_>>();
476        let labels = (0..state.node_count)
477            .map(|index| tuple_canonical(&coords_from_index(index, &output_sizes)))
478            .collect::<Vec<_>>();
479        let edge_view = state.edges_view();
480        let mut final_adjacency = vec![Vec::new(); state.node_count];
481        for &(left, right) in &edge_view {
482            final_adjacency[left].push(right);
483            if left != right {
484                final_adjacency[right].push(left);
485            }
486        }
487        let revision = u64::try_from(state.node_count)
488            .unwrap_or(u64::MAX)
489            .saturating_add(u64::try_from(edge_view.len()).unwrap_or(u64::MAX));
490        let mut graph = Self {
491            mode,
492            revision,
493            nodes: IndexMap::with_capacity(state.node_count),
494            adj_indices: Vec::with_capacity(state.node_count),
495            all_int_cache: std::sync::Arc::default(),
496            edge_index_endpoints: Vec::with_capacity(edge_view.len()),
497            edges: IndexMap::with_capacity(edge_view.len()),
498            runtime_policy: RuntimePolicy::new(mode),
499        };
500
501        for (index, label) in labels.iter().enumerate() {
502            graph.nodes.insert(label.clone(), AttrMap::new());
503            graph.adj_indices.push(final_adjacency[index].clone());
504        }
505
506        for (left, right) in edge_view {
507            graph.edge_index_endpoints.push((left, right));
508            graph
509                .edges
510                .insert(Graph::canon_pair(left, right), AttrMap::new());
511        }
512
513        graph.record_decision(
514            "grid_nd_bulk",
515            0.0,
516            false,
517            vec![EvidenceTerm {
518                signal: "nodes".to_owned(),
519                observed_value: state.node_count.to_string(),
520                log_likelihood_ratio: 0.0,
521            }],
522        );
523        Ok(graph)
524    }
525
526    /// br-r37-c1-z2eaa: Kneser graph K(n, k) replicating NetworkX's exact
527    /// construction sequence. Nodes are k-subsets of 0..n as all-int-tuple
528    /// canonicals "(a, b, ...)" (br-r37-c1-y7m24). nx builds via
529    /// `add_edges_from((s, t) for s in subsets for t in
530    /// combinations(universe - set(s), k))` — node order is edge-DISCOVERY
531    /// order (u then v per new edge) and each unordered edge is offered
532    /// twice (the second add is a structural no-op). The complement
533    /// `universe - set(s)` iterates ASCENDING whenever every value fits its
534    /// exact CPython set slot — the Python wrapper gates this kernel on
535    /// that condition, so lexicographic combinations over the sorted
536    /// complement reproduce nx byte-for-byte. For `2k > n` nodes are
537    /// pre-added in subset order and no edges exist.
538    #[must_use]
539    pub fn kneser(mode: CompatibilityMode, n: usize, k: usize) -> Self {
540        fn tuple_canonical(c: &[usize]) -> String {
541            let mut s = String::with_capacity(c.len() * 6 + 2);
542            s.push('(');
543            for (i, v) in c.iter().enumerate() {
544                if i > 0 {
545                    s.push_str(", ");
546                }
547                s.push_str(&v.to_string());
548            }
549            if c.len() == 1 {
550                s.push(',');
551            }
552            s.push(')');
553            s
554        }
555        fn lex_combinations(m: usize, k: usize) -> Vec<Vec<usize>> {
556            let mut out = Vec::new();
557            if k > m {
558                return out;
559            }
560            let mut cur: Vec<usize> = (0..k).collect();
561            loop {
562                out.push(cur.clone());
563                let mut advanced = false;
564                let mut i = k;
565                while i > 0 {
566                    i -= 1;
567                    if cur[i] != i + m - k {
568                        cur[i] += 1;
569                        for j in (i + 1)..k {
570                            cur[j] = cur[j - 1] + 1;
571                        }
572                        advanced = true;
573                        break;
574                    }
575                }
576                if !advanced {
577                    break;
578                }
579            }
580            out
581        }
582        let subsets = lex_combinations(n, k);
583        let canonicals: Vec<String> = subsets.iter().map(|c| tuple_canonical(c)).collect();
584        let total = subsets.len();
585        let mut graph = Self {
586            mode,
587            revision: u64::try_from(total).unwrap_or(u64::MAX),
588            nodes: IndexMap::with_capacity(total),
589            adj_indices: Vec::new(),
590            all_int_cache: std::sync::Arc::default(),
591            edge_index_endpoints: Vec::new(),
592            edges: IndexMap::new(),
593            runtime_policy: RuntimePolicy::new(mode),
594        };
595        if 2 * k > n {
596            // No disjoint pairs exist — nx pre-adds all subsets as nodes.
597            for canonical in &canonicals {
598                graph.nodes.insert(canonical.clone(), AttrMap::new());
599            }
600            graph.adj_indices = vec![Vec::new(); total];
601            return graph;
602        }
603        // Binomial table for lexicographic combination ranking.
604        let mut binom = vec![vec![0usize; k + 1]; n + 1];
605        for i in 0..=n {
606            binom[i][0] = 1;
607            for j in 1..=k.min(i) {
608                binom[i][j] = if i == j {
609                    1
610                } else {
611                    binom[i - 1][j - 1].saturating_add(binom[i - 1][j])
612                };
613            }
614        }
615        let lex_rank = |c: &[usize]| -> usize {
616            let mut r = 0usize;
617            let mut prev = 0usize;
618            for (i, &ci) in c.iter().enumerate() {
619                for j in prev..ci {
620                    r = r.saturating_add(binom[n - 1 - j][k - 1 - i]);
621                }
622                prev = ci + 1;
623            }
624            r
625        };
626        let mut seen = HashSet::<(usize, usize)>::with_capacity(total * 2);
627        // node first-touch insertion mirroring nx add_edge (u then v)
628        let mut node_idx: Vec<usize> = vec![usize::MAX; total];
629        let mut in_s = vec![false; n];
630        let m = n - k;
631        let picks = lex_combinations(m, k);
632        for (s_idx, s) in subsets.iter().enumerate() {
633            for &v in s {
634                in_s[v] = true;
635            }
636            let complement: Vec<usize> = (0..n).filter(|&v| !in_s[v]).collect();
637            for pick in &picks {
638                let t: Vec<usize> = pick.iter().map(|&i| complement[i]).collect();
639                let t_idx = lex_rank(&t);
640                let pair = (s_idx.min(t_idx), s_idx.max(t_idx));
641                if seen.insert(pair) {
642                    for idx in [s_idx, t_idx] {
643                        if node_idx[idx] == usize::MAX {
644                            let entry = graph
645                                .nodes
646                                .insert_full(canonicals[idx].clone(), AttrMap::new())
647                                .0;
648                            node_idx[idx] = entry;
649                            graph.adj_indices.push(Vec::new());
650                        }
651                    }
652                    if !graph.adj_indices[node_idx[s_idx]].contains(&node_idx[t_idx]) {
653                        graph.adj_indices[node_idx[s_idx]].push(node_idx[t_idx]);
654                    }
655                    if node_idx[s_idx] != node_idx[t_idx]
656                        && !graph.adj_indices[node_idx[t_idx]].contains(&node_idx[s_idx])
657                    {
658                        graph.adj_indices[node_idx[t_idx]].push(node_idx[s_idx]);
659                    }
660                    graph
661                        .edge_index_endpoints
662                        .push((node_idx[s_idx], node_idx[t_idx]));
663                    graph.edges.insert(
664                        Graph::canon_pair(node_idx[s_idx], node_idx[t_idx]),
665                        AttrMap::new(),
666                    );
667                }
668            }
669            for &v in s {
670                in_s[v] = false;
671            }
672        }
673        // adj_indices maintained inline above (P2(c) slice 2).
674        graph.revision = u64::try_from(graph.nodes.len() + graph.edges.len()).unwrap_or(u64::MAX);
675        graph
676    }
677
678    #[must_use]
679    pub fn hardened() -> Self {
680        Self::new(CompatibilityMode::Hardened)
681    }
682
683    #[must_use]
684    pub fn mode(&self) -> CompatibilityMode {
685        self.mode
686    }
687
688    #[must_use]
689    pub fn node_count(&self) -> usize {
690        self.nodes.len()
691    }
692
693    #[must_use]
694    pub fn edge_count(&self) -> usize {
695        self.edges.len()
696    }
697
698    #[must_use]
699    pub fn revision(&self) -> u64 {
700        self.revision
701    }
702
703    #[must_use]
704    pub fn has_node(&self, node: &str) -> bool {
705        self.nodes.contains_key(node)
706    }
707
708    /// br-r37-c1-d58s8 edges-map flip: resolve a String pair to the
709    /// index-canonical (min, max) key. None if either node is absent.
710    #[inline]
711    fn edge_pair_key(&self, left: &str, right: &str) -> Option<(usize, usize)> {
712        let l = self.nodes.get_index_of(left)?;
713        let r = self.nodes.get_index_of(right)?;
714        Some(if l <= r { (l, r) } else { (r, l) })
715    }
716
717    #[inline]
718    fn canon_pair(l: usize, r: usize) -> (usize, usize) {
719        if l <= r { (l, r) } else { (r, l) }
720    }
721
722    #[must_use]
723    pub fn has_edge(&self, left: &str, right: &str) -> bool {
724        self.edge_pair_key(left, right)
725            .is_some_and(|k| self.edges.contains_key(&k))
726    }
727
728    #[must_use]
729    pub fn nodes_ordered(&self) -> Vec<&str> {
730        self.nodes.keys().map(String::as_str).collect()
731    }
732
733    #[must_use]
734    pub fn get_node_index(&self, node: &str) -> Option<usize> {
735        self.nodes.get_index_of(node)
736    }
737
738    #[must_use]
739    pub fn get_node_name(&self, index: usize) -> Option<&str> {
740        self.nodes.get_index(index).map(|(k, _)| k.as_str())
741    }
742
743    #[must_use]
744    pub fn neighbors(&self, node: &str) -> Option<Vec<&str>> {
745        // br-r37-c1-d58s8 P2(a): serve from the integer rows + node-name
746        // table — adj_indices is order-faithful through every mutator
747        // (extends push in row order; remove_node repairs in place;
748        // apply_row_orders and reorder_rows_for_nx_copy_walk both
749        // resync). Migrating readers off the String rows is the
750        // prerequisite for dropping their eager maintenance (P2(c)).
751        let idx = self.nodes.get_index_of(node)?;
752        Some(
753            self.adj_indices[idx]
754                .iter()
755                .map(|&i| {
756                    self.nodes
757                        .get_index(i)
758                        .expect("adj_indices entries are valid node indices")
759                        .0
760                        .as_str()
761                })
762                .collect::<Vec<&str>>(),
763        )
764    }
765
766    #[must_use]
767    pub fn neighbors_iter(&self, node: &str) -> Option<impl Iterator<Item = &str> + '_> {
768        // br-r37-c1-d58s8 P2(a): index-backed (see neighbors()).
769        let idx = self.nodes.get_index_of(node)?;
770        Some(self.adj_indices[idx].iter().map(move |&i| {
771            self.nodes
772                .get_index(i)
773                .expect("adj_indices entries are valid node indices")
774                .0
775                .as_str()
776        }))
777    }
778
779    /// Return neighbor indices for O(1) traversal. Avoids string hashing
780    /// during BFS/DFS. Returns None if node index is out of bounds.
781    #[must_use]
782    #[inline]
783    pub fn neighbors_indices(&self, node_idx: usize) -> Option<&[usize]> {
784        self.adj_indices.get(node_idx).map(Vec::as_slice)
785    }
786
787    /// br-r37-c1-7dpyg: structural clone with a FRESH RuntimePolicy —
788    /// `Clone` deep-copies the unbounded decision ledger (copy() measured
789    /// 2.3-2.8x slower on per-edge-ctor sources with identical structure);
790    /// result graphs carry the MODE, not the history.
791    #[must_use]
792    pub fn clone_with_fresh_policy(&self) -> Self {
793        Self {
794            mode: self.mode,
795            revision: self.revision,
796            nodes: self.nodes.clone(),
797            adj_indices: self.adj_indices.clone(),
798            all_int_cache: std::sync::Arc::default(),
799            edge_index_endpoints: self.edge_index_endpoints.clone(),
800            edges: self.edges.clone(),
801            runtime_policy: RuntimePolicy::new(self.mode),
802        }
803    }
804
805    /// br-r37-c1-u3qyn: restore explicit adjacency row orders (pickle
806    /// round-trip). Each entry is (node, neighbor order); rows are
807    /// rebuilt in the given order with any unlisted survivors appended
808    /// in their current order (robust to stale state). The integer
809    /// `adj_indices` mirror is rebuilt to match.
810    pub fn apply_row_orders(&mut self, orders: &[(String, Vec<String>)]) {
811        // br-r37-c1-d58s8 P2(c) slice 2: reorder the integer rows directly.
812        for (node, order) in orders {
813            let Some(idx) = self.nodes.get_index_of(node.as_str()) else {
814                continue;
815            };
816            let row = &self.adj_indices[idx];
817            let row_set: std::collections::HashSet<usize> = row.iter().copied().collect();
818            let mut new_row: Vec<usize> = Vec::with_capacity(row.len());
819            let mut placed: std::collections::HashSet<usize> = std::collections::HashSet::new();
820            for v in order {
821                if let Some(v_idx) = self.nodes.get_index_of(v.as_str())
822                    && row_set.contains(&v_idx)
823                    && placed.insert(v_idx)
824                {
825                    new_row.push(v_idx);
826                }
827            }
828            for &v_idx in row {
829                if placed.insert(v_idx) {
830                    new_row.push(v_idx);
831                }
832            }
833            self.adj_indices[idx] = new_row;
834        }
835    }
836
837    /// br-r37-c1-0ek49: reorder every adjacency row into NetworkX's
838    /// `Graph.copy()` walk order. nx copy rebuilds via
839    /// `add_edges_from((u, v, d) for u in _adj for v in _adj[u])`, so an
840    /// unordered pair enters BOTH endpoint rows at its first touch —
841    /// during the earlier endpoint's row scan. Row u therefore lists
842    /// neighbors at a smaller node position first (ordered by that
843    /// neighbor's scan time: `(pos(v), index of u within row v)`),
844    /// followed by its remaining neighbors — self-loops included — in
845    /// row u's original order. Call on a fresh clone inside copy-shaped
846    /// constructors; graph content is unchanged, only row order moves.
847    pub fn reorder_rows_for_nx_copy_walk(&mut self) {
848        // br-r37-c1-predrebuild: rebuild rows in nx's copy-walk order in O(E),
849        // replacing the per-early-neighbor linear search
850        // `adj_indices[v].position(|w| w == pu)` (O(E * degree) — quadratic on
851        // dense graphs; it erodes dense Graph.copy() wins 9.5x->5.9x as density
852        // climbs). Early neighbors of pu (v < pu) sort by
853        // (pos(v), index-of-pu-within-adj[v]); walking adj in (v, adj-index)
854        // order and appending v to early[pu] when pu > v yields exactly that
855        // order. Late neighbors (v >= pu) keep adj[pu]'s row order. Byte-
856        // identical row order — no search, no sort.
857        let n = self.adj_indices.len();
858        let mut early: Vec<Vec<usize>> = vec![Vec::new(); n];
859        for (v, row) in self.adj_indices.iter().enumerate() {
860            for &pu in row {
861                if pu > v {
862                    early[pu].push(v);
863                }
864            }
865        }
866        let mut new_rows: Vec<Vec<usize>> = Vec::with_capacity(n);
867        for (pu, row) in self.adj_indices.iter().enumerate() {
868            let mut new_row = std::mem::take(&mut early[pu]);
869            new_row.reserve(row.len());
870            for &v in row {
871                if v >= pu {
872                    new_row.push(v);
873                }
874            }
875            new_rows.push(new_row);
876        }
877        self.adj_indices = new_rows;
878    }
879
880    pub fn edges_storage_order_index_iter(
881        &self,
882    ) -> impl Iterator<Item = (usize, usize, &AttrMap)> + '_ {
883        self.edge_index_endpoints
884            .iter()
885            .copied()
886            .zip(self.edges.values())
887            .map(|((left, right), attrs)| (left, right, attrs))
888    }
889
890    #[must_use]
891    pub fn neighbor_count(&self, node: &str) -> usize {
892        // br-r37-c1-d58s8 P2(a): index-backed.
893        self.nodes
894            .get_index_of(node)
895            .map_or(0, |idx| self.adj_indices[idx].len())
896    }
897
898    /// Return the degree of a node.
899    /// Self-loops contribute 2 to the degree (NetworkX convention).
900    #[must_use]
901    pub fn degree(&self, node: &str) -> usize {
902        let count = self.neighbor_count(node);
903        // If node has a self-loop, add 1 extra (self-loop contributes 2 total)
904        if self.has_edge(node, node) {
905            count + 1
906        } else {
907            count
908        }
909    }
910
911    /// br-r37-c1-degidx: degree by node INDEX — O(1), zero String
912    /// hashing (the &str path costs 2 hashes/node: neighbor_count's
913    /// get_index_of + the self-loop has_edge). Used by the DegreeView
914    /// iterator which already walks nodes in index order.
915    #[must_use]
916    pub fn degree_by_index(&self, idx: usize) -> usize {
917        let row = &self.adj_indices[idx];
918        let mut count = row.len();
919        if row.contains(&idx) {
920            count += 1; // self-loop contributes 2 to total degree
921        }
922        count
923    }
924
925    #[must_use]
926    pub fn node_attrs(&self, node: &str) -> Option<&AttrMap> {
927        self.nodes.get(node)
928    }
929
930    #[must_use]
931    pub fn edge_attrs(&self, left: &str, right: &str) -> Option<&AttrMap> {
932        self.edges.get(&self.edge_pair_key(left, right)?)
933    }
934
935    /// br-r37-c1-d58s8: index-keyed attr access — a DIRECT map get for
936    /// callers that already hold node indices (kernels, CSR weight
937    /// builders, compose walks). Zero node-map probes.
938    #[must_use]
939    #[inline]
940    pub fn edge_attrs_by_indices(&self, left_idx: usize, right_idx: usize) -> Option<&AttrMap> {
941        self.edges.get(&Self::canon_pair(left_idx, right_idx))
942    }
943
944    #[must_use]
945    pub fn nodes_are_contiguous_int_prefix(&self) -> bool {
946        self.nodes
947            .keys()
948            .enumerate()
949            .all(|(index, key)| Self::decimal_key_matches_index(key, index))
950    }
951
952    fn decimal_key_matches_index(key: &str, index: usize) -> bool {
953        let bytes = key.as_bytes();
954        if bytes.is_empty() || (bytes.len() > 1 && bytes[0] == b'0') {
955            return false;
956        }
957
958        let mut parsed = 0usize;
959        for &byte in bytes {
960            if !byte.is_ascii_digit() {
961                return false;
962            }
963            let digit = usize::from(byte - b'0');
964            let Some(next) = parsed
965                .checked_mul(10)
966                .and_then(|value| value.checked_add(digit))
967            else {
968                return false;
969            };
970            parsed = next;
971        }
972        parsed == index
973    }
974
975    #[must_use]
976    pub fn evidence_ledger(&self) -> &EvidenceLedger {
977        self.runtime_policy.decision_log()
978    }
979
980    #[must_use]
981    pub fn runtime_policy(&self) -> &RuntimePolicy {
982        &self.runtime_policy
983    }
984
985    pub fn set_runtime_policy(&mut self, runtime_policy: RuntimePolicy) {
986        self.mode = runtime_policy.mode();
987        self.runtime_policy = runtime_policy;
988    }
989
990    /// Type identity: always `false` for undirected Graph.
991    #[must_use]
992    pub fn is_directed(&self) -> bool {
993        false
994    }
995
996    /// Type identity: always `false` for Graph (not a multigraph).
997    #[must_use]
998    pub fn is_multigraph(&self) -> bool {
999        false
1000    }
1001
1002    pub fn add_node(&mut self, node: impl Into<String>) -> bool {
1003        self.add_node_with_attrs(node, AttrMap::new())
1004    }
1005
1006    pub fn add_node_with_attrs(&mut self, node: impl Into<String>, attrs: AttrMap) -> bool {
1007        let node = node.into();
1008        let existed = self.nodes.contains_key(&node);
1009        let mut changed = !existed;
1010        let attrs_count = {
1011            let bucket = self.nodes.entry(node.clone()).or_default();
1012            if !attrs.is_empty()
1013                && attrs
1014                    .iter()
1015                    .any(|(key, value)| bucket.get(key) != Some(value))
1016            {
1017                changed = true;
1018            }
1019            bucket.extend(attrs);
1020            bucket.len()
1021        };
1022        // Extend integer adjacency list for new nodes
1023        if !existed {
1024            self.adj_indices.push(Vec::new());
1025        }
1026        if changed {
1027            self.revision = self.revision.saturating_add(1);
1028        }
1029        self.record_decision(
1030            "add_node",
1031            0.0,
1032            false,
1033            vec![
1034                EvidenceTerm {
1035                    signal: "node_preexisting".to_owned(),
1036                    observed_value: existed.to_string(),
1037                    log_likelihood_ratio: -3.0,
1038                },
1039                EvidenceTerm {
1040                    signal: "attrs_count".to_owned(),
1041                    observed_value: attrs_count.to_string(),
1042                    log_likelihood_ratio: -1.0,
1043                },
1044            ],
1045        );
1046        changed
1047    }
1048
1049    /// Bulk node insertion for generated/batched construction paths where
1050    /// per-node compatibility evidence would be pure constant-factor overhead.
1051    #[must_use]
1052    pub fn extend_nodes_unrecorded<I, N>(&mut self, nodes: I) -> usize
1053    where
1054        I: IntoIterator<Item = N>,
1055        N: Into<String>,
1056    {
1057        let iterator = nodes.into_iter();
1058        let (lower_bound, _) = iterator.size_hint();
1059        self.nodes.reserve(lower_bound);
1060        self.adj_indices.reserve(lower_bound);
1061
1062        let mut inserted = 0usize;
1063        for node in iterator {
1064            let node = node.into();
1065            if self.nodes.contains_key(&node) {
1066                continue;
1067            }
1068            self.nodes.insert(node.clone(), AttrMap::new());
1069            self.adj_indices.push(Vec::new());
1070            inserted += 1;
1071        }
1072        if inserted > 0 {
1073            self.revision = self
1074                .revision
1075                .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
1076            self.record_decision(
1077                "extend_nodes_unrecorded",
1078                0.0,
1079                false,
1080                vec![EvidenceTerm {
1081                    signal: "batch_node_count".to_owned(),
1082                    observed_value: inserted.to_string(),
1083                    log_likelihood_ratio: -1.0,
1084                }],
1085            );
1086        }
1087        inserted
1088    }
1089
1090    /// br-r37-c1-l5ve7: bulk node insert WITH attrs, one ledger record —
1091    /// mirror of DiGraph::extend_nodes_with_attrs_unrecorded. Existing
1092    /// nodes merge attrs (extend), matching add_node_with_attrs.
1093    pub fn extend_nodes_with_attrs_unrecorded<I>(&mut self, nodes: I) -> usize
1094    where
1095        I: IntoIterator<Item = (String, AttrMap)>,
1096    {
1097        let iterator = nodes.into_iter();
1098        let (lower_bound, _) = iterator.size_hint();
1099        self.nodes.reserve(lower_bound);
1100        self.adj_indices.reserve(lower_bound);
1101
1102        let mut inserted = 0usize;
1103        for (node, attrs) in iterator {
1104            if let Some(existing) = self.nodes.get_mut(&node) {
1105                existing.extend(attrs);
1106                continue;
1107            }
1108            self.nodes.insert(node.clone(), attrs);
1109            self.adj_indices.push(Vec::new());
1110            inserted += 1;
1111        }
1112        if inserted > 0 {
1113            self.revision = self
1114                .revision
1115                .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
1116            self.record_decision(
1117                "extend_nodes_with_attrs_unrecorded",
1118                0.0,
1119                false,
1120                vec![EvidenceTerm {
1121                    signal: "batch_node_count".to_owned(),
1122                    observed_value: inserted.to_string(),
1123                    log_likelihood_ratio: -1.0,
1124                }],
1125            );
1126        }
1127        inserted
1128    }
1129
1130    pub fn apply_node_defaults(&mut self, defaults: &AttrMap) -> bool {
1131        if defaults.is_empty() {
1132            return false;
1133        }
1134        let mut inserted = 0usize;
1135        for attrs in self.nodes.values_mut() {
1136            for (key, value) in defaults {
1137                if !attrs.contains_key(key) {
1138                    attrs.insert(key.clone(), value.clone());
1139                    inserted += 1;
1140                }
1141            }
1142        }
1143        if inserted > 0 {
1144            self.revision = self.revision.saturating_add(1);
1145            self.record_decision(
1146                "apply_node_defaults",
1147                0.0,
1148                false,
1149                vec![EvidenceTerm {
1150                    signal: "defaults_applied".to_owned(),
1151                    observed_value: inserted.to_string(),
1152                    log_likelihood_ratio: 0.0,
1153                }],
1154            );
1155            return true;
1156        }
1157        false
1158    }
1159
1160    pub fn add_edge(
1161        &mut self,
1162        left: impl Into<String>,
1163        right: impl Into<String>,
1164    ) -> Result<(), GraphError> {
1165        self.add_edge_with_attrs(left, right, AttrMap::new())
1166    }
1167
1168    /// Bulk-add a sequence of attribute-free edges. Bypasses the
1169    /// per-edge `runtime_policy.record_decision` call that
1170    /// [`add_edge_with_attrs`] makes. Instead, emit one summary record
1171    /// for the whole batch.
1172    ///
1173    /// Intended for fnx-internal callers that build a fresh graph
1174    /// from a known-good edge list (e.g., `complement`,
1175    /// `transitive_closure`, etc.) where the per-edge
1176    /// incompatibility-probability accounting would just add
1177    /// constant-factor overhead and is uninteresting for the policy
1178    /// log.
1179    ///
1180    /// Nodes referenced by edges are auto-created if absent
1181    /// (matching `add_edge` semantics). br-r37-c1-4jd8m.
1182    #[must_use]
1183    pub fn extend_edges_unrecorded<I, L, R>(&mut self, edges: I) -> usize
1184    where
1185        I: IntoIterator<Item = (L, R)>,
1186        L: Into<String>,
1187        R: Into<String>,
1188    {
1189        let iterator = edges.into_iter();
1190        let (lower_bound, _) = iterator.size_hint();
1191        self.nodes.reserve(lower_bound);
1192        self.adj_indices.reserve(lower_bound);
1193        self.edges.reserve(lower_bound);
1194        self.edge_index_endpoints.reserve(lower_bound);
1195
1196        let mut inserted = 0usize;
1197        let mut nodes_added = false;
1198        for (left, right) in iterator {
1199            let left = left.into();
1200            let right = right.into();
1201            let left_idx = match self.nodes.get_index_of(&left) {
1202                Some(index) => index,
1203                None => {
1204                    let index = self.nodes.len();
1205                    self.nodes.insert(left.clone(), AttrMap::new());
1206                    self.adj_indices.push(Vec::new());
1207                    nodes_added = true;
1208                    index
1209                }
1210            };
1211            let right_idx = if left == right {
1212                left_idx
1213            } else {
1214                match self.nodes.get_index_of(&right) {
1215                    Some(index) => index,
1216                    None => {
1217                        let index = self.nodes.len();
1218                        self.nodes.insert(right.clone(), AttrMap::new());
1219                        self.adj_indices.push(Vec::new());
1220                        nodes_added = true;
1221                        index
1222                    }
1223                }
1224            };
1225            let edge_key = Self::canon_pair(left_idx, right_idx);
1226            if self.edges.contains_key(&edge_key) {
1227                continue;
1228            }
1229            if left <= right {
1230                self.edge_index_endpoints.push((left_idx, right_idx));
1231            } else {
1232                self.edge_index_endpoints.push((right_idx, left_idx));
1233            }
1234            self.edges.insert(edge_key, AttrMap::new());
1235
1236            self.adj_indices[left_idx].push(right_idx);
1237            if left_idx != right_idx {
1238                self.adj_indices[right_idx].push(left_idx);
1239            }
1240            inserted += 1;
1241        }
1242        self.record_bulk_edge_summary(inserted, nodes_added);
1243        inserted
1244    }
1245
1246    /// Bulk-add attribute-free edges whose endpoints have already been
1247    /// resolved to existing node indices. This is the index-space sibling of
1248    /// [`extend_edges_unrecorded`]: it preserves duplicate handling, adjacency
1249    /// row order, edge storage order, and string-canonical endpoint orientation
1250    /// without per-edge node-name allocation or map lookup.
1251    #[must_use]
1252    pub fn extend_existing_index_edges_unrecorded<I>(&mut self, edges: I) -> usize
1253    where
1254        I: IntoIterator<Item = (usize, usize)>,
1255    {
1256        let iterator = edges.into_iter();
1257        let (lower_bound, _) = iterator.size_hint();
1258        self.edges.reserve(lower_bound);
1259        self.edge_index_endpoints.reserve(lower_bound);
1260
1261        let mut inserted = 0usize;
1262        for (left_idx, right_idx) in iterator {
1263            debug_assert!(left_idx < self.nodes.len());
1264            debug_assert!(right_idx < self.nodes.len());
1265
1266            let edge_key = Self::canon_pair(left_idx, right_idx);
1267            if self.edges.contains_key(&edge_key) {
1268                continue;
1269            }
1270
1271            let left_name = self
1272                .nodes
1273                .get_index(left_idx)
1274                .expect("validated left node index")
1275                .0
1276                .as_str();
1277            let right_name = self
1278                .nodes
1279                .get_index(right_idx)
1280                .expect("validated right node index")
1281                .0
1282                .as_str();
1283            if left_name <= right_name {
1284                self.edge_index_endpoints.push((left_idx, right_idx));
1285            } else {
1286                self.edge_index_endpoints.push((right_idx, left_idx));
1287            }
1288            self.edges.insert(edge_key, AttrMap::new());
1289
1290            self.adj_indices[left_idx].push(right_idx);
1291            if left_idx != right_idx {
1292                self.adj_indices[right_idx].push(left_idx);
1293            }
1294            inserted += 1;
1295        }
1296        self.record_bulk_edge_summary(inserted, false);
1297        inserted
1298    }
1299
1300    /// br-r37-c1-dodattrbatch: bulk-add ATTRIBUTED edges by EXISTING node index
1301    /// — the attributed sibling of [`extend_existing_index_edges_unrecorded`].
1302    /// All endpoints MUST already exist (callers gate on a contiguous-int node
1303    /// prefix). A new edge takes the given AttrMap; a duplicate edge MERGES
1304    /// (last-writer-wins per key) exactly like `add_edge_with_attrs`. Replaces
1305    /// the per-edge ledger with one batch summary; no String hashing for the
1306    /// endpoints (they arrive as integer indices).
1307    pub fn extend_existing_index_edges_with_attrs_unrecorded<I>(&mut self, edges: I) -> usize
1308    where
1309        I: IntoIterator<Item = (usize, usize, AttrMap)>,
1310    {
1311        let iterator = edges.into_iter();
1312        let (lower_bound, _) = iterator.size_hint();
1313        self.edges.reserve(lower_bound);
1314        self.edge_index_endpoints.reserve(lower_bound);
1315
1316        let mut inserted = 0usize;
1317        for (left_idx, right_idx, attrs) in iterator {
1318            debug_assert!(left_idx < self.nodes.len());
1319            debug_assert!(right_idx < self.nodes.len());
1320
1321            let edge_key = Self::canon_pair(left_idx, right_idx);
1322            if let Some(existing) = self.edges.get_mut(&edge_key) {
1323                // Duplicate edge within the batch: merge attrs (last wins).
1324                for (key, value) in attrs {
1325                    existing.insert(key, value);
1326                }
1327                continue;
1328            }
1329
1330            let left_name = self
1331                .nodes
1332                .get_index(left_idx)
1333                .expect("validated left node index")
1334                .0
1335                .as_str();
1336            let right_name = self
1337                .nodes
1338                .get_index(right_idx)
1339                .expect("validated right node index")
1340                .0
1341                .as_str();
1342            if left_name <= right_name {
1343                self.edge_index_endpoints.push((left_idx, right_idx));
1344            } else {
1345                self.edge_index_endpoints.push((right_idx, left_idx));
1346            }
1347            self.edges.insert(edge_key, attrs);
1348
1349            self.adj_indices[left_idx].push(right_idx);
1350            if left_idx != right_idx {
1351                self.adj_indices[right_idx].push(left_idx);
1352            }
1353            inserted += 1;
1354        }
1355        self.record_bulk_edge_summary(inserted, false);
1356        inserted
1357    }
1358
1359    /// br-r37-c1-pr8q6: bulk-add ATTRIBUTED edges without per-edge ledger
1360    /// records — the attributed sibling of [`extend_edges_unrecorded`].
1361    ///
1362    /// Semantics match a sequence of `add_edge_with_attrs` calls exactly:
1363    /// nodes auto-created in first-appearance order, duplicate edges MERGE
1364    /// their attrs into the existing map (`extend`), adjacency /
1365    /// `adj_indices` / `edge_index_endpoints` maintained identically. Only
1366    /// the per-edge `record_decision` push (timestamp + String allocs per
1367    /// edge, the dominant cost of attributed bulk construction) is replaced
1368    /// by one batch summary record.
1369    ///
1370    /// Callers MUST pre-screen attr keys starting with
1371    /// `"__fnx_incompatible"` and route those edges through
1372    /// `add_edge_with_attrs`, which owns the FailClosed contract for them.
1373    pub fn extend_edges_with_attrs_unrecorded<I>(&mut self, edges: I) -> usize
1374    where
1375        I: IntoIterator<Item = (String, String, AttrMap)>,
1376    {
1377        let iterator = edges.into_iter();
1378        let (lower_bound, _) = iterator.size_hint();
1379        self.nodes.reserve(lower_bound);
1380        self.adj_indices.reserve(lower_bound);
1381        self.edges.reserve(lower_bound);
1382        self.edge_index_endpoints.reserve(lower_bound);
1383
1384        let mut inserted = 0usize;
1385        let mut nodes_added = false;
1386        let mut merged_changed = false;
1387        for (left, right, attrs) in iterator {
1388            let left_idx = match self.nodes.get_index_of(&left) {
1389                Some(index) => index,
1390                None => {
1391                    let index = self.nodes.len();
1392                    self.nodes.insert(left.clone(), AttrMap::new());
1393                    self.adj_indices.push(Vec::new());
1394                    nodes_added = true;
1395                    index
1396                }
1397            };
1398            let right_idx = if left == right {
1399                left_idx
1400            } else {
1401                match self.nodes.get_index_of(&right) {
1402                    Some(index) => index,
1403                    None => {
1404                        let index = self.nodes.len();
1405                        self.nodes.insert(right.clone(), AttrMap::new());
1406                        self.adj_indices.push(Vec::new());
1407                        nodes_added = true;
1408                        index
1409                    }
1410                }
1411            };
1412            let edge_key = Self::canon_pair(left_idx, right_idx);
1413            if let Some(existing) = self.edges.get_mut(&edge_key) {
1414                // Duplicate edge (pre-existing or earlier in this batch):
1415                // merge attrs, matching add_edge_with_attrs' `extend`.
1416                if !attrs.is_empty()
1417                    && attrs
1418                        .iter()
1419                        .any(|(key, value)| existing.get(key) != Some(value))
1420                {
1421                    merged_changed = true;
1422                }
1423                existing.extend(attrs);
1424                continue;
1425            }
1426            if left <= right {
1427                self.edge_index_endpoints.push((left_idx, right_idx));
1428            } else {
1429                self.edge_index_endpoints.push((right_idx, left_idx));
1430            }
1431            self.edges.insert(edge_key, attrs);
1432
1433            self.adj_indices[left_idx].push(right_idx);
1434            if left_idx != right_idx {
1435                self.adj_indices[right_idx].push(left_idx);
1436            }
1437            inserted += 1;
1438        }
1439        if merged_changed {
1440            // Attr-merge on an existing edge mutates observable state even
1441            // when no new edge was inserted — bump revision so caches
1442            // invalidate (over-invalidation is safe; staleness is not).
1443            self.revision = self.revision.saturating_add(1);
1444        }
1445        self.record_bulk_edge_summary(inserted, nodes_added || merged_changed);
1446        inserted
1447    }
1448
1449    /// Bulk-add attributed undirected edges into a fresh graph when the caller
1450    /// has already assigned NetworkX-compatible node indices in first-seen
1451    /// order.
1452    ///
1453    /// This is the fresh/indexed sibling of
1454    /// [`extend_edges_with_attrs_unrecorded`]. It preserves duplicate
1455    /// insert-or-merge semantics, adjacency row order, self-loop handling, and
1456    /// the public `edges()` orientation rule used by the string-key path.
1457    #[must_use]
1458    pub fn extend_fresh_index_edges_with_attrs_unrecorded<I, N>(
1459        &mut self,
1460        nodes: N,
1461        edges: I,
1462    ) -> usize
1463    where
1464        I: IntoIterator<Item = (usize, usize, AttrMap)>,
1465        N: IntoIterator<Item = String>,
1466    {
1467        if !self.nodes.is_empty()
1468            || !self.adj_indices.is_empty()
1469            || !self.edges.is_empty()
1470            || !self.edge_index_endpoints.is_empty()
1471        {
1472            return 0;
1473        }
1474
1475        let node_labels: Vec<String> = nodes.into_iter().collect();
1476        self.nodes.reserve(node_labels.len());
1477        self.adj_indices = vec![Vec::new(); node_labels.len()];
1478        for node in &node_labels {
1479            self.nodes.insert(node.clone(), AttrMap::new());
1480        }
1481
1482        let iterator = edges.into_iter();
1483        let (lower_bound, _) = iterator.size_hint();
1484        self.edges.reserve(lower_bound);
1485        self.edge_index_endpoints.reserve(lower_bound);
1486
1487        let node_count = node_labels.len();
1488        let mut inserted = 0usize;
1489        let mut merged_changed = false;
1490        for (left_idx, right_idx, attrs) in iterator {
1491            if left_idx >= node_count || right_idx >= node_count {
1492                continue;
1493            }
1494
1495            let edge_key = Self::canon_pair(left_idx, right_idx);
1496            if let Some(existing) = self.edges.get_mut(&edge_key) {
1497                if !attrs.is_empty()
1498                    && attrs
1499                        .iter()
1500                        .any(|(key, value)| existing.get(key) != Some(value))
1501                {
1502                    merged_changed = true;
1503                }
1504                existing.extend(attrs);
1505                continue;
1506            }
1507
1508            if node_labels[left_idx] <= node_labels[right_idx] {
1509                self.edge_index_endpoints.push((left_idx, right_idx));
1510            } else {
1511                self.edge_index_endpoints.push((right_idx, left_idx));
1512            }
1513            self.edges.insert(edge_key, attrs);
1514            self.adj_indices[left_idx].push(right_idx);
1515            if left_idx != right_idx {
1516                self.adj_indices[right_idx].push(left_idx);
1517            }
1518            inserted += 1;
1519        }
1520
1521        self.record_bulk_edge_summary(inserted, node_count > 0 || merged_changed);
1522        inserted
1523    }
1524
1525    fn record_bulk_edge_summary(&mut self, inserted: usize, nodes_added: bool) {
1526        if inserted == 0 && !nodes_added {
1527            return;
1528        }
1529        self.revision = self
1530            .revision
1531            .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
1532        // One summary record covers the whole batch and keeps the
1533        // policy ledger non-empty so existing diagnostics still
1534        // see the operation.
1535        self.record_decision(
1536            "extend_edges_unrecorded",
1537            0.0,
1538            false,
1539            vec![EvidenceTerm {
1540                signal: "batch_edge_count".to_owned(),
1541                observed_value: inserted.to_string(),
1542                log_likelihood_ratio: -1.0,
1543            }],
1544        );
1545    }
1546
1547    pub fn add_edge_with_attrs(
1548        &mut self,
1549        left: impl Into<String>,
1550        right: impl Into<String>,
1551        attrs: AttrMap,
1552    ) -> Result<(), GraphError> {
1553        let left = left.into();
1554        let right = right.into();
1555
1556        let unknown_feature = attrs
1557            .keys()
1558            .any(|key| key.starts_with("__fnx_incompatible"));
1559        let incompatibility_probability = if unknown_feature {
1560            1.0
1561        } else if left == right {
1562            0.22
1563        } else {
1564            0.08
1565        };
1566
1567        let action = self.record_decision(
1568            "add_edge",
1569            incompatibility_probability,
1570            unknown_feature,
1571            vec![EvidenceTerm {
1572                signal: "unknown_incompatible_feature".to_owned(),
1573                observed_value: unknown_feature.to_string(),
1574                log_likelihood_ratio: 12.0,
1575            }],
1576        );
1577
1578        if action == DecisionAction::FailClosed || action == DecisionAction::FullValidate {
1579            return Err(GraphError::FailClosed {
1580                operation: "add_edge",
1581                reason: "incompatible edge metadata".to_owned(),
1582            });
1583        }
1584
1585        let mut left_autocreated = false;
1586        if !self.nodes.contains_key(&left) {
1587            let _ = self.add_node(left.clone());
1588            left_autocreated = true;
1589        }
1590        let mut right_autocreated = false;
1591        if left == right {
1592            right_autocreated = left_autocreated;
1593        } else if !self.nodes.contains_key(&right) {
1594            let _ = self.add_node(right.clone());
1595            right_autocreated = true;
1596        }
1597
1598        let left_key_idx = self.nodes.get_index_of(&left).expect("autocreated above");
1599        let right_key_idx = self.nodes.get_index_of(&right).expect("autocreated above");
1600        let edge_key = Self::canon_pair(left_key_idx, right_key_idx);
1601        let self_loop = left == right;
1602        let new_edge = !self.edges.contains_key(&edge_key);
1603        let mut changed = new_edge;
1604        let edge_attr_count = {
1605            let edge_attrs = self.edges.entry(edge_key).or_default();
1606            if !attrs.is_empty()
1607                && attrs
1608                    .iter()
1609                    .any(|(key, value)| edge_attrs.get(key) != Some(value))
1610            {
1611                changed = true;
1612            }
1613            edge_attrs.extend(attrs);
1614            edge_attrs.len()
1615        };
1616
1617        // Update integer adjacency (only for new edges, avoid duplicates)
1618        if changed {
1619            if let (Some(left_idx), Some(right_idx)) = (
1620                self.nodes.get_index_of(&left),
1621                self.nodes.get_index_of(&right),
1622            ) {
1623                if new_edge {
1624                    if left <= right {
1625                        self.edge_index_endpoints.push((left_idx, right_idx));
1626                    } else {
1627                        self.edge_index_endpoints.push((right_idx, left_idx));
1628                    }
1629                }
1630                if !self.adj_indices[left_idx].contains(&right_idx) {
1631                    self.adj_indices[left_idx].push(right_idx);
1632                }
1633                if left_idx != right_idx && !self.adj_indices[right_idx].contains(&left_idx) {
1634                    self.adj_indices[right_idx].push(left_idx);
1635                }
1636            }
1637            self.revision = self.revision.saturating_add(1);
1638        }
1639
1640        self.record_decision(
1641            "add_edge",
1642            incompatibility_probability,
1643            unknown_feature,
1644            vec![
1645                EvidenceTerm {
1646                    signal: "self_loop".to_owned(),
1647                    observed_value: self_loop.to_string(),
1648                    log_likelihood_ratio: -0.5,
1649                },
1650                EvidenceTerm {
1651                    signal: "edge_attr_count".to_owned(),
1652                    observed_value: edge_attr_count.to_string(),
1653                    log_likelihood_ratio: -2.0,
1654                },
1655                EvidenceTerm {
1656                    signal: "left_autocreated".to_owned(),
1657                    observed_value: left_autocreated.to_string(),
1658                    log_likelihood_ratio: -1.25,
1659                },
1660                EvidenceTerm {
1661                    signal: "right_autocreated".to_owned(),
1662                    observed_value: right_autocreated.to_string(),
1663                    log_likelihood_ratio: -1.25,
1664                },
1665            ],
1666        );
1667
1668        Ok(())
1669    }
1670
1671    pub fn apply_edge_defaults(&mut self, defaults: &AttrMap) -> bool {
1672        if defaults.is_empty() {
1673            return false;
1674        }
1675        let mut inserted = 0usize;
1676        for attrs in self.edges.values_mut() {
1677            for (key, value) in defaults {
1678                if !attrs.contains_key(key) {
1679                    attrs.insert(key.clone(), value.clone());
1680                    inserted += 1;
1681                }
1682            }
1683        }
1684        if inserted > 0 {
1685            self.revision = self.revision.saturating_add(1);
1686            self.record_decision(
1687                "apply_edge_defaults",
1688                0.0,
1689                false,
1690                vec![EvidenceTerm {
1691                    signal: "defaults_applied".to_owned(),
1692                    observed_value: inserted.to_string(),
1693                    log_likelihood_ratio: 0.0,
1694                }],
1695            );
1696            return true;
1697        }
1698        false
1699    }
1700
1701    /// br-r37-c1-sjf4t: overwrite the attribute map for an existing edge,
1702    /// matching post-creation Python-side mutations. Unlike
1703    /// `add_edge_with_attrs` which extends, this replaces. Returns
1704    /// `true` if the edge existed and was updated, `false` otherwise.
1705    pub fn replace_edge_attrs(&mut self, left: &str, right: &str, attrs: AttrMap) -> bool {
1706        let Some(edge_key) = self.edge_pair_key(left, right) else {
1707            return false;
1708        };
1709        if let Some(slot) = self.edges.get_mut(&edge_key) {
1710            if *slot != attrs {
1711                *slot = attrs;
1712                self.revision = self.revision.saturating_add(1);
1713            }
1714            true
1715        } else {
1716            false
1717        }
1718    }
1719
1720    /// br-r37-c1-sjf4t: overwrite the attribute map for an existing node.
1721    pub fn replace_node_attrs(&mut self, node: &str, attrs: AttrMap) -> bool {
1722        if let Some(slot) = self.nodes.get_mut(node) {
1723            if *slot != attrs {
1724                *slot = attrs;
1725                self.revision = self.revision.saturating_add(1);
1726            }
1727            true
1728        } else {
1729            false
1730        }
1731    }
1732
1733    pub fn remove_edge(&mut self, left: &str, right: &str) -> bool {
1734        // `shift_remove_full` returns the edge's index in `self.edges` *before*
1735        // removal so we can keep `edge_index_endpoints` (maintained strictly
1736        // parallel to `self.edges`: one entry per edge, same order — see
1737        // `add_edge_with_attrs` / `extend_edges_unrecorded`) in sync by dropping
1738        // the single corresponding entry, instead of the O(|E|) hashing
1739        // `rebuild_edge_index_endpoints()`. Bit-identical result (the parallel
1740        // vector ends up exactly as a full rebuild would leave it), but turns a
1741        // remove-heavy build like watts_strogatz from O(|E|^2) into O(|E|·shift).
1742        let Some(pair) = self.edge_pair_key(left, right) else {
1743            return false;
1744        };
1745        // br-r37-c1-vbwpl: swap_remove (O(1)) instead of shift_remove
1746        // (O(|E|)) — turns remove-heavy algos (double_edge_swap 188x,
1747        // watts) from O(k*|E|) into O(k). The edge's slot is filled by
1748        // the LAST edge; edge_index_endpoints does the SAME swap_remove
1749        // so the two vectors stay element-parallel. Output is unaffected:
1750        // edges_ordered() and all user-visible iteration order off
1751        // ADJACENCY (adj_indices), not edges-map storage order; the only
1752        // storage-order consumer is the COO matrix builder, where triple
1753        // order is irrelevant (scipy CSR canonicalises). Edge keys are
1754        // (node_idx,node_idx) so no key rekey is needed on edge removal.
1755        let removed = self.edges.swap_remove_full(&pair);
1756        if let Some((edge_pos, _, _)) = removed {
1757            // br-r37-c1-d58s8 P2(c) slice 2: integer rows are the single
1758            // row store — drop the pair entries there.
1759            if let (Some(left_idx), Some(right_idx)) = (
1760                self.nodes.get_index_of(left),
1761                self.nodes.get_index_of(right),
1762            ) {
1763                self.adj_indices[left_idx].retain(|&i| i != right_idx);
1764                if left_idx != right_idx {
1765                    self.adj_indices[right_idx].retain(|&i| i != left_idx);
1766                }
1767            }
1768            self.edge_index_endpoints.swap_remove(edge_pos);
1769            self.revision = self.revision.saturating_add(1);
1770            true
1771        } else {
1772            false
1773        }
1774    }
1775
1776    pub fn remove_node(&mut self, node: &str) -> bool {
1777        let Some(idx) = self.nodes.get_index_of(node) else {
1778            return false;
1779        };
1780
1781        // br-r37-c1-rmnode: incremental index maintenance. The previous version
1782        // called `rebuild_adj_indices()` + `rebuild_edge_index_endpoints()` —
1783        // two O(|E|) passes that do a `get_index_of` HashMap lookup for EVERY
1784        // neighbour of every node and every edge — on EVERY removal, so
1785        // `remove_node` was O(|V|+|E|)-with-hashing and `remove_nodes_from` was
1786        // O(k*(|V|+|E|)) (~50x slower than nx). Instead: drop all incident edges
1787        // in a single O(|E|) retain pass, then repair the integer caches in
1788        // place (drop dangling refs to the removed index, decrement indices that
1789        // shifted down) — no hashing, no full rebuild.
1790
1791        // 1a: String rows gone (P2(c) slice 2) — index repair below covers it.
1792        // 1b. Remove ALL incident edges from `edges` + the parallel
1793        // `edge_index_endpoints` in ONE O(|E|) pass (avoids O(degree*|E|) from
1794        // per-edge `shift_remove`). The two retains share the same keep-mask so
1795        // the vectors stay element-for-element parallel.
1796        // br-r37-c1-d58s8 P2(c) slice 1: the keep-mask is a pure INTEGER
1797        // test — an edge is incident iff either endpoint index == idx
1798        // (edge_index_endpoints is element-parallel with edges). The old
1799        // version built a HashSet<EdgeKey> from the String row and hashed
1800        // every edge key against it.
1801        let has_incident = self.adj_indices.get(idx).is_some_and(|row| !row.is_empty());
1802        if has_incident {
1803            let keep: Vec<bool> = self
1804                .edge_index_endpoints
1805                .iter()
1806                .map(|&(l, r)| l != idx && r != idx)
1807                .collect();
1808            let mut i = 0usize;
1809            self.edges.retain(|_, _| {
1810                let k = keep[i];
1811                i += 1;
1812                k
1813            });
1814            let mut j = 0usize;
1815            self.edge_index_endpoints.retain(|_| {
1816                let k = keep[j];
1817                j += 1;
1818                k
1819            });
1820        }
1821
1822        // 2. Drop the node's own adjacency-index vec (outer Vec shifts to stay
1823        // aligned with `nodes`/`adjacency` after their shift_remove below).
1824        self.adj_indices.remove(idx);
1825        // 3. Remove from the string maps (renumbers indices > idx down by 1).
1826        self.nodes.shift_remove(node);
1827        // 4. Repair integer indices in place: drop any remaining reference to the
1828        // removed node (`idx`) and decrement every index that shifted down.
1829        for nbrs in &mut self.adj_indices {
1830            nbrs.retain(|&e| e != idx);
1831            for e in nbrs.iter_mut() {
1832                if *e > idx {
1833                    *e -= 1;
1834                }
1835            }
1836        }
1837        for (left, right) in &mut self.edge_index_endpoints {
1838            if *left > idx {
1839                *left -= 1;
1840            }
1841            if *right > idx {
1842                *right -= 1;
1843            }
1844        }
1845        // br-r37-c1-d58s8 edges-map flip: REKEY surviving edges (integer
1846        // rehash; order preserved by the in-order rebuild).
1847        self.edges = std::mem::take(&mut self.edges)
1848            .into_iter()
1849            .map(|((l, r), attrs)| {
1850                (
1851                    (
1852                        if l > idx { l - 1 } else { l },
1853                        if r > idx { r - 1 } else { r },
1854                    ),
1855                    attrs,
1856                )
1857            })
1858            .collect();
1859
1860        self.revision = self.revision.saturating_add(1);
1861        true
1862    }
1863
1864    pub fn remove_nodes_from<'a, I>(&mut self, nodes: I) -> (usize, usize)
1865    where
1866        I: IntoIterator<Item = &'a str>,
1867    {
1868        let remove_set: HashSet<&str> = nodes
1869            .into_iter()
1870            .filter(|node| self.nodes.contains_key(*node))
1871            .collect();
1872        if remove_set.is_empty() {
1873            return (0, 0);
1874        }
1875
1876        let old_node_count = self.nodes.len();
1877        let old_edge_count = self.edges.len();
1878
1879        // br-r37-c1-d58s8 P2(c) slice 1: integer-side removal. Compute the
1880        // removed-index mask + old->new remap BEFORE any map shifts, then
1881        // filter edges via endpoint indices (no String hashing) and
1882        // rebuild adj_indices through the remap from the OLD integer rows
1883        // (the old rebuild_adj_indices re-hashed every neighbor String).
1884        let removed_mask: Vec<bool> = self
1885            .nodes
1886            .keys()
1887            .map(|k| remove_set.contains(k.as_str()))
1888            .collect();
1889        let mut remap: Vec<usize> = Vec::with_capacity(old_node_count);
1890        let mut next = 0usize;
1891        for &gone in &removed_mask {
1892            remap.push(next);
1893            if !gone {
1894                next += 1;
1895            }
1896        }
1897        let keep_edges: Vec<bool> = self
1898            .edge_index_endpoints
1899            .iter()
1900            .map(|&(l, r)| !removed_mask[l] && !removed_mask[r])
1901            .collect();
1902
1903        self.nodes
1904            .retain(|node, _| !remove_set.contains(node.as_str()));
1905        let mut i = 0usize;
1906        self.edges.retain(|_, _| {
1907            let k = keep_edges[i];
1908            i += 1;
1909            k
1910        });
1911        let mut j = 0usize;
1912        self.edge_index_endpoints.retain(|_| {
1913            let k = keep_edges[j];
1914            j += 1;
1915            k
1916        });
1917        for endpoints in &mut self.edge_index_endpoints {
1918            endpoints.0 = remap[endpoints.0];
1919            endpoints.1 = remap[endpoints.1];
1920        }
1921        // br-r37-c1-d58s8 edges-map flip: rekey survivors through the remap.
1922        self.edges = std::mem::take(&mut self.edges)
1923            .into_iter()
1924            .map(|((l, r), attrs)| ((remap[l], remap[r]), attrs))
1925            .collect();
1926        let old_rows = std::mem::take(&mut self.adj_indices);
1927        self.adj_indices = old_rows
1928            .into_iter()
1929            .enumerate()
1930            .filter(|(old_idx, _)| !removed_mask[*old_idx])
1931            .map(|(_, row)| {
1932                row.into_iter()
1933                    .filter(|&nbr| !removed_mask[nbr])
1934                    .map(|nbr| remap[nbr])
1935                    .collect()
1936            })
1937            .collect();
1938
1939        let removed_nodes = old_node_count - self.nodes.len();
1940        let removed_edges = old_edge_count - self.edges.len();
1941        self.revision = self
1942            .revision
1943            .saturating_add(u64::try_from(removed_nodes).unwrap_or(u64::MAX));
1944        (removed_nodes, removed_edges)
1945    }
1946
1947    /// Rebuild integer adjacency from string adjacency. Called after node
1948    /// removal since indices shift.
1949    #[allow(dead_code)]
1950    /// br-r37-c1-d58s8: revision-keyed memo of "every edge's `attr`
1951    /// value is an integer (missing = default 1 = int)".
1952    #[must_use]
1953    pub fn edge_weights_all_int(&self, attr: &str) -> bool {
1954        if let Ok(guard) = self.all_int_cache.read()
1955            && let Some((rev, a, v)) = guard.as_ref()
1956            && *rev == self.revision
1957            && a == attr
1958        {
1959            return *v;
1960        }
1961        let v = self
1962            .edges
1963            .values()
1964            .all(|attrs| attrs.get(attr).is_none_or(fnx_runtime::CgseValue::is_int));
1965        if let Ok(mut guard) = self.all_int_cache.write() {
1966            *guard = Some((self.revision, attr.to_owned(), v));
1967        }
1968        v
1969    }
1970
1971    pub fn edges_ordered(&self) -> Vec<EdgeSnapshot> {
1972        // br-r37-c1-d58s8 P2(b): index-native u-major walk — integer
1973        // pair dedup + an integer (min,max)->attr map built once from
1974        // edge_index_endpoints; zero String hashing in the loop. Walk
1975        // order (u-major over adj_indices rows) is identical to the
1976        // String-row walk (rows are order-faithful mirrors).
1977        let mut pair_attrs: std::collections::HashMap<(usize, usize), &AttrMap> =
1978            std::collections::HashMap::with_capacity(self.edges.len());
1979        for ((l, r), attrs) in self.edge_index_endpoints.iter().zip(self.edges.values()) {
1980            // endpoints are stored STRING-canonical; normalize to
1981            // index-canonical (min, max) for the walk's dedup pairs.
1982            let pair = if l <= r { (*l, *r) } else { (*r, *l) };
1983            pair_attrs.insert(pair, attrs);
1984        }
1985        let mut ordered = Vec::with_capacity(self.edges.len());
1986        let mut seen = HashSet::<(usize, usize)>::with_capacity(self.edges.len());
1987        for (u, row) in self.adj_indices.iter().enumerate() {
1988            for &v in row {
1989                let pair = if u <= v { (u, v) } else { (v, u) };
1990                if !seen.insert(pair) {
1991                    continue;
1992                }
1993                if let Some(attrs) = pair_attrs.get(&pair) {
1994                    ordered.push(EdgeSnapshot {
1995                        left: self.nodes.get_index(u).expect("valid node index").0.clone(),
1996                        right: self.nodes.get_index(v).expect("valid node index").0.clone(),
1997                        attrs: (*attrs).clone(),
1998                    });
1999                }
2000            }
2001        }
2002
2003        ordered
2004    }
2005
2006    #[must_use]
2007    pub fn edges_ordered_borrowed(&self) -> Vec<(&str, &str, &AttrMap)> {
2008        // br-r37-c1-d58s8 P2(b): index-native (see edges_ordered).
2009        let mut pair_attrs: std::collections::HashMap<(usize, usize), &AttrMap> =
2010            std::collections::HashMap::with_capacity(self.edges.len());
2011        for ((l, r), attrs) in self.edge_index_endpoints.iter().zip(self.edges.values()) {
2012            // endpoints are stored STRING-canonical; normalize to
2013            // index-canonical (min, max) for the walk's dedup pairs.
2014            let pair = if l <= r { (*l, *r) } else { (*r, *l) };
2015            pair_attrs.insert(pair, attrs);
2016        }
2017        let mut ordered = Vec::with_capacity(self.edges.len());
2018        let mut seen_pairs = HashSet::<(usize, usize)>::with_capacity(self.edges.len());
2019        for (u, row) in self.adj_indices.iter().enumerate() {
2020            for &v in row {
2021                let pair = if u <= v { (u, v) } else { (v, u) };
2022                if !seen_pairs.insert(pair) {
2023                    continue;
2024                }
2025                if let Some(attrs) = pair_attrs.get(&pair) {
2026                    let left = self.nodes.get_index(u).expect("valid node index").0;
2027                    let right = self.nodes.get_index(v).expect("valid node index").0;
2028                    ordered.push((left.as_str(), right.as_str(), *attrs));
2029                }
2030            }
2031        }
2032
2033        if ordered.len() < self.edges.len() {
2034            for (&(l, r), attrs) in &self.edges {
2035                if seen_pairs.insert((l, r)) {
2036                    let left = self.nodes.get_index(l).expect("valid index").0;
2037                    let right = self.nodes.get_index(r).expect("valid index").0;
2038                    ordered.push((left.as_str(), right.as_str(), attrs));
2039                }
2040            }
2041        }
2042
2043        ordered
2044    }
2045
2046    /// br-r37-c1-2a00r: index-space twin of `edges_ordered_borrowed` — yields
2047    /// `(u, v)` node indices in the SAME node-major traversed order/orientation,
2048    /// without resolving endpoints to `&str`. Lets the EdgeView iterate edges and
2049    /// look up the per-index Python node-key object directly (O(1) incref) instead
2050    /// of hashing the canonical String per endpoint via `py_node_key`.
2051    #[must_use]
2052    pub fn edges_ordered_indices(&self) -> Vec<(usize, usize)> {
2053        let mut present: HashSet<(usize, usize)> = HashSet::with_capacity(self.edges.len());
2054        for &(l, r) in &self.edge_index_endpoints {
2055            present.insert(if l <= r { (l, r) } else { (r, l) });
2056        }
2057        let mut ordered = Vec::with_capacity(self.edges.len());
2058        let mut seen_pairs = HashSet::<(usize, usize)>::with_capacity(self.edges.len());
2059        for (u, row) in self.adj_indices.iter().enumerate() {
2060            for &v in row {
2061                let pair = if u <= v { (u, v) } else { (v, u) };
2062                if !seen_pairs.insert(pair) {
2063                    continue;
2064                }
2065                if present.contains(&pair) {
2066                    ordered.push((u, v));
2067                }
2068            }
2069        }
2070        if ordered.len() < self.edges.len() {
2071            // Mirror edges_ordered_borrowed's fallback EXACTLY: iterate
2072            // self.edges with the raw (l, r) key orientation and dedup on the
2073            // raw key (not (min,max)) so degenerate cases stay byte-identical.
2074            for &(l, r) in self.edges.keys() {
2075                if seen_pairs.insert((l, r)) {
2076                    ordered.push((l, r));
2077                }
2078            }
2079        }
2080        ordered
2081    }
2082
2083    #[must_use]
2084    pub fn edges_storage_order_borrowed(&self) -> Vec<(&str, &str, &AttrMap)> {
2085        // orientation from edge_index_endpoints (string-canonical, kept
2086        // element-parallel with edges).
2087        self.edge_index_endpoints
2088            .iter()
2089            .zip(self.edges.values())
2090            .map(|(&(l, r), attrs)| {
2091                (
2092                    self.nodes.get_index(l).expect("valid index").0.as_str(),
2093                    self.nodes.get_index(r).expect("valid index").0.as_str(),
2094                    attrs,
2095                )
2096            })
2097            .collect()
2098    }
2099
2100    pub fn edges_storage_order_iter(&self) -> impl Iterator<Item = (&str, &str, &AttrMap)> + '_ {
2101        self.edge_index_endpoints
2102            .iter()
2103            .zip(self.edges.values())
2104            .map(|(&(l, r), attrs)| {
2105                (
2106                    self.nodes.get_index(l).expect("valid index").0.as_str(),
2107                    self.nodes.get_index(r).expect("valid index").0.as_str(),
2108                    attrs,
2109                )
2110            })
2111    }
2112
2113    #[must_use]
2114    pub fn snapshot(&self) -> GraphSnapshot {
2115        // br-snapnodeattrs: preserve per-node attributes alongside the
2116        // bare node name list so callers replaying a snapshot don't lose
2117        // ``add_node(n, attrs)`` data.
2118        let node_attrs: BTreeMap<String, AttrMap> = self
2119            .nodes
2120            .iter()
2121            .filter(|(_, attrs)| !attrs.is_empty())
2122            .map(|(name, attrs)| (name.clone(), attrs.clone()))
2123            .collect();
2124        GraphSnapshot {
2125            mode: self.mode,
2126            nodes: self.nodes.keys().cloned().collect(),
2127            node_attrs,
2128            edges: self.edges_ordered(),
2129        }
2130    }
2131
2132    fn record_decision(
2133        &mut self,
2134        operation: &'static str,
2135        incompatibility_probability: f64,
2136        unknown_incompatible_feature: bool,
2137        evidence: Vec<EvidenceTerm>,
2138    ) -> DecisionAction {
2139        let action = self
2140            .runtime_policy
2141            .action_for(incompatibility_probability, unknown_incompatible_feature);
2142        self.runtime_policy.record(
2143            operation,
2144            action,
2145            incompatibility_probability,
2146            "argmin expected loss over {allow,full_validate,fail_closed}",
2147            evidence,
2148        );
2149        action
2150    }
2151}
2152
2153#[derive(Debug, Clone)]
2154pub struct MultiGraph {
2155    mode: CompatibilityMode,
2156    revision: u64,
2157    nodes: IndexMap<String, AttrMap>,
2158    adjacency: IndexMap<String, IndexMap<String, IndexSet<usize>>>,
2159    edges: IndexMap<EdgeKey, IndexMap<usize, AttrMap>>,
2160    runtime_policy: RuntimePolicy,
2161    edge_count: usize,
2162}
2163
2164impl MultiGraph {
2165    /// br-r37-c1-7dpyg: structural clone with a FRESH RuntimePolicy —
2166    /// see Graph::clone_with_fresh_policy.
2167    #[must_use]
2168    pub fn clone_with_fresh_policy(&self) -> Self {
2169        Self {
2170            mode: self.mode,
2171            revision: self.revision,
2172            nodes: self.nodes.clone(),
2173            adjacency: self.adjacency.clone(),
2174            edges: self.edges.clone(),
2175            runtime_policy: RuntimePolicy::new(self.mode),
2176            edge_count: self.edge_count,
2177        }
2178    }
2179
2180    /// br-r37-c1-s0d4x: reorder every adjacency row into NetworkX's
2181    /// `MultiGraph.copy()` walk order — the multigraph counterpart of
2182    /// Graph::reorder_rows_for_nx_copy_walk (cells move wholesale). A
2183    /// pair's cells enter both rows at the first u-major touch: row u =
2184    /// earlier-position neighbors sorted by (pos(v), index of u within
2185    /// row v), then the rest (self-loops included) in original order.
2186    pub fn reorder_rows_for_nx_copy_walk(&mut self) {
2187        // br-r37-c1-predrebuild NOTE: a 2-pass early[]-rebuild (like the directed
2188        // MultiDiGraph variant) was tried here and REVERTED — it was a regression
2189        // (dense MultiGraph.copy 0.60x -> 0.44x). Unlike the directed succ-walk
2190        // (1 pass, no lookups), the undirected early/late split needs pos(u) AND
2191        // pos(v), so the rebuild does 2 adjacency passes + 2 get_index_of/edge,
2192        // outweighing the (cheap integer-keyed) sort it removed. The sort-based
2193        // form below is faster for the String-keyed multigraph adjacency.
2194        let n = self.adjacency.len();
2195        let mut new_orders: Vec<Vec<String>> = Vec::with_capacity(n);
2196        for (pu, (u, row)) in self.adjacency.iter().enumerate() {
2197            let mut early: Vec<(usize, usize, String)> = Vec::new();
2198            let mut late: Vec<String> = Vec::new();
2199            for v in row.keys() {
2200                let pv = self
2201                    .adjacency
2202                    .get_index_of(v.as_str())
2203                    .unwrap_or(usize::MAX);
2204                if pv < pu {
2205                    let idx = self
2206                        .adjacency
2207                        .get(v.as_str())
2208                        .and_then(|r| r.get_index_of(u.as_str()))
2209                        .unwrap_or(usize::MAX);
2210                    early.push((pv, idx, v.clone()));
2211                } else {
2212                    late.push(v.clone());
2213                }
2214            }
2215            early.sort_unstable();
2216            let mut order: Vec<String> = early.into_iter().map(|(_, _, v)| v).collect();
2217            order.extend(late);
2218            new_orders.push(order);
2219        }
2220        let keys: Vec<String> = self.adjacency.keys().cloned().collect();
2221        let orders: Vec<(String, Vec<String>)> = keys.into_iter().zip(new_orders).collect();
2222        self.apply_row_orders(&orders);
2223    }
2224
2225    /// br-r37-c1-u3qyn: restore explicit adjacency row orders (pickle
2226    /// round-trip) — see Graph::apply_row_orders. Multigraph rows are
2227    /// keyed cells (neighbor -> key set); the cells move wholesale.
2228    pub fn apply_row_orders(&mut self, orders: &[(String, Vec<String>)]) {
2229        for (node, order) in orders {
2230            let Some(row) = self.adjacency.get_mut(node.as_str()) else {
2231                continue;
2232            };
2233            let mut old = std::mem::take(row);
2234            let mut new_row = IndexMap::with_capacity(old.len());
2235            for v in order {
2236                if let Some((k, val)) = old.shift_remove_entry(v.as_str()) {
2237                    new_row.insert(k, val);
2238                }
2239            }
2240            for (k, val) in old {
2241                new_row.insert(k, val);
2242            }
2243            *row = new_row;
2244        }
2245    }
2246
2247    #[must_use]
2248    pub fn new(mode: CompatibilityMode) -> Self {
2249        Self {
2250            mode,
2251            revision: 0,
2252            nodes: IndexMap::new(),
2253            adjacency: IndexMap::new(),
2254            edges: IndexMap::new(),
2255            runtime_policy: RuntimePolicy::new(mode),
2256            edge_count: 0,
2257        }
2258    }
2259
2260    #[must_use]
2261    pub fn with_runtime_policy(runtime_policy: RuntimePolicy) -> Self {
2262        let mode = runtime_policy.mode();
2263        Self {
2264            mode,
2265            revision: 0,
2266            nodes: IndexMap::new(),
2267            adjacency: IndexMap::new(),
2268            edges: IndexMap::new(),
2269            runtime_policy,
2270            edge_count: 0,
2271        }
2272    }
2273
2274    #[must_use]
2275    pub fn strict() -> Self {
2276        Self::new(CompatibilityMode::Strict)
2277    }
2278
2279    #[must_use]
2280    pub fn hardened() -> Self {
2281        Self::new(CompatibilityMode::Hardened)
2282    }
2283
2284    #[must_use]
2285    pub fn mode(&self) -> CompatibilityMode {
2286        self.mode
2287    }
2288
2289    #[must_use]
2290    pub fn node_count(&self) -> usize {
2291        self.nodes.len()
2292    }
2293
2294    #[must_use]
2295    pub fn edge_count(&self) -> usize {
2296        self.edge_count
2297    }
2298
2299    #[must_use]
2300    pub fn number_of_selfloops(&self) -> usize {
2301        self.edges
2302            .iter()
2303            .filter(|(edge_key, _)| edge_key.left == edge_key.right)
2304            .map(|(_, edge_bucket)| edge_bucket.len())
2305            .sum()
2306    }
2307
2308    /// Return edge keys as Vec (needed by Python bindings).
2309    #[must_use]
2310    pub fn edge_keys(&self, left: &str, right: &str) -> Option<Vec<usize>> {
2311        self.adjacency
2312            .get(left)?
2313            .get(right)
2314            .map(|keys| keys.iter().copied().collect())
2315    }
2316
2317    /// Return an iterator over keys for edges between left and right.
2318    pub fn edge_keys_iter(&self, left: &str, right: &str) -> Option<impl Iterator<Item = &usize>> {
2319        self.adjacency.get(left)?.get(right).map(|keys| keys.iter())
2320    }
2321
2322    #[must_use]
2323    pub fn revision(&self) -> u64 {
2324        self.revision
2325    }
2326
2327    #[must_use]
2328    pub fn has_node(&self, node: &str) -> bool {
2329        self.nodes.contains_key(node)
2330    }
2331
2332    #[must_use]
2333    pub fn has_edge(&self, left: &str, right: &str) -> bool {
2334        self.edges
2335            .get(&EdgeKeyRef::new(left, right))
2336            .is_some_and(|edge_bucket| !edge_bucket.is_empty())
2337    }
2338
2339    #[must_use]
2340    pub fn nodes_ordered(&self) -> Vec<&str> {
2341        self.nodes.keys().map(String::as_str).collect()
2342    }
2343
2344    #[must_use]
2345    pub fn neighbors(&self, node: &str) -> Option<Vec<&str>> {
2346        self.adjacency
2347            .get(node)
2348            .map(|neighbors| neighbors.keys().map(String::as_str).collect::<Vec<&str>>())
2349    }
2350
2351    pub fn neighbors_iter(&self, node: &str) -> Option<impl Iterator<Item = &str> + '_> {
2352        self.adjacency
2353            .get(node)
2354            .map(|neighbors| neighbors.keys().map(String::as_str))
2355    }
2356
2357    #[must_use]
2358    pub fn edge_keys_vec(&self, left: &str, right: &str) -> Vec<usize> {
2359        self.edges
2360            .get(&EdgeKeyRef::new(left, right))
2361            .map(|edge_bucket| edge_bucket.keys().copied().collect::<Vec<usize>>())
2362            .unwrap_or_default()
2363    }
2364
2365    #[must_use]
2366    pub fn node_attrs(&self, node: &str) -> Option<&AttrMap> {
2367        self.nodes.get(node)
2368    }
2369
2370    #[must_use]
2371    pub fn edge_attrs(&self, left: &str, right: &str, key: usize) -> Option<&AttrMap> {
2372        self.edges
2373            .get(&EdgeKeyRef::new(left, right))
2374            .and_then(|edge_bucket| edge_bucket.get(&key))
2375    }
2376
2377    #[must_use]
2378    pub fn evidence_ledger(&self) -> &EvidenceLedger {
2379        self.runtime_policy.decision_log()
2380    }
2381
2382    #[must_use]
2383    pub fn runtime_policy(&self) -> &RuntimePolicy {
2384        &self.runtime_policy
2385    }
2386
2387    pub fn set_runtime_policy(&mut self, runtime_policy: RuntimePolicy) {
2388        self.mode = runtime_policy.mode();
2389        self.runtime_policy = runtime_policy;
2390    }
2391
2392    #[must_use]
2393    pub fn is_directed(&self) -> bool {
2394        false
2395    }
2396
2397    #[must_use]
2398    pub fn is_multigraph(&self) -> bool {
2399        true
2400    }
2401
2402    /// Return the degree of a node (total number of parallel edges incident).
2403    /// Self-loops contribute 2 to the degree each (NetworkX convention).
2404    #[must_use]
2405    pub fn degree(&self, node: &str) -> usize {
2406        self.adjacency.get(node).map_or(0, |neighbors| {
2407            let mut deg = 0;
2408            for (neighbor, keys) in neighbors {
2409                let count = keys.len();
2410                if neighbor == node {
2411                    // Self-loops count double (NetworkX convention)
2412                    deg += count * 2;
2413                } else {
2414                    deg += count;
2415                }
2416            }
2417            deg
2418        })
2419    }
2420
2421    pub fn add_node(&mut self, node: impl Into<String>) -> bool {
2422        self.add_node_with_attrs(node, AttrMap::new())
2423    }
2424
2425    pub fn add_node_with_attrs(&mut self, node: impl Into<String>, attrs: AttrMap) -> bool {
2426        let node = node.into();
2427        let existed = self.nodes.contains_key(&node);
2428        let mut changed = !existed;
2429        let attrs_count = {
2430            let bucket = self.nodes.entry(node.clone()).or_default();
2431            if !attrs.is_empty()
2432                && attrs
2433                    .iter()
2434                    .any(|(key, value)| bucket.get(key) != Some(value))
2435            {
2436                changed = true;
2437            }
2438            bucket.extend(attrs);
2439            bucket.len()
2440        };
2441        self.adjacency.entry(node.clone()).or_default();
2442        if changed {
2443            self.revision = self.revision.saturating_add(1);
2444        }
2445        self.record_decision(
2446            "add_node",
2447            0.0,
2448            false,
2449            vec![
2450                EvidenceTerm {
2451                    signal: "node_preexisting".to_owned(),
2452                    observed_value: existed.to_string(),
2453                    log_likelihood_ratio: -3.0,
2454                },
2455                EvidenceTerm {
2456                    signal: "attrs_count".to_owned(),
2457                    observed_value: attrs_count.to_string(),
2458                    log_likelihood_ratio: -1.0,
2459                },
2460            ],
2461        );
2462        changed
2463    }
2464
2465    pub fn add_edge(
2466        &mut self,
2467        left: impl Into<String>,
2468        right: impl Into<String>,
2469    ) -> Result<usize, GraphError> {
2470        self.add_edge_impl(left, right, None, AttrMap::new())
2471    }
2472
2473    pub fn add_edge_with_attrs(
2474        &mut self,
2475        left: impl Into<String>,
2476        right: impl Into<String>,
2477        attrs: AttrMap,
2478    ) -> Result<usize, GraphError> {
2479        self.add_edge_impl(left, right, None, attrs)
2480    }
2481
2482    pub fn add_edge_with_key_and_attrs(
2483        &mut self,
2484        left: impl Into<String>,
2485        right: impl Into<String>,
2486        key: usize,
2487        attrs: AttrMap,
2488    ) -> Result<usize, GraphError> {
2489        self.add_edge_impl(left, right, Some(key), attrs)
2490    }
2491
2492    /// Insert one attribute-free edge for a pair known to have no existing
2493    /// parallel edge bucket. Returns `None` if the pair already exists.
2494    #[must_use]
2495    pub fn add_fresh_edge_unrecorded(
2496        &mut self,
2497        left: impl Into<String>,
2498        right: impl Into<String>,
2499    ) -> Option<usize> {
2500        self.add_fresh_edge_with_key_unrecorded(left, right, 0)
2501    }
2502
2503    /// Insert one attribute-free edge with an explicit key for a pair known to
2504    /// have no existing parallel edge bucket. Returns `None` if the pair
2505    /// already exists.
2506    #[must_use]
2507    pub fn add_fresh_edge_with_key_unrecorded(
2508        &mut self,
2509        left: impl Into<String>,
2510        right: impl Into<String>,
2511        key: usize,
2512    ) -> Option<usize> {
2513        let left = left.into();
2514        let right = right.into();
2515        let edge_key = EdgeKey::new(&left, &right);
2516        if !self.nodes.contains_key(&left) {
2517            self.nodes.insert(left.clone(), AttrMap::new());
2518            self.adjacency.entry(left.clone()).or_default();
2519        }
2520        if left != right && !self.nodes.contains_key(&right) {
2521            self.nodes.insert(right.clone(), AttrMap::new());
2522            self.adjacency.entry(right.clone()).or_default();
2523        }
2524
2525        match self.edges.entry(edge_key) {
2526            indexmap::map::Entry::Occupied(mut edge_bucket) => {
2527                if !edge_bucket.get().is_empty() {
2528                    return None;
2529                }
2530                edge_bucket.get_mut().insert(key, AttrMap::new());
2531            }
2532            indexmap::map::Entry::Vacant(edge_bucket) => {
2533                let mut bucket = IndexMap::new();
2534                bucket.insert(key, AttrMap::new());
2535                edge_bucket.insert(bucket);
2536            }
2537        }
2538        self.edge_count += 1;
2539        self.adjacency
2540            .entry(left.clone())
2541            .or_default()
2542            .entry(right.clone())
2543            .or_default()
2544            .insert(key);
2545        if left != right {
2546            self.adjacency
2547                .entry(right)
2548                .or_default()
2549                .entry(left)
2550                .or_default()
2551                .insert(key);
2552        }
2553        self.revision = self.revision.saturating_add(1);
2554        Some(key)
2555    }
2556
2557    /// br-r37-c1-l5ve7: bulk node insert WITH attrs, one ledger record —
2558    /// MultiGraph mirror of MultiDiGraph::extend_nodes_with_attrs_unrecorded.
2559    /// Existing nodes merge attrs (extend).
2560    pub fn extend_nodes_with_attrs_unrecorded<I>(&mut self, nodes: I) -> usize
2561    where
2562        I: IntoIterator<Item = (String, AttrMap)>,
2563    {
2564        let mut inserted = 0usize;
2565        for (node, attrs) in nodes {
2566            if let Some(existing) = self.nodes.get_mut(&node) {
2567                existing.extend(attrs);
2568                continue;
2569            }
2570            self.nodes.insert(node.clone(), attrs);
2571            inserted += 1;
2572        }
2573        if inserted > 0 {
2574            self.revision = self
2575                .revision
2576                .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
2577            self.record_decision(
2578                "extend_nodes_with_attrs_unrecorded",
2579                0.0,
2580                false,
2581                vec![EvidenceTerm {
2582                    signal: "batch_node_count".to_owned(),
2583                    observed_value: inserted.to_string(),
2584                    log_likelihood_ratio: -1.0,
2585                }],
2586            );
2587        }
2588        inserted
2589    }
2590
2591    /// br-r37-c1-l5ve7: bulk KEYED edge insert WITH attrs, one ledger
2592    /// record — MultiGraph mirror of
2593    /// MultiDiGraph::extend_keyed_edges_with_attrs_unrecorded
2594    /// (add_edge_impl pays TWO record_decision calls per edge). Endpoint
2595    /// nodes are created on demand; an existing (u, v, key) cell merges
2596    /// attrs (extend), matching add_edge_with_key_and_attrs.
2597    pub fn extend_keyed_edges_with_attrs_unrecorded<I>(&mut self, edges: I) -> usize
2598    where
2599        I: IntoIterator<Item = (String, String, usize, AttrMap)>,
2600    {
2601        let mut inserted = 0usize;
2602        for (left, right, key, attrs) in edges {
2603            if !self.nodes.contains_key(&left) {
2604                self.nodes.insert(left.clone(), AttrMap::new());
2605                self.adjacency.entry(left.clone()).or_default();
2606            }
2607            if left != right && !self.nodes.contains_key(&right) {
2608                self.nodes.insert(right.clone(), AttrMap::new());
2609                self.adjacency.entry(right.clone()).or_default();
2610            }
2611            let edge_key = EdgeKey::new(&left, &right);
2612            let bucket = self.edges.entry(edge_key).or_default();
2613            if !bucket.contains_key(&key) {
2614                self.edge_count += 1;
2615                inserted += 1;
2616            }
2617            bucket.entry(key).or_default().extend(attrs);
2618            self.adjacency
2619                .entry(left.clone())
2620                .or_default()
2621                .entry(right.clone())
2622                .or_default()
2623                .insert(key);
2624            if left != right {
2625                self.adjacency
2626                    .entry(right)
2627                    .or_default()
2628                    .entry(left)
2629                    .or_default()
2630                    .insert(key);
2631            }
2632        }
2633        if inserted > 0 {
2634            self.revision = self
2635                .revision
2636                .saturating_add(u64::try_from(inserted).unwrap_or(u64::MAX));
2637            self.record_decision(
2638                "extend_keyed_edges_with_attrs_unrecorded",
2639                0.0,
2640                false,
2641                vec![EvidenceTerm {
2642                    signal: "batch_edge_count".to_owned(),
2643                    observed_value: inserted.to_string(),
2644                    log_likelihood_ratio: -1.0,
2645                }],
2646            );
2647        }
2648        inserted
2649    }
2650
2651    /// Bulk-load attribute-free keyed edges for a freshly-created integer
2652    /// prefix graph. The caller has already validated that node first-seen
2653    /// order is exactly `0..node_count` and that internal keys are sequential
2654    /// per undirected pair, so this builds the string-keyed MultiGraph state
2655    /// directly from indices instead of canonicalizing both endpoints for every
2656    /// edge in the Python boundary layer.
2657    #[must_use]
2658    pub fn extend_fresh_int_prefix_keyed_edges_unrecorded<I>(
2659        &mut self,
2660        node_count: usize,
2661        edges: I,
2662    ) -> usize
2663    where
2664        I: IntoIterator<Item = (usize, usize, usize)>,
2665    {
2666        debug_assert_eq!(self.node_count(), 0);
2667        debug_assert_eq!(self.edge_count(), 0);
2668
2669        let iterator = edges.into_iter();
2670        let (lower_bound, _) = iterator.size_hint();
2671        self.nodes.reserve(node_count);
2672        self.adjacency.reserve(node_count);
2673        self.edges.reserve(lower_bound);
2674
2675        let node_names = (0..node_count)
2676            .map(|node| node.to_string())
2677            .collect::<Vec<_>>();
2678        for node in &node_names {
2679            self.nodes.insert(node.clone(), AttrMap::new());
2680            self.adjacency.insert(node.clone(), IndexMap::new());
2681        }
2682
2683        let mut inserted = 0usize;
2684        for (left_idx, right_idx, key) in iterator {
2685            debug_assert!(left_idx < node_names.len());
2686            debug_assert!(right_idx < node_names.len());
2687
2688            let left = &node_names[left_idx];
2689            let right = &node_names[right_idx];
2690            let edge_key = EdgeKey::new(left, right);
2691            self.edges
2692                .entry(edge_key)
2693                .or_default()
2694                .insert(key, AttrMap::new());
2695            self.adjacency
2696                .get_mut(left.as_str())
2697                .expect("integer-prefix left node should exist")
2698                .entry(right.clone())
2699                .or_default()
2700                .insert(key);
2701            if left_idx != right_idx {
2702                self.adjacency
2703                    .get_mut(right.as_str())
2704                    .expect("integer-prefix right node should exist")
2705                    .entry(left.clone())
2706                    .or_default()
2707                    .insert(key);
2708            }
2709            self.edge_count += 1;
2710            inserted += 1;
2711        }
2712        if inserted > 0 || node_count > 0 {
2713            self.revision = self.revision.saturating_add(
2714                u64::try_from(inserted.saturating_add(node_count)).unwrap_or(u64::MAX),
2715            );
2716            self.record_decision(
2717                "extend_fresh_int_prefix_keyed_edges_unrecorded",
2718                0.0,
2719                false,
2720                vec![EvidenceTerm {
2721                    signal: "batch_edge_count".to_owned(),
2722                    observed_value: inserted.to_string(),
2723                    log_likelihood_ratio: -1.0,
2724                }],
2725            );
2726        }
2727        inserted
2728    }
2729
2730    /// Bulk-load attributed keyed edges for a freshly-created integer-indexed
2731    /// graph. The caller has already assigned NetworkX-compatible node labels
2732    /// and per-undirected-pair keys, so this avoids endpoint string hashing in
2733    /// the Python boundary layer while preserving the normal MultiGraph storage
2734    /// layout.
2735    #[must_use]
2736    pub fn extend_fresh_index_keyed_edges_with_attrs_unrecorded<I, N>(
2737        &mut self,
2738        nodes: N,
2739        edges: I,
2740    ) -> usize
2741    where
2742        I: IntoIterator<Item = (usize, usize, usize, AttrMap)>,
2743        N: IntoIterator<Item = String>,
2744    {
2745        if !self.nodes.is_empty() || !self.adjacency.is_empty() || !self.edges.is_empty() {
2746            return 0;
2747        }
2748
2749        let node_labels: Vec<String> = nodes.into_iter().collect();
2750        for node in &node_labels {
2751            self.nodes.insert(node.clone(), AttrMap::new());
2752            self.adjacency.insert(node.clone(), IndexMap::new());
2753        }
2754
2755        let node_count = node_labels.len();
2756        let mut inserted = 0usize;
2757        let mut merged_changed = false;
2758        for (left_idx, right_idx, key, attrs) in edges {
2759            let Some(left) = node_labels.get(left_idx) else {
2760                continue;
2761            };
2762            let Some(right) = node_labels.get(right_idx) else {
2763                continue;
2764            };
2765
2766            let edge_key = EdgeKey::new(left, right);
2767            let bucket = self.edges.entry(edge_key).or_default();
2768            if !bucket.contains_key(&key) {
2769                self.edge_count += 1;
2770                inserted += 1;
2771            }
2772            let edge_attrs = bucket.entry(key).or_default();
2773            if !attrs.is_empty()
2774                && attrs
2775                    .iter()
2776                    .any(|(attr_key, value)| edge_attrs.get(attr_key) != Some(value))
2777            {
2778                merged_changed = true;
2779            }
2780            edge_attrs.extend(attrs);
2781
2782            self.adjacency
2783                .get_mut(left.as_str())
2784                .expect("fresh left node row exists")
2785                .entry(right.clone())
2786                .or_default()
2787                .insert(key);
2788            if left_idx != right_idx {
2789                self.adjacency
2790                    .get_mut(right.as_str())
2791                    .expect("fresh right node row exists")
2792                    .entry(left.clone())
2793                    .or_default()
2794                    .insert(key);
2795            }
2796        }
2797
2798        if node_count > 0 || inserted > 0 || merged_changed {
2799            self.revision = self.revision.saturating_add(
2800                u64::try_from(node_count.saturating_add(inserted)).unwrap_or(u64::MAX),
2801            );
2802            self.record_decision(
2803                "extend_fresh_index_keyed_edges_with_attrs_unrecorded",
2804                0.0,
2805                false,
2806                vec![
2807                    EvidenceTerm {
2808                        signal: "batch_node_count".to_owned(),
2809                        observed_value: node_count.to_string(),
2810                        log_likelihood_ratio: -1.0,
2811                    },
2812                    EvidenceTerm {
2813                        signal: "batch_edge_count".to_owned(),
2814                        observed_value: inserted.to_string(),
2815                        log_likelihood_ratio: -1.0,
2816                    },
2817                ],
2818            );
2819        }
2820
2821        inserted
2822    }
2823
2824    fn add_edge_impl(
2825        &mut self,
2826        left: impl Into<String>,
2827        right: impl Into<String>,
2828        explicit_key: Option<usize>,
2829        attrs: AttrMap,
2830    ) -> Result<usize, GraphError> {
2831        let left = left.into();
2832        let right = right.into();
2833
2834        let unknown_feature = attrs
2835            .keys()
2836            .any(|key| key.starts_with("__fnx_incompatible"));
2837        let incompatibility_probability = if unknown_feature {
2838            1.0
2839        } else if left == right {
2840            0.22
2841        } else {
2842            0.08
2843        };
2844
2845        let action = self.record_decision(
2846            "add_edge",
2847            incompatibility_probability,
2848            unknown_feature,
2849            vec![EvidenceTerm {
2850                signal: "unknown_incompatible_feature".to_owned(),
2851                observed_value: unknown_feature.to_string(),
2852                log_likelihood_ratio: 12.0,
2853            }],
2854        );
2855
2856        if action == DecisionAction::FailClosed || action == DecisionAction::FullValidate {
2857            return Err(GraphError::FailClosed {
2858                operation: "add_edge",
2859                reason: "incompatible edge metadata".to_owned(),
2860            });
2861        }
2862
2863        let mut left_autocreated = false;
2864        if !self.nodes.contains_key(&left) {
2865            let _ = self.add_node(left.clone());
2866            left_autocreated = true;
2867        }
2868        let mut right_autocreated = false;
2869        if left == right {
2870            right_autocreated = left_autocreated;
2871        } else if !self.nodes.contains_key(&right) {
2872            let _ = self.add_node(right.clone());
2873            right_autocreated = true;
2874        }
2875
2876        let edge_key = EdgeKey::new(&left, &right);
2877        let key = explicit_key.unwrap_or_else(|| {
2878            let edge_bucket = self.edges.get(&edge_key);
2879            let mut k = edge_bucket.map_or(0, |b| b.len());
2880            if let Some(b) = edge_bucket {
2881                while b.contains_key(&k) {
2882                    k += 1;
2883                }
2884            }
2885            k
2886        });
2887        let mut changed;
2888        let edge_attr_count = {
2889            let edge_bucket = self.edges.entry(edge_key.clone()).or_default();
2890            let is_new = !edge_bucket.contains_key(&key);
2891            if is_new {
2892                self.edge_count += 1;
2893            }
2894            changed = is_new;
2895            let edge_attrs = edge_bucket.entry(key).or_default();
2896            if !attrs.is_empty()
2897                && attrs
2898                    .iter()
2899                    .any(|(attr_key, value)| edge_attrs.get(attr_key) != Some(value))
2900            {
2901                changed = true;
2902            }
2903            edge_attrs.extend(attrs);
2904            edge_bucket.len()
2905        };
2906
2907        self.adjacency
2908            .entry(left.clone())
2909            .or_default()
2910            .entry(right.clone())
2911            .or_default()
2912            .insert(key);
2913        if left != right {
2914            self.adjacency
2915                .entry(right.clone())
2916                .or_default()
2917                .entry(left.clone())
2918                .or_default()
2919                .insert(key);
2920        }
2921        if changed {
2922            self.revision = self.revision.saturating_add(1);
2923        }
2924
2925        self.record_decision(
2926            "add_edge",
2927            incompatibility_probability,
2928            unknown_feature,
2929            vec![
2930                EvidenceTerm {
2931                    signal: "edge_key".to_owned(),
2932                    observed_value: key.to_string(),
2933                    log_likelihood_ratio: -2.0,
2934                },
2935                EvidenceTerm {
2936                    signal: "edge_attr_count".to_owned(),
2937                    observed_value: edge_attr_count.to_string(),
2938                    log_likelihood_ratio: -2.0,
2939                },
2940                EvidenceTerm {
2941                    signal: "left_autocreated".to_owned(),
2942                    observed_value: left_autocreated.to_string(),
2943                    log_likelihood_ratio: -1.25,
2944                },
2945                EvidenceTerm {
2946                    signal: "right_autocreated".to_owned(),
2947                    observed_value: right_autocreated.to_string(),
2948                    log_likelihood_ratio: -1.25,
2949                },
2950            ],
2951        );
2952
2953        Ok(key)
2954    }
2955
2956    /// br-r37-c1-sjf4t: overwrite the attribute map for an existing
2957    /// (u, v, key) edge. Returns whether the edge existed.
2958    pub fn replace_edge_attrs(
2959        &mut self,
2960        left: &str,
2961        right: &str,
2962        key: usize,
2963        attrs: AttrMap,
2964    ) -> bool {
2965        let edge_key = EdgeKey::new(left, right);
2966        if let Some(bucket) = self.edges.get_mut(&edge_key)
2967            && let Some(slot) = bucket.get_mut(&key)
2968        {
2969            if *slot != attrs {
2970                *slot = attrs;
2971                self.revision = self.revision.saturating_add(1);
2972            }
2973            return true;
2974        }
2975        false
2976    }
2977
2978    /// br-r37-c1-sjf4t: overwrite the attribute map for an existing node.
2979    pub fn replace_node_attrs(&mut self, node: &str, attrs: AttrMap) -> bool {
2980        if let Some(slot) = self.nodes.get_mut(node) {
2981            if *slot != attrs {
2982                *slot = attrs;
2983                self.revision = self.revision.saturating_add(1);
2984            }
2985            true
2986        } else {
2987            false
2988        }
2989    }
2990
2991    pub fn remove_edge(&mut self, left: &str, right: &str, key: Option<usize>) -> bool {
2992        let edge_key = EdgeKeyRef::new(left, right);
2993        let removal_key = key.or_else(|| {
2994            self.edges
2995                .get(&edge_key)
2996                .and_then(|edge_bucket| edge_bucket.keys().next_back().copied())
2997        });
2998
2999        let Some(removal_key) = removal_key else {
3000            return false;
3001        };
3002
3003        let removed = if let Some(edge_bucket) = self.edges.get_mut(&edge_key) {
3004            let was_removed = edge_bucket.shift_remove(&removal_key).is_some();
3005            if was_removed {
3006                self.edge_count -= 1;
3007                if edge_bucket.is_empty() {
3008                    self.edges.shift_remove(&edge_key);
3009                }
3010            }
3011            was_removed
3012        } else {
3013            false
3014        };
3015
3016        if !removed {
3017            return false;
3018        }
3019        self.remove_adjacency_key(left, right, removal_key);
3020        if left != right {
3021            self.remove_adjacency_key(right, left, removal_key);
3022        }
3023        self.revision = self.revision.saturating_add(1);
3024        true
3025    }
3026
3027    pub fn remove_node(&mut self, node: &str) -> bool {
3028        if !self.nodes.contains_key(node) {
3029            return false;
3030        }
3031
3032        // br-r37-c1-p6bxu: drop each incident edge bucket with O(1) `swap_remove`
3033        // (the `edges` IndexMap order is never observed externally — every public
3034        // consumer reads via `edges_ordered`, which walks node->neighbor order;
3035        // no internal consumer iterates the map order). The incident pairs are
3036        // known exactly from this node's adjacency (each distinct neighbor maps
3037        // to one canonical bucket, self-loops included), so removal is O(degree)
3038        // instead of the O(|distinct pairs|) `retain` scan — matching nx.
3039        let mut removed_count = 0usize;
3040        if let Some(neighbors) = self.adjacency.get(node) {
3041            let neighbor_names: Vec<String> = neighbors.keys().cloned().collect();
3042            for neighbor in neighbor_names {
3043                if neighbor != node
3044                    && let Some(remote_neighbors) = self.adjacency.get_mut(&neighbor)
3045                {
3046                    remote_neighbors.shift_remove(node);
3047                }
3048                if let Some(bucket) = self.edges.swap_remove(&EdgeKeyRef::new(node, &neighbor)) {
3049                    removed_count += bucket.len();
3050                }
3051            }
3052        }
3053        self.edge_count -= removed_count;
3054
3055        // Remove node from adjacency and nodes maps.
3056        self.adjacency.shift_remove(node);
3057        self.nodes.shift_remove(node);
3058        self.revision = self.revision.saturating_add(1);
3059        true
3060    }
3061
3062    #[must_use]
3063    pub fn edges_ordered(&self) -> Vec<MultiEdgeSnapshot> {
3064        let mut ordered = Vec::with_capacity(self.edge_count());
3065        let mut seen = HashSet::<(String, String, usize)>::with_capacity(self.edge_count());
3066
3067        for node in self.nodes.keys() {
3068            if let Some(neighbors) = self.adjacency.get(node) {
3069                for neighbor in neighbors.keys() {
3070                    let pair = EdgeKeyRef::new(node, neighbor);
3071                    if let Some(edge_bucket) = self.edges.get(&pair) {
3072                        for (key, attrs) in edge_bucket {
3073                            // Track using the canonical sorted pair to deduplicate correctly
3074                            let canonical_instance =
3075                                (pair.left.to_owned(), pair.right.to_owned(), *key);
3076                            if !seen.insert(canonical_instance) {
3077                                continue;
3078                            }
3079                            ordered.push(MultiEdgeSnapshot {
3080                                left: pair.left.to_owned(),
3081                                right: pair.right.to_owned(),
3082                                key: *key,
3083                                attrs: attrs.clone(),
3084                            });
3085                        }
3086                    }
3087                }
3088            }
3089        }
3090
3091        ordered
3092    }
3093
3094    #[must_use]
3095    pub fn edges_ordered_borrowed(&self) -> Vec<(&str, &str, usize, &AttrMap)> {
3096        let mut ordered = Vec::with_capacity(self.edge_count());
3097        let mut seen = HashSet::<(EdgeKeyRef, usize)>::with_capacity(self.edge_count());
3098
3099        for node in self.nodes.keys() {
3100            if let Some(neighbors) = self.adjacency.get(node) {
3101                for neighbor in neighbors.keys() {
3102                    let pair = EdgeKeyRef::new(node, neighbor);
3103                    if let Some(edge_bucket) = self.edges.get(&pair) {
3104                        for (key, attrs) in edge_bucket {
3105                            if !seen.insert((pair, *key)) {
3106                                continue;
3107                            }
3108                            ordered.push((pair.left, pair.right, *key, attrs));
3109                        }
3110                    }
3111                }
3112            }
3113        }
3114
3115        ordered
3116    }
3117
3118    #[must_use]
3119    pub fn snapshot(&self) -> MultiGraphSnapshot {
3120        // br-snapnodeattrs: see Graph::snapshot — same fix for MultiGraph.
3121        let node_attrs: BTreeMap<String, AttrMap> = self
3122            .nodes
3123            .iter()
3124            .filter(|(_, attrs)| !attrs.is_empty())
3125            .map(|(name, attrs)| (name.clone(), attrs.clone()))
3126            .collect();
3127        MultiGraphSnapshot {
3128            mode: self.mode,
3129            nodes: self.nodes.keys().cloned().collect(),
3130            node_attrs,
3131            edges: self.edges_ordered(),
3132        }
3133    }
3134
3135    fn remove_adjacency_key(&mut self, source: &str, target: &str, key: usize) {
3136        let mut drop_neighbor = false;
3137        if let Some(neighbors) = self.adjacency.get_mut(source)
3138            && let Some(keys) = neighbors.get_mut(target)
3139        {
3140            keys.shift_remove(&key);
3141            drop_neighbor = keys.is_empty();
3142        }
3143        if drop_neighbor && let Some(neighbors) = self.adjacency.get_mut(source) {
3144            neighbors.shift_remove(target);
3145        }
3146    }
3147
3148    fn record_decision(
3149        &mut self,
3150        operation: &'static str,
3151        incompatibility_probability: f64,
3152        unknown_incompatible_feature: bool,
3153        evidence: Vec<EvidenceTerm>,
3154    ) -> DecisionAction {
3155        let action = self
3156            .runtime_policy
3157            .action_for(incompatibility_probability, unknown_incompatible_feature);
3158        self.runtime_policy.record(
3159            operation,
3160            action,
3161            incompatibility_probability,
3162            "argmin expected loss over {allow,full_validate,fail_closed}",
3163            evidence,
3164        );
3165        action
3166    }
3167}
3168
3169#[cfg(test)]
3170mod tests {
3171    use super::{AttrMap, Graph, GraphError, MultiGraph};
3172    use fnx_runtime::{CgseValue, CompatibilityMode, DecisionAction, DecisionRecord};
3173    use proptest::prelude::*;
3174    use std::collections::BTreeSet;
3175
3176    fn node_name(id: u8) -> String {
3177        format!("n{}", id % 8)
3178    }
3179
3180    fn canonical_edge(left: &str, right: &str) -> (String, String) {
3181        if left <= right {
3182            (left.to_owned(), right.to_owned())
3183        } else {
3184            (right.to_owned(), left.to_owned())
3185        }
3186    }
3187
3188    fn single_attr(key: &str, value: &str) -> AttrMap {
3189        let mut attrs = AttrMap::new();
3190        attrs.insert(key.to_owned(), CgseValue::from(value));
3191        attrs
3192    }
3193
3194    #[test]
3195    fn edges_ordered_tracks_node_and_neighbor_iteration_order() {
3196        let mut graph = Graph::strict();
3197        graph
3198            .add_edge_with_attrs("a", "b", AttrMap::new())
3199            .expect("edge add should succeed");
3200        graph
3201            .add_edge_with_attrs("b", "c", AttrMap::new())
3202            .expect("edge add should succeed");
3203        graph
3204            .add_edge_with_attrs("a", "c", AttrMap::new())
3205            .expect("edge add should succeed");
3206
3207        let pairs = graph
3208            .edges_ordered()
3209            .into_iter()
3210            .map(|edge| (edge.left, edge.right))
3211            .collect::<Vec<(String, String)>>();
3212        assert_eq!(
3213            pairs,
3214            vec![
3215                ("a".to_owned(), "b".to_owned()),
3216                ("a".to_owned(), "c".to_owned()),
3217                ("b".to_owned(), "c".to_owned()),
3218            ]
3219        );
3220    }
3221
3222    #[test]
3223    fn edges_ordered_preserves_traversed_endpoint_orientation() {
3224        let mut graph = Graph::strict();
3225        graph
3226            .add_edge_with_attrs("2", "1", AttrMap::new())
3227            .expect("edge add should succeed");
3228
3229        let pairs = graph
3230            .edges_ordered()
3231            .into_iter()
3232            .map(|edge| (edge.left, edge.right))
3233            .collect::<Vec<(String, String)>>();
3234        assert_eq!(pairs, vec![("2".to_owned(), "1".to_owned())]);
3235
3236        let borrowed_pairs = graph
3237            .edges_ordered_borrowed()
3238            .into_iter()
3239            .map(|(left, right, _)| (left.to_owned(), right.to_owned()))
3240            .collect::<Vec<(String, String)>>();
3241        assert_eq!(borrowed_pairs, vec![("2".to_owned(), "1".to_owned())]);
3242    }
3243
3244    #[test]
3245    fn edges_ordered_uses_node_order_for_preexisting_nodes() {
3246        let mut graph = Graph::strict();
3247        graph.add_node("1".to_owned());
3248        graph.add_node("2".to_owned());
3249        graph
3250            .add_edge_with_attrs("2", "1", AttrMap::new())
3251            .expect("edge add should succeed");
3252
3253        let pairs = graph
3254            .edges_ordered()
3255            .into_iter()
3256            .map(|edge| (edge.left, edge.right))
3257            .collect::<Vec<(String, String)>>();
3258        assert_eq!(pairs, vec![("1".to_owned(), "2".to_owned())]);
3259    }
3260
3261    #[test]
3262    fn edge_storage_order_index_iter_tracks_mutations() {
3263        let mut graph = Graph::strict();
3264        graph.add_node("c");
3265        graph.add_node("a");
3266        graph.add_node("b");
3267        graph
3268            .add_edge_with_attrs("c", "a", single_attr("weight", "1"))
3269            .expect("edge add should succeed");
3270        graph
3271            .add_edge_with_attrs("b", "c", single_attr("weight", "2"))
3272            .expect("edge add should succeed");
3273        graph
3274            .add_edge_with_attrs("a", "c", single_attr("color", "red"))
3275            .expect("duplicate edge update should succeed");
3276
3277        let indexed_edges = |graph: &Graph| {
3278            graph
3279                .edges_storage_order_index_iter()
3280                .map(|(left, right, attrs)| {
3281                    (
3282                        graph
3283                            .get_node_name(left)
3284                            .expect("left endpoint index should resolve")
3285                            .to_owned(),
3286                        graph
3287                            .get_node_name(right)
3288                            .expect("right endpoint index should resolve")
3289                            .to_owned(),
3290                        attrs.get("weight").cloned(),
3291                    )
3292                })
3293                .collect::<Vec<_>>()
3294        };
3295
3296        assert_eq!(
3297            indexed_edges(&graph),
3298            vec![
3299                (
3300                    "a".to_owned(),
3301                    "c".to_owned(),
3302                    Some(CgseValue::String("1".to_owned())),
3303                ),
3304                (
3305                    "b".to_owned(),
3306                    "c".to_owned(),
3307                    Some(CgseValue::String("2".to_owned())),
3308                ),
3309            ]
3310        );
3311
3312        assert!(graph.remove_edge("a", "c"));
3313        assert_eq!(
3314            indexed_edges(&graph),
3315            vec![(
3316                "b".to_owned(),
3317                "c".to_owned(),
3318                Some(CgseValue::String("2".to_owned())),
3319            )]
3320        );
3321
3322        assert!(graph.remove_node("b"));
3323        assert!(indexed_edges(&graph).is_empty());
3324    }
3325
3326    fn assert_graph_core_invariants(graph: &Graph) {
3327        let mut unique_edges = BTreeSet::new();
3328        for node in graph.nodes_ordered() {
3329            let neighbors = graph
3330                .neighbors(node)
3331                .expect("graph nodes should always have adjacency buckets");
3332            for neighbor in neighbors {
3333                assert!(graph.has_node(neighbor));
3334                assert!(graph.has_edge(node, neighbor));
3335                let reverse_neighbors = graph
3336                    .neighbors(neighbor)
3337                    .expect("neighbor should always have adjacency bucket");
3338                assert!(reverse_neighbors.contains(&node));
3339                unique_edges.insert(canonical_edge(node, neighbor));
3340            }
3341        }
3342        assert_eq!(graph.edge_count(), unique_edges.len());
3343    }
3344
3345    fn assert_multigraph_core_invariants(graph: &MultiGraph) {
3346        let mut edge_instances = BTreeSet::new();
3347        for node in graph.nodes_ordered() {
3348            let neighbors = graph
3349                .neighbors(node)
3350                .expect("multigraph nodes should always have adjacency buckets");
3351            for neighbor in neighbors {
3352                assert!(graph.has_node(neighbor));
3353                assert!(graph.has_edge(node, neighbor));
3354                let reverse_neighbors = graph
3355                    .neighbors(neighbor)
3356                    .expect("neighbor should always have adjacency bucket");
3357                assert!(reverse_neighbors.contains(&node));
3358                let keys = graph
3359                    .edge_keys(node, neighbor)
3360                    .expect("parallel edge bucket should exist");
3361                for key in keys {
3362                    let canonical = canonical_edge(node, neighbor);
3363                    edge_instances.insert((canonical.0, canonical.1, key));
3364                }
3365            }
3366        }
3367        assert_eq!(graph.edge_count(), edge_instances.len());
3368    }
3369
3370    fn assert_decision_record_schema(record: &DecisionRecord, expected_mode: CompatibilityMode) {
3371        assert!(record.ts_unix_ms > 0);
3372        assert!(!record.operation.trim().is_empty());
3373        assert_eq!(record.mode, expected_mode);
3374        assert!((0.0..=1.0).contains(&record.incompatibility_probability));
3375        assert!(!record.rationale.trim().is_empty());
3376        assert!(!record.evidence.is_empty());
3377        for term in &record.evidence {
3378            assert!(!term.signal.trim().is_empty());
3379            assert!(!term.observed_value.trim().is_empty());
3380        }
3381    }
3382
3383    #[test]
3384    fn add_edge_autocreates_nodes_and_preserves_order() {
3385        let mut graph = Graph::strict();
3386        graph
3387            .add_edge_with_attrs("a", "b", AttrMap::new())
3388            .expect("edge insert should succeed");
3389        graph
3390            .add_edge_with_attrs("a", "c", AttrMap::new())
3391            .expect("edge insert should succeed");
3392
3393        assert_eq!(graph.node_count(), 3);
3394        assert_eq!(graph.edge_count(), 2);
3395        assert_eq!(graph.nodes_ordered(), vec!["a", "b", "c"]);
3396        assert_eq!(graph.neighbors("a"), Some(vec!["b", "c"]));
3397    }
3398
3399    #[test]
3400    fn extend_edges_unrecorded_preserves_adjacency_and_records_once() {
3401        let mut graph = Graph::strict();
3402        graph.add_node("a");
3403        graph.add_node("b");
3404        graph.add_node("c");
3405        let before = graph.evidence_ledger().records().len();
3406
3407        let inserted = graph.extend_edges_unrecorded([("a", "b"), ("a", "c"), ("b", "a")]);
3408
3409        assert_eq!(inserted, 2);
3410        assert_eq!(graph.edge_count(), 2);
3411        assert_eq!(graph.neighbors("a"), Some(vec!["b", "c"]));
3412        let records = graph.evidence_ledger().records();
3413        assert_eq!(records.len(), before + 1);
3414        assert_eq!(
3415            records.last().map(|record| record.operation.as_str()),
3416            Some("extend_edges_unrecorded")
3417        );
3418    }
3419
3420    #[test]
3421    fn extend_existing_index_edges_unrecorded_matches_string_batch_on_int_prefix() {
3422        let nodes = (0..12).map(|node| node.to_string()).collect::<Vec<_>>();
3423        let edge_indices = vec![(10usize, 2usize), (2, 10), (3, 3), (1, 11), (11, 1)];
3424        let edge_strings = edge_indices
3425            .iter()
3426            .map(|(left, right)| (left.to_string(), right.to_string()))
3427            .collect::<Vec<_>>();
3428
3429        let mut by_string = Graph::strict();
3430        let mut by_index = Graph::strict();
3431        let _ = by_string.extend_nodes_unrecorded(nodes.clone());
3432        let _ = by_index.extend_nodes_unrecorded(nodes);
3433
3434        assert!(by_index.nodes_are_contiguous_int_prefix());
3435        assert_eq!(by_string.extend_edges_unrecorded(edge_strings), 3);
3436        assert_eq!(
3437            by_index.extend_existing_index_edges_unrecorded(edge_indices),
3438            3
3439        );
3440
3441        assert_eq!(by_index.snapshot(), by_string.snapshot());
3442        assert_graph_core_invariants(&by_index);
3443    }
3444
3445    #[test]
3446    fn extend_edges_with_attrs_unrecorded_matches_add_edge_with_attrs() {
3447        // br-r37-c1-pr8q6: bulk attributed insertion must be observationally
3448        // identical to a sequence of add_edge_with_attrs calls (node order,
3449        // adjacency order, attr merge on duplicates) minus the per-edge
3450        // ledger records.
3451        let mut attrs1 = AttrMap::new();
3452        attrs1.insert("w".to_owned(), CgseValue::Int(1));
3453        let mut attrs2 = AttrMap::new();
3454        attrs2.insert("w".to_owned(), CgseValue::Int(7));
3455        attrs2.insert("c".to_owned(), CgseValue::String("x".to_owned()));
3456
3457        let mut reference = Graph::strict();
3458        reference
3459            .add_edge_with_attrs("a", "b", attrs1.clone())
3460            .unwrap();
3461        reference
3462            .add_edge_with_attrs("b", "c", AttrMap::new())
3463            .unwrap();
3464        // duplicate edge: attrs merge
3465        reference
3466            .add_edge_with_attrs("b", "a", attrs2.clone())
3467            .unwrap();
3468        // self-loop
3469        reference
3470            .add_edge_with_attrs("d", "d", attrs1.clone())
3471            .unwrap();
3472
3473        let mut bulk = Graph::strict();
3474        let before = bulk.evidence_ledger().records().len();
3475        let inserted = bulk.extend_edges_with_attrs_unrecorded([
3476            ("a".to_owned(), "b".to_owned(), attrs1.clone()),
3477            ("b".to_owned(), "c".to_owned(), AttrMap::new()),
3478            ("b".to_owned(), "a".to_owned(), attrs2.clone()),
3479            ("d".to_owned(), "d".to_owned(), attrs1.clone()),
3480        ]);
3481
3482        assert_eq!(inserted, 3);
3483        assert_eq!(bulk.edge_count(), reference.edge_count());
3484        assert_eq!(bulk.nodes_ordered(), reference.nodes_ordered());
3485        for node in bulk.nodes_ordered() {
3486            assert_eq!(bulk.neighbors(node), reference.neighbors(node));
3487        }
3488        assert_eq!(bulk.edge_attrs("a", "b"), reference.edge_attrs("a", "b"));
3489        assert_eq!(bulk.edge_attrs("d", "d"), reference.edge_attrs("d", "d"));
3490        // one summary record, not one per edge
3491        assert_eq!(bulk.evidence_ledger().records().len(), before + 1);
3492    }
3493
3494    #[test]
3495    fn multigraph_int_prefix_keyed_edges_match_string_batch() {
3496        let indexed_edges = vec![
3497            (0usize, 1usize, 0usize),
3498            (1, 2, 0),
3499            (2, 0, 0),
3500            (0, 1, 1),
3501            (2, 2, 0),
3502        ];
3503        let string_edges = indexed_edges
3504            .iter()
3505            .map(|(left, right, key)| (left.to_string(), right.to_string(), *key, AttrMap::new()))
3506            .collect::<Vec<_>>();
3507
3508        let mut by_string = MultiGraph::strict();
3509        let mut by_index = MultiGraph::strict();
3510
3511        assert_eq!(
3512            by_string.extend_keyed_edges_with_attrs_unrecorded(string_edges),
3513            indexed_edges.len()
3514        );
3515        assert_eq!(
3516            by_index.extend_fresh_int_prefix_keyed_edges_unrecorded(3, indexed_edges),
3517            5
3518        );
3519
3520        assert_eq!(by_index.snapshot(), by_string.snapshot());
3521        assert_multigraph_core_invariants(&by_index);
3522    }
3523
3524    #[test]
3525    fn extend_nodes_unrecorded_preserves_order_and_records_once() {
3526        let mut graph = Graph::strict();
3527        graph.add_node("1");
3528        let before = graph.evidence_ledger().records().len();
3529
3530        let inserted =
3531            graph.extend_nodes_unrecorded(["0".to_owned(), "1".to_owned(), "2".to_owned()]);
3532
3533        assert_eq!(inserted, 2);
3534        assert_eq!(graph.node_count(), 3);
3535        assert_eq!(graph.nodes_ordered(), vec!["1", "0", "2"]);
3536        assert_eq!(graph.neighbor_count("0"), 0);
3537        assert_eq!(graph.neighbor_count("2"), 0);
3538        let records = graph.evidence_ledger().records();
3539        assert_eq!(records.len(), before + 1);
3540        assert_eq!(
3541            records.last().map(|record| record.operation.as_str()),
3542            Some("extend_nodes_unrecorded")
3543        );
3544    }
3545
3546    #[test]
3547    fn neighbors_iter_preserves_deterministic_order() {
3548        let mut graph = Graph::strict();
3549        graph.add_edge("a", "b").expect("edge add should succeed");
3550        graph.add_edge("a", "c").expect("edge add should succeed");
3551        graph.add_edge("a", "d").expect("edge add should succeed");
3552
3553        let neighbors = graph
3554            .neighbors_iter("a")
3555            .expect("neighbors should exist")
3556            .collect::<Vec<&str>>();
3557        assert_eq!(neighbors, vec!["b", "c", "d"]);
3558    }
3559
3560    #[test]
3561    fn neighbor_count_matches_neighbors_len() {
3562        let mut graph = Graph::strict();
3563        graph.add_edge("a", "b").expect("edge add should succeed");
3564        graph.add_edge("a", "c").expect("edge add should succeed");
3565        assert_eq!(graph.neighbor_count("a"), 2);
3566        assert_eq!(graph.neighbor_count("missing"), 0);
3567    }
3568
3569    #[test]
3570    fn repeated_edge_updates_attrs_in_place() {
3571        let mut graph = Graph::strict();
3572        let mut first = AttrMap::new();
3573        first.insert("weight".to_owned(), "1".into());
3574        graph
3575            .add_edge_with_attrs("a", "b", first)
3576            .expect("edge insert should succeed");
3577
3578        let mut second = AttrMap::new();
3579        second.insert("color".to_owned(), "blue".into());
3580        graph
3581            .add_edge_with_attrs("b", "a", second)
3582            .expect("edge update should succeed");
3583
3584        let attrs = graph
3585            .edge_attrs("a", "b")
3586            .expect("edge attrs should be present");
3587        assert_eq!(
3588            attrs.get("weight"),
3589            Some(&CgseValue::String("1".to_owned()))
3590        );
3591        assert_eq!(
3592            attrs.get("color"),
3593            Some(&CgseValue::String("blue".to_owned()))
3594        );
3595        assert_eq!(graph.edge_count(), 1);
3596    }
3597
3598    #[test]
3599    fn complete_graph_bulk_constructor_preserves_ordered_contract() {
3600        let graph = Graph::complete_graph(CompatibilityMode::Strict, 5);
3601
3602        assert_eq!(graph.node_count(), 5);
3603        assert_eq!(graph.edge_count(), 10);
3604        assert_eq!(graph.nodes_ordered(), vec!["0", "1", "2", "3", "4"]);
3605        assert_eq!(
3606            graph.neighbors("0").expect("node 0 should exist"),
3607            vec!["1", "2", "3", "4"]
3608        );
3609        assert_eq!(
3610            graph.neighbors("4").expect("node 4 should exist"),
3611            vec!["0", "1", "2", "3"]
3612        );
3613        assert_eq!(
3614            graph
3615                .edges_ordered()
3616                .into_iter()
3617                .map(|edge| (edge.left, edge.right))
3618                .collect::<Vec<_>>(),
3619            vec![
3620                ("0".to_owned(), "1".to_owned()),
3621                ("0".to_owned(), "2".to_owned()),
3622                ("0".to_owned(), "3".to_owned()),
3623                ("0".to_owned(), "4".to_owned()),
3624                ("1".to_owned(), "2".to_owned()),
3625                ("1".to_owned(), "3".to_owned()),
3626                ("1".to_owned(), "4".to_owned()),
3627                ("2".to_owned(), "3".to_owned()),
3628                ("2".to_owned(), "4".to_owned()),
3629                ("3".to_owned(), "4".to_owned()),
3630            ]
3631        );
3632
3633        let operations = graph
3634            .evidence_ledger()
3635            .records()
3636            .iter()
3637            .map(|record| record.operation.as_str())
3638            .collect::<Vec<_>>();
3639        assert_eq!(operations, vec!["complete_graph_bulk"]);
3640    }
3641
3642    #[test]
3643    fn remove_node_removes_incident_edges() {
3644        let mut graph = Graph::strict();
3645        graph.add_edge("a", "b").expect("edge add should succeed");
3646        graph.add_edge("b", "c").expect("edge add should succeed");
3647        assert!(graph.remove_node("b"));
3648        assert_eq!(graph.node_count(), 2);
3649        assert_eq!(graph.edge_count(), 0);
3650    }
3651
3652    // br-r37-c1-p6bxu: A/B substrate bench for MultiGraph::remove_node
3653    // (O(degree) swap_remove vs the old O(|E|) retain). Ignored by default;
3654    // run with `cargo test -p fnx-classes --release ab_bench_multigraph_remove_node
3655    // -- --ignored --nocapture`. Also asserts byte-exact parity (node_count,
3656    // edge_count, edges_ordered) between the two paths.
3657    #[test]
3658    #[ignore]
3659    fn ab_bench_multigraph_remove_node() {
3660        use std::time::Instant;
3661        const N: usize = 1000;
3662        const M: usize = 8000;
3663        const ITERS: usize = 500;
3664        let build = || {
3665            let mut g = MultiGraph::new(CompatibilityMode::Strict);
3666            for i in 0..N {
3667                let _ = g.add_node(i.to_string());
3668            }
3669            let mut s: u64 = 0x9E3779B97F4A7C15;
3670            let mut next = || {
3671                s = s
3672                    .wrapping_mul(6364136223846793005)
3673                    .wrapping_add(1442695040888963407);
3674                (s >> 33) as usize % N
3675            };
3676            for _ in 0..M {
3677                let a = next();
3678                let b = next();
3679                let _ = g.add_edge(a.to_string(), b.to_string());
3680            }
3681            g
3682        };
3683
3684        let mut gnew = build();
3685        let victims: Vec<String> = gnew.nodes.keys().take(ITERS).cloned().collect();
3686        let t = Instant::now();
3687        for node in &victims {
3688            gnew.remove_node(node);
3689        }
3690        let new_t = t.elapsed();
3691
3692        // OLD path: full O(|E|) retain per removal.
3693        let mut gold = build();
3694        let t = Instant::now();
3695        for node in &victims {
3696            if !gold.nodes.contains_key(node) {
3697                continue;
3698            }
3699            if let Some(neighbors) = gold.adjacency.get(node) {
3700                let names: Vec<String> = neighbors.keys().cloned().collect();
3701                for nb in names {
3702                    if nb != *node
3703                        && let Some(rn) = gold.adjacency.get_mut(&nb)
3704                    {
3705                        rn.shift_remove(node.as_str());
3706                    }
3707                }
3708            }
3709            let mut rc = 0usize;
3710            let nd = node.clone();
3711            gold.edges.retain(|k, bucket| {
3712                let keep = k.left != nd && k.right != nd;
3713                if !keep {
3714                    rc += bucket.len();
3715                }
3716                keep
3717            });
3718            gold.edge_count -= rc;
3719            gold.adjacency.shift_remove(node);
3720            gold.nodes.shift_remove(node);
3721        }
3722        let old_t = t.elapsed();
3723
3724        assert_eq!(gnew.node_count(), gold.node_count());
3725        assert_eq!(gnew.edge_count(), gold.edge_count());
3726        assert_eq!(gnew.edges_ordered(), gold.edges_ordered());
3727        eprintln!(
3728            "MultiGraph remove_node x{ITERS}: retain {old_t:?} -> swap_remove {new_t:?} = {:.2}x",
3729            old_t.as_secs_f64() / new_t.as_secs_f64()
3730        );
3731    }
3732
3733    #[test]
3734    fn remove_nodes_from_matches_repeated_removal_and_rebuilds_indices() {
3735        let edges = [
3736            ("0", "1"),
3737            ("1", "2"),
3738            ("2", "3"),
3739            ("3", "4"),
3740            ("4", "5"),
3741            ("0", "5"),
3742            ("1", "4"),
3743            ("2", "5"),
3744        ];
3745        let mut batch = Graph::strict();
3746        let mut repeated = Graph::strict();
3747        for (left, right) in edges {
3748            batch.add_edge(left, right).expect("batch edge add");
3749            repeated.add_edge(left, right).expect("repeated edge add");
3750        }
3751
3752        let victims = ["1", "3", "99", "1"];
3753        let removed = batch.remove_nodes_from(victims);
3754        for victim in victims {
3755            let _ = repeated.remove_node(victim);
3756        }
3757
3758        assert_eq!(removed, (2, 5));
3759        assert_eq!(batch.snapshot(), repeated.snapshot());
3760        let ordered_nodes = batch
3761            .nodes_ordered()
3762            .into_iter()
3763            .map(str::to_owned)
3764            .collect::<Vec<_>>();
3765        for (node_index, node) in ordered_nodes.iter().enumerate() {
3766            let names_from_strings = batch.neighbors(node).expect("node should exist");
3767            let names_from_indices = batch
3768                .neighbors_indices(node_index)
3769                .expect("index should exist")
3770                .iter()
3771                .map(|index| batch.get_node_name(*index).expect("index should resolve"))
3772                .collect::<Vec<_>>();
3773            assert_eq!(names_from_indices, names_from_strings);
3774        }
3775        let names_from_edge_indices = batch
3776            .edges_storage_order_index_iter()
3777            .map(|(left, right, _)| {
3778                (
3779                    batch
3780                        .get_node_name(left)
3781                        .expect("left endpoint should resolve")
3782                        .to_owned(),
3783                    batch
3784                        .get_node_name(right)
3785                        .expect("right endpoint should resolve")
3786                        .to_owned(),
3787                )
3788            })
3789            .collect::<Vec<_>>();
3790        let names_from_edge_storage = batch
3791            .edges_storage_order_iter()
3792            .map(|(left, right, _)| (left.to_owned(), right.to_owned()))
3793            .collect::<Vec<_>>();
3794        assert_eq!(names_from_edge_indices, names_from_edge_storage);
3795    }
3796
3797    #[test]
3798    fn strict_mode_fails_closed_for_unknown_incompatible_feature() {
3799        let mut graph = Graph::strict();
3800        let mut attrs = AttrMap::new();
3801        attrs.insert("__fnx_incompatible_decoder".to_owned(), "v2".into());
3802        let err = graph
3803            .add_edge_with_attrs("a", "b", attrs)
3804            .expect_err("strict mode should fail closed");
3805
3806        assert_eq!(
3807            err,
3808            GraphError::FailClosed {
3809                operation: "add_edge",
3810                reason: "incompatible edge metadata".to_owned(),
3811            }
3812        );
3813
3814        let last_record = graph
3815            .evidence_ledger()
3816            .records()
3817            .last()
3818            .expect("strict fail-closed path should emit a ledger row");
3819        assert_decision_record_schema(last_record, CompatibilityMode::Strict);
3820        assert_eq!(last_record.operation, "add_edge");
3821        assert_eq!(last_record.action, DecisionAction::FailClosed);
3822        assert!(
3823            last_record
3824                .evidence
3825                .iter()
3826                .any(|term| term.signal == "unknown_incompatible_feature")
3827        );
3828    }
3829
3830    #[test]
3831    fn revision_increments_on_mutating_operations() {
3832        let mut graph = Graph::strict();
3833        let r0 = graph.revision();
3834        let _ = graph.add_node("a");
3835        let r1 = graph.revision();
3836        assert!(r1 > r0);
3837
3838        graph.add_edge("a", "b").expect("edge add should succeed");
3839        let r2 = graph.revision();
3840        assert!(r2 > r1);
3841
3842        let _ = graph.remove_edge("a", "b");
3843        let r3 = graph.revision();
3844        assert!(r3 > r2);
3845    }
3846
3847    #[test]
3848    fn add_node_with_attrs_reports_change_on_existing_node() {
3849        let mut graph = Graph::strict();
3850        assert!(graph.add_node_with_attrs("a", single_attr("color", "red")));
3851        let r1 = graph.revision();
3852        assert!(graph.add_node_with_attrs("a", single_attr("color", "blue")));
3853        let r2 = graph.revision();
3854        assert!(r2 > r1);
3855        let expected = CgseValue::from("blue");
3856        assert_eq!(graph.node_attrs("a").unwrap().get("color"), Some(&expected));
3857    }
3858
3859    #[test]
3860    fn multigraph_add_node_with_attrs_reports_change_on_existing_node() {
3861        let mut graph = MultiGraph::strict();
3862        assert!(graph.add_node_with_attrs("a", single_attr("color", "red")));
3863        let r1 = graph.revision();
3864        assert!(graph.add_node_with_attrs("a", single_attr("color", "blue")));
3865        let r2 = graph.revision();
3866        assert!(r2 > r1);
3867        let expected = CgseValue::from("blue");
3868        assert_eq!(graph.node_attrs("a").unwrap().get("color"), Some(&expected));
3869    }
3870
3871    #[test]
3872    fn hardened_self_loop_records_allow_decision() {
3873        let mut graph = Graph::hardened();
3874        graph
3875            .add_edge("loop", "loop")
3876            .expect("hardened self-loop edge should be accepted");
3877
3878        let add_edge_record = graph
3879            .evidence_ledger()
3880            .records()
3881            .iter()
3882            .rev()
3883            .find(|record| record.operation == "add_edge")
3884            .expect("add_edge operation should emit ledger row");
3885        assert_decision_record_schema(add_edge_record, CompatibilityMode::Hardened);
3886        assert_eq!(add_edge_record.action, DecisionAction::Allow);
3887        assert!(
3888            add_edge_record
3889                .evidence
3890                .iter()
3891                .any(|term| term.signal == "self_loop" && term.observed_value == "true")
3892        );
3893    }
3894
3895    #[test]
3896    fn runtime_policy_tracks_hardened_graph_recovery() {
3897        let mut graph = Graph::hardened();
3898        graph
3899            .add_edge("a", "b")
3900            .expect("hardened graph edge add should succeed");
3901
3902        assert_eq!(graph.runtime_policy().mode(), CompatibilityMode::Hardened);
3903        assert!(!graph.runtime_policy().decision_log().records().is_empty());
3904        assert!(graph.runtime_policy().posterior().observation_count >= 1);
3905    }
3906
3907    #[test]
3908    fn snapshot_roundtrip_replays_to_identical_state() {
3909        let mut graph = Graph::strict();
3910
3911        let mut first_attrs = AttrMap::new();
3912        first_attrs.insert("weight".to_owned(), "7".into());
3913        graph
3914            .add_edge_with_attrs("a", "b", first_attrs)
3915            .expect("edge insert should succeed");
3916
3917        let mut second_attrs = AttrMap::new();
3918        second_attrs.insert("color".to_owned(), "green".into());
3919        graph
3920            .add_edge_with_attrs("b", "c", second_attrs)
3921            .expect("edge insert should succeed");
3922
3923        let snapshot = graph.snapshot();
3924        let mut replayed = Graph::new(snapshot.mode);
3925        for node in &snapshot.nodes {
3926            let _ = replayed.add_node(node.clone());
3927        }
3928        for edge in &snapshot.edges {
3929            replayed
3930                .add_edge_with_attrs(edge.left.clone(), edge.right.clone(), edge.attrs.clone())
3931                .expect("snapshot replay should be valid");
3932        }
3933
3934        assert_eq!(replayed.snapshot(), snapshot);
3935        assert_graph_core_invariants(&replayed);
3936    }
3937
3938    #[test]
3939    fn multigraph_tracks_parallel_edges_with_distinct_keys() {
3940        let mut graph = MultiGraph::strict();
3941        let first = graph.add_edge("a", "b").expect("edge add should succeed");
3942        let second = graph.add_edge("a", "b").expect("edge add should succeed");
3943
3944        assert_ne!(first, second);
3945        assert_eq!(graph.edge_count(), 2);
3946        assert_eq!(graph.edge_keys("a", "b"), Some(vec![0, 1]));
3947        assert_multigraph_core_invariants(&graph);
3948    }
3949
3950    #[test]
3951    fn multigraph_counts_parallel_selfloops_only() {
3952        let mut graph = MultiGraph::strict();
3953        let _ = graph
3954            .add_edge("a", "a")
3955            .expect("self-loop add should succeed");
3956        let _ = graph
3957            .add_edge("a", "a")
3958            .expect("self-loop add should succeed");
3959        let _ = graph
3960            .add_edge("b", "b")
3961            .expect("self-loop add should succeed");
3962        let _ = graph.add_edge("a", "b").expect("edge add should succeed");
3963        let _ = graph.add_edge("a", "b").expect("edge add should succeed");
3964
3965        assert_eq!(graph.number_of_selfloops(), 3);
3966        assert_multigraph_core_invariants(&graph);
3967    }
3968
3969    #[test]
3970    fn multigraph_add_fresh_edge_unrecorded_rejects_occupied_bucket() {
3971        let mut graph = MultiGraph::strict();
3972        assert_eq!(graph.add_fresh_edge_unrecorded("a", "b"), Some(0));
3973        let revision = graph.revision();
3974        let edge_count = graph.edge_count();
3975
3976        assert_eq!(graph.add_fresh_edge_unrecorded("b", "a"), None);
3977
3978        assert_eq!(graph.revision(), revision);
3979        assert_eq!(graph.edge_count(), edge_count);
3980        assert_eq!(graph.edge_keys("a", "b"), Some(vec![0]));
3981        assert_eq!(graph.nodes_ordered(), vec!["a", "b"]);
3982        assert_multigraph_core_invariants(&graph);
3983    }
3984
3985    #[test]
3986    fn multigraph_add_fresh_edge_with_key_unrecorded_preserves_key() {
3987        let mut graph = MultiGraph::strict();
3988
3989        assert_eq!(
3990            graph.add_fresh_edge_with_key_unrecorded("a", "b", 42),
3991            Some(42)
3992        );
3993
3994        assert_eq!(graph.edge_count(), 1);
3995        assert_eq!(graph.edge_keys("a", "b"), Some(vec![42]));
3996        assert_eq!(graph.nodes_ordered(), vec!["a", "b"]);
3997        assert_multigraph_core_invariants(&graph);
3998    }
3999
4000    #[test]
4001    fn multigraph_remove_edge_without_key_removes_latest_instance() {
4002        let mut graph = MultiGraph::strict();
4003        let first = graph.add_edge("a", "b").expect("edge add should succeed");
4004        let second = graph.add_edge("a", "b").expect("edge add should succeed");
4005
4006        assert!(graph.remove_edge("a", "b", None));
4007        assert_eq!(graph.edge_count(), 1);
4008        assert_eq!(graph.edge_keys("a", "b"), Some(vec![first]));
4009        assert_ne!(first, second);
4010        assert_multigraph_core_invariants(&graph);
4011    }
4012
4013    #[test]
4014    fn multigraph_remove_node_clears_parallel_incident_edges() {
4015        let mut graph = MultiGraph::strict();
4016        let _ = graph.add_edge("a", "b").expect("edge add should succeed");
4017        let _ = graph.add_edge("a", "b").expect("edge add should succeed");
4018        let _ = graph.add_edge("b", "c").expect("edge add should succeed");
4019
4020        assert!(graph.remove_node("b"));
4021        assert_eq!(graph.edge_count(), 0);
4022        assert!(!graph.has_node("b"));
4023        assert_eq!(graph.neighbors("a"), Some(vec![]));
4024        assert_eq!(graph.neighbors("c"), Some(vec![]));
4025        assert_multigraph_core_invariants(&graph);
4026    }
4027
4028    #[test]
4029    fn runtime_policy_tracks_hardened_multigraph_recovery() {
4030        let mut graph = MultiGraph::hardened();
4031        graph
4032            .add_edge("a", "b")
4033            .expect("hardened multigraph edge add should succeed");
4034
4035        assert_eq!(graph.runtime_policy().mode(), CompatibilityMode::Hardened);
4036        assert!(!graph.runtime_policy().decision_log().records().is_empty());
4037        assert!(graph.runtime_policy().posterior().observation_count >= 1);
4038    }
4039
4040    #[test]
4041    fn multigraph_roundtrips_sparse_snapshot_keys() {
4042        let mut graph = MultiGraph::strict();
4043        assert_eq!(
4044            graph.add_edge("a", "b").expect("edge add should succeed"),
4045            0
4046        );
4047        assert_eq!(
4048            graph.add_edge("a", "b").expect("edge add should succeed"),
4049            1
4050        );
4051        assert_eq!(
4052            graph.add_edge("a", "b").expect("edge add should succeed"),
4053            2
4054        );
4055        assert!(graph.remove_edge("a", "b", Some(1)));
4056        assert_eq!(
4057            graph.add_edge("a", "b").expect("edge add should succeed"),
4058            3
4059        );
4060
4061        let snapshot = graph.snapshot();
4062        assert_eq!(
4063            snapshot
4064                .edges
4065                .iter()
4066                .map(|edge| edge.key)
4067                .collect::<Vec<_>>(),
4068            vec![0, 2, 3]
4069        );
4070
4071        let mut replayed = MultiGraph::new(snapshot.mode);
4072        for node in &snapshot.nodes {
4073            let _ = replayed.add_node(node.clone());
4074        }
4075        for edge in &snapshot.edges {
4076            replayed
4077                .add_edge_with_key_and_attrs(
4078                    edge.left.clone(),
4079                    edge.right.clone(),
4080                    edge.key,
4081                    edge.attrs.clone(),
4082                )
4083                .expect("snapshot replay should preserve explicit keys");
4084        }
4085
4086        assert_eq!(replayed.snapshot(), snapshot);
4087        assert_multigraph_core_invariants(&replayed);
4088    }
4089
4090    proptest! {
4091        #[test]
4092        fn prop_core_invariants_hold_for_mixed_edge_mutations(
4093            ops in prop::collection::vec((0_u8..8, 0_u8..8, any::<bool>()), 1..80),
4094        ) {
4095            let mut graph = Graph::strict();
4096            let mut last_revision = graph.revision();
4097
4098            for (left_id, right_id, is_add) in ops {
4099                let left = node_name(left_id);
4100                let right = node_name(right_id);
4101                if is_add {
4102                    prop_assert!(graph.add_edge(left.clone(), right.clone()).is_ok());
4103                } else {
4104                    let _ = graph.remove_edge(&left, &right);
4105                }
4106                let revision = graph.revision();
4107                prop_assert!(revision >= last_revision);
4108                last_revision = revision;
4109                assert_graph_core_invariants(&graph);
4110            }
4111        }
4112
4113        #[test]
4114        fn prop_snapshot_is_deterministic_for_same_operation_stream(
4115            ops in prop::collection::vec((0_u8..8, 0_u8..8, 0_u8..3), 0..64),
4116        ) {
4117            let mut graph_left = Graph::hardened();
4118            let mut graph_right = Graph::hardened();
4119
4120            for (left_id, right_id, attrs_variant) in ops {
4121                let left = node_name(left_id);
4122                let right = node_name(right_id);
4123                let mut attrs = AttrMap::new();
4124                if attrs_variant == 1 {
4125                    attrs.insert("weight".to_owned(), (left_id % 5).to_string().into());
4126                } else if attrs_variant == 2 {
4127                    attrs.insert("tag".to_owned(), format!("k{}", right_id % 4).into());
4128                }
4129                prop_assert!(
4130                    graph_left
4131                        .add_edge_with_attrs(left.clone(), right.clone(), attrs.clone())
4132                        .is_ok()
4133                );
4134                prop_assert!(
4135                    graph_right
4136                        .add_edge_with_attrs(left, right, attrs)
4137                        .is_ok()
4138                );
4139            }
4140
4141            prop_assert_eq!(graph_left.snapshot(), graph_right.snapshot());
4142            prop_assert_eq!(graph_left.snapshot(), graph_left.snapshot());
4143        }
4144
4145        #[test]
4146        fn prop_reapplying_identical_edge_attrs_is_revision_stable(
4147            left_id in 0_u8..8,
4148            right_id in 0_u8..8,
4149            weight in 0_u16..5000,
4150        ) {
4151            let mut graph = Graph::strict();
4152            let left = node_name(left_id);
4153            let right = node_name(right_id);
4154            let mut attrs = AttrMap::new();
4155            attrs.insert("weight".to_owned(), weight.to_string().into());
4156
4157            prop_assert!(
4158                graph
4159                    .add_edge_with_attrs(left.clone(), right.clone(), attrs.clone())
4160                    .is_ok()
4161            );
4162            let revision_after_first = graph.revision();
4163            prop_assert!(
4164                graph
4165                    .add_edge_with_attrs(left, right, attrs)
4166                    .is_ok()
4167            );
4168            prop_assert_eq!(graph.revision(), revision_after_first);
4169        }
4170
4171        #[test]
4172        fn prop_remove_node_clears_incident_edges(
4173            ops in prop::collection::vec((0_u8..8, 0_u8..8), 1..64),
4174            target_id in 0_u8..8,
4175        ) {
4176            let mut graph = Graph::strict();
4177            for (left_id, right_id) in ops {
4178                let left = node_name(left_id);
4179                let right = node_name(right_id);
4180                prop_assert!(graph.add_edge(left, right).is_ok());
4181            }
4182
4183            let target = node_name(target_id);
4184            let removed = graph.remove_node(&target);
4185            if removed {
4186                prop_assert!(!graph.has_node(&target));
4187                for node in graph.nodes_ordered() {
4188                    let neighbors = graph
4189                        .neighbors(node)
4190                        .expect("graph nodes should always have adjacency buckets");
4191                    prop_assert!(!neighbors.contains(&target.as_str()));
4192                    prop_assert!(!graph.has_edge(node, &target));
4193                }
4194            }
4195            assert_graph_core_invariants(&graph);
4196        }
4197
4198        #[test]
4199        fn prop_decision_ledger_records_follow_schema(
4200            ops in prop::collection::vec((0_u8..8, 0_u8..8, 0_u8..4), 1..72),
4201        ) {
4202            let mut graph = Graph::strict();
4203            for (left_id, right_id, attrs_kind) in ops {
4204                let left = node_name(left_id);
4205                let right = node_name(right_id);
4206                let mut attrs = AttrMap::new();
4207                match attrs_kind {
4208                    0 => {}
4209                    1 => {
4210                        attrs.insert("weight".to_owned(), (left_id % 9).to_string().into());
4211                    }
4212                    2 => {
4213                        attrs.insert("color".to_owned(), format!("c{}", right_id % 6).into());
4214                    }
4215                    _ => {
4216                        attrs.insert("__fnx_incompatible_decoder".to_owned(), "v2".into());
4217                    }
4218                }
4219                let _ = graph.add_edge_with_attrs(left, right, attrs);
4220            }
4221
4222            let records = graph.evidence_ledger().records();
4223            prop_assert!(!records.is_empty());
4224            for record in records {
4225                assert_decision_record_schema(record, CompatibilityMode::Strict);
4226                if record.operation == "add_node" {
4227                    prop_assert_eq!(record.action, DecisionAction::Allow);
4228                    prop_assert!(record.evidence.iter().any(|term| term.signal == "node_preexisting"));
4229                    prop_assert!(record.evidence.iter().any(|term| term.signal == "attrs_count"));
4230                } else {
4231                    prop_assert_eq!(&record.operation, "add_edge");
4232                    if record.action == DecisionAction::FailClosed {
4233                        prop_assert!(
4234                            record
4235                                .evidence
4236                                .iter()
4237                                .any(|term| term.signal == "unknown_incompatible_feature")
4238                        );
4239                    } else {
4240                        prop_assert_eq!(record.action, DecisionAction::Allow);
4241                        // add_edge records two decisions: a pre-check (only
4242                        // unknown_incompatible_feature) and a post-check (with
4243                        // self_loop, edge_attr_count, etc.). Both are valid.
4244                        let is_precheck = record
4245                            .evidence
4246                            .iter()
4247                            .any(|term| term.signal == "unknown_incompatible_feature")
4248                            && !record
4249                                .evidence
4250                                .iter()
4251                                .any(|term| term.signal == "edge_attr_count");
4252                        if !is_precheck {
4253                            prop_assert!(record.evidence.iter().any(|term| term.signal == "edge_attr_count"));
4254                            prop_assert!(record.evidence.iter().any(|term| term.signal == "self_loop"));
4255                        }
4256                    }
4257                }
4258            }
4259        }
4260    }
4261}