Skip to content

Commit 3597572

Browse files
authored
Expose can_contract_without_cycle (#1459)
* expose can-contract-without-cycle function * add tests for can_contract_without_cycle & make it non static * fix doc test * update release note * revert can_contract_without_cycle to can_contract
1 parent ac1082c commit 3597572

File tree

7 files changed

+119
-5
lines changed

7 files changed

+119
-5
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
features:
3+
- |
4+
Exposed the `can_contract_without_cycle` method on `PyDiGraph` object. This method enables users to check, prior to contraction, whether merging a set of nodes into a single node would introduce a cycle in the graph. This is especially useful for applications that require maintaining acyclic properties, such as working with DAGs.

rustworkx-core/src/graph_ext/contraction.rs

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -498,8 +498,44 @@ where
498498

499499
Ok(node_index)
500500
}
501-
502-
fn can_contract<G>(graph: G, nodes: &IndexSet<G::NodeId, foldhash::fast::RandomState>) -> bool
501+
/// Check if a set of nodes in a directed graph can be contracted without introducing a cycle.
502+
///
503+
/// This function determines whether contracting the given set of nodes into a single node
504+
/// would introduce a cycle in the graph. It is typically used to check the feasibility of the contraction before performing
505+
/// the actual operation.
506+
///
507+
/// # Arguments
508+
///
509+
/// * `graph` - The graph to check.
510+
/// * `nodes` - A set of node indices to be contracted.
511+
///
512+
/// A bool whether the graph can be contracted based on the given nodes is returned.
513+
///
514+
/// # Example
515+
///
516+
/// ```rust
517+
/// use petgraph::stable_graph::StableDiGraph;
518+
/// use indexmap::IndexSet;
519+
/// use foldhash::fast::RandomState;
520+
/// use rustworkx_core::graph_ext::contraction::can_contract;
521+
///
522+
/// // Create a simple DAG: a -> b -> c
523+
/// let mut graph = StableDiGraph::<&str, ()>::default();
524+
/// let a = graph.add_node("a");
525+
/// let b = graph.add_node("b");
526+
/// let c = graph.add_node("c");
527+
/// graph.add_edge(a, b, ());
528+
/// graph.add_edge(b, c, ());
529+
///
530+
/// // Try to contract nodes b and c
531+
/// let mut nodes = IndexSet::with_hasher(RandomState::default());
532+
/// nodes.insert(b);
533+
/// nodes.insert(c);
534+
///
535+
/// let can_contract = can_contract(&graph, &nodes);
536+
/// assert!(can_contract); // true: contracting b and c does not introduce a cycle
537+
/// ```
538+
pub fn can_contract<G>(graph: G, nodes: &IndexSet<G::NodeId, foldhash::fast::RandomState>) -> bool
503539
where
504540
G: Data + Visitable + IntoEdgesDirected,
505541
G::NodeId: Eq + Hash,

rustworkx-core/src/graph_ext/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ pub mod contraction;
7777
pub mod multigraph;
7878

7979
pub use contraction::{
80-
ContractNodesDirected, ContractNodesSimpleDirected, ContractNodesSimpleUndirected,
81-
ContractNodesUndirected,
80+
can_contract, ContractNodesDirected, ContractNodesSimpleDirected,
81+
ContractNodesSimpleUndirected, ContractNodesUndirected,
8282
};
8383
pub use multigraph::{HasParallelEdgesDirected, HasParallelEdgesUndirected};
8484

rustworkx-core/tests/graph_ext/contraction.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,17 @@
1010
// License for the specific language governing permissions and limitations
1111
// under the License.
1212

13+
use foldhash::fast::RandomState;
1314
use foldhash::HashSet;
1415
use hashbrown::HashMap;
16+
use indexmap::IndexSet;
1517
use petgraph::data::Build;
18+
use petgraph::prelude::*;
1619
use petgraph::visit::{
1720
Data, EdgeCount, EdgeRef, GraphBase, IntoEdgeReferences, IntoNodeIdentifiers,
1821
};
1922
use rustworkx_core::err::ContractError;
23+
use rustworkx_core::graph_ext::contraction::can_contract;
2024
use rustworkx_core::graph_ext::*;
2125
use std::convert::Infallible;
2226
use std::fmt::Debug;
@@ -401,3 +405,36 @@ where
401405
assert_eq!(dag.node_count(), 1);
402406
assert_eq!(dag.edge_count(), 0);
403407
}
408+
409+
#[test]
410+
fn test_can_contract_without_cycle_true() {
411+
// a -> b -> c (contract b and c, should be allowed)
412+
let mut graph = StableDiGraph::<&str, ()>::default();
413+
let a = graph.add_node("a");
414+
let b = graph.add_node("b");
415+
let c = graph.add_node("c");
416+
graph.add_edge(a, b, ());
417+
graph.add_edge(b, c, ());
418+
let mut nodes: IndexSet<_, RandomState> = IndexSet::with_hasher(RandomState::default());
419+
nodes.insert(b);
420+
nodes.insert(c);
421+
422+
assert!(can_contract(&graph, &nodes));
423+
}
424+
425+
#[test]
426+
fn test_can_contract_without_cycle_false() {
427+
// a -> b -> c, c -> a (contract a and c, would create a cycle)
428+
let mut graph = StableDiGraph::<&str, ()>::default();
429+
let a = graph.add_node("a");
430+
let b = graph.add_node("b");
431+
let c = graph.add_node("c");
432+
graph.add_edge(a, b, ());
433+
graph.add_edge(b, c, ());
434+
graph.add_edge(c, a, ());
435+
let mut nodes: IndexSet<_, RandomState> = IndexSet::with_hasher(RandomState::default());
436+
nodes.insert(a);
437+
nodes.insert(c);
438+
439+
assert!(!can_contract(&graph, &nodes));
440+
}

