Skip to content

Commit 01090b9

Browse files
committed
stable version
1 parent 65451d5 commit 01090b9

38 files changed

Lines changed: 1213 additions & 205 deletions

CMakeLists.txt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,23 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
2626
set(CMAKE_CXX_EXTENSIONS OFF)
2727
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
2828

29+
# macOS: enforce consistent deployment target and architecture for wheels/builds
30+
if(APPLE)
31+
# Allow environment to override, otherwise choose a sensible default
32+
if(DEFINED ENV{MACOSX_DEPLOYMENT_TARGET})
33+
set(CMAKE_OSX_DEPLOYMENT_TARGET "$ENV{MACOSX_DEPLOYMENT_TARGET}" CACHE STRING "" FORCE)
34+
elseif(NOT DEFINED CMAKE_OSX_DEPLOYMENT_TARGET)
35+
# Default matches Homebrew Python's target in this workspace
36+
set(CMAKE_OSX_DEPLOYMENT_TARGET "15.0" CACHE STRING "" FORCE)
37+
endif()
38+
if(NOT DEFINED CMAKE_OSX_ARCHITECTURES)
39+
# Default to arm64 on Apple Silicon hosts
40+
if(CMAKE_SYSTEM_PROCESSOR MATCHES "arm64|aarch64")
41+
set(CMAKE_OSX_ARCHITECTURES "arm64" CACHE STRING "" FORCE)
42+
endif()
43+
endif()
44+
endif()
45+
2946
# Dependencies
3047
find_package(pybind11 3 CONFIG QUIET)
3148
if(NOT pybind11_FOUND)
@@ -69,6 +86,11 @@ endif()
6986
pybind11_add_module(_netgraph_core bindings/python/module.cpp)
7087
target_link_libraries(_netgraph_core PRIVATE netgraph_core)
7188
target_compile_features(_netgraph_core PRIVATE cxx_std_20)
89+
if(APPLE)
90+
# Prefer maximum runtime compatibility for libc++ on macOS
91+
target_compile_definitions(netgraph_core PUBLIC _LIBCPP_DISABLE_AVAILABILITY=1 _LIBCPP_ABI_VERSION=1)
92+
target_compile_definitions(_netgraph_core PRIVATE _LIBCPP_DISABLE_AVAILABILITY=1 _LIBCPP_ABI_VERSION=1)
93+
endif()
7294

7395
# Optional coverage instrumentation for GCC/Clang
7496
option(NETGRAPH_CORE_COVERAGE "Enable C++ coverage instrumentation" OFF)

Makefile

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# NetGraph-Core Development Makefile
22

3-
.PHONY: help venv clean-venv dev install check check-ci lint format test qt clean build info hooks cov cpp-test
3+
.PHONY: help venv clean-venv dev install check check-ci lint format test qt clean build info hooks cov cpp-test rebuild
44

55
.DEFAULT_GOAL := help
66

@@ -12,6 +12,11 @@ PYTEST = $(PYTHON) -m pytest
1212
RUFF = $(PYTHON) -m ruff
1313
PRECOMMIT = $(PYTHON) -m pre_commit
1414

15+
# Prefer Apple Command Line Tools compilers to avoid Homebrew libc++ ABI mismatches
16+
APPLE_CLANG := $(shell xcrun --find clang 2>/dev/null)
17+
APPLE_CLANGXX := $(shell xcrun --find clang++ 2>/dev/null)
18+
DEFAULT_MACOSX := 15.0
19+
1520
help:
1621
@echo "🔧 NetGraph-Core Development Commands"
1722
@echo " make venv - Create a local virtualenv (./venv)"
@@ -28,6 +33,14 @@ help:
2833
@echo " make clean - Clean build artifacts"
2934
@echo " make hooks - Run pre-commit on all files"
3035
@echo " make info - Show tool versions"
36+
@echo " make rebuild - Clean and rebuild using Apple Clang (respects CMAKE_ARGS)"
37+
38+
# Allow callers to pass CMAKE_ARGS and MACOSX_DEPLOYMENT_TARGET consistently
39+
ENV_MACOS := $(if $(MACOSX_DEPLOYMENT_TARGET),MACOSX_DEPLOYMENT_TARGET=$(MACOSX_DEPLOYMENT_TARGET),MACOSX_DEPLOYMENT_TARGET=$(DEFAULT_MACOSX))
40+
ENV_CC := $(if $(APPLE_CLANG),CC=$(APPLE_CLANG),)
41+
ENV_CXX := $(if $(APPLE_CLANGXX),CXX=$(APPLE_CLANGXX),)
42+
ENV_CMAKE := $(if $(APPLE_CLANGXX),CMAKE_ARGS="$(strip $(CMAKE_ARGS) -DCMAKE_C_COMPILER=$(APPLE_CLANG) -DCMAKE_CXX_COMPILER=$(APPLE_CLANGXX))",$(if $(CMAKE_ARGS),CMAKE_ARGS="$(CMAKE_ARGS)",))
43+
DEV_ENV := $(ENV_MACOS) $(ENV_CC) $(ENV_CXX) $(ENV_CMAKE)
3144

