diff --git a/docs/source/api/algorithm_functions/dominance.rst b/docs/source/api/algorithm_functions/dominance.rst index d711e89a7..368ea2641 100644 --- a/docs/source/api/algorithm_functions/dominance.rst +++ b/docs/source/api/algorithm_functions/dominance.rst @@ -7,3 +7,4 @@ Dominance :toctree: ../../apiref rustworkx.immediate_dominators + rustworkx.dominance_frontiers diff --git a/releasenotes/notes/dominance-frontiers-6e3dcd59e9201b24.yaml b/releasenotes/notes/dominance-frontiers-6e3dcd59e9201b24.yaml new file mode 100644 index 000000000..947537bd3 --- /dev/null +++ b/releasenotes/notes/dominance-frontiers-6e3dcd59e9201b24.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Add the :func:`rustworkx.dominance_frontiers` function to compute + the dominance frontiers of all nodes in a directed graph. + This function mirrors the ``networkx.dominance_frontiers`` function. diff --git a/rustworkx/__init__.pyi b/rustworkx/__init__.pyi index 7a7af8be4..5952e177e 100644 --- a/rustworkx/__init__.pyi +++ b/rustworkx/__init__.pyi @@ -243,6 +243,7 @@ from .rustworkx import metric_closure as metric_closure from .rustworkx import digraph_union as digraph_union from .rustworkx import graph_union as graph_union from .rustworkx import immediate_dominators as immediate_dominators +from .rustworkx import dominance_frontiers as dominance_frontiers from .rustworkx import NodeIndices as NodeIndices from .rustworkx import PathLengthMapping as PathLengthMapping from .rustworkx import PathMapping as PathMapping diff --git a/rustworkx/rustworkx.pyi b/rustworkx/rustworkx.pyi index 1655a759e..4a18edd61 100644 --- a/rustworkx/rustworkx.pyi +++ b/rustworkx/rustworkx.pyi @@ -1055,6 +1055,7 @@ def graph_union( # Dominance def immediate_dominators(graph: PyDiGraph[_S, _T], start_node: int, /) -> dict[int, int]: ... +def dominance_frontiers(graph: PyDiGraph[_S, _T], start_node: int, /) -> dict[int, set[int]]: ... # Iterators diff --git a/src/dominance.rs b/src/dominance.rs index 2dd31e0f1..3f1dcc5a4 100644 --- a/src/dominance.rs +++ b/src/dominance.rs @@ -13,6 +13,8 @@ use super::{digraph, InvalidNode, NullGraph}; use rustworkx_core::dictmap::DictMap; +use hashbrown::HashSet; + use petgraph::algo::dominators; use petgraph::graph::NodeIndex; @@ -58,3 +60,53 @@ pub fn immediate_dominators( }); Ok(root_dom.into_iter().chain(others_dom).collect()) } + +/// Compute the dominance frontiers of all nodes in a directed graph. +/// +/// The dominance and dominance frontiers computations use the +/// algorithms published in 2006 by Cooper, Harvey, and Kennedy +/// (https://hdl.handle.net/1911/96345). +/// +/// :param PyDiGraph graph: directed graph +/// :param int start_node: the start node for the dominance computation +/// +/// :returns: a mapping of node indices to their dominance frontiers +/// :rtype: dict[int, set[int]] +/// +/// :raises NullGraph: the passed graph is empty +/// :raises InvalidNode: the start node is not in the graph +#[pyfunction] +#[pyo3(text_signature = "(graph, start_node, /)")] +pub fn dominance_frontiers( + graph: &digraph::PyDiGraph, + start_node: usize, +) -> PyResult>> { + let idom = immediate_dominators(graph, start_node)?; + + let mut df: DictMap<_, _> = idom + .iter() + .map(|(&node, _)| (node, HashSet::default())) + .collect(); + + for (&node, &node_idom) in &idom { + let preds = graph.predecessor_indices(node); + if preds.nodes.len() >= 2 { + for mut runner in preds.nodes { + while runner != node_idom { + df.entry(runner) + .and_modify(|e| { + e.insert(node); + }) + .or_insert([node].into_iter().collect()); + if let Some(&runner_idom) = idom.get(&runner) { + runner = runner_idom; + } else { + break; + } + } + } + } + } + + Ok(df) +} diff --git a/src/lib.rs b/src/lib.rs index 45a9629d6..16c421453 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -467,6 +467,7 @@ fn rustworkx(py: Python<'_>, m: &Bound) -> PyResult<()> { m.add_wrapped(wrap_pyfunction!(digraph_union))?; m.add_wrapped(wrap_pyfunction!(graph_union))?; m.add_wrapped(wrap_pyfunction!(immediate_dominators))?; + m.add_wrapped(wrap_pyfunction!(dominance_frontiers))?; m.add_wrapped(wrap_pyfunction!(digraph_maximum_bisimulation))?; m.add_wrapped(wrap_pyfunction!(digraph_cartesian_product))?; m.add_wrapped(wrap_pyfunction!(graph_cartesian_product))?; diff --git a/tests/digraph/test_dominance.py b/tests/digraph/test_dominance.py index ed37a5496..33e1ed6a4 100644 --- a/tests/digraph/test_dominance.py +++ b/tests/digraph/test_dominance.py @@ -137,3 +137,209 @@ def test_boost_example(self): self.assertDictEqual(result, {0: 1, 1: 7, 2: 7, 3: 4, 4: 5, 5: 7, 6: 4, 7: 7}) self.assertDictEqual(nx.immediate_dominators(nx_graph.reverse(copy=False), 7), result) + + +class TestDominanceFrontiers(unittest.TestCase): + """ + Test `rustworkx.dominance_frontiers`. + + Test cases adapted from `networkx`: + https://github.com/networkx/networkx/blob/9c5ca54b7e5310a21568bb2e0104f8c87bf74ff7/networkx/algorithms/tests/test_dominance.py + (Copyright 2004-2024 NetworkX Developers, 3-clause BSD License) + """ + + def test_empty(self): + """ + Edge case: empty graph. + """ + graph = rx.PyDiGraph() + + with self.assertRaises(rx.NullGraph): + rx.dominance_frontiers(graph, 0) + + def test_start_node_not_in_graph(self): + """ + Edge case: start_node is not in the graph. + """ + graph = rx.PyDiGraph() + graph.add_node(0) + + self.assertEqual(list(graph.node_indices()), [0]) + + with self.assertRaises(rx.InvalidNode): + rx.dominance_frontiers(graph, 1) + + def test_singleton(self): + """ + Edge cases: single node, optionally cyclic. + """ + graph = rx.PyDiGraph() + graph.add_node(0) + self.assertDictEqual(rx.dominance_frontiers(graph, 0), {0: set()}) + + graph.add_edge(0, 0, None) + self.assertDictEqual(rx.dominance_frontiers(graph, 0), {0: set()}) + + def test_irreducible1(self): + """ + Graph taken from figure 2 of "A simple, fast dominance algorithm." (2006). + https://hdl.handle.net/1911/96345 + """ + edges = [(1, 2), (2, 1), (3, 2), (4, 1), (5, 3), (5, 4)] + graph = rx.PyDiGraph() + graph.add_node(0) + graph.extend_from_edge_list(edges) + + result = rx.dominance_frontiers(graph, 5) + self.assertDictEqual(result, {1: {2}, 2: {1}, 3: {2}, 4: {1}, 5: set()}) + + nx_graph = nx.DiGraph() + nx_graph.add_edges_from(graph.edge_list()) + self.assertDictEqual(nx.dominance_frontiers(nx_graph, 5), result) + + def test_irreducible2(self): + """ + Graph taken from figure 4 of "A simple, fast dominance algorithm." (2006). + https://hdl.handle.net/1911/96345 + """ + edges = [(1, 2), (2, 1), (2, 3), (3, 2), (4, 2), (4, 3), (5, 1), (6, 4), (6, 5)] + graph = rx.PyDiGraph() + graph.add_node(0) + graph.extend_from_edge_list(edges) + + result = rx.dominance_frontiers(graph, 6) + + self.assertDictEqual( + result, + { + 1: {2}, + 2: {1, 3}, + 3: {2}, + 4: {2, 3}, + 5: {1}, + 6: set(), + }, + ) + + nx_graph = nx.DiGraph() + nx_graph.add_edges_from(graph.edge_list()) + self.assertDictEqual(nx.dominance_frontiers(nx_graph, 6), result) + + def test_domrel_png(self): + """ + Graph taken from https://commons.wikipedia.org/wiki/File:Domrel.png + """ + edges = [(1, 2), (2, 3), (2, 4), (2, 6), (3, 5), (4, 5), (5, 2)] + graph = rx.PyDiGraph() + graph.add_node(0) + graph.extend_from_edge_list(edges) + + result = rx.dominance_frontiers(graph, 1) + + self.assertDictEqual( + result, + { + 1: set(), + 2: {2}, + 3: {5}, + 4: {5}, + 5: {2}, + 6: set(), + }, + ) + + nx_graph = nx.DiGraph() + nx_graph.add_edges_from(graph.edge_list()) + self.assertDictEqual(nx.dominance_frontiers(nx_graph, 1), result) + + # Test postdominance. + graph.reverse() + result = rx.dominance_frontiers(graph, 6) + self.assertDictEqual( + result, + { + 1: set(), + 2: {2}, + 3: {2}, + 4: {2}, + 5: {2}, + 6: set(), + }, + ) + + self.assertDictEqual(nx.dominance_frontiers(nx_graph.reverse(copy=False), 6), result) + + def test_boost_example(self): + """ + Graph taken from Figure 1 of + http://www.boost.org/doc/libs/1_56_0/libs/graph/doc/lengauer_tarjan_dominator.htm + """ + edges = [(0, 1), (1, 2), (1, 3), (2, 7), (3, 4), (4, 5), (4, 6), (5, 7), (6, 4)] + graph = rx.PyDiGraph() + graph.extend_from_edge_list(edges) + + nx_graph = nx.DiGraph() + nx_graph.add_edges_from(graph.edge_list()) + + result = rx.dominance_frontiers(graph, 0) + self.assertDictEqual( + result, + { + 0: set(), + 1: set(), + 2: {7}, + 3: {7}, + 4: {4, 7}, + 5: {7}, + 6: {4}, + 7: set(), + }, + ) + + self.assertDictEqual(nx.dominance_frontiers(nx_graph, 0), result) + + # Test postdominance + graph.reverse() + result = rx.dominance_frontiers(graph, 7) + self.assertDictEqual( + result, + { + 0: set(), + 1: set(), + 2: {1}, + 3: {1}, + 4: {1, 4}, + 5: {1}, + 6: {4}, + 7: set(), + }, + ) + + self.assertDictEqual(nx.dominance_frontiers(nx_graph.reverse(copy=False), 7), result) + + def test_missing_immediate_doms(self): + """ + Test that the `dominance_frontiers` function doesn't regress on + https://github.com/networkx/networkx/issues/2070 + """ + edges = [(0, 1), (1, 2), (2, 3), (3, 4), (5, 3)] + graph = rx.PyDiGraph() + graph.extend_from_edge_list(edges) + + idom = rx.immediate_dominators(graph, 0) + self.assertNotIn(5, idom) + + # In networkx#2070, the call would fail because node 5 + # has no immediate dominators + result = rx.dominance_frontiers(graph, 0) + self.assertDictEqual( + result, + { + 0: set(), + 1: set(), + 2: set(), + 3: set(), + 4: set(), + 5: {3}, + }, + )