differt-core 0.5.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. {differt_core-0.5.0 → differt_core-0.6.0}/Cargo.lock +1 -1
  2. {differt_core-0.5.0 → differt_core-0.6.0}/PKG-INFO +2 -3
  3. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/Cargo.toml +3 -2
  4. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/python/differt_core/_differt_core/rt/graph.pyi +3 -0
  5. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/src/rt/graph.rs +205 -4
  6. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/tests/rt/test_graph.py +71 -0
  7. {differt_core-0.5.0 → differt_core-0.6.0}/pyproject.toml +1 -2
  8. {differt_core-0.5.0 → differt_core-0.6.0}/python/differt_core/_differt_core/rt/graph.pyi +3 -0
  9. {differt_core-0.5.0 → differt_core-0.6.0}/src/rt/graph.rs +205 -4
  10. {differt_core-0.5.0 → differt_core-0.6.0}/Cargo.toml +0 -0
  11. {differt_core-0.5.0 → differt_core-0.6.0}/LICENSE.md +0 -0
  12. {differt_core-0.5.0 → differt_core-0.6.0}/README.md +0 -0
  13. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/LICENSE.md +0 -0
  14. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/README.md +0 -0
  15. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/benches/bench_main.rs +0 -0
  16. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/benches/benchmarks/graph_iterators.rs +0 -0
  17. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/benches/benchmarks/mod.rs +0 -0
  18. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/python/differt_core/__init__.py +0 -0
  19. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/python/differt_core/_differt_core/__init__.pyi +0 -0
  20. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/python/differt_core/_differt_core/geometry/triangle_mesh.pyi +0 -0
  21. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/python/differt_core/_differt_core/rt/__init__.pyi +0 -0
  22. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/python/differt_core/_differt_core/scene/sionna.pyi +0 -0
  23. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/python/differt_core/_differt_core/scene/triangle_scene.pyi +0 -0
  24. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/python/differt_core/geometry/__init__.py +0 -0
  25. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/python/differt_core/geometry/_triangle_mesh.py +0 -0
  26. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/python/differt_core/py.typed +0 -0
  27. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/python/differt_core/rt/__init__.py +0 -0
  28. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/python/differt_core/rt/_graph.py +0 -0
  29. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/python/differt_core/scene/__init__.py +0 -0
  30. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/python/differt_core/scene/_sionna.py +0 -0
  31. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/python/differt_core/scene/_triangle_scene.py +0 -0
  32. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/src/geometry/mod.rs +0 -0
  33. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/src/geometry/triangle_mesh.rs +0 -0
  34. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/src/lib.rs +0 -0
  35. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/src/rt/mod.rs +0 -0
  36. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/src/scene/mod.rs +0 -0
  37. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/src/scene/sionna.rs +0 -0
  38. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/src/scene/triangle_scene.rs +0 -0
  39. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/tests/__init__.py +0 -0
  40. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/tests/rt/__init__.py +0 -0
  41. {differt_core-0.5.0 → differt_core-0.6.0}/differt-core/tests/test_version.py +0 -0
  42. {differt_core-0.5.0 → differt_core-0.6.0}/python/differt_core/__init__.py +0 -0
  43. {differt_core-0.5.0 → differt_core-0.6.0}/python/differt_core/_differt_core/__init__.pyi +0 -0
  44. {differt_core-0.5.0 → differt_core-0.6.0}/python/differt_core/_differt_core/geometry/triangle_mesh.pyi +0 -0
  45. {differt_core-0.5.0 → differt_core-0.6.0}/python/differt_core/_differt_core/rt/__init__.pyi +0 -0
  46. {differt_core-0.5.0 → differt_core-0.6.0}/python/differt_core/_differt_core/scene/sionna.pyi +0 -0
  47. {differt_core-0.5.0 → differt_core-0.6.0}/python/differt_core/_differt_core/scene/triangle_scene.pyi +0 -0
  48. {differt_core-0.5.0 → differt_core-0.6.0}/python/differt_core/geometry/__init__.py +0 -0
  49. {differt_core-0.5.0 → differt_core-0.6.0}/python/differt_core/geometry/_triangle_mesh.py +0 -0
  50. {differt_core-0.5.0 → differt_core-0.6.0}/python/differt_core/py.typed +0 -0
  51. {differt_core-0.5.0 → differt_core-0.6.0}/python/differt_core/rt/__init__.py +0 -0
  52. {differt_core-0.5.0 → differt_core-0.6.0}/python/differt_core/rt/_graph.py +0 -0
  53. {differt_core-0.5.0 → differt_core-0.6.0}/python/differt_core/scene/__init__.py +0 -0
  54. {differt_core-0.5.0 → differt_core-0.6.0}/python/differt_core/scene/_sionna.py +0 -0
  55. {differt_core-0.5.0 → differt_core-0.6.0}/python/differt_core/scene/_triangle_scene.py +0 -0
  56. {differt_core-0.5.0 → differt_core-0.6.0}/src/geometry/mod.rs +0 -0
  57. {differt_core-0.5.0 → differt_core-0.6.0}/src/geometry/triangle_mesh.rs +0 -0
  58. {differt_core-0.5.0 → differt_core-0.6.0}/src/lib.rs +0 -0
  59. {differt_core-0.5.0 → differt_core-0.6.0}/src/rt/mod.rs +0 -0
  60. {differt_core-0.5.0 → differt_core-0.6.0}/src/scene/mod.rs +0 -0
  61. {differt_core-0.5.0 → differt_core-0.6.0}/src/scene/sionna.rs +0 -0
  62. {differt_core-0.5.0 → differt_core-0.6.0}/src/scene/triangle_scene.rs +0 -0