3245
dev:
3346
@echo "🚀 Setting up development environment..."
@@ -37,7 +50,7 @@ dev:
3750
$(VENV_BIN)/python -m pip install -U pip wheel; \
3851
fi
3952
@echo "📦 Installing dev dependencies..."
40-
@$(VENV_BIN)/python -m pip install -e .[dev]
53+
@$(DEV_ENV) $(VENV_BIN)/python -m pip install -e .'[dev]'
4154
@echo "🔗 Installing pre-commit hooks..."
4255
@$(VENV_BIN)/python -m pre_commit install --install-hooks
4356
@echo "✅ Dev environment ready. Activate with: source venv/bin/activate"
@@ -53,7 +66,7 @@ clean-venv:
5366

5467
install:
5568
@echo "📦 Installing package (editable)"
56-
@$(PIP) install -e .
69+
@$(DEV_ENV) $(PIP) install -e .
5770

5871

5972
check:
@@ -120,7 +133,7 @@ cpp-test:
120133
cov:
121134
@echo "📦 Reinstalling with C++ coverage instrumentation..."
122135
@$(PIP) install -U scikit-build-core "pybind11>=3"
123-
@PIP_NO_BUILD_ISOLATION=1 CMAKE_ARGS="-DNETGRAPH_CORE_COVERAGE=ON" $(PIP) install -e .[dev]
136+
@PIP_NO_BUILD_ISOLATION=1 CMAKE_ARGS="-DNETGRAPH_CORE_COVERAGE=ON" $(PIP) install -e .'[dev]'
124137
@echo "🧪 Running Python tests with coverage..."
125138
@mkdir -p build/coverage
126139
@$(PYTEST) --cov=netgraph_core --cov-report=term-missing --cov-report=xml:build/coverage/coverage-python.xml
@@ -155,3 +168,7 @@ sanitize-test:
155168
cmake -S . -B "$$BUILD_DIR" -DNETGRAPH_CORE_BUILD_TESTS=ON -DNETGRAPH_CORE_SANITIZE=ON -DCMAKE_BUILD_TYPE=Debug $$GEN_ARGS; \
156169
cmake --build "$$BUILD_DIR" --config Debug -j; \
157170
ASAN_OPTIONS=detect_leaks=1 ctest --test-dir "$$BUILD_DIR" --output-on-failure || true
171+
172+
# Clean + reinstall in dev mode (respects CMAKE_ARGS and MACOSX_DEPLOYMENT_TARGET)
173+
rebuild: clean
174+
@$(DEV_ENV) $(PIP) install -e .'[dev]'

bindings/python/module.cpp

