Skip to content

Commit c3e2210

Browse files
committed
fix(community): improve C++ compatibility, fallbacks, and add test suite
- localsearch: replace C++17 structured binding with std::tie for C++14 compatibility (parent/dist destructuring in BFS queue) - louvain / motif / graph_coloring: detect OpenMP support via _OPENMP macro and emit a clear runtime warning when OpenMP is unavailable, falling back to serial execution gracefully - modularity: handle edge cases (empty node list, edgeless graph) by returning 0.0 and skip zero-degree nodes without division-by-zero - tests/: add a complete unittest suite covering modularity, greedy_modularity, enumerate_subgraph, louvain (parallel + serial), LPA, ego_graph (+ CSR variant), and localsearch, plus a run_all_tests.py entry point with a friendly summary
1 parent cd873b6 commit c3e2210

14 files changed

Lines changed: 1299 additions & 7 deletions

cpp_easygraph/functions/community/graph_coloring.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
#ifdef _OPENMP
1313
#include <omp.h>
14+
#else
15+
#warning "OpenMP is not available: omp_graph_coloring will fall back to single-threaded execution."
1416
#endif
1517

1618
#include "../../classes/linkgraph.h"

cpp_easygraph/functions/community/localsearch.cpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,8 @@ py::object cpp_localsearch(
154154
}
155155

