diff --git a/releasenotes/notes/all-simple-paths-self-loops-4d5e9a247d39d6a4.yaml b/releasenotes/notes/all-simple-paths-self-loops-4d5e9a247d39d6a4.yaml new file mode 100644 index 0000000000..3c2bd06cba --- /dev/null +++ b/releasenotes/notes/all-simple-paths-self-loops-4d5e9a247d39d6a4.yaml @@ -0,0 +1,7 @@ +fixes: + - | + Fixed :func:`~rustworkx.all_simple_paths` so a self-loop is returned as a + two-node path when its source is also the requested target. This restores + the behavior from 0.17.1 for both :class:`~rustworkx.PyGraph` and + :class:`~rustworkx.PyDiGraph`. Refer to + `#1617 `__ for more details. diff --git a/src/connectivity/mod.rs b/src/connectivity/mod.rs index b6d05e1006..154e1c79cf 100644 --- a/src/connectivity/mod.rs +++ b/src/connectivity/mod.rs @@ -1086,6 +1086,16 @@ pub fn graph_all_simple_paths( )); } + // Preserve the legacy behavior of emitting a self-loop as a + // two-node path. petgraph's iterator filters the target because + // it is already visited before it can yield this path. + if from_index == to_index && min_intermediate_nodes == 0 { + let self_loop_count = graph.graph.edges_connecting(from_index, to_index).count(); + if self_loop_count > 0 { + return Ok(vec![vec![origin, origin]; self_loop_count]); + } + } + let result: Vec> = algo::all_simple_paths::, _, foldhash::fast::RandomState>( &graph.graph, @@ -1099,7 +1109,7 @@ pub fn graph_all_simple_paths( Ok(result) } TargetNodes::Multiple(target_set) => { - let result: Vec> = + let mut result: Vec> = algo::all_simple_paths_multi::, _, foldhash::fast::RandomState>( &graph.graph, from_index, @@ -1109,6 +1119,10 @@ pub fn graph_all_simple_paths( ) .map(|v: Vec| v.into_iter().map(|i| i.index()).collect()) .collect(); + if min_intermediate_nodes == 0 && target_set.contains(&from_index) { + let self_loop_count = graph.graph.edges_connecting(from_index, from_index).count(); + result.extend(vec![vec![origin, origin]; self_loop_count]); + } Ok(result) } } @@ -1159,6 +1173,16 @@ pub fn digraph_all_simple_paths( )); } + // Preserve the legacy behavior of emitting a self-loop as a + // two-node path. petgraph's iterator filters the target because + // it is already visited before it can yield this path. + if from_index == to_index && min_intermediate_nodes == 0 { + let self_loop_count = graph.graph.edges_connecting(from_index, to_index).count(); + if self_loop_count > 0 { + return Ok(vec![vec![origin, origin]; self_loop_count]); + } + } + let result: Vec> = algo::all_simple_paths::, _, foldhash::fast::RandomState>( &graph.graph, @@ -1172,7 +1196,7 @@ pub fn digraph_all_simple_paths( Ok(result) } TargetNodes::Multiple(target_set) => { - let result: Vec> = + let mut result: Vec> = algo::all_simple_paths_multi::, _, foldhash::fast::RandomState>( &graph.graph, from_index, @@ -1182,6 +1206,10 @@ pub fn digraph_all_simple_paths( ) .map(|v: Vec| v.into_iter().map(|i| i.index()).collect()) .collect(); + if min_intermediate_nodes == 0 && target_set.contains(&from_index) { + let self_loop_count = graph.graph.edges_connecting(from_index, from_index).count(); + result.extend(vec![vec![origin, origin]; self_loop_count]); + } Ok(result) } } diff --git a/tests/digraph/test_all_simple_paths.py b/tests/digraph/test_all_simple_paths.py index 002fc257d2..f1cf39dd7e 100644 --- a/tests/digraph/test_all_simple_paths.py +++ b/tests/digraph/test_all_simple_paths.py @@ -118,6 +118,39 @@ def test_all_simple_path_no_path(self): dag.add_node(1) self.assertEqual([], rustworkx.digraph_all_simple_paths(dag, 0, 1)) + def test_all_simple_paths_self_loop(self): + graph = rustworkx.PyDiGraph() + node = graph.add_node("A") + graph.add_edge(node, node, {"label": "self"}) + + self.assertEqual([[node, node]], rustworkx.digraph_all_simple_paths(graph, node, node)) + + def test_all_simple_paths_self_loop_is_not_revisited(self): + graph = rustworkx.PyDiGraph() + source = graph.add_node("A") + target = graph.add_node("B") + graph.add_edges_from_no_data([(source, target), (target, target)]) + + self.assertEqual( + [[source, target]], rustworkx.digraph_all_simple_paths(graph, source, target) + ) + + def test_all_simple_paths_self_loop_respects_min_depth(self): + graph = rustworkx.PyDiGraph() + node = graph.add_node("A") + graph.add_edge(node, node, {"label": "self"}) + + self.assertEqual([], rustworkx.digraph_all_simple_paths(graph, node, node, min_depth=3)) + + def test_all_simple_paths_multiple_targets_includes_self_loop(self): + graph = rustworkx.PyDiGraph() + source = graph.add_node("A") + target = graph.add_node("B") + graph.add_edges_from_no_data([(source, source), (source, target)]) + + paths = rustworkx.digraph_all_simple_paths(graph, source, [source, target]) + self.assertCountEqual([[source, source], [source, target]], paths) + def test_all_simple_path_invalid_node_index(self): dag = rustworkx.PyDAG() dag.add_node(0) diff --git a/tests/graph/test_all_simple_paths.py b/tests/graph/test_all_simple_paths.py index d1ba33e326..5dfe9ac752 100644 --- a/tests/graph/test_all_simple_paths.py +++ b/tests/graph/test_all_simple_paths.py @@ -219,6 +219,13 @@ def test_all_simple_path_no_path(self): dag.add_node(1) self.assertEqual([], rustworkx.graph_all_simple_paths(dag, 0, 1)) + def test_all_simple_paths_self_loop(self): + graph = rustworkx.PyGraph() + node = graph.add_node("A") + graph.add_edge(node, node, {"label": "self"}) + + self.assertEqual([[node, node]], rustworkx.graph_all_simple_paths(graph, node, node)) + def test_all_simple_path_invalid_node_index(self): dag = rustworkx.PyGraph() dag.add_node(0)