@@ -247,7 +247,7 @@ checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929"
247
247
 
248
248
  [[package]]
249
249
  name = "differt-core"
250
- version = "0.5.0"
250
+ version = "0.6.0"
251
251
  dependencies = [
252
252
  "criterion",
253
253
  "indexmap",
@@ -1,8 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: differt-core
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Classifier: Programming Language :: Python :: 3
5
- Classifier: Programming Language :: Python :: 3.10
6
5
  Classifier: Programming Language :: Python :: 3.11
7
6
  Classifier: Programming Language :: Python :: 3.12
8
7
  Classifier: Programming Language :: Python :: 3.13
@@ -17,7 +16,7 @@ License-File: LICENSE.md
17
16
  Summary: Core backend of DiffeRT implemented in Rust
18
17
  Keywords: ray tracing,differentiable,propagation,radio,jax
19
18
  Author-email: Jérome Eertmans <jeertmans@icloud.com>
20
- Requires-Python: >=3.10
19
+ Requires-Python: >=3.11
21
20
  Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
22
21
 
23
22
  <div align="center">
@@ -10,7 +10,7 @@ ndarray = {version = "0.16", features = ["rayon"]}
10
10
  numpy = "0.25"
11
11
  obj-rs = "0.7.1"
12
12
  ply-rs = "0.1.3"
13
- pyo3 = {version = "0.25", features = ["abi3-py310", "indexmap", "generate-import-lib"]}
13
+ pyo3 = {version = "0.25", features = ["abi3-py311", "indexmap", "generate-import-lib"]}
14
14
  pyo3-log = "0.12.4"
15
15
  quick-xml = {version = "0.37.2", features = ["serialize", "serde-types"]}
16
16
  serde = {version = "1.0", features = ["derive"]}
@@ -37,4 +37,5 @@ unexpected_cfgs = {level = "warn", check-cfg = ['cfg(tarpaulin_include)']}
37
37
  edition = "2021"
38
38
  name = "differt-core"
39
39
  rust-version = "1.78.0"
40
- version = "0.5.0"
40
+ version = "0.6.0"
41
+ readme = "README.md"
@@ -52,6 +52,9 @@ class DiGraph:
52
52
  to_adjacency: Bool[np.ndarray, " num_nodes"],
53
53
  ) -> tuple[int, int]: ...
54
54
  def disconnect_nodes(self, *nodes: int, fast_mode: bool = True) -> None: ...