156156
while (!bfs_queue.empty()) {
157-
auto [parent, dist] = bfs_queue.front();
157+
int parent, dist;
158+
std::tie(parent, dist) = bfs_queue.front();
158159
bfs_queue.pop();
159160

160161
for (int pred : dag_pred[parent]) {

cpp_easygraph/functions/community/louvain.cpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
#include <cstdint>
77
#include <pybind11/pybind11.h>
88
#include <pybind11/stl.h>
9+
#ifdef _OPENMP
910
#include <omp.h>
11+
#else
12+
#warning "OpenMP is not available: cpp_louvain_communities will fall back to single-threaded execution."
13+
#endif
1014
#include "../../classes/graph.h"
1115
#include "../../common/utils.h"
1216
#include "../../classes/linkgraph.h"
@@ -467,7 +471,9 @@ py::object cpp_louvain_communities_serial(py::object G, py::object weight, py::o
467471
py::object cpp_louvain_communities(py::object G, py::object weight, py::object threshold, py::object resolution) {
468472
Graph& G_ = G.cast<Graph&>();
469473

474+
#ifdef _OPENMP
470475
omp_set_num_threads(8);
476+
#endif
471477

472478
Graph_L original_GL = G_._get_linkgraph_structure();
473479

cpp_easygraph/functions/community/modularity.cpp

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
#include <unordered_map>
33
#include <unordered_set>
44
#include <string>
5+
#ifdef _OPENMP
56
#include <omp.h>
7+
#else
8+
#warning "OpenMP is not available: modularity utility functions will fall back to single-threaded execution."
9+
#endif
610

711
#include "../../classes/graph.h"
812
#include "../../common/utils.h"
@@ -143,15 +147,35 @@ void calculate_degrees_and_edges_adj_serial(
143147
}
144148
}
145149

150+
// The input `communities` may be either:
151+
// (a) a membership list: a flat sequence of ints, membership[i] = community id of node (i+1); or
152+
// (b) a community list: a sequence of iterables of node ids
153+
146154
py::object cpp_modularity(py::object G, py::object communities, py::object weight=py::str("weight")) {
147155
Graph& G_ = G.cast<Graph&>();
148156
bool directed = G.attr("is_directed")().cast<bool>();
149157
adj_dict_factory& adj = G_.adj;
150158
const int N = G_.node.size();
151159

152-
// The input `communities` may be either:
153-
// (a) a membership list: a flat sequence of ints, membership[i] = community id of node (i+1); or
154-
// (b) a community list: a sequence of iterables of node ids
160+
161+
{
162+
bool is_empty_seq = false;
163+
try {
164+
py::sequence seq = communities.cast<py::sequence>();
165+
is_empty_seq = (seq.size() == 0);
166+
} catch (const py::cast_error&) {
167+
is_empty_seq = false;
168+
}
169+
if (is_empty_seq) {
170+
py::module warnings = py::module::import("warnings");
171+
warnings.attr("warn")(
172+
"cpp_modularity: received an empty community list; returning Q = 0.0."
173+
);
174+
return py::float_(0.0);
175+
}
176+
}
177+
178+
155179
std::vector<int> membership_vec;
156180

157181
bool is_membership = false;
@@ -205,7 +229,19 @@ py::object cpp_modularity(py::object G, py::object communities, py::object weigh
205229
}
206230

207231
if (!directed) addVectorsInPlace(k_out, k_in);
208-
232+
233+
// Handle empty graph / zero total edge weight: m == 0 makes
234+
// `norm = 1.0 / (directed_factor * m)` divide by zero and produces
235+
// inf / nan. Define Q = 0.0 in this degenerate case (no edges -> no
236+
// community structure to measure), with a Python warning.
237+
if (m == 0.0) {
238+
py::module warnings = py::module::import("warnings");
239+
warnings.attr("warn")(
240+
"cpp_modularity: graph has no edges (m == 0); returning Q = 0.0."
241+
);
242+
return py::float_(0.0);
243+
}
244+
209245
double directed_factor = directed ? 1.0 : 2.0;
210246
double norm = 1.0 / (directed_factor * m);
211247
e *= norm;

cpp_easygraph/functions/community/motif.cpp

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88
#include <pybind11/pybind11.h>
99
#include <pybind11/stl.h>
1010
#include <pybind11/numpy.h>
11+
#ifdef _OPENMP
1112
#include <omp.h>
13+
#else
14+
#warning "OpenMP is not available: motif counting functions will fall back to single-threaded execution."
15+
#endif
1216
#include "../../classes/graph.h"
1317
#include "../../classes/linkgraph.h"
1418
#include "motif.h"
@@ -245,11 +249,16 @@ class MotifEnumerator {
245249
}
246250

247251
vector<vector<MotifResult>> thread_results(8);
248-
252+
253+
#ifdef _OPENMP
249254
omp_set_num_threads(8);
255+
#endif
250256
#pragma omp parallel
251257
{
252-
int thread_id = omp_get_thread_num();
258+
int thread_id = 0;
259+
#ifdef _OPENMP
260+
thread_id = omp_get_thread_num();
261+
#endif
253262
vector<MotifResult>& local_results = thread_results[thread_id];
254263
local_results.reserve(10000);
255264

@@ -350,7 +359,9 @@ class MotifEnumerator {
350359
return count;
351360
}
352361

362+
#ifdef _OPENMP
353363
omp_set_num_threads(8);
364+
#endif
354365
#pragma omp parallel reduction(+:count)
355366
{
356367
#pragma omp for schedule(dynamic)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# TEST package init
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import unittest
2+
3+
import easygraph as eg
4+
5+
6+
def _get_cpp_module():
7+
try:
8+
import cpp_easygraph
9+
return cpp_easygraph
10+
except ImportError:
11+
return None
12+
13+
14+
def _to_undirected_cpp(G_cpp_digraph):
15+
import cpp_easygraph
16+
17+
G_cpp = cpp_easygraph.Graph()
18+
G_cpp.graph.update(G_cpp_digraph.graph)
19+
for node, node_attr in G_cpp_digraph.nodes.items():
20+
G_cpp.add_node(node, **node_attr)
21+
22+
seen_edges = set()
23+
for u, v, edge_data in G_cpp_digraph.edges:
24+
edge = (min(u, v), max(u, v))
25+
if edge not in seen_edges:
26+
seen_edges.add(edge)
27+
G_cpp.add_edge(u, v, **edge_data)
28+
29+
return G_cpp
30+
31+
32+
class TestLPA(unittest.TestCase):
33+
def setUp(self):
34+
self.G_simple = eg.Graph()
35+
self.G_simple.add_edges_from([(0, 1), (1, 2), (3, 4)])
36+
37+
self.G_weighted = eg.Graph()
38+
self.G_weighted.add_edges_from([
39+
(0, 1, {"weight": 3}),
40+
(1, 2, {"weight": 2}),
41+
(2, 0, {"weight": 4}),
42+
(3, 4, {"weight": 1}),
43+
])
44+
45+
self.G_disconnected = eg.Graph()
46+
self.G_disconnected.add_edges_from([(0, 1), (2, 3), (4, 5)])
47+
48+
self.G_single = eg.Graph()
49+
self.G_single.add_node(42)
50+
51+
self.G_empty = eg.Graph()
52+
53+
def _get_cpp_module(self):
54+
return _get_cpp_module()
55+
56+
def test_lpa_simple(self):
57+
cpeg = self._get_cpp_module()
58+
if cpeg is None:
59+
self.skipTest("cpp_easygraph module not available")
60+
61+
result = cpeg.cpp_LPA(self.G_simple.cpp())
62+
self.assertIsInstance(result, dict)
63+
64+
all_nodes = set()
65+
for community in result.values():
66+
all_nodes.update(community)
67+
self.assertEqual(all_nodes, set(self.G_simple.nodes))
68+
69+
def test_lpa_weighted(self):
70+
cpeg = self._get_cpp_module()
71+
if cpeg is None:
72+
self.skipTest("cpp_easygraph module not available")
73+
74+
result = cpeg.cpp_LPA(self.G_weighted.cpp())
75+
self.assertIsInstance(result, dict)
76+
77+
all_nodes = set(self.G_weighted.nodes)
78+
result_nodes = set()
79+
for community in result.values():
80+
result_nodes.update(community)
81+
self.assertEqual(all_nodes, result_nodes)
82+
83+
def test_lpa_disconnected(self):
84+
cpeg = self._get_cpp_module()
85+
if cpeg is None:
86+
self.skipTest("cpp_easygraph module not available")
87+
88+
result = cpeg.cpp_LPA(self.G_disconnected.cpp())
89+
self.assertIsInstance(result, dict)
90+
91+
all_nodes = set(self.G_disconnected.nodes)
92+
result_nodes = set()
93+
for community in result.values():
94+
result_nodes.update(community)
95+
self.assertEqual(all_nodes, result_nodes)
96+
97+
def test_lpa_single_node(self):
98+
cpeg = self._get_cpp_module()
99+
if cpeg is None:
100+
self.skipTest("cpp_easygraph module not available")
101+
102+
result = cpeg.cpp_LPA(self.G_single.cpp())
103+
self.assertIsInstance(result, dict)
104+
self.assertEqual(len(result), 1)
105+
for community in result.values():
106+
self.assertIn(42, community)
107+
108+
def test_lpa_empty_graph(self):
109+
cpeg = self._get_cpp_module()
110+
if cpeg is None:
111+
self.skipTest("cpp_easygraph module not available")
112+
113+
result = cpeg.cpp_LPA(self.G_empty.cpp())
114+
self.assertIsInstance(result, dict)
115+
self.assertEqual(result, {})
116+
117+
def test_python_cpp_consistency(self):
118+
cpeg = self._get_cpp_module()
119+
if cpeg is None:
120+
self.skipTest("cpp_easygraph module not available")
121+
122+
result_cpp = cpeg.cpp_LPA(self.G_simple.cpp())
123+
result_py = eg.functions.community.LPA(self.G_simple)
124+
125+
self.assertEqual(len(result_cpp), len(result_py))
126+
127+
cpp_nodes = set()
128+
for community in result_cpp.values():
129+
cpp_nodes.update(community)
130+
131+
py_nodes = set()
132+
for community in result_py.values():
133+
py_nodes.update(community)
134+
135+
self.assertEqual(cpp_nodes, py_nodes)
136+
137+
138+
if __name__ == "__main__":
139+
unittest.main()

0 commit comments

Comments
 (0)