-
Notifications
You must be signed in to change notification settings - Fork 195
Bugfix/json preserves node gaps #1517
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c9a9bdf
14ec240
f870d26
31db01e
922e1a5
388ad00
7d1e516
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -83,22 +83,70 @@ pub fn parse_node_link_data<Ty: EdgeType>( | |||||
| out_graph: &mut StablePyGraph<Ty>, | ||||||
| node_attrs: Option<PyObject>, | ||||||
| edge_attrs: Option<PyObject>, | ||||||
| ) -> PyResult<()> { | ||||||
| ) -> PyResult<bool> { | ||||||
| let mut id_mapping: HashMap<usize, NodeIndex> = HashMap::with_capacity(graph.nodes.len()); | ||||||
| for node in graph.nodes { | ||||||
| let payload = match node.data { | ||||||
| Some(data) => match node_attrs { | ||||||
| Some(ref callback) => callback.call1(*py, (data,))?, | ||||||
| None => data.into_py_any(*py)?, | ||||||
| }, | ||||||
| None => py.None(), | ||||||
| }; | ||||||
| let id = out_graph.add_node(payload); | ||||||
| match node.id { | ||||||
| Some(input_id) => id_mapping.insert(input_id, id), | ||||||
| None => id_mapping.insert(id.index(), id), | ||||||
| }; | ||||||
| } | ||||||
|
|
||||||
| // Check if nodes have explicit IDs that need preservation | ||||||
| let preserve_ids = graph.nodes.iter().any(|n| n.id.is_some()); | ||||||
|
|
||||||
| let node_removed = if preserve_ids { | ||||||
| // Find the maximum node ID to determine how many placeholder nodes we need | ||||||
| let max_id = graph.nodes.iter().filter_map(|n| n.id).max().unwrap_or(0); | ||||||
|
|
||||||
| // Create placeholder nodes up to max_id | ||||||
| let mut tmp_nodes: Vec<NodeIndex> = Vec::new(); | ||||||
| for _ in 0..=max_id { | ||||||
| let idx = out_graph.add_node(py.None()); | ||||||
| tmp_nodes.push(idx); | ||||||
| } | ||||||
|
|
||||||
| // Replace placeholder nodes with actual data and track which to keep | ||||||
| for node in graph.nodes { | ||||||
| let payload = match node.data { | ||||||
| Some(data) => match node_attrs { | ||||||
| Some(ref callback) => callback.call1(*py, (data,))?, | ||||||
| None => data.into_py_any(*py)?, | ||||||
| }, | ||||||
| None => py.None(), | ||||||
| }; | ||||||
| let node_id = node.id.unwrap_or(0); | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
I don't think we should default to 0 if it is not defined. |
||||||
| let idx = NodeIndex::new(node_id); | ||||||
|
|
||||||
| // Replace the placeholder with actual data | ||||||
| if let Some(weight) = out_graph.node_weight_mut(idx) { | ||||||
| *weight = payload; | ||||||
| } | ||||||
|
|
||||||
| id_mapping.insert(node_id, idx); | ||||||
| // Mark this index as used (remove from tmp_nodes) | ||||||
| tmp_nodes.retain(|&n| n != idx); | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This can lead to quadratic behaviour, we are looping through all Please use https://docs.rs/hashbrown/latest/hashbrown/struct.HashSet.html instead |
||||||
| } | ||||||
|
|
||||||
| // Track if we're removing any nodes (indicates gaps in indices) | ||||||
| let has_gaps = !tmp_nodes.is_empty(); | ||||||
|
|
||||||
| // Remove remaining placeholder nodes | ||||||
| for tmp_node in tmp_nodes { | ||||||
| out_graph.remove_node(tmp_node); | ||||||
| } | ||||||
|
|
||||||
| has_gaps | ||||||
| } else { | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit picking: can you swap the if/else order? i.e. use This is a style choice but I prefer to have the most common case first in if statements. |
||||||
| // No explicit IDs, just add nodes sequentially (legacy behavior) | ||||||
| for node in graph.nodes { | ||||||
| let payload = match node.data { | ||||||
| Some(data) => match node_attrs { | ||||||
| Some(ref callback) => callback.call1(*py, (data,))?, | ||||||
| None => data.into_py_any(*py)?, | ||||||
| }, | ||||||
| None => py.None(), | ||||||
| }; | ||||||
| let id = out_graph.add_node(payload); | ||||||
| id_mapping.insert(id.index(), id); | ||||||
| } | ||||||
| false | ||||||
| }; | ||||||
|
|
||||||
| for edge in graph.links { | ||||||
| let data = match edge.data { | ||||||
| Some(data) => match edge_attrs { | ||||||
|
|
@@ -109,7 +157,7 @@ pub fn parse_node_link_data<Ty: EdgeType>( | |||||
| }; | ||||||
| out_graph.add_edge(id_mapping[&edge.source], id_mapping[&edge.target], data); | ||||||
| } | ||||||
| Ok(()) | ||||||
| Ok(node_removed) | ||||||
| } | ||||||
|
|
||||||
| #[allow(clippy::too_many_arguments)] | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,3 +39,46 @@ def test_weight_graph(self): | |
| self.assertEqual([1, 2, 3], gprime.node_indices()) | ||
| self.assertEqual(["B", "C", "D"], gprime.nodes()) | ||
| self.assertEqual({1: (1, 2, "B -> C"), 3: (3, 1, "D -> B")}, dict(gprime.edge_index_map())) | ||
|
|
||
| def test_contracted_nodes_pickle(self): | ||
| """Test pickle/unpickle of graphs with contracted nodes (issue #1503)""" | ||
| g = rx.PyGraph() | ||
| g.add_node("A") # Node 0 | ||
| g.add_node("B") # Node 1 | ||
| g.add_node("C") # Node 2 | ||
|
|
||
| # Contract nodes 0 and 1 into a new node | ||
| contracted_idx = g.contract_nodes([0, 1], "AB") | ||
| g.add_edge(2, contracted_idx, "C -> AB") | ||
|
|
||
| # Verify initial state | ||
| self.assertEqual([2, contracted_idx], g.node_indices()) | ||
| self.assertEqual([(2, contracted_idx)], g.edge_list()) | ||
|
|
||
| # Test pickle/unpickle | ||
| gprime = pickle.loads(pickle.dumps(g)) | ||
|
|
||
| # Verify the unpickled graph matches | ||
| self.assertEqual(g.node_indices(), gprime.node_indices()) | ||
| self.assertEqual(g.edge_list(), gprime.edge_list()) | ||
| self.assertEqual(g.nodes(), gprime.nodes()) | ||
|
|
||
| def test_contracted_nodes_with_weights_pickle(self): | ||
| """Test pickle/unpickle of graphs with contracted nodes and edge weights""" | ||
| g = rx.PyGraph() | ||
| g.add_nodes_from(["Node0", "Node1", "Node2", "Node3"]) | ||
| g.add_edges_from([(0, 2, "edge_0_2"), (1, 3, "edge_1_3")]) | ||
|
|
||
| # Contract multiple nodes | ||
| contracted_idx = g.contract_nodes([0, 1], "Contracted_0_1") | ||
| g.add_edge(contracted_idx, 2, "contracted_to_2") | ||
| g.add_edge(3, contracted_idx, "3_to_contracted") | ||
|
|
||
| # Test pickle/unpickle | ||
| gprime = pickle.loads(pickle.dumps(g)) | ||
|
|
||
| # Verify complete graph state is preserved | ||
| self.assertEqual(g.node_indices(), gprime.node_indices()) | ||
| self.assertEqual(g.edge_list(), gprime.edge_list()) | ||
| self.assertEqual(g.nodes(), gprime.nodes()) | ||
| self.assertEqual(dict(g.edge_index_map()), dict(gprime.edge_index_map())) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Of course you copied examples that don't use it but: https://docs.python.org/3/library/unittest.html#unittest.TestCase.assertDictEqual This just has a nicer error message, in case someone ever introduces a bug |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe a more idiomatic way of writing this:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This suggestion also works with
HashSetby the way