55
+ def filter_by_mask(
56
+ self, mask: Bool[np.ndarray, " num_nodes"], fast_mode: bool = True
57
+ ) -> None: ...
55
58
  def all_paths(
56
59
  self,
57
60
  from_: int,
@@ -6,7 +6,11 @@ use numpy::{
6
6
  IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2,
7
7
  ndarray::{Array2, ArrayView1, ArrayView2, Axis, parallel::prelude::*},
8
8
  };
9
- use pyo3::{prelude::*, types::PyType};
9
+ use pyo3::{
10
+ exceptions::{PyIndexError, PyValueError},
11
+ prelude::*,
12
+ types::PyType,
13
+ };
10
14
 
11
15
  /// NodeId type.
12
16
  pub type NodeId = usize;
@@ -820,11 +824,18 @@ pub mod directed {
820
824
  /// from the nodes is sufficient, and faster to perform,
821
825
  /// but can lead to a slower graph traversal when
822
826
  /// generating all possible paths.
827
+ ///
828
+ /// Raises:
829
+ /// IndexError: If any of the specified nodes is not part
830
+ /// of the graph.
823
831
  #[pyo3(signature = (*nodes, fast_mode=true))]
824
832
  #[pyo3(text_signature = "(self, *nodes, fast_mode=True)")]
825
- pub fn disconnect_nodes(&mut self, mut nodes: Vec<usize>, fast_mode: bool) {
833
+ pub fn disconnect_nodes(&mut self, mut nodes: Vec<usize>, fast_mode: bool) -> PyResult<()> {
826
834
  for i in nodes.iter() {
827
- self.edges_list[*i].clear();
835
+ self.edges_list
836
+ .get_mut(*i)
837
+ .ok_or_else(|| PyIndexError::new_err(format!("Node {i} is out-of-bounds")))?
838
+ .clear();
828
839
  }
829
840
 
830
841
  if !fast_mode {
@@ -834,6 +845,68 @@ pub mod directed {
834
845
  edge.retain(|node| nodes.binary_search(node).is_err());
835
846
  }
836
847
  }
848
+
849
+ Ok(())
850
+ }
851
+
852
+ /// Disconnect all nodes where the mask is False, keeping only
853
+ /// nodes where the mask is True.
854
+ ///
855
+ /// This is a more efficient version of :meth:`disconnect_nodes` when
856
+ /// working with NumPy boolean arrays, as it avoids creating
857
+ /// intermediate Vec<usize> collections.
858
+ ///
859
+ /// This has two effects:
860
+ ///
861
+ /// - all paths from nodes where mask is False will be removed;
862
+ /// - and all paths to nodes where mask is False will be removed.
863
+ ///
864
+ /// Args:
865
+ /// mask (:class:`Bool[ndarray, "num_nodes"]<jaxtyping.Bool>`): A boolean mask array where
866
+ /// :data:`True` means the node should remain connected, and
867
+ /// :data:`False` means the node should be disconnected.
868
+ /// fast_mode (bool): If set to :data:`True` (default),
869
+ /// only disconnecting all paths (i.e., edges)
870
+ /// from the nodes is sufficient, and faster to perform,
871
+ /// but can lead to a slower graph traversal when
872
+ /// generating all possible paths.
873
+ ///
874
+ /// Raises:
875
+ /// ValueError: If the length of ``mask`` is larger
876
+ /// from the number of nodes in the graph.
877
+ #[pyo3(signature = (mask, fast_mode=true))]
878
+ #[pyo3(text_signature = "(self, mask, fast_mode=True)")]
879
+ pub fn filter_by_mask(
880
+ &mut self,
881
+ mask: PyReadonlyArray1<bool>,
882
+ fast_mode: bool,
883
+ ) -> PyResult<()> {
884
+ let mask = mask.as_array();
885
+
886
+ if mask.len() > self.num_nodes() {
887
+ return Err(PyValueError::new_err(format!(
888
+ "'mask' length ({}) must be smaller than or equal to the number of nodes in \
889
+ the graph ({})",
890
+ mask.len(),
891
+ self.num_nodes()
892
+ )));
893
+ }
894
+
895
+ // Clear edges from nodes that should be disconnected
896
+ for (i, &keep_node) in mask.iter().enumerate() {
897
+ if !keep_node {
898
+ self.edges_list[i].clear();
899
+ }
900
+ }
901
+
902
+ if !fast_mode {
903
+ // Remove references to disconnected nodes from all other nodes
904
+ for edge_list in self.edges_list.iter_mut() {
905
+ edge_list.retain(|&node| if node < mask.len() { mask[node] } else { true });
906
+ }
907
+ }
908
+
909
+ Ok(())
837
910
  }
838
911
 
839
912
  /// Return an iterator over all paths of length ``depth``
@@ -1135,7 +1208,7 @@ pub(crate) fn graph(m: Bound<'_, PyModule>) -> PyResult<()> {
1135
1208
  mod tests {
1136
1209
  use std::cmp::Ordering;
1137
1210
 
1138
- use ndarray::array;
1211
+ use numpy::{PyArrayMethods, ToPyArray, ndarray::array, pyo3::Python};
1139
1212
  use rstest::*;
1140
1213
 
1141
1214
  use super::{complete::CompleteGraph, directed::DiGraph, *};
@@ -1502,4 +1575,132 @@ mod tests {
1502
1575
 
1503
1576
  assert!(complete_iter.eq(di_iter));
1504
1577
  }
1578
+
1579
+ #[rstest]
1580
+ #[case(true)]
1581
+ #[case(false)]
1582
+ fn test_di_graph_disconnect_node(#[case] fast_mode: bool) {
1583
+ let mut graph: DiGraph = CompleteGraph::new(10).into();
1584
+ let (from, to) = graph.insert_from_and_to_nodes(true, None, None);
1585
+
1586
+ // Count paths before disconnecting any nodes
1587
+ let paths_before = graph.all_paths(from, to, 3, false).count();
1588
+
1589
+ // Disconnect nodes 2 and 5
1590
+ let disconnected_nodes = vec![2, 5];
1591
+ graph
1592
+ .disconnect_nodes(disconnected_nodes.clone(), fast_mode)
1593
+ .unwrap();
1594
+
1595
+ // Count paths after disconnecting nodes
1596
+ let paths_after: Vec<_> = graph.all_paths(from, to, 3, false).collect();
1597
+
1598
+ // Verify that no path contains any of the disconnected nodes
1599
+ for path in &paths_after {
1600
+ for &node in &disconnected_nodes {
1601
+ assert!(
1602
+ !path.contains(&node),
1603
+ "Path {:?} should not contain disconnected node {}",
1604
+ path,
1605
+ node
1606
+ );
1607
+ }
1608
+ }
1609
+
1610
+ // Verify that we have fewer paths after disconnection
1611
+ assert!(
1612
+ paths_after.len() < paths_before,
1613
+ "Should have fewer paths after disconnecting nodes"
1614
+ );
1615
+
1616
+ // Test disconnecting a single node
1617
+ let mut graph2: DiGraph = CompleteGraph::new(5).into();
1618
+ let (from2, to2) = graph2.insert_from_and_to_nodes(true, None, None);
1619
+
1620
+ let paths_before2 = graph2.all_paths(from2, to2, 4, false).count();
1621
+ graph2.disconnect_nodes(vec![1], fast_mode).unwrap();
1622
+ let paths_after2: Vec<_> = graph2.all_paths(from2, to2, 4, false).collect();
1623
+
1624
+ // Verify node 1 is not in any path
1625
+ for path in &paths_after2 {
1626
+ assert!(
1627
+ !path.contains(&1),
1628
+ "Path {:?} should not contain disconnected node 1",
1629
+ path
1630
+ );
1631
+ }
1632
+
1633
+ assert!(paths_after2.len() < paths_before2);
1634
+
1635
+ // Test disconnecting all intermediate nodes (should result in no paths for depth > 2)
1636
+ let mut graph3: DiGraph = CompleteGraph::new(3).into();
1637
+ let (from3, to3) = graph3.insert_from_and_to_nodes(false, None, None); // No direct path
1638
+
1639
+ // Disconnect all intermediate nodes (0, 1, 2)
1640
+ graph3.disconnect_nodes(vec![0, 1, 2], fast_mode).unwrap();
1641
+ let paths_after3 = graph3.all_paths(from3, to3, 4, false).count();
1642
+
1643
+ // Should have no paths since all intermediate nodes are disconnected
1644
+ assert_eq!(
1645
+ paths_after3, 0,
1646
+ "Should have no paths when all intermediate nodes are disconnected"
1647
+ );
1648
+ }
1649
+
1650
+ #[rstest]
1651
+ #[case(true)]
1652
+ #[case(false)]
1653
+ fn test_di_graph_filter_by_mask(#[case] fast_mode: bool) {
1654
+ Python::with_gil(|py| {
1655
+ let mut graph: DiGraph = CompleteGraph::new(8).into();
1656
+ let (from, to) = graph.insert_from_and_to_nodes(true, None, None);
1657
+
1658
+ // Count paths before filtering
1659
+ let paths_before = graph.all_paths(from, to, 3, false).count();
1660
+
1661
+ // Create a mask that keeps nodes 0, 2, 4, 6 and disconnects 1, 3, 5, 7
1662
+ let mask = array![true, false, true, false, true, false, true, false].to_pyarray(py);
1663
+
1664
+ // Apply the mask filter
1665
+ graph.filter_by_mask(mask.readonly(), fast_mode).unwrap();
1666
+
1667
+ // Count paths after filtering
1668
+ let paths_after: Vec<_> = graph.all_paths(from, to, 3, false).collect();
1669
+
1670
+ // Verify that no path contains any of the disconnected nodes (1, 3, 5, 7)
1671
+ let disconnected_nodes = vec![1, 3, 5, 7];
1672
+ for path in &paths_after {
1673
+ for &node in &disconnected_nodes {
1674
+ assert!(
1675
+ !path.contains(&node),
1676
+ "Path {:?} should not contain disconnected node {}",
1677
+ path,
1678
+ node
1679
+ );
1680
+ }
1681
+ }
1682
+
1683
+ // Verify that we have fewer paths after filtering
1684
+ assert!(
1685
+ paths_after.len() < paths_before,
1686
+ "Should have fewer paths after filtering by mask"
1687
+ );
1688
+
1689
+ // Test with a mask that disconnects all nodes (should result in no paths)
1690
+ let mut graph2: DiGraph = CompleteGraph::new(4).into();
1691
+ let (from2, to2) = graph2.insert_from_and_to_nodes(false, None, None);
1692
+
1693
+ // Disconnect all intermediate nodes
1694
+ let all_false_mask = ndarray::array![false, false, false, false].to_pyarray(py);
1695
+ graph2
1696
+ .filter_by_mask(all_false_mask.readonly(), fast_mode)
1697
+ .unwrap();
1698
+
1699
+ let paths_after2 = graph2.all_paths(from2, to2, 4, false).count();
1700
+ assert_eq!(
1701
+ paths_after2, 0,
1702
+ "Should have no paths when all nodes are disconnected"
1703
+ );
1704
+ });
1705
+ }
1505
1706
  }
@@ -1,4 +1,5 @@
1
1
  import logging
2
+ import re
2
3
 
3
4
  import numpy as np
4
5
  import pytest
@@ -46,6 +47,76 @@ class TestDiGraph:
46
47
  for node in nodes:
47
48
  assert node not in path
48
49
 
50
+ @pytest.mark.parametrize("fast_mode", [True, False])
51
+ def test_filter_by_mask(self, fast_mode: bool) -> None:
52
+ graph = DiGraph.from_complete_graph(CompleteGraph(8))
53
+ from_, to = graph.insert_from_and_to_nodes()
54
+
55
+ # Create a mask that disconnects nodes 1, 3, 5, 7 (keeps 0, 2, 4, 6)
56
+ mask = np.array([True, False, True, False, True, False, True, False])
57
+ disconnected_nodes = [1, 3, 5, 7]
58
+
59
+ # Count paths before filtering
60
+ paths_before = len(
61
+ list(graph.all_paths(from_, to, 3, include_from_and_to=False))
62
+ )
63
+
64
+ # Apply the mask filter
65
+ graph.filter_by_mask(mask, fast_mode=fast_mode)
66
+
67
+ # Count paths after filtering and verify no disconnected nodes appear
68
+ paths_after = []
69
+ for path in graph.all_paths(from_, to, 3, include_from_and_to=False):
70
+ paths_after.append(path)
71
+ for node in disconnected_nodes:
72
+ assert node not in path, (
73
+ f"Path {path} should not contain disconnected node {node}"
74
+ )
75
+
76
+ # Verify that we have fewer paths after filtering
77
+ assert len(paths_after) < paths_before, (
78
+ "Should have fewer paths after filtering by mask"
79
+ )
80
+
81
+ @pytest.mark.parametrize("fast_mode", [True, False])
82
+ def test_filter_by_mask_all_disconnected(self, fast_mode: bool) -> None:
83
+ graph = DiGraph.from_complete_graph(CompleteGraph(4))
84
+ from_, to = graph.insert_from_and_to_nodes(direct_path=False)
85
+
86
+ # Disconnect all intermediate nodes
87
+ mask = np.array([False, False, False, False])
88
+ graph.filter_by_mask(mask, fast_mode=fast_mode)
89
+
90
+ # Should have no paths when all intermediate nodes are disconnected
91
+ paths = list(graph.all_paths(from_, to, 4, include_from_and_to=False))
92
+ assert len(paths) == 0, "Should have no paths when all nodes are disconnected"
93
+
94
+ def test_filter_by_mask_wrong_size(self) -> None:
95
+ graph = DiGraph.from_complete_graph(CompleteGraph(5))
96
+
97
+ # Test with mask of wrong size
98
+ wrong_mask = np.array([True, False, True]) # Only 3 elements for 5 nodes
99
+ # Ok to use a smaller mask
100
+ graph.filter_by_mask(wrong_mask, fast_mode=True)
101
+
102
+ graph = DiGraph.from_complete_graph(CompleteGraph(5))
103
+
104
+ wrong_mask = np.array([
105
+ True,
106
+ False,
107
+ True,
108
+ False,
109
+ False,
110
+ False,
111
+ ]) # 6 elements for 5 nodes
112
+ with pytest.raises(
113
+ ValueError,
114
+ match=re.escape(
115
+ "'mask' length (6) must be smaller than or equal to the number of nodes in the graph (5)"
116
+ ),
117
+ ):
118
+ graph.filter_by_mask(wrong_mask, fast_mode=True)
119
+
49
120
  @pytest.mark.parametrize("fast_mode", [True, False])
50
121
  def test_disconnect_nodes_equivalence(self, fast_mode: bool) -> None:
51
122
  complete_graph = CompleteGraph(3)
@@ -8,7 +8,6 @@ authors = [
8
8
  ]
9
9
  classifiers = [
10
10
  "Programming Language :: Python :: 3",
11
- "Programming Language :: Python :: 3.10",
12
11
  "Programming Language :: Python :: 3.11",
13
12
  "Programming Language :: Python :: 3.12",
14
13
  "Programming Language :: Python :: 3.13",
@@ -24,7 +23,7 @@ description = "Core backend of DiffeRT implemented in Rust"
24
23
  dynamic = ["license", "readme", "version"]
25
24
  keywords = ["ray tracing", "differentiable", "propagation", "radio", "jax"]
26
25
  name = "differt-core"
27
- requires-python = ">= 3.10"
26
+ requires-python = ">= 3.11"
28
27
 
29
28
  [tool.maturin]
30
29
  bindings = "pyo3"
@@ -52,6 +52,9 @@ class DiGraph:
52
52
  to_adjacency: Bool[np.ndarray, " num_nodes"],
53
53
  ) -> tuple[int, int]: ...
54
54
  def disconnect_nodes(self, *nodes: int, fast_mode: bool = True) -> None: ...
55
+ def filter_by_mask(
56
+ self, mask: Bool[np.ndarray, " num_nodes"], fast_mode: bool = True
57
+ ) -> None: ...
55
58
  def all_paths(
56
59
  self,
57
60
  from_: int,
@@ -6,7 +6,11 @@ use numpy::{
6
6
  IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2,
7
7
  ndarray::{Array2, ArrayView1, ArrayView2, Axis, parallel::prelude::*},
8
8
  };
9
- use pyo3::{prelude::*, types::PyType};
9
+ use pyo3::{
10
+ exceptions::{PyIndexError, PyValueError},
11
+ prelude::*,
12
+ types::PyType,
13
+ };
10
14
 
11
15
  /// NodeId type.
12
16
  pub type NodeId = usize;
@@ -820,11 +824,18 @@ pub mod directed {
820
824
  /// from the nodes is sufficient, and faster to perform,
821
825
  /// but can lead to a slower graph traversal when
822
826
  /// generating all possible paths.
827
+ ///
828
+ /// Raises:
829
+ /// IndexError: If any of the specified nodes is not part
830
+ /// of the graph.
823
831
  #[pyo3(signature = (*nodes, fast_mode=true))]
824
832
  #[pyo3(text_signature = "(self, *nodes, fast_mode=True)")]
825
- pub fn disconnect_nodes(&mut self, mut nodes: Vec<usize>, fast_mode: bool) {
833
+ pub fn disconnect_nodes(&mut self, mut nodes: Vec<usize>, fast_mode: bool) -> PyResult<()> {
826
834
  for i in nodes.iter() {
827
- self.edges_list[*i].clear();
835
+ self.edges_list
836
+ .get_mut(*i)
837
+ .ok_or_else(|| PyIndexError::new_err(format!("Node {i} is out-of-bounds")))?
838
+ .clear();
828
839
  }
829
840
 
830
841
  if !fast_mode {
@@ -834,6 +845,68 @@ pub mod directed {
834
845
  edge.retain(|node| nodes.binary_search(node).is_err());
835
846
  }
836
847
  }
848
+
849
+ Ok(())
850
+ }
851
+
852
+ /// Disconnect all nodes where the mask is False, keeping only
853
+ /// nodes where the mask is True.
854
+ ///
855
+ /// This is a more efficient version of :meth:`disconnect_nodes` when
856
+ /// working with NumPy boolean arrays, as it avoids creating
857
+ /// intermediate Vec<usize> collections.
858
+ ///
859
+ /// This has two effects:
860
+ ///
861
+ /// - all paths from nodes where mask is False will be removed;
862
+ /// - and all paths to nodes where mask is False will be removed.
863
+ ///
864
+ /// Args:
865
+ /// mask (:class:`Bool[ndarray, "num_nodes"]<jaxtyping.Bool>`): A boolean mask array where
866
+ /// :data:`True` means the node should remain connected, and
867
+ /// :data:`False` means the node should be disconnected.
868
+ /// fast_mode (bool): If set to :data:`True` (default),
869
+ /// only disconnecting all paths (i.e., edges)
870
+ /// from the nodes is sufficient, and faster to perform,
871
+ /// but can lead to a slower graph traversal when
872
+ /// generating all possible paths.
873
+ ///
874
+ /// Raises:
875
+ /// ValueError: If the length of ``mask`` is larger
876
+ /// from the number of nodes in the graph.
877
+ #[pyo3(signature = (mask, fast_mode=true))]
878
+ #[pyo3(text_signature = "(self, mask, fast_mode=True)")]
879
+ pub fn filter_by_mask(
880
+ &mut self,
881
+ mask: PyReadonlyArray1<bool>,
882
+ fast_mode: bool,
883
+ ) -> PyResult<()> {
884
+ let mask = mask.as_array();
885
+
886
+ if mask.len() > self.num_nodes() {
887
+ return Err(PyValueError::new_err(format!(
888
+ "'mask' length ({}) must be smaller than or equal to the number of nodes in \
889
+ the graph ({})",
890
+ mask.len(),
891
+ self.num_nodes()
892
+ )));
893
+ }
894
+
895
+ // Clear edges from nodes that should be disconnected
896
+ for (i, &keep_node) in mask.iter().enumerate() {
897
+ if !keep_node {
898
+ self.edges_list[i].clear();
899
+ }
900
+ }
901
+
902
+ if !fast_mode {
903
+ // Remove references to disconnected nodes from all other nodes
904
+ for edge_list in self.edges_list.iter_mut() {
905
+ edge_list.retain(|&node| if node < mask.len() { mask[node] } else { true });
906
+ }
907
+ }
908
+
909
+ Ok(())
837
910
  }
838
911
 
839
912
  /// Return an iterator over all paths of length ``depth``
@@ -1135,7 +1208,7 @@ pub(crate) fn graph(m: Bound<'_, PyModule>) -> PyResult<()> {
1135
1208
  mod tests {
1136
1209
  use std::cmp::Ordering;
1137
1210
 
1138
- use ndarray::array;
1211
+ use numpy::{PyArrayMethods, ToPyArray, ndarray::array, pyo3::Python};
1139
1212
  use rstest::*;
1140
1213
 
1141
1214
  use super::{complete::CompleteGraph, directed::DiGraph, *};
@@ -1502,4 +1575,132 @@ mod tests {
1502
1575
 
1503
1576
  assert!(complete_iter.eq(di_iter));
1504
1577
  }
1578
+
1579
+ #[rstest]
1580
+ #[case(true)]
1581
+ #[case(false)]
1582
+ fn test_di_graph_disconnect_node(#[case] fast_mode: bool) {
1583
+ let mut graph: DiGraph = CompleteGraph::new(10).into();
1584
+ let (from, to) = graph.insert_from_and_to_nodes(true, None, None);
1585
+
1586
+ // Count paths before disconnecting any nodes
1587
+ let paths_before = graph.all_paths(from, to, 3, false).count();
1588
+
1589
+ // Disconnect nodes 2 and 5
1590
+ let disconnected_nodes = vec![2, 5];
1591
+ graph
1592
+ .disconnect_nodes(disconnected_nodes.clone(), fast_mode)
1593
+ .unwrap();
1594
+
1595
+ // Count paths after disconnecting nodes
1596
+ let paths_after: Vec<_> = graph.all_paths(from, to, 3, false).collect();
1597
+
1598
+ // Verify that no path contains any of the disconnected nodes
1599
+ for path in &paths_after {
1600
+ for &node in &disconnected_nodes {
1601
+ assert!(
1602
+ !path.contains(&node),
1603
+ "Path {:?} should not contain disconnected node {}",
1604
+ path,
1605
+ node
1606
+ );
1607
+ }
1608
+ }
1609
+
1610
+ // Verify that we have fewer paths after disconnection
1611
+ assert!(
1612
+ paths_after.len() < paths_before,
1613
+ "Should have fewer paths after disconnecting nodes"
1614
+ );
1615
+
1616
+ // Test disconnecting a single node
1617
+ let mut graph2: DiGraph = CompleteGraph::new(5).into();
1618
+ let (from2, to2) = graph2.insert_from_and_to_nodes(true, None, None);
1619
+
1620
+ let paths_before2 = graph2.all_paths(from2, to2, 4, false).count();
1621
+ graph2.disconnect_nodes(vec![1], fast_mode).unwrap();
1622
+ let paths_after2: Vec<_> = graph2.all_paths(from2, to2, 4, false).collect();
1623
+
1624
+ // Verify node 1 is not in any path
1625
+ for path in &paths_after2 {
1626
+ assert!(
1627
+ !path.contains(&1),
1628
+ "Path {:?} should not contain disconnected node 1",
1629
+ path
1630
+ );
1631
+ }
1632
+
1633
+ assert!(paths_after2.len() < paths_before2);
1634
+
1635
+ // Test disconnecting all intermediate nodes (should result in no paths for depth > 2)
1636
+ let mut graph3: DiGraph = CompleteGraph::new(3).into();
1637
+ let (from3, to3) = graph3.insert_from_and_to_nodes(false, None, None); // No direct path
1638
+
1639
+ // Disconnect all intermediate nodes (0, 1, 2)
1640
+ graph3.disconnect_nodes(vec![0, 1, 2], fast_mode).unwrap();
1641
+ let paths_after3 = graph3.all_paths(from3, to3, 4, false).count();
1642
+
1643
+ // Should have no paths since all intermediate nodes are disconnected
1644
+ assert_eq!(
1645
+ paths_after3, 0,
1646
+ "Should have no paths when all intermediate nodes are disconnected"
1647
+ );
1648
+ }
1649
+
1650
+ #[rstest]
1651
+ #[case(true)]
1652
+ #[case(false)]
1653
+ fn test_di_graph_filter_by_mask(#[case] fast_mode: bool) {
1654
+ Python::with_gil(|py| {
1655
+ let mut graph: DiGraph = CompleteGraph::new(8).into();
1656
+ let (from, to) = graph.insert_from_and_to_nodes(true, None, None);
1657
+
1658
+ // Count paths before filtering
1659
+ let paths_before = graph.all_paths(from, to, 3, false).count();
1660
+
1661
+ // Create a mask that keeps nodes 0, 2, 4, 6 and disconnects 1, 3, 5, 7
1662
+ let mask = array![true, false, true, false, true, false, true, false].to_pyarray(py);
1663
+
1664
+ // Apply the mask filter
1665
+ graph.filter_by_mask(mask.readonly(), fast_mode).unwrap();
1666
+
1667
+ // Count paths after filtering
1668
+ let paths_after: Vec<_> = graph.all_paths(from, to, 3, false).collect();
1669
+
1670
+ // Verify that no path contains any of the disconnected nodes (1, 3, 5, 7)
1671
+ let disconnected_nodes = vec![1, 3, 5, 7];
1672
+ for path in &paths_after {
1673
+ for &node in &disconnected_nodes {
1674
+ assert!(
1675
+ !path.contains(&node),
1676
+ "Path {:?} should not contain disconnected node {}",
1677
+ path,
1678
+ node
1679
+ );
1680
+ }
1681
+ }
1682
+
1683
+ // Verify that we have fewer paths after filtering
1684
+ assert!(
1685
+ paths_after.len() < paths_before,
1686
+ "Should have fewer paths after filtering by mask"
1687
+ );
1688
+
1689
+ // Test with a mask that disconnects all nodes (should result in no paths)
1690
+ let mut graph2: DiGraph = CompleteGraph::new(4).into();
1691
+ let (from2, to2) = graph2.insert_from_and_to_nodes(false, None, None);
1692
+
1693
+ // Disconnect all intermediate nodes
1694
+ let all_false_mask = ndarray::array![false, false, false, false].to_pyarray(py);
1695
+ graph2
1696
+ .filter_by_mask(all_false_mask.readonly(), fast_mode)
1697
+ .unwrap();
1698
+
1699
+ let paths_after2 = graph2.all_paths(from2, to2, 4, false).count();
1700
+ assert_eq!(
1701
+ paths_after2, 0,
1702
+ "Should have no paths when all nodes are disconnected"
1703
+ );
1704
+ });
1705
+ }
1505
1706
  }
File without changes
File without changes
File without changes
File without changes
File without changes