Lines changed: 123 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,21 @@ PYBIND11_MODULE(_netgraph_core, m) {
7474
auto dst_s = as_span<std::int32_t>(dst, "dst");
7575
if (src_s.size() != dst_s.size()) throw py::type_error("src and dst must have the same length");
7676
auto cap_s = as_span<double>(capacity, "capacity");
77-
// Accept any numeric dtype for cost; force-cast to int64
77+
// Accept any numeric dtype for cost; if float, require finite values, then force-cast to int64
78+
if (py::isinstance<py::array_t<double>>(cost)) {
79+
auto carr = py::cast<py::array_t<double>>(cost);
80+
if (!(carr.flags() & py::array::c_style)) {
81+
throw py::type_error("cost: array must be C-contiguous (use np.ascontiguousarray)");
82+
}
83+
auto cbuf_d = carr.request();
84+
if (cbuf_d.ndim != 1) throw py::type_error("cost must be a 1-D array");
85+
const double* cd = static_cast<const double*>(cbuf_d.ptr);
86+
for (ssize_t i = 0; i < cbuf_d.shape[0]; ++i) {
87+
if (!std::isfinite(cd[i])) {
88+
throw py::value_error("cost values must be finite");
89+
}
90+
}
91+
}
7892
py::array_t<std::int64_t, py::array::c_style | py::array::forcecast> cost_i64(cost);
7993
auto cbuf = cost_i64.request();
8094
std::span<const Cost> cost_s(static_cast<const Cost*>(cbuf.ptr), static_cast<std::size_t>(cbuf.size));
@@ -102,6 +116,34 @@ PYBIND11_MODULE(_netgraph_core, m) {
102116
self_obj
103117
);
104118
})
119+
.def("edge_src_view", [](py::object self_obj, const StrictMultiDiGraph& g){
120+
auto s = g.edge_src_view();
121+
return py::array(
122+
py::buffer_info(
123+
const_cast<std::int32_t*>(s.data()),
124+
sizeof(std::int32_t),
125+
py::format_descriptor<std::int32_t>::format(),
126+
1,
127+
{ s.size() },
128+
{ sizeof(std::int32_t) }
129+
),
130+
self_obj
131+
);
132+
})
133+
.def("edge_dst_view", [](py::object self_obj, const StrictMultiDiGraph& g){
134+
auto s = g.edge_dst_view();
135+
return py::array(
136+
py::buffer_info(
137+
const_cast<std::int32_t*>(s.data()),
138+
sizeof(std::int32_t),
139+
py::format_descriptor<std::int32_t>::format(),
140+
1,
141+
{ s.size() },
142+
{ sizeof(std::int32_t) }
143+
),
144+
self_obj
145+
);
146+
})
105147
.def("cost_view", [](py::object self_obj, const StrictMultiDiGraph& g){
106148
auto s = g.cost_view();
107149
return py::array(
@@ -167,8 +209,18 @@ PYBIND11_MODULE(_netgraph_core, m) {
167209
m.def("spf",
168210
[](const StrictMultiDiGraph& g, std::int32_t src, py::object dst,
169211
py::object selection_obj, py::object residual_obj, py::object node_mask, py::object edge_mask) {
212+
// Basic index validation
213+
if (src < 0 || src >= g.num_nodes()) {
214+
throw py::value_error("src out of range");
215+
}
170216
std::optional<NodeId> dst_opt;
171-
if (!dst.is_none()) dst_opt = static_cast<NodeId>(py::cast<std::int32_t>(dst));
217+
if (!dst.is_none()) {
218+
auto dval = static_cast<NodeId>(py::cast<std::int32_t>(dst));
219+
if (dval < 0 || dval >= g.num_nodes()) {
220+
throw py::value_error("dst out of range");
221+
}
222+
dst_opt = dval;
223+
}
172224
// Parse selection
173225
EdgeSelection selection;
174226
if (!selection_obj.is_none()) {
@@ -224,6 +276,9 @@ PYBIND11_MODULE(_netgraph_core, m) {
224276
m.def("ksp",
225277
[](const StrictMultiDiGraph& g, std::int32_t src, std::int32_t dst,
226278
int k, py::object max_cost_factor, bool unique, py::object node_mask, py::object edge_mask) {
279+
if (src < 0 || src >= g.num_nodes()) throw py::value_error("src out of range");
280+
if (dst < 0 || dst >= g.num_nodes()) throw py::value_error("dst out of range");
281+
if (k <= 0) throw py::value_error("k must be >= 1");
227282
std::optional<double> mcf;
228283
if (!max_cost_factor.is_none()) mcf = py::cast<double>(max_cost_factor);
229284
const bool* node_ptr = nullptr;
@@ -264,24 +319,71 @@ PYBIND11_MODULE(_netgraph_core, m) {
264319
return out;
265320
}, py::arg("g"), py::arg("src"), py::arg("dst"), py::kw_only(), py::arg("k"), py::arg("max_cost_factor") = py::none(), py::arg("unique") = true, py::arg("node_mask") = py::none(), py::arg("edge_mask") = py::none());
266321

322+
// resolve_to_paths: return list of paths; each path is list of (node, tuple(edge_ids...)) ending with (dst, ())
323+
m.def("resolve_to_paths",
324+
[](const PredDAG& dag, std::int32_t src, std::int32_t dst, bool split_parallel_edges, py::object max_paths){
325+
std::optional<std::int64_t> mp;
326+
if (!max_paths.is_none()) mp = py::cast<std::int64_t>(max_paths);
327+
auto out = netgraph::core::resolve_to_paths(dag, src, dst, split_parallel_edges, mp);
328+
py::list paths;
329+
for (auto& path : out) {
330+
py::list py_elems;
331+
for (auto& pr : path) {
332+
py::tuple edge_tuple(pr.second.size());
333+
for (std::size_t i=0;i<pr.second.size();++i) edge_tuple[i] = py::int_(pr.second[i]);
334+
py_elems.append(py::make_tuple(py::int_(pr.first), edge_tuple));
335+
}
336+
paths.append(py::tuple(py_elems));
337+
}
338+
return paths;
339+
}, py::arg("dag"), py::arg("src"), py::arg("dst"), py::kw_only(), py::arg("split_parallel_edges") = false, py::arg("max_paths") = py::none());
340+
267341
py::class_<MinCut>(m, "MinCut")
268-
.def_readonly("edges", &MinCut::edges);
269-
py::class_<CostBucket>(m, "CostBucket")
270-
.def_readonly("cost", &CostBucket::cost)
271-
.def_readonly("share", &CostBucket::share);
272-
py::class_<CostDistribution>(m, "CostDistribution")
273-
.def_readonly("buckets", &CostDistribution::buckets);
342+
.def_property_readonly("edges", [](const MinCut& mc){
343+
py::array_t<std::int32_t> arr(mc.edges.size());
344+
if (!mc.edges.empty()) {
345+
std::memcpy(arr.mutable_data(), mc.edges.data(), mc.edges.size()*sizeof(std::int32_t));
346+
}
347+
return arr;
348+
});
349+
// CostBucket/CostDistribution removed in favor of parallel arrays on FlowSummary
274350
py::class_<FlowSummary>(m, "FlowSummary")
275351
.def_readonly("total_flow", &FlowSummary::total_flow)
276352
.def_readonly("min_cut", &FlowSummary::min_cut)
277-
.def_readonly("cost_distribution", &FlowSummary::cost_distribution)
278-
.def_readonly("edge_flows", &FlowSummary::edge_flows);
353+
.def_property_readonly("costs", [](const FlowSummary& s){
354+
py::array_t<std::int64_t> arr(s.costs.size());
355+
if (!s.costs.empty()) std::memcpy(arr.mutable_data(), s.costs.data(), s.costs.size()*sizeof(std::int64_t));
356+
return arr;
357+
})
358+
.def_property_readonly("flows", [](const FlowSummary& s){
359+
py::array_t<double> arr(s.flows.size());
360+
if (!s.flows.empty()) std::memcpy(arr.mutable_data(), s.flows.data(), s.flows.size()*sizeof(double));
361+
return arr;
362+
})
363+
.def_property_readonly("edge_flows", [](const FlowSummary& s){
364+
py::array_t<double> arr(s.edge_flows.size());
365+
if (!s.edge_flows.empty()) std::memcpy(arr.mutable_data(), s.edge_flows.data(), s.edge_flows.size()*sizeof(double));
366+
return arr;
367+
})
368+
.def_property_readonly("residual_capacity", [](const FlowSummary& s){
369+
py::array_t<double> arr(s.residual_capacity.size());
370+
if (!s.residual_capacity.empty()) std::memcpy(arr.mutable_data(), s.residual_capacity.data(), s.residual_capacity.size()*sizeof(double));
371+
return arr;
372+
})
373+
.def_property_readonly("reachable_nodes", [](const FlowSummary& s){
374+
// Store as bool array for Python; cast bytes to bool
375+
py::array_t<bool> arr(s.reachable_nodes.size());
376+
auto* out = arr.mutable_data();
377+
for (std::size_t i=0;i<s.reachable_nodes.size();++i) out[i] = static_cast<bool>(s.reachable_nodes[i]);
378+
return arr;
379+
});
279380

280381
// spf_residual removed; unified spf accepts optional residual and EdgeSelection
281382

282383
m.def("max_flow",
283384
[](const StrictMultiDiGraph& g, std::int32_t src, std::int32_t dst,
284385
FlowPlacement placement, bool shortest_path, bool with_edge_flows,
386+
bool with_reachable, bool with_residuals,
285387
py::object node_mask, py::object edge_mask) {
286388
const bool* node_ptr = nullptr;
287389
const bool* edge_ptr = nullptr;
@@ -305,22 +407,24 @@ PYBIND11_MODULE(_netgraph_core, m) {
305407
edge_ptr = static_cast<const bool*>(buf.ptr);
306408
}
307409
py::gil_scoped_release release;
308-
auto res = calc_max_flow(g, src, dst, placement, shortest_path, with_edge_flows, node_ptr, edge_ptr);
410+
auto res = calc_max_flow(g, src, dst, placement, shortest_path, with_edge_flows, with_reachable, with_residuals, node_ptr, edge_ptr);
309411
py::gil_scoped_acquire acquire;
310412
return res;
311-
}, py::arg("g"), py::arg("src"), py::arg("dst"), py::kw_only(), py::arg("flow_placement") = FlowPlacement::Proportional, py::arg("shortest_path") = false, py::arg("with_edge_flows") = false, py::arg("node_mask") = py::none(), py::arg("edge_mask") = py::none());
413+
}, py::arg("g"), py::arg("src"), py::arg("dst"), py::kw_only(), py::arg("flow_placement") = FlowPlacement::Proportional, py::arg("shortest_path") = false, py::arg("with_edge_flows") = false, py::arg("with_reachable") = false, py::arg("with_residuals") = false, py::arg("node_mask") = py::none(), py::arg("edge_mask") = py::none());
312414

313415
m.def("batch_max_flow",
314416
[](const StrictMultiDiGraph& g, py::array pairs,
315417
py::object node_masks, py::object edge_masks,
316-
FlowPlacement placement, bool shortest_path, bool with_edge_flows) {
418+
FlowPlacement placement, bool shortest_path, bool with_edge_flows,
419+
bool with_reachable, bool with_residuals) {
317420
auto buf = pairs.request();
318421
if (buf.ndim != 2 || buf.shape[1] != 2) throw py::type_error("pairs must be shape [B,2]");
319422
// accept int32 for pairs
320423
if (buf.format != py::format_descriptor<std::int32_t>::format()) throw py::type_error("pairs dtype must be int32");
424+
const std::size_t batch_size = static_cast<std::size_t>(buf.shape[0]);
321425
std::vector<std::pair<NodeId,NodeId>> pp;
322426
auto* ptr = static_cast<const std::int32_t*>(buf.ptr);
323-
pp.reserve(static_cast<std::size_t>(buf.shape[0]));
427+
pp.reserve(batch_size);
324428
for (ssize_t i=0;i<buf.shape[0];++i) {
325429
if (ptr[2*i] < 0 || ptr[2*i+1] < 0) throw py::type_error("pairs must be non-negative indices");
326430
pp.emplace_back(static_cast<std::int32_t>(ptr[2*i]), static_cast<std::int32_t>(ptr[2*i+1]));
@@ -329,6 +433,7 @@ PYBIND11_MODULE(_netgraph_core, m) {
329433
auto parse_mask_list = [&](py::object list_obj, std::size_t expected_len, const char* what, std::vector<const bool*>& out_ptrs, std::vector<py::array>& keep){
330434
if (list_obj.is_none()) return;
331435
auto seq = py::cast<py::sequence>(list_obj);
436+
if (static_cast<std::size_t>(py::len(seq)) != batch_size) throw py::type_error(std::string(what) + " length must equal number of pairs");
332437
for (auto item: seq) {
333438
auto arr = py::cast<py::array>(item);
334439
if (!(arr.flags() & py::array::c_style)) throw py::type_error(std::string(what) + " arrays must be C-contiguous (np.ascontiguousarray)");
@@ -347,10 +452,10 @@ PYBIND11_MODULE(_netgraph_core, m) {
347452
parse_mask_list(node_masks, static_cast<std::size_t>(g.num_nodes()), "node_masks", node_ptrs, node_keep);
348453
parse_mask_list(edge_masks, static_cast<std::size_t>(g.num_edges()), "edge_masks", edge_ptrs, edge_keep);
349454
py::gil_scoped_release release;
350-
auto out = netgraph::core::batch_max_flow(g, pp, placement, shortest_path, with_edge_flows, node_ptrs, edge_ptrs);
455+
auto out = netgraph::core::batch_max_flow(g, pp, placement, shortest_path, with_edge_flows, with_reachable, with_residuals, node_ptrs, edge_ptrs);
351456
py::gil_scoped_acquire acquire;
352457
return out;
353-
}, py::arg("g"), py::arg("pairs"), py::kw_only(), py::arg("node_masks") = py::none(), py::arg("edge_masks") = py::none(), py::arg("flow_placement") = FlowPlacement::Proportional, py::arg("shortest_path") = false, py::arg("with_edge_flows") = false);
458+
}, py::arg("g"), py::arg("pairs"), py::kw_only(), py::arg("node_masks") = py::none(), py::arg("edge_masks") = py::none(), py::arg("flow_placement") = FlowPlacement::Proportional, py::arg("shortest_path") = false, py::arg("with_edge_flows") = false, py::arg("with_reachable") = false, py::arg("with_residuals") = false);
354459

355460
// FlowState bindings
356461
py::class_<FlowState>(m, "FlowState")
@@ -468,7 +573,8 @@ PYBIND11_MODULE(_netgraph_core, m) {
468573
.def("flow_count", &FlowPolicy::flow_count)
469574
.def("placed_demand", &FlowPolicy::placed_demand)
470575
.def("place_demand", [](FlowPolicy& p, FlowGraph& fg, std::int32_t src, std::int32_t dst, std::int32_t flowClass, double volume, py::object target_per_flow, py::object min_flow){ std::optional<double> tpf; if (!target_per_flow.is_none()) tpf = py::cast<double>(target_per_flow); std::optional<double> mfl; if (!min_flow.is_none()) mfl = py::cast<double>(min_flow); py::gil_scoped_release rel; auto pr = p.place_demand(fg, src, dst, flowClass, volume, tpf, mfl); py::gil_scoped_acquire acq; return py::make_tuple(pr.first, pr.second); }, py::arg("flow_graph"), py::arg("src"), py::arg("dst"), py::arg("flowClass"), py::arg("volume"), py::arg("target_per_flow") = py::none(), py::arg("min_flow") = py::none())
471-
.def("rebalance_demand", [](FlowPolicy& p, FlowGraph& fg, std::int32_t src, std::int32_t dst, std::int32_t flowClass, double target){ py::gil_scoped_release rel; auto pr = p.rebalance_demand(fg, src, dst, flowClass, target); py::gil_scoped_acquire acq; return py::make_tuple(pr.first, pr.second); })
576+
.def("rebalance_demand", [](FlowPolicy& p, FlowGraph& fg, std::int32_t src, std::int32_t dst, std::int32_t flowClass, double target){ py::gil_scoped_release rel; auto pr = p.rebalance_demand(fg, src, dst, flowClass, target); py::gil_scoped_acquire acq; return py::make_tuple(pr.first, pr.second); },
577+
py::arg("flow_graph"), py::arg("src"), py::arg("dst"), py::arg("flowClass"), py::arg("target"))
472578
.def("remove_demand", [](FlowPolicy& p, FlowGraph& fg){ py::gil_scoped_release rel; p.remove_demand(fg); py::gil_scoped_acquire acq; })
473579
.def_property_readonly("flows", [](const FlowPolicy& p){ py::dict out; for (auto const& kv : p.flows()) { const auto& idx = kv.first; const auto& f = kv.second; out[py::make_tuple(idx.src, idx.dst, idx.flowClass, idx.flowId)] = py::make_tuple(f.src, f.dst, f.cost, f.placed_flow); } return out; });
474580
}

0 commit comments

Comments
 (0)