rustworkx/rustworkx.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1426,6 +1426,7 @@ class PyDiGraph(Generic[_S, _T]):
14261426
check_cycle: bool | None = ...,
14271427
weight_combo_fn: Callable[[_T, _T], _T] | None = ...,
14281428
) -> int: ...
1429+
def can_contract_without_cycle(self, nodes: Sequence[int], /) -> bool: ...
14291430
def copy(self) -> Self: ...
14301431
def edge_index_map(self) -> EdgeIndexMap[_T]: ...
14311432
def edge_indices(self) -> EdgeIndices: ...

src/digraph.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,10 @@ use ndarray::prelude::*;
4141
use num_traits::Zero;
4242
use numpy::Complex64;
4343
use numpy::PyReadonlyArray2;
44-
4544
use petgraph::algo;
4645
use petgraph::graph::{EdgeIndex, NodeIndex};
4746
use petgraph::prelude::*;
47+
use rustworkx_core::graph_ext::contraction::can_contract;
4848

4949
use crate::RxPyResult;
5050
use petgraph::visit::{
@@ -60,6 +60,8 @@ use super::{
6060
find_node_by_weight, weight_callable, DAGHasCycle, DAGWouldCycle, IsNan, NoEdgeBetweenNodes,
6161
NoSuitableNeighbors, NodesRemoved, StablePyGraph,
6262
};
63+
use foldhash::fast::RandomState;
64+
use indexmap::IndexSet;
6365

6466
use super::dag_algo::is_directed_acyclic_graph;
6567

@@ -3013,6 +3015,17 @@ impl PyDiGraph {
30133015
Ok(res.index())
30143016
}
30153017

3018+
/// Check if contracting the specified nodes can occur without introducing cycles.
3019+
///
3020+
/// :param list[int] nodes: A set of node indices to check for contraction.
3021+
/// :returns: `True` if contraction can proceed without creating cycles, `False` otherwise.
3022+
#[pyo3(text_signature = "(self, nodes, /)",signature = (nodes))]
3023+
pub fn can_contract_without_cycle(&self, nodes: Vec<usize>) -> bool {
3024+
let index_set: IndexSet<NodeIndex, RandomState> =
3025+
nodes.into_iter().map(NodeIndex::new).collect();
3026+
can_contract(&self.graph, &index_set)
3027+
}
3028+
30163029
/// Return a new PyDiGraph object for a subgraph of this graph and a NodeMap
30173030
/// object that maps the nodes of the subgraph to the nodes of the original graph.
30183031
///

tests/digraph/test_contract_nodes.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,3 +263,26 @@ def test_replace_all_nodes(self):
263263
self.dag.contract_nodes(self.dag.node_indexes(), "m")
264264
self.assertEqual(set(self.dag.nodes()), {"m"})
265265
self.assertFalse(self.dag.edges())
266+
267+
268+
class TestCanContractWithoutCycle(unittest.TestCase):
269+
def test_can_contract_without_cycle_true(self):
270+
# a -> b -> c (contract b and c, should be allowed)
271+
graph = rustworkx.PyDiGraph()
272+
a = graph.add_node("a")
273+
b = graph.add_node("b")
274+
c = graph.add_node("c")
275+
graph.add_edge(a, b, 0)
276+
graph.add_edge(b, c, 0)
277+
self.assertTrue(graph.can_contract_without_cycle([b, c]))
278+
279+
def test_can_contract_without_cycle_false(self):
280+
# a -> b -> c, c -> a (contract a and c, would create a cycle)
281+
graph = rustworkx.PyDiGraph()
282+
a = graph.add_node("a")
283+
b = graph.add_node("b")
284+
c = graph.add_node("c")
285+
graph.add_edge(a, b, 0)
286+
graph.add_edge(b, c, 0)
287+
graph.add_edge(c, a, 0)
288+
self.assertFalse(graph.can_contract_without_cycle([a, c]))

0 commit comments

Comments
 (0)